Compare commits

..

22 Commits

Author SHA1 Message Date
42b0c7e7af fix: 修复未登录皮肤站时启动卡住且不再弹玩家选择
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
未登录皮肤站时启动游戏会弹窗要求选择/登录玩家。若点击“登录皮肤站”
跳转后不登录直接返回,再次启动不会重新弹窗,而是卡在“正在启动”。

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

修复:
- instance-player 新增哨兵错误 PlayerSelectionNavigatedAwayError。
- signInSkinSite 跳转登录前以该错误 reject 当前选择,使
  prepareInstancePlayer 的任务正常结束、清理 in-flight 记录,下次
  启动会重新弹出玩家选择。
- App.vue 的启动错误处理识别该哨兵:跳转登录是用户主动操作而非启动
  失败,静默中止启动,不弹错误提示。
2026-09-19 20:01:46 +08:00
405d980311 fix: 登录皮肤站后侧栏立即显示玩家选择器
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
登录 StarLight 皮肤站后,侧栏账户选择器(AccountsCard)不会列出
皮肤站玩家,必须先使用该玩家启动一次实例,玩家才会出现。

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

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

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

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

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

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

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

hosted_create 增加 game_dir_root 参数,前端 hostedCreate/install 同步透传。
2026-09-18 13:04:28 +08:00
51fb0c30f7 fix: 完善整合包同步与启动器交互
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-09-16 17:01:22 +08:00
bd09fed0ed fix: improve parallel pack downloads and suppress stale notifications
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Download hosted pack files concurrently, recover file transfers from proxy and content-encoding failures, and prevent old installation failures from reappearing in notifications.
2026-09-15 21:14:18 +08:00
bc904065c3 feat: complete hosted mod sync and launcher interface updates
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00
5d473ebfbc feat: 关于页接入凋灵胜利循环动画
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
将关于页顶部的旧展示替换为已确认的凋灵胜利场景,加入像素月亮、方块云、下界之星浮动旋转、附魔闪光、护盾闪烁和消散粒子。

素材随启动器本地打包,保持 3:1 构图;页面隐藏、切换或场景彩蛋覆盖时暂停渲染,退出后释放绘图资源,并兼容系统减少动态效果设置。

验证:前端构建、组件类型检查、动画暂停恢复及尺寸适配检查通过;本地启动器编译通过,并确认可执行文件已包含新场景素材和页面代码。
2026-09-15 00:11:59 +08:00
0b30fc96a0 fix: 复用皮肤站逻辑并稳定玩家头像
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-09-14 23:06:03 +08:00
2a1dfa3c42 feat: 添加获批整合包同步与 StarLight 实例模式 2026-09-14 22:59:16 +08:00
fbcaed528b feat: 完善 StarLight 皮肤站集成 2026-09-14 11:29:48 +08:00
9dd2b013af merge: 合并远端 main 分支 2026-09-13 22:16:12 +08:00
c50595b252 fix:正版登陆的文字显示问题 2026-09-13 22:16:07 +08:00
3471cd3da2 feat: 嵌入 StarLight 皮肤站并同步登录状态 2026-09-13 21:34:09 +08:00
1593ac7a7c refactor: 移除遥测并修复启动器流程 2026-09-13 20:32:57 +08:00
1fa56add19 feat: 添加星光矿域彩蛋 2026-09-13 20:32:57 +08:00
154 changed files with 12116 additions and 2596 deletions

View File

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

View File

@ -22,6 +22,10 @@ Item identifiers, readable names, and textures are sourced from the `minecraft-t
The recipe data and item artwork ultimately derive from Minecraft data generator output and Minecraft game resources. Minecraft and its original resources are Copyright Mojang Studios / Microsoft and are used only to identify compatible game content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft.
## About-page scene attribution
The about-page victory illustration is the user-approved generated artwork. Its bundled scene layers preserve the approved preview without regenerating the illustration at runtime. The supplied netherite sword artwork and the unmodified `clouds.png` and `moon_phases.png` textures from Minecraft: Java Edition 1.20.6 are Minecraft resources, Copyright Mojang Studios / Microsoft. These assets are stored in `src/assets/about-scene` and do not require a network connection.
## AI integration attribution
The AI provider settings information architecture, provider catalog, and provider descriptions are adapted from [LobeChat](https://github.com/lobehub/lobe-chat) at commit `a27dfaeda1ab499ac024a6eb0448917b216ba8a1`. The localized provider descriptions under `src/data/lobehub-provider-descriptions` are reproduced from that release. Bundled text-model metadata is synchronized separately from [LobeHub's model bank](https://github.com/lobehub/lobehub/tree/main/packages/model-bank/src/aiModels), with its exact source revision recorded in the backend catalog. LobeChat is distributed under the LobeHub Community License; a verbatim copy of that license is provided in [third-party/licenses/LobeHub-Community-License.txt](../../third-party/licenses/LobeHub-Community-License.txt).

View File

@ -3,7 +3,6 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin
import {
ChangeSkinIcon,
CompassIcon,
GlobeIcon,
DownloadIcon,
ExternalIcon,
FlaskConicalIcon,
@ -57,13 +56,13 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe
import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { hideAllPoppers } from 'floating-vue'
import { listen } from '@tauri-apps/api/event'
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type as getOsType } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { hideAllPoppers } from 'floating-vue'
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
@ -80,6 +79,7 @@ import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
@ -91,6 +91,8 @@ import NavRail from '@/components/ui/NavRail.vue'
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'
@ -111,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'
@ -730,10 +733,6 @@ const messages = defineMessages({
id: 'app.navigation.skin-selector',
defaultMessage: 'Skin selector',
},
starlightSkin: {
id: 'app.navigation.starlight-skin',
defaultMessage: '斯达莱特',
},
library: {
id: 'app.navigation.library',
defaultMessage: 'Library',
@ -1759,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,
@ -2156,6 +2159,8 @@ 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"
@ -2237,13 +2242,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<ChangeSkinIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.starlightSkin)"
data-onboarding-id="nav-starlight-skin"
to="/starlight-skin"
>
<GlobeIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.library)"
data-onboarding-id="nav-library"
@ -2579,6 +2577,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@install="handleContentInstallModpackInstall"
@cancel="handleContentInstallModpackInstallCancel"
/>
<TaggedModDownloadsModal />
<CurseForgeManualDownloadsModal
ref="contentInstallCurseForgeManualDownloadsModal"
@view-instance="handleContentInstallModpackDuplicateGoToInstance"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -0,0 +1,31 @@
# 凋灵局部清理素材
## 横梁删除修正2026-09-15
- 素材:[wither-beams-removed.png](./wither-beams-removed.png)2172 × 724。
- 方式:内置 imagegen局部对象删除输入为上一版清理图与用户标注的两处横梁。
- 仅使用左右横梁及其侧面的轮廓区域,补回露出的背景;其他位置仍使用之前的清晰素材。对应的横梁护盾面同时删除。
- 不重编码既有母图,不使用整张新生成图替换画面,不增加模糊或羽化。
最终提示词:
Use case: precise-object-edit.
Image 1 is the edit target: the full 3:1 Minecraft night scene. Image 2 is a location guide only, with red rectangles identifying two unwanted horizontal shoulder beams. Do NOT copy the UI, red marks, sword, star, clouds or alternate appearance from image 2.
Remove the TWO long thin horizontal brown/black bars running out sideways from the fallen Wither's upper chest to its left and right side skulls. In image 1 at normalized 2048x683 coordinates these are approximately left x9781140 y467527 and right x12801405 y487535. ERASE these bars completely, including their top face and side face; show the existing dark blue-gray ground/background visible through the new open gaps. The left and right skulls must stay in their exact positions on the ground, separated visually from the chest in these gaps. Do not invent substitute bars, short stumps, connecting rods, upright shoulder bones, debris, ribs or decorations in these two gaps.
Preserve all other pixels/geometry/composition: the three blocky skulls, central neck, broken rib cage, spine, player's exact pose and clothes, moon, landscape, frame edges, lighting and colors. In particular do not remove or redraw the existing curved/segmented chest ribs below the two bars. Precisely restore the small exposed background behind removed bars, with crisp square voxel texture. No blur, smoothing, feathering, depth of field, re-rendering of the whole image, glow or haze. Keep the original full 3:1 frame and exact object registration, at 2172x724 if possible. This is a tiny local object removal, not a restyle.
## 上一版肩部凸出结构清理
- 素材:[wither-shoulder-cleanup.png](./wither-shoulder-cleanup.png)2172 × 724。
- 生成方式:内置 imagegen 图像编辑工具。
- 编辑目标:`victory-clean.jpg`;辅助参考为用户标红的两处肩部异常结构及 Minecraft 凋灵截图。
- 实际使用:仅将两处肩部异常结构的清理结果合成到清晰母图。整张生成图不作为背景使用,三个头、肋骨、角色和环境保留原有素材。
- 动画在独立图层中绘制;护盾几何记录在 `wither-shield.ts`,坐标以 2048 × 682⅔ 的场景空间表示。
## 生成时使用的提示词
Use case: precise-object-edit. Asset type: a sharp replacement Wither plate for an existing Minecraft cinematic animated banner. Image 1 is the EDIT TARGET: retain its exact 3:1 panoramic framing and camera, keep every other object and the character unchanged. Image 2 is ONLY an annotation reference: its red rectangles identify two erroneous upright slabs/extra structures on the Wither's shoulders which must be removed; do not draw any red marks. Image 3 is ONLY a vanilla Minecraft Wither anatomy reference; use its simple three-headed skeletal structure, not its standing pose or blue shield.
Regenerate ONLY the fallen Wither in Image 1 cleanly, with crisp straight voxel edges, dark charcoal/black nether bone material and clean low-resolution Minecraft pixel textures. Exactly three cuboid skulls: near-left skull under the player's raised boot, central skull farther back facing upward, right skull at the right, preserving their existing centers, scale, screen orientation and silhouette as closely as possible. Retain the existing supine defeated pose, clean horizontal shoulder crossbar linking the three heads, a compact rib cage with separated squared ribs, and the central spine receding into the foreground. Remove the two upright paddle-like shoulder projections indicated by the red boxes, all extra heads, all spikes/horns, all gold mechanisms, odd rubble embedded in the body, noisy invented decorations and duplicate bones. The chest has one coherent small broken sternum opening centered exactly where the animated sword will enter (about 56% across and 72% down the full panorama). Preserve visible negative space between ribs, without random detached bits. This is a defeated non-human Minecraft boss, no blood or gore.
Keep the player's existing foot resting on the near-left skull at precisely the same height and location. Keep the character, face, volumetric brown hair, yellow clothing, armor, hands and boots absolutely unchanged. Keep the sky, moon, trees, terrain and all other regions unchanged and sharp. Do NOT add a sword or nether star: these already exist as separate animated layers. Do NOT paint any blue shielding, electricity, glow halos or particles onto the Wither; those will be separate precisely aligned animated layers. Match existing cool night illumination and subtle warm gold light on the top-facing bone planes. Reconstruct only the small pieces of ground/sky revealed by removing the two wrong shoulder slabs. No blur, no painterly smoothing, no depth-of-field blur, no sharpening halos. Output a single full panoramic 3:1 edited plate, same framing as Image 1, at the highest available resolution.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<title>幸运四叶草</title>
<path
d="M33 34C39.5 38.5 44.5 46 47 56"
stroke="var(--clover-edge, #16883F)"
stroke-width="5.5"
stroke-linecap="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(90 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(180 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(270 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<circle cx="32" cy="32" r="3.7" fill="var(--clover-edge, #16883F)" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { loading_listener } from '@/helpers/events'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
const { formatMessage } = useVIntl()
@ -15,6 +15,7 @@ const messages = defineMessages({
})
interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type']
loader_uuid: string
fraction: number | null
@ -22,6 +23,7 @@ interface LoadingEventPayload {
}
interface LaunchProgressItem {
waiting: boolean
key: string
message: string
fraction: number | null
@ -29,6 +31,7 @@ interface LaunchProgressItem {
// 启动相关(以及准备)阶段会发的 loading 类型;安装/下载也一并显示,便于用户看到进度
const LAUNCH_BAR_TYPES = new Set([
'hosted_pack_sync',
'minecraft_download',
'instance_update',
'zip_extract',
@ -54,6 +57,7 @@ function applyEvent(payload: LoadingEventPayload) {
if (!isVisible(payload.event)) return
activeMap.set(payload.loader_uuid, {
waiting: payload.total === 0,
key: payload.loader_uuid,
message: payload.message,
fraction: payload.fraction,
@ -64,13 +68,28 @@ function applyEvent(payload: LoadingEventPayload) {
const hasProgress = computed(() => progressItems.value.length > 0)
function percent(item: LaunchProgressItem): string {
if (item.fraction == null || !Number.isFinite(item.fraction)) return ''
if (item.waiting || item.fraction == null || !Number.isFinite(item.fraction)) return ''
return `${Math.round(Math.max(0, Math.min(1, item.fraction)) * 100)}%`
}
let initializing = true
const buffered: LoadingEventPayload[] = []
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
applyEvent(payload)
if (initializing) buffered.push(payload)
else applyEvent(payload)
})
const bars = await progress_bars_list().catch(() => ({}))
for (const bar of Object.values(bars)) {
applyEvent({
event: bar.bar_type,
loader_uuid: String(bar.loading_bar_uuid),
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
total: bar.total,
message: bar.message ?? '',
})
}
initializing = false
for (const payload of buffered) applyEvent(payload)
onUnmounted(() => {
unlistenLoading?.()
@ -96,12 +115,7 @@ onUnmounted(() => {
{{ percent(item) }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-surface-4">
<div
class="h-full rounded-full bg-brand transition-[width] duration-200"
:style="{ width: percent(item) || '100%' }"
/>
</div>
<ProgressBar :progress="item.fraction ?? 0" :waiting="item.waiting" full-width />
</li>
</ul>
</section>

View File

@ -1,35 +1,70 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import LuckyCloverIcon from '@/assets/icons/lucky-clover.svg'
import {
openSkinSiteLogin,
requestSkinSiteLuck,
skinSiteUser,
} from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
widgetTitle: { id: 'app.home.skin-site.title', defaultMessage: 'StarLight skin site' },
luckyIndex: { id: 'app.home.skin-site.lucky-index', defaultMessage: 'Lucky index' },
jump: { id: 'app.home.skin-site.jump', defaultMessage: 'Jump to StarLight skin site' },
widgetTitle: { id: 'app.home.luck.title', defaultMessage: 'Daily luck index' },
getLuck: { id: 'app.home.luck.get', defaultMessage: 'Get with one click' },
loginToGet: { id: 'app.home.luck.login-to-get', defaultMessage: 'Sign in to get it' },
loading: { id: 'app.home.luck.loading', defaultMessage: 'Asking the little sprite...' },
error: {
id: 'app.home.luck.error',
defaultMessage: 'The little sprite did not answer. Please try again later.',
},
outOf: { id: 'app.home.luck.out-of', defaultMessage: 'out of 100' },
})
const SKIN_PROFILE_URL = 'https://skin.starlight.cool/profile'
const SKIN_HOME_URL = 'https://skin.starlight.cool/'
const luck = ref<number | null>(null)
const loading = ref(false)
const failed = ref(false)
const waitingForLogin = ref(false)
const signedIn = computed(() => Boolean(skinSiteUser.value))
async function openProfile() {
async function fetchLuck() {
if (loading.value) return
loading.value = true
failed.value = false
try {
await openUrl(SKIN_PROFILE_URL)
} catch (error) {
handleError(error)
luck.value = await requestSkinSiteLuck()
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function openSkinSite() {
try {
await openUrl(SKIN_HOME_URL)
} catch (error) {
handleError(error)
function handlePrimaryAction() {
if (signedIn.value) {
void fetchLuck()
return
}
waitingForLogin.value = true
openSkinSiteLogin()
}
watch(
() => skinSiteUser.value?.uuid,
(uuid, previousUuid) => {
if (uuid === previousUuid) return
luck.value = null
failed.value = false
if (uuid && waitingForLogin.value) {
waitingForLogin.value = false
void fetchLuck()
} else if (!uuid) {
waitingForLogin.value = false
}
},
)
</script>
<template>
@ -37,32 +72,72 @@ async function openSkinSite() {
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<div class="flex items-center gap-2">
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<LuckyCloverIcon class="lucky-clover size-5 shrink-0" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">
{{ formatMessage(messages.widgetTitle) }}
</h2>
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between rounded-lg border border-[--brand-gradient-border] bg-transparent px-3 py-2 text-left transition-colors hover:bg-button-bg"
@click="openProfile"
>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.luckyIndex) }}
</span>
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
</button>
<div class="flex flex-col gap-3">
<div v-if="loading" class="luck-feedback" role="status" aria-live="polite">
<LuckyCloverIcon
class="lucky-clover lucky-clover-loading size-7 shrink-0"
aria-hidden="true"
/>
<span>{{ formatMessage(messages.loading) }}</span>
</div>
<div v-else-if="luck !== null" class="luck-feedback" aria-live="polite">
<strong class="text-3xl leading-none text-contrast">{{ luck }}</strong>
<span class="text-xs text-secondary">{{ formatMessage(messages.outOf) }}</span>
</div>
<p v-else-if="failed" class="m-0 text-sm leading-5 text-secondary" role="alert">
{{ formatMessage(messages.error) }}
</p>
<button
type="button"
class="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-[--brand-gradient] px-3 py-2 font-semibold text-[var(--color-contrast)] transition-opacity hover:opacity-90"
@click="openSkinSite"
>
<ExternalIcon class="size-4 shrink-0" aria-hidden="true" />
<span>{{ formatMessage(messages.jump) }}</span>
</button>
<ButtonStyled color="brand" class="w-full">
<button type="button" class="w-full" :disabled="loading" @click="handlePrimaryAction">
{{ formatMessage(signedIn ? messages.getLuck : messages.loginToGet) }}
</button>
</ButtonStyled>
</div>
</section>
</template>
<style scoped>
.lucky-clover {
--clover-leaf: color-mix(in srgb, #42c95a 88%, var(--color-brand));
--clover-edge: color-mix(in srgb, #16883f 88%, var(--color-brand));
}
.luck-feedback {
display: flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 600;
color: var(--color-secondary);
text-align: center;
}
.lucky-clover-loading {
transform-origin: center;
animation: clover-wait 850ms ease-in-out infinite;
}
@keyframes clover-wait {
0%,
100% {
transform: translateY(1px) rotate(-5deg) scale(0.96);
}
50% {
transform: translateY(-2px) rotate(6deg) scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.lucky-clover-loading {
animation: none;
}
}
</style>

View File

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

View File

@ -0,0 +1,208 @@
<template>
<InstanceModeSettings :instance-id="instanceId" :disabled="syncing" class="mb-5" />
<div v-if="modeQuery.data.value === 'local'" class="flex flex-col gap-3">
<h2 class="m-0">{{ formatMessage(messages.localTitle) }}</h2>
<p class="m-0 text-secondary">{{ formatMessage(messages.localPacks) }}</p>
<div class="flex flex-wrap gap-3">
<ButtonStyled
><button type="button" @click="router.push('/browse/modpack')">
{{ formatMessage(messages.browse) }}
</button></ButtonStyled
>
<ButtonStyled
><button type="button" @click="importLocal">
{{ formatMessage(messages.importLocal) }}
</button></ButtonStyled
>
</div>
</div>
<template v-if="modeQuery.data.value === 'starlight'">
<section class="flex flex-col gap-4" :aria-busy="loading || syncing">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0">{{ formatMessage(messages.title) }}</h2>
<p>{{ formatMessage(messages.description) }}</p>
</div>
<ButtonStyled
><button type="button" :disabled="loading || syncing" @click="load">
{{ formatMessage(messages.refresh) }}
</button></ButtonStyled
>
</div>
<p v-if="loading" role="status">{{ formatMessage(messages.loading) }}</p>
<p v-if="error" role="alert" class="text-red">{{ error }}</p>
<p v-if="binding">
{{
formatMessage(messages.bound, {
name: binding.publication.manifest.name,
version: binding.publication.manifest.version,
})
}}
</p>
<HostedPackProgress :instance-id="instanceId" :active="syncing" />
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
<p>
{{
formatMessage(messages.complete, {
version: result.version,
count: result.changedFiles,
size: (result.downloadedBytes / 1048576).toFixed(2),
})
}}
</p>
<details v-if="result.preservedFiles.length">
<summary>
{{ formatMessage(messages.preserved, { count: result.preservedFiles.length }) }}
</summary>
<ul>
<li v-for="path in result.preservedFiles" :key="path">{{ path }}</li>
</ul>
</details>
</div>
<article
v-if="pack"
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
>
<div>
<h3 class="m-0">{{ pack.manifest.name }}</h3>
<p class="mb-0">
{{ pack.manifest.version }} · Minecraft {{ pack.manifest.runtime.gameVersion }} ·
{{ pack.manifest.runtime.loader }}
</p>
</div>
<ButtonStyled color="brand"
><button type="button" :disabled="!ready || syncing || loading" @click="sync">
{{ formatMessage(binding ? messages.update : messages.install) }}
</button></ButtonStyled
>
</article>
</section>
</template>
</template>
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
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()
const showCreation = inject<(options: { skipSetupType: boolean; initialMode: 'import' }) => void>(
'showCreationModalWithOptions',
)
function importLocal() {
showCreation?.({ skipSetupType: true, initialMode: 'import' })
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
localTitle: { id: 'app.instance-mode.local-packs-title', defaultMessage: 'Local modpacks' },
localPacks: {
id: 'app.instance-mode.local-packs',
defaultMessage:
'Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized.',
},
browse: { id: 'app.instance-mode.browse', defaultMessage: 'Browse modpacks' },
importLocal: { id: 'app.instance-mode.import', defaultMessage: 'Import as a local instance' },
title: { id: 'app.hosted-packs.title', defaultMessage: 'Server-managed modpack' },
description: {
id: 'app.hosted-packs.description',
defaultMessage:
'The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required.',
},
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
bound: { id: 'app.hosted-packs.bound', defaultMessage: 'Installed: {name} · {version}' },
syncing: {
id: 'app.hosted-packs.syncing',
defaultMessage: 'Comparing files and synchronizing changes…',
},
complete: {
id: 'app.hosted-packs.complete',
defaultMessage: 'Synced to {version}. Changed {count} files; downloaded {size} MiB.',
},
preserved: {
id: 'app.hosted-packs.preserved',
defaultMessage: 'Preserved {count} locally modified or personal files',
},
empty: {
id: 'app.hosted-packs.empty',
defaultMessage: 'No modpacks have been approved for publication yet.',
},
update: { id: 'app.hosted-packs.update', defaultMessage: 'Synchronize now' },
install: { id: 'app.hosted-packs.install', defaultMessage: 'Retry automatic installation' },
})
const pack = ref<HostedPublication | null>(null)
const binding = ref<HostedBinding | null>(null)
const task = useHostedSync(() => props.instanceId)
const result = task.result
const manager = injectDownloadManager()
const loading = ref(false)
const syncing = computed(
() =>
task.busy.value ||
manager.legacyDownloads.value.some(
(bar) =>
bar.bar_type?.type === 'hosted_pack_sync' &&
bar.bar_type.instance_id === props.instanceId &&
!bar.bar_type.error,
),
)
const ready = ref(false)
const loadError = ref('')
const error = computed(() => loadError.value || task.error.value)
let generation = 0
async function load() {
if (modeQuery.data.value !== 'starlight') return
const current = ++generation
const instanceId = props.instanceId
loading.value = true
ready.value = false
loadError.value = ''
try {
const [official, installed] = await Promise.all([hostedDefault(), hostedBinding(instanceId)])
if (current !== generation) return
pack.value = official
binding.value = installed
ready.value = true
} catch (cause) {
if (current === generation) loadError.value = String(cause)
} finally {
if (current === generation) loading.value = false
}
}
async function sync() {
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
loadError.value = ''
const result = await task.sync()
if (result) markHostedCreationCompleted(props.instanceId)
}
watch(syncing, (busy, wasBusy) => {
if (!busy && wasBusy) void load()
})
watch(
() => [props.instanceId, modeQuery.data.value] as const,
() => {
generation++
loading.value = false
ready.value = false
loadError.value = ''
pack.value = null
binding.value = null
void load()
},
{ immediate: true },
)
</script>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { ProgressBar, defineMessages, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { injectDownloadManager } from '@/providers/download-manager'
const props = defineProps<{ instanceId?: string; active?: boolean }>()
const manager = injectDownloadManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
preparing: {
id: 'app.hosted-packs.progress.preparing',
defaultMessage: 'Preparing modpack installation…',
},
downloads: { id: 'app.hosted-packs.progress.downloads', defaultMessage: 'View downloads' },
})
const bar = computed(() =>
manager.legacyDownloads.value.find(
(item) =>
props.instanceId &&
item.bar_type?.type === 'hosted_pack_sync' &&
!item.bar_type.error &&
item.bar_type.instance_id === props.instanceId,
),
)
const waiting = computed(() => !bar.value?.total)
const current = computed(() =>
Math.max(0, Math.min(bar.value?.current ?? 0, bar.value?.total ?? 0)),
)
const message = computed(() => bar.value?.message || formatMessage(messages.preparing))
</script>
<template>
<div v-if="active || bar" class="flex min-w-0 flex-col gap-2" role="status">
<ProgressBar
:progress="current"
:max="bar?.total || 1"
:waiting="waiting"
:label="message"
label-class="min-w-0 break-all text-sm text-secondary"
:show-progress="!waiting"
full-width
>
<template #progress-icon />
</ProgressBar>
<RouterLink to="/downloads" class="self-start text-sm text-brand hover:underline">
{{ formatMessage(messages.downloads) }}
</RouterLink>
</div>
</template>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { useId } from 'vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
defineProps<{ modelValue?: InstanceMode; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: InstanceMode] }>()
const group = useId()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.instance-mode.title', defaultMessage: 'Instance type' },
starlight: { id: 'app.instance-mode.starlight', defaultMessage: 'StarLight instance' },
local: { id: 'app.instance-mode.local', defaultMessage: 'Local instance' },
starlightDescription: {
id: 'app.instance-mode.starlight-description',
defaultMessage:
'Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting.',
},
localDescription: {
id: 'app.instance-mode.local-description',
defaultMessage:
'Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds.',
},
})
</script>
<template>
<fieldset class="m-0 flex flex-col gap-3 border-0 p-0" :disabled="disabled">
<legend class="mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</legend>
<label
v-for="mode in ['starlight', 'local'] as const"
:key="mode"
class="flex items-start gap-3 rounded-xl border border-solid border-divider p-4"
:class="[
modelValue === mode ? 'bg-surface-3' : 'bg-surface-1',
disabled ? 'opacity-60' : 'cursor-pointer',
]"
>
<input
type="radio"
class="mt-1"
:name="group"
:value="mode"
:checked="modelValue === mode"
@change="emit('update:modelValue', mode)"
/>
<span class="flex flex-col gap-1"
><span class="font-semibold text-contrast">{{ formatMessage(messages[mode]) }}</span
><span class="text-sm text-secondary">{{
formatMessage(
mode === 'starlight' ? messages.starlightDescription : messages.localDescription,
)
}}</span></span
>
</label>
</fieldset>
</template>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, watch } from 'vue'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import { useInstanceMode, useSetInstanceMode } from '@/composables/useInstanceMode'
import type { InstanceMode } from '@/helpers/hosted-packs'
const props = defineProps<{ instanceId: string; disabled?: boolean }>()
const query = useInstanceMode(() => props.instanceId)
const save = useSetInstanceMode()
const { formatMessage } = useVIntl()
const messages = defineMessages({
saving: {
id: 'app.instance-mode.saving',
defaultMessage: 'Applying instance type and installing the server modpack when needed…',
},
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
})
const saving = computed(
() => save.isPending.value && save.variables.value?.instanceId === props.instanceId,
)
const selection = computed(() => (saving.value ? save.variables.value?.mode : query.data.value))
const failure = computed(
() =>
query.error.value ??
(save.variables.value?.instanceId === props.instanceId ? save.error.value : null),
)
function select(mode: InstanceMode) {
if (props.disabled || saving.value || !query.data.value || query.data.value === mode) return
save.mutate({ instanceId: props.instanceId, mode })
}
function retry() {
if (props.disabled || saving.value) return
if (query.error.value) void query.refetch()
else if (save.variables.value?.instanceId === props.instanceId) save.mutate(save.variables.value)
}
watch(
() => props.instanceId,
() => save.reset(),
)
</script>
<template>
<div class="flex flex-col gap-3">
<InstanceModeOptions
:model-value="selection"
:disabled="disabled || !query.data.value || query.isPending.value || saving"
@update:model-value="select"
/>
<p v-if="query.isPending.value || saving" class="m-0 text-secondary" role="status">
{{ formatMessage(saving ? messages.saving : messages.loading) }}
</p>
<div v-if="failure" role="alert" class="flex items-center gap-3">
<span>{{ String(failure) }}</span
><ButtonStyled
><button
type="button"
:disabled="disabled || query.isFetching.value || saving"
@click="retry"
>
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
</div>
</div>
</template>

View File

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

View File

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

View File

@ -0,0 +1,73 @@
<template>
<div
class="management-switch relative isolate mb-4 flex w-full max-w-[26rem] rounded-xl bg-surface-1 p-1"
:data-packs="selected === 'packs'"
role="group"
:aria-label="formatMessage(messages.label)"
>
<span
class="selection absolute inset-y-1 left-1 -z-10 w-[calc(50%_-_4px)] rounded-lg bg-surface-3 shadow-sm"
aria-hidden="true"
/>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'mods'"
@click="select('mods')"
>
{{ formatMessage(messages.mods) }}
</button>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'packs'"
@click="select('packs')"
>
{{ formatMessage(messages.packs) }}
</button>
</div>
<div v-show="selected === 'mods'"><slot /></div>
<div v-if="openedPacks" v-show="selected === 'packs'"><slot name="packs" /></div>
</template>
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
label: { id: 'app.hosted-packs.management', defaultMessage: 'Content management' },
mods: { id: 'app.hosted-packs.mods', defaultMessage: 'Manage mods' },
packs: { id: 'app.hosted-packs.packs', defaultMessage: 'Manage modpacks' },
})
const key = 'starlight:instance:mod-management-tab'
function initial(): 'mods' | 'packs' {
try {
return localStorage.getItem(key) === 'packs' ? 'packs' : 'mods'
} catch {
return 'mods'
}
}
const selected = ref(initial())
const openedPacks = ref(selected.value === 'packs')
function select(value: 'mods' | 'packs') {
selected.value = value
if (value === 'packs') openedPacks.value = true
try {
localStorage.setItem(key, value)
} catch {
/* Selection remains usable without storage. */
}
}
</script>
<style scoped>
.selection {
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-packs='true'] .selection {
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.selection {
transition: none;
}
}
</style>

View File

@ -0,0 +1,150 @@
<script setup lang="ts">
import { NewModal, ProgressBar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, onUnmounted, nextTick, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { loading_listener } from '@/helpers/events'
import { progress_bars_list } from '@/helpers/state'
import { onHostedPackAttemptStarted } from '@/helpers/hosted-packs'
import {
createTaggedModProgress,
type TaggedProgressEvent,
type TaggedProgressGroup,
} from '@/helpers/tagged-mod-progress'
const modal = ref<InstanceType<typeof NewModal>>()
const router = useRouter()
const { formatMessage } = useVIntl()
const state = createTaggedModProgress()
const groups = shallowRef<TaggedProgressGroup[]>([])
const messages = defineMessages({
title: { id: 'app.hosted-mods.title', defaultMessage: 'Updating server Mods' },
description: {
id: 'app.hosted-mods.description',
defaultMessage: 'The game starts after all required updates have been installed.',
},
complete: { id: 'app.hosted-mods.complete', defaultMessage: 'Downloaded' },
downloads: { id: 'app.hosted-mods.downloads', defaultMessage: 'View downloads' },
close: { id: 'app.hosted-mods.close', defaultMessage: 'Close' },
})
const size = (value: number) => `${(value / 1048576).toFixed(1)} MiB`
let disposed = false
let unlisten: (() => void) | undefined
let stopAttempts: (() => void) | undefined
function refresh() {
groups.value = [...state.groups.values()]
}
function consume(payload: TaggedProgressEvent) {
const opened = state.update(payload)
refresh()
if (opened)
void nextTick(() => {
const group = state.groups.get(payload.event?.batch_id ?? '')
if (!disposed && group && (!group.done || group.error)) modal.value?.show()
})
if (groups.value.length > 0 && groups.value.every((group) => group.done && !group.error))
modal.value?.hide()
}
onMounted(async () => {
stopAttempts = onHostedPackAttemptStarted((id) => {
state.reset(id)
refresh()
if (!groups.value.length) modal.value?.hide()
})
let initializing = true
const buffered: TaggedProgressEvent[] = []
const stop = await loading_listener((payload: TaggedProgressEvent) => {
if (initializing) buffered.push(payload)
else consume(payload)
})
if (disposed) {
stop()
return
}
unlisten = stop
const bars = await progress_bars_list().catch(() => ({}))
if (disposed) return
const ordered = Object.values(bars).sort(
(a, b) =>
Number(b.bar_type?.type === 'hosted_pack_sync') -
Number(a.bar_type?.type === 'hosted_pack_sync'),
)
for (const bar of ordered)
consume({
loader_uuid: String(bar.loading_bar_uuid),
event: bar.bar_type,
fraction: bar.total ? (bar.current ?? 0) / bar.total : 0,
total: bar.total,
message: bar.message ?? '',
})
initializing = false
for (const payload of buffered) consume(payload)
})
onUnmounted(() => {
disposed = true
unlisten?.()
stopAttempts?.()
})
function openDownloads() {
modal.value?.hide()
void router.push('/downloads')
}
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="min(38rem, calc(100vw - 2rem))"
scrollable
max-content-height="60vh"
:close-on-click-outside="false"
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
<section v-for="group in groups" :key="group.id" class="flex min-w-0 flex-col gap-3">
<h3 class="m-0 text-contrast">{{ group.name }}</h3>
<p v-if="group.error" role="alert" class="m-0 break-words text-red">{{ group.error }}</p>
<p v-else-if="group.message" class="m-0 break-words text-sm text-secondary">
{{ group.message }}
</p>
<div
v-for="file in group.files.values()"
:key="file.id"
class="flex min-w-0 flex-col gap-1"
>
<div class="flex flex-wrap justify-between gap-2 text-sm">
<span class="min-w-0 break-all text-contrast">{{ file.name }}</span>
<span class="text-secondary tabular-nums"
>{{ size(file.current) }} / {{ size(file.total) }}</span
>
</div>
<p v-if="file.error" class="m-0 break-words text-sm text-red">{{ file.error }}</p>
<ProgressBar
v-else
:progress="file.current"
:max="file.total || 1"
:waiting="!file.total"
:show-progress="!file.done"
:label="file.done ? formatMessage(messages.complete) : file.message"
full-width
><template #progress-icon
/></ProgressBar>
</div>
</section>
</div>
<template #actions>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled>
<button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal?.hide()">
{{ formatMessage(messages.close) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

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

View File

@ -1,318 +1,44 @@
<template>
<canvas id="about_scene" class="size-full" />
<canvas ref="canvas" class="about-scene" aria-hidden="true" />
</template>
<script setup lang="ts">
import * as THREE from 'three'
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
import { onActivated, onDeactivated, onMounted, onScopeDispose, useTemplateRef, watch } from 'vue'
import { useTheming } from '@/store/theme'
import { createWitherVictoryScene } from './about-scene/wither-victory'
const themeStore = useTheming()
function isDarkMode() {
if (themeStore.selectedTheme == 'system') {
return matchMedia('(prefers-color-scheme: dark)').matches
}
return ['dark', 'oled'].includes(themeStore.selectedTheme)
const props = defineProps<{ paused?: boolean }>()
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
let scene: ReturnType<typeof createWitherVictoryScene> | undefined
let active = true
function updatePlayback() {
scene?.setPaused(!active || Boolean(props.paused))
}
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
const tipGeometry = new THREE.SphereGeometry(2)
const tipMaterial = new THREE.MeshBasicMaterial({ color })
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
tipMesh.position.copy(position)
return tipMesh
}
function createWaterMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
seed: { value: Math.random() * 83 + 17 },
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
},
transparent: true,
vertexShader: `#define WATER_VERT
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `#define WATER_FRAG
uniform float time;
uniform float seed;
uniform vec3 color;
varying vec2 vUv;
vec2 randomGradient(vec2 p) {
float n = sin(dot(p, vec2(127.1, 311.7)));
float angle = fract(n * 43758.5453123) * 6.28318530718 * seed;
return vec2(cos(angle), sin(angle));
}
float perlinNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
vec2 g1 = randomGradient(i);
vec2 g2 = randomGradient(i + vec2(1.0, 0.0));
vec2 g3 = randomGradient(i + vec2(0.0, 1.0));
vec2 g4 = randomGradient(i + vec2(1.0, 1.0));
vec2 d1 = f;
vec2 d2 = f - vec2(1.0, 0.0);
vec2 d3 = f - vec2(0.0, 1.0);
vec2 d4 = f - vec2(1.0, 1.0);
float v1 = dot(g1, d1);
float v2 = dot(g2, d2);
float v3 = dot(g3, d3);
float v4 = dot(g4, d4);
return mix(mix(v1, v2, u.x), mix(v3, v4, u.x), u.y);
}
void main() {
float height = 0.0;
height += perlinNoise(vec2(vUv.x * 10.0, time * 0.8)) * 0.3;
height += perlinNoise(vec2(vUv.x * 5.0, time * 0.4)) * 0.35;
height += perlinNoise(vec2(vUv.x * 2.5, time * 0.2)) * 0.15;
height += perlinNoise(vec2(vUv.x * 2.0, time * 0.2)) * 0.2;
height = clamp(height, -1.0, 1.0);
height = height * 0.8 + 0.6;
float thickness = 0.008;
if(vUv.y < height - thickness) {
float scalar = 1.0 - height + vUv.y;
scalar = scalar * scalar * scalar * 0.6;
gl_FragColor = vec4(color, scalar);
} else if(vUv.y > height + thickness) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
} else {
gl_FragColor = vec4(color, 1.0);
}
}`,
})
}
function createWater(material: THREE.ShaderMaterial, position: THREE.Vector3) {
const geometry = new THREE.PlaneGeometry(120, 16)
const waterMesh = new THREE.Mesh(geometry, material)
waterMesh.position.copy(position)
return waterMesh
}
function createCircleMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
uniforms: {
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
},
transparent: true,
vertexShader: `#define CIRCLE_VERT
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `#define CIRCLE_FRAG
varying vec2 vUv;
uniform vec3 color;
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
float t = (v - inMin) / (inMax - inMin);
return outMin + (outMax - outMin) * t;
}
void main() {
float dis = distance(vUv, vec2(0.5));
float thickness = 0.05;
gl_FragColor = vec4(0.0);
if(dis <= 0.35 && dis >= 0.35 - thickness) {
gl_FragColor = vec4(color, 0.8);
} else {
// emissive
float scalar = 0.0;
if(dis >= 0.35) {
scalar = clamp(0.5 - dis, 0.0, 0.15);
scalar = remap(scalar, 0.0, 0.15, 0.0, 1.0);
} else {
scalar = clamp(0.35 - dis, 0.0, 0.5);
scalar = remap(scalar, 0.0, 0.35, 1.0, 0.0);
}
scalar = clamp(scalar * scalar * scalar, 0.0, 1.0);
gl_FragColor = vec4(color, scalar);
}
}`,
})
}
function createCircle(material: THREE.ShaderMaterial, position: THREE.Vector3) {
const geometry = new THREE.PlaneGeometry(0.6, 0.6)
const mesh = new THREE.Mesh(geometry, material)
mesh.position.copy(position)
return mesh
}
function main() {
const canvas = document.querySelector<HTMLCanvasElement>('#about_scene')
if (!canvas) return console.error('No canvas')
let isUpdating = true
const canvasSize = new THREE.Vector2(
canvas.getBoundingClientRect().width,
canvas.getBoundingClientRect().height,
)
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
canvas,
})
renderer.setPixelRatio(devicePixelRatio)
renderer.setSize(canvasSize.x, canvasSize.y)
const deltaClock = new THREE.Clock()
const elapseClock = new THREE.Clock()
deltaClock.start()
elapseClock.start()
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(30, canvasSize.x / canvasSize.y, 1, 3000)
camera.fov *= 0.7
camera.position.set(-10, 5, 30)
camera.lookAt(0, 0, 0)
const ambientLight = new THREE.AmbientLight(0xffffff)
scene.add(ambientLight)
const dirLight = new THREE.DirectionalLight(0xffffff, 4.0)
dirLight.position.set(-30, 30, 28)
scene.add(dirLight)
scene.add(createTip(dirLight.position, 0xffff00))
scene.add(createTip(camera.position))
const accentColor =
getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#4444ff'
const waterMaterial = createWaterMaterial()
waterMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
isDarkMode() ? 0.6 : 2.4,
)
// .multiplyScalar(0.6)
// .multiplyScalar(2.4)
scene.add(createWater(waterMaterial, new THREE.Vector3(0, -6.5, 4)))
scene.add(createWater(waterMaterial, new THREE.Vector3(2, -8, -10)))
scene.add(createWater(waterMaterial, new THREE.Vector3(16, -8, -26)))
// 静止的下界之星(替换原动态美西螈)
const starTexture = new THREE.TextureLoader().load('/models/netherstar.png')
starTexture.colorSpace = THREE.SRGBColorSpace
const starMaterial = new THREE.SpriteMaterial({ map: starTexture, transparent: true })
const starSprite = new THREE.Sprite(starMaterial)
starSprite.scale.set(8, 8, 1)
starSprite.position.set(0, -2.5, 0)
scene.add(starSprite)
let updateGLTF = (_deltaTime: number, _elapsedTime: number) => {}
const circleMaterial = createCircleMaterial()
circleMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
isDarkMode() ? 1.2 : 3,
)
// .multiplyScalar(1.2)
// .multiplyScalar(3)
let circleMeshList: THREE.Mesh[] = []
let nextCircleCreateTime = 0.0
function updateCircle(deltaTime: number, elapsedTime: number) {
circleMeshList = circleMeshList.filter((m) => {
m.position.y += deltaTime * 2.0
if (m.position.y >= 32) {
scene.remove(m)
return false
}
return true
})
if (elapsedTime >= nextCircleCreateTime) {
nextCircleCreateTime = elapsedTime + Math.random() * 0.8
const circle = createCircle(
circleMaterial,
new THREE.Vector3(Math.random() * 64 - 32 - 12, -20, Math.random() * 6 + 1),
)
scene.add(circle)
circleMeshList.push(circle)
}
}
function animate(_time: number) {
if (isUpdating === false) return
requestAnimationFrame(animate)
const deltaTime = deltaClock.getDelta()
const elapsedTime = elapseClock.getElapsedTime()
updateGLTF(deltaTime, elapsedTime)
waterMaterial.uniforms.time.value = elapsedTime
updateCircle(deltaTime, elapsedTime)
renderer.render(scene, camera)
}
animate(Date.now())
const originCameraPosition = camera.position.clone()
function onMouseMove(event: MouseEvent) {
const mouseXOffsetRatio = ((event.clientX - innerWidth / 2) / innerWidth) * 2
const mouseYOffsetRatio = ((event.clientY - innerHeight / 2) / innerHeight) * 2
const newPosition = new THREE.Vector3(
originCameraPosition.x + mouseXOffsetRatio,
originCameraPosition.y + mouseYOffsetRatio * 0.5,
originCameraPosition.z,
)
camera.position.copy(newPosition)
}
function updateSize() {
if (!isUpdating) return
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const w = rect.width
const h = rect.height
if (w > 0 && h > 0) {
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
}
}
const resizeObserver = new ResizeObserver(updateSize)
resizeObserver.observe(canvas)
addEventListener('mousemove', onMouseMove)
onScopeDispose(() => {
isUpdating = false
removeEventListener('mousemove', onMouseMove)
resizeObserver.disconnect()
deltaClock.stop()
elapseClock.stop()
renderer.dispose()
})
}
onMounted(main)
onMounted(() => {
if (!canvas.value) return
scene = createWitherVictoryScene(canvas.value)
updatePlayback()
scene.ready.catch((error: unknown) => console.warn('Unable to load about-page scene', error))
})
watch(() => props.paused, updatePlayback)
onActivated(() => {
active = true
updatePlayback()
})
onDeactivated(() => {
active = false
updatePlayback()
})
onScopeDispose(() => scene?.dispose())
</script>
<style>
#about_scene {
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
#00000000 40%
);
<style scoped>
.about-scene {
display: block;
width: 100%;
height: 100%;
background: #0c1729;
}
</style>

View File

@ -12,6 +12,75 @@
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteSyncError) }}
</p>
<div
v-if="skinSiteUser"
class="mt-2 overflow-hidden rounded-xl border border-solid border-surface-5 bg-button-bg"
>
<div class="flex items-center justify-between gap-3 px-3 py-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.skinSitePlayers, { count: skinSitePlayers.length }) }}
</span>
<button
v-if="skinSitePlayersStatus === 'error'"
type="button"
class="button-base flex cursor-pointer items-center gap-1 border-0 bg-transparent p-1 text-xs text-secondary hover:text-brand"
@click="refreshSkinSitePlayers()"
>
<RefreshCwIcon class="h-4 w-4" />
{{ formatMessage(messages.retrySkinSitePlayers) }}
</button>
<SpinnerIcon
v-else-if="skinSitePlayersStatus === 'checking'"
class="h-4 w-4 animate-spin text-secondary"
/>
</div>
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
<button
v-for="player in skinSitePlayers"
:key="player.uuid"
type="button"
class="flex w-full cursor-pointer items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-button-hover"
:class="selectedSkinSitePlayerId === player.uuid ? 'bg-button-hover' : 'bg-transparent'"
:aria-pressed="selectedSkinSitePlayerId === player.uuid"
@click="setSkinSitePlayer(player.uuid)"
>
<Avatar
v-if="player.skinState === 'ready' && player.headDataUrl"
:src="player.headDataUrl"
size="32px"
pixelated
:unframed-natural-width="36"
/>
<div
v-else
class="h-8 w-8 shrink-0 rounded-sm border-2 border-dashed border-secondary/45 bg-surface-5/50 text-secondary"
style="
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-position:
6px 8px,
22px 8px,
8px 20px;
background-repeat: no-repeat;
background-size:
4px 4px,
4px 4px,
16px 2px;
"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-sm text-primary">{{ player.name }}</span>
</button>
</div>
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.noSkinSitePlayers) }}
</p>
<p v-else-if="skinSitePlayersStatus === 'error'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.skinSitePlayersError) }}
</p>
</div>
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<LogInIcon />
@ -23,7 +92,15 @@
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
>
<span class="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span>
<span class="text-sm text-secondary">{{ formatMessage(messages.offlineModeDescription) }}</span>
<span class="text-sm text-secondary">
{{
formatMessage(
browserOffline
? messages.offlineModeNoInternetDescription
: messages.offlineModeServerUnavailableDescription,
)
}}
</span>
<ButtonStyled>
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
@ -44,12 +121,6 @@
{{ formatMessage(messages.signInToStarlight) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!offline && skinSiteUser">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addSkinGameAccount) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!offline">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
@ -161,12 +232,6 @@
</div>
</template>
<div class="flex flex-col gap-2 px-2 pt-2">
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addSkinGameAccount) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
@ -177,99 +242,6 @@
</div>
</Accordion>
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
<ModalWrapper ref="yggdrasilAccountModal" :header="formatMessage(messages.thirdPartyModalTitle)">
<div class="flex min-w-[24rem] flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.thirdPartyModalDescription) }}</p>
<div v-if="savedYggdrasilLogins.length > 0" class="flex flex-col gap-2">
<span class="font-semibold">{{ formatMessage(messages.savedLogins) }}</span>
<div
v-for="savedLogin in savedYggdrasilLogins"
:key="`${savedLogin.api_root}:${savedLogin.login}`"
class="flex items-center gap-1 rounded-xl bg-surface-3 p-1"
>
<button
class="flex min-w-0 flex-grow flex-col items-start border-0 bg-transparent px-3 py-2 text-left cursor-pointer"
:disabled="loginDisabled"
@click="selectSavedYggdrasilLogin(savedLogin)"
>
<span class="w-full truncate font-semibold text-primary">{{ savedLogin.login }}</span>
<span class="w-full truncate text-xs text-secondary">{{ savedLogin.api_root }}</span>
</button>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button
v-tooltip="formatMessage(messages.removeSavedLogin)"
:disabled="loginDisabled"
@click="removeSavedYggdrasilLogin(savedLogin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.apiRootLabel) }}
<StyledInput
v-model="yggdrasilApiRoot"
:disabled="true"
readonly
:placeholder="formatMessage(messages.apiRootPlaceholder)"
inputmode="url"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.accountLabel) }}
<StyledInput
v-model="yggdrasilLogin"
:disabled="loginDisabled"
:placeholder="formatMessage(messages.accountPlaceholder)"
autocomplete="username"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.passwordLabel) }}
<StyledInput
v-model="yggdrasilPassword"
type="password"
:disabled="loginDisabled"
autocomplete="current-password"
@keyup.enter="addYggdrasilAccount()"
/>
</label>
<Checkbox
v-model="rememberYggdrasilPassword"
:disabled="loginDisabled"
:label="formatMessage(messages.rememberPassword)"
/>
<div class="input-group push-right">
<ButtonStyled>
<button :disabled="loginDisabled" @click="yggdrasilAccountModal?.hide()">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loginDisabled || !yggdrasilFormValid" @click="addYggdrasilAccount()">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<LogInIcon v-else />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="yggdrasilProfileModal" :header="formatMessage(messages.selectProfileTitle)">
<div class="flex min-w-[22rem] flex-col gap-2">
<p class="m-0 mb-2 text-secondary">{{ formatMessage(messages.selectProfileDescription) }}</p>
<ButtonStyled v-for="profile in pendingYggdrasilProfiles" :key="profile.id" class="w-full">
<button :disabled="loginDisabled" @click="selectYggdrasilProfile(profile.id)">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<RadioButtonIcon v-else />
{{ profile.name }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
@ -287,11 +259,8 @@ import {
Accordion,
Avatar,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@ -301,26 +270,28 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png'
import steveSkinTexture from '@/assets/skins/steve.png?inline'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { openSkinSiteLogin, skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
selectedSkinSitePlayerId,
selectSkinSitePlayer,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { compareMinecraftAccounts } from '@/helpers/accounts'
import {
begin_yggdrasil_login,
delete_yggdrasil_password,
finish_yggdrasil_login,
get_default_user,
get_yggdrasil_password,
list_yggdrasil_saved_logins,
login as loginToMinecraft,
remove_user,
set_default_user,
set_yggdrasil_password,
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'
@ -329,7 +300,7 @@ import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { offline, refreshBrowserOffline } = useNetworkStatus()
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
@ -384,20 +355,6 @@ type MinecraftCredential = {
}
}
type YggdrasilProfile = {
id: string
name: string
}
type SavedYggdrasilLogin = {
api_root: string
login: string
}
type YggdrasilLoginResult =
| { status: 'complete'; credentials: MinecraftCredential }
| { status: 'select_profile'; flow_id: string; profiles: YggdrasilProfile[] }
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
const accounts: Ref<MinecraftCredential[]> = ref([])
@ -412,34 +369,13 @@ let refreshGeneration = 0
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
let defaultUserUpdateQueue = Promise.resolve()
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
const yggdrasilLogin = ref('')
const yggdrasilPassword = ref('')
const rememberYggdrasilPassword = ref(true)
const savedYggdrasilLogins = ref<SavedYggdrasilLogin[]>([])
const pendingYggdrasilFlowId = ref<string | undefined>()
const pendingYggdrasilProfiles = ref<YggdrasilProfile[]>([])
const yggdrasilFormValid = computed(
() =>
yggdrasilApiRoot.value.trim().length > 0 &&
yggdrasilLogin.value.trim().length > 0 &&
yggdrasilPassword.value.length > 0,
)
function createSkinHeadDataUrl(textureUrl: string) {
const escapedTextureUrl = textureUrl
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges"><image href="${escapedTextureUrl}" x="-8" y="-8" width="64" height="64" style="image-rendering:pixelated"/><image href="${escapedTextureUrl}" x="-40" y="-8" width="64" height="64" style="image-rendering:pixelated"/></svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
async function refreshSkinSitePlayers() {
await requestSkinSitePlayers().catch(() => {})
}
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
function setSkinSitePlayer(playerId: string) {
selectSkinSitePlayer(playerId)
}
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
@ -684,6 +620,7 @@ function persistDefaultUser(userId: string) {
async function setAccount(account: MinecraftCredential) {
const userId = account.profile.id
refreshGeneration += 1
selectSkinSitePlayer(null)
defaultUser.value = userId
equippedSkin.value = null
@ -693,6 +630,27 @@ async function setAccount(account: MinecraftCredential) {
if (defaultUser.value === userId) notifyAccountChange()
}
watch(
[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 },
)
async function login() {
if (offline.value) return
loginDisabled.value = true
@ -721,185 +679,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
}
}
async function showYggdrasilAccountModal() {
yggdrasilApiRoot.value = STARLIGHT_YGGDRASIL_API_ROOT
yggdrasilLogin.value = ''
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = true
pendingYggdrasilFlowId.value = undefined
pendingYggdrasilProfiles.value = []
await loadSavedYggdrasilLogins()
yggdrasilAccountModal.value?.show()
}
async function loadSavedYggdrasilLogins() {
const storedLogins = await list_yggdrasil_saved_logins().catch(handleError)
const savedLogins: SavedYggdrasilLogin[] = Array.isArray(storedLogins)
? [...storedLogins].filter(
(savedLogin) => savedLogin.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT,
)
: []
const savedLoginKeys = new Set(
savedLogins.map((savedLogin) => `${savedLogin.api_root}\n${savedLogin.login}`),
)
for (const account of accounts.value) {
if (
!account.yggdrasil ||
account.yggdrasil.api_root.replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT
)
continue
const savedLogin = {
api_root: account.yggdrasil.api_root,
login: account.yggdrasil.login,
}
const key = `${savedLogin.api_root}\n${savedLogin.login}`
if (savedLoginKeys.has(key)) continue
try {
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
if (!password) continue
await set_yggdrasil_password(savedLogin.api_root, savedLogin.login, password)
savedLogins.push(savedLogin)
savedLoginKeys.add(key)
} catch {
continue
}
}
savedYggdrasilLogins.value = savedLogins.sort((left, right) =>
left.login.localeCompare(right.login),
)
}
async function selectSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
if (loginDisabled.value) return
loginDisabled.value = true
try {
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
if (!password) {
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
await loadSavedYggdrasilLogins()
return
}
yggdrasilApiRoot.value = savedLogin.api_root
yggdrasilLogin.value = savedLogin.login
yggdrasilPassword.value = password
rememberYggdrasilPassword.value = true
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function removeSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
if (loginDisabled.value) return
loginDisabled.value = true
try {
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
savedYggdrasilLogins.value = savedYggdrasilLogins.value.filter(
(entry) => entry.api_root !== savedLogin.api_root || entry.login !== savedLogin.login,
)
if (
yggdrasilApiRoot.value === savedLogin.api_root &&
yggdrasilLogin.value === savedLogin.login
) {
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = false
}
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function loadRememberedYggdrasilPassword() {
if (
!rememberYggdrasilPassword.value ||
!yggdrasilApiRoot.value.trim() ||
!yggdrasilLogin.value.trim() ||
yggdrasilPassword.value
)
return
try {
const password = await get_yggdrasil_password(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
)
if (password) yggdrasilPassword.value = password
} catch {
return
}
}
async function persistYggdrasilPasswordPreference() {
try {
if (rememberYggdrasilPassword.value) {
await set_yggdrasil_password(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
yggdrasilPassword.value,
)
} else {
await delete_yggdrasil_password(yggdrasilApiRoot.value.trim(), yggdrasilLogin.value.trim())
}
} catch (error) {
handleError(error as Error)
}
}
async function addYggdrasilAccount() {
if (!yggdrasilFormValid.value || loginDisabled.value) return
if (yggdrasilApiRoot.value.trim().replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT) return
loginDisabled.value = true
try {
const result = (await begin_yggdrasil_login(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
yggdrasilPassword.value,
)) as YggdrasilLoginResult
if (result.status === 'complete') {
await persistYggdrasilPasswordPreference()
yggdrasilAccountModal.value?.hide()
await setAccount(result.credentials)
} else {
pendingYggdrasilFlowId.value = result.flow_id
pendingYggdrasilProfiles.value = result.profiles
yggdrasilAccountModal.value?.hide()
yggdrasilProfileModal.value?.show()
}
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function selectYggdrasilProfile(profileId: string) {
if (!pendingYggdrasilFlowId.value || loginDisabled.value) return
loginDisabled.value = true
try {
const account = (await finish_yggdrasil_login(
pendingYggdrasilFlowId.value,
profileId,
)) as MinecraftCredential
await persistYggdrasilPasswordPreference()
yggdrasilProfileModal.value?.hide()
await setAccount(account)
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function logout(account: MinecraftCredential) {
await remove_user(account.profile.id).catch(handleError)
await refreshValues()
@ -946,18 +725,35 @@ const messages = defineMessages({
id: 'minecraft-account.skin-site.sync-error',
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
},
addSkinGameAccount: {
id: 'minecraft-account.add-skin-game-account',
defaultMessage: 'Add skin site game account',
skinSitePlayers: {
id: 'minecraft-account.skin-site.players',
defaultMessage: 'Skin site players ({count})',
},
retrySkinSitePlayers: {
id: 'minecraft-account.skin-site.players.retry',
defaultMessage: 'Retry',
},
noSkinSitePlayers: {
id: 'minecraft-account.skin-site.players.empty',
defaultMessage: 'No player profiles are attached to this skin site account.',
},
skinSitePlayersError: {
id: 'minecraft-account.skin-site.players.error',
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
},
offlineMode: {
id: 'minecraft-account.offline-mode',
defaultMessage: 'Offline mode',
},
offlineModeDescription: {
id: 'minecraft-account.offline-mode.description',
offlineModeNoInternetDescription: {
id: 'minecraft-account.offline-mode.description.no-internet',
defaultMessage:
'Only offline accounts are available. You can launch fully downloaded instances.',
'It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below.',
},
offlineModeServerUnavailableDescription: {
id: 'minecraft-account.offline-mode.description.server-unavailable',
defaultMessage:
"Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status.",
},
refreshNetworkStatus: {
id: 'minecraft-account.offline-mode.refresh',
@ -983,58 +779,6 @@ const messages = defineMessages({
id: 'minecraft-account.third-party-badge',
defaultMessage: 'StarLight skin',
},
thirdPartyModalTitle: {
id: 'minecraft-account.third-party-modal.title',
defaultMessage: 'Sign in with StarLight skin',
},
thirdPartyModalDescription: {
id: 'minecraft-account.third-party-modal.description',
defaultMessage: 'Sign in with your StarLight skin account.',
},
apiRootLabel: {
id: 'minecraft-account.third-party-modal.api-root',
defaultMessage: 'StarLight skin API address',
},
apiRootPlaceholder: {
id: 'minecraft-account.third-party-modal.api-root-placeholder',
defaultMessage: 'https://skin.starlight.cool/yggdrasil',
},
accountLabel: {
id: 'minecraft-account.third-party-modal.account',
defaultMessage: 'Account or email',
},
accountPlaceholder: {
id: 'minecraft-account.third-party-modal.account-placeholder',
defaultMessage: 'Enter your account or email',
},
passwordLabel: {
id: 'minecraft-account.third-party-modal.password',
defaultMessage: 'Password',
},
rememberPassword: {
id: 'minecraft-account.third-party-modal.remember-password',
defaultMessage: 'Save this login on this device',
},
savedLogins: {
id: 'minecraft-account.third-party-modal.saved-logins',
defaultMessage: 'Saved logins',
},
removeSavedLogin: {
id: 'minecraft-account.third-party-modal.remove-saved-login',
defaultMessage: 'Remove saved login',
},
signInButton: {
id: 'minecraft-account.third-party-modal.sign-in',
defaultMessage: 'Sign in',
},
selectProfileTitle: {
id: 'minecraft-account.third-party-profile.title',
defaultMessage: 'Select a profile',
},
selectProfileDescription: {
id: 'minecraft-account.third-party-profile.description',
defaultMessage: 'Choose the Minecraft profile to use with this account.',
},
offlineAccount: {
id: 'minecraft-account.offline-account',
defaultMessage: 'Offline Minecraft account',

View File

@ -312,6 +312,7 @@ interface RunningProcess {
}
interface LoadingEventPayload {
total?: number | null
event: LoadingBar['bar_type']
loader_uuid: string
fraction: number | null
@ -435,7 +436,6 @@ const unlistenProcess = await process_listener(async () => {
const stop = async (process: RunningProcess) => {
try {
await killProcess(process.uuid).catch(handleError)
} catch (e) {
console.error(e)
}
@ -629,6 +629,7 @@ function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
return (
loadingBar.bar_type?.type !== 'launcher_update' &&
[
'hosted_pack_sync',
'java_download',
'pack_file_download',
'pack_download',
@ -655,8 +656,8 @@ function applyLoadingEvent(payload: LoadingEventPayload): boolean {
const loadingBar = formatLoadingBars({
loading_bar_uuid: payload.loader_uuid,
message: payload.message,
current: payload.fraction,
total: 1,
current: payload.fraction * (payload.total ?? 1),
total: payload.total ?? 1,
bar_type: payload.event,
})
if (!isVisibleLoadingBar(loadingBar)) return false

View File

@ -1,13 +1,7 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import {
receiveSkinSiteSession,
resetSkinSiteSession,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
} from '@/composables/skin-site-session'
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
@ -17,42 +11,12 @@ const messages = defineMessages({
},
})
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
function connect() {
frame.value?.contentWindow?.postMessage(
{ type: 'starlight-skin-session-connect' },
SKIN_SITE_ORIGIN,
)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteSession(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
resetSkinSiteSession()
})
</script>
<template>
<iframe
ref="frame"
:src="skinSiteFrameUrl"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
@load="connect"
/>
</template>

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { clearHostedSession } from '@/helpers/hosted-packs'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
watch(
[skinSiteStatus, () => skinSiteUser.value?.uuid],
() => {
void clearHostedSession().catch(() => {})
},
{ flush: 'sync' },
)
function connect() {
resetSkinSiteSession()
lastMessage = 0
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
void clearHostedSession().catch(() => {})
})
</script>
<template>
<iframe
ref="frame"
:src="`${SKIN_SITE_ORIGIN}/`"
title="StarLight Skin Site session"
aria-hidden="true"
tabindex="-1"
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
@load="connect"
/>
</template>

View File

@ -0,0 +1,274 @@
type Point = [number, number]
type Quad = [Point, Point, Point, Point]
type ShieldSurface = { plane: Quad; outline?: Point[]; shade?: number }
// Coordinates follow the sharp master with its localized shoulder cleanup,
// normalized to the scene's 2048 × 682⅔ plate.
// Each visible bone has its own face: the air between ribs must never receive armor.
export const witherShieldSurfaces: ShieldSurface[] = [
// Only the upper chest remains; the removed side beams have no shield surfaces.
{
plane: [
[1194, 479],
[1294, 489],
[1283, 516],
[1183, 505],
],
},
// Far central skull: face, jaw wall and right side share the same edges.
{
plane: [
[1162, 355],
[1319, 352],
[1267, 446],
[1108, 442],
],
},
{
plane: [
[1108, 442],
[1267, 446],
[1280, 489],
[1119, 470],
],
shade: 0.68,
},
{
plane: [
[1319, 352],
[1356, 443],
[1319, 492],
[1267, 446],
],
shade: 0.6,
},
// Near-left skull, underneath the boot.
{
plane: [
[794, 477],
[968, 454],
[1003, 549],
[833, 602],
],
},
{
plane: [
[794, 477],
[833, 602],
[821, 625],
[781, 539],
],
shade: 0.55,
},
{
plane: [
[833, 602],
[1003, 549],
[983, 587],
[821, 625],
],
shade: 0.65,
},
// Right skull.
{
plane: [
[1482, 399],
[1639, 469],
[1568, 582],
[1402, 507],
],
},
{
plane: [
[1639, 469],
[1650, 557],
[1597, 631],
[1568, 582],
],
shade: 0.57,
},
{
plane: [
[1402, 507],
[1568, 582],
[1597, 631],
[1414, 583],
],
shade: 0.66,
outline: [
[1402, 507],
[1568, 582],
[1597, 631],
[1561, 637],
[1444, 621],
[1414, 583],
],
},
// Rear rib, left and right of the chest opening.
{
plane: [
[1021, 505],
[1129, 484],
[1118, 501],
[1009, 520],
],
},
{
plane: [
[1009, 520],
[1118, 501],
[1112, 515],
[1006, 535],
],
shade: 0.65,
},
{
plane: [
[1191, 486],
[1300, 510],
[1289, 530],
[1181, 510],
],
},
{
plane: [
[1289, 530],
[1300, 510],
[1310, 611],
[1296, 621],
],
shade: 0.6,
},
// Middle rib and broken sternum: separate profiles preserve the dark gaps.
{
plane: [
[1052, 514],
[1159, 519],
[1129, 553],
[992, 548],
],
outline: [
[1015, 535],
[1052, 520],
[1094, 522],
[1110, 514],
[1159, 519],
[1129, 553],
[992, 548],
],
},
{
plane: [
[992, 548],
[1129, 553],
[1137, 575],
[987, 567],
],
shade: 0.6,
},
{
plane: [
[1159, 519],
[1170, 560],
[1137, 575],
[1129, 553],
],
shade: 0.55,
},
{
plane: [
[1180, 513],
[1289, 551],
[1274, 575],
[1159, 538],
],
},
{
plane: [
[1274, 575],
[1289, 551],
[1298, 617],
[1279, 632],
],
shade: 0.62,
},
// Closest right rib; its downturned end stops above the ground.
{
plane: [
[1157, 542],
[1261, 581],
[1243, 602],
[1141, 564],
],
},
{
plane: [
[1141, 564],
[1243, 602],
[1255, 641],
[1148, 605],
],
shade: 0.7,
},
{
plane: [
[1243, 602],
[1261, 581],
[1270, 627],
[1255, 641],
],
shade: 0.55,
},
// Foreground rib and spine.
{
plane: [
[1000, 566],
[1140, 581],
[1107, 604],
[960, 589],
],
},
{
plane: [
[960, 589],
[1107, 604],
[1112, 680],
[968, 622],
],
shade: 0.6,
outline: [
[960, 589],
[1107, 604],
[1112, 680],
[1070, 681],
[1066, 619],
[968, 611],
],
},
{
plane: [
[1107, 604],
[1140, 581],
[1160, 669],
[1112, 680],
],
shade: 0.5,
},
{
plane: [
[986, 611],
[1070, 617],
[967, 684],
[874, 671],
],
},
{
plane: [
[1070, 617],
[1074, 668],
[1060, 684],
[967, 684],
],
shade: 0.55,
},
]

View File

@ -0,0 +1,953 @@
import cloudTextureUrl from '@/assets/about-scene/clouds.png'
import moonTextureUrl from '@/assets/about-scene/moon-phases.png'
import swordTextureUrl from '@/assets/about-scene/netherite-sword.png'
import cleanUrl from '@/assets/about-scene/victory-clean.jpg'
import originalUrl from '@/assets/about-scene/victory-master.jpg'
import correctedPoseUrl from '@/assets/about-scene/victory-pose.jpg'
import removedBeamsUrl from '@/assets/about-scene/wither-beams-removed.png'
import rebuiltWitherUrl from '@/assets/about-scene/wither-shoulder-cleanup.png'
import { witherShieldSurfaces } from './wither-shield'
type Point = [number, number]
type Vertex = [number, number, number]
/** The approved artwork and item geometry are kept separate from the animation lifecycle. */
export function createWitherVictoryScene(canvas: HTMLCanvasElement) {
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to initialize about-page canvas')
const ctx = context
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const original = new Image()
const clean = new Image()
const correctedPose = new Image()
const rebuiltWither = new Image()
const removedBeams = new Image()
const swordTexture = new Image()
const cloudTexture = new Image()
const moonTexture = new Image()
const W = 2048,
H = W / 3,
artworkWidth = 2172,
artworkHeight = 724,
artworkScale = artworkWidth / W,
TAU = Math.PI * 2
let time = 0,
last = 0,
paused = false,
visible = false,
ready = false
let frame = 0,
destroyed = false
const makeLayer = (w = W, h = H) => {
const layer = document.createElement('canvas')
layer.width = Math.ceil(w)
layer.height = Math.ceil(h)
return layer
}
const base = makeLayer(artworkWidth, artworkHeight)
const shield = makeLayer(artworkWidth, artworkHeight)
const star = makeLayer(180, 180)
const starFace = makeLayer(180, 180)
const swordFace = makeLayer(128, 128)
const grippingFingers = makeLayer(64, 80)
let cloudField: ReturnType<typeof makeVanillaCloudField>
const buriedBlade: Point[] = [
[1088, 526],
[1099, 505],
[1105, 491],
[1116, 490],
[1124, 483],
[1133, 485],
[1142, 478],
[1158, 477],
[1195, 480],
[1212, 534],
[1169, 567],
[1101, 555],
]
const wound: Point[] = [
[1104, 507],
[1116, 490],
[1136, 490],
[1140, 480],
[1156, 477],
[1178, 479],
[1173, 493],
[1157, 505],
[1152, 524],
[1137, 534],
[1126, 523],
]
const starRows = [
'....p....',
'...pwp...',
'..pwYwp..',
'.pwYIYwp.',
'pwYISIYwp',
'.pwYIYwp.',
'..pwYwp..',
'...pwp...',
'....p....',
]
const starColors: Record<string, string> = {
p: '#b692d0',
w: '#f8e6cf',
Y: '#ffe3a0',
I: '#fff6da',
S: '#fffef1',
}
// Irregular failing-lamp events: brief reignitions, weak sputters, and dark gaps.
const shieldEvents = [
[0, 0.68, 0.48],
[0.87, 0.09, 0.82],
[1.05, 0.16, 0.27],
[1.39, 0.08, 0.61],
[2.7, 0.42, 0.2],
[3.27, 0.13, 0.65],
[4.8, 0.82, 0.42],
[5.83, 0.06, 0.82],
[6.08, 0.13, 0.29],
[6.39, 0.09, 0.7],
[8.15, 0.53, 0.26],
[9.64, 0.12, 0.69],
[9.88, 0.19, 0.34],
[10.2, 0.08, 0.73],
[11.75, 0.7, 0.45],
[12.8, 0.07, 0.87],
[13.14, 0.1, 0.24],
[15.02, 0.28, 0.5],
[15.58, 0.09, 0.86],
[15.82, 0.13, 0.31],
[17.5, 0.84, 0.22],
[18.63, 0.08, 0.68],
[18.85, 0.18, 0.39],
[20.25, 0.56, 0.53],
[21.13, 0.1, 0.86],
[21.36, 0.12, 0.35],
[22.17, 0.29, 0.18],
]
const rand = (n: number) => {
const v = Math.sin(n * 127.1 + 311.7) * 43758.5453
return v - Math.floor(v)
}
const sparks = Array.from({ length: 34 }, (_, i) => ({
seed: i,
phase: rand(i),
life: 6,
size: 2 + rand(i + 9) * 5,
}))
const dust = Array.from({ length: 56 }, (_, i) => ({
seed: i + 80,
phase: rand(i + 31),
life: 6 + rand(i + 42) * 5,
size: 4 + rand(i + 64) * 7,
}))
const armor: Point[][] = [
[
[655, 22],
[809, 72],
[885, 81],
[858, 132],
[773, 119],
[690, 132],
[651, 112],
],
[
[693, 214],
[768, 223],
[805, 385],
[727, 398],
[712, 301],
],
[
[627, 157],
[670, 171],
[649, 237],
[596, 221],
],
[
[877, 183],
[918, 210],
[899, 244],
[865, 224],
],
]
function path(points: Point[]) {
ctx.beginPath()
points.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
}
function glow(x: number, y: number, r: number, color: string, alpha: number) {
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r)
gradient.addColorStop(0, `rgba(${color},${alpha})`)
gradient.addColorStop(0.35, `rgba(${color},${alpha * 0.36})`)
gradient.addColorStop(1, `rgba(${color},0)`)
ctx.fillStyle = gradient
ctx.fillRect(x - r, y - r, r * 2, r * 2)
}
function prepare() {
// Restore the sharp master for all unaffected materials, anatomy and terrain.
const b = base.getContext('2d')!
b.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
b.imageSmoothingEnabled = false
b.drawImage(original, 0, 0, W, H)
const sky = makeLayer(artworkWidth, artworkHeight),
skyCtx = sky.getContext('2d')!
skyCtx.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
skyCtx.drawImage(clean, 0, 0, W, H)
skyCtx.globalCompositeOperation = 'destination-in'
const skyFade = skyCtx.createLinearGradient(0, 409, 0, 448)
skyFade.addColorStop(0, '#fff')
skyFade.addColorStop(1, 'transparent')
skyCtx.fillStyle = skyFade
skyCtx.fillRect(0, 0, W, 442)
b.drawImage(sky, 0, 0, W, H)
const restore = (source: HTMLImageElement, polygon: Point[]) => {
b.save()
b.beginPath()
polygon.forEach((p, i) => (i ? b.lineTo(...p) : b.moveTo(...p)))
b.closePath()
b.clip()
if (source === correctedPose)
b.drawImage(source, (583 * W) / 2172, (212 * W) / 2172, (510 * W) / 2172, (477 * W) / 2172)
else b.drawImage(source, 0, 0, W, H)
b.restore()
}
// Crisp original character; only previously corrected hands/boots use their local plate.
restore(original, [
[687, 8],
[906, 81],
[884, 173],
[919, 183],
[923, 209],
[876, 242],
[854, 273],
[862, 314],
[918, 308],
[938, 385],
[950, 424],
[930, 480],
[817, 480],
[805, 399],
[747, 399],
[720, 491],
[627, 502],
[651, 423],
[625, 452],
[590, 437],
[574, 410],
[594, 386],
[572, 374],
[577, 341],
[574, 331],
[593, 270],
[605, 237],
[588, 237],
[607, 159],
[650, 139],
])
// Remove the baked angled moon locally with matching moon-free sky.
// The new vanilla moon is a separate, camera-facing layer.
const moonSky = makeLayer(280, 270),
ms = moonSky.getContext('2d')!
ms.drawImage(
clean,
0,
0,
(280 * clean.naturalWidth) / W,
(270 * clean.naturalHeight) / H,
0,
0,
280,
270,
)
ms.globalCompositeOperation = 'destination-in'
ms.save()
ms.translate(140, 130)
ms.scale(1, 1.13)
const moonMask = ms.createRadialGradient(0, 0, 80, 0, 0, 123)
moonMask.addColorStop(0, '#fff')
moonMask.addColorStop(1, 'transparent')
ms.fillStyle = moonMask
ms.fillRect(-140, -135, 280, 270)
ms.restore()
b.drawImage(moonSky, 193, -20)
// Restore the sharp skull planes covered by the sky plate.
restore(original, [
[1159, 355],
[1318, 352],
[1358, 421],
[1315, 498],
[1246, 474],
[1107, 443],
])
// Remove the old painted blade without resampling the rest of the creature.
restore(clean, [
[975, 339],
[1002, 334],
[1078, 369],
[1148, 456],
[1185, 469],
[1200, 481],
[1178, 520],
[1144, 562],
[1080, 570],
[1025, 548],
[1053, 502],
[1006, 476],
])
// Retain the previously approved removal of baked lightning below the skulls.
restore(clean, [
[900, 595],
[948, 572],
[1000, 588],
[982, 615],
[914, 630],
[877, 625],
])
restore(clean, [
[1380, 420],
[1428, 430],
[1400, 492],
[1375, 535],
[1356, 606],
[1377, 626],
[1294, 639],
[1284, 579],
[1318, 525],
[1320, 470],
])
// Use regeneration only to erase the two unwanted shoulder slabs. The skulls,
// ribs, character and background retain their original sharp source pixels.
restore(rebuiltWither, [
[963, 423],
[1014, 417],
[1049, 431],
[1074, 479],
[1066, 495],
[1017, 511],
[985, 524],
[963, 466],
])
restore(rebuiltWither, [
[1360, 415],
[1392, 414],
[1429, 427],
[1448, 445],
[1405, 506],
[1398, 532],
[1355, 528],
[1319, 514],
[1324, 494],
])
// Replace only the removed beam silhouettes with the newly exposed background.
// No full-frame regeneration or feathering: unaffected source pixels stay intact.
restore(removedBeams, [
[976, 480],
[1146, 462],
[1152, 488],
[1124, 508],
[999, 530],
[990, 542],
])
restore(removedBeams, [
[1294, 489],
[1404, 498],
[1445, 548],
[1452, 629],
[1402, 621],
[1305, 615],
[1299, 549],
[1283, 521],
])
restore(correctedPose, [
[811, 377],
[951, 377],
[975, 415],
[987, 481],
[952, 496],
[813, 486],
])
restore(correctedPose, [
[591, 486],
[744, 495],
[757, 650],
[550, 650],
[565, 553],
])
restore(correctedPose, [
[880, 210],
[925, 218],
[951, 235],
[977, 245],
[979, 280],
[957, 302],
[932, 299],
[910, 280],
[877, 267],
])
try {
cloudField = makeVanillaCloudField()
} catch (error) {
console.warn('Unable to initialize about-page clouds', error)
}
// Occlude with skin pixels only: background in the palm must not erase the grip.
const fingers = grippingFingers.getContext('2d')!
fingers.drawImage(
clean,
(922 * clean.naturalWidth) / W,
(228 * clean.naturalHeight) / H,
(64 * clean.naturalWidth) / W,
(80 * clean.naturalHeight) / H,
0,
0,
64,
80,
)
const skin = fingers.getImageData(0, 0, 64, 80)
for (let pixel = 0; pixel < skin.data.length; pixel += 4) {
const [red, green, blue] = skin.data.subarray(pixel, pixel + 3)
if (red < 90 || green < 48 || red < green * 1.04 || green < blue * 1.08)
skin.data[pixel + 3] = 0
}
fingers.putImageData(skin, 0, 0)
// A small extruded item sprite retains sharp pixels during its vertical-axis turn.
const s = star.getContext('2d')!
starRows.forEach((row, y) =>
[...row].forEach((v, x) => {
if (v !== '.') {
s.fillStyle = starColors[v]
s.fillRect(x * 20, y * 20, 20, 20)
}
}),
)
}
function enchantedFace(
target: HTMLCanvasElement,
texture: CanvasImageSource,
t: number,
strength: number,
) {
const c = target.getContext('2d')!,
size = target.width
c.clearRect(0, 0, size, size)
c.imageSmoothingEnabled = false
c.globalCompositeOperation = 'source-over'
c.drawImage(texture, 0, 0, size, size)
c.globalCompositeOperation = 'source-atop'
c.fillStyle = `rgba(123,63,207,${strength * 0.14})`
c.fillRect(0, 0, size, size)
// Translation of glint only: the texture coordinates and geometry never change.
const shift = ((t / 6) % 1) * size * 1.25
for (let band = -2; band < 3; band++) {
const x = shift + band * size * 1.25
const g = c.createLinearGradient(x - size * 0.7, 0, x + size * 0.3, size)
g.addColorStop(0, 'rgba(135,72,236,0)')
g.addColorStop(0.34, 'rgba(135,72,236,0)')
g.addColorStop(0.46, `rgba(163,99,247,${strength * 0.6})`)
g.addColorStop(0.5, `rgba(224,178,255,${strength})`)
g.addColorStop(0.55, `rgba(151,81,244,${strength * 0.5})`)
g.addColorStop(0.66, 'rgba(135,72,236,0)')
g.addColorStop(1, 'rgba(135,72,236,0)')
c.fillStyle = g
c.fillRect(0, 0, size, size)
}
c.globalCompositeOperation = 'source-over'
return target
}
function drawSword(t: number) {
const face = enchantedFace(swordFace, swordTexture, t, 0.34)
ctx.save()
// Hide only the buried sword pixels; never paint a base-image patch over the shield.
ctx.beginPath()
ctx.rect(0, 0, W, H)
buriedBlade.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
ctx.clip('evenodd')
// Original 128x128 asset is rigidly rotated/scaled, with no mesh warp or repainting.
// Place the grip's actual texture center inside the corrected curled palm.
ctx.translate(938, 268)
ctx.rotate((95.5 * Math.PI) / 180)
ctx.scale(2.12, 2.12)
ctx.imageSmoothingEnabled = false
ctx.save()
ctx.translate(1.25, 1.25)
ctx.filter = 'brightness(.55)'
ctx.drawImage(swordTexture, -24, -104, 128, 128)
ctx.restore()
ctx.drawImage(face, -24, -104, 128, 128)
ctx.restore()
// The fingers occlude the handle so it remains held, rather than pasted over the hand.
ctx.drawImage(grippingFingers, 922, 228)
}
function drawStar(t: number, angle: number, x: number, y: number) {
const front = enchantedFace(starFace, star, t, 0.23)
const cosine = Math.cos(angle),
sine = Math.sin(angle),
halfDepth = 5
ctx.save()
ctx.translate(x, y)
ctx.imageSmoothingEnabled = false
// One native item-texel layer of thickness; keep the front's pixel silhouette intact.
const edge = sine > 0 ? -1 : 1
for (let row = 0; row < 9; row++)
for (let col = 0; col < 9; col++) {
const color = starRows[row][col]
if (color === '.') continue
const neighbor = starRows[row][col + edge]
if (neighbor && neighbor !== '.') continue
const px = ((col + (edge > 0 ? 1 : 0)) / 9 - 0.5) * 160,
py = (row / 9 - 0.5) * 160
const a = px * cosine - halfDepth * sine,
b = px * cosine + halfDepth * sine
ctx.fillStyle = color === 'p' ? '#9070b0' : '#d9af67'
ctx.beginPath()
ctx.moveTo(a, py)
ctx.lineTo(b, py)
ctx.lineTo(b, py + 160 / 9)
ctx.lineTo(a, py + 160 / 9)
ctx.closePath()
ctx.fill()
}
ctx.translate(Math.sign(cosine) * halfDepth * sine, 0)
ctx.scale(cosine, 1)
ctx.filter = 'brightness(1.04)'
ctx.shadowColor = '#ffdc98'
ctx.shadowBlur = 9
ctx.drawImage(front, -80, -80, 160, 160)
ctx.restore()
}
function shieldLevel(t: number) {
let level = 0
for (const [start, duration, power] of shieldEvents) {
const p = (t - start) / duration
if (p >= 0 && p < 1) {
const envelope = Math.min(1, p * 14, (1 - p) * 10)
const sputter = 0.64 + 0.36 * rand(Math.floor(t * 19) + start * 91)
level = Math.max(level, power * envelope * sputter)
}
}
return level
}
function makeVanillaCloudField() {
// Minecraft 1.20.6's unmodified clouds.png supplies every occupied cell.
// One texel is a 12 x 12 block footprint with the vanilla 4-block thickness.
const textureCanvas = makeLayer(256, 256),
tc = textureCanvas.getContext('2d')!
tc.drawImage(cloudTexture, 0, 0)
const pixels = tc.getImageData(0, 0, 256, 256).data
const occupied = (x: number, z: number) =>
pixels[(((z + 256) % 256) * 256 + ((x + 256) % 256)) * 4 + 3] > 127
const layer = makeLayer()
const context = layer.getContext('webgl', {
alpha: true,
antialias: true,
premultipliedAlpha: true,
})
if (!context) return
const gl = context
const vertices: number[] = []
const quad = (a: Vertex, b: Vertex, c: Vertex, d: Vertex, shade: number) => {
for (const point of [a, b, c, a, c, d]) vertices.push(...point, shade)
}
for (let z = 8; z < 96; z++)
for (let x = 0; x < 256; x++) {
if (!occupied(x, z + 36)) continue
const l = (x - 128) * 12,
r = l + 12,
n = z * 12,
f = n + 12,
y = 84,
h = y + 4
quad([l, y, n], [r, y, n], [r, y, f], [l, y, f], 0.7)
if (!occupied(x - 1, z + 36)) quad([l, y, n], [l, y, f], [l, h, f], [l, h, n], 0.9)
if (!occupied(x + 1, z + 36)) quad([r, y, f], [r, y, n], [r, h, n], [r, h, f], 0.9)
if (!occupied(x, z + 35)) quad([r, y, n], [l, y, n], [l, h, n], [r, h, n], 0.8)
if (!occupied(x, z + 37)) quad([l, y, f], [r, y, f], [r, h, f], [l, h, f], 0.8)
}
const shader = (kind: number, source: string) => {
const result = gl.createShader(kind)
if (!result) throw new Error('Unable to allocate cloud shader')
gl.shaderSource(result, source)
gl.compileShader(result)
if (!gl.getShaderParameter(result, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(result) ?? 'Cloud shader compilation failed')
return result
}
const vertex = shader(
gl.VERTEX_SHADER,
`
attribute vec4 vertex; uniform float offset;
varying float shade; varying float distance;
void main(){
vec3 p=vertex.xyz+vec3(offset,0.0,0.0);
float focal=650.0; float horizon=350.0;
gl_Position=vec4(2.0*focal*p.x/2048.0,
(1.0-2.0*horizon/682.6667)*p.z+2.0*focal*p.y/682.6667,
1.020202*p.z-28.282828,p.z);
shade=vertex.w; distance=p.z;
}
`,
)
const fragment = shader(
gl.FRAGMENT_SHADER,
`
precision mediump float; varying float shade; varying float distance;
void main(){
float alpha=.70*(1.0-smoothstep(480.0,1150.0,distance));
gl_FragColor=vec4(vec3(.34,.40,.50)*shade*alpha,alpha);
}
`,
)
const program = gl.createProgram()
if (!program) throw new Error('Unable to allocate cloud program')
gl.attachShader(program, vertex)
gl.attachShader(program, fragment)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
throw new Error(gl.getProgramInfoLog(program) ?? 'Cloud program linking failed')
gl.useProgram(program)
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
const attribute = gl.getAttribLocation(program, 'vertex')
gl.enableVertexAttribArray(attribute)
gl.vertexAttribPointer(attribute, 4, gl.FLOAT, false, 16, 0)
const offset = gl.getUniformLocation(program, 'offset')
gl.enable(gl.DEPTH_TEST)
gl.disable(gl.BLEND)
gl.clearColor(0, 0, 0, 0)
gl.viewport(0, 0, layer.width, layer.height)
return {
draw(elapsed: number) {
if (gl.isContextLost()) return
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
const travel = (elapsed * 2.4) % 3072
for (const tile of [-3072, 0, 3072]) {
gl.uniform1f(offset, tile + travel)
gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 4)
}
return layer
},
resize(width: number, height: number) {
layer.width = width
layer.height = height
gl.viewport(0, 0, width, height)
},
dispose() {
gl.deleteBuffer(buffer)
gl.deleteProgram(program)
gl.deleteShader(vertex)
gl.deleteShader(fragment)
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
}
}
function drawShield(t: number) {
const level = shieldLevel(t)
canvas.dataset.shieldIntensity = level.toFixed(3)
if (level < 0.003) return
const surface = shield.getContext('2d')!
surface.setTransform(artworkScale, 0, 0, artworkScale, 0, 0)
surface.clearRect(0, 0, W, H + 1)
surface.save()
surface.globalCompositeOperation = 'source-over'
const trace = (points: Point[]) => {
surface.beginPath()
points.forEach((p, i) => (i ? surface.lineTo(...p) : surface.moveTo(...p)))
surface.closePath()
}
// Tight fractured-cavity contour: blue armor remains on the surrounding ribs.
surface.beginPath()
surface.rect(0, 0, W, H)
wound.forEach((p, i) => (i ? surface.lineTo(p[0], p[1]) : surface.moveTo(p[0], p[1])))
surface.closePath()
surface.clip('evenodd')
witherShieldSurfaces.forEach(({ plane: quad, outline = quad, shade = 1 }, index) => {
const map = (u: number, v: number): Point => [
(1 - v) * ((1 - u) * quad[0][0] + u * quad[1][0]) +
v * ((1 - u) * quad[3][0] + u * quad[2][0]),
(1 - v) * ((1 - u) * quad[0][1] + u * quad[1][1]) +
v * ((1 - u) * quad[3][1] + u * quad[2][1]),
]
const alpha = level * shade * (0.79 + 0.21 * rand(index + Math.floor(t * 7)))
trace(outline)
surface.fillStyle = `rgba(55,126,203,${alpha * 0.38})`
surface.fill()
surface.save()
trace(outline)
surface.clip()
for (let row = 0; row < 8; row++)
for (let col = 0; col < 8; col++) {
const grain = rand(row * 17 + col * 5 + index * 139)
if (grain < 0.56) continue
trace([
map(col / 8, row / 8),
map((col + 1) / 8, row / 8),
map((col + 1) / 8, (row + 1) / 8),
map(col / 8, (row + 1) / 8),
])
surface.fillStyle = `rgba(104,167,223,${alpha * (grain - 0.4) * 0.42})`
surface.fill()
}
// Stepped translucent bands wrap each actual surface, like the supplied armor image.
for (let band = -1; band < 3; band++)
for (let col = 0; col < 8; col++) {
const v = band * 0.48 + (t / 24) * 0.48 + Math.floor(rand(col + index * 11) * 3) / 32
const height = 0.034 + rand(col * 3 + band + index) * 0.022
trace([
map(col / 8, v),
map((col + 1) / 8, v),
map((col + 1) / 8, v + height),
map(col / 8, v + height),
])
surface.fillStyle = `rgba(210,229,172,${alpha * 0.75})`
surface.fill()
}
surface.restore()
})
surface.restore()
// Composite the fitted faces once, without bright overlaps at shared bone edges.
ctx.save()
ctx.globalCompositeOperation = 'screen'
ctx.drawImage(shield, 0, 0, W, H)
ctx.restore()
}
function drawClouds(elapsed: number) {
ctx.save()
// Clouds pass behind the character silhouette, never across the face or armor.
ctx.beginPath()
ctx.rect(0, 0, W, H)
;[
[687, 7],
[907, 80],
[884, 175],
[918, 182],
[921, 208],
[980, 246],
[980, 299],
[945, 306],
[899, 280],
[864, 260],
[861, 325],
[573, 325],
[594, 241],
[588, 238],
[607, 159],
[650, 139],
].forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])))
ctx.closePath()
ctx.clip('evenodd')
const clouds = cloudField?.draw(elapsed)
if (clouds) ctx.drawImage(clouds, 0, 0, W, H)
ctx.restore()
canvas.dataset.cloudOffset = (elapsed * 2.4).toFixed(2)
}
function drawMoon() {
ctx.save()
ctx.globalCompositeOperation = 'screen'
ctx.imageSmoothingEnabled = false
ctx.filter = 'brightness(2.15)'
// Vanilla waning-crescent tile, rendered square-on with no skew or side extrusion.
// Retain its original pixel shading and native halo; black texels add no light.
ctx.drawImage(moonTexture, 96, 0, 32, 32, 45, -179, 576, 576)
ctx.restore()
}
function draw(elapsed: number) {
const t = elapsed % 24
const ratio = canvas.width / W
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
ctx.clearRect(0, 0, W, H)
const cycle = (t * TAU) / 24
ctx.save()
// Keep the static plate pixel-stable; animate only the independent effects.
// The full-resolution master is sampled once, without a floating camera zoom.
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(base, 0, 0, W, H)
drawMoon()
drawClouds(elapsed)
drawShield(t)
drawSword(t)
const bob = 5.2 * Math.sin(cycle * 3) + 1.3 * Math.sin(cycle * 6 + 0.4)
const sx = 1150,
sy = 247 + bob
const pulse = 0.8 + 0.12 * Math.sin(cycle * 3 + 0.5) + 0.06 * Math.sin(cycle * 7)
ctx.save()
ctx.globalCompositeOperation = 'screen'
glow(sx, sy, 195, '255,195,102', 0.42 * pulse)
glow(sx, sy, 88, '255,232,177', 0.15 * pulse)
glow(1134, 495, 165, '255,194,101', 0.13 * pulse)
glow(790, 191, 90, '255,201,129', 0.055 * pulse)
// Narrow rays breathe with the star, instead of rotating the whole scene.
ctx.translate(sx, sy)
for (let i = 0; i < 8; i++) {
ctx.save()
ctx.rotate((i * TAU) / 8 + 0.08 * Math.sin(cycle))
const g = ctx.createLinearGradient(0, 35, 0, 155)
g.addColorStop(0, `rgba(255,228,165,${0.13 * pulse})`)
g.addColorStop(1, 'transparent')
ctx.fillStyle = g
ctx.beginPath()
ctx.moveTo(-2, 30)
ctx.lineTo(-6, 155)
ctx.lineTo(6, 155)
ctx.lineTo(2, 30)
ctx.fill()
ctx.restore()
}
ctx.restore()
ctx.save()
ctx.globalCompositeOperation = 'screen'
for (const polygon of armor) {
ctx.save()
path(polygon)
ctx.clip()
const x = ((t / 8) % 1) * 1350 - 240
const glint = ctx.createLinearGradient(x - 100, 0, x + 150, 450)
glint.addColorStop(0, 'transparent')
glint.addColorStop(0.4, 'rgba(160,88,255,0)')
glint.addColorStop(0.5, 'rgba(206,149,255,.28)')
glint.addColorStop(0.58, 'rgba(136,81,250,.09)')
glint.addColorStop(1, 'transparent')
ctx.fillStyle = glint
ctx.fillRect(550, 0, 650, 520)
ctx.restore()
}
ctx.restore()
// Staggered particles have invisible births/deaths, so the loop has no pop.
dust.forEach((d) => {
const q = (elapsed / d.life + d.phase) % 1,
alpha = Math.pow(Math.sin(Math.PI * q), 0.65) * 0.9
const x =
1145 +
(rand(d.seed) - 0.5) * 170 +
Math.sin(q * 4 + d.seed) * 24 +
q * (45 + rand(d.seed + 2) * 85)
const y = 490 - q * (180 + rand(d.seed + 9) * 130)
const size = d.size * (1 - q * 0.4)
ctx.fillStyle = `rgba(7,10,16,${alpha})`
ctx.fillRect(x, y, size, size)
if (d.seed % 3 === 0) {
ctx.fillRect(x - size * 0.65, y + size * 0.4, size * 0.7, size * 0.65)
ctx.fillRect(x + size * 0.65, y - size * 0.5, size * 0.55, size * 0.6)
}
})
ctx.save()
ctx.globalCompositeOperation = 'screen'
sparks.forEach((d) => {
const q = (t / d.life + d.phase) % 1,
alpha = Math.pow(Math.sin(Math.PI * q), 1.7) * 0.55
const x = 1140 + (rand(d.seed + 7) - 0.5) * 150 + Math.sin(q * 4 + d.seed) * 14
const y = 440 - q * (130 + rand(d.seed + 19) * 120)
ctx.fillStyle = `rgba(255,207,122,${alpha})`
ctx.fillRect(x, y, d.size * 0.45, d.size * 0.45)
})
ctx.restore()
const angle = cycle * 2 + 0.12 * Math.sin(cycle * 2)
drawStar(t, angle, sx, sy)
ctx.restore()
canvas.dataset.animationTime = elapsed.toFixed(3)
}
function canAnimate() {
return ready && !destroyed && !paused && visible && !document.hidden && !reducedMotion.matches
}
function tick(now: number) {
frame = 0
if (!canAnimate()) return
if (last) time += Math.min((now - last) / 1000, 0.1)
last = now
draw(time)
frame = requestAnimationFrame(tick)
}
function updatePlayback() {
if (frame) cancelAnimationFrame(frame)
frame = 0
last = 0
if (canAnimate()) frame = requestAnimationFrame(tick)
}
function resize() {
if (destroyed) return
const width = canvas.getBoundingClientRect().width
if (width <= 0) return
canvas.width = Math.min(
artworkWidth,
Math.max(1, Math.round(width * Math.min(2, devicePixelRatio || 1))),
)
canvas.height = Math.round(canvas.width / 3)
cloudField?.resize(canvas.width, canvas.height)
if (ready) draw(time)
}
const observer = new ResizeObserver(resize)
observer.observe(canvas)
const intersection = new IntersectionObserver((entries) => {
visible = entries[0].isIntersecting
updatePlayback()
})
intersection.observe(canvas)
document.addEventListener('visibilitychange', updatePlayback)
reducedMotion.addEventListener('change', updatePlayback)
const loaded = (img: HTMLImageElement, url: string) =>
new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('Unable to load about-page artwork'))
img.src = url
})
const imageAssets = [
original,
clean,
swordTexture,
correctedPose,
cloudTexture,
moonTexture,
rebuiltWither,
removedBeams,
]
const loading = Promise.all([
loaded(original, originalUrl),
loaded(clean, cleanUrl),
loaded(swordTexture, swordTextureUrl),
loaded(correctedPose, correctedPoseUrl),
loaded(cloudTexture, cloudTextureUrl),
loaded(moonTexture, moonTextureUrl),
loaded(rebuiltWither, rebuiltWitherUrl),
loaded(removedBeams, removedBeamsUrl),
])
.then(() => {
if (destroyed) return
prepare()
ready = true
resize()
updatePlayback()
})
.finally(() => {
for (const img of imageAssets) {
img.onload = null
img.onerror = null
}
})
return {
ready: loading,
setPaused(value: boolean) {
paused = value
updatePlayback()
},
dispose() {
if (destroyed) return
destroyed = true
updatePlayback()
observer.disconnect()
intersection.disconnect()
document.removeEventListener('visibilitychange', updatePlayback)
reducedMotion.removeEventListener('change', updatePlayback)
cloudField?.dispose()
cloudField = undefined
for (const layer of [base, shield, star, starFace, swordFace, grippingFingers]) {
layer.width = 1
layer.height = 1
}
},
}
}

View File

@ -1,93 +0,0 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { onScopeDispose, ref } from 'vue'
defineProps<{ src: string; name: string; href?: string }>()
const emit = defineEmits<{ activate: [] }>()
const holding = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let origin = { x: 0, y: 0 }
let suppressClick = false
function cancel() {
clearTimeout(timer)
timer = undefined
holding.value = false
}
function begin() {
cancel()
suppressClick = false
holding.value = true
timer = setTimeout(() => {
cancel()
suppressClick = true
emit('activate')
}, 800)
}
function pointerDown(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return
origin = { x: event.clientX, y: event.clientY }
begin()
}
function pointerMove(event: PointerEvent) {
if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel()
}
function click(event: MouseEvent) {
if (!suppressClick) return
event.preventDefault()
event.stopPropagation()
suppressClick = false
}
function keydown(event: KeyboardEvent) {
if (event.code === 'Space') {
event.preventDefault()
if (!event.repeat) begin()
}
}
window.addEventListener('blur', cancel)
onScopeDispose(() => {
cancel()
window.removeEventListener('blur', cancel)
})
</script>
<template>
<a
:href="href"
target="_blank"
rel="noopener noreferrer"
class="mine-avatar"
:class="{ holding }"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="cancel"
@pointercancel="cancel"
@pointerleave="cancel"
@blur="cancel"
@click="click"
@contextmenu.prevent
@dragstart.prevent
@keydown="keydown"
@keyup.space.prevent="cancel"
>
<Avatar :src="src" :alt="name" size="2.5rem" circle no-shadow loading="lazy" />
</a>
</template>
<style scoped>
.mine-avatar {
display: inline-flex;
flex-shrink: 0;
border-radius: 50%;
user-select: none;
touch-action: manipulation;
}
.mine-avatar.holding {
outline: 2px solid var(--color-brand);
outline-offset: 3px;
}
.mine-avatar:focus-visible {
outline: 2px solid var(--color-contrast);
outline-offset: 3px;
}
</style>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { NewButton as Button, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onMounted, onScopeDispose, ref } from 'vue'
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue'
import type { Puzzle } from './engine'
import { messages } from './messages'
@ -20,7 +20,10 @@ const emit = defineEmits<{
}>()
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<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) =>
@ -85,6 +88,22 @@ function onWheel(event: WheelEvent) {
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
}
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`
}
watch(() => [props.playing, props.selected], updateBrushCursor, {
flush: 'post',
})
function label(index: number) {
const values = {
row: Math.floor(index / props.puzzle.size) + 1,
@ -101,6 +120,7 @@ function label(index: number) {
let observer: ResizeObserver | undefined
onMounted(() => {
updateBrushCursor()
observer = new ResizeObserver(updateView)
if (viewport.value) observer.observe(viewport.value)
})
@ -108,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>
@ -116,10 +139,20 @@ defineExpose({
<div class="mine-navigation" :class="{ 'mine-large': large }">
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
<div
ref="grid"
class="mine-grid"
:class="{ 'mine-grid-small': !large }"
:style="{ '--mine-size': puzzle.size, '--mine-cell-size': `${40 * zoom}px` }"
: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 })"
@pointerenter="updateBrushCursor"
>
<button
v-for="(_, i) in puzzle.answer"
@ -200,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);
}
@ -208,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;
@ -244,10 +283,14 @@ defineExpose({
color: var(--color-contrast);
font: inherit;
font-weight: 700;
cursor: crosshair;
cursor: pointer;
user-select: none;
touch-action: manipulation;
}
.mine-grid-brush-active,
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: var(--mine-brush-cursor, crosshair);
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
border-color: var(--mine-ink);

View File

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

View File

@ -16,6 +16,8 @@ import { basename, dirname, join } from '@tauri-apps/api/path'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
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'
@ -229,7 +231,7 @@ async function setGameDirMode(mode: GameDirMode) {
}
const editInstanceObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance',
name: title.value.trim(),
}))
watch(
@ -379,6 +381,8 @@ const messages = defineMessages({
</script>
<template>
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
<InstancePlayerSettings :instance-id="instance.id" />
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:symlink-target="instance.symlink_target"
@ -437,7 +441,6 @@ const messages = defineMessages({
id="instance-name"
v-model="title"
autocomplete="off"
:maxlength="80"
wrapper-class="flex-grow"
/>
</div>

View File

@ -41,24 +41,6 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Your last next launcher.',
},
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
homeWidgetsTitle: {
id: 'app.onboarding.home-widgets.title',
defaultMessage: 'Your Home, your layout',
},
homeWidgetsDescription: {
id: 'app.onboarding.home-widgets.description',
defaultMessage:
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
},
homeCustomizeTitle: {
id: 'app.onboarding.home-customize.title',
defaultMessage: 'Arrange it your way',
},
homeCustomizeDescription: {
id: 'app.onboarding.home-customize.description',
defaultMessage:
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
},
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
discoverDescription: {
id: 'app.onboarding.discover.description',
@ -90,12 +72,12 @@ export const onboardingMessages = defineMessages({
},
homeLayoutTitle: {
id: 'app.onboarding.home-layout.title',
defaultMessage: 'Change the amount of detail',
defaultMessage: 'Switch your home',
},
homeLayoutDescription: {
id: 'app.onboarding.home-layout.description',
defaultMessage:
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
'Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher.',
},
continueArea: {
id: 'app.onboarding.action.continue-area',
@ -105,7 +87,7 @@ export const onboardingMessages = defineMessages({
skinsDescription: {
id: 'app.onboarding.skins.description',
defaultMessage:
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
'Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile.',
},
clickSkins: {
id: 'app.onboarding.action.click-skins',
@ -114,16 +96,8 @@ export const onboardingMessages = defineMessages({
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
skinsPageDescription: {
id: 'app.onboarding.skins-page.description',
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
},
accountTitle: {
id: 'app.onboarding.account.title',
defaultMessage: 'Accounts, on your schedule',
},
accountDescription: {
id: 'app.onboarding.account.description',
defaultMessage:
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
'Select a profile, preview its available skins, and apply changes when that account supports skin management.',
},
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
downloadsDescription: {
@ -160,35 +134,6 @@ export const onboardingMessages = defineMessages({
defaultMessage:
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
},
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
languageDescription: {
id: 'app.onboarding.language.description',
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
},
translationTitle: {
id: 'app.onboarding.translation.title',
defaultMessage: 'Translation, the Starlight way',
},
translationDescription: {
id: 'app.onboarding.translation.description',
defaultMessage:
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
},
aiTitle: {
id: 'app.onboarding.ai.title',
defaultMessage: 'Bring your own AI provider',
},
aiDescription: {
id: 'app.onboarding.ai.description',
defaultMessage:
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
},
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
javaDescription: {
id: 'app.onboarding.java.description',
defaultMessage:
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
},
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
defaultsDescription: {
id: 'app.onboarding.defaults.description',
@ -204,11 +149,6 @@ export const onboardingMessages = defineMessages({
defaultMessage:
'Choose how content downloads and installs, from download sources to safety checks.',
},
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: {
id: 'app.onboarding.updates.description',
defaultMessage: 'Choose when Starlight checks for updates and whether it installs them for you.',
},
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
libraryDescription: {
@ -227,7 +167,7 @@ export const onboardingMessages = defineMessages({
libraryPageDescription: {
id: 'app.onboarding.library-page.description',
defaultMessage:
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
'Switch between all instances, modpacks, and custom setups, then open any instance to manage it.',
},
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
createDescription: {
@ -239,6 +179,15 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Click Create new instance to continue',
},
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
instanceModeTitle: {
id: 'app.onboarding.instance-mode.title',
defaultMessage: 'Choose your instance type',
},
instanceModeDescription: {
id: 'app.onboarding.instance-mode.description',
defaultMessage:
'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.',
},
creationDescription: {
id: 'app.onboarding.creation.description',
defaultMessage:
@ -293,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',
@ -310,7 +259,7 @@ export const onboardingMessages = defineMessages({
labDescription: {
id: 'app.onboarding.lab.description',
defaultMessage:
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
'The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher.',
},
clickLab: {
id: 'app.onboarding.action.click-lab',
@ -323,63 +272,7 @@ export const onboardingMessages = defineMessages({
labToolsDescription: {
id: 'app.onboarding.lab-tools.description',
defaultMessage:
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
},
openGradientText: {
id: 'app.onboarding.action.open-gradient-text',
defaultMessage: 'Open Gradient text generator to continue',
},
labEditorTitle: {
id: 'app.onboarding.lab-editor.title',
defaultMessage: 'Build and copy in one place',
},
labEditorDescription: {
id: 'app.onboarding.lab-editor.description',
defaultMessage:
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
},
labSeedMapTitle: {
id: 'app.onboarding.lab-seed-map.title',
defaultMessage: 'Find a world before you load it',
},
labSeedMapDescription: {
id: 'app.onboarding.lab-seed-map.description',
defaultMessage:
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
},
returnToLab: {
id: 'app.onboarding.action.return-lab',
defaultMessage: 'Click Lab to continue',
},
openSeedMap: {
id: 'app.onboarding.action.open-seed-map',
defaultMessage: 'Open Seed map to continue',
},
openSchematicWorkshop: {
id: 'app.onboarding.action.open-schematic-workshop',
defaultMessage: 'Open Schematic workshop to continue',
},
openRecipeGenerator: {
id: 'app.onboarding.action.open-recipe-generator',
defaultMessage: 'Open Recipe generator to continue',
},
labRecipeGeneratorTitle: {
id: 'app.onboarding.lab-recipe-generator.title',
defaultMessage: 'Craft data pack recipes',
},
labRecipeGeneratorDescription: {
id: 'app.onboarding.lab-recipe-generator.description',
defaultMessage:
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
},
labSchematicTitle: {
id: 'app.onboarding.lab-schematic.title',
defaultMessage: 'Inspect a build before placing it',
},
labSchematicDescription: {
id: 'app.onboarding.lab-schematic.description',
defaultMessage:
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
'Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally.',
},
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
@ -447,18 +340,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.start,
),
),
inspect(
'home-widget-grid',
'home-widget-grid',
onboardingMessages.homeWidgetsTitle,
onboardingMessages.homeWidgetsDescription,
),
inspect(
'home-widget-customize',
'home-widget-customize',
onboardingMessages.homeCustomizeTitle,
onboardingMessages.homeCustomizeDescription,
),
inspect(
'home-layout-switch',
'home-layout-switch',
@ -513,16 +394,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.skinsPageTitle,
onboardingMessages.skinsPageDescription,
),
step(
'account',
'inspect',
copy(
onboardingMessages.accountTitle,
onboardingMessages.accountDescription,
onboardingMessages.continueArea,
),
control('account-entry'),
),
step(
'lab-navigation',
'navigate',
@ -539,90 +410,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.labToolsTitle,
onboardingMessages.labToolsDescription,
),
step(
'lab-gradient-text-navigation',
'navigate',
copy(
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
onboardingMessages.openGradientText,
),
control('lab-gradient-text-card', '/lab/gradient-text'),
),
inspect(
'lab-gradient-text-editor',
'lab-gradient-text-editor',
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
),
step(
'lab-return-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-seed-map-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.openSeedMap,
),
control('lab-seed-map-card', '/lab/seed-map'),
),
inspect(
'lab-seed-map-workspace',
'seed-map-workspace',
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
),
step(
'lab-return-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.openSchematicWorkshop,
),
control('lab-schematic-preview-card', '/lab/schematic-preview'),
),
inspect(
'lab-schematic-workspace',
'schematic-preview-workspace',
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
),
step(
'lab-recipe-generator-navigation',
'navigate',
copy(
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
onboardingMessages.openRecipeGenerator,
),
control('lab-recipe-generator-card', '/lab/recipe-generator'),
),
inspect(
'lab-recipe-generator-workspace',
'recipe-generator-workspace',
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
),
step(
'downloads-navigation',
'navigate',
@ -681,6 +468,12 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
control('create-instance', '/create'),
),
inspect(
'creation-instance-mode',
'creation-instance-mode',
onboardingMessages.instanceModeTitle,
onboardingMessages.instanceModeDescription,
),
step(
'creation-flow',
'activate',
@ -692,6 +485,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
{
targetId: 'creation-methods',
branchByTarget: {
'creation-method-starlight': { next: 'complete' },
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
'creation-method-import': { next: 'complete' },
},

View File

@ -5,7 +5,6 @@ import { getVersion } from '@tauri-apps/api/app'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import ColorMineAvatar from '@/components/ui/easteregg/color-mine/ColorMineAvatar.vue'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config'
@ -17,7 +16,7 @@ import { type AboutMemberExperience, getAboutMemberExperience } from './about-me
const { formatMessage } = useVIntl()
const version = await getVersion()
const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<AboutMemberExperience>()
const activeMemberExperience = shallowRef<Extract<AboutMemberExperience, { kind: 'scene' }>>()
const pressingMemberName = ref<string>()
let longPressTimer: number | undefined
let pressStart = { x: 0, y: 0 }
@ -41,9 +40,13 @@ function startMemberLongPress(member: TeamMember, event: PointerEvent) {
pressStart = { x: event.clientX, y: event.clientY }
pressingMemberName.value = member.name
longPressTimer = window.setTimeout(async () => {
activeMemberExperience.value = experience
suppressNextMemberClick = true
cancelMemberLongPress()
if (experience.kind === 'color-mine') {
openColorMine()
return
}
activeMemberExperience.value = experience
await nextTick()
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, experience.longPressDuration)
@ -216,13 +219,13 @@ const messages = defineMessages({
<div class="flex flex-col items-center gap-4">
<div
ref="experienceHost"
class="relative m-0 w-full overflow-hidden h-64 rounded-xl"
class="relative m-0 aspect-[3/1] w-full overflow-hidden rounded-xl"
style="
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
"
>
<AboutScene />
<AboutScene :paused="Boolean(activeMemberExperience)" />
<component
:is="activeMemberExperience?.component"
v-if="activeMemberExperience"
@ -300,56 +303,37 @@ const messages = defineMessages({
{{ formatMessage(messages.developmentTeam) }}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
<template v-for="member in teamMembers" :key="member.name">
<div
v-if="member.name === 'Disy920'"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
>
<ColorMineAvatar
:src="member.avatarUrl"
:name="member.name"
:href="member.url"
@activate="openColorMine"
/>
<a
:href="member.url"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 flex-1 items-center gap-3"
>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
member.name
}}</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</div>
<a
v-else
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
>
<Avatar
:src="member.avatarUrl"
:alt="member.name"
size="2.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
{{ member.name }}
</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</template>
<a
v-for="member in teamMembers"
:key="member.name"
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
:class="{ 'member-card-holding': pressingMemberName === member.name }"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointercancel="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@blur="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
@dragstart.prevent
>
<Avatar
:src="member.avatarUrl"
:alt="member.name"
size="2.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
{{ member.name }}
</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</div>
</section>
<details class="group pt-4 about-settings-details">
@ -417,6 +401,11 @@ const messages = defineMessages({
padding: var(--gap-lg);
}
.member-card-holding {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.about-page :deep(.rounded-xl.bg-surface-4) {
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));

View File

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

View File

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

View File

@ -1,21 +1,31 @@
import type { Component } from 'vue'
import AboutEasterEgg from '../AboutEasterEgg.vue'
export type AboutMemberExperience = {
component: Component
longPressDuration: number
}
export type AboutMemberExperience =
| {
kind: 'scene'
component: Component
longPressDuration: number
}
| {
kind: 'color-mine'
longPressDuration: number
}
// 长按「关于」页成员名字触发的彩蛋体验。
// 长按「关于」页成员卡片触发的彩蛋体验。
// 下界之星合成彩蛋AboutEasterEgg.vue由长按成员名 / 暗号 / Konami 秘技触发。
// 若要为特定成员挂载自定义彩蛋,在这里新增条目即可。
const memberExperiences: Record<string, AboutMemberExperience> = {
'easter-egg': {
kind: 'scene',
component: AboutEasterEgg,
longPressDuration: 800,
},
'color-mine': {
kind: 'color-mine',
longPressDuration: 800,
},
}
export function getAboutMemberExperience(experience: unknown): AboutMemberExperience | undefined {

View File

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

View File

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

View File

@ -84,6 +84,7 @@ const props = defineProps<{
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
readOnly?: boolean
manageSavedSkins?: boolean
activeTab?: 'saved' | 'default'
}>()
@ -182,7 +183,9 @@ const sections = computed<SkinSection[]>(() => {
const draggableSavedSkins = ref<Skin[]>([])
const isDraggingSavedSkin = ref(false)
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
const canReorderSavedSkins = computed(
() => props.manageSavedSkins !== false && draggableSavedSkins.value.length > 1,
)
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
const sectionLayouts = computed(() => {
@ -486,7 +489,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@ -526,7 +529,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@ -568,7 +571,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template #overlay-buttons>
<template v-if="manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"

View File

@ -13,7 +13,6 @@ import type { Router } from 'vue-router'
import {
install_job_dismiss,
install_job_repair_cache_and_retry,
install_job_retry,
install_job_support_details,
installJobInstanceId,
type InstallJobSnapshot,
@ -21,6 +20,7 @@ import {
type InstallPhaseId,
type InstallProgress,
} from '@/helpers/install'
import { createInstallJobNotificationFilter } from '@/helpers/install-job-notification-visibility'
import { effectiveInstallProgress, hasDeterminateInstallProgress } from '@/helpers/install-progress'
import { get_many as getInstances } from '@/helpers/instance'
import type { DownloadManager } from '@/providers/download-manager'
@ -258,15 +258,6 @@ const failureSummaryMessages = defineMessages({
},
})
const visibleJobStatuses = new Set<InstallJobStatus>([
'queued',
'running',
'canceling',
'waiting_for_user',
'failed',
'interrupted',
])
const retainedJobStatuses = new Set<InstallJobStatus>(['succeeded', 'canceled'])
const activeJobStatuses = new Set<InstallJobStatus>([
'queued',
'running',
@ -682,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
}
@ -724,6 +715,8 @@ export async function useInstallJobNotifications(opts: {
return buttons
}
const filterVisibleJobs = createInstallJobNotificationFilter(opts.manager.jobs.value)
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
@ -731,12 +724,7 @@ export async function useInstallJobNotifications(opts: {
}
}
const currentJobIds = new Set(jobs.value.map((job) => job.job_id))
const visibleJobs = nextJobs.filter(
(job) =>
visibleJobStatuses.has(job.status) ||
(retainedJobStatuses.has(job.status) && currentJobIds.has(job.job_id)),
)
const visibleJobs = filterVisibleJobs(nextJobs)
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(

View File

@ -1,16 +1,271 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useHostedSync } from './useHostedSync.ts'
import { useHostedCreation, forgetHostedCreation } from './useHostedCreation.ts'
import {
clearHostedSession,
hostedCreate,
hostedDefault,
hostedSync,
onHostedPackAttemptStarted,
setInstanceMode,
} from '../helpers/hosted-packs.ts'
import {
openSkinSiteLogin,
receiveSkinSiteSession,
receiveSkinSiteMessage,
requestSkinSiteLuck,
requestSkinSiteDownloadToken,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
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> }> = []
let syncResponse: (() => Promise<unknown>) | undefined
let createdId = 'instance'
let instanceExists = false
let tokenRequests = 0
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return
tokenRequests++
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} 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, args })
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null
return command === 'plugin:install|hosted_create' ? createdId : null
},
},
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site', username: 'Site' },
},
} as MessageEvent,
frame,
)
await hostedDefault()
assert.equal(await hostedCreate(), 'instance')
await hostedSync('instance')
await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4)
assert.deepEqual(
calls.map(({ command }) => command),
[
'hosted_set_session',
'hosted_default',
'hosted_set_session',
'hosted_create',
'hosted_set_session',
'hosted_sync',
'hosted_set_session',
'hosted_set_instance_mode',
].map((name) => `plugin:install|${name}`),
)
for (const call of calls.filter(({ command }) => command.endsWith('hosted_set_session'))) {
assert.equal(call.args.token, 'site.jwt.secret')
}
let rejectSync!: (error: unknown) => void
let syncCalls = 0
syncResponse = () => {
syncCalls++
return new Promise((_, reject) => {
rejectSync = reject
})
}
const originalPage = useHostedSync(() => 'shared-progress-instance')
const pendingSync = originalPage.sync()
const reopenedPage = useHostedSync(() => 'shared-progress-instance')
assert.equal(reopenedPage.busy.value, true)
assert.equal(useHostedSync(() => 'other-instance').busy.value, false)
await reopenedPage.sync()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(syncCalls, 1)
rejectSync({ message: 'download interrupted' })
await pendingSync
assert.equal(reopenedPage.busy.value, false)
assert.match(reopenedPage.error.value, /download interrupted/)
assert.equal(
useHostedSync(() => 'shared-progress-instance').error.value,
reopenedPage.error.value,
)
syncResponse = async () => ({ revision: 'new-version' })
await reopenedPage.sync()
assert.equal(originalPage.error.value, '')
assert.deepEqual(originalPage.result.value, { revision: 'new-version' })
const creation = useHostedCreation()
createdId = 'deleted-instance'
syncResponse = async () => {
throw new Error('download failed')
}
await creation.install()
assert.equal(creation.createdInstance.value, 'deleted-instance')
assert.match(creation.installError.value, /download failed/)
createdId = 'replacement-instance'
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const retryCallStart = calls.length
assert.equal(await creation.install(), 'replacement-instance')
assert.equal(calls[retryCallStart].command, 'plugin:instance|instance_get')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_sync')).at(-1)?.args.instanceId,
'replacement-instance',
)
assert.equal(creation.installError.value, '')
forgetHostedCreation('unrelated-instance')
assert.equal(creation.completed.value, true)
forgetHostedCreation('replacement-instance')
assert.equal(creation.createdInstance.value, undefined)
assert.equal(creation.completed.value, false)
createdId = 'third-instance'
syncResponse = () =>
new Promise((_, reject) => {
rejectSync = reject
})
const deletedDuringSync = creation.install()
await new Promise((resolve) => setImmediate(resolve))
forgetHostedCreation('third-instance')
rejectSync(new Error('Unknown instance'))
await deletedDuringSync
assert.equal(creation.installError.value, '')
assert.equal(creation.createdInstance.value, undefined)
createdId = 'existing-instance'
syncResponse = async () => {
throw new Error('download interrupted')
}
await creation.install()
instanceExists = true
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const createCount = calls.filter((call) => call.command.endsWith('|hosted_create')).length
assert.equal(await creation.install(), 'existing-instance')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_create')).length,
createCount,
)
creation.acknowledge('existing-instance')
const requestsBeforeLogout = tokenRequests
resetSkinSiteSession()
await clearHostedSession()
assert.equal(calls.at(-1)!.args.token, null)
await setInstanceMode('instance', 'local')
assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
const unauthenticatedRetry = hostedSync('failed-instance')
assert.deepEqual(attempts, ['failed-instance'])
await assert.rejects(unauthenticatedRetry)
stopListening()
await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1)
await assert.rejects(hostedCreate(), /无需选择玩家/)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
}
})
test('pack download gets the site JWT without choosing a player and rejects stale replies', async () => {
const sent: Array<{ type: string; requestId: string }> = []
const frame = {
postMessage(data: { type: string; requestId: string }) {
sent.push(data)
},
} as unknown as Window
const receive = (data: unknown, origin = SKIN_SITE_ORIGIN) =>
receiveSkinSiteMessage(
{
origin,
source: frame,
data,
} as MessageEvent,
frame,
)
resetSkinSiteSession()
setSkinSiteFrame(frame)
await assert.rejects(requestSkinSiteDownloadToken(), /无需选择玩家/)
receive({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
})
const token = requestSkinSiteDownloadToken()
const requestId = sent.at(-1)!.requestId
const reply = { type: 'starlight-pack-token-result', requestId, token: 'site.jwt.secret' }
assert.equal(receive(reply, 'https://evil.example'), false)
assert.equal(receive(reply), true)
assert.equal(await token, 'site.jwt.secret')
assert.equal(skinSitePlayers.value.length, 0)
assert.equal(JSON.stringify(skinSiteUser.value).includes('secret'), false)
const stale = requestSkinSiteDownloadToken()
const staleId = sent.at(-1)!.requestId
const rejected = assert.rejects(stale, /登录状态已变化/)
resetSkinSiteSession()
await rejected
assert.equal(receive({ ...reply, requestId: staleId }), false)
setSkinSiteFrame(null)
})
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
const frame = {} as Window
const message = (status: string, user?: unknown) =>
@ -28,24 +283,280 @@ test('skin site session accepts only the embedded origin and window, and redirec
jwt: 'must-not-copy',
})
assert.equal(
receiveSkinSiteSession({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
false,
)
assert.equal(receiveSkinSiteSession(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteSession(message('signed-in', {}), frame), false)
assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false)
assert.equal(skinSiteUser.value, null)
assert.equal(receiveSkinSiteSession(signedIn, frame), true)
assert.equal(receiveSkinSiteMessage(signedIn, frame), true)
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
assert.equal(skinSiteStatus.value, 'signed-in')
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
assert.equal(receiveSkinSiteSession(message('checking'), frame), true)
assert.equal(receiveSkinSiteMessage(message('checking'), frame), true)
assert.equal(skinSiteUser.value, null)
receiveSkinSiteSession(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
receiveSkinSiteSession(message('signed-out'), frame)
receiveSkinSiteMessage(message('signed-out'), frame)
assert.equal(skinSiteUser.value, null)
assert.equal(skinSiteStatus.value, 'signed-out')
receiveSkinSiteSession(message('error'), frame)
receiveSkinSiteMessage(message('error'), frame)
assert.equal(skinSiteStatus.value, 'error')
resetSkinSiteSession()
})
test('luck requests use only the connected skin site frame and validate its result', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
const sessionMessage = {
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'lucky-user', username: 'Lucky' },
},
} as MessageEvent
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(sessionMessage, frame)
const automaticPlayersRequest = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(automaticPlayersRequest.type, 'starlight-skin-players-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticPlayersRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteLuck()
assert.equal(sent.at(-1)?.targetOrigin, SKIN_SITE_ORIGIN)
const request = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(request.type, 'starlight-skin-luck-request')
assert.match(request.requestId, /^skin-luck-\d+-\d+$/)
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-luck-result',
requestId: request.requestId,
ok: true,
luck: 88,
},
} as MessageEvent,
frame,
),
true,
)
assert.equal(await result, 88)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('player requests validate identities and replace the collection atomically', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
assert.equal(skinSitePlayersStatus.value, 'checking')
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
{ uuid: 'invalid', name: 'Ignored', isMojang: false },
],
},
} as MessageEvent,
frame,
),
true,
)
assert.deepEqual(skinSitePlayers.value, [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
])
assert.equal(skinSitePlayersStatus.value, 'ready')
const transientFailure = requestSkinSitePlayers()
const transientFailureRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: transientFailureRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne Renamed',
isMojang: false,
skinState: 'error',
},
],
},
} as MessageEvent,
frame,
)
assert.equal((await transientFailure)[0].skinState, 'ready')
assert.equal(skinSitePlayers.value[0].name, 'PlayerOne Renamed')
assert.equal(skinSitePlayers.value[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
const retry = requestSkinSitePlayers()
const retryRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: retryRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
assert.deepEqual(await retry, [])
assert.deepEqual(skinSitePlayers.value, [])
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('skin updates are restricted to known non-Mojang players and validated results', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'empty',
},
],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteSkinUpdate(
'0123456789abcdef0123456789abcdef',
'data:image/png;base64,SEVBRERBVEE=',
'slim',
)
const request = sent.at(-1)?.data as { type: string; requestId: string; playerId: string }
assert.equal(request.type, 'starlight-skin-update-request')
assert.equal(request.playerId, '0123456789abcdef0123456789abcdef')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-update-result', requestId: request.requestId, ok: true },
} as MessageEvent,
frame,
)
await result
await assert.rejects(
requestSkinSiteSkinUpdate(
'fedcba9876543210fedcba9876543210',
'data:image/png;base64,SEVBRERBVEE=',
'default',
),
)
setSkinSiteFrame(null)
resetSkinSiteSession()
})

View File

@ -1,31 +1,403 @@
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 }
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
export type SkinSitePlayer = {
uuid: string
name: string
isMojang: boolean
skinState: 'ready' | 'empty' | 'error'
headDataUrl?: string
skinDataUrl?: string
model?: 'default' | 'slim'
}
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
const user = ref<SkinSiteUser | null>(null)
const status = ref<SkinSiteStatus>('signed-out')
const players = ref<SkinSitePlayer[]>([])
const playersStatus = ref<SkinSitePlayersStatus>('idle')
const selectedPlayerId = ref<string | null>(null)
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
let connectedFrame: Window | null = null
let luckRequestSequence = 0
let playersRequestSequence = 0
let skinUpdateRequestSequence = 0
let packTokenRequestSequence = 0
const pendingPackTokens = new Map<
string,
{
resolve: (token: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
>()
function rejectPendingPackTokens() {
for (const request of pendingPackTokens.values()) {
clearTimeout(request.timeout)
request.reject(new Error('StarLight 登录状态已变化,请重试。'))
}
pendingPackTokens.clear()
}
export function requestSkinSiteDownloadToken(): Promise<string> {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(
new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。无需选择玩家。'),
)
}
const requestId = `pack-token-${Date.now()}-${++packTokenRequestSequence}`
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPackTokens.delete(requestId)
reject(new Error('获取 StarLight 登录凭据超时,请重试。'))
}, 10_000)
pendingPackTokens.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-pack-token-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch {
clearTimeout(timeout)
pendingPackTokens.delete(requestId)
reject(new Error('无法读取 StarLight 登录状态,请重新登录。'))
}
})
}
type PendingLuckRequest = {
resolve: (luck: number) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingLuckRequests = new Map<string, PendingLuckRequest>()
type PendingPlayersRequest = {
resolve: (players: SkinSitePlayer[]) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingPlayersRequests = new Map<string, PendingPlayersRequest>()
type PendingSkinUpdateRequest = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingSkinUpdateRequests = new Map<string, PendingSkinUpdateRequest>()
function isSafePngDataUrl(value: unknown, maxLength: number) {
return (
typeof value === 'string' &&
value.length <= maxLength &&
/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(value)
)
}
function hasSafeSkinHead(player: SkinSitePlayer) {
if (!['ready', 'empty', 'error'].includes(player.skinState)) return false
if (player.skinState !== 'ready') return player.headDataUrl === undefined
return (
isSafePngDataUrl(player.headDataUrl, 100_000) &&
(player.skinDataUrl === undefined || isSafePngDataUrl(player.skinDataUrl, 2_000_000)) &&
(player.model === undefined || player.model === 'default' || player.model === 'slim')
)
}
export const skinSiteUser = readonly(user)
export const skinSiteStatus = readonly(status)
export const skinSitePlayers = readonly(players)
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)
request.reject(new Error(message))
}
pendingLuckRequests.clear()
}
function rejectPendingPlayersRequests(message: string) {
for (const request of pendingPlayersRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingPlayersRequests.clear()
}
function rejectPendingSkinUpdateRequests(message: string) {
for (const request of pendingSkinUpdateRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingSkinUpdateRequests.clear()
}
export function setSkinSiteFrame(frame: Window | null) {
if (frame && !user.value) status.value = 'checking'
if (connectedFrame === frame) return
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site connection changed.')
rejectPendingPlayersRequests('The skin site connection changed.')
rejectPendingSkinUpdateRequests('The skin site connection changed.')
connectedFrame = frame
}
export function selectSkinSitePlayer(playerId: string | null) {
if (playerId === null) {
selectedPlayerId.value = null
return
}
if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId
}
export function openSkinSiteLogin() {
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
}
export function resetSkinSiteSession() {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session ended.')
rejectPendingPlayersRequests('The skin site session ended.')
rejectPendingSkinUpdateRequests('The skin site session ended.')
user.value = null
status.value = 'signed-out'
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null) {
export function requestSkinSiteSkinUpdate(
playerId: string,
textureDataUrl: string,
model: 'default' | 'slim',
) {
const player = players.value.find((candidate) => candidate.uuid === playerId)
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before changing a skin.'))
}
if (!player || player.isMojang) {
return Promise.reject(new Error('This skin site player cannot be changed.'))
}
if (!isSafePngDataUrl(textureDataUrl, 2_000_000)) {
return Promise.reject(new Error('The selected skin texture is invalid or too large.'))
}
const requestId = `skin-update-${Date.now()}-${++skinUpdateRequestSequence}`
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingSkinUpdateRequests.delete(requestId)
reject(new Error('The skin site update timed out.'))
}, 20_000)
pendingSkinUpdateRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{
type: 'starlight-skin-update-request',
requestId,
playerId,
textureDataUrl,
model,
},
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingSkinUpdateRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSitePlayers() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting players.'))
}
playersStatus.value = 'checking'
const requestId = `skin-players-${Date.now()}-${++playersRequestSequence}`
return new Promise<SkinSitePlayer[]>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(new Error('The skin site player request timed out.'))
}, 30_000)
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-players-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSiteLuck() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting luck.'))
}
const requestId = `skin-luck-${Date.now()}-${++luckRequestSequence}`
return new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingLuckRequests.delete(requestId)
reject(new Error('The skin site luck request timed out.'))
}, 12_000)
pendingLuckRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-luck-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingLuckRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null) {
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
const data = event.data
if (!data || data.type !== 'starlight-skin-session') return false
if (!data || typeof data !== 'object') return false
if (data.type === 'starlight-pack-token-result') {
const pending = pendingPackTokens.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPackTokens.delete(data.requestId)
if (
status.value === 'signed-in' &&
typeof data.token === 'string' &&
data.token.length > 0 &&
data.token.length <= 16_384 &&
!/\s/.test(data.token)
) {
pending.resolve(data.token)
} else pending.reject(new Error('StarLight 登录凭据不可用,请重新登录。'))
return true
}
if (data.type === 'starlight-skin-players-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingPlayersRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPlayersRequests.delete(data.requestId)
if (data.ok === true && Array.isArray(data.players)) {
const nextPlayers = data.players.filter(
(player: unknown): player is SkinSitePlayer =>
typeof player === 'object' &&
player !== null &&
typeof (player as SkinSitePlayer).uuid === 'string' &&
/^[0-9a-f-]{32,36}$/i.test((player as SkinSitePlayer).uuid) &&
typeof (player as SkinSitePlayer).name === 'string' &&
(player as SkinSitePlayer).name.length > 0 &&
(player as SkinSitePlayer).name.length <= 64 &&
typeof (player as SkinSitePlayer).isMojang === 'boolean' &&
hasSafeSkinHead(player as SkinSitePlayer),
)
const previousPlayers = new Map(players.value.map((player) => [player.uuid, player]))
players.value = nextPlayers.map((player) => {
const previous = previousPlayers.get(player.uuid)
return player.skinState === 'error' && previous?.skinState === 'ready'
? { ...player, ...previous, name: player.name, isMojang: player.isMojang }
: player
})
if (
selectedPlayerId.value &&
!nextPlayers.some((player) => player.uuid === selectedPlayerId.value)
) {
selectedPlayerId.value = null
}
playersStatus.value = 'ready'
pending.resolve(players.value)
} else {
playersStatus.value = 'error'
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid player list.',
),
)
}
return true
}
if (data.type === 'starlight-skin-luck-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingLuckRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingLuckRequests.delete(data.requestId)
if (
data.ok === true &&
typeof data.luck === 'number' &&
Number.isFinite(data.luck) &&
data.luck >= 0 &&
data.luck <= 100
) {
pending.resolve(data.luck)
} else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid luck result.',
),
)
}
return true
}
if (data.type === 'starlight-skin-update-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingSkinUpdateRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingSkinUpdateRequests.delete(data.requestId)
if (data.ok === true) pending.resolve()
else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site rejected the skin update.',
),
)
}
return true
}
if (data.type !== 'starlight-skin-session') return false
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
let refreshPlayers = false
if (data.status === 'signed-in') {
if (
!data.user ||
@ -36,13 +408,30 @@ export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null
data.user.username.length > 256
)
return false
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
rejectPendingPackTokens()
rejectPendingPlayersRequests('The skin site account changed.')
rejectPendingSkinUpdateRequests('The skin site account changed.')
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
user.value = { uuid: data.user.uuid, username: data.user.username }
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
}
refreshPlayers = true
} else {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session is unavailable.')
rejectPendingPlayersRequests('The skin site session is unavailable.')
rejectPendingSkinUpdateRequests('The skin site session is unavailable.')
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
status.value = data.status
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
return true
}

View File

@ -0,0 +1,73 @@
import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { hostedCreate } from '../helpers/hosted-packs.ts'
import { runHostedSync } from './useHostedSync.ts'
const installing = ref(false)
const installError = ref('')
const createdInstance = ref<string>()
const completed = ref(false)
let generation = 0
export function forgetHostedCreation(instanceId: string) {
if (createdInstance.value !== instanceId) return
generation++
createdInstance.value = undefined
completed.value = false
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) {
createdInstance.value = undefined
completed.value = false
}
}
async function install(gameDirRoot?: string | null) {
if (installing.value) return
installing.value = true
installError.value = ''
const attempt = generation
try {
if (createdInstance.value) {
const instance = await invoke<unknown | null>('plugin:instance|instance_get', {
instanceId: createdInstance.value,
})
if (attempt !== generation) return
if (!instance) {
createdInstance.value = undefined
completed.value = false
}
}
if (completed.value) return createdInstance.value
createdInstance.value ??= await hostedCreate(gameDirRoot)
const instanceId = createdInstance.value
await runHostedSync(instanceId)
if (attempt !== generation) return
markHostedCreationCompleted(instanceId)
return instanceId
} catch (cause) {
if (attempt === generation) {
if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause)
else installError.value = String(cause)
}
} finally {
installing.value = false
}
}
return { installing, installError, createdInstance, completed, acknowledge, install }
}

View File

@ -0,0 +1,66 @@
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(() => {
return getTask(instanceId())
})
async function sync() {
const id = instanceId()
if (task.value.busy) return undefined
try {
return await runHostedSync(id)
} catch {
return undefined
}
}
return {
busy: computed(() => task.value.busy),
result: computed(() => task.value.result),
error: computed(() => task.value.error),
sync,
}
}

View File

@ -0,0 +1,27 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
import { getInstanceMode, type InstanceMode, setInstanceMode } from '@/helpers/hosted-packs'
export const instanceModeKey = (instanceId: string) => ['instance-mode', instanceId] as const
export function useInstanceMode(instanceId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => instanceModeKey(toValue(instanceId))),
queryFn: () => getInstanceMode(toValue(instanceId)),
enabled: computed(() => !!toValue(instanceId)),
staleTime: 30_000,
})
}
export function useSetInstanceMode() {
const client = useQueryClient()
return useMutation({
mutationFn: ({ instanceId, mode }: { instanceId: string; mode: InstanceMode }) =>
setInstanceMode(instanceId, mode),
onMutate: ({ instanceId }) => client.cancelQueries({ queryKey: instanceModeKey(instanceId) }),
onSuccess: (_, { instanceId, mode }) => client.setQueryData(instanceModeKey(instanceId), mode),
onError: (_, { instanceId }) =>
client.invalidateQueries({ queryKey: instanceModeKey(instanceId) }),
})
}

View File

@ -1,4 +1,3 @@
[
{
"name": "Ax_Tps",
@ -9,6 +8,7 @@
{
"name": "Disy920",
"avatar": "Disy920.png",
"url": "https://space.bilibili.com/231241189?spm_id_from=333.337.0.0"
"url": "https://space.bilibili.com/231241189?spm_id_from=333.337.0.0",
"experience": "color-mine"
}
]

View File

@ -14,8 +14,8 @@ import { invoke } from '@tauri-apps/api/core'
// }
/**
* Check if the authentication servers are reachable, throwing an exception if
* not reachable.
* Check if the StarLight authentication server is reachable, throwing an
* exception if it is not reachable.
*/
export async function check_reachable() {
await invoke('plugin:auth|check_reachable')

View File

@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
const state = createHostedDownloadFailures()
const failed = (instanceId: string, loader: string, error: string) => ({
fraction: null,
loader_uuid: loader,
event: { type: 'hosted_pack_sync', instance_id: instanceId, instance_name: instanceId, error },
})
const oldFailure = failed('one', 'old', 'old failure')
state.update(oldFailure)
state.update(failed('two', 'other', 'other failure'))
state.begin('one', state.values())
assert.equal(state.has('one'), false)
assert.equal(state.has('two'), true)
assert.equal(state.isRetired('old'), true)
state.update(oldFailure)
assert.equal(state.has('one'), false)
state.update(failed('one', 'new', 'new failure'))
state.update({ ...oldFailure, fraction: 0.5 })
assert.equal(
state.values().find((bar) => bar.bar_type?.instance_id === 'one')?.message,
'new failure',
)
})
test('a new native task retires the previous failed task without a frontend retry', () => {
const state = createHostedDownloadFailures()
const old = {
fraction: null,
loader_uuid: 'old',
event: { type: 'hosted_pack_sync', instance_id: 'instance', error: 'old failure' },
}
state.update(old)
state.update({ fraction: 0, loader_uuid: 'new', event: { ...old.event, error: null } })
state.update(old)
assert.equal(state.has('instance'), false)
})
test('late failure from an older active task cannot replace the new task', () => {
const state = createHostedDownloadFailures()
const event = { type: 'hosted_pack_sync', instance_id: 'instance' }
state.update({ fraction: 0.5, loader_uuid: 'old', event })
state.update({ fraction: 0, loader_uuid: 'new', event })
state.update({ fraction: null, loader_uuid: 'old', event: { ...event, error: 'old failure' } })
assert.equal(state.has('instance'), false)
assert.equal(state.isRetired('old'), true)
assert.equal(state.isRetired('new'), false)
})

View File

@ -0,0 +1,53 @@
import type { LoadingBar } from './state.ts'
export function createHostedDownloadFailures() {
const failures = new Map<string, LoadingBar>()
const retired = new Set<string>()
const currentTasks = new Map<string, string>()
return {
values: () => [...failures.values()],
has: (instanceId: string) => failures.has(instanceId),
isRetired: (id: string) => retired.has(id),
begin(instanceId: string, bars: LoadingBar[]) {
const current = currentTasks.get(instanceId)
if (current) retired.add(current)
currentTasks.delete(instanceId)
const previous = failures.get(instanceId)
if (previous) retired.add(String(previous.loading_bar_uuid))
for (const bar of bars) {
if (bar.bar_type?.type === 'hosted_pack_sync' && bar.bar_type.instance_id === instanceId) {
retired.add(String(bar.loading_bar_uuid))
}
}
failures.delete(instanceId)
},
update(payload: {
fraction: number | null
loader_uuid: string
event: LoadingBar['bar_type']
}) {
const event = payload.event
if (
event?.type !== 'hosted_pack_sync' ||
!event.instance_id ||
retired.has(payload.loader_uuid)
)
return
const current = currentTasks.get(event.instance_id)
if (current && current !== payload.loader_uuid) retired.add(current)
currentTasks.set(event.instance_id, payload.loader_uuid)
if (payload.fraction === null && event.error) {
failures.set(event.instance_id, {
loading_bar_uuid: payload.loader_uuid,
bar_type: event,
title: event.instance_name,
message: event.error,
total: 0,
current: 0,
})
} else {
failures.delete(event.instance_id)
}
},
}
}

View File

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

View File

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

View File

@ -0,0 +1,111 @@
import { invoke } from '@tauri-apps/api/core'
import {
requestSkinSiteDownloadToken,
skinSiteStatus,
skinSiteUser,
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
let sessionUpdate: Promise<void> = Promise.resolve()
const attemptListeners = new Set<(instanceId: string) => void>()
export function onHostedPackAttemptStarted(listener: (instanceId: string) => void) {
attemptListeners.add(listener)
return () => {
attemptListeners.delete(listener)
}
}
function startHostedPackAttempt(instanceId: string) {
for (const listener of attemptListeners) listener(instanceId)
}
export function clearHostedSession(): Promise<void> {
const update = sessionUpdate
.catch(() => {})
.then(() => invokeHosted<void>('plugin:install|hosted_set_session', { token: null }))
sessionUpdate = update
return update
}
export async function prepareHostedSession(): Promise<void> {
await waitForSkinSiteSession()
const userId = skinSiteUser.value?.uuid
const token = await requestSkinSiteDownloadToken()
const update = sessionUpdate
.catch(() => {})
.then(async () => {
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userId) {
throw new Error('StarLight 登录状态已变化,请重试。')
}
await invokeHosted<void>('plugin:install|hosted_set_session', { token })
})
sessionUpdate = update
await update
}
async function invokeWithSession<T>(command: string, args?: Record<string, unknown>): Promise<T> {
await prepareHostedSession()
return invokeHosted<T>(command, args)
}
async function invokeHosted<T>(command: string, args?: Record<string, unknown>): Promise<T> {
try {
return await invoke<T>(command, args)
} catch (cause) {
if (
!(cause instanceof Error) &&
typeof cause === 'object' &&
cause !== null &&
'message' in cause &&
typeof cause.message === 'string'
) {
throw new Error(cause.message, { cause })
}
throw cause
}
}
export type InstanceMode = 'starlight' | 'local'
export const getInstanceMode = (instanceId: string) =>
invokeHosted<InstanceMode>('plugin:install|hosted_instance_mode', { instanceId })
export const setInstanceMode = (instanceId: string, mode: InstanceMode) => {
if (mode === 'starlight') startHostedPackAttempt(instanceId)
return (mode === 'starlight' ? invokeWithSession<void> : invokeHosted<void>)(
'plugin:install|hosted_set_instance_mode',
{ instanceId, mode },
)
}
export interface HostedPublication {
packId: string
releaseId: number
manifest: {
name: string
version: string
format: string
runtime: { gameVersion: string; loader: string; loaderVersion: string | null }
files: { path: string; size: number; sha256: string }[]
}
}
export interface HostedBinding {
publication: HostedPublication
}
export interface HostedSyncResult {
version: string
downloadedBytes: number
changedFiles: number
preservedFiles: string[]
}
export const hostedDefault = () =>
invokeWithSession<HostedPublication>('plugin:install|hosted_default')
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) => {
startHostedPackAttempt(instanceId)
return invokeWithSession<HostedSyncResult>('plugin:install|hosted_sync', { instanceId })
}

View File

@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { InstallJobSnapshot, InstallJobStatus } from './install.ts'
import { createInstallJobNotificationFilter } from './install-job-notification-visibility.ts'
function job(jobId: string, status: InstallJobStatus): InstallJobSnapshot {
return { job_id: jobId, status } as InstallJobSnapshot
}
test('does not resurrect failures that finished before the notification surface started', () => {
const oldFailure = job('old-failure', 'failed')
const filter = createInstallJobNotificationFilter([oldFailure, job('old-success', 'succeeded')])
assert.deepEqual(filter([oldFailure, job('old-success', 'succeeded')]), [])
assert.deepEqual(filter([oldFailure, job('current', 'running')]).map((item) => item.job_id), [
'current',
])
})
test('keeps an observed task visible when it finishes', () => {
const filter = createInstallJobNotificationFilter([job('old-failure', 'failed')])
assert.deepEqual(filter([job('current', 'running')]).map((item) => item.job_id), ['current'])
assert.deepEqual(filter([job('current', 'failed')]).map((item) => item.job_id), ['current'])
assert.deepEqual(filter([job('current', 'succeeded')]).map((item) => item.job_id), ['current'])
})
test('shows a newly received failure even if its active phase completed too quickly to observe', () => {
const filter = createInstallJobNotificationFilter([job('old-failure', 'failed')])
assert.deepEqual(filter([job('new-failure', 'failed')]).map((item) => item.job_id), [
'new-failure',
])
})

View File

@ -0,0 +1,32 @@
import type { InstallJobSnapshot, InstallJobStatus } from './install.ts'
const activeStatuses = new Set<InstallJobStatus>([
'queued',
'running',
'canceling',
'waiting_for_user',
])
const failureStatuses = new Set<InstallJobStatus>(['failed', 'interrupted'])
/**
* Keeps the popup scoped to work the user could actually have observed.
* Finished jobs already present when the action bar starts belong to download
* history; they must not be resurrected by an unrelated loading event.
*/
export function createInstallJobNotificationFilter(initialJobs: InstallJobSnapshot[]) {
const missedFinishedJobIds = new Set(
initialJobs.filter((job) => !activeStatuses.has(job.status)).map((job) => job.job_id),
)
let visibleJobIds = new Set<string>()
return (nextJobs: InstallJobSnapshot[]) => {
const visibleJobs = nextJobs.filter((job) => {
if (activeStatuses.has(job.status)) return true
if (visibleJobIds.has(job.job_id)) return true
return failureStatuses.has(job.status) && !missedFinishedJobIds.has(job.job_id)
})
visibleJobIds = new Set(visibleJobs.map((job) => job.job_id))
return visibleJobs
}
}

View File

@ -32,6 +32,7 @@ export interface InstallModpackPreview {
}
export interface InstallCreateInstanceRequest {
instanceMode?: 'starlight' | 'local'
name: string
gameVersion: string
loader: InstanceLoader

View File

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

View File

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

View File

@ -17,6 +17,8 @@ import { collectGcContext } from '@/helpers/gc/context'
import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategies'
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'
@ -1073,6 +1075,8 @@ 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', {
instanceId,

View File

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

View File

@ -118,6 +118,10 @@ export async function get_available_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_available_skins', {})
}
export async function get_default_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_default_skins', {})
}
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,

View File

@ -6,6 +6,9 @@
import { invoke } from '@tauri-apps/api/core'
export interface LoadingBarType {
batch_id?: string
file_name?: string
error?: string | null
type?: string
version?: string
instance_id?: string
@ -25,12 +28,7 @@ export interface LoadingBar {
}
export type OpeningCommandEvent =
| 'RunMRPack'
| 'InstallServer'
| 'InstallVersion'
| 'InstallMod'
| 'InstallModpack'
| string
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string
export interface OpeningCommand {
event: OpeningCommandEvent

View File

@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createTaggedModProgress } from './tagged-mod-progress.ts'
const parent = (id = 'parent', fraction: number | null = 0, error = '') => ({
loader_uuid: id,
fraction,
message: 'Applying updates',
event: { type: 'hosted_pack_sync', instance_id: 'one', error },
})
const file = (id: string, fraction: number | null, batch = 'batch', error = '') => ({
loader_uuid: id,
fraction,
total: 100,
message: 'Downloading',
event: {
type: 'hosted_mod_download',
instance_id: 'one',
instance_name: 'Server',
batch_id: batch,
file_name: id,
error,
},
})
test('parallel files keep independent progress and wait for installation to complete', () => {
const state = createTaggedModProgress()
state.update(parent())
assert.equal(state.update(file('first', 0.25)), true)
assert.equal(state.update(file('second', 0.6)), false)
const group = state.groups.get('batch')!
assert.equal(group.files.get('first')!.current, 25)
assert.equal(group.files.get('second')!.current, 60)
state.update(file('first', null))
state.update(file('first', 0.1))
assert.equal(group.files.get('first')!.current, 100)
state.update(file('second', null))
assert.equal(group.done, false)
state.update(parent('parent', null))
assert.equal(group.done, true)
assert.equal(group.error, '')
})
test('failures retain downloaded bytes and retry discards all late events from the old attempt', () => {
const state = createTaggedModProgress()
state.update(parent())
state.update(file('first', 0.4))
state.update(file('first', null, 'batch', 'network failed'))
state.update(parent('parent', null, 'network failed'))
assert.equal(state.groups.get('batch')!.files.get('first')!.current, 40)
assert.equal(state.groups.get('batch')!.error, 'network failed')
state.reset('one')
state.update(parent('new-parent'))
state.update(file('new-file', 0.2, 'new-batch'))
state.update(parent('parent', 0.9))
state.update(parent('parent', null, 'old error'))
state.update(file('first', null))
assert.deepEqual([...state.groups.keys()], ['new-batch'])
assert.equal(state.groups.get('new-batch')!.done, false)
})
test('native retry and resetting one instance leave other instance downloads alone', () => {
const state = createTaggedModProgress()
state.update(parent())
state.update(file('first', 0))
const other = file('other', 0.8, 'other-batch')
other.event.instance_id = 'two'
state.update(other)
state.update(parent('new-parent'))
state.update(file('new-file', 0.3, 'new-batch'))
state.update(parent('parent', 0.5))
assert.deepEqual([...state.groups.keys()], ['other-batch', 'new-batch'])
state.reset('one')
assert.deepEqual([...state.groups.keys()], ['other-batch'])
})

View File

@ -0,0 +1,102 @@
import type { LoadingBarType } from './state.ts'
export interface TaggedProgressEvent {
loader_uuid: string
event?: LoadingBarType
fraction: number | null
total?: number | null
message: string
}
export interface TaggedProgressFile {
id: string
name: string
current: number
total: number
message: string
done: boolean
error: string
}
export interface TaggedProgressGroup {
id: string
instanceId: string
name: string
message: string
error: string
done: boolean
files: Map<string, TaggedProgressFile>
}
export function createTaggedModProgress() {
const groups = new Map<string, TaggedProgressGroup>()
const parents = new Map<string, string>()
const retiredParents = new Set<string>()
const retired = new Set<string>()
function reset(instanceId: string) {
const parent = parents.get(instanceId)
if (parent) retiredParents.add(parent)
parents.delete(instanceId)
for (const [id, group] of groups) {
if (group.instanceId === instanceId) {
retired.add(id)
groups.delete(id)
}
}
}
function update(payload: TaggedProgressEvent): boolean {
const event = payload.event
if (!event?.instance_id) return false
if (event.type === 'hosted_pack_sync') {
if (retiredParents.has(payload.loader_uuid)) return false
if (payload.fraction !== null && parents.get(event.instance_id) !== payload.loader_uuid) {
reset(event.instance_id)
parents.set(event.instance_id, payload.loader_uuid)
}
if (parents.get(event.instance_id) !== payload.loader_uuid) return false
for (const group of groups.values()) {
if (group.instanceId !== event.instance_id) continue
if (group.done) continue
group.message = payload.message
if (payload.fraction === null) {
group.done = true
group.error = event.error ?? ''
}
}
return false
}
if (event.type !== 'hosted_mod_download' || !event.batch_id || retired.has(event.batch_id))
return false
let group = groups.get(event.batch_id)
const isNew = !group
if (!group) {
group = {
id: event.batch_id,
instanceId: event.instance_id,
name: event.instance_name ?? '',
message: '',
error: '',
done: false,
files: new Map(),
}
groups.set(group.id, group)
}
const previous = group.files.get(payload.loader_uuid)
if (previous?.done) return false
const total = Math.max(0, payload.total ?? previous?.total ?? 0)
group.files.set(payload.loader_uuid, {
id: payload.loader_uuid,
name: event.file_name ?? '',
total,
current:
payload.fraction === null
? event.error
? (previous?.current ?? 0)
: total
: Math.max(0, Math.min(1, payload.fraction)) * total,
message: payload.message,
done: payload.fraction === null,
error: event.error ?? '',
})
return isNew
}
return { groups, reset, update }
}

View File

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

View File

@ -1,8 +1,91 @@
{
"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" },
"app.hosted-mods.downloads": { "message": "View downloads" },
"app.hosted-mods.close": { "message": "Close" },
"app.hosted-packs.progress.retry": { "message": "Return to the instance to retry" },
"app.hosted-packs.progress.preparing": { "message": "Preparing modpack installation…" },
"app.hosted-packs.progress.downloads": { "message": "View downloads" },
"app.hosted-packs.open-instance": { "message": "Open installed instance" },
"app.hosted-packs.retry-install": { "message": "Retry installation" },
"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" },
"app.instance-mode.starlight": { "message": "StarLight instance" },
"app.instance-mode.local": { "message": "Local instance" },
"app.instance-mode.starlight-description": { "message": "Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting." },
"app.instance-mode.local-description": { "message": "Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds." },
"app.instance-mode.saving": { "message": "Applying instance type and installing the server modpack when needed…" },
"app.instance-mode.loading": { "message": "Loading instance type…" },
"app.instance-mode.retry": { "message": "Retry" },
"app.instance-mode.local-packs-title": { "message": "Local modpacks" },
"app.instance-mode.local-packs": { "message": "Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized." },
"app.instance-mode.browse": { "message": "Browse modpacks" },
"app.instance-mode.import": { "message": "Import as a local instance" },
"app.hosted-packs.title": { "message": "Server-managed modpack" },
"app.hosted-packs.description": { "message": "The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required." },
"app.hosted-packs.refresh": {"message": "Refresh"},
"app.hosted-packs.loading": {"message": "Loading published modpacks…"},
"app.hosted-packs.bound": {"message": "Installed: {name} · {version}"},
"app.hosted-packs.syncing": {"message": "Comparing files and synchronizing changes…"},
"app.hosted-packs.complete": {"message": "Synced to {version}. Changed {count} files; downloaded {size} MiB."},
"app.hosted-packs.preserved": {"message": "Preserved {count} locally modified or personal files"},
"app.hosted-packs.empty": {"message": "No modpacks have been approved for publication yet."},
"app.hosted-packs.update": {"message": "Synchronize now"},
"app.hosted-packs.install": { "message": "Retry automatic installation" },
"app.hosted-packs.management": {"message": "Content management"},
"app.hosted-packs.mods": {"message": "Manage mods"},
"app.hosted-packs.packs": {"message": "Manage modpacks"},
"app.home.luck.title": { "message": "Daily luck index" },
"app.home.luck.get": { "message": "Get with one click" },
"app.home.luck.login-to-get": { "message": "Sign in to get it" },
"app.home.luck.loading": { "message": "Asking the little sprite..." },
"app.home.luck.error": {
"message": "The little sprite did not answer. Please try again later."
},
"app.home.luck.out-of": { "message": "out of 100" },
"minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" },
"minecraft-account.skin-site.checking": { "message": "Checking skin site session…" },
"minecraft-account.skin-site.sync-error": { "message": "Could not verify the skin site session. Retrying automatically." },
"minecraft-account.add-skin-game-account": { "message": "Add skin site game account" },
"minecraft-account.skin-site.sync-error": {
"message": "Could not verify the skin site session. Retrying automatically."
},
"minecraft-account.skin-site.players": { "message": "Skin site players ({count})" },
"minecraft-account.skin-site.players.retry": { "message": "Retry" },
"minecraft-account.skin-site.players.empty": {
"message": "No player profiles are attached to this skin site account."
},
"minecraft-account.skin-site.players.error": {
"message": "Could not refresh the player list. Previously loaded players are kept."
},
"app.easteregg.color-mine.title": {
"message": "Starlight Mine: Chromatic Realms"
},
@ -4983,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"
},
@ -5676,12 +5762,6 @@
"app.notification.warning": {
"message": "Warning"
},
"app.onboarding.account.description": {
"message": "When you are ready, sign in, switch accounts, or open your profile here. No deadline."
},
"app.onboarding.account.title": {
"message": "Accounts, on your schedule"
},
"app.onboarding.action.click-create": {
"message": "Click Create new instance to continue"
},
@ -5718,33 +5798,12 @@
"app.onboarding.action.finish-area": {
"message": "Click anywhere and you are all set"
},
"app.onboarding.action.open-gradient-text": {
"message": "Open Gradient text generator to continue"
},
"app.onboarding.action.open-recipe-generator": {
"message": "Open Recipe generator to continue"
},
"app.onboarding.action.open-schematic-workshop": {
"message": "Open Schematic workshop to continue"
},
"app.onboarding.action.open-seed-map": {
"message": "Open Seed map to continue"
},
"app.onboarding.action.return-lab": {
"message": "Click Lab to continue"
},
"app.onboarding.action.skip": {
"message": "Leave the tour"
},
"app.onboarding.action.start": {
"message": "Take the tour"
},
"app.onboarding.ai.description": {
"message": "Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place."
},
"app.onboarding.ai.title": {
"message": "Bring your own AI provider"
},
"app.onboarding.appearance.description": {
"message": "Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar."
},
@ -5823,26 +5882,14 @@
"app.onboarding.favorites.title": {
"message": "Keep a short list"
},
"app.onboarding.home-customize.description": {
"message": "Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells."
},
"app.onboarding.home-customize.title": {
"message": "Arrange it your way"
},
"app.onboarding.home-layout.description": {
"message": "Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home."
"message": "Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher."
},
"app.onboarding.home-layout.title": {
"message": "Change the amount of detail"
},
"app.onboarding.home-widgets.description": {
"message": "Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes."
},
"app.onboarding.home-widgets.title": {
"message": "Your Home, your layout"
"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"
@ -5853,56 +5900,20 @@
"app.onboarding.instance-tabs.title": {
"message": "The rest of the workshop"
},
"app.onboarding.java.description": {
"message": "The Java runtimes that start Minecraft live here. Technical, but well-behaved."
},
"app.onboarding.java.title": {
"message": "Java, under the hood"
},
"app.onboarding.lab-editor.description": {
"message": "Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects."
},
"app.onboarding.lab-editor.title": {
"message": "Build and copy in one place"
},
"app.onboarding.lab-recipe-generator.description": {
"message": "Pick a Java version, fill the recipe slots, and copy or export the JSON locally."
},
"app.onboarding.lab-recipe-generator.title": {
"message": "Craft data pack recipes"
},
"app.onboarding.lab-schematic.description": {
"message": "Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together."
},
"app.onboarding.lab-schematic.title": {
"message": "Inspect a build before placing it"
},
"app.onboarding.lab-seed-map.description": {
"message": "Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map."
},
"app.onboarding.lab-seed-map.title": {
"message": "Find a world before you load it"
},
"app.onboarding.lab-tools.description": {
"message": "Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher."
"message": "Create and edit skins, generate formatted text and recipes, explore seeds, inspect schematics, and translate mods locally."
},
"app.onboarding.lab-tools.title": {
"message": "Local tools for Minecraft"
},
"app.onboarding.lab.description": {
"message": "The Lab keeps local Minecraft tools inside the launcher, without another website or account."
"message": "The Lab keeps Minecraft creation, world, and maintenance tools inside the launcher."
},
"app.onboarding.lab.title": {
"message": "Useful tools, built in"
},
"app.onboarding.language.description": {
"message": "Pick the launcher language and manage translations. No decoder ring required."
},
"app.onboarding.language.title": {
"message": "Speak your language"
},
"app.onboarding.library-page.description": {
"message": "Filter by modpack, server, or custom setup, then open any instance to manage it."
"message": "Switch between all instances, modpacks, and custom setups, then open any instance to manage it."
},
"app.onboarding.library-page.title": {
"message": "Everything, in its place"
@ -5929,29 +5940,17 @@
"message": "Make it yours"
},
"app.onboarding.skins-page.description": {
"message": "Add, preview, sort, and apply skins here. No pressure to sign in just yet."
"message": "Select a profile, preview its available skins, and apply changes when that account supports skin management."
},
"app.onboarding.skins-page.title": {
"message": "Your skin drawer"
},
"app.onboarding.skins.description": {
"message": "Keep your Minecraft skins together. Signing in can wait until you feel like it."
"message": "Choose a skin site player or Minecraft account, then preview and apply the skins available to that profile."
},
"app.onboarding.skins.title": {
"message": "A new look, maybe"
},
"app.onboarding.translation.description": {
"message": "Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character."
},
"app.onboarding.translation.title": {
"message": "Translation, the Starlight way"
},
"app.onboarding.updates.description": {
"message": "Choose when Starlight checks for updates and whether it installs them for you."
},
"app.onboarding.updates.title": {
"message": "Stay in the loop"
},
"app.onboarding.welcome.description": {
"message": "Your instances, content, worlds, and downloads now have one home. Let us take a quick lap before you settle in."
},
@ -6840,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"
},
@ -7749,6 +7754,27 @@
"app.skins.section.tiny-takeover": {
"message": "Tiny Takeover"
},
"app.skins.skin-site-account.description": {
"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."
},
"app.skins.skin-site-account.empty-title": {
"message": "No players available"
},
"app.skins.skin-site-account.loading-description": {
"message": "The launcher is synchronizing the players attached to your account."
},
"app.skins.skin-site-account.loading-title": {
"message": "Loading skin site players…"
},
"app.skins.skin-site-account.mojang-description": {
"message": "{player} is an official Minecraft player. Its skin remains managed by the official account provider."
},
"app.skins.skin-site-account.title": {
"message": "StarLight skin site player"
},
"app.skins.sign-in.axolotl-alt": {
"message": "Starlight Launcher"
},
@ -10146,8 +10172,11 @@
"minecraft-account.offline-mode": {
"message": "Offline mode"
},
"minecraft-account.offline-mode.description": {
"message": "Only offline accounts are available. You can launch fully downloaded instances."
"minecraft-account.offline-mode.description.no-internet": {
"message": "It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below."
},
"minecraft-account.offline-mode.description.server-unavailable": {
"message": "Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status."
},
"minecraft-account.offline-mode.refresh": {
"message": "Refresh connection status"

View File

@ -1,8 +1,87 @@
{
"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": "下载完成" },
"app.hosted-mods.downloads": { "message": "查看下载" },
"app.hosted-mods.close": { "message": "关闭" },
"app.hosted-packs.progress.retry": { "message": "返回实例重试" },
"app.hosted-packs.progress.preparing": { "message": "正在准备整合包安装…" },
"app.hosted-packs.progress.downloads": { "message": "查看下载" },
"app.hosted-packs.open-instance": { "message": "打开已安装实例" },
"app.hosted-packs.retry-install": { "message": "重试安装" },
"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": "实例类型" },
"app.instance-mode.starlight": { "message": "StarLight 实例" },
"app.instance-mode.local": { "message": "本地实例" },
"app.instance-mode.starlight-description": { "message": "游玩 StarLight 服务器必选。自动安装服务器指定的整合包、游戏版本和加载器。每次启动需要登录 StarLight检查并完成更新后再进入游戏。" },
"app.instance-mode.local-description": { "message": "不会同步 StarLight 服务器的变更。跳过同步检查,启动更快,可自选整合包安装,更适合第三方服务器与本地个人游玩。" },
"app.instance-mode.saving": { "message": "正在应用实例类型,并按需安装服务器整合包…" },
"app.instance-mode.loading": { "message": "正在读取实例类型…" },
"app.instance-mode.retry": { "message": "重试" },
"app.instance-mode.local-packs-title": { "message": "本地整合包" },
"app.instance-mode.local-packs": { "message": "可以自选整合包并安装为本地实例。切换为本地后保留现有游戏文件和存档,之后不再同步 StarLight 的变更。" },
"app.instance-mode.browse": { "message": "浏览整合包" },
"app.instance-mode.import": { "message": "导入为本地实例" },
"app.hosted-packs.management": {"message": "内容管理"},
"app.hosted-packs.mods": {"message": "管理 Mod"},
"app.hosted-packs.packs": {"message": "管理整合包"},
"app.hosted-packs.title": { "message": "服务器管理的整合包" },
"app.hosted-packs.description": { "message": "整合包和版本由管理员指定。每次启动都会检查服务器更新,有变更时先下载并安装,再启动游戏。需要有效的 StarLight 登录状态和网络连接。" },
"app.hosted-packs.refresh": {"message": "刷新"},
"app.hosted-packs.loading": {"message": "正在获取已发布整合包…"},
"app.hosted-packs.bound": {"message": "已安装:{name} · {version}"},
"app.hosted-packs.syncing": {"message": "正在比对文件并同步变动…"},
"app.hosted-packs.complete": {"message": "已同步至 {version},变更 {count} 个文件,下载 {size} MiB。"},
"app.hosted-packs.preserved": {"message": "保留了 {count} 个本地修改或个人文件"},
"app.hosted-packs.empty": {"message": "暂无审核通过的整合包。"},
"app.hosted-packs.update": {"message": "立即同步"},
"app.hosted-packs.install": { "message": "重试自动安装" },
"app.home.luck.title": { "message": "每日幸运指数" },
"app.home.luck.get": { "message": "一键获取" },
"app.home.luck.login-to-get": { "message": "登录后获取" },
"app.home.luck.loading": { "message": "正在询问小精灵..." },
"app.home.luck.error": { "message": "小精灵暂时没有回应,请稍后重试。" },
"app.home.luck.out-of": { "message": "满分 100" },
"minecraft-account.skin-site.signed-in": { "message": "已登录 StarLight皮肤站" },
"minecraft-account.skin-site.checking": { "message": "正在验证皮肤站登录状态…" },
"minecraft-account.skin-site.sync-error": { "message": "暂时无法验证皮肤站登录状态,将自动重试。" },
"minecraft-account.add-skin-game-account": { "message": "添加皮肤站游戏账号" },
"minecraft-account.skin-site.sync-error": {
"message": "暂时无法验证皮肤站登录状态,将自动重试。"
},
"minecraft-account.skin-site.players": { "message": "皮肤站玩家({count}" },
"minecraft-account.skin-site.players.retry": { "message": "重试" },
"minecraft-account.skin-site.players.empty": { "message": "这个皮肤站账号还没有玩家。" },
"minecraft-account.skin-site.players.error": {
"message": "暂时无法刷新玩家列表,已保留上一次结果。"
},
"app.easteregg.color-mine.title": {
"message": "星光矿井:彩域"
},
@ -5073,6 +5152,9 @@
"app.lab.skin-editor.retry": {
"message": "重试"
},
"app.lab.skin-editor.resource-error": {
"message": "皮肤编辑器文件缺失或损坏,请重新安装启动器,或完整解压便携版。"
},
"app.lab.skin-editor.title": {
"message": "皮肤编辑器"
},
@ -5766,12 +5848,6 @@
"app.notification.warning": {
"message": "警告"
},
"app.onboarding.account.description": {
"message": "想登录、切换账号或看看个人资料,随时来这里。没人打卡。"
},
"app.onboarding.account.title": {
"message": "账号,随你安排"
},
"app.onboarding.action.click-create": {
"message": "点击创建新实例,继续"
},
@ -5808,33 +5884,12 @@
"app.onboarding.action.finish-area": {
"message": "点击任意位置,收工"
},
"app.onboarding.action.open-gradient-text": {
"message": "打开渐变文字生成器,继续"
},
"app.onboarding.action.open-recipe-generator": {
"message": "打开配方生成器,继续"
},
"app.onboarding.action.open-schematic-workshop": {
"message": "打开投影工坊,继续"
},
"app.onboarding.action.open-seed-map": {
"message": "打开种子地图,继续"
},
"app.onboarding.action.return-lab": {
"message": "点击实验室,继续"
},
"app.onboarding.action.skip": {
"message": "先不逛了"
},
"app.onboarding.action.start": {
"message": "带我逛逛"
},
"app.onboarding.ai.description": {
"message": "统一连接文本模型供应商、选择要使用的模型,也可在这里一键关闭所有 AI 功能。"
},
"app.onboarding.ai.title": {
"message": "连接你的 AI 供应商"
},
"app.onboarding.appearance.description": {
"message": "主题、强调色、背景和窗口效果都在这里。把启动器调成你熟悉的样子。"
},
@ -5913,26 +5968,14 @@
"app.onboarding.favorites.title": {
"message": "留个清单"
},
"app.onboarding.home-customize.description": {
"message": "使用右下角的编辑控件添加、调整和配置小组件。编辑时可在自动排满的网格与保留空格的自由网格之间切换。"
},
"app.onboarding.home-customize.title": {
"message": "按你的方式排列"
},
"app.onboarding.home-layout.description": {
"message": "使用右下角控件切换信息主页和极简主页;小组件编辑仅在信息主页中提供。"
"message": "使用右下角控件在 StarLight 皮肤站主页与专注的实例启动页之间切换。"
},
"app.onboarding.home-layout.title": {
"message": "切换信息密度"
},
"app.onboarding.home-widgets.description": {
"message": "信息主页由最近活动、游玩时间、实例、世界和服务器小组件组成。窗口或账号侧边栏变化时,网格会自动重排。"
},
"app.onboarding.home-widgets.title": {
"message": "你的主页,你的布局"
"message": "切换主页样式"
},
"app.onboarding.instance-actions.description": {
"message": "从实例页头部启动、停止、修复、配置、导出或打开这个实例。"
"message": "从这里启动或配置实例。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
},
"app.onboarding.instance-actions.title": {
"message": "主操作台"
@ -5943,56 +5986,20 @@
"app.onboarding.instance-tabs.title": {
"message": "剩下的工作台"
},
"app.onboarding.java.description": {
"message": "启动 Minecraft 的 Java 运行环境在这。技术活,但它很听话。"
},
"app.onboarding.java.title": {
"message": "Java在后台"
},
"app.onboarding.lab-editor.description": {
"message": "编辑文本、选择颜色、实时预览,并复制适用于 Minecraft 配置的格式。"
},
"app.onboarding.lab-editor.title": {
"message": "在同一处编辑和复制"
},
"app.onboarding.lab-recipe-generator.description": {
"message": "选择 Java 版本、填充配方槽位,然后在本地复制或导出 JSON。"
},
"app.onboarding.lab-recipe-generator.title": {
"message": "制作数据包配方"
},
"app.onboarding.lab-schematic.description": {
"message": "打开本地 .litematic 或 .schem 文件也可以从已安装实例中选择。3D 工作区集中提供查看、测量、图层控制、材料统计和本地编辑。"
},
"app.onboarding.lab-schematic.title": {
"message": "放置前先检查建筑"
},
"app.onboarding.lab-seed-map.description": {
"message": "输入种子或从实例载入,然后在本地地图中查看群系、结构与矿物图层。"
},
"app.onboarding.lab-seed-map.title": {
"message": "加载世界前先找到它"
},
"app.onboarding.lab-tools.description": {
"message": "生成格式文字、探索 Java 世界,并直接在启动器中检查投影建筑。"
"message": "在本地编辑皮肤、生成格式文字与配方、探索种子、检查投影并翻译模组。"
},
"app.onboarding.lab-tools.title": {
"message": "Minecraft 本地工具"
},
"app.onboarding.lab.description": {
"message": "实验室将本地 Minecraft 工具放进启动器,无需打开其他网站或注册额外账号。"
"message": "实验室 Minecraft 创作、世界与维护工具集中放在启动器里。"
},
"app.onboarding.lab.title": {
"message": "内置的实用工具"
},
"app.onboarding.language.description": {
"message": "在这里选择启动器语言和管理翻译。不需要翻译腔。"
},
"app.onboarding.language.title": {
"message": "说你的语言"
},
"app.onboarding.library-page.description": {
"message": "按整合包、服务器或自定义配置筛选,再打开任意实例管理。"
"message": "在全部实例、整合包和自定义配置之间切换,再打开任意实例进行管理。"
},
"app.onboarding.library-page.title": {
"message": "各就各位"
@ -6019,29 +6026,17 @@
"message": "按你的方式使用"
},
"app.onboarding.skins-page.description": {
"message": "在这里添加、预览、排序和应用皮肤。现在不登录也完全没关系。"
"message": "选择玩家资料,预览可用皮肤;账号支持皮肤管理时,还可以直接应用更改。"
},
"app.onboarding.skins-page.title": {
"message": "你的皮肤抽屉"
},
"app.onboarding.skins.description": {
"message": " Minecraft 皮肤放在一起管理。想登录的时候再登录。"
"message": "选择皮肤站玩家或 Minecraft 账号,然后预览并应用该玩家可用的皮肤。"
},
"app.onboarding.skins.title": {
"message": "换个造型吧"
},
"app.onboarding.translation.description": {
"message": "浏览时直接翻译 Modrinth 项目的标题、摘要和介绍。保留原文、双语同屏,或者让译文站 C 位,都由你决定。"
},
"app.onboarding.translation.title": {
"message": "翻译,是 Starlight 的拿手活"
},
"app.onboarding.updates.description": {
"message": "决定 Starlight 何时检查更新,以及是否替你安装。"
},
"app.onboarding.updates.title": {
"message": "保持在线"
},
"app.onboarding.welcome.description": {
"message": "实例、内容、世界和下载,现在都有了同一个家。正式开玩之前,先快速逛一圈。"
},
@ -6903,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": "全屏"
},
@ -7824,6 +7825,27 @@
"app.skins.section.tiny-takeover": {
"message": "小鬼当家"
},
"app.skins.skin-site-account.description": {
"message": "在下方选择皮肤后,可直接应用到 {player}。"
},
"app.skins.skin-site-account.empty-description": {
"message": "这个皮肤站账号暂时没有可用的玩家角色。"
},
"app.skins.skin-site-account.empty-title": {
"message": "暂无可用玩家"
},
"app.skins.skin-site-account.loading-description": {
"message": "启动器正在同步此账号绑定的玩家。"
},
"app.skins.skin-site-account.loading-title": {
"message": "正在加载皮肤站玩家……"
},
"app.skins.skin-site-account.mojang-description": {
"message": "{player} 是正版 Minecraft 玩家,其皮肤仍由正版账号服务管理。"
},
"app.skins.skin-site-account.title": {
"message": "StarLight 皮肤站玩家"
},
"app.skins.sign-in.axolotl-alt": {
"message": "Starlight Launcher"
},
@ -10227,8 +10249,11 @@
"minecraft-account.offline-mode": {
"message": "离线模式"
},
"minecraft-account.offline-mode.description": {
"message": "仅可使用离线账号并且只能启动已完整下载的实例。这通常是因为你的电脑无法连接到Mojang的认证服务器,请检查网络连接。如果正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。"
"minecraft-account.offline-mode.description.no-internet": {
"message": "看起来本机似乎并没有连接到互联网您目前只能启动已完整下载的实例且极可能无法连接到StarLight服务器,请检查网络连接。如果正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。"
},
"minecraft-account.offline-mode.description.server-unavailable": {
"message": "您目前网络正常但无法连接到StarLight验证服务器这可能是由于StarLight服务器正在维护或者您配置的代理存在问题。如果您正在使用代理请关闭或开启代理后点击下方按钮刷新连接状态若该操作不生效请前往StarLight交流群联系服务器管理确认服务器的维护状态。"
},
"minecraft-account.offline-mode.refresh": {
"message": "刷新连接状态"

View File

@ -1,8 +1,70 @@
{
"app.hosted-mods.title": { "message": "正在更新伺服器 Mod" },
"app.hosted-mods.description": { "message": "所有更新安裝完成後將繼續啟動遊戲。" },
"app.hosted-mods.complete": { "message": "下載完成" },
"app.hosted-mods.downloads": { "message": "查看下載" },
"app.hosted-mods.close": { "message": "關閉" },
"app.hosted-packs.progress.retry": { "message": "返回實例重試" },
"app.hosted-packs.progress.preparing": { "message": "正在準備整合包安裝…" },
"app.hosted-packs.progress.downloads": { "message": "查看下載" },
"app.hosted-packs.open-instance": { "message": "開啟已安裝實例" },
"app.hosted-packs.retry-install": { "message": "重試安裝" },
"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": "實例類型" },
"app.instance-mode.starlight": { "message": "StarLight 實例" },
"app.instance-mode.local": { "message": "本機實例" },
"app.instance-mode.starlight-description": { "message": "遊玩 StarLight 伺服器必選。自動安裝伺服器指定的整合包、遊戲版本和載入器。每次啟動需要登入 StarLight檢查並完成更新後再進入遊戲。" },
"app.instance-mode.local-description": { "message": "不會同步 StarLight 伺服器的變更。略過同步檢查,啟動更快,可自行選擇整合包安裝,更適合第三方伺服器與本機個人遊玩。" },
"app.instance-mode.saving": { "message": "正在套用實例類型,並視需要安裝伺服器整合包…" },
"app.instance-mode.loading": { "message": "正在讀取實例類型…" },
"app.instance-mode.retry": { "message": "重試" },
"app.instance-mode.local-packs-title": { "message": "本機整合包" },
"app.instance-mode.local-packs": { "message": "可以自行選擇整合包並安裝為本機實例。切換為本機後保留現有遊戲檔案和存檔,之後不再同步 StarLight 的變更。" },
"app.instance-mode.browse": { "message": "瀏覽整合包" },
"app.instance-mode.import": { "message": "匯入為本機實例" },
"app.hosted-packs.management": {"message": "內容管理"},
"app.hosted-packs.mods": {"message": "管理 Mod"},
"app.hosted-packs.packs": {"message": "管理整合包"},
"app.hosted-packs.title": { "message": "伺服器管理的整合包" },
"app.hosted-packs.description": { "message": "整合包和版本由管理員指定。每次啟動都會檢查伺服器更新,有變更時先下載並安裝,再啟動遊戲。需要有效的 StarLight 登入狀態和網路連線。" },
"app.hosted-packs.refresh": {"message": "重新整理"},
"app.hosted-packs.loading": {"message": "正在取得已發布整合包…"},
"app.hosted-packs.bound": {"message": "已安裝:{name} · {version}"},
"app.hosted-packs.syncing": {"message": "正在比對檔案並同步變動…"},
"app.hosted-packs.complete": {"message": "已同步至 {version},變更 {count} 個檔案,下載 {size} MiB。"},
"app.hosted-packs.preserved": {"message": "保留了 {count} 個本機修改或個人檔案"},
"app.hosted-packs.empty": {"message": "尚無審核通過的整合包。"},
"app.hosted-packs.update": {"message": "立即同步"},
"app.hosted-packs.install": { "message": "重試自動安裝" },
"app.home.luck.title": { "message": "每日幸運指數" },
"app.home.luck.get": { "message": "一鍵獲取" },
"app.home.luck.login-to-get": { "message": "登入後獲取" },
"app.home.luck.loading": { "message": "正在詢問小精靈..." },
"app.home.luck.error": { "message": "小精靈暫時沒有回應,請稍後重試。" },
"app.home.luck.out-of": { "message": "滿分 100" },
"minecraft-account.skin-site.signed-in": { "message": "已登入 StarLight皮膚站" },
"minecraft-account.skin-site.checking": { "message": "正在驗證皮膚站登入狀態…" },
"minecraft-account.skin-site.sync-error": { "message": "暫時無法驗證皮膚站登入狀態,將自動重試。" },
"minecraft-account.add-skin-game-account": { "message": "新增皮膚站遊戲帳號" },
"minecraft-account.skin-site.sync-error": {
"message": "暫時無法驗證皮膚站登入狀態,將自動重試。"
},
"minecraft-account.skin-site.players": { "message": "皮膚站玩家({count}" },
"minecraft-account.skin-site.players.retry": { "message": "重試" },
"minecraft-account.skin-site.players.empty": { "message": "這個皮膚站帳號還沒有玩家。" },
"minecraft-account.skin-site.players.error": {
"message": "暫時無法重新整理玩家清單,已保留上一次結果。"
},
"app.easteregg.color-mine.title": {
"message": "星光礦井:彩域"
},
@ -5487,12 +5549,6 @@
"app.notification.warning": {
"message": "警告"
},
"app.onboarding.account.description": {
"message": "想登錄、切換帳號或看看個人資料,隨時來這裡。沒人打卡。"
},
"app.onboarding.account.title": {
"message": "帳號,隨你安排"
},
"app.onboarding.action.click-create": {
"message": "點擊創建新實例,繼續"
},
@ -5529,33 +5585,12 @@
"app.onboarding.action.finish-area": {
"message": "點擊任意位置,收工"
},
"app.onboarding.action.open-gradient-text": {
"message": "打開漸變文字生成器,繼續"
},
"app.onboarding.action.open-recipe-generator": {
"message": "開啟配方生成器,繼續"
},
"app.onboarding.action.open-schematic-workshop": {
"message": "開啟投影工坊,繼續"
},
"app.onboarding.action.open-seed-map": {
"message": "打開種子地圖,繼續"
},
"app.onboarding.action.return-lab": {
"message": "點擊實驗室,查看另一個項目"
},
"app.onboarding.action.skip": {
"message": "先不逛了"
},
"app.onboarding.action.start": {
"message": "帶我逛逛"
},
"app.onboarding.ai.description": {
"message": "統一連線文字模型供應商、選擇要使用的模型,也可在這裡一鍵關閉所有 AI 功能。"
},
"app.onboarding.ai.title": {
"message": "連線你的 AI 供應商"
},
"app.onboarding.appearance.description": {
"message": "Theme, accent, background, and window behavior. Make this launcher look familiar."
},
@ -5634,23 +5669,11 @@
"app.onboarding.favorites.title": {
"message": "留個清單"
},
"app.onboarding.home-customize.description": {
"message": "使用右下角的編輯控制元件新增、調整和配置小元件。編輯時可在自動排滿的網格與保留空格的自由網格之間切換。"
},
"app.onboarding.home-customize.title": {
"message": "按你的方式排列"
},
"app.onboarding.home-layout.description": {
"message": "隨時使用這個懸浮按鈕,在資訊首頁和極簡首頁之間切換。"
"message": "使用右下角控制項,在 StarLight 皮膚站首頁與專注的實例啟動頁之間切換。"
},
"app.onboarding.home-layout.title": {
"message": "切換資訊密度"
},
"app.onboarding.home-widgets.description": {
"message": "資訊主頁由最近活動、遊玩時間、例項、世界和伺服器小元件組成。視窗或賬號側邊欄變化時,網格會自動重排。"
},
"app.onboarding.home-widgets.title": {
"message": "你的主頁,你的佈局"
"message": "切換首頁樣式"
},
"app.onboarding.instance-actions.description": {
"message": "從實例頁頭部啟動、停止、修復、配置、導出或打開這個實例。"
@ -5664,56 +5687,20 @@
"app.onboarding.instance-tabs.title": {
"message": "剩下的工作台"
},
"app.onboarding.java.description": {
"message": "啟動 Minecraft 的 Java 運行環境在這。技術活,但它很聽話。"
},
"app.onboarding.java.title": {
"message": "Java在後台"
},
"app.onboarding.lab-editor.description": {
"message": "編輯文本、選擇顏色、即時預覽,並複製適用於 Minecraft 配置的格式。"
},
"app.onboarding.lab-editor.title": {
"message": "在同一處編輯和複製"
},
"app.onboarding.lab-recipe-generator.description": {
"message": "選擇 Java 版本、填充配方槽位,然後在本地複製或匯出 JSON。"
},
"app.onboarding.lab-recipe-generator.title": {
"message": "製作資料包配方"
},
"app.onboarding.lab-schematic.description": {
"message": "開啟本地 .litematic 或 .schem 檔案也可以從已安裝例項中選擇。3D 工作區集中提供檢視、測量、圖層控制、材料統計和本地編輯。"
},
"app.onboarding.lab-schematic.title": {
"message": "放置前先檢查建築"
},
"app.onboarding.lab-seed-map.description": {
"message": "輸入種子或從實例載入,然後在本地地圖中查看群系、結構與礦物圖層。"
},
"app.onboarding.lab-seed-map.title": {
"message": "載入世界前先找到它"
},
"app.onboarding.lab-tools.description": {
"message": "生成格式的漸變文字,或用本地種子地圖探索 Java 世界。"
"message": "在本機編輯皮膚、生成格式文字與配方、探索種子、檢查投影並翻譯模組。"
},
"app.onboarding.lab-tools.title": {
"message": "首批兩個項目"
"message": "Minecraft 本機工具"
},
"app.onboarding.lab.description": {
"message": "實驗室將本地 Minecraft 工具放進啟動器,無需打開其他網站或註冊額外帳號。"
"message": "實驗室 Minecraft 創作、世界與維護工具集中放在啟動器裡。"
},
"app.onboarding.lab.title": {
"message": "內建的實用工具"
},
"app.onboarding.language.description": {
"message": "在這裡選擇啟動器語言和管理翻譯。不需要翻譯腔。"
},
"app.onboarding.language.title": {
"message": "說你的語言"
},
"app.onboarding.library-page.description": {
"message": "按整合包、伺服器或自訂配置篩選,再打開任意實例管理。"
"message": "在全部實例、整合包與自訂配置之間切換,再開啟任意實例進行管理。"
},
"app.onboarding.library-page.title": {
"message": "各就各位"
@ -5740,29 +5727,17 @@
"message": "按你的方式使用"
},
"app.onboarding.skins-page.description": {
"message": "在這裡添加、預覽、排序和應用皮膚。現在不登錄也完全沒關係。"
"message": "選擇玩家資料並預覽可用皮膚;帳號支援皮膚管理時,還可以直接套用變更。"
},
"app.onboarding.skins-page.title": {
"message": "你的皮膚抽屜"
},
"app.onboarding.skins.description": {
"message": " Minecraft 皮膚放在一起管理。想登錄的時候再登錄。"
"message": "選擇皮膚站玩家或 Minecraft 帳號,然後預覽並套用該玩家可用的皮膚。"
},
"app.onboarding.skins.title": {
"message": "換個造型吧"
},
"app.onboarding.translation.description": {
"message": "瀏覽時直接翻譯 Modrinth 項目的標題、摘要和介紹。保留原文、雙語同屏,或者讓譯文站 C 位,都由你決定。"
},
"app.onboarding.translation.title": {
"message": "翻譯,是 Starlight 的拿手活"
},
"app.onboarding.updates.description": {
"message": "決定 Starlight 何時檢查更新,以及是否替你安裝。"
},
"app.onboarding.updates.title": {
"message": "保持在線"
},
"app.onboarding.welcome.description": {
"message": "實例、內容、世界和下載,現在都有了同一個家。正式開玩之前,先快速逛一圈。"
},
@ -6600,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": "全螢幕"
},
@ -7515,6 +7496,27 @@
"app.skins.section.tiny-takeover": {
"message": "小鬼當家"
},
"app.skins.skin-site-account.description": {
"message": "在下方選擇皮膚後,可直接套用到 {player}。"
},
"app.skins.skin-site-account.empty-description": {
"message": "這個皮膚站帳號暫時沒有可用的玩家角色。"
},
"app.skins.skin-site-account.empty-title": {
"message": "暫無可用玩家"
},
"app.skins.skin-site-account.loading-description": {
"message": "啟動器正在同步此帳號綁定的玩家。"
},
"app.skins.skin-site-account.loading-title": {
"message": "正在載入皮膚站玩家……"
},
"app.skins.skin-site-account.mojang-description": {
"message": "{player} 是正版 Minecraft 玩家,其皮膚仍由正版帳號服務管理。"
},
"app.skins.skin-site-account.title": {
"message": "StarLight 皮膚站玩家"
},
"app.skins.sign-in.axolotl-alt": {
"message": "Starlight Launcher"
},
@ -9873,8 +9875,11 @@
"minecraft-account.offline-mode": {
"message": "離線模式"
},
"minecraft-account.offline-mode.description": {
"message": "僅可使用離線帳號,並且只能啟動已完整下載的實例。"
"minecraft-account.offline-mode.description.no-internet": {
"message": "看起來本機似乎並未連線到網際網路,您目前只能啟動已完整下載的實例,且極可能無法連線到 StarLight 伺服器,請檢查網路連線。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態。"
},
"minecraft-account.offline-mode.description.server-unavailable": {
"message": "您目前網路正常,但無法連線到 StarLight 驗證伺服器;這可能是因為 StarLight 伺服器正在維護,或者您設定的代理存在問題。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態;若該操作沒有作用,請前往 StarLight 交流群組,聯絡伺服器管理員確認維護狀態。"
},
"minecraft-account.offline-mode.refresh": {
"message": "刷新連接狀態"

View File

@ -1,11 +1,49 @@
<script setup lang="ts">
import { FolderOpenIcon, LeftArrowIcon, SparklesIcon } from '@modrinth/assets'
import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
import { inject } from 'vue'
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'
const { formatMessage } = useVIntl()
const router = useRouter()
const { installing, installError, createdInstance, completed, acknowledge, install } =
useHostedCreation()
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)}/`)
if (!failure) acknowledge(instanceId)
else installError.value = failure.message
} catch (cause) {
installError.value = String(cause)
}
}
watch(
() => (completed.value ? createdInstance.value : undefined),
(instanceId) => {
if (instanceId) void openCompleted(instanceId)
},
{ immediate: true },
)
const showModal = inject<
(options?: {
@ -16,6 +54,21 @@ const showModal = inject<
>('showCreationModalWithOptions')
const messages = defineMessages({
openInstance: { id: 'app.hosted-packs.open-instance', defaultMessage: 'Open installed instance' },
autoInstall: {
id: 'app.hosted-packs.auto-install',
defaultMessage: 'Install the StarLight modpack',
},
autoDescription: {
id: 'app.hosted-packs.auto-description',
defaultMessage:
'Download and automatically install the modpack from StarLight to play on the StarLight server with one click.',
},
autoInstalling: {
id: 'app.hosted-packs.auto-installing',
defaultMessage: 'Downloading and installing the server modpack…',
},
retryInstall: { id: 'app.hosted-packs.retry-install', defaultMessage: 'Retry installation' },
title: {
id: 'create.title',
defaultMessage: 'Create Instance',
@ -56,7 +109,21 @@ const messages = defineMessages({
const navigateBack = () => router.push('/library')
function handleStartFresh() {
async function handleStartFresh() {
if (installing.value) return
if (instanceMode.value === 'starlight') {
if (completed.value && createdInstance.value) {
await openCompleted(createdInstance.value)
return
}
if (createdInstance.value) {
// A previous attempt already created the instance; retry in place.
await install()
return
}
promptHostedGameDir()
return
}
showModal?.({
skipSetupType: true,
initialMode: 'custom',
@ -74,8 +141,8 @@ function handleImportExisting() {
</script>
<template>
<div class="flex h-full w-full flex-col items-center justify-center p-6">
<div class="flex w-full max-w-2xl flex-col gap-6">
<div class="flex h-full w-full flex-col items-center overflow-y-auto p-6">
<div class="my-auto flex w-full max-w-2xl shrink-0 flex-col gap-6">
<div class="flex flex-col gap-2">
<h1 class="m-0 text-2xl font-bold text-contrast">
{{ formatMessage(messages.title) }}
@ -85,18 +152,43 @@ function handleImportExisting() {
</p>
</div>
<InstanceModeOptions
v-model="instanceMode"
:disabled="installing"
data-onboarding-id="creation-instance-mode"
/>
<div data-onboarding-id="creation-methods" class="flex flex-col gap-4 sm:flex-row">
<BigOptionButton
data-onboarding-id="creation-method-custom"
:data-onboarding-id="
instanceMode === 'starlight' ? 'creation-method-starlight' : 'creation-method-custom'
"
:disabled="installing"
:icon="SparklesIcon"
:title="formatMessage(messages.newTitle)"
:description="formatMessage(messages.newDescription)"
:title="
formatMessage(
instanceMode === 'starlight'
? installing
? messages.autoInstalling
: completed
? messages.openInstance
: createdInstance
? messages.retryInstall
: messages.autoInstall
: messages.newTitle,
)
"
:description="
formatMessage(
instanceMode === 'starlight' ? messages.autoDescription : messages.newDescription,
)
"
no-icon-box
@click="handleStartFresh"
/>
<BigOptionButton
data-onboarding-id="creation-method-import"
v-if="instanceMode === 'local'"
:icon="FolderOpenIcon"
:title="formatMessage(messages.importTitle)"
:description="formatMessage(messages.importDescription)"
@ -105,7 +197,13 @@ function handleImportExisting() {
/>
</div>
<p class="m-0 text-sm text-secondary">
<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">
{{ formatMessage(messages.pclHmclHint) }}
{{ ' ' }}
<RouterLink

View File

@ -72,22 +72,38 @@
</div>
<div class="min-w-0 flex-grow">
<div class="truncate font-semibold text-contrast">{{ bar.title || bar.message }}</div>
<div class="truncate text-sm text-secondary">{{ bar.message }}</div>
<div class="break-words text-sm text-secondary">{{ bar.message }}</div>
</div>
<TagItem>
<TagItem
v-if="['hosted_pack_sync', 'hosted_mod_download'].includes(bar.bar_type?.type ?? '')"
>StarLight</TagItem
>
<TagItem v-else>
<component :is="providerIcon(legacyProvider(bar))" />
{{ providerLabel(legacyProvider(bar)) }}
</TagItem>
<Badge color="orange" :type="statusLabel('running')" />
<Badge
:color="bar.bar_type?.error ? 'red' : 'orange'"
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')"
/>
</div>
<ProgressBar
v-if="!bar.bar_type?.error"
class="mt-4"
full-width
:progress="legacyPercent(bar)"
:max="100"
:label="formatMessage(messages.progress)"
show-progress
:waiting="!bar.total"
:show-progress="Boolean(bar.total)"
/>
<RouterLink
v-else
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')"
class="mt-3 inline-block text-brand hover:underline"
>
{{ formatMessage(messages.hostedRetry) }}
</RouterLink>
</Card>
<Card
@ -140,10 +156,7 @@
v-if="downloadDetails(job).length"
class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary"
>
<template
v-for="(metric, index) in downloadDetails(job)"
:key="`${index}-${metric}`"
>
<template v-for="(metric, index) in downloadDetails(job)" :key="`${index}-${metric}`">
<BulletDivider v-if="index > 0" />
<span>{{ metric }}</span>
</template>
@ -389,10 +402,10 @@
<Card v-else class="flex flex-1">
<EmptyState
class="my-auto"
:type="query ? 'no-search-result' : 'no-tasks'"
:heading="formatMessage(query ? messages.noResultsTitle : messages.emptyTitle)"
:type="hasFilters ? 'no-search-result' : 'no-tasks'"
:heading="formatMessage(hasFilters ? messages.noResultsTitle : messages.emptyTitle)"
:description="
formatMessage(query ? messages.noResultsDescription : messages.emptyDescription)
formatMessage(hasFilters ? messages.noResultsDescription : messages.emptyDescription)
"
/>
</Card>
@ -458,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,
@ -503,6 +517,10 @@ const focusedJobId = computed(() => focusedDownloadJobId(route.query.job))
const focusState = ref(createDownloadFocusState(focusedJobId.value))
const messages = defineMessages({
hostedRetry: {
id: 'app.hosted-packs.progress.retry',
defaultMessage: 'Return to the instance to retry',
},
newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' },
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
history: { id: 'app.downloads.history', defaultMessage: 'History' },
@ -727,10 +745,22 @@ const phaseMessages = defineMessages({
rolling_back: { id: 'app.downloads.phase.rolling-back', defaultMessage: 'Rolling back changes' },
} satisfies Record<InstallPhaseId, MessageDescriptor>)
const legacyDownloads = manager.legacyDownloads
const legacyDownloads = computed(() => {
const normalized = query.value.trim().toLowerCase()
return manager.legacyDownloads.value.filter((bar) => {
if (provider.value !== 'all' && legacyProvider(bar) !== provider.value) return false
return (
!normalized ||
[bar.title, bar.message, bar.bar_type?.instance_name, bar.bar_type?.file_name].some((value) =>
value?.toLowerCase().includes(normalized),
)
)
})
})
const historyJobs = manager.historyJobs
const providerOptions = [
'all',
'starlight',
'modrinth',
'curse_forge',
'minecraft',
@ -739,6 +769,12 @@ const providerOptions = [
'local',
]
const historyStatusOptions = ['all', 'succeeded', 'failed', 'interrupted', 'canceled']
const hasFilters = computed(
() =>
Boolean(query.value.trim()) ||
provider.value !== 'all' ||
(tab.value === 'history' && historyStatus.value !== 'all'),
)
const downloadTabs = computed(() => [
{
href: 'active',
@ -786,8 +822,9 @@ function displayIcon(icon: string) {
return /^(https?:|data:|blob:|asset:|tauri:)/.test(icon) ? icon : convertFileSrc(icon)
}
function providerLabel(value: InstallJobSnapshot['provider']) {
function providerLabel(value: InstallJobSnapshot['provider'] | 'starlight') {
return {
starlight: 'StarLight',
modrinth: 'Modrinth',
curse_forge: 'CurseForge',
minecraft: 'Minecraft',
@ -800,10 +837,10 @@ function providerLabel(value: InstallJobSnapshot['provider']) {
function providerFilterLabel(value: string) {
return value === 'all'
? formatMessage(messages.allSources)
: providerLabel(value as InstallJobSnapshot['provider'])
: providerLabel(value as InstallJobSnapshot['provider'] | 'starlight')
}
function providerIcon(value: InstallJobSnapshot['provider']) {
function providerIcon(value: InstallJobSnapshot['provider'] | 'starlight') {
return value === 'curse_forge'
? CurseForgeIcon
: value === 'modrinth'
@ -821,7 +858,9 @@ function jobTypeIcon(job: InstallJobSnapshot) {
return job.kind === 'upgrade_unmanaged_instance' ? RefreshCwIcon : providerIcon(job.provider)
}
function legacyProvider(bar: LoadingBar): InstallJobSnapshot['provider'] {
function legacyProvider(bar: LoadingBar): InstallJobSnapshot['provider'] | 'starlight' {
if (['hosted_pack_sync', 'hosted_mod_download'].includes(bar.bar_type?.type ?? ''))
return 'starlight'
if (bar.bar_type?.type === 'pack_download' || bar.bar_type?.type === 'pack_file_download')
return 'curse_forge'
if (bar.bar_type?.type === 'minecraft_download') return 'minecraft'
@ -1254,14 +1293,12 @@ async function resolveMissing(job: InstallJobSnapshot) {
(item) =>
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
)
.map(
(item): CurseForgeManualDownloadItem => ({
projectId: Number(item.project_id),
fileId: Number(item.version_id),
fileName: item.name,
websiteUrl: item.manual_url ?? undefined,
}),
)
.map((item): CurseForgeManualDownloadItem => ({
projectId: Number(item.project_id),
fileId: Number(item.version_id),
fileName: item.name,
websiteUrl: item.manual_url ?? undefined,
}))
const instanceId = job.instance_id
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {

View File

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

View File

@ -31,12 +31,23 @@ import { onBeforeRouteLeave, useRouter } from 'vue-router'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
selectedSkinSitePlayerId,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { check_reachable, get_default_user, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
import {
determineModelType,
equip_skin,
filterDefaultSkins,
filterSavedSkins,
@ -44,6 +55,7 @@ import {
flush_pending_skin_change_for_profile,
get_available_capes,
get_available_skins,
get_default_skins,
get_normalized_skin_texture,
normalize_skin_texture,
remove_custom_skin,
@ -179,6 +191,22 @@ const messages = defineMessages({
id: 'app.skins.sign-in.button',
defaultMessage: 'Sign In',
},
loadingSkinSitePlayersTitle: {
id: 'app.skins.skin-site-account.loading-title',
defaultMessage: 'Loading skin site players…',
},
loadingSkinSitePlayersDescription: {
id: 'app.skins.skin-site-account.loading-description',
defaultMessage: 'The launcher is synchronizing the players attached to your account.',
},
noSkinSitePlayersTitle: {
id: 'app.skins.skin-site-account.empty-title',
defaultMessage: 'No players available',
},
noSkinSitePlayersDescription: {
id: 'app.skins.skin-site-account.empty-description',
defaultMessage: 'This skin site account does not have an available player profile yet.',
},
offlineCompatibility: {
id: 'app.skins.offline-account.compatibility',
defaultMessage:
@ -197,6 +225,19 @@ const messages = defineMessages({
defaultMessage:
'Skins for this account are managed by its Yggdrasil provider. Open the provider website to change skins or capes.',
},
skinSiteManagementTitle: {
id: 'app.skins.skin-site-account.title',
defaultMessage: 'StarLight skin site player',
},
skinSiteManagementDescription: {
id: 'app.skins.skin-site-account.description',
defaultMessage: 'Select a skin below and apply it directly to {player}.',
},
skinSiteMojangDescription: {
id: 'app.skins.skin-site-account.mojang-description',
defaultMessage:
'{player} is an official Minecraft player. Its skin remains managed by the official account provider.',
},
savedTab: {
id: 'app.skins.tabs.saved',
defaultMessage: 'Saved skins',
@ -228,7 +269,16 @@ const currentUser = ref(undefined)
const currentUserId = ref<string | undefined>(undefined)
const currentAccountType = ref<'microsoft' | 'offline' | 'yggdrasil' | undefined>(undefined)
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const activeSkinSitePlayer = computed(() =>
selectedSkinSitePlayerId.value
? skinSitePlayers.value.find((player) => player.uuid === selectedSkinSitePlayerId.value)
: undefined,
)
const isSkinSiteProfile = computed(() => Boolean(activeSkinSitePlayer.value && skinSiteUser.value))
const hasCurrentProfile = computed(() => Boolean(currentUser.value || isSkinSiteProfile.value))
const username = computed(
() => activeSkinSitePlayer.value?.name ?? currentUser.value?.profile?.name ?? undefined,
)
const selectedSkin = ref<Skin | null>(null)
const isApplyingSkin = ref(false)
@ -314,10 +364,16 @@ const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
const isSkinManagementReadOnly = computed(
() =>
currentAccountType.value === 'yggdrasil' ||
(currentAccountType.value !== 'offline' &&
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value))),
() => {
if (isSkinSiteProfile.value) {
return activeSkinSitePlayer.value?.isMojang === true || skinSiteStatus.value !== 'signed-in'
}
return (
currentAccountType.value === 'yggdrasil' ||
(currentAccountType.value !== 'offline' &&
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value)))
)
},
)
const hasPendingSkinChange = computed(
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
@ -356,7 +412,7 @@ function confirmDeleteSkin(skin: Skin) {
}
async function deleteSkin() {
if (isSkinManagementReadOnly.value) return
if (isSkinManagementReadOnly.value || isSkinSiteProfile.value) return
const deletedSkin = skinToDelete.value
if (!deletedSkin) return
@ -372,10 +428,17 @@ async function deleteSkin() {
}
async function loadCapes() {
if (isSkinSiteProfile.value) {
capes.value = []
return
}
const profileId = currentUserId.value
try {
capes.value = (await get_available_capes()) ?? []
const loadedCapes = (await get_available_capes()) ?? []
if (isSkinSiteProfile.value || currentUserId.value !== profileId) return
capes.value = loadedCapes
} catch (error) {
if (currentUser.value && error instanceof Error) {
if (hasCurrentProfile.value && error instanceof Error) {
handleError(error)
}
}
@ -383,7 +446,36 @@ async function loadCapes() {
async function loadSkins() {
try {
if (isSkinSiteProfile.value) {
const player = activeSkinSitePlayer.value
if (!player) return
const playerId = player.uuid
const bundledSkins = (await get_default_skins()).map((skin) => ({
...skin,
is_equipped: false,
}))
if (activeSkinSitePlayer.value?.uuid !== playerId) return
const currentSkin: Skin | null =
player.skinState === 'ready' && player.skinDataUrl
? {
texture_key: `starlight:${player.uuid}:current`,
name: player.name,
variant: player.model === 'slim' ? 'SLIM' : 'CLASSIC',
texture: player.skinDataUrl,
source: 'custom_external',
is_equipped: true,
}
: null
skins.value = currentSkin ? [currentSkin, ...bundledSkins] : bundledSkins
generateSkinPreviews(skins.value, [])
selectedSkin.value = currentSkin
originalSelectedSkin.value = currentSkin
return
}
const profileId = currentUserId.value
const loadedSkins = (await get_available_skins()) ?? []
if (isSkinSiteProfile.value || currentUserId.value !== profileId) return
const loadedEquippedSkin = loadedSkins.find((s) => s.is_equipped)
const locallyKnownEquippedSkin =
originalSelectedSkin.value &&
@ -404,7 +496,7 @@ async function loadSkins() {
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value
} catch (error) {
if (currentUser.value && error instanceof Error) {
if (hasCurrentProfile.value && error instanceof Error) {
handleError(error)
}
}
@ -612,6 +704,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
}
async function reorderSavedSkins(orderedSkins: Skin[]) {
if (isSkinSiteProfile.value) return
const previousSkins = skins.value
const previousSelectedSkin = selectedSkin.value
const previousOriginalSelectedSkin = originalSelectedSkin.value
@ -733,6 +826,19 @@ async function applySelectedSkin() {
isApplyingSkin.value = true
try {
if (isSkinSiteProfile.value) {
const player = activeSkinSitePlayer.value
if (!player) return
const textureDataUrl = await get_normalized_skin_texture(skinToApply)
await requestSkinSiteSkinUpdate(
player.uuid,
textureDataUrl,
skinToApply.variant === 'SLIM' ? 'slim' : 'default',
)
await requestSkinSitePlayers()
await loadSkins()
return
}
await equip_skin(skinToApply)
setLocallyEquippedSkin(skinToApply)
schedulePendingSkinRefresh()
@ -776,7 +882,7 @@ async function loadCurrentUser() {
currentAccountType.value = selectedAccount?.account_type
currentUser.value = selectedAccount
} catch (e) {
handleError(e as Error)
if (!isSkinSiteProfile.value) handleError(e as Error)
currentUser.value = undefined
currentUserId.value = undefined
currentAccountType.value = undefined
@ -794,14 +900,22 @@ watch(accountChangeRevision, (revision, previousRevision) => {
void refreshSelectedAccount()
})
watch(
() => [selectedSkinSitePlayerId.value, activeSkinSitePlayer.value?.skinDataUrl] as const,
() => {
void loadCapes()
void loadSkins()
},
)
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return skinBlobUrlMap.get(key)
}
async function login() {
if (offline.value) return
accountsCard.value?.login()
async function loginToSkinSite() {
openSkinSiteLogin()
await router.push('/')
}
function openAddSkinFileBrowser() {
@ -877,7 +991,6 @@ async function onAddSkinDrop(event: DragEvent) {
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
if (isSkinManagementReadOnly.value) return
const fakeEvent = new MouseEvent('click')
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
try {
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
@ -885,6 +998,24 @@ async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
original: originalSkinTexUrl,
normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
}
if (isSkinSiteProfile.value) {
const variant = await determineModelType(skinTexUrl.normalized)
const pendingSkin: Skin = {
texture_key: `starlight-upload:${Date.now()}`,
name: username.value,
variant,
texture: skinTexUrl.normalized,
source: 'custom_external',
is_equipped: false,
}
skins.value = [pendingSkin, ...skins.value.filter((skin) => skin.source === 'default')]
selectedSkin.value = pendingSkin
skinListTab.value = 'saved'
generateSkinPreviews(skins.value, [])
return
}
const fakeEvent = new MouseEvent('click')
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
} catch (error) {
handleError(error as Error)
@ -984,6 +1115,7 @@ onUnmounted(() => {
})
async function checkUserChanges() {
if (isSkinSiteProfile.value) return
try {
const defaultId = await get_default_user(offline.value)
if (defaultId !== currentUserId.value) {
@ -1047,9 +1179,26 @@ await loadSkins()
</p>
</section>
</Teleport>
<Teleport v-if="isSkinSiteProfile" to="#sidebar-default-teleport-target">
<section class="p-4">
<h3 class="m-0 text-base font-semibold text-primary">
{{ formatMessage(messages.skinSiteManagementTitle) }}
</h3>
<p class="mb-0 mt-2 text-sm leading-6 text-secondary">
{{
formatMessage(
activeSkinSitePlayer?.isMojang
? messages.skinSiteMojangDescription
: messages.skinSiteManagementDescription,
{ player: activeSkinSitePlayer?.name ?? '' },
)
}}
</p>
</section>
</Teleport>
<div
v-if="currentUser"
v-if="hasCurrentProfile"
data-onboarding-id="skins-page"
class="skin-layout box-border min-h-full p-4"
>
@ -1100,7 +1249,7 @@ await loadSkins()
</button>
</div>
<button
v-else
v-else-if="!isSkinSiteProfile"
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="!selectedSkin || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
@ -1125,7 +1274,7 @@ await loadSkins()
}
"
/>
<ButtonStyled color="brand">
<ButtonStyled v-if="!isSkinSiteProfile" color="brand">
<button @click="router.push('/lab/skin-editor')">
<PlusIcon />
{{ formatMessage(messages.createSkinButton) }}
@ -1142,8 +1291,9 @@ await loadSkins()
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
:manage-saved-skins="!isSkinSiteProfile"
@select="changeSkin"
@edit="(skin, event) => editSkinModal?.show(event, skin)"
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@ -1183,20 +1333,37 @@ await loadSkins()
<div class="flex flex-col gap-5">
<h1 class="text-3xl font-extrabold m-0">
{{ formatMessage(messages.signInTitle) }}
{{
formatMessage(
skinSiteUser
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersTitle
: messages.noSkinSitePlayersTitle
: messages.signInTitle,
)
}}
</h1>
<p class="text-lg m-0">
{{ formatMessage(messages.signInDescription) }}
{{
formatMessage(
skinSiteUser
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersDescription
: messages.noSkinSitePlayersDescription
: messages.signInDescription,
)
}}
</p>
<ButtonStyled
v-if="!offline"
v-show="accountsCard"
color="brand"
:disabled="accountsCard.loginDisabled"
>
<button :disabled="accountsCard.loginDisabled" @click="login">
<LogInIcon v-if="!accountsCard.loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
<SpinnerIcon
v-if="
skinSiteUser &&
(skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle')
"
class="h-8 w-8 animate-spin text-brand"
/>
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
<button @click="loginToSkinSite">
<LogInIcon />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>

View File

@ -417,6 +417,7 @@ import {
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { useInstanceMode } from '@/composables/useInstanceMode'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
import { get_project_v3 } from '@/helpers/cache.js'
@ -514,6 +515,7 @@ const { offline } = useNetworkStatus()
const instance = ref<GameInstance>()
const instanceId = computed(() => instance.value?.id)
const instanceModeQuery = useInstanceMode(() => instance.value?.id ?? props.id)
const postUpgradeNoticeQuery = usePostUpgradeNotice(() => instance.value?.id ?? props.id)
const postUpgradeNotice = computed(() => postUpgradeNoticeQuery.data.value ?? null)
const symlinkWarning = useSymlinkWarningDismiss(instanceId)
@ -532,7 +534,9 @@ const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const canUpgradeInstance = computed(() =>
instance.value ? isUnmanagedUpgradeEligible(instance.value) : false,
instance.value && instanceModeQuery.data.value === 'local'
? isUnmanagedUpgradeEligible(instance.value)
: false,
)
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
@ -774,7 +778,6 @@ const startInstance = async (context: string) => {
launchElapsedTimer = undefined
loading.value = false
}
}
const stopInstance = async (context: string) => {

View File

@ -1,215 +1,220 @@
<template>
<ReadyTransition :pending="loading">
<template #pending>
<LoadingIndicator class="pt-4" />
</template>
<CollapsibleAdmonition
v-if="postUpgradeNotice?.warnings.length"
v-model="postUpgradeNoticeExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.postUpgradeNoticeTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">{{
postUpgradeNotice.warnings.length
}}</span>
</span>
<ModManagementSwitch>
<template #packs><HostedModpacks :instance-id="instance.id" /></template>
<ReadyTransition :pending="loading">
<template #pending>
<LoadingIndicator class="pt-4" />
</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ formatMessage(messages.postUpgradeNoticeBody) }}</p>
<div class="mt-3 flex justify-end">
<ButtonStyled color="orange" size="small">
<button type="button" @click="dismissPostUpgradeNotice">
{{ formatMessage(messages.ignoreAllPostUpgradeWarnings) }}
</button>
</ButtonStyled>
</div>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="skippedManualDownloads.length > 0"
v-model="manualWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.skippedFilesWarningTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
{{ skippedManualDownloads.length }}
<CollapsibleAdmonition
v-if="postUpgradeNotice?.warnings.length"
v-model="postUpgradeNoticeExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.postUpgradeNoticeTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">{{
postUpgradeNotice.warnings.length
}}</span>
</span>
</span>
</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ skippedFilesWarningBody }}</p>
<ul class="mb-0 mt-2 flex list-none flex-col gap-1 p-0">
<li
v-for="item in visibleSkippedManualDownloads"
:key="`${item.projectId}:${item.fileId}`"
class="min-w-0"
>
<button
class="inline-flex max-w-full cursor-pointer items-center gap-1 text-left font-semibold text-brand hover:underline"
@click="openManualCurseForgeDownload(item)"
>
<span class="truncate">{{ item.fileName }}</span>
<ExternalIcon class="size-4 shrink-0" />
</button>
</li>
</ul>
<p v-if="hiddenSkippedManualDownloadCount > 0" class="mb-0 mt-2 text-secondary">
{{
formatMessage(messages.skippedFilesWarningMore, {
count: hiddenSkippedManualDownloadCount,
})
}}
</p>
<div class="mt-3 flex justify-end">
<ButtonStyled color="orange" size="small">
<button @click="openManualCurseForgeResolver">
<FolderSearchIcon />
{{ formatMessage(messages.completeSkippedFiles) }}
</button>
</ButtonStyled>
</div>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="missingPackMembers.length > 0"
v-model="missingWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.missingFilesWarningTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
{{ missingPackMembers.length }}
</span>
</span>
</template>
<div class="bg-bg-orange px-4 pb-4 pt-3">
<p class="m-0 text-sm leading-6 text-secondary">
{{ formatMessage(messages.missingFilesWarningBody) }}
</p>
<ul class="m-0 mt-2 flex max-h-64 list-none flex-col gap-1 overflow-y-auto p-0">
<li
v-for="item in missingPackMembers"
:key="item.memberId ?? item.expectedRelativePath"
class="flex min-h-14 min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-surface-5/40"
>
<FileIcon class="size-5 shrink-0 text-brand-orange" aria-hidden="true" />
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate font-medium text-contrast" :title="item.expectedRelativePath">
{{ fileNameFromPath(item.expectedRelativePath) }}
</span>
<code class="truncate text-xs text-secondary" :title="item.expectedRelativePath">
{{ item.expectedRelativePath }}
</code>
</span>
<ButtonStyled size="small" type="highlight-colored-text" color="orange">
<button
type="button"
:disabled="!item.memberId || isInstanceBusy || isRestoringMissingPackMember(item)"
@click="restoreMissingPackMember(item)"
>
<SpinnerIcon
v-if="isRestoringMissingPackMember(item)"
class="animate-spin"
aria-hidden="true"
/>
<UndoIcon v-else aria-hidden="true" />
{{ formatMessage(messages.restoreMissingFile) }}
</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ formatMessage(messages.postUpgradeNoticeBody) }}</p>
<div class="mt-3 flex justify-end">
<ButtonStyled color="orange" size="small">
<button type="button" @click="dismissPostUpgradeNotice">
{{ formatMessage(messages.ignoreAllPostUpgradeWarnings) }}
</button>
</ButtonStyled>
</li>
</ul>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="contentWarnings.length > 0"
v-model="contentWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>{{ formatMessage(messages.contentRefreshWarningTitle) }}</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ formatMessage(messages.contentRefreshWarningBody) }}</p>
</div>
</CollapsibleAdmonition>
<ContentPageLayout @visible-items="handleVisibleItems">
<template #modals>
<ContentToggleDependenciesModal ref="toggleDependenciesModal" />
<DependencyGraphModal
ref="dependencyGraphModal"
:instance-id="props.instance.id"
:instance-name="props.instance.name"
:instance-icon-url="localContentIconUrl(props.instance.icon_path)"
/>
</div>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="skippedManualDownloads.length > 0"
v-model="manualWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.skippedFilesWarningTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
{{ skippedManualDownloads.length }}
</span>
</span>
</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ skippedFilesWarningBody }}</p>
<ul class="mb-0 mt-2 flex list-none flex-col gap-1 p-0">
<li
v-for="item in visibleSkippedManualDownloads"
:key="`${item.projectId}:${item.fileId}`"
class="min-w-0"
>
<button
class="inline-flex max-w-full cursor-pointer items-center gap-1 text-left font-semibold text-brand hover:underline"
@click="openManualCurseForgeDownload(item)"
>
<span class="truncate">{{ item.fileName }}</span>
<ExternalIcon class="size-4 shrink-0" />
</button>
</li>
</ul>
<p v-if="hiddenSkippedManualDownloadCount > 0" class="mb-0 mt-2 text-secondary">
{{
formatMessage(messages.skippedFilesWarningMore, {
count: hiddenSkippedManualDownloadCount,
})
}}
</p>
<div class="mt-3 flex justify-end">
<ButtonStyled color="orange" size="small">
<button @click="openManualCurseForgeResolver">
<FolderSearchIcon />
{{ formatMessage(messages.completeSkippedFiles) }}
</button>
</ButtonStyled>
</div>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="missingPackMembers.length > 0"
v-model="missingWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.missingFilesWarningTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
{{ missingPackMembers.length }}
</span>
</span>
</template>
<div class="bg-bg-orange px-4 pb-4 pt-3">
<p class="m-0 text-sm leading-6 text-secondary">
{{ formatMessage(messages.missingFilesWarningBody) }}
</p>
<ul class="m-0 mt-2 flex max-h-64 list-none flex-col gap-1 overflow-y-auto p-0">
<li
v-for="item in missingPackMembers"
:key="item.memberId ?? item.expectedRelativePath"
class="flex min-h-14 min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-surface-5/40"
>
<FileIcon class="size-5 shrink-0 text-brand-orange" aria-hidden="true" />
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate font-medium text-contrast" :title="item.expectedRelativePath">
{{ fileNameFromPath(item.expectedRelativePath) }}
</span>
<code class="truncate text-xs text-secondary" :title="item.expectedRelativePath">
{{ item.expectedRelativePath }}
</code>
</span>
<ButtonStyled size="small" type="highlight-colored-text" color="orange">
<button
type="button"
:disabled="!item.memberId || isInstanceBusy || isRestoringMissingPackMember(item)"
@click="restoreMissingPackMember(item)"
>
<SpinnerIcon
v-if="isRestoringMissingPackMember(item)"
class="animate-spin"
aria-hidden="true"
/>
<UndoIcon v-else aria-hidden="true" />
{{ formatMessage(messages.restoreMissingFile) }}
</button>
</ButtonStyled>
</li>
</ul>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="contentWarnings.length > 0"
v-model="contentWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>{{ formatMessage(messages.contentRefreshWarningTitle) }}</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ formatMessage(messages.contentRefreshWarningBody) }}</p>
</div>
</CollapsibleAdmonition>
<ContentPageLayout @visible-items="handleVisibleItems">
<template #modals>
<ContentToggleDependenciesModal ref="toggleDependenciesModal" />
<DependencyGraphModal
ref="dependencyGraphModal"
:instance-id="props.instance.id"
:instance-name="props.instance.name"
:instance-icon-url="localContentIconUrl(props.instance.icon_path)"
/>
<ShareModalWrapper
ref="shareModal"
:share-title="formatMessage(messages.shareTitle)"
:share-text="formatMessage(messages.shareText)"
:open-in-new-tab="false"
/>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[displayedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
:symlink-target="props.instance.symlink_target"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="instance.game_version"
:current-loader="instance.loader"
:current-version-id="
updatingModpack
? (instance.link?.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="true"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack ? displayedModpackProject?.icon_url : updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (displayedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@version-hover="handleVersionHover"
/>
</template>
</ContentPageLayout>
</ReadyTransition>
<ShareModalWrapper
ref="shareModal"
:share-title="formatMessage(messages.shareTitle)"
:share-text="formatMessage(messages.shareText)"
:open-in-new-tab="false"
/>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[displayedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
:symlink-target="props.instance.symlink_target"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="instance.game_version"
:current-loader="instance.loader"
:current-version-id="
updatingModpack
? (instance.link?.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="true"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack
? displayedModpackProject?.icon_url
: updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (displayedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@version-hover="handleVersionHover"
/>
</template>
</ContentPageLayout>
</ReadyTransition>
</ModManagementSwitch>
</template>
<script setup lang="ts">
@ -259,6 +264,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import DependencyGraphModal from '@/components/instance/dependencies/DependencyGraphModal.vue'
import HostedModpacks from '@/components/instance/HostedModpacks.vue'
import ModManagementSwitch from '@/components/instance/ModManagementSwitch.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import ContentToggleDependenciesModal from '@/components/ui/modal/ContentToggleDependenciesModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
@ -1401,8 +1408,7 @@ async function getUpdaterProjectVersions(
if (!versions) {
versions = (await get_project_versions(projectId).catch(() => null)) as
| Labrinth.Versions.v2.Version[]
| null
Labrinth.Versions.v2.Version[] | null
}
if (!versions && fetchError) {
@ -1636,7 +1642,6 @@ async function applyToggleDisableMod(mod: ContentItem, enabled: boolean) {
file_name: newFileName,
enabled: actualEnabled,
})
} catch (err) {
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
file_path: originalFilePath,
@ -1805,7 +1810,6 @@ async function removeMod(mod: ContentItem) {
await remove_content_entry(props.instance.id, contentId)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
}
} catch (err) {
handleError(err as Error)
} finally {
@ -1966,7 +1970,6 @@ async function updateProject(mod: ContentItem) {
try {
await update_content_entry(props.instance.id, contentId)
} catch (err) {
handleError(err as Error)
throw err
@ -1984,7 +1987,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
try {
await switch_content_entry_version(props.instance.id, contentId, version.id)
} catch (err) {
handleError(err as Error)
} finally {
@ -2750,8 +2752,8 @@ provideContentManager({
const instanceLink = props.instance.link
const projectPath =
instanceLink?.type === 'curseforge_modpack'
? `/project/curseforge/${instanceLink.project_id}`
: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`
? `/project/curseforge/${instanceLink.project_id}`
: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`
return {
project: displayedModpackProject.value ?? linkedModpackProject.value,

View File

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

View File

@ -1,8 +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 { download_request_listener, install_job_listener, loading_listener } from '@/helpers/events'
import {
download_request_listener,
install_job_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,
@ -21,6 +35,8 @@ import { progress_bars_list } from '@/helpers/state'
const activeStatuses = new Set(['queued', 'running', 'canceling', 'waiting_for_user'])
export const downloadBarTypes = new Set([
'hosted_mod_download',
'hosted_pack_sync',
'java_download',
'pack_file_download',
'pack_download',
@ -40,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>
@ -73,11 +90,14 @@ export interface DownloadManager {
export function createDownloadManager(handleError: (error: unknown) => void): DownloadManager {
const jobs = ref<InstallJobSnapshot[]>([])
const legacyDownloads = ref<LoadingBar[]>([])
const hostedFailures = createHostedDownloadFailures()
let started = false
let disposed = false
let unlistenJobs: (() => void) | null = null
let unlistenRequests: (() => void) | null = null
let unlistenLoading: (() => void) | null = null
let unlistenHostedAttempts: (() => void) | null = null
let unlistenInstances: (() => void) | null = null
let initializing = false
const pendingInitialUpdates: Array<
{ kind: 'job'; job: InstallJobSnapshot } | { kind: 'request'; update: DownloadRequestUpdate }
@ -86,6 +106,7 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
const pendingRequestUpdates: DownloadRequestUpdate[] = []
let requestFlushTimer: ReturnType<typeof setTimeout> | null = null
let legacyRefreshTimer: ReturnType<typeof setTimeout> | null = null
let legacyRefreshGeneration = 0
function persistManualDownloadsFromJob(job: InstallJobSnapshot) {
if (job.status !== 'waiting_for_user' && job.status !== 'succeeded') return
@ -263,16 +284,32 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
}
async function refreshLegacyDownloads() {
const generation = ++legacyRefreshGeneration
const bars = await progress_bars_list().catch((error) => {
handleError(error)
return {}
})
if (disposed || generation !== legacyRefreshGeneration) return
legacyDownloads.value = Object.values(bars)
.filter((bar) => !hostedFailures.isRetired(String(bar.loading_bar_uuid)))
.filter((bar) => downloadBarTypes.has(bar.bar_type?.type ?? ''))
.filter(
(bar) =>
!(
bar.bar_type?.type === 'hosted_pack_sync' &&
hostedFailures.has(bar.bar_type.instance_id ?? '')
),
)
.map((bar) => ({
...bar,
title: bar.title ?? bar.bar_type?.pack_name ?? bar.bar_type?.instance_name ?? bar.message,
title:
bar.title ??
bar.bar_type?.file_name ??
bar.bar_type?.pack_name ??
bar.bar_type?.instance_name ??
bar.message,
}))
legacyDownloads.value.push(...hostedFailures.values())
}
function scheduleLegacyRefresh() {
@ -283,15 +320,38 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
}, 300)
}
function clearHostedAttempt(instanceId: string) {
hostedFailures.begin(instanceId, legacyDownloads.value)
legacyRefreshGeneration++
legacyDownloads.value = legacyDownloads.value.filter(
(bar) => !hostedFailures.isRetired(String(bar.loading_bar_uuid)),
)
}
async function start() {
if (started || disposed) return
started = true
unlistenHostedAttempts = onHostedPackAttemptStarted(clearHostedAttempt)
unlistenInstances = await instance_listener((event: { event: string; instance_id: string }) => {
if (event.event !== 'removed') return
forgetHostedCreation(event.instance_id)
clearHostedAttempt(event.instance_id)
})
initializing = true
unlistenRequests = await download_request_listener((update: DownloadRequestUpdate) =>
updateRequest(update),
)
unlistenJobs = await install_job_listener((job: InstallJobSnapshot) => setJob(job))
unlistenLoading = await loading_listener(() => scheduleLegacyRefresh())
unlistenLoading = await loading_listener(
(payload: {
fraction: number | null
loader_uuid: string
event: LoadingBar['bar_type']
}) => {
hostedFailures.update(payload)
scheduleLegacyRefresh()
},
)
await Promise.all([refresh(), refreshLegacyDownloads()])
initializing = false
for (const update of pendingInitialUpdates.splice(0)) {
@ -323,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) {
@ -396,12 +477,17 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
legacyDownloads,
activeJobs,
historyJobs,
activeCount: computed(() => activeJobs.value.length + legacyDownloads.value.length),
activeCount: computed(
() =>
activeJobs.value.length +
legacyDownloads.value.filter((bar) => !bar.bar_type?.error).length,
),
queuedCount: computed(() => jobs.value.filter((job) => job.status === 'queued').length),
start,
refresh,
cancel,
retry,
retryHosted,
resume,
skipMissingContent,
remove,
@ -428,6 +514,8 @@ export function createDownloadManager(handleError: (error: unknown) => void): Do
unlistenJobs?.()
unlistenRequests?.()
unlistenLoading?.()
unlistenHostedAttempts?.()
unlistenInstances?.()
},
}
}

View File

@ -276,7 +276,6 @@ export function setupCreationModal(
iconPath,
gameDirOverride,
}).catch(handleError)
} catch (err) {
handleError(err as Error)
}

View File

@ -111,6 +111,9 @@ fn main() {
"poll_device_login",
"begin_yggdrasil_login",
"finish_yggdrasil_login",
"login_skin_site_player",
"get_instance_player",
"set_instance_player",
"list_yggdrasil_saved_logins",
"get_yggdrasil_password",
"set_yggdrasil_password",
@ -320,6 +323,7 @@ fn main() {
.commands(&[
"get_available_capes",
"get_available_skins",
"get_default_skins",
"add_and_equip_custom_skin",
"equip_skin",
"remove_custom_skin",
@ -360,6 +364,13 @@ fn main() {
"install_cancel_import_plan",
"install_duplicate_instance",
"install_existing_instance",
"hosted_default",
"hosted_set_session",
"hosted_create",
"hosted_binding",
"hosted_sync",
"hosted_instance_mode",
"hosted_set_instance_mode",
"install_pack_to_existing_instance",
"install_job_list",
"install_job_get",

View File

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

View File

@ -31,11 +31,14 @@ 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()
}
/// Checks if the authentication servers are reachable.
/// Checks if the StarLight authentication server is reachable.
#[tauri::command]
pub async fn check_reachable() -> Result<()> {
minecraft_auth::check_reachable().await?;
@ -318,6 +321,33 @@ pub async fn finish_yggdrasil_login(
const YGGDRASIL_SAVED_LOGINS_KEY: &str = "yggdrasil-saved-logins";
#[tauri::command]
pub async fn get_instance_player(
instance_id: String,
) -> Result<Option<minecraft_auth::InstancePlayer>> {
Ok(minecraft_auth::get_instance_player(&instance_id).await?)
}
#[tauri::command]
pub async fn set_instance_player(
instance_id: String,
player: minecraft_auth::InstancePlayer,
) -> Result<()> {
Ok(minecraft_auth::set_instance_player(&instance_id, player).await?)
}
#[tauri::command]
pub async fn login_skin_site_player(
token: String,
player_id: uuid::Uuid,
user_id: String,
) -> Result<Credentials> {
Ok(
minecraft_auth::login_skin_site_player(&token, player_id, &user_id)
.await?,
)
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SavedYggdrasilLogin {
pub api_root: String,
@ -443,22 +473,24 @@ fn yggdrasil_saved_logins_entry() -> Result<keyring::Entry> {
fn read_yggdrasil_saved_logins() -> Result<Vec<SavedYggdrasilLogin>> {
match yggdrasil_saved_logins_entry()?.get_password() {
Ok(saved_logins) => match serde_json::from_str::<Vec<SavedYggdrasilLogin>>(
&saved_logins,
) {
Ok(saved_logins) => Ok(saved_logins
.into_iter()
.filter(|saved_login| {
saved_login.api_root == STARLIGHT_YGGDRASIL_API_ROOT
})
.collect()),
Err(error) => {
tracing::warn!(
"Ignoring an invalid saved Yggdrasil login index: {error}"
);
Ok(Vec::new())
Ok(saved_logins) => {
match serde_json::from_str::<Vec<SavedYggdrasilLogin>>(
&saved_logins,
) {
Ok(saved_logins) => Ok(saved_logins
.into_iter()
.filter(|saved_login| {
saved_login.api_root == STARLIGHT_YGGDRASIL_API_ROOT
})
.collect()),
Err(error) => {
tracing::warn!(
"Ignoring an invalid saved Yggdrasil login index: {error}"
);
Ok(Vec::new())
}
}
},
}
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
Err(error) => Err(keyring_error(error)),
}

View File

@ -14,6 +14,13 @@ use uuid::Uuid;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("install")
.invoke_handler(tauri::generate_handler![
hosted_default,
hosted_set_session,
hosted_create,
hosted_binding,
hosted_sync,
hosted_instance_mode,
hosted_set_instance_mode,
install_get_modpack_preview,
install_create_instance,
install_create_modpack_instance,
@ -48,9 +55,57 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.build()
}
#[tauri::command]
pub async fn hosted_set_session(token: Option<String>) -> Result<()> {
Ok(theseus::pack::hosted::set_session(token).await?)
}
#[tauri::command]
pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
Ok(theseus::pack::hosted::default_publication().await?)
}
#[tauri::command]
pub async fn hosted_create(
game_dir_root: Option<String>,
) -> Result<String> {
Ok(theseus::pack::hosted::create(game_dir_root).await?)
}
#[tauri::command]
pub async fn hosted_instance_mode(
instance_id: String,
) -> Result<theseus::data::InstanceMode> {
Ok(theseus::pack::hosted::instance_mode(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_set_instance_mode(
instance_id: String,
mode: theseus::data::InstanceMode,
) -> Result<()> {
Ok(theseus::pack::hosted::set_instance_mode(&instance_id, mode).await?)
}
#[tauri::command]
pub async fn hosted_binding(
instance_id: String,
) -> Result<Option<theseus::pack::hosted::Binding>> {
Ok(theseus::pack::hosted::binding(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_sync(
instance_id: String,
) -> Result<theseus::pack::hosted::SyncResult> {
Ok(theseus::pack::hosted::synchronize(&instance_id).await?)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallCreateInstanceRequest {
#[serde(default)]
pub instance_mode: theseus::data::InstanceMode,
pub name: String,
pub game_version: String,
pub loader: ModLoader,
@ -97,7 +152,15 @@ pub async fn install_get_modpack_preview(
pub async fn install_create_instance(
request: InstallCreateInstanceRequest,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::create_instance_with_adjuncts(
if request.instance_mode == theseus::data::InstanceMode::StarLight {
return Err(theseus::ErrorKind::InputError(
"StarLight 实例的版本由服务器决定,请使用官方整合包自动安装入口"
.into(),
)
.as_error()
.into());
}
let job = theseus::install::create_instance_with_adjuncts(
request.name.trim().to_string(),
request.game_version,
request.loader,
@ -110,7 +173,23 @@ pub async fn install_create_instance(
},
request.game_dir_override,
)
.await?)
.await?;
if let Some(id) = &job.instance_id {
theseus::instance::edit(
id,
theseus::data::EditInstance {
launch_overrides: Some(
theseus::data::InstanceLaunchOverridesPatch {
instance_mode: Some(request.instance_mode),
..Default::default()
},
),
..Default::default()
},
)
.await?;
}
Ok(job)
}
#[tauri::command]

View File

@ -462,6 +462,8 @@ 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,
custom_env_vars: edit_instance.custom_env_vars,

View File

@ -10,6 +10,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
get_available_capes,
get_available_skins,
get_default_skins,
add_and_equip_custom_skin,
equip_skin,
remove_custom_skin,
@ -40,6 +41,12 @@ pub async fn get_available_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_available_skins().await?)
}
/// `invoke('plugin:minecraft-skins|get_default_skins')`
#[tauri::command]
pub async fn get_default_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_default_skins())
}
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
///
/// See also: [minecraft_skins::add_and_equip_custom_skin]

View File

@ -354,7 +354,8 @@ unsafe extern "system" fn maximize_if_owned_by_process(
_: windows::Win32::Foundation::LPARAM,
) -> windows::core::BOOL {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE, SetForegroundWindow, ShowWindow,
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE,
SetForegroundWindow, ShowWindow,
};
use windows::core::BOOL;

View File

@ -77,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())
@ -88,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())
@ -125,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 {
@ -232,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)?
@ -670,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())
@ -688,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!(
@ -838,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,

View File

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

View File

@ -1,98 +1,508 @@
// Runs inside the embedded skin site only. The JWT never leaves that origin.
(() => {
if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return
// Only the allowlisted launcher parent may request a JWT for native pack downloads.
;(() => {
if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return
const launcherOrigins = new Set([
'http://localhost:5201',
'http://tauri.localhost',
'https://tauri.localhost',
'tauri://localhost',
])
let parentOrigin
let lastToken
let lastCheck = 0
let generation = 0
let pending
let snapshot = { status: 'checking', user: null }
const launcherOrigins = new Set([
'http://localhost:5201',
'http://tauri.localhost',
'https://tauri.localhost',
'tauri://localhost',
])
let parentOrigin
let lastToken
let lastCheck = 0
let generation = 0
let pending
const pendingLuck = new Map()
const pendingPlayers = new Map()
const pendingSkinUpdates = new Map()
let knownPlayers = new Map()
let snapshot = { status: 'checking', user: null }
function publish(status, user = null) {
snapshot = { status, user }
if (parentOrigin) {
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
}
}
function publish(status, user = null) {
snapshot = { status, user }
if (parentOrigin) {
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
}
}
async function checkSession(force = false) {
if (!parentOrigin) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publish('error')
return
}
const changed = token !== lastToken
if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return
const revision = ++generation
pending?.abort()
pending = undefined
lastToken = token
lastCheck = Date.now()
if (!token) {
publish('signed-out')
return
}
if (changed) publish('checking')
const controller = new AbortController()
pending = controller
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch('/starlight/user', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
})
const body = response.ok ? await response.json() : null
// A logout or account switch must win over an older in-flight response.
if (revision !== generation || localStorage.getItem('loginToken') !== token) return
if (response.status === 401 || response.status === 403 || body?.payload?.banned) {
publish('signed-out')
} else if (
response.ok && typeof body?.payload?.uuid === 'string' &&
typeof body.payload.username === 'string' && body.payload.username.length > 0
) {
publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username })
} else {
publish('error')
}
} catch {
if (revision === generation) publish('error')
} finally {
clearTimeout(timeout)
if (revision === generation) pending = undefined
}
}
function publishLuck(requestId, result) {
if (!parentOrigin) return
window.parent.postMessage(
{ type: 'starlight-skin-luck-result', requestId, ...result },
parentOrigin,
)
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return
if (event.data?.type !== 'starlight-skin-session-connect') return
parentOrigin = event.origin
publish(snapshot.status, snapshot.user)
void checkSession(true)
})
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
window.addEventListener('storage', () => void checkSession())
let timer = setInterval(() => void checkSession(), 1000)
window.addEventListener('pagehide', () => {
clearInterval(timer)
++generation
pending?.abort()
pending = undefined
})
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return
timer = setInterval(() => void checkSession(), 1000)
void checkSession(true)
})
function publishPlayers(requestId, result) {
if (!parentOrigin) return
window.parent.postMessage(
{ type: 'starlight-skin-players-result', requestId, ...result },
parentOrigin,
)
}
function publishSkinUpdate(requestId, result) {
if (!parentOrigin) return
window.parent.postMessage(
{ type: 'starlight-skin-update-result', requestId, ...result },
parentOrigin,
)
}
// Keep this geometry identical to the skin site's SkinRender.renderHead.
// In particular, the site does not infer texture dimensions before cropping.
// Rendering here keeps the source on the skin site's origin and only exposes
// the finished PNG to the launcher.
function renderPlayerHead(skinSource) {
return new Promise((resolve, reject) => {
const image = new Image()
const timeout = setTimeout(() => reject(new Error('Skin rendering timed out.')), 8_000)
image.crossOrigin = 'anonymous'
image.onerror = () => {
clearTimeout(timeout)
reject(new Error('Unable to load the skin texture.'))
}
image.onload = () => {
clearTimeout(timeout)
try {
const buffer = document.createElement('canvas')
buffer.width = 18
buffer.height = 18
const context = buffer.getContext('2d')
if (!context) throw new Error('Unable to create the skin renderer.')
context.imageSmoothingEnabled = false
context.drawImage(image, 8, 8, 8, 8, 1, 1, 16, 16)
context.globalCompositeOperation = 'source-over'
context.drawImage(image, 40, 8, 8, 8, 0, 0, 18, 18)
const output = document.createElement('canvas')
output.width = 36
output.height = 36
const outputContext = output.getContext('2d')
if (!outputContext) throw new Error('Unable to create the skin renderer.')
outputContext.imageSmoothingEnabled = false
outputContext.drawImage(buffer, 0, 0, 18, 18, 0, 0, 36, 36)
resolve(output.toDataURL('image/png'))
} catch (error) {
reject(error)
}
}
image.src = skinSource
})
}
function serializePlayerSkin(skinSource) {
return new Promise((resolve, reject) => {
const image = new Image()
const timeout = setTimeout(() => reject(new Error('Skin loading timed out.')), 8_000)
image.crossOrigin = 'anonymous'
image.onerror = () => {
clearTimeout(timeout)
reject(new Error('Unable to load the skin texture.'))
}
image.onload = () => {
clearTimeout(timeout)
try {
const width = image.naturalWidth || image.width
const height = image.naturalHeight || image.height
if (!width || !height) throw new Error('The skin texture is empty.')
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to create the skin renderer.')
context.imageSmoothingEnabled = false
context.drawImage(image, 0, 0)
const dataUrl = canvas.toDataURL('image/png')
if (dataUrl.length > 2_000_000) throw new Error('The skin texture is too large.')
resolve(dataUrl)
} catch (error) {
reject(error)
}
}
image.src = skinSource
})
}
function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve, delay))
}
function pngDataUrlToBlob(dataUrl) {
if (
typeof dataUrl !== 'string' ||
dataUrl.length > 2_000_000 ||
!/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(dataUrl)
)
throw new Error('Invalid skin texture.')
const bytes = atob(dataUrl.slice(dataUrl.indexOf(',') + 1))
const buffer = new Uint8Array(bytes.length)
for (let index = 0; index < bytes.length; index += 1) buffer[index] = bytes.charCodeAt(index)
return new Blob([buffer], { type: 'image/png' })
}
async function requestPlayers(requestId) {
if (pendingPlayers.has(requestId)) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publishPlayers(requestId, { ok: false, error: 'Unable to read the skin site session.' })
return
}
if (!token) {
publish('signed-out')
publishPlayers(requestId, { ok: false, error: 'Sign in to the skin site first.' })
return
}
const controller = new AbortController()
pendingPlayers.set(requestId, controller)
try {
const response = await fetch('/starlight/skin/player', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
})
const body = await response.json().catch(() => null)
if (localStorage.getItem('loginToken') !== token) {
publishPlayers(requestId, { ok: false, error: 'The skin site account changed.' })
return
}
if (response.status === 401 || response.status === 403) publish('signed-out')
const rawPlayers = Array.isArray(body?.payload) ? body.payload : null
const players = rawPlayers?.slice(0, 100).flatMap((player) => {
if (
typeof player?.uuid !== 'string' ||
!/^[0-9a-f-]{32,36}$/i.test(player.uuid) ||
typeof player?.name !== 'string' ||
player.name.length < 1 ||
player.name.length > 64
)
return []
return [{ uuid: player.uuid, name: player.name, isMojang: player.isMojang === true }]
})
if (response.ok && players) {
knownPlayers = new Map(players.map((player) => [player.uuid, player]))
// Match the skin site: stagger texture requests by 150 ms so the
// service is not hit with a burst that can drop individual players.
const skinResults = await Promise.all(
players.map(async (player, index) => {
if (index > 0) await sleep(index * 150)
try {
const skinResponse = await fetch(
`/starlight/skin/player/skin/${encodeURIComponent(player.uuid)}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
},
)
const skinBody = await skinResponse.json().catch(() => null)
if (!skinResponse.ok || !skinBody?.payload || typeof skinBody.payload !== 'object')
return { player, skinData: null }
return { player, skinData: skinBody.payload }
} catch {
return { player, skinData: null }
}
}),
)
const playersWithSkins = await Promise.all(
skinResults.map(async ({ player, skinData }) => {
if (!skinData) return { ...player, skinState: 'error' }
try {
const skinSource = skinData.skin
if (typeof skinSource !== 'string' || !skinSource.trim()) {
return { ...player, skinState: 'empty' }
}
const headDataUrl = await renderPlayerHead(skinSource.trim())
const skinDataUrl = await serializePlayerSkin(skinSource.trim()).catch(
() => undefined,
)
const model = skinData.model === 'slim' ? 'slim' : 'default'
return { ...player, skinState: 'ready', headDataUrl, skinDataUrl, model }
} catch {
return { ...player, skinState: 'error' }
}
}),
)
if (localStorage.getItem('loginToken') !== token) {
publishPlayers(requestId, { ok: false, error: 'The skin site account changed.' })
return
}
publishPlayers(requestId, { ok: true, players: playersWithSkins })
} else {
publishPlayers(requestId, {
ok: false,
error:
typeof body?.errorMessage === 'string' && body.errorMessage
? body.errorMessage.slice(0, 300)
: 'The skin site returned an invalid player list.',
})
}
} catch {
publishPlayers(requestId, { ok: false, error: 'Unable to reach the player service.' })
} finally {
pendingPlayers.delete(requestId)
}
}
async function updatePlayerSkin(requestId, playerId, textureDataUrl, model) {
if (pendingSkinUpdates.has(requestId)) return
const player = knownPlayers.get(playerId)
if (!player || player.isMojang) {
publishSkinUpdate(requestId, {
ok: false,
error: 'This skin site player cannot be changed.',
})
return
}
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publishSkinUpdate(requestId, { ok: false, error: 'Unable to read the skin site session.' })
return
}
if (!token) {
publish('signed-out')
publishSkinUpdate(requestId, { ok: false, error: 'Sign in to the skin site first.' })
return
}
const controller = new AbortController()
pendingSkinUpdates.set(requestId, controller)
const timeout = setTimeout(() => controller.abort(), 18_000)
try {
const texture = pngDataUrlToBlob(textureDataUrl)
const form = new FormData()
form.append('file', texture, `${playerId}.png`)
form.append('model', model)
const response = await fetch(
`/starlight/skin/player/skin/${encodeURIComponent(playerId)}/SKIN`,
{
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: form,
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
},
)
const body = await response.json().catch(() => null)
if (localStorage.getItem('loginToken') !== token) {
publishSkinUpdate(requestId, { ok: false, error: 'The skin site account changed.' })
return
}
if (response.status === 401 || response.status === 403) publish('signed-out')
if (response.ok && body?.success !== false) publishSkinUpdate(requestId, { ok: true })
else {
publishSkinUpdate(requestId, {
ok: false,
error:
typeof body?.errorMessage === 'string' && body.errorMessage
? body.errorMessage.slice(0, 300)
: 'The skin site rejected the skin update.',
})
}
} catch (error) {
publishSkinUpdate(requestId, {
ok: false,
error: error instanceof Error ? error.message : 'Unable to reach the skin service.',
})
} finally {
clearTimeout(timeout)
pendingSkinUpdates.delete(requestId)
}
}
async function requestLuck(requestId) {
if (pendingLuck.has(requestId)) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publishLuck(requestId, { ok: false, error: 'Unable to read the skin site session.' })
return
}
if (!token) {
publish('signed-out')
publishLuck(requestId, { ok: false, error: 'Sign in to the skin site first.' })
return
}
const controller = new AbortController()
pendingLuck.set(requestId, controller)
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch('/starlight/luck', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
})
const body = await response.json().catch(() => null)
if (localStorage.getItem('loginToken') !== token) {
publishLuck(requestId, { ok: false, error: 'The skin site account changed.' })
return
}
if (response.status === 401 || response.status === 403) publish('signed-out')
const luck = Number(body?.payload?.luck)
if (response.ok && Number.isFinite(luck) && luck >= 0 && luck <= 100) {
publishLuck(requestId, { ok: true, luck })
} else {
publishLuck(requestId, {
ok: false,
error:
typeof body?.errorMessage === 'string' && body.errorMessage
? body.errorMessage.slice(0, 300)
: 'The skin site returned an invalid luck result.',
})
}
} catch {
publishLuck(requestId, { ok: false, error: 'Unable to reach the luck service.' })
} finally {
clearTimeout(timeout)
pendingLuck.delete(requestId)
}
}
async function checkSession(force = false) {
if (!parentOrigin) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publish('error')
return
}
const changed = token !== lastToken
if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return
const revision = ++generation
pending?.abort()
pending = undefined
lastToken = token
if (changed) knownPlayers = new Map()
lastCheck = Date.now()
if (!token) {
publish('signed-out')
return
}
if (changed) publish('checking')
const controller = new AbortController()
pending = controller
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch('/starlight/user', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
})
const body = response.ok ? await response.json() : null
// A logout or account switch must win over an older in-flight response.
if (revision !== generation || localStorage.getItem('loginToken') !== token) return
if (response.status === 401 || response.status === 403 || body?.payload?.banned) {
publish('signed-out')
} else if (
response.ok &&
typeof body?.payload?.uuid === 'string' &&
typeof body.payload.username === 'string' &&
body.payload.username.length > 0
) {
publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username })
} else {
publish('error')
}
} catch {
if (revision === generation) publish('error')
} finally {
clearTimeout(timeout)
if (revision === generation) pending = undefined
}
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return
if (event.data?.type === 'starlight-skin-session-connect') {
parentOrigin = event.origin
publish(snapshot.status, snapshot.user)
void checkSession(true)
return
}
if (
event.data?.type === 'starlight-pack-token-request' &&
parentOrigin === event.origin &&
typeof event.data.requestId === 'string' &&
/^pack-token-\d+-\d+$/.test(event.data.requestId)
) {
let token = null
try {
token = localStorage.getItem('loginToken') || null
} catch {}
if (snapshot.status !== 'signed-in' || token !== lastToken) token = null
window.parent.postMessage(
{ type: 'starlight-pack-token-result', requestId: event.data.requestId, token },
parentOrigin,
)
return
}
if (
event.data?.type === 'starlight-skin-luck-request' &&
parentOrigin === event.origin &&
typeof event.data.requestId === 'string' &&
/^skin-luck-\d+-\d+$/.test(event.data.requestId)
) {
void requestLuck(event.data.requestId)
return
}
if (
event.data?.type === 'starlight-skin-players-request' &&
parentOrigin === event.origin &&
typeof event.data.requestId === 'string' &&
/^skin-players-\d+-\d+$/.test(event.data.requestId)
) {
void requestPlayers(event.data.requestId)
return
}
if (
event.data?.type === 'starlight-skin-update-request' &&
parentOrigin === event.origin &&
typeof event.data.requestId === 'string' &&
/^skin-update-\d+-\d+$/.test(event.data.requestId) &&
typeof event.data.playerId === 'string' &&
/^[0-9a-f-]{32,36}$/i.test(event.data.playerId) &&
(event.data.model === 'default' || event.data.model === 'slim')
) {
void updatePlayerSkin(
event.data.requestId,
event.data.playerId,
event.data.textureDataUrl,
event.data.model,
)
}
})
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
window.addEventListener('storage', () => void checkSession())
let timer = setInterval(() => void checkSession(), 1000)
window.addEventListener('pagehide', () => {
clearInterval(timer)
++generation
pending?.abort()
pending = undefined
for (const controller of pendingLuck.values()) controller.abort()
pendingLuck.clear()
for (const controller of pendingPlayers.values()) controller.abort()
pendingPlayers.clear()
for (const controller of pendingSkinUpdates.values()) controller.abort()
pendingSkinUpdates.clear()
})
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return
timer = setInterval(() => void checkSession(), 1000)
void checkSession(true)
})
})()

View File

@ -4,64 +4,380 @@ const vm = require('node:vm')
const test = require('node:test')
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
function harness(origin = 'https://skin.starlight.cool') {
let token = null
const listeners = {}, messages = [], requests = []
let tick
let result = async () => ({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
const parent = { postMessage(data, target) { messages.push({ data, target }) } }
const context = {
location: { origin },
window: { parent, addEventListener(type, callback) { listeners[type] = callback } },
localStorage: { getItem() { return token } },
fetch(...args) { requests.push(args); return result(...args) },
AbortController, Date, setTimeout, clearTimeout,
setInterval(callback) { tick = callback; return 1 }, clearInterval() {},
}
vm.runInNewContext(source, context)
return {
messages, requests, listeners, parent,
token(value) { token = value }, result(value) { result = value },
async tick() { tick?.(); await new Promise(setImmediate) },
async connect(origin = 'http://localhost:5201', source = parent) {
listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } })
await new Promise(setImmediate)
},
}
function harness(origin = 'https://skin.starlight.cool', skinSize = 64) {
let token = null
const listeners = {},
messages = [],
requests = [],
drawCalls = []
const skinWidth = typeof skinSize === 'number' ? skinSize : skinSize.width
const skinHeight = typeof skinSize === 'number' ? skinSize : skinSize.height
let tick
let result = async () => ({
ok: true,
status: 200,
json: async () => ({ payload: { uuid: 'one', username: 'One' } }),
})
const parent = {
postMessage(data, target) {
messages.push({ data, target })
},
}
const context = {
location: { origin },
window: {
parent,
addEventListener(type, callback) {
listeners[type] = callback
},
},
localStorage: {
getItem() {
return token
},
},
fetch(...args) {
requests.push(args)
return result(...args)
},
AbortController,
Blob,
Date,
FormData,
Uint8Array,
atob,
document: {
createElement() {
return {
getContext() {
return {
drawImage(...args) {
drawCalls.push(args)
},
}
},
toDataURL() {
return 'data:image/png;base64,SEVBRERBVEE='
},
}
},
},
Image: class {
naturalWidth = skinWidth
naturalHeight = skinHeight
set src(_value) {
queueMicrotask(() => this.onload?.())
}
},
setTimeout,
clearTimeout,
setInterval(callback) {
tick = callback
return 1
},
clearInterval() {},
}
vm.runInNewContext(source, context)
return {
drawCalls,
messages,
requests,
listeners,
parent,
token(value) {
token = value
},
result(value) {
result = value
},
async tick() {
tick?.()
await new Promise(setImmediate)
},
async connect(origin = 'http://localhost:5201', source = parent) {
listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } })
await new Promise(setImmediate)
},
async message(data, origin = 'http://localhost:5201', source = parent) {
listeners.message?.({ source, origin, data })
await new Promise(setImmediate)
},
async waitForMessage(type, requestId, timeout = 2_000) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const message = messages.find(
(entry) =>
entry.data?.type === type &&
(requestId === undefined || entry.data?.requestId === requestId),
)
if (message) return message
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error(`Timed out waiting for ${type}`)
},
}
}
test('bridge is restricted to the skin origin and an allowlisted parent', async () => {
assert.equal(harness('https://evil.example').listeners.message, undefined)
const h = harness(); h.token('test-token')
await h.connect('https://evil.example'); await h.connect('http://localhost:5201', {})
assert.equal(h.requests.length, 0); assert.equal(h.messages.length, 0)
await h.connect()
assert.equal(h.requests[0][0], '/starlight/user')
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token')
assert.equal(h.requests[0][1].redirect, 'error')
assert.equal(h.messages.at(-1).data.status, 'signed-in')
assert.ok(h.messages.every(m => m.target === 'http://localhost:5201'))
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
assert.equal(harness('https://evil.example').listeners.message, undefined)
const h = harness()
h.token('test-token')
await h.connect('https://evil.example')
await h.connect('http://localhost:5201', {})
assert.equal(h.requests.length, 0)
assert.equal(h.messages.length, 0)
await h.connect()
assert.equal(h.requests[0][0], '/starlight/user')
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token')
assert.equal(h.requests[0][1].redirect, 'error')
assert.equal(h.messages.at(-1).data.status, 'signed-in')
assert.ok(h.messages.every((m) => m.target === 'http://localhost:5201'))
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('logout, invalid token, network error and account switching replace old state', async () => {
const h = harness(); await h.connect()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.token('first'); await h.tick(); assert.equal(h.messages.at(-1).data.user.username, 'One')
h.result(async () => ({ ok: false, status: 401 }))
h.token('expired'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.result(async () => { throw Error('offline') })
h.token('network-error'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'error')
h.token(null); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out')
const h = harness()
await h.connect()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.token('first')
await h.tick()
assert.equal(h.messages.at(-1).data.user.username, 'One')
h.result(async () => ({ ok: false, status: 401 }))
h.token('expired')
await h.tick()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.result(async () => {
throw Error('offline')
})
h.token('network-error')
await h.tick()
assert.equal(h.messages.at(-1).data.status, 'error')
h.token(null)
await h.tick()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
})
test('pack JWT is returned only for an explicit request from the connected launcher', async () => {
const h = harness()
h.token('pack.jwt.secret')
const request = { type: 'starlight-pack-token-request', requestId: 'pack-token-1-1' }
await h.message(request)
await h.connect('https://evil.example')
assert.equal(h.messages.length, 0)
await h.connect()
assert.ok(!JSON.stringify(h.messages).includes('pack.jwt.secret'))
await h.message(request, 'https://evil.example')
await h.message(request, 'http://localhost:5201', {})
assert.ok(!JSON.stringify(h.messages).includes('pack.jwt.secret'))
await h.message(request)
assert.equal(h.messages.at(-1).data.type, 'starlight-pack-token-result')
assert.equal(h.messages.at(-1).data.token, 'pack.jwt.secret')
assert.equal(h.messages.at(-1).target, 'http://localhost:5201')
h.token('unverified.account.token')
await h.message({ ...request, requestId: 'pack-token-1-2' })
assert.equal(h.messages.at(-1).data.token, null)
h.token(null)
await h.message({ ...request, requestId: 'pack-token-1-3' })
assert.equal(h.messages.at(-1).data.token, null)
})
test('a delayed response cannot restore the user after logout', async () => {
const h = harness(); let finish
h.result(() => new Promise(resolve => { finish = resolve }))
h.token('first'); await h.connect()
h.token(null); await h.tick()
finish({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) })
await new Promise(setImmediate)
assert.equal(h.messages.at(-1).data.status, 'signed-out')
assert.ok(!h.messages.some(m => m.data.status === 'signed-in'))
const h = harness()
let finish
h.result(
() =>
new Promise((resolve) => {
finish = resolve
}),
)
h.token('first')
await h.connect()
h.token(null)
await h.tick()
finish({
ok: true,
status: 200,
json: async () => ({ payload: { uuid: 'one', username: 'One' } }),
})
await new Promise(setImmediate)
assert.equal(h.messages.at(-1).data.status, 'signed-out')
assert.ok(!h.messages.some((m) => m.data.status === 'signed-in'))
})
test('luck requests stay on the skin origin and return only the validated score', async () => {
const h = harness()
h.token('test-token')
await h.connect()
h.result(async () => ({
ok: true,
status: 200,
json: async () => ({ payload: { luck: 73 } }),
}))
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-1-1' })
const request = h.requests.at(-1)
assert.equal(request[0], '/starlight/luck')
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
assert.equal(request[1].redirect, 'error')
const message = h.messages.at(-1)
assert.equal(message.target, 'http://localhost:5201')
assert.equal(message.data.type, 'starlight-skin-luck-result')
assert.equal(message.data.requestId, 'skin-luck-1-1')
assert.equal(message.data.ok, true)
assert.equal(message.data.luck, 73)
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('luck requests reject untrusted parents, missing sessions, and invalid scores', async () => {
const h = harness()
await h.connect()
const before = h.requests.length
await h.message(
{ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-1' },
'https://evil.example',
)
assert.equal(h.requests.length, before)
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-2' })
assert.equal(h.requests.length, before)
assert.equal(h.messages.at(-1).data.ok, false)
h.token('test-token')
h.result(async () => ({
ok: true,
status: 200,
json: async () => ({ payload: { luck: 101 } }),
}))
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-3' })
assert.equal(h.messages.at(-1).data.ok, false)
})
test('player requests return a sanitized complete player collection without exposing the token', async () => {
const h = harness()
h.token('test-token')
await h.connect()
h.result(async (url) => {
if (url.startsWith('/starlight/skin/player/skin/')) {
return {
ok: true,
status: 200,
json: async () => ({
payload: {
skin: url.endsWith('0123456789abcdef0123456789abcdef')
? '/textures/player-one.png'
: null,
},
}),
}
}
return {
ok: true,
status: 200,
json: async () => ({
payload: [
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
{ uuid: 'fedcba9876543210fedcba9876543210', name: 'Official', isMojang: true },
{ uuid: 'bad', name: 'Ignored', isMojang: false },
],
}),
}
})
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-1-1' })
await h.waitForMessage('starlight-skin-players-result', 'skin-players-1-1')
const request = h.requests.find(([url]) => url === '/starlight/skin/player')
assert.equal(request[0], '/starlight/skin/player')
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
assert.equal(
h.requests.filter(([url]) => url.startsWith('/starlight/skin/player/skin/')).length,
2,
)
const message = h.messages.at(-1)
assert.equal(message.data.type, 'starlight-skin-players-result')
assert.equal(message.data.ok, true)
assert.equal(message.data.players.length, 2)
assert.equal(message.data.players[1].isMojang, true)
assert.equal(message.data.players[0].skinState, 'ready')
assert.equal(message.data.players[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
assert.equal(message.data.players[0].skinDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
assert.equal(message.data.players[0].model, 'default')
assert.equal(message.data.players[1].skinState, 'empty')
assert.equal(message.data.players[1].headDataUrl, undefined)
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('skin updates upload a PNG to the selected skin-site player without exposing the token', async () => {
const h = harness()
h.token('test-token')
await h.connect()
h.result(async (url) => ({
ok: true,
status: 200,
json: async () => ({
payload:
url === '/starlight/skin/player'
? [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
},
]
: { skin: null },
}),
}))
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-2-1' })
h.result(async () => ({
ok: true,
status: 200,
json: async () => ({ success: true, payload: 'updated' }),
}))
await h.message({
type: 'starlight-skin-update-request',
requestId: 'skin-update-1-1',
playerId: '0123456789abcdef0123456789abcdef',
textureDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
})
const request = h.requests.at(-1)
assert.equal(
request[0],
'/starlight/skin/player/skin/0123456789abcdef0123456789abcdef/SKIN',
)
assert.equal(request[1].method, 'PUT')
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
assert.equal(request[1].body.get('model'), 'slim')
assert.equal(request[1].body.get('file').type, 'image/png')
assert.equal(h.messages.at(-1).data.type, 'starlight-skin-update-result')
assert.equal(h.messages.at(-1).data.ok, true)
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('skin rendering uses the skin site crop and preserves the source texture dimensions', async () => {
const h = harness('https://skin.starlight.cool', { width: 64, height: 128 })
h.token('test-token')
await h.connect()
h.result(async (url) => ({
ok: true,
status: 200,
json: async () => ({
payload:
url === '/starlight/skin/player'
? [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'HighResolutionPlayer',
isMojang: false,
},
]
: { skin: '/textures/hd.png', model: 'slim' },
}),
}))
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-3-1' })
const player = h.messages.at(-1).data.players[0]
assert.equal(player.skinState, 'ready')
assert.equal(player.headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
assert.equal(player.skinDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
assert.equal(player.model, 'slim')
assert.deepEqual(h.drawCalls[0].slice(1), [8, 8, 8, 8, 1, 1, 16, 16])
assert.deepEqual(h.drawCalls[1].slice(1), [40, 8, 8, 8, 0, 0, 18, 18])
})

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -0,0 +1,9 @@
{
"replace": false,
"values": [
"simpletomb:grave_simple",
"simpletomb:grave_normal",
"simpletomb:grave_cross",
"simpletomb:tombstone"
]
}

View File

@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"simpletomb:grave_key"
]
}

Some files were not shown because too many files have changed in this diff Show More