Compare commits

...
30 Commits
Author SHA1 Message Date
AxTps 52b3606062 feat: 资源下载页未登录时显示登录提示
Sync source to CNB / Sync Git ref (push) Skipped
未登录时不请求接口,直接展示「登录后浏览资源下载」,避免 401 错误弹窗。
2026-10-22 21:46:06 +08:00
AxTps fdfc2d36f5 fix: 资源下载接口带上登录凭据
Sync source to CNB / Sync Git ref (push) Skipped
download/list、download/count、download/category/list 在后端位于 requireLogin 内,
前端却以 auth: false 请求,未携带 token 导致 401,已登录用户也被提示未登录。
2026-10-22 21:43:38 +08:00
AxTps 30fbf5e5a6 fix: 补齐维基页缺失的中英文案
Sync source to CNB / Sync Git ref (push) Skipped
WikiView / WikiBrowser 使用的 24 个文案键未进 locale,导致 i18n-check 报缺键。
2026-10-22 21:30:21 +08:00
AxTps 9c92e157a5 feat: 皮肤站玩家管理补充二次确认、切换正版入口与默认徽章
Sync source to CNB / Sync Git ref (push) Skipped
- 删除/重命名/切换正版改为两段式确认,取消可整体复位
- 玩家列表与详情区均提供切换正版按钮
- 默认角色在列表和详情区显示「默认」徽章
- 补 rename/delete/mojang 三组确认文案
2026-10-21 22:25:30 +08:00
AxTps bc37e5d436 feat: 皮肤站玩家管理(新建/重命名/删除/默认角色/切换正版)
Sync source to CNB / Sync Git ref (push) Skipped
- skin-site-api.ts 补 setDefault/clearDefault/defaultPlayer/switchMojang/adaptLegacyInternal,并恢复 deleteLauncherRelease
- SkinSiteProfileView 增加玩家管理 UI 与状态
- en-US / zh-CN 各补 15 个 profile-view 文案
2026-10-21 22:19:47 +08:00
AxTps 0aa901ad48 merge: 同步 main 到 feat/home 2026-10-21 21:34:49 +08:00
Disy 6b0b2814a5 chore: release 1.0.2-beta13 2026-10-21 16:38:10 +08:00
Disy 115d31e0b4 fix(ci): restore cross-platform Rust compilation caches 2026-10-21 09:40:46 +08:00
Disy c44fb1a0a5 chore: release 1.0.2-beta12 2026-10-21 09:16:55 +08:00
AxTps afc03152e4 fix: 验证码图片改为完整显示并补上角度提示
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 22:15:04 +08:00
AxTps fd341d4b9e fix: 统一皮肤站文案脚本的拼写
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 22:09:31 +08:00
AxTps 0af663ac8c fix: 皮肤站玩家列表加载失败后自动重试并显示错误状态
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 22:05:24 +08:00
AxTps 26a29714b2 fix: 补回维基界面缺失的文案键
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 22:02:56 +08:00
AxTps 6501eb6aea merge: 同步 main 到 feat/home
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 21:48:33 +08:00
Disy bf95d715a4 fix: make hosted downloads controllable and failures diagnosable
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-20 21:25:19 +08:00
Disy 1838afce56 chore: release 1.0.2-beta11 2026-10-20 20:36:53 +08:00
Disy 7f3a1cdf5c fix: localize skin site UI and renew JWT on startup
Catalog missing skin-site messages and check source IDs during i18n validation. Renew the saved JWT before loading session data, guard overlapping login/logout requests, and reload announcements after authentication with a retry action.
2026-10-20 20:28:13 +08:00
Disy 2d7683b0bb ci: add credential-safe skin site authentication diagnosis 2026-10-20 19:45:32 +08:00
Disy 6edc44fe26 fix(ci): match skin site response envelopes 2026-10-20 19:19:36 +08:00
Disy f81a588dc1 fix(ci): include skin site API error details in failures 2026-10-20 19:17:07 +08:00
Disy be3a1eda8c fix(ci): publish skin site releases with chunked uploads
Use bounded chunks with retries, processing deadlines and verified activation. Fail publication explicitly on upload errors. Add a manual recovery workflow for existing release packages.
2026-10-20 19:14:58 +08:00
Disy 0098e9eb07 docs: add beta10 security release notes 2026-10-20 17:10:39 +08:00
Disy 629fe9c26a chore: bump launcher version to 1.0.2-beta10 2026-10-20 17:07:32 +08:00
Disy 159f9e3f38 fix(security): backport upstream modpack path traversal fixes
Backport Mystic-Stars/Axolotl b2f81391e584aebf3ef07e8db2fc9cdad2ffb4c7. Adapt validation to this fork and protect CurseForge archive paths and MRPack uninstall cleanup.
2026-10-20 17:04:46 +08:00
AxTps 79d49d7916 fix: 在线玩家提示渲染 MOTD 颜色
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-19 22:36:32 +08:00
AxTps 9c530247ef fix: 备份支持外部游戏目录实例
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-19 22:15:41 +08:00
AxTps daaaebc5a8 fix: 修复服务器地图被 CSP 拦截、字体命令缺少权限与审核表单空数组报错
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-19 22:00:06 +08:00
AxTps 2542f6dcb6 feat: 维基页支持关注与移动页面
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-19 21:36:40 +08:00
AxTps 1a4fd54b68 feat: 维基页支持搜索、最近更新与分类浏览
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 15:32:06 +08:00
AxTps a325298eb6 feat: 维基阅读页补历史与版本对比
Sync source to CNB / Sync Git ref (push) Skipped
- 阅读页新增阅读/历史模式切换
- 历史列表展示修订号、作者、摘要、时间与小修改标记
- 支持查看任意历史修订的渲染内容
- 与当前版本的逐行差异对比,新增/删除行高亮
- 支持撤销到指定修订,带二次确认
- 修复导航栈与修订列表变量重名
- 补充维基历史相关 API:历史列表、修订详情、差异对比、撤销
- 中英文各补 13 个文案
2026-10-18 15:04:05 +08:00
89 changed files with 10433 additions and 3176 deletions

No files matched your search

+19 -8
View File
@@ -58,24 +58,34 @@ jobs:
node-version-file: .nvmrc
cache: pnpm
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Restore Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: desktop-${{ runner.os }}-${{ runner.arch }}
- name: Set up Rust compiler cache
if: runner.os == 'Windows'
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
id: sccache-setup
continue-on-error: true
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Check Rust compiler cache availability
id: sccache
if: runner.os == 'Windows'
if: steps.sccache-setup.outcome == 'success'
continue-on-error: true
shell: bash
env:
SCCACHE_GHA_ENABLED: 'true'
run: ${SCCACHE_PATH} rustc -vV
run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV'
- name: Enable Rust compiler cache
if: runner.os == 'Windows' && steps.sccache.outcome == 'success'
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV"
echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV"
- name: Report unavailable Rust compiler cache
if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure'
shell: bash
run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.'
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- run: pnpm install --frozen-lockfile
@@ -112,9 +122,10 @@ jobs:
}
Write-Output "Built native NSIS installer: $($installers[0].Name)"
- name: Show Rust compiler cache statistics
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always()
if: always() && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: ${SCCACHE_PATH} --show-stats
run: '"${SCCACHE_PATH}" --show-stats'
website:
needs: guardrails
+34 -40
View File
@@ -51,6 +51,7 @@ jobs:
node scripts/axolotl/check-migrations.mjs --release
node scripts/axolotl/brand-guard.mjs
node scripts/axolotl/i18n-check.mjs
node --test scripts/axolotl/publish-skin-site.test.mjs
cargo fmt --all --check
- name: Check updater signing configuration
@@ -130,26 +131,38 @@ jobs:
- name: Set up Rust
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
- name: Restore Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: desktop-${{ runner.os }}-${{ runner.arch }}
- name: Set up Rust compiler cache
if: runner.os == 'Windows'
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
id: sccache-setup
continue-on-error: true
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Check Rust compiler cache availability
id: sccache
if: runner.os == 'Windows'
if: steps.sccache-setup.outcome == 'success'
continue-on-error: true
shell: bash
env:
SCCACHE_GHA_ENABLED: 'true'
run: ${SCCACHE_PATH} rustc -vV
run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV'
- name: Enable Rust compiler cache
if: runner.os == 'Windows' && steps.sccache.outcome == 'success'
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV"
echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV"
- name: Report unavailable Rust compiler cache
if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure'
shell: bash
run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.'
- name: Add macOS universal targets
if: runner.os == 'macOS'
@@ -192,11 +205,6 @@ jobs:
updaterJsonPreferNsis: true
retryAttempts: 2
- name: Show Rust compiler cache statistics
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always()
shell: bash
run: ${SCCACHE_PATH} --show-stats
- name: Publish Windows distribution variants
if: runner.os == 'Windows'
env:
@@ -282,10 +290,19 @@ jobs:
throw "Binary not found at $appExe"
}
- name: Show Rust compiler cache statistics
if: always() && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: '"${SCCACHE_PATH}" --show-stats'
verify-and-publish:
name: Verify and publish
needs: build
runs-on: ubuntu-24.04
concurrency:
group: skin-site-${{ github.ref_name }}
cancel-in-progress: false
env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
@@ -298,6 +315,11 @@ jobs:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Set up Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: .nvmrc
- name: Download updater signatures
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -375,42 +397,14 @@ jobs:
gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber
- name: Publish to skin site
continue-on-error: true
env:
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
set -euo pipefail
if [ -z "${SKIN_SITE_TOKEN:-}" ]; then
echo "::warning::SKIN_SITE_CI_UPLOAD_TOKEN not configured, skipping skin site publish"
exit 0
fi
version="${GITHUB_REF_NAME#v}"
bundle="release-check/Starlight_Launcher_${version}_skin-site-update.zip"
if [ ! -f "${bundle}" ]; then
echo "::warning::Bundle ${bundle} not found, skipping skin site publish"
exit 0
fi
upload_response=$(curl -fsS -X POST \
-H "Authorization: Bearer ${SKIN_SITE_TOKEN}" \
-F "archive=@${bundle};type=application/zip" \
"${SKIN_SITE_URL}/starlight/launcher/admin/upload?forceUpdate=false")
release_id=$(printf '%s' "${upload_response}" | jq -r '.payload.id // empty')
if [ -z "${release_id}" ]; then
echo "::warning::Skin site upload did not return a release id: ${upload_response}"
exit 0
fi
echo "Uploaded release id: ${release_id}"
curl -fsS -X POST \
-H "Authorization: Bearer ${SKIN_SITE_TOKEN}" \
"${SKIN_SITE_URL}/starlight/launcher/admin/${release_id}/activate"
echo "Skin site release ${version} published"
node scripts/axolotl/publish-skin-site.mjs "${bundle}" "${GITHUB_REF_NAME}"
- name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
@@ -0,0 +1,49 @@
name: Diagnose skin site CI authentication
on:
workflow_dispatch:
permissions:
contents: read
jobs:
diagnose:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Inspect token format without revealing credentials
env:
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
node --input-type=module <<'NODE'
const token = process.env.SKIN_SITE_TOKEN || ''
const checks = {
configured: token.length > 0,
containsWhitespace: /\s/.test(token),
containsComma: token.includes(','),
containsQuotes: /["']/.test(token),
containsBearerPrefix: /^Bearer\s/i.test(token),
bearerTokenFormatValid: /^[A-Za-z0-9._~+/-]+=*$/.test(token),
}
console.log(JSON.stringify(checks))
if (!checks.bearerTokenFormatValid) {
console.error('::error::CI secret is not a valid Bearer token. No credential value has been logged.')
process.exitCode = 1
} else {
const base = process.env.SKIN_SITE_URL.replace(/\/$/, '')
if (new URL(base).protocol !== 'https:') throw new Error('HTTPS is required')
try {
const response = await fetch(base + '/starlight/launcher/admin', {
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
redirect: 'error', signal: AbortSignal.timeout(30000),
})
console.log('Read-only CI authentication HTTP status: ' + response.status)
await response.body?.cancel()
if (!response.ok) process.exitCode = 1
} catch {
console.error('::error::Read-only CI authentication request failed; credentials have not been logged.')
process.exitCode = 1
}
}
NODE
+56
View File
@@ -0,0 +1,56 @@
name: Sync release to skin site
on:
workflow_dispatch:
inputs:
tag:
description: Existing GitHub release tag to sync (for example v1.0.2-beta10)
required: true
type: string
permissions:
contents: read
concurrency:
group: skin-site-${{ inputs.tag }}
cancel-in-progress: false
jobs:
publish:
name: Upload and activate existing release
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- name: Check out publisher
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: .nvmrc
- name: Download complete update package
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo '::error::Expected a semantic version tag beginning with v'
exit 1
fi
version="${RELEASE_TAG#v}"
gh release download "${RELEASE_TAG}" \
--pattern "Starlight_Launcher_${version}_skin-site-update.zip" \
--dir release-check
- name: Publish to skin site
env:
RELEASE_TAG: ${{ inputs.tag }}
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
node scripts/axolotl/publish-skin-site.mjs \
"release-check/Starlight_Launcher_${version}_skin-site-update.zip" "${RELEASE_TAG}"
Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "1.0.2-beta7"
version = "1.0.2-beta13"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.2-beta7"
version = "1.0.2-beta13"
dependencies = [
"ashpd",
"async_zip",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.2-beta9",
"version": "1.0.2-beta13",
"type": "module",
"scripts": {
"dev": "vite",
+57 -37
View File
@@ -134,6 +134,7 @@ import {
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { shouldDelayAppUpdate } from '@/helpers/app-update-policy'
import {
areUpdatesEnabled,
backupAppDbForUpdate,
@@ -1998,34 +1999,55 @@ function showDelayedUpdatePopup() {
}
let lastUpdateChannel = 'release'
let updateCheckRevision = 0
async function clearAvailableUpdate(isCurrent) {
const unlisten = unlistenUpdateDownload
unlistenUpdateDownload = null
await unlisten?.()
if (!isCurrent()) return
availableUpdate.value = null
updateSize.value = null
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
downloading.value = false
clearDelayedUpdatePopup()
}
async function performUpdateCheck() {
const revision = ++updateCheckRevision
const isLatestCheck = () => revision === updateCheckRevision
const currentResult = () => (availableUpdate.value ? 'available' : 'up-to-date')
const channel = await getUpdateChannel()
const preferences = await getUpdatePreferences()
if (!isLatestCheck()) return currentResult()
updatesPaused.value = preferences.updatesPaused
if (updatesPaused.value) return 'paused'
if (channel !== lastUpdateChannel) {
availableUpdate.value = null
updateSize.value = null
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
downloading.value = false
await clearAvailableUpdate(isLatestCheck)
if (!isLatestCheck()) return currentResult()
lastUpdateChannel = channel
}
const update = await checkAppUpdate(channel)
let update
try {
update = await checkAppUpdate(channel)
} catch (error) {
if (!isLatestCheck()) return currentResult()
throw error
}
if (!isLatestCheck()) return currentResult()
if (!update || update.version !== availableUpdate.value?.version) {
await clearAvailableUpdate(isLatestCheck)
}
if (!isLatestCheck()) return currentResult()
if (!update) {
console.log('No update available')
return 'up-to-date'
}
const publishedAt = Date.parse(update.publishedAt ?? '')
if (
channel === 'release' &&
!preferences.immediateUpdateFetch &&
!update.forceUpdate &&
(!Number.isFinite(publishedAt) || Date.now() < publishedAt + 24 * 60 * 60 * 1000)
) {
if (shouldDelayAppUpdate(update, channel, preferences.immediateUpdateFetch)) {
console.warn(
Number.isFinite(publishedAt)
? `Update ${update.version} is waiting for the release delay.`
@@ -2042,6 +2064,9 @@ async function performUpdateCheck() {
return 'available'
}
const meteredNetwork = await isNetworkMetered()
if (!isLatestCheck()) return currentResult()
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
downloading.value = false
@@ -2050,7 +2075,7 @@ async function performUpdateCheck() {
console.log(`Update ${update.version} is available.`)
metered.value = await isNetworkMetered()
metered.value = meteredNetwork
if (!metered.value) {
console.log('Starting download of update')
@@ -2062,7 +2087,9 @@ async function performUpdateCheck() {
}
getUpdateSize(update.rid)
.then((size) => (updateSize.value = size))
.then((size) => {
if (availableUpdate.value?.rid === update.rid) updateSize.value = size
})
.catch((error) => console.warn('Failed to fetch update size', error))
return 'available'
}
@@ -2092,6 +2119,7 @@ async function downloadUpdate(versionToDownload) {
handleError(`Failed to download update: no version available`)
return
}
if (availableUpdate.value?.rid !== versionToDownload.rid) return
if (downloading.value || appUpdateDownload.progress.value !== 0) {
console.error(`Update ${versionToDownload.version} already downloading`)
@@ -2100,35 +2128,27 @@ async function downloadUpdate(versionToDownload) {
console.log(`Downloading update ${versionToDownload.version} from Update Server`)
downloading.value = true
const isCurrent = () => availableUpdate.value?.rid === versionToDownload.rid
let unlisten
try {
enqueueUpdateForInstallation(versionToDownload.rid)
.then(() => {
downloading.value = false
finishedDownloading.value = true
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
console.log('Finished downloading!')
markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup()
})
.catch((error) => {
downloading.value = false
appUpdateDownload.progress.value = 0
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
handleError(error)
})
unlistenUpdateDownload = await subscribeToDownloadProgress(
appUpdateDownload,
versionToDownload.version,
)
unlisten = await subscribeToDownloadProgress(appUpdateDownload, versionToDownload.version)
if (!isCurrent()) return
unlistenUpdateDownload = unlisten
await enqueueUpdateForInstallation(versionToDownload.rid)
if (!isCurrent()) return
downloading.value = false
finishedDownloading.value = true
markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup()
} catch (error) {
if (!isCurrent()) return
downloading.value = false
appUpdateDownload.progress.value = 0
handleError(error)
} finally {
await unlisten?.()
if (unlistenUpdateDownload === unlisten) unlistenUpdateDownload = null
}
}
+253 -82
View File
@@ -32,6 +32,134 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta13',
version: '1.0.2-beta13',
publishedAt: '2026-10-21',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta13',
'zh-CN': 'Starlight Launcher 1.0.2-beta13',
},
changes: {
changed: [
{
'en-US':
'The redesigned home page brings together the 3D skin preview, skin site and Microsoft player selection, announcements, donation acknowledgements and a compact navigation toolbar.',
'zh-CN':
'新版首页集中展示 3D 皮肤预览、皮肤站与微软玩家切换、公告、捐赠鸣谢及底部功能工具栏。',
},
],
fixed: [
{
'en-US':
'The home page keeps the current 3D skin preview stable when accounts or avatars refresh in the background, avoiding unnecessary texture reloads and model flicker.',
'zh-CN':
'后台刷新账号或头像时保持当前 3D 皮肤预览稳定,避免无关刷新导致纹理重新加载和模型闪烁。',
},
],
},
},
{
id: 'launcher-1.0.2-beta12',
version: '1.0.2-beta12',
publishedAt: '2026-10-21',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta12',
'zh-CN': 'Starlight Launcher 1.0.2-beta12',
},
changes: {
fixed: [
{
'en-US':
'Fixed automatic updates ignoring server rollbacks to an earlier launcher version.',
'zh-CN': '修复启动器忽略服务端旧版本回滚的问题,回滚后可自动下载并在退出或重启时安装。',
},
{
'en-US':
'Fixed outdated update checks and downloads overriding a newly selected update version.',
'zh-CN': '修复旧的更新检查或下载结果覆盖新目标版本的问题。',
},
],
},
notes: {
'en-US':
'Install this version once to enable automatic rollback support for subsequent updates.',
'zh-CN': '需要先安装一次此版本,之后才能自动跟随服务端回滚。',
},
},
{
id: 'launcher-1.0.2-beta11',
version: '1.0.2-beta11',
publishedAt: '2026-10-20',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta11',
'zh-CN': 'Starlight Launcher 1.0.2-beta11',
},
changes: {
fixed: [
{
'en-US':
'Filled missing Chinese translations across skin site sign-in, announcements, verification, registration, account settings, downloads and maps, and localized remaining user and Wiki labels.',
'zh-CN':
'补齐皮肤站登录、公告、验证、注册、账号设置、下载和地图等界面的中文翻译,并修复用户信息与维基权限选项中的英文标签。',
},
{
'en-US':
'Renew the saved skin site JWT on launcher startup before loading the user and announcements. Fixed overlapping refresh, sign-in and sign-out requests that could leave the account in an incorrect state.',
'zh-CN':
'启动启动器时先刷新已保存的皮肤站 JWT,再加载用户与公告;修复刷新、登录和退出请求交错时可能导致的账号状态异常。',
},
{
'en-US':
'Announcements now reload after sign-in, show a sign-in hint when signed out, and offer a retry action after a loading failure.',
'zh-CN': '公告在登录后自动重新加载,未登录时显示登录提示,加载失败时可直接重试。',
},
],
changed: [
{
'en-US':
'Skin site release publishing now uses chunked uploads and reports synchronization failures explicitly. Localization checks also scan message IDs used in source to catch missing catalog entries.',
'zh-CN':
'皮肤站自动发布改用分片上传,同步失败会明确报错;国际化检查新增源码词条扫描,防止再次遗漏语言包内容。',
},
],
},
notes: {
'en-US':
'If the previously saved JWT has already expired, sign in again once to restore the session.',
'zh-CN': '如果此前保存的 JWT 已经过期,需要重新登录一次以恢复会话。',
},
},
{
id: 'launcher-1.0.2-beta10',
version: '1.0.2-beta10',
publishedAt: '2026-10-20',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta10',
'zh-CN': 'Starlight Launcher 1.0.2-beta10',
},
changes: {
security: [
{
'en-US':
'Backported the upstream Axolotl modpack path traversal fix to prevent malicious modpacks from writing files outside the intended installation directory.',
'zh-CN':
'合入上游 Axolotl 整合包路径穿越安全补丁,阻止恶意整合包向安装目录之外写入文件。',
},
{
'en-US':
'Strengthened path validation for Modrinth and CurseForge modpacks, server installations, cached files and modpack uninstall cleanup, including unsafe Windows path aliases.',
'zh-CN':
'加强 Modrinth 与 CurseForge 整合包、服务端安装、缓存文件及整合包卸载清理的路径校验,拦截不安全的 Windows 路径别名。',
},
],
},
notes: {
'en-US':
'Users of earlier versions should update promptly, especially before installing modpacks from untrusted sources.',
'zh-CN': '建议旧版本用户尽快更新,尤其是在安装来源不明的整合包之前。',
},
},
{
id: 'launcher-1.0.2-beta9',
version: '1.0.2-beta9',
@@ -43,21 +171,27 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Finished the skin site admin console: system overview, announcements, user and player management, review paper editor, mod upload with batch operations, chat room management with real-time voice, servers, audit log, resource downloads, donations and launcher releases.',
'zh-CN': '补全皮肤站管理台:系统概览、公告、用户与玩家管理、审核试卷编辑器、Mod 上传与批量操作、聊天室房间管理与实时语音、服务器、审计日志、资源下载、捐赠与启动器版本管理。',
'en-US':
'Finished the skin site admin console: system overview, announcements, user and player management, review paper editor, mod upload with batch operations, chat room management with real-time voice, servers, audit log, resource downloads, donations and launcher releases.',
'zh-CN':
'补全皮肤站管理台:系统概览、公告、用户与玩家管理、审核试卷编辑器、Mod 上传与批量操作、聊天室房间管理与实时语音、服务器、审计日志、资源下载、捐赠与启动器版本管理。',
},
{
'en-US': 'Added Wiki management to the admin console, including a MediaWiki migration center that imports pages from an existing wiki and lets you review, publish or roll back the result.',
'zh-CN': '管理台新增维基管理,包含 MediaWiki 迁移中心:可从现有 Wiki 导入页面,并对结果进行审核、发布或回滚。',
'en-US':
'Added Wiki management to the admin console, including a MediaWiki migration center that imports pages from an existing wiki and lets you review, publish or roll back the result.',
'zh-CN':
'管理台新增维基管理,包含 MediaWiki 迁移中心:可从现有 Wiki 导入页面,并对结果进行审核、发布或回滚。',
},
{
'en-US': 'Added a read-only Wiki reader in the launcher: render pages, follow links between entries, and keep a back stack while browsing.',
'en-US':
'Added a read-only Wiki reader in the launcher: render pages, follow links between entries, and keep a back stack while browsing.',
'zh-CN': '启动器新增维基阅读页:渲染词条正文、跟随词条链接跳转,并在浏览时保留返回栈。',
},
],
changed: [
{
'en-US': 'Releases are now published to the skin site automatically when a tag is pushed, so the download page stays in sync without a manual upload.',
'en-US':
'Releases are now published to the skin site automatically when a tag is pushed, so the download page stays in sync without a manual upload.',
'zh-CN': '推送 tag 发版时会自动把版本同步到皮肤站,下载页无需再手动上传即可保持同步。',
},
{
@@ -88,27 +222,35 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'The skin site is now fully native inside the launcher: sign-in, registration, password reset, account settings, players, downloads, world map and player review all run through the launcher UI instead of an embedded web page.',
'zh-CN': '皮肤站全面改为启动器原生界面:登录、注册、找回密码、账号设置、角色管理、资源下载、服务器地图与玩家审核均不再依赖内嵌网页。',
'en-US':
'The skin site is now fully native inside the launcher: sign-in, registration, password reset, account settings, players, downloads, world map and player review all run through the launcher UI instead of an embedded web page.',
'zh-CN':
'皮肤站全面改为启动器原生界面:登录、注册、找回密码、账号设置、角色管理、资源下载、服务器地图与玩家审核均不再依赖内嵌网页。',
},
{
'en-US': 'Added a bug tracker: browse and filter issues, read details and comments, post replies, create new issues and follow notifications.',
'zh-CN': '新增问题追踪:可浏览与筛选问题、查看详情与评论、发表回复、提交新问题并接收通知。',
'en-US':
'Added a bug tracker: browse and filter issues, read details and comments, post replies, create new issues and follow notifications.',
'zh-CN':
'新增问题追踪:可浏览与筛选问题、查看详情与评论、发表回复、提交新问题并接收通知。',
},
{
'en-US': 'Added an admin console with a permission gate: system overview, announcements, users, players, player review and launcher releases.',
'zh-CN': '新增管理控制台(带权限校验):系统概览、公告管理、用户管理、玩家管理、玩家审核与启动器版本管理。',
'en-US':
'Added an admin console with a permission gate: system overview, announcements, users, players, player review and launcher releases.',
'zh-CN':
'新增管理控制台(带权限校验):系统概览、公告管理、用户管理、玩家管理、玩家审核与启动器版本管理。',
},
],
changed: [
{
'en-US': 'Hosted modpack downloads now go through the dedicated download server, keeping large downloads off the main API host.',
'en-US':
'Hosted modpack downloads now go through the dedicated download server, keeping large downloads off the main API host.',
'zh-CN': '整合包下载改为走独立的下载服务器,避免大文件下载挤占主 API 服务。',
},
],
fixed: [
{
'en-US': 'LogShare now collects logs with a lightweight reader, fixing crash log uploads that previously failed.',
'en-US':
'LogShare now collects logs with a lightweight reader, fixing crash log uploads that previously failed.',
'zh-CN': 'LogShare 改用轻量日志收集,修复崩溃日志上传失败的问题。',
},
{
@@ -137,12 +279,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复实时日志积压导致控制台卡顿的问题。',
},
{
'en-US': 'The update channel now defaults to the build type: installing a Beta build starts on the Beta channel instead of Release.',
'zh-CN': '更新通道默认值改为跟随安装包版本:安装 Beta 版后默认使用 Beta 通道,不再回落为 Release。',
'en-US':
'The update channel now defaults to the build type: installing a Beta build starts on the Beta channel instead of Release.',
'zh-CN':
'更新通道默认值改为跟随安装包版本:安装 Beta 版后默认使用 Beta 通道,不再回落为 Release。',
},
{
'en-US': 'On Linux, the default game directory for StarLight instances no longer points at the system install path (e.g. /usr/bin); it falls back to the home directory.',
'zh-CN': 'Linux 上 StarLight 实例的默认游戏目录不再指向系统安装路径(如 /usr/bin),改为回退到用户主目录。',
'en-US':
'On Linux, the default game directory for StarLight instances no longer points at the system install path (e.g. /usr/bin); it falls back to the home directory.',
'zh-CN':
'Linux 上 StarLight 实例的默认游戏目录不再指向系统安装路径(如 /usr/bin),改为回退到用户主目录。',
},
],
},
@@ -158,15 +304,19 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Added keyboard shortcut settings: enable and rebind navigation and quick-scrolling shortcuts.',
'en-US':
'Added keyboard shortcut settings: enable and rebind navigation and quick-scrolling shortcuts.',
'zh-CN': '新增键盘快捷键设置:可启用并自定义导航与快速滚动快捷键。',
},
{
'en-US': 'Added crash log sharing and AI analysis: share crash diagnostics to LogShare for a structured summary and optional AI analysis, with a rebuilt crash window that browses log files and streams the analysis.',
'zh-CN': '新增崩溃日志分享与 AI 分析:可将崩溃诊断分享到 LogShare 获取结构化摘要与 AI 分析;崩溃窗口重做为双栏布局,可浏览日志文件并流式查看分析结果。',
'en-US':
'Added crash log sharing and AI analysis: share crash diagnostics to LogShare for a structured summary and optional AI analysis, with a rebuilt crash window that browses log files and streams the analysis.',
'zh-CN':
'新增崩溃日志分享与 AI 分析:可将崩溃诊断分享到 LogShare 获取结构化摘要与 AI 分析;崩溃窗口重做为双栏布局,可浏览日志文件并流式查看分析结果。',
},
{
'en-US': 'Crash logs now also collect JVM crash reports (hs_err_pid*.log) and launcher logs.',
'en-US':
'Crash logs now also collect JVM crash reports (hs_err_pid*.log) and launcher logs.',
'zh-CN': '崩溃日志现在也会收集 JVM 崩溃报告(hs_err_pid*.log)与启动器日志。',
},
{
@@ -207,12 +357,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
'zh-CN': '新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
'en-US':
'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
'zh-CN':
'新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
},
{
'en-US': 'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
'zh-CN': '新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
'en-US':
'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
'zh-CN':
'新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
},
],
fixed: [
@@ -234,24 +388,31 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
'en-US':
'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN':
'皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
},
{
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'en-US':
'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
},
],
changed: [
{
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
'en-US':
'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
'zh-CN':
'优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
},
],
fixed: [
{
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
'en-US':
'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
'zh-CN':
'修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
},
{
'en-US': 'Fixed instance group changes not saving for some instances.',
@@ -271,8 +432,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
fixed: [
{
'en-US': 'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
'zh-CN': '修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
'en-US':
'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
'zh-CN':
'修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
},
],
},
@@ -288,8 +451,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
fixed: [
{
'en-US': 'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
'zh-CN': '修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
'en-US':
'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
'zh-CN':
'修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
},
],
},
@@ -305,15 +470,20 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
'zh-CN': '跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
'en-US':
'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
'zh-CN':
'跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
},
{
'en-US': 'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
'zh-CN': 'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
'en-US':
'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
'zh-CN':
'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
},
{
'en-US': 'Added a developer feature flag to preview the anniversary celebration at any time.',
'en-US':
'Added a developer feature flag to preview the anniversary celebration at any time.',
'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。',
},
],
@@ -330,8 +500,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.',
'zh-CN': '实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。',
'en-US':
'Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.',
'zh-CN':
'实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。',
},
{
'en-US': "Customize the launcher's UI font and monospace font in appearance settings.",
@@ -340,12 +512,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
fixed: [
{
'en-US': 'Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).',
'zh-CN': '修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。',
'en-US':
'Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).',
'zh-CN':
'修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。',
},
{
'en-US': 'Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.',
'zh-CN': '修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。',
'en-US':
'Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.',
'zh-CN':
'修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。',
},
],
},
@@ -361,11 +537,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.',
'en-US':
'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.',
'zh-CN': '重做实例页导览,分为内容、文件、世界、日志四步。',
},
{
'en-US': 'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.',
'en-US':
'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.',
'zh-CN': '安装位置选到盘符根目录时,安装器会自动创建 Starlight 子文件夹。',
},
],
@@ -379,11 +557,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复开发者功能开关无法保存的问题。',
},
{
'en-US': 'Fixed instance group edits not saving for instances without an applied content set.',
'en-US':
'Fixed instance group edits not saving for instances without an applied content set.',
'zh-CN': '修复没有应用内容集的实例无法保存分组修改的问题。',
},
{
'en-US': 'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.',
'en-US':
'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.',
'zh-CN': '修复前后端功能开关不同步导致启动器启动崩溃的问题。',
},
{
@@ -500,12 +680,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
'en-US':
'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.',
'zh-CN':
'修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
'zh-CN': '修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
},
{
'en-US':
'Fixed the onboarding tour target on the minimal home layout.',
'en-US': 'Fixed the onboarding tour target on the minimal home layout.',
'zh-CN': '修复精简主页下导览目标找不到的问题。',
},
],
@@ -536,13 +714,11 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
fixed: [
{
'en-US':
'Fixed the developer feature flags page rendering blank.',
'en-US': 'Fixed the developer feature flags page rendering blank.',
'zh-CN': '修复开发者“功能开关”页面空白的问题。',
},
{
'en-US':
'Fixed a build error that prevented the launcher from compiling.',
'en-US': 'Fixed a build error that prevented the launcher from compiling.',
'zh-CN': '修复导致启动器无法编译的构建错误。',
},
],
@@ -559,8 +735,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
fixed: [
{
'en-US':
'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
'en-US': 'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
'zh-CN': '修复 1.0.1-beta3 中导致启动器无法编译的构建错误。',
},
],
@@ -619,8 +794,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
fixed: [
{
'en-US':
'Improved the reliability of live logs and database switching.',
'en-US': 'Improved the reliability of live logs and database switching.',
'zh-CN': '提高实时日志与数据库切换的可靠性。',
},
],
@@ -712,15 +886,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复更新清单的下载地址与发布顺序,更新检查现在可以成功。',
},
{
'en-US':
'Updated the signing key pair used to sign update packages.',
'en-US': 'Updated the signing key pair used to sign update packages.',
'zh-CN': '更新了用于签名更新包的密钥对。',
},
],
changed: [
{
'en-US':
'Removed upstream release announcements prior to 1.0.0-beta1.',
'en-US': 'Removed upstream release announcements prior to 1.0.0-beta1.',
'zh-CN': '移除了 1.0.0-beta1 之前的上游版本公告。',
},
],
@@ -737,13 +909,11 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US':
'Installing a modpack now lets you choose the game directory.',
'en-US': 'Installing a modpack now lets you choose the game directory.',
'zh-CN': '一键安装整合包时现在可以选择游戏目录。',
},
{
'en-US':
'Approved (hosted) modpack sync and StarLight instance mode were added.',
'en-US': 'Approved (hosted) modpack sync and StarLight instance mode were added.',
'zh-CN': '新增获批整合包同步与 StarLight 实例模式。',
},
{
@@ -754,8 +924,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
changed: [
{
'en-US':
'The about page now plays a looping wither victory animation.',
'en-US': 'The about page now plays a looping wither victory animation.',
'zh-CN': '关于页现在会播放循环的凋灵胜利动画。',
},
],
@@ -767,8 +936,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'修复从 .minecraft 文件夹导入实例的多项问题(内容复制、版本隔离、符号链接、加载器版本识别等)。',
},
{
'en-US':
'Fixed the instance log view stopping updates and overlapping log lines.',
'en-US': 'Fixed the instance log view stopping updates and overlapping log lines.',
'zh-CN': '修复实例日志窗口停更与日志行重叠的问题。',
},
{
@@ -787,8 +955,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复未登录皮肤站时启动卡住且不再弹出玩家选择的问题。',
},
{
'en-US':
'Fixed various skin site login, player list sync, and display issues.',
'en-US': 'Fixed various skin site login, player list sync, and display issues.',
'zh-CN': '修复皮肤站登录、玩家列表同步及若干显示问题。',
},
],
@@ -807,7 +974,8 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
'en-US':
'The launcher has been rebranded from Axolotl Launcher to Starlight Launcher, with a new icon and a refreshed about page.',
'zh-CN': '启动器已从 Axolotl Launcher 更名为 Starlight Launcher,并更换了全新图标与关于页。',
'zh-CN':
'启动器已从 Axolotl Launcher 更名为 Starlight Launcher,并更换了全新图标与关于页。',
},
{
'en-US':
@@ -821,7 +989,8 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
added: [
{
'en-US': 'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.',
'en-US':
'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.',
'zh-CN': '侧边栏新增「斯达莱特」入口,内嵌 StarLight 皮肤站。',
},
{
@@ -840,10 +1009,12 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
'en-US':
'The hard-coded Starlight official server was removed from "Pinned servers"; only servers you favorite yourself are shown now.',
'zh-CN': '移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。',
'zh-CN':
'移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。',
},
{
'en-US': 'The swimming axolotl animation on the about page was replaced with a static nether star.',
'en-US':
'The swimming axolotl animation on the about page was replaced with a static nether star.',
'zh-CN': '关于页中游动的美西螈动画已替换为静止的下界之星。',
},
],
@@ -5,12 +5,6 @@ import test from 'node:test'
import { createRenderer, h, nextTick, Suspense } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '../../composables/skin-site-session.ts'
import { useNetworkStatus } from '../../composables/useNetworkStatus.ts'
// Exercise the real component's setup and reactive account list. Only platform,
@@ -19,6 +13,7 @@ const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@tauri-apps/plugin-http': `export const fetch=(...args)=>globalThis.accountTestFetch(...args)`,
'@modrinth/assets': `export const CopyIcon={}, LogInIcon={}, PlusIcon={}, RadioButtonCheckedIcon={}, RadioButtonIcon={}, RefreshCwIcon={}, SpinnerIcon={}, TrashIcon={}`,
'@modrinth/ui': `export const Accordion={}, Avatar={}, ButtonStyled={}; export const defineMessages=x=>x; export const injectNotificationManager=()=>({handleError:()=>{}}); export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})`,
'@tanstack/vue-query': `export const useQueryClient=()=>({refetchQueries:async()=>{}})`,
@@ -26,6 +21,7 @@ const stubs: Record<string, string> = {
'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`,
'@/assets/netherstar.png': `export default 'logo'`,
'@/components/ui/MinecraftLoginModal.vue': `export default {}`,
'@/components/ui/skin-site/SkinSiteLoginModal.vue': `export default {}`,
'@/helpers/events': `export const process_listener=async()=>()=>{}`,
'@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`,
'@/helpers/skins': `export const get_available_skins=async()=>[]`,
@@ -59,6 +55,13 @@ const hooks = registerHooks({
},
})
const { default: AccountsCard } = await import(componentUrl)
const {
resetSkinSiteSession,
signInSkinSite,
signOutSkinSite,
selectedSkinSitePlayerId,
SKIN_SITE_ORIGIN,
} = await import('../../composables/skin-site-session.ts')
hooks.deregister()
type TestNode = Record<string, unknown>
@@ -72,6 +75,7 @@ type CardState = {
defaultUser: string | undefined
selectedAccount: TestAccount | undefined
setAccount: (account: TestAccount) => Promise<void>
setSkinSitePlayer: (id: string) => Promise<void>
getAccountAvatarUrl: (account: TestAccount) => string | undefined
}
const renderer = createRenderer<TestNode, TestNode>({
@@ -93,37 +97,39 @@ const flush = async () => {
const firstId = '12345678-1234-1234-1234-123456789abc'
const secondId = '22345678-1234-1234-1234-123456789abc'
async function fixture(hasOfficial = true) {
async function fixture(hasOfficial = true, officialId = 'official', linkedMojang = false) {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
const stored: TestAccount[] = hasOfficial
? [{ account_type: 'microsoft', profile: { id: 'official', name: 'Official' } }]
? [{ account_type: 'microsoft', profile: { id: officialId, name: 'Official' } }]
: []
let selected: string | undefined = hasOfficial ? 'official' : undefined
let selected: string | undefined = hasOfficial ? officialId : undefined
let rejectLogin = false
let loginCalls = 0
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request')
message({
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'fixture.jwt',
})
if (data.type === 'starlight-skin-players-request')
message({
type: 'starlight-skin-players-result',
requestId: data.requestId,
ok: true,
players: [
{ uuid: firstId, name: 'First', isMojang: false, skinState: 'empty' },
{ uuid: secondId, name: 'Second', isMojang: false, skinState: 'empty' },
],
})
const storage = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
} as unknown as Window
function message(data: unknown) {
receiveSkinSiteMessage({ origin: SKIN_SITE_ORIGIN, source: frame, data } as MessageEvent, frame)
})
;(
globalThis as unknown as { accountTestFetch: (url: string) => Promise<Response> }
).accountTestFetch = async (url) => {
const path = new URL(url).pathname
const payload = path.endsWith('/login')
? { username: 'Owner', jwt: 'fixture.jwt' }
: path.endsWith('/user')
? { uuid: 'owner', username: 'Owner', permissions: [] }
: path.endsWith('/player')
? [
{ uuid: firstId, name: 'First', isMojang: linkedMojang || firstId === officialId },
{ uuid: secondId, name: 'Second', isMojang: false },
]
: {}
return Response.json({ payload })
}
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline()
@@ -163,22 +169,18 @@ async function fixture(hasOfficial = true) {
},
})
resetSkinSiteSession()
setSkinSiteFrame(frame)
const node = h({ ...AccountsCard, render: () => null })
const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) })
app.mount({})
await flush()
return {
state: (node.component! as unknown as { setupState: CardState }).setupState,
login() {
message({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
})
async login() {
await signInSkinSite({ username: 'Owner', password: 'password', clientId: 'test' })
await flush()
},
signOut() {
message({ type: 'starlight-skin-session', status: 'signed-out' })
signOutSkinSite()
},
failLogin(value: boolean) {
rejectLogin = value
@@ -192,7 +194,7 @@ async function fixture(hasOfficial = true) {
close() {
app.unmount()
resetSkinSiteSession()
setSkinSiteFrame(null)
Reflect.deleteProperty(globalThis, 'localStorage')
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
@@ -205,7 +207,7 @@ test('sidebar offers every skin-site player before any game authentication or la
const view = await fixture(false)
try {
view.failLogin(true)
view.login()
await view.login()
await flush()
assert.deepEqual(
view.state.accounts.map((account) => account.profile.id),
@@ -221,7 +223,7 @@ test('sidebar offers every skin-site player before any game authentication or la
test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => {
const view = await fixture()
try {
view.login()
await view.login()
await flush()
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!,
@@ -238,7 +240,7 @@ test('choosing an unused skin-site player authenticates and persists the default
test('failed player authentication preserves the default and allows retry', async () => {
const view = await fixture()
try {
view.login()
await view.login()
await flush()
view.failLogin(true)
await view.state.setAccount(
@@ -259,7 +261,7 @@ test('failed player authentication preserves the default and allows retry', asyn
test('signing out removes unregistered choices while keeping saved accounts', async () => {
const view = await fixture()
try {
view.login()
await view.login()
await flush()
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === firstId)!,
@@ -271,7 +273,7 @@ test('signing out removes unregistered choices while keeping saved accounts', as
[firstId, 'official'],
)
assert.equal(view.selected, firstId)
view.login()
await view.login()
await flush()
assert.equal(view.state.accounts.length, 3)
assert.equal(view.loginCalls, 1)
@@ -283,10 +285,71 @@ test('signing out removes unregistered choices while keeping saved accounts', as
test('a new player without a skin can render the shared empty avatar', async () => {
const view = await fixture(false)
try {
view.login()
await view.login()
await flush()
assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined)
} finally {
view.close()
}
})
test('choosing a player from the home avatar rail switches the persisted account and shared skin selection', async () => {
const view = await fixture()
try {
await view.login()
await view.state.setSkinSitePlayer(secondId)
assert.equal(view.selected, secondId)
assert.equal(view.state.selectedAccount?.profile.id, secondId)
assert.equal(selectedSkinSitePlayerId.value, secondId)
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === 'official')!,
)
assert.equal(view.selected, 'official')
assert.equal(selectedSkinSitePlayerId.value, null)
} finally {
view.close()
}
})
test('failed home player selection keeps the current skin selection as well as the account', async () => {
const view = await fixture()
try {
await view.login()
await view.state.setSkinSitePlayer(firstId)
view.failLogin(true)
await view.state.setSkinSitePlayer(secondId)
assert.equal(view.selected, firstId)
assert.equal(selectedSkinSitePlayerId.value, firstId)
} finally {
view.close()
}
})
test('a Microsoft player linked on the skin site keeps official skin and cape management', async () => {
const view = await fixture(true, firstId)
try {
await view.login()
assert.equal(selectedSkinSitePlayerId.value, null)
await view.state.setSkinSitePlayer(secondId)
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === firstId)!,
)
assert.equal(view.selected, firstId)
assert.equal(selectedSkinSitePlayerId.value, null)
} finally {
view.close()
}
})
test('site players linked to Mojang remain selectable without a saved Microsoft credential', async () => {
const view = await fixture(false, 'official', true)
try {
await view.login()
assert.equal(view.state.accounts.length, 2)
await view.state.setSkinSitePlayer(firstId)
assert.equal(view.selected, firstId)
assert.equal(selectedSkinSitePlayerId.value, firstId)
} finally {
view.close()
}
})
@@ -373,7 +373,6 @@ const accounts = computed(() => {
)
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
for (const player of skinSitePlayers.value) {
if (player.isMojang) continue
const id = player.uuid.replaceAll('-', '').toLowerCase()
const stored = byId.get(id)
if (stored && stored.account_type !== 'yggdrasil') continue
@@ -409,8 +408,12 @@ async function refreshSkinSitePlayers() {
await requestSkinSitePlayers().catch(() => {})
}
function setSkinSitePlayer(playerId: string) {
selectSkinSitePlayer(playerId)
async function setSkinSitePlayer(playerId: string) {
const normalizedId = playerId.replaceAll('-', '').toLowerCase()
const account = accounts.value.find(
(candidate) => candidate.profile.id.replaceAll('-', '').toLowerCase() === normalizedId,
)
if (account) await setAccount(account)
}
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
@@ -531,6 +534,12 @@ function setLoginDisabled(value: boolean) {
}
defineExpose({
accounts,
defaultUser,
accountSelectionPending,
setAccount,
getAccountSkin,
getAccountAvatarUrl,
accountChangeRevision,
login,
refreshValues,
@@ -669,7 +678,15 @@ async function setAccount(account: MinecraftCredential) {
})
}
await persistDefaultUser(userId)
selectSkinSitePlayer(null)
const sitePlayer =
account.account_type === 'yggdrasil'
? skinSitePlayers.value.find(
(player) =>
player.uuid.replaceAll('-', '').toLowerCase() ===
userId.replaceAll('-', '').toLowerCase(),
)
: undefined
selectSkinSitePlayer(sitePlayer?.uuid ?? null)
defaultUser.value = userId
equippedSkin.value = null
notifyAccountChange()
@@ -682,9 +699,20 @@ async function setAccount(account: MinecraftCredential) {
}
watch(
[skinSitePlayers, defaultUser, skinSiteUser],
[skinSitePlayers, defaultUser, skinSiteUser, accounts],
([availablePlayers, selectedLocalUser]) => {
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
if (selectedLocalUser) {
const account = accounts.value.find((candidate) => candidate.profile.id === selectedLocalUser)
const match =
account?.account_type === 'yggdrasil'
? availablePlayers.find(
(player) =>
player.uuid.replaceAll('-', '').toLowerCase() ===
selectedLocalUser.replaceAll('-', '').toLowerCase(),
)
: undefined
selectSkinSitePlayer(match?.uuid ?? null)
} else if (!selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
selectSkinSitePlayer(availablePlayers[0].uuid)
}
},
@@ -21,6 +21,7 @@ import {
const settingsComponentFiles = {
interface: ['./AppearanceSettings.vue'],
'home-navigation': ['./AppearanceSettings.vue'],
'shortcut-settings': ['./KeybindSettings.vue'],
'language-translation': ['./LanguageSettings.vue', './TranslationSettings.vue'],
ai: ['./AISettings.vue'],
'java-performance': ['./JavaSettings.vue'],
@@ -30,6 +31,7 @@ const settingsComponentFiles = {
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
updates: ['./UpdateSettings.vue'],
about: ['./AboutSettings.vue'],
logs: ['./LogsSettings.vue'],
'feature-flags': ['./FeatureFlagSettings.vue'],
} as const
@@ -168,8 +170,7 @@ test('developer-only settings stay out of the normal search categories', () => {
)
})
test('settings navigation groups preserve the intended Starlight information architecture', () => {
test('settings navigation groups preserve the intended Starlight information architecture', () => {
const categoriesForGroup = (
group: 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer',
developerMode = false,
@@ -181,6 +182,7 @@ test('developer-only settings stay out of the normal search categories', () => {
assert.deepEqual(categoriesForGroup('launcher'), [
'interface',
'home-navigation',
'shortcut-settings',
'language-translation',
'ai',
])
@@ -191,7 +193,7 @@ test('developer-only settings stay out of the normal search categories', () => {
'network-multiplayer',
])
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups'])
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about', 'logs'])
assert.deepEqual(categoriesForGroup('developer'), [])
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
})
@@ -70,6 +70,13 @@ const messages = defineMessages({
defaultMessage: 'Selected',
},
idLabel: { id: 'app.skin-site.chat.user-picker.id', defaultMessage: 'ID' },
emailLabel: { id: 'app.skin-site.chat.user-picker.email', defaultMessage: 'Email' },
registeredLabel: {
id: 'app.skin-site.chat.user-picker.registered',
defaultMessage: 'Registered',
},
bannedStatus: { id: 'app.skin-site.chat.user-picker.banned-status', defaultMessage: 'Banned' },
activeStatus: { id: 'app.skin-site.chat.user-picker.active-status', defaultMessage: 'Active' },
})
const keyword = ref('')
@@ -82,9 +89,7 @@ let timer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
const currentLabel = computed(() => props.label ?? formatMessage(messages.label))
const currentPlaceholder = computed(
() => props.placeholder ?? formatMessage(messages.placeholder),
)
const currentPlaceholder = computed(() => props.placeholder ?? formatMessage(messages.placeholder))
watch([keyword, searchMode], () => {
clearTimeout(timer)
@@ -174,13 +179,19 @@ function formatTime(ts: number): string {
v-model.trim="keyword"
type="text"
maxlength="100"
:placeholder="searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder"
:placeholder="
searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder
"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
autocomplete="off"
/>
</div>
<span class="text-[11px] text-secondary">
{{ searchMode === 'player' ? formatMessage(messages.playerHint) : formatMessage(messages.hint) }}
{{
searchMode === 'player'
? formatMessage(messages.playerHint)
: formatMessage(messages.hint)
}}
</span>
</div>
@@ -201,7 +212,9 @@ function formatTime(ts: number): string {
@click="preview = user"
>
<span class="flex min-w-0 items-center gap-2">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand">
<span
class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand"
>
{{ user.username.slice(0, 1).toUpperCase() }}
</span>
<span class="min-w-0">
@@ -244,7 +257,7 @@ function formatTime(ts: number): string {
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="preview.banned ? 'bg-red text-contrast' : 'bg-green text-contrast'"
>
{{ preview.banned ? 'Banned' : 'OK' }}
{{ formatMessage(preview.banned ? messages.bannedStatus : messages.activeStatus) }}
</span>
</div>
<div class="grid gap-2 text-[11px] sm:grid-cols-2">
@@ -253,7 +266,7 @@ function formatTime(ts: number): string {
<span class="block break-all font-mono text-contrast">{{ preview.uuid }}</span>
</div>
<div>
<span class="block text-secondary">Email</span>
<span class="block text-secondary">{{ formatMessage(messages.emailLabel) }}</span>
<span class="block break-all text-contrast">{{ preview.email }}</span>
</div>
<div>
@@ -261,7 +274,7 @@ function formatTime(ts: number): string {
<span class="block text-contrast">{{ preview.qq ?? '—' }}</span>
</div>
<div>
<span class="block text-secondary">Registered</span>
<span class="block text-secondary">{{ formatMessage(messages.registeredLabel) }}</span>
<span class="block text-contrast">{{ formatTime(preview.registerTime) }}</span>
</div>
</div>
@@ -283,7 +296,11 @@ function formatTime(ts: number): string {
:disabled="Boolean(unavailableReason(preview))"
@click="selectPreview"
>
{{ model?.uuid === preview.uuid ? formatMessage(messages.selected) : formatMessage(messages.select) }}
{{
model?.uuid === preview.uuid
? formatMessage(messages.selected)
: formatMessage(messages.select)
}}
</button>
</section>
</div>
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, nextTick, ref } from 'vue'
const testSession = {
skinSiteStatus: ref('checking'),
skinSiteUser: ref<{ uuid: string } | null>(null),
}
const testGlobal = globalThis as unknown as {
skinSiteAnnouncementSession: typeof testSession
skinSiteAnnouncementFetch: () => Promise<unknown>
}
testGlobal.skinSiteAnnouncementSession = testSession
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSiteAnnouncements.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': 'export const NewspaperIcon={}, SpinnerIcon={}, StarIcon={}',
'@modrinth/ui':
'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@/helpers/skin-site-api':
'export const skinSiteAnnouncements=()=>globalThis.skinSiteAnnouncementFetch()',
'@/composables/skin-site-session':
'export const {skinSiteStatus,skinSiteUser}=globalThis.skinSiteAnnouncementSession; export const reloadSkinSiteSession=async()=>{}',
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs)
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(
compileScript(descriptor, { id: 'skin-announcement-test' }).content,
),
shortCircuit: true,
}
},
})
const { default: Announcements } = await import(componentUrl)
hooks.deregister()
Announcements.render = () => null
type Node = Record<string, unknown>
const renderer = createRenderer<Node, Node>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
type State = {
announcements: Array<{ id: number }>
loading: boolean
failed: boolean
load: () => Promise<void>
}
let requests = 0
function mount(t: test.TestContext) {
const app = renderer.createApp(Announcements)
const instance = app.mount({})
t.after(() => app.unmount())
return instance.$.setupState as State
}
test.beforeEach(() => {
testSession.skinSiteStatus.value = 'checking'
testSession.skinSiteUser.value = null
requests = 0
testGlobal.skinSiteAnnouncementFetch = async () => {
requests++
return { success: true, payload: [{ id: 1, title: '公告', updateAt: 1, star: false }] }
}
})
test('announcements wait for startup renewal and reload when sign-in finishes', async (t) => {
const state = mount(t)
await flush()
assert.equal(requests, 0)
assert.equal(state.loading, true)
testSession.skinSiteUser.value = { uuid: 'owner' }
testSession.skinSiteStatus.value = 'signed-in'
await flush()
assert.equal(requests, 1)
assert.equal(state.announcements.length, 1)
})
test('signed-out users do not request protected announcements or see a load failure', async (t) => {
testSession.skinSiteStatus.value = 'signed-out'
const state = mount(t)
await flush()
assert.equal(requests, 0)
assert.equal(state.loading, false)
assert.equal(state.failed, false)
})
test('signing out clears announcements and ignores an old in-flight response', async (t) => {
testSession.skinSiteStatus.value = 'signed-in'
let finish!: (value: unknown) => void
testGlobal.skinSiteAnnouncementFetch = () =>
new Promise((resolve) => {
finish = resolve
})
const state = mount(t)
await flush()
testSession.skinSiteStatus.value = 'signed-out'
await flush()
finish({ success: true, payload: [{ id: 2 }] })
await flush()
assert.deepEqual(state.announcements, [])
assert.equal(state.failed, false)
})
test('a failed announcement request can be retried', async (t) => {
testSession.skinSiteStatus.value = 'signed-in'
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: false })
const state = mount(t)
await flush()
assert.equal(state.failed, true)
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: true, payload: [{ id: 3 }] })
await state.load()
assert.equal(state.failed, false)
assert.equal(state.announcements[0].id, 3)
})
@@ -1,8 +1,13 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import { NewspaperIcon, SpinnerIcon, StarIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
reloadSkinSiteSession,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { type SkinSiteAnnouncement, skinSiteAnnouncements } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
@@ -18,11 +23,16 @@ const messages = defineMessages({
id: 'app.skin-site.home.announcements.failed',
defaultMessage: 'Could not load announcements.',
},
signedOut: {
id: 'app.skin-site.home.announcements.signed-out',
defaultMessage: 'Sign in to view announcements.',
},
})
const announcements = ref<SkinSiteAnnouncement[]>([])
const loading = ref(true)
const failed = ref(false)
let requestId = 0
function formatDate(ts: number): string {
try {
@@ -32,9 +42,16 @@ function formatDate(ts: number): string {
}
}
onMounted(async () => {
async function load() {
const currentRequest = ++requestId
announcements.value = []
failed.value = skinSiteStatus.value === 'error'
loading.value = skinSiteStatus.value === 'checking'
if (skinSiteStatus.value !== 'signed-in') return
loading.value = true
try {
const result = await skinSiteAnnouncements()
if (currentRequest !== requestId) return
if (result.success) {
announcements.value = [...(result.payload ?? [])]
.sort((a, b) => {
@@ -46,45 +63,98 @@ onMounted(async () => {
failed.value = true
}
} catch {
failed.value = true
if (currentRequest === requestId) failed.value = true
} finally {
loading.value = false
if (currentRequest === requestId) loading.value = false
}
}
async function retry() {
if (skinSiteStatus.value === 'error') await reloadSkinSiteSession()
else await load()
}
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], load, { immediate: true })
onUnmounted(() => {
requestId++
})
</script>
<template>
<div class="flex flex-col gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<section class="announcements">
<header class="flex items-center gap-2 border-0 border-b border-solid border-surface-5 pb-4">
<NewspaperIcon class="size-5 text-brand" />
<h2 class="m-0 text-base font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span
v-if="announcements.length"
class="ml-auto rounded-full bg-button-bg px-2 py-0.5 text-xs text-secondary"
>{{ announcements.length }}</span
>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<div v-if="loading" class="announcement-feedback" role="status">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
<div v-else-if="failed" class="announcement-feedback">
<p class="m-0">{{ formatMessage(messages.failed) }}</p>
<button type="button" class="text-brand hover:underline" @click="retry">
{{ formatMessage(commonMessages.retryButton) }}
</button>
</div>
<p v-else-if="skinSiteStatus === 'signed-out'" class="announcement-feedback">
{{ formatMessage(messages.signedOut) }}
</p>
<p v-else-if="!announcements.length" class="m-0 text-sm text-secondary">
<p v-else-if="!announcements.length" class="announcement-feedback">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in announcements"
:key="item.id"
class="flex flex-col gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div v-else class="flex flex-col">
<article v-for="item in announcements" :key="item.id" class="announcement-entry">
<div class="flex items-start gap-2">
<StarIcon v-if="item.star" class="mt-0.5 size-4 shrink-0 text-brand" />
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.title }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
</div>
<span v-if="item.subtitle" class="truncate text-xs text-secondary">{{
item.subtitle
}}</span>
</div>
<p v-if="item.subtitle" class="m-0 text-sm leading-relaxed text-secondary">
{{ item.subtitle }}
</p>
</article>
</div>
</div>
</section>
</template>
<style scoped>
.announcements {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xl);
background: var(--color-raised-bg);
}
.announcement-entry {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 1.25rem 0;
border-bottom: 1px solid var(--color-divider);
}
.announcement-entry:last-child {
border-bottom: 0;
padding-bottom: 0.25rem;
}
.announcement-feedback {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.625rem;
margin: 0;
padding: 1.5rem 0 0.5rem;
color: var(--color-secondary);
font-size: 0.875rem;
}
</style>
@@ -100,16 +100,19 @@ defineExpose({ show })
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
<div
class="size-44 overflow-hidden rounded-full border-4 border-solid border-bg-raised bg-button-bg"
class="relative size-64 overflow-hidden rounded-2xl border-2 border-solid border-surface-5 bg-bg"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-cover transition-transform duration-75"
class="size-full object-contain transition-transform duration-75"
:style="{ transform: `rotate(${previewRotation})` }"
draggable="false"
/>
<div v-else class="flex size-full items-center justify-center text-sm text-secondary">
{{ formatMessage(messages.loading) }}
</div>
</div>
<div class="w-full max-w-sm">
@@ -122,11 +125,9 @@ defineExpose({ show })
class="w-full accent-brand"
:disabled="loading || submitting"
/>
<div class="mt-1 flex justify-between text-xs text-secondary">
<div class="mt-1 flex items-center justify-between text-xs text-secondary">
<span>0°</span>
<span>90°</span>
<span>180°</span>
<span>270°</span>
<span class="font-semibold text-contrast">{{ degree }}°</span>
<span>360°</span>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { HeartIcon, SpinnerIcon } from '@modrinth/assets'
import { ChevronRightIcon, HeartIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
@@ -8,6 +8,9 @@ import {
skinSitePublicDonationSummary,
} from '@/helpers/skin-site-api'
import { formatDonationAmount } from './donation-format'
import SkinSiteDonationModal from './SkinSiteDonationModal.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
@@ -24,6 +27,7 @@ const messages = defineMessages({
const summary = ref<SkinSitePublicDonationSummary | null>(null)
const loading = ref(true)
const failed = ref(false)
const donationModal = ref<InstanceType<typeof SkinSiteDonationModal>>()
onMounted(async () => {
try {
@@ -39,10 +43,8 @@ onMounted(async () => {
</script>
<template>
<div
class="flex items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
>
<HeartIcon class="size-5 shrink-0 text-brand" />
<button type="button" class="donation-card" @click="donationModal?.show()">
<span class="donation-icon"><HeartIcon class="size-6" /></span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="text-sm font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<span v-if="loading" class="flex items-center gap-1 text-xs text-secondary">
@@ -52,10 +54,69 @@ onMounted(async () => {
<span v-else-if="failed" class="text-xs text-secondary">
{{ formatMessage(messages.unavailable) }}
</span>
<span v-else-if="summary" class="text-xs text-secondary">
{{ formatMessage(messages.total) }}: {{ summary.totalAmount }} ·
{{ formatMessage(messages.donors, { count: summary.donorCount }) }}
</span>
<span v-else-if="summary" class="text-xs text-secondary">{{
formatMessage(messages.donors, { count: summary.donorCount })
}}</span>
</div>
</div>
<strong v-if="summary" class="donation-amount">{{
formatDonationAmount(summary.totalAmount)
}}</strong>
<ChevronRightIcon class="size-5 shrink-0 text-secondary" />
</button>
<SkinSiteDonationModal ref="donationModal" />
</template>
<style scoped>
.donation-card {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 1rem;
padding: 1.25rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xl);
background: var(--color-raised-bg);
color: var(--color-contrast);
text-align: left;
font: inherit;
cursor: pointer;
transition:
border-color 180ms,
background 180ms;
}
.donation-card:hover {
border-color: var(--color-brand);
background: var(--color-button-bg);
}
.donation-card:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 3px;
}
.donation-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
border-radius: var(--radius-lg);
background: var(--color-brand-highlight);
color: var(--color-brand);
}
.donation-amount {
font-variant-numeric: tabular-nums;
font-size: 1.25rem;
white-space: nowrap;
}
@media (max-width: 520px) {
.donation-card {
gap: 0.625rem;
padding: 1rem;
}
.donation-amount {
font-size: 1rem;
}
.donation-icon {
display: none;
}
}
</style>
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, nextTick } from 'vue'
const testGlobal = globalThis as unknown as {
donationTestFetch: (offset: number) => Promise<unknown>
}
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSiteDonationModal.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': 'export const HeartIcon={}, SpinnerIcon={}, StarIcon={}',
'@modrinth/ui':
'export const Avatar={}, ButtonStyled={}, NewModal={}, commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@/helpers/skin-site-api':
'export const skinSiteDonationList=offset=>globalThis.donationTestFetch(offset)',
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs)
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
if (specifier === './donation-format')
return nextResolve(new URL('./donation-format.ts', import.meta.url).href, context)
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(compileScript(descriptor, { id: 'donation-test' }).content),
shortCircuit: true,
}
},
})
const { default: Donations } = await import(componentUrl)
hooks.deregister()
Donations.render = () => null
type Node = Record<string, unknown>
const renderer = createRenderer<Node, Node>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
type State = {
items: Array<{ id: number }>
loading: boolean
failed: boolean
hasMore: boolean
load: (reset?: boolean) => Promise<void>
show: () => void
}
function mount(t: test.TestContext) {
const app = renderer.createApp(Donations)
const instance = app.mount({})
t.after(() => app.unmount())
return (instance.$ as unknown as { setupState: State }).setupState
}
const page = (id: number, hasMore = true) => ({
success: true,
payload: {
items: [{ id, rank: id, username: 'Donor', avatar: null, amount: '12.50' }],
summary: { totalAmount: '12.50', donorCount: 1 },
hasMore,
},
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
test('donations load on opening and a failed next page preserves the list and retries the same offset', async (t) => {
const offsets: number[] = []
let fail = false
testGlobal.donationTestFetch = async (offset) => {
offsets.push(offset)
return fail ? { success: false, errorMessage: 'Offline' } : page(offset + 1, offset === 0)
}
const state = mount(t)
assert.deepEqual(offsets, [])
state.show()
await flush()
assert.deepEqual(
state.items.map((item) => item.id),
[1],
)
fail = true
await state.load()
assert.equal(state.failed, true)
assert.deepEqual(
state.items.map((item) => item.id),
[1],
)
fail = false
await state.load()
assert.deepEqual(offsets, [0, 1, 1])
assert.deepEqual(
state.items.map((item) => item.id),
[1, 2],
)
assert.equal(state.hasMore, false)
})
test('reopening the donation list discards an older in-flight page', async (t) => {
const finish: Array<(result: unknown) => void> = []
testGlobal.donationTestFetch = () => new Promise((resolve) => finish.push(resolve))
const state = mount(t)
state.show()
state.show()
finish[1](page(2, false))
await flush()
finish[0](page(1))
await flush()
assert.deepEqual(
state.items.map((item) => item.id),
[2],
)
assert.equal(state.loading, false)
assert.equal(state.hasMore, false)
})
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { HeartIcon, SpinnerIcon, StarIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { onUnmounted, ref } from 'vue'
import {
skinSiteDonationList,
type SkinSitePublicDonationEntry,
type SkinSitePublicDonationSummary,
} from '@/helpers/skin-site-api'
import { formatDonationAmount } from './donation-format'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.donation.title', defaultMessage: 'Donation thanks' },
total: { id: 'app.skin-site.home.donation.total', defaultMessage: 'Total raised' },
donors: { id: 'app.skin-site.home.donation.donors', defaultMessage: '{count} donors' },
thanks: {
id: 'app.skin-site.home.donation.thanks',
defaultMessage: 'Thank you to everyone who helps keep StarLight running.',
},
empty: { id: 'app.skin-site.home.donation.empty', defaultMessage: 'No donations yet.' },
failed: {
id: 'app.skin-site.home.donation.list-failed',
defaultMessage: 'Could not load donations.',
},
more: { id: 'app.skin-site.home.donation.more', defaultMessage: 'Load more' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const items = ref<SkinSitePublicDonationEntry[]>([])
const summary = ref<SkinSitePublicDonationSummary | null>(null)
const loading = ref(false)
const failed = ref(false)
const hasMore = ref(false)
let generation = 0
async function load(reset = false) {
if (loading.value && !reset) return
const request = ++generation
if (reset) {
items.value = []
summary.value = null
hasMore.value = false
}
loading.value = true
failed.value = false
try {
const result = await skinSiteDonationList(items.value.length)
if (request !== generation) return
if (!result.success) throw new Error(result.errorMessage)
items.value = [...items.value, ...result.payload.items]
summary.value = result.payload.summary
hasMore.value = result.payload.hasMore
} catch {
if (request === generation) failed.value = true
} finally {
if (request === generation) loading.value = false
}
}
function show() {
modal.value?.show()
void load(true)
}
onUnmounted(() => {
generation++
})
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
max-width="42rem"
width="42rem"
scrollable
>
<div class="flex flex-col gap-6">
<div class="donation-summary">
<HeartIcon class="size-7 text-brand" />
<span class="text-sm text-secondary">{{ formatMessage(messages.total) }}</span>
<strong v-if="summary" class="text-3xl text-contrast tabular-nums">{{
formatDonationAmount(summary.totalAmount)
}}</strong>
<span v-if="summary" class="text-sm text-secondary">{{
formatMessage(messages.donors, { count: summary.donorCount })
}}</span>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.thanks) }}</p>
</div>
<div
v-if="loading && !items.length"
class="flex justify-center p-6 text-secondary"
role="status"
>
<SpinnerIcon class="size-6 animate-spin" />
</div>
<ol v-if="items.length" class="donation-list">
<li v-for="donor in items" :key="donor.id" class="donor-row">
<span class="donor-rank" :class="{ podium: donor.rank <= 3 }"
><StarIcon v-if="donor.rank <= 3" class="size-4" /><span v-else>#</span
>{{ donor.rank }}</span
>
<Avatar :src="donor.avatar ?? undefined" :alt="donor.username" size="2.25rem" />
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
donor.username
}}</span>
<strong class="shrink-0 tabular-nums text-contrast">{{
formatDonationAmount(donor.amount)
}}</strong>
</li>
</ol>
<p v-else-if="!loading && !failed" class="m-0 py-4 text-center text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div
v-if="failed"
class="flex items-center justify-center gap-3 text-sm text-secondary"
role="alert"
>
<span>{{ formatMessage(messages.failed) }}</span>
<button
type="button"
class="border-0 bg-transparent text-brand cursor-pointer"
@click="load()"
>
{{ formatMessage(commonMessages.retryButton) }}
</button>
</div>
</div>
<template v-if="hasMore && !failed" #actions>
<ButtonStyled
><button type="button" :disabled="loading" @click="load()">
<SpinnerIcon v-if="loading" class="size-4 animate-spin" />{{
formatMessage(messages.more)
}}
</button></ButtonStyled
>
</template>
</NewModal>
</template>
<style scoped>
.donation-summary {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1.5rem;
border-radius: var(--radius-xl);
background: var(--color-bg);
text-align: center;
}
.donation-list {
margin: 0;
padding: 0;
list-style: none;
}
.donor-row {
display: flex;
min-width: 0;
align-items: center;
gap: 0.875rem;
padding: 1rem 0;
border-bottom: 1px solid var(--color-divider);
font-size: 0.875rem;
}
.donor-row:last-child {
border: 0;
}
.donor-rank {
display: flex;
align-items: center;
gap: 0.25rem;
width: 2.5rem;
flex-shrink: 0;
color: var(--color-secondary);
font-variant-numeric: tabular-nums;
}
.donor-rank.podium {
color: var(--color-brand);
}
</style>
@@ -3,6 +3,7 @@ import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import { skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
import {
skinSitePublicDownloadCategories,
type SkinSitePublicDownloadCategory,
@@ -32,6 +33,10 @@ const messages = defineMessages({
statusLegacy: { id: 'app.skin-site.downloads.status.legacy', defaultMessage: 'Legacy' },
statusOutdated: { id: 'app.skin-site.downloads.status.outdated', defaultMessage: 'Outdated' },
statusRemoval: { id: 'app.skin-site.downloads.status.removal', defaultMessage: 'Removal' },
signedOut: {
id: 'app.skin-site.downloads.signed-out',
defaultMessage: 'Sign in to browse downloads.',
},
})
const emit = defineEmits<{ (e: 'back'): void }>()
@@ -42,6 +47,8 @@ const selectedCategory = ref<string | null>(null)
const loading = ref(true)
const failed = ref(false)
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
const filtered = computed(() =>
selectedCategory.value
? items.value.filter((item) => item.category.includes(selectedCategory.value!))
@@ -76,6 +83,10 @@ function download(item: SkinSitePublicDownloadItem) {
async function load() {
loading.value = true
failed.value = false
if (!signedIn.value) {
loading.value = false
return
}
try {
const [listResult, categoryResult] = await Promise.all([
skinSitePublicDownloadList(),
@@ -138,7 +149,10 @@ onMounted(load)
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<p v-if="!signedIn" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.signedOut) }}
</p>
<div v-else-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { CopyIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session'
import SkinSiteAccountCard from './SkinSiteAccountCard.vue'
import SkinSiteAdminView from './SkinSiteAdminView.vue'
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
import SkinSiteBugCreateModal from './SkinSiteBugCreateModal.vue'
@@ -12,30 +12,26 @@ import SkinSiteBugListView from './SkinSiteBugListView.vue'
import SkinSiteBugNotificationsView from './SkinSiteBugNotificationsView.vue'
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue'
import SkinSiteLoginModal from './SkinSiteLoginModal.vue'
import SkinSiteMapView from './SkinSiteMapView.vue'
import SkinSiteNavCards from './SkinSiteNavCards.vue'
import SkinSitePlayerPreview from './SkinSitePlayerPreview.vue'
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
import SkinSiteProfileView from './SkinSiteProfileView.vue'
import SkinSiteReviewView from './SkinSiteReviewView.vue'
import WikiView from './WikiView.vue'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.home.title', defaultMessage: 'StarLight Skin Site' },
subtitle: {
id: 'app.skin-site.home.subtitle',
defaultMessage: 'The wardrobe for StarLight Server players.',
},
address: { id: 'app.skin-site.home.address', defaultMessage: 'Skin site address' },
copied: { id: 'app.skin-site.home.copied', defaultMessage: 'Address copied.' },
account: { id: 'app.skin-site.home.settings', defaultMessage: 'Account' },
admin: { id: 'app.skin-site.home.admin', defaultMessage: 'Admin' },
})
const YGGDRASIL_URL = 'https://skin.starlight.cool/yggdrasil'
const loginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
const isAdmin = computed(() =>
skinSiteUser.value?.permissions.some(
(permission) => permission === 'WEB_ADMIN' || permission === 'SERVER_ADMIN',
),
)
const view = ref<
| 'home'
| 'players'
@@ -69,21 +65,26 @@ function openBugNotifications() {
function onBugCreated(id: number) {
openBug(id)
}
async function copyAddress() {
try {
await navigator.clipboard.writeText(YGGDRASIL_URL)
addNotification({ type: 'success', title: formatMessage(messages.copied) })
} catch {
// Clipboard may be unavailable; the address stays selectable.
}
}
</script>
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-y-auto">
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-6">
<SkinSiteProfileView v-if="view === 'players'" @back="view = 'home'" />
<div class="skin-site-home mx-auto flex w-full max-w-6xl flex-col gap-6 p-6">
<template v-if="view === 'players'">
<div v-if="skinSiteUser" class="flex flex-wrap justify-end gap-2">
<ButtonStyled
><button type="button" @click="profileModal?.show()">
{{ formatMessage(messages.account) }}
</button></ButtonStyled
>
<ButtonStyled v-if="isAdmin"
><button type="button" @click="view = 'admin'">
{{ formatMessage(messages.admin) }}
</button></ButtonStyled
>
</div>
<SkinSiteProfileView @back="view = 'home'" />
</template>
<SkinSiteDownloadsView v-else-if="view === 'downloads'" @back="view = 'home'" />
<SkinSiteMapView v-else-if="view === 'map'" @back="view = 'home'" />
<SkinSiteReviewView v-else-if="view === 'review'" @back="view = 'home'" />
@@ -109,46 +110,49 @@ async function copyAddress() {
/>
<template v-else>
<header class="flex flex-col items-center gap-1 text-center">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</header>
<SkinSiteAccountCard
@sign-in="loginModal?.show()"
@open-settings="profileModal?.show()"
@open-players="view = 'players'"
@open-admin="view = 'admin'"
/>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkinSiteNavCards @navigate="(v) => (view = v)" />
<div class="home-overview">
<SkinSitePlayerPreview />
<SkinSiteAnnouncements />
</div>
<SkinSiteDonationCard />
<div
class="flex flex-wrap items-center gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
>
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.address)
}}</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">{{ YGGDRASIL_URL }}</code>
<button
type="button"
class="flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="copyAddress"
>
<CopyIcon class="size-3.5" />
{{ formatMessage({ id: 'app.skin-site.home.copy', defaultMessage: 'Copy' }) }}
</button>
</div>
</template>
<div class="home-toolbar">
<SkinSiteNavCards
:active="view === 'bug-detail' || view === 'bug-notifications' ? 'bugs' : view"
@navigate="(target) => (view = target === view ? 'home' : target)"
/>
</div>
</div>
<SkinSiteLoginModal ref="loginModal" />
<SkinSiteProfileModal ref="profileModal" />
<SkinSiteBugCreateModal ref="bugCreateModal" @created="onBugCreated" />
<SkinSiteProfileModal ref="profileModal" />
</div>
</template>
<style scoped>
.skin-site-home {
box-sizing: border-box;
min-height: 100%;
container-type: inline-size;
}
.home-overview {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 2rem;
align-items: start;
}
.home-toolbar {
position: sticky;
bottom: 0;
z-index: 10;
margin-top: auto;
padding-top: 0.75rem;
background: var(--color-bg);
}
@container (max-width: 48rem) {
.home-overview {
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
}
</style>
@@ -8,12 +8,16 @@ import {
UserIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed } from 'vue'
import SkinSiteSelectionRail from './SkinSiteSelectionRail.vue'
defineProps<{ active: string }>()
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki'): void
(e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki' | 'players'): void
}>()
const messages = defineMessages({
@@ -50,96 +54,56 @@ const messages = defineMessages({
},
})
interface NavItem {
id: string
title: string
description: string
icon: Component
href: string
}
const SKIN_SITE = 'https://skin.starlight.cool'
const items: NavItem[] = [
const destinations = ['downloads', 'review', 'wiki', 'bugs', 'map', 'players'] as const
const items = computed(() => [
{
id: 'download',
title: formatMessage(messages.download),
id: 'downloads',
label: formatMessage(messages.download),
description: formatMessage(messages.downloadDesc),
icon: DownloadIcon,
href: `${SKIN_SITE}/download`,
},
{
id: 'review',
title: formatMessage(messages.review),
label: formatMessage(messages.review),
description: formatMessage(messages.reviewDesc),
icon: ShieldCheckIcon,
href: `${SKIN_SITE}/review`,
},
{
id: 'wiki',
title: formatMessage(messages.wiki),
label: formatMessage(messages.wiki),
description: formatMessage(messages.wikiDesc),
icon: BookIcon,
href: `${SKIN_SITE}/wiki`,
},
{
id: 'bugs',
title: formatMessage(messages.bugs),
label: formatMessage(messages.bugs),
description: formatMessage(messages.bugsDesc),
icon: BugIcon,
href: `${SKIN_SITE}/bugs`,
},
{
id: 'map',
title: formatMessage(messages.map),
label: formatMessage(messages.map),
description: formatMessage(messages.mapDesc),
icon: MapIcon,
href: `${SKIN_SITE}/map`,
},
{
id: 'profile',
title: formatMessage(messages.profile),
id: 'players',
label: formatMessage(messages.profile),
description: formatMessage(messages.profileDesc),
icon: UserIcon,
href: `${SKIN_SITE}/profile`,
},
]
function open(item: NavItem) {
if (
item.id === 'download' ||
item.id === 'review' ||
item.id === 'bugs' ||
item.id === 'map' ||
item.id === 'wiki'
) {
emit(
'navigate',
item.id === 'download'
? 'downloads'
: (item.id as 'review' | 'bugs' | 'map' | 'wiki'),
)
return
}
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href))
])
function navigate(id: string) {
const destination = destinations.find((value) => value === id)
if (destination) emit('navigate', destination)
}
</script>
<template>
<div class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-contrast">{{ formatMessage(messages.title) }}</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<button
v-for="item in items"
:key="item.id"
type="button"
class="flex flex-col items-start gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 text-left transition-colors hover:bg-button-bg"
@click="open(item)"
>
<component :is="item.icon" class="size-5 text-brand" />
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
<span class="text-xs text-secondary">{{ item.description }}</span>
</button>
</div>
</div>
<SkinSiteSelectionRail
:items="items"
:selected="active"
:label="formatMessage(messages.title)"
@select="navigate"
/>
</template>
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, nextTick, ref } from 'vue'
const session = {
skinSitePlayers: ref([]),
selectedSkinSitePlayerId: ref(null),
skinSiteStatus: ref('signed-in'),
skinSitePlayersStatus: ref('ready'),
}
const testGlobal = globalThis as unknown as {
previewTestSession: typeof session
previewNormalize: (skin: { texture: string }) => Promise<string>
}
testGlobal.previewTestSession = session
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSitePlayerPreview.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': 'export const RefreshCwIcon={}, SpinnerIcon={}, UserIcon={}',
'@modrinth/ui':
'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@modrinth/ui/src/components/skin/SkinPreviewRenderer.vue': 'export default {}',
'./SkinSiteSelectionRail.vue': 'export default {}',
'@/composables/skin-site-session':
'export const {skinSitePlayers,selectedSkinSitePlayerId,skinSiteStatus,skinSitePlayersStatus}=globalThis.previewTestSession; export const requestSkinSitePlayers=async()=>[]',
'@/helpers/skins':
'export const get_normalized_skin_texture=skin=>globalThis.previewNormalize(skin)',
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs)
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(
compileScript(descriptor, { id: 'player-preview-test' }).content,
),
shortCircuit: true,
}
},
})
const { default: Preview } = await import(componentUrl)
hooks.deregister()
Preview.render = () => null
type Node = Record<string, unknown>
const renderer = createRenderer<Node, Node>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
function mountPreview(t: test.TestContext, card: unknown) {
const app = renderer.createApp(Preview)
app.provide('accountsCard', card)
const instance = app.mount({})
t.after(() => app.unmount())
return (instance.$ as unknown as { setupState: { texture: string; textureLoading: boolean } })
.setupState
}
test('home normalizes official textures and ignores a late texture after switching players', async (t) => {
const accounts = [
{ profile: { id: 'first', name: 'First' }, account_type: 'microsoft' },
{ profile: { id: 'second', name: 'Second' }, account_type: 'microsoft' },
]
const card = ref({
accounts,
defaultUser: 'first',
getAccountSkin: (account: (typeof accounts)[number]) => ({
texture: `https://textures.test/${account.profile.id}.png`,
variant: 'CLASSIC',
}),
getAccountAvatarUrl: () => undefined,
})
const requests: Array<{ texture: string; finish: (texture: string) => void }> = []
testGlobal.previewNormalize = (skin) =>
new Promise((resolve) => requests.push({ texture: skin.texture, finish: resolve }))
const state = mountPreview(t, card)
assert.equal(requests[0].texture, 'https://textures.test/first.png')
card.value.defaultUser = 'second'
await flush()
assert.equal(state.texture, '')
requests[1].finish('data:image/png;base64,normalized-second')
await flush()
requests[0].finish('data:image/png;base64,normalized-first')
await flush()
assert.equal(state.texture, 'data:image/png;base64,normalized-second')
assert.equal(state.textureLoading, false)
})
test('account polling, avatar updates and another player changing skin keep the current preview loaded', async (t) => {
const avatar = ref('first-head')
const skins = ref<Record<string, { texture: string; variant: 'CLASSIC' | 'SLIM' }>>({
first: { texture: 'https://textures.test/first.png', variant: 'CLASSIC' },
second: { texture: 'https://textures.test/second.png', variant: 'CLASSIC' },
})
const card = ref({
accounts: [
{ profile: { id: 'first', name: 'First' }, account_type: 'microsoft' },
{ profile: { id: 'second', name: 'Second' }, account_type: 'microsoft' },
],
defaultUser: 'first',
getAccountSkin: (account: { profile: { id: string } }) => skins.value[account.profile.id],
getAccountAvatarUrl: () => avatar.value,
})
const requests: string[] = []
testGlobal.previewNormalize = (skin) => {
requests.push(skin.texture)
return requests.length === 1
? Promise.resolve('data:image/png;base64,current-skin')
: new Promise(() => {})
}
const state = mountPreview(t, card)
await flush()
assert.equal(state.texture, 'data:image/png;base64,current-skin')
card.value.accounts = card.value.accounts.map((account) => ({
...account,
profile: { ...account.profile },
}))
await flush()
assert.equal(
state.texture,
'data:image/png;base64,current-skin',
'polling must not clear the visible skin',
)
assert.equal(requests.length, 1, 'unchanged accounts must not normalize the skin again')
avatar.value = 'updated-head'
await flush()
assert.equal(state.texture, 'data:image/png;base64,current-skin')
assert.equal(requests.length, 1, 'avatar-only changes must not reload the 3D skin')
skins.value.second = { texture: 'https://textures.test/second-new.png', variant: 'SLIM' }
await flush()
assert.equal(state.texture, 'data:image/png;base64,current-skin')
assert.equal(state.textureLoading, false)
assert.equal(
requests.length,
1,
'another player changing skin must not reload the current preview',
)
})
test('the current player changing texture or model still reloads the preview', async (t) => {
const skin = ref({ texture: 'https://textures.test/original.png', variant: 'CLASSIC' })
const card = ref({
accounts: [{ profile: { id: 'first', name: 'First' }, account_type: 'microsoft' }],
defaultUser: 'first',
getAccountSkin: () => skin.value,
getAccountAvatarUrl: () => undefined,
})
const requests: string[] = []
testGlobal.previewNormalize = async (current) => {
requests.push(current.texture)
return `data:image/png;base64,skin-${requests.length}`
}
const state = mountPreview(t, card)
await flush()
skin.value.texture = 'https://textures.test/updated.png'
await flush()
assert.equal(state.texture, 'data:image/png;base64,skin-2')
skin.value.variant = 'SLIM'
await flush()
assert.equal(state.texture, 'data:image/png;base64,skin-3')
assert.deepEqual(requests, [
'https://textures.test/original.png',
'https://textures.test/updated.png',
'https://textures.test/updated.png',
])
})
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { RefreshCwIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import SkinPreviewRenderer from '@modrinth/ui/src/components/skin/SkinPreviewRenderer.vue'
import type { Ref } from 'vue'
import { computed, inject, onUnmounted, ref, watch } from 'vue'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import {
requestSkinSitePlayers,
selectedSkinSitePlayerId,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
} from '@/composables/skin-site-session'
import { get_normalized_skin_texture } from '@/helpers/skins'
import SkinSiteSelectionRail from './SkinSiteSelectionRail.vue'
const { formatMessage } = useVIntl()
const accountsCard = inject<Ref<InstanceType<typeof AccountsCard> | undefined>>('accountsCard')
const pendingId = ref<string | null>(null)
const messages = defineMessages({
players: { id: 'app.skin-site.home.preview.players', defaultMessage: 'Players' },
empty: { id: 'app.skin-site.home.preview.empty', defaultMessage: 'No players yet' },
emptyHint: {
id: 'app.skin-site.home.preview.empty-hint',
defaultMessage: 'Sign in or add a Minecraft account in the sidebar to see your skin here.',
},
unavailable: {
id: 'app.skin-site.home.preview.unavailable',
defaultMessage: 'This player has no skin to preview.',
},
loading: { id: 'app.skin-site.home.preview.loading', defaultMessage: 'Loading players…' },
failed: {
id: 'app.skin-site.home.preview.failed',
defaultMessage: 'Could not load skin-site players.',
},
})
const normalizeId = (id: string) => id.replaceAll('-', '').toLowerCase()
const players = computed(() =>
(accountsCard?.value?.accounts ?? []).map((account) => {
const sitePlayer =
account.account_type === 'yggdrasil'
? skinSitePlayers.value.find(
(player) => normalizeId(player.uuid) === normalizeId(account.profile.id),
)
: undefined
const skin = accountsCard?.value?.getAccountSkin(account)
return {
id: account.profile.id,
label: account.profile.name,
avatar: sitePlayer?.headDataUrl ?? accountsCard?.value?.getAccountAvatarUrl(account),
texture: sitePlayer?.skinDataUrl ?? skin?.texture ?? '',
variant: sitePlayer?.model
? sitePlayer.model === 'slim'
? ('SLIM' as const)
: ('CLASSIC' as const)
: (skin?.variant ?? 'CLASSIC'),
account,
}
}),
)
const selectedId = computed(() => {
const id = selectedSkinSitePlayerId.value ?? accountsCard?.value?.defaultUser
return (
players.value.find((player) => id && normalizeId(player.id) === normalizeId(id))?.id ?? null
)
})
const selectedPlayer = computed(() =>
players.value.find((player) => player.id === selectedId.value),
)
const loading = computed(
() =>
!players.value.length &&
(skinSiteStatus.value === 'checking' ||
skinSitePlayersStatus.value === 'checking' ||
!accountsCard?.value),
)
const texture = ref('')
const textureLoading = ref(false)
let textureRevision = 0
watch(
// Background account/head refreshes rebuild player objects without changing the skin.
[
() => selectedPlayer.value?.id,
() => selectedPlayer.value?.texture,
() => selectedPlayer.value?.variant,
],
async () => {
const player = selectedPlayer.value
const revision = ++textureRevision
texture.value = ''
textureLoading.value = !!player?.texture
if (!player?.texture) return
try {
const normalized = await get_normalized_skin_texture({
texture_key: `${player.id}:${player.texture}`,
texture: player.texture,
variant: player.variant,
source: 'custom_external',
is_equipped: true,
})
if (revision === textureRevision) texture.value = normalized
} catch {
if (revision === textureRevision && player.texture.startsWith('data:image/'))
texture.value = player.texture
} finally {
if (revision === textureRevision) textureLoading.value = false
}
},
{ immediate: true },
)
onUnmounted(() => {
textureRevision++
})
async function select(id: string) {
if (pendingId.value) return
const player = players.value.find((player) => player.id === id)
if (!player) return
pendingId.value = id
try {
await accountsCard?.value?.setAccount(player.account)
} finally {
pendingId.value = null
}
}
</script>
<template>
<section class="player-preview" :aria-label="formatMessage(messages.players)">
<SkinSiteSelectionRail
v-if="players.length"
:items="players"
:selected="selectedId"
:label="formatMessage(messages.players)"
vertical
:disabled="!!pendingId || accountsCard?.accountSelectionPending"
@select="select"
/>
<div class="preview-stage" :aria-busy="!!pendingId || loading || textureLoading">
<SkinPreviewRenderer
v-if="selectedPlayer && texture"
:texture-src="texture"
:variant="selectedPlayer.variant"
:nametag="selectedPlayer.label"
:initial-rotation="Math.PI / 8"
fit
/>
<div v-else class="preview-empty" role="status">
<SpinnerIcon v-if="loading || textureLoading" class="size-7 animate-spin" />
<UserIcon v-else class="size-10 opacity-40" />
<span v-if="loading || textureLoading">{{ formatMessage(messages.loading) }}</span>
<template v-else-if="selectedPlayer">
<strong class="text-contrast">{{ selectedPlayer.label }}</strong>
<span>{{ formatMessage(messages.unavailable) }}</span>
</template>
<template v-else>
<strong class="text-contrast">{{ formatMessage(messages.empty) }}</strong>
<span>{{ formatMessage(messages.emptyHint) }}</span>
</template>
</div>
<div v-if="pendingId" class="preview-switching">
<SpinnerIcon class="size-4 animate-spin" />
</div>
</div>
<div v-if="skinSitePlayersStatus === 'error'" class="preview-error">
<span>{{ formatMessage(messages.failed) }}</span>
<button type="button" @click="requestSkinSitePlayers().catch(() => {})">
<RefreshCwIcon class="size-4" />{{ formatMessage(commonMessages.retryButton) }}
</button>
</div>
</section>
</template>
<style scoped>
.player-preview {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1.5rem;
min-width: 0;
}
.preview-stage {
position: relative;
min-width: 0;
height: clamp(22rem, 57dvh, 38rem);
}
.preview-stage:only-child {
grid-column: 1 / -1;
}
.preview-empty {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
text-align: center;
color: var(--color-secondary);
font-size: 0.875rem;
}
.preview-switching {
position: absolute;
top: 1rem;
right: 1rem;
color: var(--color-brand);
}
.preview-error {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
color: var(--color-secondary);
font-size: 0.75rem;
}
.preview-error button {
display: flex;
align-items: center;
gap: 0.375rem;
border: 0;
background: transparent;
color: var(--color-brand);
cursor: pointer;
}
@media (max-width: 720px) {
.player-preview {
gap: 0.75rem;
}
.preview-stage {
height: clamp(20rem, 48dvh, 28rem);
}
}
</style>
@@ -12,9 +12,16 @@ import {
skinSiteUser,
} from '@/composables/skin-site-session'
import {
skinSiteAddPlayer,
skinSiteClearDefaultPlayer,
skinSiteDefaultPlayer,
skinSiteDeletePlayer,
skinSiteDeleteTexture,
type SkinSitePlayerSkin,
skinSitePlayerSkin,
type SkinSitePlayerSkin,
skinSiteRenamePlayer,
skinSiteSetDefaultPlayer,
skinSiteSwitchMojang,
skinSiteUploadTexture,
type SkinTextureType,
} from '@/helpers/skin-site-api'
@@ -51,6 +58,48 @@ const messages = defineMessages({
uploaded: { id: 'app.skin-site.profile-view.uploaded', defaultMessage: 'Texture updated.' },
removed: { id: 'app.skin-site.profile-view.removed', defaultMessage: 'Texture removed.' },
back: { id: 'app.skin-site.profile-view.back', defaultMessage: 'Back to home' },
addPlayer: { id: 'app.skin-site.profile-view.add-player', defaultMessage: 'New player' },
playerNamePlaceholder: {
id: 'app.skin-site.profile-view.player-name-placeholder',
defaultMessage: 'Player name',
},
confirm: { id: 'app.skin-site.profile-view.confirm', defaultMessage: 'Confirm' },
cancel: { id: 'app.skin-site.profile-view.cancel', defaultMessage: 'Cancel' },
rename: { id: 'app.skin-site.profile-view.rename', defaultMessage: 'Rename' },
delete: { id: 'app.skin-site.profile-view.delete', defaultMessage: 'Delete' },
setDefault: { id: 'app.skin-site.profile-view.set-default', defaultMessage: 'Set as default' },
clearDefault: {
id: 'app.skin-site.profile-view.clear-default',
defaultMessage: 'Clear default',
},
toggleMojang: {
id: 'app.skin-site.profile-view.toggle-mojang',
defaultMessage: 'Toggle Mojang',
},
isDefault: { id: 'app.skin-site.profile-view.is-default', defaultMessage: 'Default' },
added: { id: 'app.skin-site.profile-view.added', defaultMessage: 'Player added.' },
renamed: { id: 'app.skin-site.profile-view.renamed', defaultMessage: 'Player renamed.' },
deleted: { id: 'app.skin-site.profile-view.deleted', defaultMessage: 'Player deleted.' },
defaultSet: {
id: 'app.skin-site.profile-view.default-set',
defaultMessage: 'Default player updated.',
},
mojangToggled: {
id: 'app.skin-site.profile-view.mojang-toggled',
defaultMessage: 'Mojang status updated.',
},
deleteConfirm: {
id: 'app.skin-site.profile-view.delete-confirm',
defaultMessage: 'Delete this player? This cannot be undone.',
},
mojangConfirm: {
id: 'app.skin-site.profile-view.mojang-confirm',
defaultMessage: 'Toggle Mojang status for this player?',
},
renameConfirm: {
id: 'app.skin-site.profile-view.rename-confirm',
defaultMessage: 'Rename this player?',
},
})
const selectedId = ref<string | null>(null)
const texture = ref<SkinSitePlayerSkin>({})
@@ -93,6 +142,174 @@ watch(selectedId, (id) => {
else texture.value = {}
})
// --- Player management ---
const defaultUuid = ref<string | null>(null)
const busy = ref<string | null>(null)
async function loadDefaultPlayer() {
if (!signedIn.value) {
defaultUuid.value = null
return
}
try {
const res = await skinSiteDefaultPlayer()
defaultUuid.value = res.success ? (res.payload?.uuid ?? null) : null
} catch {
defaultUuid.value = null
}
}
const showAdd = ref(false)
const newName = ref('')
const renamingId = ref<string | null>(null)
const renameValue = ref('')
const confirmingRename = ref(false)
const confirmingDeleteId = ref<string | null>(null)
const confirmingMojangId = ref<string | null>(null)
function resetDialogs() {
showAdd.value = false
newName.value = ''
renamingId.value = null
renameValue.value = ''
confirmingRename.value = false
confirmingDeleteId.value = null
confirmingMojangId.value = null
}
async function submitAdd() {
const name = newName.value.trim()
if (!name || busy.value) return
busy.value = 'add'
try {
const res = await skinSiteAddPlayer(name)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.added) })
resetDialogs()
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function startRename(player: { uuid: string; name: string }) {
renamingId.value = player.uuid
renameValue.value = player.name
confirmingRename.value = false
}
function requestRename() {
const name = renameValue.value.trim()
if (!renamingId.value || !name) return
confirmingRename.value = true
}
async function submitRename() {
const name = renameValue.value.trim()
if (!renamingId.value || !name || busy.value) return
if (!confirmingRename.value) {
confirmingRename.value = true
return
}
busy.value = 'rename'
try {
const res = await skinSiteRenamePlayer(renamingId.value, name)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.renamed) })
resetDialogs()
await requestSkinSitePlayers()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function requestDelete(uuid: string) {
if (busy.value) return
confirmingDeleteId.value = uuid
}
async function submitDelete(uuid: string) {
if (busy.value) return
if (confirmingDeleteId.value !== uuid) {
confirmingDeleteId.value = uuid
return
}
busy.value = 'delete'
try {
const res = await skinSiteDeletePlayer(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
resetDialogs()
if (selectedId.value === uuid) selectedId.value = null
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function setDefaultPlayer(uuid: string) {
if (busy.value) return
busy.value = 'default'
try {
const res = await skinSiteSetDefaultPlayer(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.defaultSet) })
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function clearDefaultPlayer() {
if (busy.value) return
busy.value = 'default'
try {
const res = await skinSiteClearDefaultPlayer()
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.defaultSet) })
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function requestToggleMojang(uuid: string) {
if (busy.value) return
confirmingMojangId.value = uuid
}
async function toggleMojang(uuid: string) {
if (busy.value) return
if (confirmingMojangId.value !== uuid) {
confirmingMojangId.value = uuid
return
}
busy.value = 'mojang'
try {
const res = await skinSiteSwitchMojang(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.mojangToggled) })
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function onUpload(type: SkinTextureType, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -135,7 +352,10 @@ async function removeTexture(type: SkinTextureType) {
}
onMounted(() => {
if (signedIn.value) void requestSkinSitePlayers().catch(() => {})
if (signedIn.value) {
void requestSkinSitePlayers().catch(() => {})
void loadDefaultPlayer()
}
})
watch(signedIn, (value) => {
@@ -161,9 +381,79 @@ watch(signedIn, (value) => {
<div
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<div class="flex items-center justify-between gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<button
v-if="!showAdd"
type="button"
class="shrink-0 rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="showAdd = true"
>
+ {{ formatMessage(messages.addPlayer) }}
</button>
</div>
<div v-if="showAdd" class="flex flex-col gap-1.5 rounded-xl bg-button-bg p-2">
<input
v-model="newName"
type="text"
:placeholder="formatMessage(messages.playerNamePlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="submitAdd"
/>
<div class="flex gap-1.5">
<button
type="button"
class="flex-1 rounded-lg bg-brand px-2 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="busy === 'add' || !newName.trim()"
@click="submitAdd"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
type="button"
class="rounded-lg bg-bg-raised px-2 py-1 text-xs font-semibold text-secondary"
@click="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div
v-if="renamingId"
class="flex flex-col gap-1.5 rounded-xl bg-button-bg p-2"
>
<input
v-model="renameValue"
type="text"
:placeholder="formatMessage(messages.playerNamePlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="submitRename"
/>
<p v-if="confirmingRename" class="m-0 text-[11px] text-secondary">
{{ formatMessage(messages.renameConfirm) }}
</p>
<div class="flex gap-1.5">
<button
type="button"
class="flex-1 rounded-lg bg-brand px-2 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="busy === 'rename' || !renameValue.trim()"
@click="submitRename"
>
{{ formatMessage(confirmingRename ? messages.confirm : messages.rename) }}
</button>
<button
type="button"
class="rounded-lg bg-bg-raised px-2 py-1 text-xs font-semibold text-secondary"
@click="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
@@ -172,18 +462,17 @@ watch(signedIn, (value) => {
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.noPlayers) }}
</p>
<button
v-for="player in players"
:key="player.uuid"
type="button"
class="flex items-center gap-2 rounded-xl px-2 py-1.5 text-left transition-colors"
:class="
player.uuid === selectedId
? 'bg-brand-highlight text-contrast'
: 'bg-button-bg hover:brightness-110'
"
@click="selectPlayer(player.uuid)"
>
<div
v-for="player in players"
:key="player.uuid"
class="flex items-center gap-2 rounded-xl px-2 py-1.5 transition-colors"
:class="
player.uuid === selectedId
? 'bg-brand-highlight text-contrast'
: 'bg-button-bg hover:brightness-110'
"
@click="selectPlayer(player.uuid)"
>
<Avatar
:src="player.headDataUrl"
:alt="player.name"
@@ -192,10 +481,98 @@ watch(signedIn, (value) => {
class="!rounded-md"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ player.name }}</span>
<span
v-if="defaultUuid === player.uuid"
class="shrink-0 rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.isDefault) }}
</span>
<span v-if="player.isMojang" class="shrink-0 text-[10px] uppercase text-secondary"
>Mojang</span
>
</button>
<div class="flex shrink-0 items-center gap-1">
<button
v-if="defaultUuid === player.uuid"
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-brand"
:disabled="busy !== null"
@click.stop="clearDefaultPlayer"
>
{{ formatMessage(messages.clearDefault) }}
</button>
<button
v-else
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="setDefaultPlayer(player.uuid)"
>
{{ formatMessage(messages.setDefault) }}
</button>
<button
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="startRename(player)"
>
{{ formatMessage(messages.rename) }}
</button>
<button
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="requestToggleMojang(player.uuid)"
>
{{ formatMessage(messages.toggleMojang) }}
</button>
<button
v-if="confirmingDeleteId === player.uuid"
type="button"
class="rounded bg-red px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
:disabled="busy !== null"
@click.stop="submitDelete(player.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
v-else
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-red hover:bg-bg"
:disabled="busy !== null"
@click.stop="requestDelete(player.uuid)"
>
{{ formatMessage(messages.delete) }}
</button>
<button
v-if="confirmingDeleteId === player.uuid || confirmingMojangId === player.uuid"
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
@click.stop="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div
v-if="confirmingDeleteId === player.uuid"
class="rounded-lg bg-button-bg px-2 py-1 text-[11px] text-red"
>
{{ formatMessage(messages.deleteConfirm) }}
</div>
<div
v-if="confirmingMojangId === player.uuid"
class="rounded-lg bg-button-bg px-2 py-1 text-[11px] text-secondary"
>
{{ formatMessage(messages.mojangConfirm) }}
<button
type="button"
class="ml-1 rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
:disabled="busy !== null"
@click.stop="toggleMojang(player.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
</div>
</div>
<!-- Preview + texture actions -->
@@ -207,10 +584,37 @@ watch(signedIn, (value) => {
</p>
<template v-else>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">{{ selectedPlayer.name }}</h3>
<span
v-if="defaultUuid === selectedPlayer.uuid"
class="rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.isDefault) }}
</span>
<span v-if="selectedPlayer.isMojang" class="text-xs text-secondary">Mojang</span>
<button
v-if="confirmingMojangId === selectedPlayer.uuid"
type="button"
class="rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-contrast"
:disabled="busy !== null"
@click="toggleMojang(selectedPlayer.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
v-else
type="button"
class="rounded-full bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary hover:brightness-110"
:disabled="busy !== null"
@click="requestToggleMojang(selectedPlayer.uuid)"
>
{{ formatMessage(messages.toggleMojang) }}
</button>
</div>
<p v-if="confirmingMojangId === selectedPlayer.uuid" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.mojangConfirm) }}
</p>
<div class="relative h-80 w-full overflow-hidden rounded-xl bg-button-bg">
<div v-if="loadingTexture" class="absolute inset-0 flex items-center justify-center">
@@ -60,7 +60,7 @@ const emailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.
const requiredAnswered = computed(() => {
if (!activePaper.value) return false
return activePaper.value.questions.every((q) => {
return (activePaper.value.questions ?? []).every((q) => {
if (q.informationQuestion || q.optional) return true
const value = answers.value[q.id]
if (Array.isArray(value)) return value.length > 0
@@ -81,7 +81,7 @@ function defaultAnswer(q: SkinSiteReviewPublicQuestion): string | string[] {
function openPaper(paper: SkinSiteReviewPublicPaper) {
activePaper.value = paper
answers.value = {}
for (const q of paper.questions) answers.value[q.id] = defaultAnswer(q)
for (const q of paper.questions ?? []) answers.value[q.id] = defaultAnswer(q)
email.value = ''
inviteCode.value = ''
receipt.value = null
@@ -119,7 +119,7 @@ async function submitApplication() {
submitting.value = true
try {
const payloadAnswers: Record<string, unknown> = {}
for (const q of activePaper.value.questions) {
for (const q of activePaper.value.questions ?? []) {
if (q.informationQuestion) continue
payloadAnswers[q.id] = answers.value[q.id] ?? ''
}
@@ -226,7 +226,7 @@ onMounted(loadCatalog)
</label>
<div
v-for="question in activePaper.questions"
v-for="question in activePaper.questions ?? []"
:key="question.id"
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { useResizeObserver } from '@vueuse/core'
import type { Component } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
export interface SelectionItem {
id: string
label: string
description?: string
icon?: Component
avatar?: string
}
const props = withDefaults(
defineProps<{
items: SelectionItem[]
selected: string | null
label: string
vertical?: boolean
disabled?: boolean
}>(),
{ vertical: false, disabled: false },
)
const emit = defineEmits<{ select: [id: string] }>()
const rail = ref<HTMLElement>()
const indicator = ref({ x: 0, y: 0, width: 0, height: 0 })
const ready = ref(false)
const active = computed(() => props.items.some((item) => item.id === props.selected))
async function updateIndicator() {
await nextTick()
const button = rail.value?.querySelector<HTMLElement>('[aria-pressed="true"]')
if (!button) return
indicator.value = {
x: button.offsetLeft,
y: button.offsetTop,
width: button.offsetWidth,
height: button.offsetHeight,
}
if (!ready.value)
requestAnimationFrame(() => {
ready.value = true
})
}
watch(() => [props.selected, props.items], updateIndicator, { deep: true })
onMounted(updateIndicator)
useResizeObserver(rail, updateIndicator)
</script>
<template>
<div class="selection-rail" :class="{ vertical }" :aria-label="label" role="group">
<div ref="rail" class="selection-track">
<span
v-show="active"
aria-hidden="true"
class="selection-indicator"
:class="{ ready }"
:style="{
transform: `translate3d(${indicator.x}px, ${indicator.y}px, 0)`,
width: `${indicator.width}px`,
height: `${indicator.height}px`,
}"
/>
<button
v-for="item in items"
:key="item.id"
v-tooltip="{
content: item.description ? `${item.label} · ${item.description}` : item.label,
placement: vertical ? 'right' : 'top',
}"
type="button"
class="selection-button"
:aria-label="item.label"
:aria-pressed="selected === item.id"
:disabled="disabled"
@click="emit('select', item.id)"
>
<Avatar
v-if="vertical"
:src="item.avatar"
:alt="item.label"
size="2rem"
pixelated
:unframed-natural-width="36"
/>
<component :is="item.icon" v-else-if="item.icon" class="size-5 shrink-0" />
<span v-if="!vertical" class="selection-label">{{ item.label }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.selection-rail {
min-width: 0;
padding: 0.375rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xl);
background: var(--color-raised-bg);
}
.selection-track {
position: relative;
display: flex;
gap: 0.375rem;
}
.selection-rail:not(.vertical) {
container-type: inline-size;
}
.selection-button {
position: relative;
z-index: 1;
display: flex;
flex: 1;
min-width: 0;
align-items: center;
justify-content: center;
gap: 0.625rem;
padding: 0.875rem 0.75rem;
border: 0;
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-secondary);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition:
color 180ms,
background 180ms,
transform 180ms;
}
.selection-button:hover {
color: var(--color-contrast);
background: var(--color-button-bg);
}
.selection-button[aria-pressed='true'] {
color: var(--color-contrast);
}
.selection-button:active {
transform: scale(0.94);
}
.selection-button:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -2px;
}
.selection-button:disabled {
cursor: wait;
}
.selection-indicator {
position: absolute;
top: 0;
left: 0;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-contrast) 13%, var(--color-raised-bg));
pointer-events: none;
}
.selection-indicator.ready {
transition:
transform 480ms cubic-bezier(0.22, 1.4, 0.36, 1),
width 350ms ease,
height 350ms ease;
}
.vertical {
align-self: start;
}
.vertical .selection-track {
flex-direction: column;
}
.vertical .selection-button {
flex: none;
padding: 0.75rem;
}
.selection-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container (max-width: 40rem) {
.selection-label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.selection-indicator.ready,
.selection-button {
transition: none;
}
}
</style>
@@ -6,12 +6,20 @@ import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteWikiPageDetail,
skinSiteWikiPage,
skinSiteWikiDiff,
skinSiteWikiHistory,
skinSiteWikiRevert,
skinSiteWikiRevision,
type SkinSiteWikiDiffLine,
type SkinSiteWikiNode,
type SkinSiteWikiRevisionInfo,
} from '@/helpers/skin-site-api'
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
import WikiBrowser from './wiki/WikiBrowser.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { handleError, addNotification } = injectNotificationManager()
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
initialTitle: '',
@@ -40,13 +48,50 @@ const messages = defineMessages({
revision: { id: 'app.skin-site.wiki.revision', defaultMessage: 'Revision #{id}' },
categories: { id: 'app.skin-site.wiki.categories', defaultMessage: 'Categories' },
back: { id: 'app.skin-site.wiki.back', defaultMessage: 'Back' },
history: { id: 'app.skin-site.wiki.history', defaultMessage: 'History' },
read: { id: 'app.skin-site.wiki.read', defaultMessage: 'Read' },
historyEmpty: {
id: 'app.skin-site.wiki.history-empty',
defaultMessage: 'No revisions yet.',
},
noSummary: {
id: 'app.skin-site.wiki.no-summary',
defaultMessage: 'No edit summary',
},
minor: { id: 'app.skin-site.wiki.minor', defaultMessage: 'Minor' },
viewRevision: { id: 'app.skin-site.wiki.view-revision', defaultMessage: 'View' },
revertTo: {
id: 'app.skin-site.wiki.revert-to',
defaultMessage: 'Revert to this',
},
confirmRevert: {
id: 'app.skin-site.wiki.confirm-revert',
defaultMessage: 'Confirm revert',
},
revertDone: {
id: 'app.skin-site.wiki.revert-done',
defaultMessage: 'Page reverted to the selected revision.',
},
revisionPreview: {
id: 'app.skin-site.wiki.revision-preview',
defaultMessage: 'Preview of revision #{id}',
},
closePreview: {
id: 'app.skin-site.wiki.close-preview',
defaultMessage: 'Close preview',
},
diffTitle: {
id: 'app.skin-site.wiki.diff-title',
defaultMessage: 'Line diff against the current revision',
},
cancel: { id: 'app.skin-site.wiki.cancel', defaultMessage: 'Cancel' },
})
const titleInput = ref(props.initialTitle)
const page = ref<SkinSiteWikiPageDetail | null>(null)
const loading = ref(false)
const notFound = ref(false)
const history = ref<string[]>([])
const navStack = ref<string[]>([])
const currentTitle = computed(() => page.value?.title ?? '')
@@ -64,8 +109,8 @@ async function openPage(title: string) {
return
}
page.value = res.payload
if (currentTitle.value && history.value[history.value.length - 1] !== currentTitle.value) {
history.value.push(currentTitle.value)
if (currentTitle.value && navStack.value[navStack.value.length - 1] !== currentTitle.value) {
navStack.value.push(currentTitle.value)
}
} catch (error) {
handleError(error)
@@ -77,7 +122,7 @@ async function openPage(title: string) {
}
function goBack() {
const prev = history.value.pop()
const prev = navStack.value.pop()
if (prev) void openPage(prev)
}
@@ -89,6 +134,77 @@ function formatDate(ts: number): string {
}
}
const mode = ref<'read' | 'history'>('read')
const history = ref<SkinSiteWikiRevisionInfo[]>([])
const historyLoading = ref(false)
const selectedRevisionId = ref<number | null>(null)
const selectedRevisionNodes = ref<SkinSiteWikiNode[] | null>(null)
const diffLines = ref<SkinSiteWikiDiffLine[]>([])
const pendingRevertId = ref<number | null>(null)
const reverting = ref(false)
async function showHistory() {
if (!page.value) return
mode.value = 'history'
historyLoading.value = true
try {
const res = await skinSiteWikiHistory(page.value.title)
if (!res.success) throw new Error(res.errorMessage)
history.value = res.payload.items ?? []
} catch (error) {
handleError(error)
history.value = []
} finally {
historyLoading.value = false
}
}
function backToRead() {
mode.value = 'read'
selectedRevisionNodes.value = null
diffLines.value = []
pendingRevertId.value = null
}
async function viewRevision(revisionId: number) {
if (!page.value) return
try {
const res = await skinSiteWikiRevision(page.value.title, revisionId)
if (!res.success) throw new Error(res.errorMessage)
selectedRevisionId.value = revisionId
selectedRevisionNodes.value = res.payload.nodes ?? []
diffLines.value = []
if (revisionId !== page.value.revisionId) {
const diff = await skinSiteWikiDiff(page.value.title, revisionId, page.value.revisionId)
if (diff.success) diffLines.value = diff.payload ?? []
}
} catch (error) {
handleError(error)
}
}
async function revertToRevision(revisionId: number) {
if (!page.value || reverting.value) return
reverting.value = true
try {
const res = await skinSiteWikiRevert({
title: page.value.title,
revisionId,
baseRevisionId: page.value.revisionId,
})
if (!res.success) throw new Error(res.errorMessage)
page.value = res.payload
pendingRevertId.value = null
backToRead()
addNotification({ type: 'success', title: formatMessage(messages.revertDone) })
} catch (error) {
handleError(error)
} finally {
reverting.value = false
}
}
function onNavigate(title: string) {
if (title) void openPage(title)
}
@@ -102,7 +218,7 @@ onMounted(() => {
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<button
v-if="history.length"
v-if="navStack.length"
type="button"
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="goBack"
@@ -124,8 +240,28 @@ onMounted(() => {
>
{{ formatMessage(messages.open) }}
</button>
<template v-if="page">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
:class="mode === 'read' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="backToRead"
>
{{ formatMessage(messages.read) }}
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
:class="mode === 'history' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="showHistory"
>
{{ formatMessage(messages.history) }}
</button>
</template>
</div>
<WikiBrowser @navigate="onNavigate" />
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
@@ -142,12 +278,59 @@ onMounted(() => {
>
{{ formatMessage(messages.empty) }}
</p>
<template v-else>
<template v-else-if="mode === 'read'">
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b border-solid border-surface-5 pb-2">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ page.title }}</h1>
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
</span>
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
</span>
<button
type="button"
class="rounded-lg px-2.5 py-1 text-xs font-semibold disabled:opacity-50"
:class="watching ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
:disabled="watchBusy"
@click="toggleWatch"
>
{{ formatMessage(watching ? messages.unwatch : messages.watch) }}
</button>
<button
v-if="page.canEdit"
type="button"
class="rounded-lg bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="openMove"
>
{{ formatMessage(messages.move) }}
</button>
</div>
</div>
<div
v-if="moving"
class="flex flex-wrap items-center gap-2 rounded-lg border border-solid border-surface-5 bg-bg-raised p-2"
>
<input
v-model="moveTarget"
type="text"
:placeholder="formatMessage(messages.moveTarget)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="confirmMove"
/>
<button
type="button"
class="rounded-lg bg-brand px-2.5 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="moveBusy"
@click="confirmMove"
>
{{ formatMessage(messages.moveConfirm) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary"
@click="moving = false"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
<p class="m-0 text-[11px] text-secondary">
@@ -188,5 +371,123 @@ onMounted(() => {
</span>
</div>
</template>
<template v-else>
<div v-if="historyLoading" class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<template v-else>
<div
v-if="selectedRevisionNodes"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.revisionPreview, { id: selectedRevisionId }) }}
</span>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="selectedRevisionNodes = null; diffLines = []"
>
{{ formatMessage(messages.closePreview) }}
</button>
</div>
<article class="min-w-0 text-sm leading-7 text-contrast">
<WikiNodeRenderer :nodes="selectedRevisionNodes" @navigate="onNavigate" />
</article>
<details v-if="diffLines.length" class="rounded-lg border border-solid border-surface-5 p-3">
<summary class="cursor-pointer text-xs font-semibold text-contrast">
{{ formatMessage(messages.diffTitle) }}
</summary>
<div class="mt-2 max-h-96 overflow-auto font-mono text-[11px]">
<div
v-for="(line, idx) in diffLines"
:key="idx"
class="px-2 py-0.5 whitespace-pre-wrap"
:class="
line.type === 'ADDED'
? 'bg-green/15 text-green'
: line.type === 'REMOVED'
? 'bg-red/15 text-red'
: 'text-secondary'
"
>
{{ line.type === 'ADDED' ? '+' : line.type === 'REMOVED' ? '-' : ' ' }}{{ line.text }}
</div>
</div>
</details>
</div>
<p
v-if="!history.length"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.historyEmpty) }}
</p>
<div v-else class="flex flex-col divide-y divide-surface-5">
<div
v-for="revision in history"
:key="revision.id"
class="flex flex-wrap items-start justify-between gap-3 py-3"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-contrast">{{ revision.authorName }}</span>
<span
v-if="revision.minor"
class="rounded bg-button-bg px-2 py-0.5 text-[10px] text-secondary"
>
{{ formatMessage(messages.minor) }}
</span>
<span class="text-[10px] text-secondary">#{{ revision.id }}</span>
</div>
<p class="m-0 mt-1 text-xs text-secondary">
{{ revision.summary || formatMessage(messages.noSummary) }}
</p>
<p class="m-0 mt-0.5 text-[10px] text-secondary">{{ formatDate(revision.createdAt) }}</p>
</div>
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="viewRevision(revision.id)"
>
{{ formatMessage(messages.viewRevision) }}
</button>
<template v-if="page && revision.id !== page.revisionId">
<button
v-if="pendingRevertId !== revision.id"
type="button"
class="rounded-lg bg-yellow/20 px-3 py-1 text-xs font-semibold text-yellow hover:brightness-110 disabled:opacity-50"
:disabled="reverting"
@click="pendingRevertId = revision.id"
>
{{ formatMessage(messages.revertTo) }}
</button>
<template v-else>
<button
type="button"
class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="reverting"
@click="revertToRevision(revision.id)"
>
{{ formatMessage(messages.confirmRevert) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary"
@click="pendingRevertId = null"
>
{{ formatMessage(messages.cancel) }}
</button>
</template>
</template>
</div>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -5,6 +5,7 @@ import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateLauncherRelease,
skinSiteDeleteLauncherRelease,
type SkinSiteLauncherAdminState,
skinSiteLauncherAdminState,
type SkinSiteLauncherRelease,
@@ -32,6 +33,19 @@ const messages = defineMessages({
active: { id: 'app.skin-site.admin.launcher.active', defaultMessage: 'Active' },
activate: { id: 'app.skin-site.admin.launcher.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.launcher.activated', defaultMessage: 'Release activated.' },
deleteRelease: {
id: 'app.skin-site.admin.launcher.delete',
defaultMessage: 'Delete release',
},
confirmDelete: {
id: 'app.skin-site.admin.launcher.confirm-delete',
defaultMessage:
'Permanently delete launcher {version} and all its installation files from the server? This version will no longer be available for download or rollback. Installed launchers will not be affected.',
},
deleted: {
id: 'app.skin-site.admin.launcher.deleted',
defaultMessage: 'Release and its installation files deleted.',
},
upload: { id: 'app.skin-site.admin.launcher.upload', defaultMessage: 'Upload release' },
uploadHint: {
id: 'app.skin-site.admin.launcher.upload-hint',
@@ -64,6 +78,10 @@ const uploadPercent = ref(0)
const forceUpdate = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const activatingId = ref<number | null>(null)
const deletingId = ref<number | null>(null)
const mutating = computed(
() => uploading.value || activatingId.value !== null || deletingId.value !== null,
)
const releases = computed(() => state.value?.releases ?? [])
const total = computed(() => releases.value.length)
@@ -105,7 +123,7 @@ function nextPage() {
}
async function activate(release: SkinSiteLauncherRelease) {
if (activatingId.value !== null) return
if (release.active || mutating.value || loading.value) return
activatingId.value = release.id
try {
const result = await skinSiteActivateLauncherRelease(release.id)
@@ -119,6 +137,24 @@ async function activate(release: SkinSiteLauncherRelease) {
}
}
async function remove(release: SkinSiteLauncherRelease) {
if (release.active || mutating.value || loading.value) return
if (!window.confirm(formatMessage(messages.confirmDelete, { version: release.version }))) return
deletingId.value = release.id
try {
const result = await skinSiteDeleteLauncherRelease(release.id)
if (!result.success) throw new Error(result.errorMessage)
if (state.value)
state.value.releases = state.value.releases.filter((item) => item.id !== release.id)
offset.value = Math.min(offset.value, Math.max(0, (pageCount.value - 1) * PAGE_SIZE))
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
function pickFile() {
fileInput.value?.click()
}
@@ -127,7 +163,7 @@ async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || uploading.value) return
if (!file || mutating.value || loading.value) return
uploading.value = true
uploadPercent.value = 0
try {
@@ -180,7 +216,7 @@ onMounted(() => void load())
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="uploading"
:disabled="mutating || loading"
@click="pickFile"
>
{{ formatMessage(messages.upload) }}
@@ -244,11 +280,21 @@ onMounted(() => void load())
v-if="!release.active"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="activatingId !== null"
:disabled="mutating"
@click="activate(release)"
>
{{ formatMessage(messages.activate) }}
</button>
<button
v-if="!release.active"
type="button"
class="flex shrink-0 cursor-pointer items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-red hover:brightness-110 active:brightness-90 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="mutating"
@click="remove(release)"
>
<SpinnerIcon v-if="deletingId === release.id" class="size-3 animate-spin" />
{{ formatMessage(messages.deleteRelease) }}
</button>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
@@ -7,6 +7,16 @@ import {
skinSiteWikiAudit,
type SkinSiteWikiAuditInfo,
type SkinSiteWikiEditPolicy,
type SkinSiteWikiMigrationCandidate,
skinSiteWikiMigrationCandidates,
skinSiteWikiMigrationDecide,
skinSiteWikiMigrationDelete,
type SkinSiteWikiMigrationJobInfo,
skinSiteWikiMigrationJobs,
skinSiteWikiMigrationPublish,
skinSiteWikiMigrationRollback,
skinSiteWikiMigrationStartAutomatic,
skinSiteWikiMigrationStartXml,
skinSiteWikiPage,
type SkinSiteWikiPageDetail,
skinSiteWikiProtectPage,
@@ -16,22 +26,18 @@ import {
type SkinSiteWikiSettings,
skinSiteWikiSettings,
skinSiteWikiUpdateSettings,
skinSiteWikiMigrationCandidates,
skinSiteWikiMigrationDecide,
skinSiteWikiMigrationDelete,
skinSiteWikiMigrationJobs,
skinSiteWikiMigrationPublish,
skinSiteWikiMigrationRollback,
skinSiteWikiMigrationStartAutomatic,
skinSiteWikiMigrationStartXml,
type SkinSiteWikiMigrationCandidate,
type SkinSiteWikiMigrationJobInfo,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
policyPublic: { id: 'app.skin-site.admin.wiki.policy-public', defaultMessage: 'Everyone' },
policyMember: { id: 'app.skin-site.admin.wiki.policy-member', defaultMessage: 'Members' },
policyAdmin: {
id: 'app.skin-site.admin.wiki.policy-admin',
defaultMessage: 'Website administrators',
},
title: { id: 'app.skin-site.admin.wiki.title', defaultMessage: 'Wiki' },
tabPage: { id: 'app.skin-site.admin.wiki.tab-page', defaultMessage: 'Page' },
tabAudit: { id: 'app.skin-site.admin.wiki.tab-audit', defaultMessage: 'Audit log' },
@@ -61,7 +67,8 @@ const messages = defineMessages({
},
credentialsHint: {
id: 'app.skin-site.admin.wiki.credentials-hint',
defaultMessage: 'Credentials stay in memory for this job only. HTTPS is required when supplied.',
defaultMessage:
'Credentials stay in memory for this job only. HTTPS is required when supplied.',
},
allowPrivate: {
id: 'app.skin-site.admin.wiki.allow-private',
@@ -529,8 +536,7 @@ async function loadMigrationJobs() {
if (!res.success) throw new Error(res.errorMessage)
migrationJobs.value = res.payload ?? []
if (selectedJob.value) {
selectedJob.value =
migrationJobs.value.find((j) => j.id === selectedJob.value!.id) ?? null
selectedJob.value = migrationJobs.value.find((j) => j.id === selectedJob.value!.id) ?? null
}
} catch (error) {
handleError(error)
@@ -821,8 +827,8 @@ onMounted(() => void loadSettings())
v-model="protectRead"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
>
<option value="PUBLIC">PUBLIC</option>
<option value="MEMBER">MEMBER</option>
<option value="PUBLIC">{{ formatMessage(messages.policyPublic) }}</option>
<option value="MEMBER">{{ formatMessage(messages.policyMember) }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
@@ -833,8 +839,8 @@ onMounted(() => void loadSettings())
v-model="protectEdit"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
>
<option value="MEMBER">MEMBER</option>
<option value="WEB_ADMIN">WEB_ADMIN</option>
<option value="MEMBER">{{ formatMessage(messages.policyMember) }}</option>
<option value="WEB_ADMIN">{{ formatMessage(messages.policyAdmin) }}</option>
</select>
</label>
<button
@@ -1045,24 +1051,60 @@ onMounted(() => void loadSettings())
</summary>
<div class="mt-3 grid gap-2 sm:grid-cols-3">
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.staleDays) }}</span>
<input v-model.number="automaticForm.rules.staleDays" type="number" min="1" max="36500" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
<span class="text-[11px] text-secondary">{{
formatMessage(messages.staleDays)
}}</span>
<input
v-model.number="automaticForm.rules.staleDays"
type="number"
min="1"
max="36500"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.maintainerWindow) }}</span>
<input v-model.number="automaticForm.rules.maintainerWindowDays" type="number" min="1" max="36500" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
<span class="text-[11px] text-secondary">{{
formatMessage(messages.maintainerWindow)
}}</span>
<input
v-model.number="automaticForm.rules.maintainerWindowDays"
type="number"
min="1"
max="36500"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.maxMaintainers) }}</span>
<input v-model.number="automaticForm.rules.maximumMaintainers" type="number" min="0" max="100" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
<span class="text-[11px] text-secondary">{{
formatMessage(messages.maxMaintainers)
}}</span>
<input
v-model.number="automaticForm.rules.maximumMaintainers"
type="number"
min="0"
max="100"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
<label class="flex flex-col gap-1 sm:col-span-3">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.staleMarkers) }}</span>
<input v-model="staleMarkersText" type="text" :placeholder="formatMessage(messages.staleMarkersHint)" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
<span class="text-[11px] text-secondary">{{
formatMessage(messages.staleMarkers)
}}</span>
<input
v-model="staleMarkersText"
type="text"
:placeholder="formatMessage(messages.staleMarkersHint)"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast"
/>
</label>
</div>
</details>
<button type="button" class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="startingMigration" @click="startAutomaticMigration">
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="startingMigration"
@click="startAutomaticMigration"
>
{{ formatMessage(messages.startAuto) }}
</button>
</div>
@@ -1075,9 +1117,19 @@ onMounted(() => void loadSettings())
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.xmlFile) }}
</span>
<input ref="xmlInput" type="file" accept=".xml,text/xml,application/xml" class="text-xs text-contrast" />
<input
ref="xmlInput"
type="file"
accept=".xml,text/xml,application/xml"
class="text-xs text-contrast"
/>
</label>
<button type="button" class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="startingMigration" @click="startXmlMigration">
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="startingMigration"
@click="startXmlMigration"
>
{{ formatMessage(messages.startXml) }}
</button>
</div>
@@ -1087,16 +1139,27 @@ onMounted(() => void loadSettings())
<h4 class="m-0 text-sm font-bold text-contrast">
{{ formatMessage(messages.jobs) }}
</h4>
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="jobsLoading" @click="loadMigrationJobs">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="jobsLoading"
@click="loadMigrationJobs"
>
{{ formatMessage(messages.refreshJobs) }}
</button>
</div>
<div v-if="jobsLoading" class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary">
<div
v-if="jobsLoading"
class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="!migrationJobs.length" class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary">
<p
v-else-if="!migrationJobs.length"
class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary"
>
{{ formatMessage(messages.jobsEmpty) }}
</p>
<div v-else class="flex flex-col gap-2">
@@ -1107,21 +1170,43 @@ onMounted(() => void loadSettings())
:class="selectedJob?.id === job.id ? 'border-brand bg-brand/5' : 'border-surface-5'"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-semibold text-contrast">#{{ job.id }} · {{ job.source }}</span>
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" :class="jobStateClass(job.state)">
<span class="text-sm font-semibold text-contrast"
>#{{ job.id }} · {{ job.source }}</span
>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="jobStateClass(job.state)"
>
{{ jobStateLabel(job.state) }}
</span>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-button-bg">
<div class="h-full bg-brand transition-all" :style="{ width: (job.total ? (job.reviewed / job.total) * 100 : 0) + '%' }"></div>
<div
class="h-full bg-brand transition-all"
:style="{ width: (job.total ? (job.reviewed / job.total) * 100 : 0) + '%' }"
></div>
</div>
<div class="mt-2 flex flex-wrap justify-between gap-2 text-[11px] text-secondary">
<span>{{ formatMessage(messages.reviewedCount, { reviewed: job.reviewed, total: job.total }) }}</span>
<span>{{ formatMessage(messages.importedExcluded, { imported: job.imported, excluded: job.excluded }) }}</span>
<span>{{
formatMessage(messages.reviewedCount, {
reviewed: job.reviewed,
total: job.total,
})
}}</span>
<span>{{
formatMessage(messages.importedExcluded, {
imported: job.imported,
excluded: job.excluded,
})
}}</span>
</div>
<p v-if="job.error" class="m-0 mt-2 text-xs text-red">{{ job.error }}</p>
<div class="mt-2 flex flex-wrap justify-end gap-2">
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110" @click="selectJob(job)">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="selectJob(job)"
>
{{ formatMessage(messages.viewJob) }}
</button>
<button
@@ -1139,10 +1224,19 @@ onMounted(() => void loadSettings())
{{ formatMessage(messages.confirmDeleteJob, { id: job.id }) }}
</p>
<div class="mt-2 flex justify-end gap-2">
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary" @click="confirmingDeleteId = null">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary"
@click="confirmingDeleteId = null"
>
{{ formatMessage(messages.cancel) }}
</button>
<button type="button" class="rounded-lg bg-red px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50" :disabled="deletingJob" @click="deleteJob(job)">
<button
type="button"
class="rounded-lg bg-red px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="deletingJob"
@click="deleteJob(job)"
>
{{ formatMessage(messages.deleteJob) }}
</button>
</div>
@@ -1151,7 +1245,10 @@ onMounted(() => void loadSettings())
</div>
</div>
<div v-if="selectedJob" class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 p-4">
<div
v-if="selectedJob"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<h4 class="m-0 text-sm font-bold text-contrast">
{{ formatMessage(messages.candidates) }}
@@ -1180,20 +1277,35 @@ onMounted(() => void loadSettings())
<div v-if="confirmingRollbackId === selectedJob.id" class="rounded-lg bg-yellow/10 p-2">
<p class="m-0 text-xs text-yellow">{{ formatMessage(messages.confirmRollback) }}</p>
<div class="mt-2 flex justify-end gap-2">
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary" @click="confirmingRollbackId = null">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary"
@click="confirmingRollbackId = null"
>
{{ formatMessage(messages.cancel) }}
</button>
<button type="button" class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50" :disabled="rollingBack" @click="rollbackMigration">
<button
type="button"
class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="rollingBack"
@click="rollbackMigration"
>
{{ formatMessage(messages.rollback) }}
</button>
</div>
</div>
<div v-if="candidatesLoading && !candidates.length" class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary">
<div
v-if="candidatesLoading && !candidates.length"
class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="!candidates.length" class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary">
<p
v-else-if="!candidates.length"
class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary"
>
{{ formatMessage(messages.candidatesEmpty) }}
</p>
<div v-else class="flex flex-col gap-2">
@@ -1203,20 +1315,38 @@ onMounted(() => void loadSettings())
class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">{{ candidate.title }}</span>
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" :class="decisionClass(candidate.decision)">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">{{
candidate.title
}}</span>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="decisionClass(candidate.decision)"
>
{{ decisionLabel(candidate.decision) }}
</span>
<span v-if="candidate.conflict" class="rounded-full bg-yellow/20 px-2 py-0.5 text-[10px] font-semibold text-yellow">
<span
v-if="candidate.conflict"
class="rounded-full bg-yellow/20 px-2 py-0.5 text-[10px] font-semibold text-yellow"
>
{{ formatMessage(messages.conflict) }}
</span>
</div>
<div class="flex flex-wrap gap-3 text-[11px] text-secondary">
<span>{{ formatMessage(messages.lastUpdated, { date: new Date(candidate.lastUpdatedAt).toLocaleDateString() }) }}</span>
<span>{{ formatMessage(messages.maintainerCount, { count: candidate.maintainerCount }) }}</span>
<span>{{
formatMessage(messages.lastUpdated, {
date: new Date(candidate.lastUpdatedAt).toLocaleDateString(),
})
}}</span>
<span>{{
formatMessage(messages.maintainerCount, { count: candidate.maintainerCount })
}}</span>
</div>
<div v-if="candidate.suspiciousReasons.length" class="flex flex-wrap gap-1">
<span v-for="reason in candidate.suspiciousReasons" :key="reason" class="rounded bg-bg px-1.5 py-0.5 text-[10px] text-secondary">
<span
v-for="reason in candidate.suspiciousReasons"
:key="reason"
class="rounded bg-bg px-1.5 py-0.5 text-[10px] text-secondary"
>
{{ reason }}
</span>
</div>
@@ -1228,13 +1358,25 @@ onMounted(() => void loadSettings())
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<label class="flex cursor-pointer items-center gap-1.5 text-[11px] text-contrast">
<input v-model="candidateOverwrite[candidate.id]" type="checkbox" class="size-3" />
<input
v-model="candidateOverwrite[candidate.id]"
type="checkbox"
class="size-3"
/>
{{ formatMessage(messages.overwrite) }}
</label>
<button type="button" class="rounded-lg bg-green/20 px-3 py-1 text-xs font-semibold text-green hover:brightness-110" @click="decideCandidate(candidate, 'IMPORT')">
<button
type="button"
class="rounded-lg bg-green/20 px-3 py-1 text-xs font-semibold text-green hover:brightness-110"
@click="decideCandidate(candidate, 'IMPORT')"
>
{{ formatMessage(messages.importAction) }}
</button>
<button type="button" class="rounded-lg bg-red/20 px-3 py-1 text-xs font-semibold text-red hover:brightness-110" @click="decideCandidate(candidate, 'EXCLUDE')">
<button
type="button"
class="rounded-lg bg-red/20 px-3 py-1 text-xs font-semibold text-red hover:brightness-110"
@click="decideCandidate(candidate, 'EXCLUDE')"
>
{{ formatMessage(messages.excludeAction) }}
</button>
</div>
@@ -1251,8 +1393,6 @@ onMounted(() => void loadSettings())
</div>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatDonationAmount } from './donation-format.ts'
test('donation totals and individual amounts carry yuan and exactly two decimals', () => {
for (const [amount, expected] of [
['12794.57', '¥12794.57'],
['300', '¥300.00'],
['0.1', '¥0.10'],
['1.005', '¥1.01'],
['0', '¥0.00'],
]) {
assert.equal(formatDonationAmount(amount), expected)
}
})
test('invalid amounts do not show a made-up zero donation', () => {
for (const amount of ['', 'unknown', 'Infinity']) assert.equal(formatDonationAmount(amount), '—')
})
@@ -0,0 +1,10 @@
const amountFormatter = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
useGrouping: false,
})
export function formatDonationAmount(amount: string): string {
const value = Number(amount)
return amount.trim() && Number.isFinite(value) ? `¥${amountFormatter.format(value)}` : '—'
}
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import {
type SkinSiteWikiPageSummary,
type SkinSiteWikiSearchPage,
skinSiteWikiCategory,
skinSiteWikiRecent,
skinSiteWikiSearch,
} from '@/helpers/skin-site-api'
const emit = defineEmits<{ navigate: [title: string] }>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
searchPlaceholder: {
id: 'app.skin-site.wiki.browser.search-placeholder',
defaultMessage: 'Search wiki pages…',
},
tabSearch: { id: 'app.skin-site.wiki.browser.tab-search', defaultMessage: 'Search' },
tabRecent: { id: 'app.skin-site.wiki.browser.tab-recent', defaultMessage: 'Recent' },
tabCategory: { id: 'app.skin-site.wiki.browser.tab-category', defaultMessage: 'Category' },
categoryPlaceholder: {
id: 'app.skin-site.wiki.browser.category-placeholder',
defaultMessage: 'Category name',
},
loadMore: { id: 'app.skin-site.wiki.browser.load-more', defaultMessage: 'Load more' },
emptySearch: {
id: 'app.skin-site.wiki.browser.empty-search',
defaultMessage: 'No pages match your search.',
},
emptyList: {
id: 'app.skin-site.wiki.browser.empty-list',
defaultMessage: 'Nothing here yet.',
},
hintSearch: {
id: 'app.skin-site.wiki.browser.hint-search',
defaultMessage: 'Type a keyword to search.',
},
hintCategory: {
id: 'app.skin-site.wiki.browser.hint-category',
defaultMessage: 'Enter a category name to browse.',
},
byAuthor: {
id: 'app.skin-site.wiki.browser.by-author',
defaultMessage: 'by {author}',
},
})
type Tab = 'search' | 'recent' | 'category'
const tab = ref<Tab>('recent')
const keyword = ref('')
const categoryName = ref('')
const items = ref<SkinSiteWikiPageSummary[]>([])
const nextCursor = ref<number | null>(null)
const loading = ref(false)
const loaded = ref(false)
const canLoadMore = computed(() => nextCursor.value != null)
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return '—'
}
}
function resetState() {
items.value = []
nextCursor.value = null
loaded.value = false
}
async function runSearch() {
const q = keyword.value.trim()
if (!q) {
resetState()
return
}
loading.value = true
try {
const res = await skinSiteWikiSearch(q)
if (!res.success) throw new Error(res.errorMessage)
items.value = res.payload ?? []
nextCursor.value = null
loaded.value = true
} catch (error) {
handleError(error)
resetState()
} finally {
loading.value = false
}
}
async function fetchPage(mode: 'recent' | 'category', append: boolean) {
if (mode === 'category' && !categoryName.value.trim()) {
resetState()
return
}
loading.value = true
try {
const before = append ? (nextCursor.value ?? undefined) : undefined
const res: { success: boolean; payload?: SkinSiteWikiSearchPage; errorMessage?: string } =
mode === 'recent'
? await skinSiteWikiRecent(before)
: await skinSiteWikiCategory(categoryName.value.trim(), before)
if (!res.success || !res.payload) throw new Error(res.errorMessage ?? 'request failed')
const page = res.payload
items.value = append ? [...items.value, ...page.items] : page.items
nextCursor.value = page.nextCursor ?? null
loaded.value = true
} catch (error) {
handleError(error)
if (!append) resetState()
} finally {
loading.value = false
}
}
function switchTab(next: Tab) {
if (tab.value === next) return
tab.value = next
resetState()
if (next === 'recent') void fetchPage('recent', false)
}
function loadMore() {
if (tab.value === 'recent') void fetchPage('recent', true)
else if (tab.value === 'category') void fetchPage('category', true)
}
watch(categoryName, () => {
if (tab.value === 'category') {
resetState()
}
})
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(keyword, () => {
if (tab.value !== 'search') return
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
void runSearch()
}, 300)
})
onMounted(() => {
void fetchPage('recent', false)
})
</script>
<template>
<div class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3">
<div class="flex flex-wrap items-center gap-2">
<button
v-for="t in (['search', 'recent', 'category'] as Tab[])"
:key="t"
type="button"
class="rounded-lg px-3 py-1 text-xs font-semibold"
:class="tab === t ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab(t)"
>
{{
formatMessage(
t === 'search'
? messages.tabSearch
: t === 'recent'
? messages.tabRecent
: messages.tabCategory,
)
}}
</button>
</div>
<input
v-if="tab === 'search'"
v-model="keyword"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="runSearch"
/>
<input
v-else-if="tab === 'category'"
v-model="categoryName"
type="text"
:placeholder="formatMessage(messages.categoryPlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="fetchPage('category', false)"
/>
<div v-if="loading && !items.length" class="flex min-h-16 items-center justify-center gap-2 text-xs text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
</div>
<p v-else-if="tab === 'search' && !keyword.trim()" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.hintSearch) }}
</p>
<p v-else-if="tab === 'category' && !categoryName.trim()" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.hintCategory) }}
</p>
<p v-else-if="loaded && !items.length" class="m-0 text-xs text-secondary">
{{ formatMessage(tab === 'search' ? messages.emptySearch : messages.emptyList) }}
</p>
<div v-else class="flex flex-col divide-y divide-surface-5">
<button
v-for="item in items"
:key="item.title"
type="button"
class="flex flex-col items-start gap-0.5 py-2 text-left hover:brightness-110"
@click="emit('navigate', item.title)"
>
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
<span class="text-[10px] text-secondary">
{{ formatMessage(messages.byAuthor, { author: item.authorName }) }} ·
{{ formatDate(item.updatedAt) }}
</span>
</button>
</div>
<button
v-if="canLoadMore && !loading"
type="button"
class="self-start rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="loadMore"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
</template>
@@ -34,6 +34,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import { getPingLevel } from '@modrinth/utils'
import { autoToHTML } from '@sfirew/minecraft-motd-parser'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
@@ -360,10 +361,12 @@ const messages = defineMessages({
}}
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.name">
{{ player.name }}
</span>
<div class="flex flex-col gap-1 font-minecraft">
<span
v-for="player in serverStatus.players?.sample"
:key="player.name"
v-html="autoToHTML(player.name)"
/>
</div>
</template>
</Tooltip>
@@ -0,0 +1,265 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
type FetchHandler = (url: string, options: RequestInit) => Promise<Response>
const testGlobal = globalThis as unknown as {
skinSiteTestFetch: FetchHandler
skinSiteTestHead: (texture: string) => Promise<string>
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@/helpers/skin-site-render') {
const renderer = new URL('../helpers/skin-site-render.ts', import.meta.url).href
return {
url: `data:text/javascript,${encodeURIComponent(`export { fetchImageAsDataUrl } from ${JSON.stringify(renderer)}; export const renderSkinHead=texture=>globalThis.skinSiteTestHead(texture)`)}`,
shortCircuit: true,
}
}
if (specifier === '@tauri-apps/plugin-http') {
return {
url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinSiteTestFetch(...args)',
shortCircuit: true,
}
}
if (specifier.startsWith('@/')) {
return nextResolve(new URL(`../${specifier.slice(2)}.ts`, import.meta.url).href, context)
}
return nextResolve(specifier, context)
},
})
const session = await import('./skin-site-session.ts')
hooks.deregister()
const user = { uuid: 'owner', username: 'Player', permissions: [] }
const ok = (payload: unknown) => Response.json({ payload })
const storage = new Map<string, string>()
const tokenKey = 'starlight-skin-site-token'
let calls: Array<{ path: string; token: string | undefined }> = []
let respond: FetchHandler
test.beforeEach(() => {
storage.clear()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
})
calls = []
respond = async (url) => {
if (url.endsWith('/user/refresh')) return ok({ username: 'Player', jwt: 'fresh-token' })
if (url.endsWith('/user')) return ok(user)
return ok([])
}
testGlobal.skinSiteTestFetch = async (url, options = {}) => {
calls.push({
path: new URL(url).pathname,
token: (options.headers as Record<string, string>)?.Authorization,
})
return respond(url, options)
}
session.resetSkinSiteSession()
})
test.afterEach(async () => {
session.signOutSkinSite()
await new Promise((resolve) => setImmediate(resolve))
Reflect.deleteProperty(globalThis, 'localStorage')
})
test('startup renews the stored JWT before loading the user with the new token', async () => {
storage.set(tokenKey, 'old-token')
await session.initSkinSiteSession()
assert.deepEqual(calls.slice(0, 2), [
{ path: '/starlight/user/refresh', token: 'Bearer old-token' },
{ path: '/starlight/user', token: 'Bearer fresh-token' },
])
assert.equal(storage.get(tokenKey), 'fresh-token')
assert.equal(session.skinSiteStatus.value, 'signed-in')
})
test('simultaneous startup consumers share one refresh', async () => {
storage.set(tokenKey, 'old-token')
await Promise.all([
session.initSkinSiteSession(),
session.initSkinSiteSession(),
session.waitForSkinSiteSession(),
])
assert.equal(calls.filter((call) => call.path.endsWith('/refresh')).length, 1)
})
test('startup without a stored token does not make authenticated requests', async () => {
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.deepEqual(calls, [])
})
test('an expired token is cleared when renewal is rejected', async () => {
storage.set(tokenKey, 'expired-token')
respond = async () => Response.json({ errorMessage: 'Expired' }, { status: 401 })
await session.initSkinSiteSession()
assert.equal(storage.has(tokenKey), false)
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(calls.length, 1)
})
test('temporary renewal failure preserves the token and allows startup retry', async () => {
storage.set(tokenKey, 'old-token')
respond = async () => Response.json({ errorMessage: 'Unavailable' }, { status: 503 })
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'error')
assert.equal(storage.get(tokenKey), 'old-token')
respond = async (url) =>
url.endsWith('/refresh')
? ok({ jwt: 'fresh-token' })
: url.endsWith('/user')
? ok(user)
: ok([])
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'signed-in')
})
test('a late refresh cannot restore a session after sign-out', async () => {
storage.set(tokenKey, 'old-token')
let finish!: (response: Response) => void
respond = () =>
new Promise((resolve) => {
finish = resolve
})
const initializing = session.initSkinSiteSession()
await new Promise((resolve) => setImmediate(resolve))
session.signOutSkinSite()
finish(ok({ username: 'Player', jwt: 'late-token' }))
await initializing
assert.equal(storage.has(tokenKey), false)
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(session.skinSiteUser.value, null)
})
test('a malformed refresh response never replaces the saved token', async () => {
storage.set(tokenKey, 'old-token')
respond = async () => ok({ username: 'Player' })
await session.initSkinSiteSession()
assert.equal(storage.get(tokenKey), 'old-token')
assert.equal(session.skinSiteStatus.value, 'error')
})
test('a failed sign-in attempt does not invalidate a pending startup renewal', async () => {
storage.set(tokenKey, 'old-token')
let finish!: (response: Response) => void
respond = async (url) => {
if (url.endsWith('/refresh'))
return new Promise((resolve) => {
finish = resolve
})
if (url.endsWith('/login'))
return Response.json({ errorMessage: 'Invalid password' }, { status: 401 })
return url.endsWith('/user') ? ok(user) : ok([])
}
const initializing = session.initSkinSiteSession()
await new Promise((resolve) => setImmediate(resolve))
await session.signInSkinSite({ username: 'Player', password: 'wrong', clientId: 'captcha' })
finish(ok({ username: 'Player', jwt: 'fresh-token' }))
await initializing
assert.equal(session.skinSiteStatus.value, 'signed-in')
assert.equal(storage.get(tokenKey), 'fresh-token')
})
test('rejection of an old startup token does not discard a new successful login', async () => {
storage.set(tokenKey, 'expired-token')
let finishRefresh!: (response: Response) => void
let finishLogin!: (response: Response) => void
respond = async (url) => {
if (url.endsWith('/refresh'))
return new Promise((resolve) => {
finishRefresh = resolve
})
if (url.endsWith('/login'))
return new Promise((resolve) => {
finishLogin = resolve
})
return url.endsWith('/user') ? ok(user) : ok([])
}
const initializing = session.initSkinSiteSession()
const signingIn = session.signInSkinSite({
username: 'Player',
password: 'valid',
clientId: 'captcha',
})
await new Promise((resolve) => setImmediate(resolve))
finishRefresh(Response.json({ errorMessage: 'Expired' }, { status: 401 }))
await initializing
finishLogin(ok({ username: 'Player', jwt: 'login-token' }))
await signingIn
assert.equal(session.skinSiteStatus.value, 'signed-in')
assert.equal(storage.get(tokenKey), 'login-token')
})
test('explicit sign-out discards a late successful login', async () => {
let finishLogin!: (response: Response) => void
respond = () =>
new Promise((resolve) => {
finishLogin = resolve
})
const signingIn = session.signInSkinSite({
username: 'Player',
password: 'valid',
clientId: 'captcha',
})
await new Promise((resolve) => setImmediate(resolve))
session.signOutSkinSite()
finishLogin(ok({ username: 'Player', jwt: 'late-login-token' }))
await signingIn
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(storage.has(tokenKey), false)
})
test('player heads are cropped from one downloaded local texture rather than a cross-origin image', async (t) => {
const textureUrl = 'https://skin.starlight.cool/yggdrasil/textures/test'
const textureData = 'data:image/png;base64,iVBORw=='
const previousReader = Object.getOwnPropertyDescriptor(globalThis, 'FileReader')
Object.defineProperty(globalThis, 'FileReader', {
configurable: true,
value: class {
result: string | null = null
onload?: () => void
readAsDataURL(blob: Blob) {
void blob.arrayBuffer().then((bytes) => {
this.result = `data:${blob.type};base64,${Buffer.from(bytes).toString('base64')}`
this.onload?.()
})
}
},
})
t.after(() => {
if (previousReader) Object.defineProperty(globalThis, 'FileReader', previousReader)
else Reflect.deleteProperty(globalThis, 'FileReader')
})
t.mock.method(globalThis, 'fetch', async () => {
throw new TypeError('Cross-origin image request blocked')
})
testGlobal.skinSiteTestHead = async (source) => {
if (source !== textureData) throw new Error('Cross-origin canvas image blocked')
return 'data:image/png;base64,local-head'
}
respond = async (url) => {
if (url === textureUrl)
return new Response(new Uint8Array([137, 80, 78, 71]), {
headers: { 'Content-Type': 'image/png' },
})
if (url.endsWith('/player/skin/player')) return ok({ skin: textureUrl, model: 'slim' })
if (url.endsWith('/player')) return ok([{ uuid: 'player', name: 'Player', isMojang: false }])
return url.endsWith('/user') ? ok(user) : ok({ jwt: 'token' })
}
storage.set(tokenKey, 'old-token')
await session.initSkinSiteSession()
const [player] = await session.requestSkinSitePlayers()
assert.equal(player.skinState, 'ready')
assert.equal(player.skinDataUrl, textureData)
assert.equal(player.headDataUrl, 'data:image/png;base64,local-head')
assert.equal(player.model, 'slim')
assert.equal(calls.filter((call) => call.path === '/yggdrasil/textures/test').length, 1)
})
@@ -65,13 +65,54 @@ function clearSessionState() {
// ---------------------------------------------------------------------------
let initialized: Promise<void> | null = null
let sessionRevision = 0
let loginRevision = 0
let refreshRequest: { token: string; revision: number; promise: Promise<boolean> } | null = null
/** Share renewals and discard results from a session that has since changed. */
function renewSkinSiteToken(): Promise<boolean> {
const token = getSkinSiteToken()
if (!token) return Promise.resolve(false)
const revision = sessionRevision
if (refreshRequest?.token === token && refreshRequest.revision === revision) {
return refreshRequest.promise
}
const request = {
token,
revision,
promise: (async () => {
let result: SkinSiteResponse<{ username: string; jwt: string }>
try {
result = await skinSiteRefresh()
} catch {
return false
}
if (revision !== sessionRevision || getSkinSiteToken() !== token) return false
if (result.success) {
if (typeof result.payload?.jwt !== 'string' || !result.payload.jwt.trim()) return false
setSkinSiteToken(result.payload.jwt)
return true
}
if (result.errorCode === 401 || result.errorCode === 403) invalidateSkinSiteSession()
return false
})(),
}
refreshRequest = request
const clearRequest = () => {
if (refreshRequest === request) refreshRequest = null
}
void request.promise.then(clearRequest, clearRequest)
return request.promise
}
/**
* Resolve the current session from the stored JWT. Safe to call repeatedly;
* Renew the saved JWT on each launcher startup before loading the user.
* Safe to call repeatedly;
* the first call performs the actual work and later ones reuse it.
*/
export function initSkinSiteSession(): Promise<void> {
if (initialized) return initialized
const revision = sessionRevision
initialized = (async () => {
const token = getSkinSiteToken()
if (!token) {
@@ -79,7 +120,13 @@ export function initSkinSiteSession(): Promise<void> {
return
}
status.value = 'checking'
if (!(await renewSkinSiteToken())) {
if (revision === sessionRevision) status.value = 'error'
return
}
if (revision !== sessionRevision) return
const result = await skinSiteCurrentUser()
if (revision !== sessionRevision) return
if (result.success) {
user.value = toUser(result.payload)
status.value = 'signed-in'
@@ -92,11 +139,18 @@ export function initSkinSiteSession(): Promise<void> {
status.value = 'error'
}
})()
.catch(() => {
if (revision === sessionRevision) status.value = 'error'
})
.finally(() => {
if (revision === sessionRevision && status.value === 'error') initialized = null
})
return initialized
}
/** Force a re-check (e.g. after the user signs in from another view). */
export function reloadSkinSiteSession(): Promise<void> {
sessionRevision++
initialized = null
return initSkinSiteSession()
}
@@ -106,10 +160,14 @@ export async function signInSkinSite(payload: {
password: string
clientId: string
}): Promise<SkinSiteResponse<{ username: string; jwt: string }>> {
const attemptRevision = ++loginRevision
const result = await skinSiteLogin(payload)
if (result.success) {
if (result.success && attemptRevision === loginRevision) {
const revision = ++sessionRevision
initialized = null
setSkinSiteToken(result.payload.jwt)
const current = await skinSiteCurrentUser()
if (revision !== sessionRevision) return result
if (current.success) {
user.value = toUser(current.payload)
status.value = 'signed-in'
@@ -122,23 +180,25 @@ export async function signInSkinSite(payload: {
return result
}
export function signOutSkinSite() {
/** Reject an old credential without cancelling a newer explicit login attempt. */
function invalidateSkinSiteSession() {
sessionRevision++
refreshRequest = null
clearSkinSiteToken()
clearSessionState()
status.value = 'signed-out'
initialized = null
}
export function signOutSkinSite() {
loginRevision++
invalidateSkinSiteSession()
}
/** Refresh the JWT; returns true when a new token was stored. */
export async function refreshSkinSiteSession(): Promise<boolean> {
if (status.value !== 'signed-in') return false
const result = await skinSiteRefresh()
if (result.success) {
setSkinSiteToken(result.payload.jwt)
return true
}
if (result.errorCode === 401 || result.errorCode === 403) signOutSkinSite()
return false
return renewSkinSiteToken()
}
// ---------------------------------------------------------------------------
@@ -157,6 +217,9 @@ export function receiveSkinSiteMessage(_event: MessageEvent, _frame: Window | nu
export function openSkinSiteLogin() {}
export function resetSkinSiteSession() {
loginRevision++
sessionRevision++
refreshRequest = null
clearSessionState()
status.value = getSkinSiteToken() ? 'checking' : 'signed-out'
initialized = null
@@ -212,10 +275,8 @@ async function buildPlayer(entry: {
if (!texture.skin) return base
try {
const [headDataUrl, skinDataUrl] = await Promise.all([
renderSkinHead(texture.skin),
fetchImageAsDataUrl(texture.skin),
])
const skinDataUrl = await fetchImageAsDataUrl(texture.skin)
const headDataUrl = await renderSkinHead(skinDataUrl)
return {
...base,
skinState: 'ready',
@@ -231,6 +292,9 @@ async function buildPlayer(entry: {
export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
if (playersRequest) return playersRequest
if (status.value !== 'signed-in' || !user.value) {
// Keep the status meaningful so the UI does not spin forever: an
// unauthenticated request means "not loaded yet", not "loading".
playersStatus.value = 'idle'
return Promise.reject(new Error('请先登录 StarLight 皮肤站。'))
}
@@ -256,6 +320,16 @@ export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
return playersRequest
}
/**
* Drop any in-flight or cached player request and fetch again. Used by views
* that need to recover after a failed load (e.g. signing in from the skin
* page while the first attempt raced the session check).
*/
export function reloadSkinSitePlayers(): Promise<SkinSitePlayer[]> {
playersRequest = null
return requestSkinSitePlayers()
}
export function selectSkinSitePlayer(playerId: string | null) {
if (playerId === null) {
selectedPlayerId.value = null
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { shouldDelayAppUpdate } from './app-update-policy.ts'
const now = Date.parse('2026-10-21T08:00:00Z')
const recent = { publishedAt: '2026-10-21T07:00:00Z' }
test('administrator rollbacks bypass the normal release waiting period', () => {
assert.equal(shouldDelayAppUpdate({ ...recent, rollback: true }, 'release', false, now), false)
assert.equal(shouldDelayAppUpdate({ rollback: true }, 'release', false, now), false)
})
test('ordinary releases retain the 24-hour delay and missing dates remain delayed', () => {
assert.equal(shouldDelayAppUpdate(recent, 'release', false, now), true)
assert.equal(shouldDelayAppUpdate({}, 'release', false, now), true)
assert.equal(
shouldDelayAppUpdate({ publishedAt: '2026-10-20T08:00:00Z' }, 'release', false, now),
false,
)
})
test('beta, forced updates and immediate fetching still bypass the delay', () => {
assert.equal(shouldDelayAppUpdate(recent, 'beta', false, now), false)
assert.equal(shouldDelayAppUpdate({ ...recent, forceUpdate: true }, 'release', false, now), false)
assert.equal(shouldDelayAppUpdate(recent, 'release', true, now), false)
})
@@ -0,0 +1,11 @@
export function shouldDelayAppUpdate(
update: { publishedAt?: string; forceUpdate?: boolean; rollback?: boolean },
channel: string,
immediateUpdateFetch: boolean,
now = Date.now(),
): boolean {
if (channel !== 'release' || immediateUpdateFetch || update.forceUpdate || update.rollback)
return false
const publishedAt = Date.parse(update.publishedAt ?? '')
return !Number.isFinite(publishedAt) || now < publishedAt + 24 * 60 * 60 * 1000
}
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
hostedDownloadActions,
hostedDownloadState,
runHostedDownloadAction,
runHostedDownloadControl,
} from './hosted-download-controls.ts'
test('only a controllable parent hosted task offers pause, resume and stop', () => {
const bar = {
bar_type: {
type: 'hosted_pack_sync',
instance_id: 'one',
state: 'running' as const,
can_pause: true,
can_cancel: true,
},
}
assert.deepEqual(hostedDownloadActions(bar), ['pause', 'cancel'])
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state: 'paused' } }), [
'resume',
'cancel',
])
for (const state of ['finalizing', 'canceling'] as const) {
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state } }), [])
}
assert.deepEqual(
hostedDownloadActions({ bar_type: { ...bar.bar_type, type: 'hosted_mod_download' } }),
[],
)
assert.deepEqual(
hostedDownloadActions({ bar_type: { type: 'hosted_pack_sync', instance_id: 'one' } }),
[],
)
})
test('failed and canceled hosted tasks offer direct retry, preserving terminal state', () => {
for (const state of ['failed', 'canceled'] as const) {
const bar = {
bar_type: { type: 'hosted_pack_sync', instance_id: 'one', state, error: 'failure' },
}
assert.deepEqual(hostedDownloadActions(bar), ['retry'])
assert.equal(hostedDownloadState(bar), state)
}
assert.equal(hostedDownloadState({ bar_type: { error: 'old backend failure' } }), 'failed')
})
test('resume refreshes the session before invoking controls; pause and stop need no session', async () => {
const calls: string[] = []
const deps = {
prepareSession: async () => {
calls.push('session')
},
invoke: async (command: string, args: { instanceId: string }) => {
calls.push(`${command}:${args.instanceId}`)
},
}
await runHostedDownloadControl('resume', 'one', deps)
await runHostedDownloadControl('pause', 'one', deps)
await runHostedDownloadControl('cancel', 'one', deps)
assert.deepEqual(calls, [
'session',
'plugin:install|hosted_resume:one',
'plugin:install|hosted_pause:one',
'plugin:install|hosted_cancel:one',
])
})
test('session refresh failures prevent resume', async () => {
await assert.rejects(
runHostedDownloadControl('resume', 'one', {
prepareSession: async () => {
throw new Error('signed out')
},
invoke: async () => {
assert.fail('resume must not run')
},
}),
/signed out/,
)
})
test('retry immediately restarts the existing hosted instance through the manager', async () => {
const calls: string[] = []
await runHostedDownloadAction('retry', 'existing-instance', {
retryHosted: async (instanceId) => {
calls.push(instanceId)
},
control: async () => {
assert.fail('retry must use the full hosted transaction')
},
})
assert.deepEqual(calls, ['existing-instance'])
})
@@ -0,0 +1,45 @@
import type { LoadingBar } from './state.ts'
export type HostedDownloadControl = 'pause' | 'resume' | 'cancel'
export type HostedDownloadAction = HostedDownloadControl | 'retry'
export function runHostedDownloadAction(
action: HostedDownloadAction,
instanceId: string,
operations: {
retryHosted: (instanceId: string) => Promise<void>
control: (action: HostedDownloadControl, instanceId: string) => Promise<void>
},
) {
return action === 'retry'
? operations.retryHosted(instanceId)
: operations.control(action, instanceId)
}
export function hostedDownloadState(bar: LoadingBar) {
return bar.bar_type?.state ?? (bar.bar_type?.error ? 'failed' : 'running')
}
export function hostedDownloadActions(bar: LoadingBar): HostedDownloadAction[] {
const type = bar.bar_type
if (type?.type !== 'hosted_pack_sync' || !type.instance_id) return []
const state = hostedDownloadState(bar)
if (state === 'failed' || state === 'canceled') return ['retry']
if (state !== 'running' && state !== 'paused') return []
const actions: HostedDownloadAction[] = []
if (type.can_pause) actions.push(state === 'paused' ? 'resume' : 'pause')
if (type.can_cancel) actions.push('cancel')
return actions
}
export async function runHostedDownloadControl(
action: HostedDownloadControl,
instanceId: string,
dependencies: {
prepareSession: () => Promise<void>
invoke: (command: string, args: { instanceId: string }) => Promise<void>
},
) {
if (action === 'resume') await dependencies.prepareSession()
await dependencies.invoke(`plugin:install|hosted_${action}`, { instanceId })
}
@@ -3,6 +3,27 @@ import test from 'node:test'
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
test('stopped hosted downloads retain their state and instance for direct retry', () => {
const state = createHostedDownloadFailures()
state.update({
fraction: null,
loader_uuid: 'stopped',
event: {
type: 'hosted_pack_sync',
instance_id: 'existing-instance',
state: 'canceled',
error: 'Stopped by user',
can_pause: false,
can_cancel: false,
},
})
assert.equal(state.values()[0]?.bar_type?.state, 'canceled')
assert.equal(state.values()[0]?.bar_type?.instance_id, 'existing-instance')
state.begin('existing-instance', state.values())
assert.equal(state.has('existing-instance'), false)
assert.equal(state.isRetired('stopped'), true)
})
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) => ({
@@ -8,6 +8,7 @@ import {
skinSiteUser,
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
import { type HostedDownloadControl, runHostedDownloadControl } from './hosted-download-controls.ts'
import { authenticateInstancePlayer } from './instance-player.ts'
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
@@ -146,6 +147,11 @@ export const hostedCreate = async (gameDirRoot?: string | null) => {
}
export const hostedBinding = (instanceId: string) =>
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const controlHostedDownload = (action: HostedDownloadControl, instanceId: string) =>
runHostedDownloadControl(action, instanceId, {
prepareSession: prepareHostedSession,
invoke: invokeHosted<void>,
})
export const hostedSync = async (instanceId: string) => {
await requireHostedGameAccount()
startHostedPackAttempt(instanceId)
@@ -1,20 +1,32 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '../composables/skin-site-session.ts'
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@tauri-apps/plugin-http') {
return {
url: `data:text/javascript,${encodeURIComponent(`export const fetch=async url=>Response.json({payload:url.endsWith('/user/refresh')?{jwt:'fixture.jwt'}:url.endsWith('/user')?{uuid:'owner',username:'Owner',permissions:[]}:[]})`)}`,
shortCircuit: true,
}
}
if (specifier.startsWith('@/')) {
return nextResolve(new URL(`../${specifier.slice(2)}.ts`, import.meta.url).href, context)
}
return nextResolve(specifier, context)
},
})
const { initSkinSiteSession, resetSkinSiteSession, setSkinSiteFrame } =
await import('../composables/skin-site-session.ts')
hooks.deregister()
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import {
type InstancePlayer,
const {
onInstancePlayerChanged,
prepareInstancePlayer,
registerInstancePlayerPicker,
saveInstancePlayer,
} from './instance-player.ts'
} = await import('./instance-player.ts')
import type { InstancePlayer } from './instance-player.ts'
test('instance player selection persists independently from the globally selected account', async () => {
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
@@ -84,23 +96,16 @@ test('signed-in skin site renews the saved player without prompting and only not
}
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,
)
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
const storage = new Map([['starlight-skin-site-token', 'old.jwt']])
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
} as unknown as Window
})
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
@@ -128,19 +133,7 @@ test('signed-in skin site renews the saved player without prompting and only not
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 initSkinSiteSession()
await prepareInstancePlayer('one')
assert.equal(prompts, 0)
assert.ok(calls.includes('plugin:auth|login_skin_site_player'))
@@ -149,6 +142,7 @@ test('signed-in skin site renews the saved player without prompting and only not
failSave = true
await assert.rejects(saveInstancePlayer('two', player), /Save failed/)
assert.deepEqual(changes, ['one'])
storage.clear()
resetSkinSiteSession()
await prepareInstancePlayer('one')
assert.equal(prompts, 1)
@@ -157,6 +151,8 @@ test('signed-in skin site renews the saved player without prompting and only not
unregister()
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage)
else Reflect.deleteProperty(globalThis, 'localStorage')
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
@@ -72,7 +72,7 @@ test('notice query uses the target instance value and registered Tauri command',
)
const helperSource = readFileSync(new URL('./instance.ts', import.meta.url), 'utf8')
const tauriSource = readFileSync(
new URL('../../../app/src/api/instance.rs', import.meta.url),
new URL('../../../app/src/api/instance/mod.rs', import.meta.url),
'utf8',
)
assert.match(querySource, /\['post-upgrade-notice', instanceId\]/)
@@ -1,7 +1,23 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts'
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@modrinth/ui') {
return nextResolve(
new URL(
'../../../../packages/ui/src/composables/skin-rendering/armor-preview-types.ts',
import.meta.url,
).href,
context,
)
}
return nextResolve(specifier, context)
},
})
const { loadSkinArmorPreview, saveSkinArmorPreview } = await import('./skin-armor-preview.ts')
hooks.deregister()
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
+222 -23
View File
@@ -336,6 +336,40 @@ export function skinSiteRenamePlayer(
{ method: 'POST', type: 'skin' },
)
}
/** Mark a player as the account default (used by launcher login). */
export function skinSiteSetDefaultPlayer(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/default/set?uuid=${encodeURIComponent(uuid)}`, {
method: 'POST',
type: 'skin',
})
}
/** Clear the account default player. */
export function skinSiteClearDefaultPlayer(): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('player/default', { method: 'DELETE', type: 'skin' })
}
/** Fetch the account default player, if one is set. */
export function skinSiteDefaultPlayer(): Promise<SkinSiteResponse<SkinSitePlayerInfo | null>> {
return skinSiteRequest<SkinSitePlayerInfo | null>('player/default', { type: 'skin' })
}
/** Toggle the Mojang (official) status of a player and resync its profile. */
export function skinSiteSwitchMojang(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/${encodeURIComponent(uuid)}/switchMojang`, {
method: 'GET',
type: 'skin',
})
}
/** Convert a legacy internal player so it can log in through the modern flow. */
export function skinSiteAdaptLegacyInternal(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/${encodeURIComponent(uuid)}/adaptLegacyInternal`, {
method: 'POST',
type: 'skin',
})
}
// ---------------------------------------------------------------------------
// Registration & password reset
// ---------------------------------------------------------------------------
@@ -522,22 +556,18 @@ export function skinSitePublicDownloadList(options?: {
offset: String(options?.offset ?? 0),
})
if (options?.category) query.set('category', options.category)
return skinSiteRequest<SkinSitePublicDownloadItem[]>(`download/list?${query.toString()}`, {
auth: false,
})
return skinSiteRequest<SkinSitePublicDownloadItem[]>(`download/list?${query.toString()}`)
}
export function skinSitePublicDownloadCount(category?: string): Promise<SkinSiteResponse<number>> {
const suffix = category ? `?category=${encodeURIComponent(category)}` : ''
return skinSiteRequest<number>(`download/count${suffix}`, { auth: false })
return skinSiteRequest<number>(`download/count${suffix}`)
}
export function skinSitePublicDownloadCategories(): Promise<
SkinSiteResponse<SkinSitePublicDownloadCategory[]>
> {
return skinSiteRequest<SkinSitePublicDownloadCategory[]>('download/category/list', {
auth: false,
})
return skinSiteRequest<SkinSitePublicDownloadCategory[]>('download/category/list')
}
// ---------------------------------------------------------------------------
// Maps
@@ -1214,6 +1244,9 @@ export function skinSiteActivateLauncherRelease(
})
}
export function skinSiteDeleteLauncherRelease(id: number): Promise<SkinSiteResponse<boolean>> {
return skinSiteRequest<boolean>('launcher/admin/' + id, { method: 'DELETE' })
}
export interface SkinSiteLauncherUploadStatus {
id: string
chunkSize: number
@@ -1861,7 +1894,6 @@ export function skinSiteWikiUpdateSettings(
})
}
export interface SkinSiteReviewPaperSummary {
id: string
enabled: boolean
@@ -1904,9 +1936,7 @@ export interface SkinSiteReviewConfigData {
prerequisitePermissions: string[]
}
export function skinSiteReviewPaperList(): Promise<
SkinSiteResponse<SkinSiteReviewPaperSummary[]>
> {
export function skinSiteReviewPaperList(): Promise<SkinSiteResponse<SkinSiteReviewPaperSummary[]>> {
return skinSiteRequest<SkinSiteReviewPaperSummary[]>('review/admin/papers')
}
@@ -1987,9 +2017,7 @@ export interface SkinSiteInviteCode {
code: string
}
export function skinSiteAdminBotInfo(
userUuid: string,
): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
export function skinSiteAdminBotInfo(userUuid: string): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
return skinSiteRequest<SkinSiteBotInfo>('admin/users/' + encodeURIComponent(userUuid) + '/bot')
}
@@ -2043,7 +2071,9 @@ export function skinSiteAdminUserAudit(params?: {
return skinSiteRequest<SkinSiteUserAdminAuditPage>('admin/users/audit' + suffix)
}
export function skinSiteAdminInviteUser(email: string): Promise<SkinSiteResponse<SkinSiteInviteCode>> {
export function skinSiteAdminInviteUser(
email: string,
): Promise<SkinSiteResponse<SkinSiteInviteCode>> {
return skinSiteRequest<SkinSiteInviteCode>('admin/inviteCode', {
method: 'POST',
body: { email },
@@ -2377,10 +2407,10 @@ export function skinSiteChatVoiceConfig(): Promise<
export function skinSiteChatVoiceConfigUpdate(
data: SkinSiteChatVoiceGlobalConfig,
): Promise<SkinSiteResponse<SkinSiteChatVoiceGlobalConfigUpdateResult>> {
return skinSiteRequest<SkinSiteChatVoiceGlobalConfigUpdateResult>(
'chat/voice/admin/config',
{ method: 'PUT', body: data },
)
return skinSiteRequest<SkinSiteChatVoiceGlobalConfigUpdateResult>('chat/voice/admin/config', {
method: 'PUT',
body: data,
})
}
export function skinSiteChatVoiceLogs(
@@ -2499,7 +2529,11 @@ export function skinSiteWikiMigrationCandidates(
export function skinSiteWikiMigrationDecide(
candidateId: number,
data: { decision: SkinSiteWikiMigrationDecision; targetTitle?: string | null; overwrite?: boolean },
data: {
decision: SkinSiteWikiMigrationDecision
targetTitle?: string | null
overwrite?: boolean
},
): Promise<SkinSiteResponse<void>> {
return skinSiteRequest<void>('wiki/admin/migration/candidates/' + candidateId, {
method: 'POST',
@@ -2516,9 +2550,7 @@ export function skinSiteWikiMigrationPublish(
)
}
export function skinSiteWikiMigrationRollback(
jobId: number,
): Promise<SkinSiteResponse<void>> {
export function skinSiteWikiMigrationRollback(jobId: number): Promise<SkinSiteResponse<void>> {
return skinSiteRequest<void>('wiki/admin/migration/' + jobId + '/rollback', {
method: 'POST',
})
@@ -2539,3 +2571,170 @@ export function skinSiteWikiAttachment(
export function skinSiteWikiAttachmentUrl(id: number): string {
return `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/wiki/attachment/${id}/content`
}
export interface SkinSiteWikiRevisionInfo {
id: number
authorName: string
summary: string
minor: boolean
createdAt: number
sourceWiki?: string | null
sourceRevisionId?: number | null
}
export interface SkinSiteWikiRevisionPage {
items: SkinSiteWikiRevisionInfo[]
nextCursor?: number | null
}
export interface SkinSiteWikiRevisionDetail {
revision: SkinSiteWikiRevisionInfo
source: string
nodes: SkinSiteWikiNode[]
}
export interface SkinSiteWikiDiffLine {
type: 'UNCHANGED' | 'REMOVED' | 'ADDED'
oldLine?: number | null
newLine?: number | null
text: string
}
export function skinSiteWikiHistory(
title: string,
before?: number,
): Promise<SkinSiteResponse<SkinSiteWikiRevisionPage>> {
const query = new URLSearchParams({ title })
if (before) query.set('before', String(before))
return skinSiteRequest<SkinSiteWikiRevisionPage>('wiki/history?' + query.toString())
}
export function skinSiteWikiRevision(
title: string,
revisionId: number,
): Promise<SkinSiteResponse<SkinSiteWikiRevisionDetail>> {
return skinSiteRequest<SkinSiteWikiRevisionDetail>(
'wiki/revision/' + revisionId + '?title=' + encodeURIComponent(title),
)
}
export function skinSiteWikiDiff(
title: string,
older: number,
newer: number,
): Promise<SkinSiteResponse<SkinSiteWikiDiffLine[]>> {
const query = new URLSearchParams({ title, older: String(older), newer: String(newer) })
return skinSiteRequest<SkinSiteWikiDiffLine[]>('wiki/diff?' + query.toString())
}
export function skinSiteWikiRevert(data: {
title: string
revisionId: number
baseRevisionId: number
}): Promise<SkinSiteResponse<SkinSiteWikiPageDetail>> {
return skinSiteRequest<SkinSiteWikiPageDetail>('wiki/revert', {
method: 'POST',
body: data,
})
}
// ─── Wiki: 搜索 / 列表 / 最近 / 分类 ───────────────────────────────────────
export interface SkinSiteWikiPageSummary {
title: string
updatedAt: number
authorName: string
readPolicy: SkinSiteWikiReadPolicy
editPolicy: SkinSiteWikiEditPolicy
redirectTarget?: string | null
}
export interface SkinSiteWikiPageListItem {
id: number
title: string
namespace: string
revisionId: number
updatedAt: number
authorName: string
readPolicy: SkinSiteWikiReadPolicy
editPolicy: SkinSiteWikiEditPolicy
redirectTarget?: string | null
}
export interface SkinSiteWikiPageList {
items: SkinSiteWikiPageListItem[]
nextCursor?: string | null
}
export interface SkinSiteWikiSearchPage {
items: SkinSiteWikiPageSummary[]
nextCursor?: number | null
}
export function skinSiteWikiSearch(
query: string,
): Promise<SkinSiteResponse<SkinSiteWikiPageSummary[]>> {
const q = query.trim()
if (!q) {
return Promise.resolve({
success: true,
payload: [],
timestamp: Date.now(),
} as SkinSiteResponse<SkinSiteWikiPageSummary[]>)
}
return skinSiteRequest<SkinSiteWikiPageSummary[]>('wiki/search?q=' + encodeURIComponent(q))
}
export function skinSiteWikiPages(
params: {
prefix?: string
namespace?: string
limit?: number
cursor?: string
} = {},
): Promise<SkinSiteResponse<SkinSiteWikiPageList>> {
const query = new URLSearchParams()
if (params.prefix) query.set('prefix', params.prefix)
if (params.namespace != null) query.set('namespace', params.namespace)
if (params.limit != null) query.set('limit', String(params.limit))
if (params.cursor) query.set('cursor', params.cursor)
return skinSiteRequest<SkinSiteWikiPageList>('wiki/pages?' + query.toString())
}
export function skinSiteWikiRecent(
before?: number,
): Promise<SkinSiteResponse<SkinSiteWikiSearchPage>> {
const query = new URLSearchParams()
if (before != null) query.set('before', String(before))
return skinSiteRequest<SkinSiteWikiSearchPage>('wiki/recent?' + query.toString())
}
export function skinSiteWikiCategory(
name: string,
before?: number,
): Promise<SkinSiteResponse<SkinSiteWikiSearchPage>> {
const query = new URLSearchParams({ name })
if (before != null) query.set('before', String(before))
return skinSiteRequest<SkinSiteWikiSearchPage>('wiki/category?' + query.toString())
}
// ─── Wiki: 移动 / 关注 / 设首页 ─────────────────────────────────────────
export function skinSiteWikiMove(data: {
title: string
targetTitle: string
leaveRedirect?: boolean
}): Promise<SkinSiteResponse<SkinSiteWikiPageDetail>> {
return skinSiteRequest<SkinSiteWikiPageDetail>('wiki/move', {
method: 'POST',
body: data,
})
}
export function skinSiteWikiWatch(
title: string,
watched: boolean,
): Promise<SkinSiteResponse<boolean>> {
return skinSiteRequest<boolean>('wiki/watch', {
method: 'POST',
body: { title, watched },
})
}
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
const testGlobal = globalThis as unknown as {
skinImageTestFetch: (url: string) => Promise<Response>
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@tauri-apps/plugin-http') {
return {
url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinImageTestFetch(...args)',
shortCircuit: true,
}
}
return nextResolve(specifier, context)
},
})
const { fetchImageAsDataUrl } = await import('./skin-site-render.ts')
hooks.deregister()
test.beforeEach((t) => {
t.mock.method(globalThis, 'fetch', async () => {
throw new TypeError('Cross-origin image request blocked')
})
const previousReader = Object.getOwnPropertyDescriptor(globalThis, 'FileReader')
Object.defineProperty(globalThis, 'FileReader', {
configurable: true,
value: class {
result: string | null = null
onload?: () => void
onerror?: () => void
readAsDataURL(blob: Blob) {
void blob.arrayBuffer().then(
(bytes) => {
this.result = `data:${blob.type};base64,${Buffer.from(bytes).toString('base64')}`
this.onload?.()
},
() => this.onerror?.(),
)
}
},
})
t.after(() => {
if (previousReader) Object.defineProperty(globalThis, 'FileReader', previousReader)
else Reflect.deleteProperty(globalThis, 'FileReader')
})
})
test('skin textures download through the native client when browser CORS blocks the site', async () => {
testGlobal.skinImageTestFetch = async () =>
new Response(new Uint8Array([137, 80, 78, 71]), { headers: { 'Content-Type': 'image/png' } })
assert.equal(
await fetchImageAsDataUrl('https://skin.starlight.cool/yggdrasil/textures/test'),
'data:image/png;base64,iVBORw==',
)
})
test('failed image downloads report the HTTP error instead of accepting the response as a texture', async () => {
testGlobal.skinImageTestFetch = async () => new Response('Unavailable', { status: 503 })
await assert.rejects(
fetchImageAsDataUrl('https://skin.starlight.cool/yggdrasil/textures/test'),
/HTTP 503/,
)
})
@@ -1,3 +1,5 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
/**
* Renders a player's skin into a base64 head image, mirroring the skin site's
* own head cropper (front face at 8,8 plus the hat overlay at 40,8).
@@ -28,9 +30,9 @@ function loadImage(src: string): Promise<HTMLImageElement> {
})
}
/** Crop the head (and hat overlay) out of a skin image URL. Returns a data URL. */
export async function renderSkinHead(skinUrl: string, scale = 2): Promise<string> {
const img = await loadImage(skinUrl)
/** Crop the head (and hat overlay) from a locally downloaded skin data URL. */
export async function renderSkinHead(skinDataUrl: string, scale = 2): Promise<string> {
const img = await loadImage(skinDataUrl)
const { canvas, ctx } = getBuffer()
canvas.width = HEAD_BUFFER_SIZE
@@ -74,7 +76,7 @@ export async function renderSkinHead(skinUrl: string, scale = 2): Promise<string
/** Fetch an image URL as a data URL (used to keep full skin textures local). */
export async function fetchImageAsDataUrl(url: string): Promise<string> {
const response = await fetch(url)
const response = await tauriFetch(url)
if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`)
const blob = await response.blob()
return await new Promise<string>((resolve, reject) => {
+9 -1
View File
@@ -9,6 +9,9 @@ export interface LoadingBarType {
batch_id?: string
file_name?: string
error?: string | null
state?: 'running' | 'paused' | 'canceling' | 'canceled' | 'failed' | 'finalizing'
can_pause?: boolean
can_cancel?: boolean
type?: string
version?: string
instance_id?: string
@@ -28,7 +31,12 @@ export interface LoadingBar {
}
export type OpeningCommandEvent =
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string
| 'RunMRPack'
| 'InstallServer'
| 'InstallVersion'
| 'InstallMod'
| 'InstallModpack'
| string
export interface OpeningCommand {
event: OpeningCommandEvent
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+66 -18
View File
@@ -84,7 +84,7 @@
</TagItem>
<Badge
:color="bar.bar_type?.error ? 'red' : 'orange'"
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')"
:type="statusLabel(hostedDownloadState(bar))"
/>
</div>
<ProgressBar
@@ -94,16 +94,35 @@
:progress="legacyPercent(bar)"
:max="100"
:label="formatMessage(messages.progress)"
:waiting="!bar.total"
:waiting="!bar.total && hostedDownloadState(bar) !== 'paused'"
:show-progress="Boolean(bar.total)"
/>
<RouterLink
v-else
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')"
class="mt-3 inline-block text-brand hover:underline"
<div
v-if="hostedDownloadActions(bar).length"
class="mt-3 flex flex-wrap items-center gap-2"
>
{{ formatMessage(messages.hostedRetry) }}
</RouterLink>
<ButtonStyled
v-for="action in hostedDownloadActions(bar)"
:key="action"
:color="action === 'retry' ? 'brand' : action === 'cancel' ? 'red' : 'standard'"
:type="action === 'retry' ? 'standard' : 'outlined'"
size="small"
>
<button
:disabled="busy.has(hostedBusyKey(bar, action))"
@click="hostedAction(bar, action)"
>
{{ formatMessage(hostedActionMessages[action]) }}
</button>
</ButtonStyled>
<RouterLink
v-if="bar.bar_type?.error"
:to="hostedRetryRoute(bar.bar_type.instance_id ?? '')"
class="text-brand hover:underline"
>
{{ formatMessage(messages.openInstance) }}
</RouterLink>
</div>
</Card>
<Card
@@ -471,7 +490,14 @@ 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 {
type HostedDownloadAction,
hostedDownloadActions,
hostedDownloadState,
runHostedDownloadAction,
} from '@/helpers/hosted-download-controls'
import { hostedRetryRoute } from '@/helpers/hosted-install-retry'
import { controlHostedDownload } from '@/helpers/hosted-packs'
import {
download_job_support_details,
type InstallJobSnapshot,
@@ -517,10 +543,6 @@ 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' },
@@ -653,7 +675,15 @@ const messages = defineMessages({
unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' },
})
const hostedActionMessages = defineMessages({
pause: { id: 'app.hosted-packs.progress.pause', defaultMessage: 'Pause' },
resume: { id: 'app.hosted-packs.progress.resume', defaultMessage: 'Resume' },
cancel: { id: 'app.hosted-packs.progress.stop', defaultMessage: 'Stop' },
retry: { id: 'app.downloads.retry', defaultMessage: 'Retry' },
})
const statusMessages = defineMessages({
paused: { id: 'app.downloads.status.paused', defaultMessage: 'Paused' },
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
running: { id: 'app.downloads.status.running', defaultMessage: 'Running' },
canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' },
@@ -1262,6 +1292,7 @@ function toggleExpanded(jobId: string) {
}
async function withBusy(jobId: string, action: () => Promise<void>) {
if (busy.value.has(jobId)) return
busy.value = new Set([...busy.value, jobId])
try {
await action()
@@ -1278,6 +1309,21 @@ async function cancel(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.cancel(job.job_id))
}
function hostedBusyKey(bar: LoadingBar, action: HostedDownloadAction) {
return `hosted-${action === 'retry' ? 'retry' : 'control'}:${bar.bar_type?.instance_id}`
}
async function hostedAction(bar: LoadingBar, action: HostedDownloadAction) {
const instanceId = bar.bar_type?.instance_id
if (!instanceId || !hostedDownloadActions(bar).includes(action)) return
await withBusy(hostedBusyKey(bar, action), () =>
runHostedDownloadAction(action, instanceId, {
retryHosted: manager.retryHosted,
control: controlHostedDownload,
}),
)
}
async function retry(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.retry(job.job_id))
}
@@ -1293,12 +1339,14 @@ 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)) {
+63 -10
View File
@@ -39,6 +39,7 @@ import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import {
openSkinSiteLogin,
reloadSkinSitePlayers,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
selectedSkinSitePlayerId,
@@ -214,6 +215,18 @@ const messages = defineMessages({
id: 'app.skins.skin-site-account.empty-description',
defaultMessage: 'This skin site account does not have an available player profile yet.',
},
errorSkinSitePlayersTitle: {
id: 'app.skins.skin-site-account.error-title',
defaultMessage: 'Could not load your players',
},
errorSkinSitePlayersDescription: {
id: 'app.skins.skin-site-account.error-description',
defaultMessage: 'Check your connection and try again.',
},
retryButton: {
id: 'app.skins.skin-site-account.retry',
defaultMessage: 'Retry',
},
offlineCompatibility: {
id: 'app.skins.offline-account.compatibility',
defaultMessage:
@@ -982,6 +995,37 @@ watch(
},
)
// Load the player list whenever the session becomes available or the view is
// opened while the list never started loading. Without this the skin page can
// sit on "loading" forever when the first request raced the session check.
function ensureSkinSitePlayersLoaded() {
if (!skinSiteUser.value) return
if (skinSitePlayersStatus.value === 'checking') return
if (skinSitePlayersStatus.value === 'ready' && skinSitePlayers.value.length) return
if (skinSitePlayersStatus.value === 'error') return
void reloadSkinSitePlayers().catch((error) => {
console.warn('Failed to load skin site players:', error)
})
}
watch(
() => [skinSiteUser.value?.uuid, skinSiteStatus.value] as const,
([uuid, sessionStatus]) => {
if (uuid && sessionStatus === 'signed-in') ensureSkinSitePlayersLoaded()
},
{ immediate: true },
)
watch(skinSitePlayersStatus, (value) => {
if (value === 'idle' && skinSiteUser.value) ensureSkinSitePlayersLoaded()
})
function retrySkinSitePlayers() {
void reloadSkinSitePlayers().catch((error) => {
console.warn('Failed to reload skin site players:', error)
})
}
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return skinBlobUrlMap.get(key)
@@ -1436,22 +1480,26 @@ await loadSkins()
<h1 class="text-3xl font-extrabold m-0">
{{
formatMessage(
skinSiteUser
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersTitle
: messages.noSkinSitePlayersTitle
: messages.signInTitle,
!skinSiteUser
? messages.signInTitle
: skinSitePlayersStatus === 'error'
? messages.errorSkinSitePlayersTitle
: skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersTitle
: messages.noSkinSitePlayersTitle,
)
}}
</h1>
<p class="text-lg m-0">
{{
formatMessage(
skinSiteUser
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersDescription
: messages.noSkinSitePlayersDescription
: messages.signInDescription,
!skinSiteUser
? messages.signInDescription
: skinSitePlayersStatus === 'error'
? messages.errorSkinSitePlayersDescription
: skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersDescription
: messages.noSkinSitePlayersDescription,
)
}}
</p>
@@ -1462,6 +1510,11 @@ await loadSkins()
"
class="h-8 w-8 animate-spin text-brand"
/>
<ButtonStyled v-if="skinSiteUser && skinSitePlayersStatus === 'error'" color="brand">
<button @click="retrySkinSitePlayers">
{{ formatMessage(messages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
<button @click="loginToSkinSite">
<LogInIcon />
@@ -10,6 +10,7 @@ export interface AppUpdate {
currentVersion?: string
publishedAt?: string
forceUpdate?: boolean
rollback?: boolean
}
interface UpdatePromptState {
+11 -1
View File
@@ -1,7 +1,17 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
import { applySystemAccentHue, DEFAULT_CUSTOM_ACCENT_COLOR, hexToHsl } from './theme.ts'
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@/helpers/font-family.ts') {
return nextResolve(new URL('../helpers/font-family.ts', import.meta.url).href, context)
}
return nextResolve(specifier, context)
},
})
const { applySystemAccentHue, DEFAULT_CUSTOM_ACCENT_COLOR, hexToHsl } = await import('./theme.ts')
hooks.deregister()
test('applies the system hue with the custom accent saturation and lightness', () => {
const system = hexToHsl('#0078d4')
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta7"
version = "1.0.2-beta13"
edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red"
+3
View File
@@ -380,6 +380,9 @@ fn main() {
"hosted_create",
"hosted_binding",
"hosted_sync",
"hosted_pause",
"hosted_resume",
"hosted_cancel",
"hosted_instance_mode",
"hosted_set_instance_mode",
"install_pack_to_existing_instance",
+2 -1
View File
@@ -174,6 +174,7 @@
"terracotta:default",
"multiplayer:default",
"servers:default",
"planet-minecraft:default"
"planet-minecraft:default",
"fonts:default"
]
}
+18
View File
@@ -20,6 +20,9 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
hosted_create,
hosted_binding,
hosted_sync,
hosted_pause,
hosted_resume,
hosted_cancel,
hosted_instance_mode,
hosted_set_instance_mode,
install_get_modpack_preview,
@@ -105,6 +108,21 @@ pub async fn hosted_sync(
Ok(theseus::pack::hosted::synchronize(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_pause(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::pause(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_resume(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::resume(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_cancel(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::cancel(&instance_id).await?)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallCreateInstanceRequest {
+4 -1
View File
@@ -43,6 +43,8 @@ struct UpdatePreferences {
#[cfg(target_os = "macos")]
mod macos;
#[cfg(feature = "updater")]
mod update_policy;
#[cfg(feature = "updater")]
mod updater_impl;
#[cfg(not(feature = "updater"))]
@@ -1024,7 +1026,8 @@ fn main() {
s.restart_after_pending_update.load(Ordering::Relaxed)
})
.unwrap_or(false);
if let Some((update, data)) = &*update_data.0.lock().unwrap()
if let Some((update, data)) =
&update_data.0.lock().unwrap().pending
{
fn set_changelog_toast(version: Option<String>) {
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
+227
View File
@@ -0,0 +1,227 @@
use semver::Version;
pub fn requires_update(current: &Version, target: &Version) -> bool {
current != target
}
pub fn is_rollback(current: &Version, target: &Version) -> bool {
requires_update(current, target) && !is_remote_newer(current, target)
}
pub struct UpdateTarget<T> {
target: Option<String>,
check_revision: u64,
pub pending: Option<T>,
}
impl<T> Default for UpdateTarget<T> {
fn default() -> Self {
Self {
target: None,
check_revision: 0,
pending: None,
}
}
}
impl<T> UpdateTarget<T> {
pub fn begin_check(&mut self) -> u64 {
self.check_revision = self.check_revision.wrapping_add(1);
self.check_revision
}
pub fn select_checked(
&mut self,
revision: u64,
version: Option<&str>,
) -> bool {
if revision != self.check_revision {
return false;
}
self.select(version);
true
}
pub fn select(&mut self, version: Option<&str>) {
if self.target.as_deref() != version {
self.target = version.map(str::to_owned);
self.pending = None;
}
}
pub fn is_selected(&self, version: &str) -> bool {
self.target.as_deref() == Some(version)
}
pub fn enqueue(&mut self, version: &str, download: T) -> bool {
if !self.is_selected(version) {
return false;
}
self.pending = Some(download);
true
}
}
// semver compares prerelease identifiers lexically, so "beta10" < "beta9"
// (because the character '1' sorts before '9'). Our release names use a
// "betaN" scheme where N is a decimal counter, so a strict lexical compare
// would wrongly report an older beta as the latest. Use a comparator that
// treats the trailing digits of a prerelease identifier numerically.
fn is_remote_newer(current: &Version, remote: &Version) -> bool {
use std::cmp::Ordering;
let core = (remote.major, remote.minor, remote.patch).cmp(&(
current.major,
current.minor,
current.patch,
));
if core != Ordering::Equal {
return core == Ordering::Greater;
}
// Same x.y.z. A final release outranks any prerelease of that version.
match (current.pre.is_empty(), remote.pre.is_empty()) {
(true, true) => false,
(true, false) => false,
(false, true) => true,
(false, false) => {
compare_prerelease(remote.pre.as_str(), current.pre.as_str())
== Ordering::Greater
}
}
}
/// Split a prerelease identifier into its non-digit prefix and an optional
/// trailing decimal number (so "beta10" -> ("beta", Some(10))).
fn split_prerelease_identifier(segment: &str) -> (&str, Option<u64>) {
let digit_start = segment
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
match digit_start {
Some(i) => (&segment[..i], segment[i..].parse::<u64>().ok()),
None => (segment, None),
}
}
/// Compare two prerelease strings, treating a trailing run of ASCII digits
/// as a decimal number (so "beta10" > "beta9"). All other segments fall back
/// to a plain lexical comparison.
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let left_segments: Vec<&str> = left.split('.').collect();
let right_segments: Vec<&str> = right.split('.').collect();
for (l, r) in left_segments.iter().zip(right_segments.iter()) {
let (l_prefix, l_num) = split_prerelease_identifier(l);
let (r_prefix, r_num) = split_prerelease_identifier(r);
match (l_num, r_num) {
(Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
},
_ => {
let prefix_cmp = l_prefix.cmp(r_prefix);
if prefix_cmp != Ordering::Equal {
return prefix_cmp;
}
let num_cmp = l_num.cmp(&r_num);
if num_cmp != Ordering::Equal {
return num_cmp;
}
}
}
}
left_segments.len().cmp(&right_segments.len())
}
#[cfg(test)]
mod tests {
use super::*;
use semver::Version;
fn version(value: &str) -> Version {
Version::parse(value).unwrap()
}
#[test]
fn follows_the_server_target_in_both_directions() {
for (current, target) in [
("1.0.2-beta12", "1.0.2-beta9"),
("1.0.2-beta9", "1.0.2-beta12"),
("1.2.0", "1.1.0"),
("1.1.0", "1.2.0"),
("1.1.0", "1.1.0-beta1"),
] {
assert!(requires_update(&version(current), &version(target)));
}
assert!(!requires_update(
&version("1.0.2-beta12"),
&version("1.0.2-beta12")
));
}
#[test]
fn rollback_direction_uses_numeric_beta_suffixes() {
assert!(is_rollback(
&version("1.0.2-beta12"),
&version("1.0.2-beta9")
));
assert!(!is_rollback(
&version("1.0.2-beta9"),
&version("1.0.2-beta12")
));
assert!(is_rollback(&version("1.2.0"), &version("1.1.0")));
assert!(!is_rollback(&version("1.1.0"), &version("1.2.0")));
assert!(!is_rollback(&version("1.2.0"), &version("1.2.0")));
}
#[test]
fn changing_the_target_discards_an_already_downloaded_update() {
let mut state = UpdateTarget::default();
state.select(Some("1.0.2-beta12"));
assert!(state.enqueue("1.0.2-beta12", "new installer"));
state.select(Some("1.0.2-beta9"));
assert!(state.pending.is_none());
assert!(state.enqueue("1.0.2-beta9", "rollback installer"));
assert_eq!(state.pending, Some("rollback installer"));
}
#[test]
fn a_stale_download_cannot_replace_the_selected_rollback() {
let mut state = UpdateTarget::default();
state.select(Some("1.0.2-beta12"));
state.select(Some("1.0.2-beta9"));
assert!(state.enqueue("1.0.2-beta9", "rollback installer"));
assert!(!state.enqueue("1.0.2-beta12", "late installer"));
assert_eq!(state.pending, Some("rollback installer"));
}
#[test]
fn late_checks_cannot_reauthorize_a_superseded_version() {
let mut state = UpdateTarget::default();
let old_check = state.begin_check();
let rollback_check = state.begin_check();
assert!(state.select_checked(rollback_check, Some("1.0.2-beta9")));
assert!(state.enqueue("1.0.2-beta9", "rollback installer"));
assert!(!state.select_checked(old_check, Some("1.0.2-beta12")));
assert!(!state.enqueue("1.0.2-beta12", "late installer"));
assert_eq!(state.pending, Some("rollback installer"));
}
#[test]
fn repeated_checks_preserve_the_matching_download_and_no_update_clears_it()
{
let mut state = UpdateTarget::default();
state.select(Some("1.0.2-beta9"));
state.enqueue("1.0.2-beta9", "rollback installer");
state.select(Some("1.0.2-beta9"));
assert_eq!(state.pending, Some("rollback installer"));
state.select(None);
assert!(state.pending.is_none());
assert!(!state.enqueue("1.0.2-beta9", "late installer"));
}
}
+42 -82
View File
@@ -1,4 +1,5 @@
use crate::api::Result;
use crate::update_policy::{UpdateTarget, is_rollback, requires_update};
use futures::StreamExt;
use semver::Version;
use serde::{Deserialize, Serialize};
@@ -27,82 +28,6 @@ const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/";
const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15 * 60);
// semver compares prerelease identifiers lexically, so "beta10" < "beta9"
// (because the character '1' sorts before '9'). Our release names use a
// "betaN" scheme where N is a decimal counter, so a strict lexical compare
// would wrongly report an older beta as the latest. Use a comparator that
// treats the trailing digits of a prerelease identifier numerically.
fn is_remote_newer(current: &Version, remote: &Version) -> bool {
use std::cmp::Ordering;
let core = (remote.major, remote.minor, remote.patch).cmp(&(
current.major,
current.minor,
current.patch,
));
if core != Ordering::Equal {
return core == Ordering::Greater;
}
// Same x.y.z. A final release outranks any prerelease of that version.
match (current.pre.is_empty(), remote.pre.is_empty()) {
(true, true) => false,
(true, false) => false,
(false, true) => true,
(false, false) => {
compare_prerelease(remote.pre.as_str(), current.pre.as_str())
== Ordering::Greater
}
}
}
/// Split a prerelease identifier into its non-digit prefix and an optional
/// trailing decimal number (so "beta10" -> ("beta", Some(10))).
fn split_prerelease_identifier(segment: &str) -> (&str, Option<u64>) {
let digit_start = segment
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
match digit_start {
Some(i) => (&segment[..i], segment[i..].parse::<u64>().ok()),
None => (segment, None),
}
}
/// Compare two prerelease strings, treating a trailing run of ASCII digits
/// as a decimal number (so "beta10" > "beta9"). All other segments fall back
/// to a plain lexical comparison.
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let left_segments: Vec<&str> = left.split('.').collect();
let right_segments: Vec<&str> = right.split('.').collect();
for (l, r) in left_segments.iter().zip(right_segments.iter()) {
let (l_prefix, l_num) = split_prerelease_identifier(l);
let (r_prefix, r_num) = split_prerelease_identifier(r);
match (l_num, r_num) {
(Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
},
_ => {
let prefix_cmp = l_prefix.cmp(r_prefix);
if prefix_cmp != Ordering::Equal {
return prefix_cmp;
}
let num_cmp = l_num.cmp(&r_num);
if num_cmp != Ordering::Equal {
return num_cmp;
}
}
}
}
left_segments.len().cmp(&right_segments.len())
}
// ── Shared types ─────────────────────────────────────────────────
#[derive(Serialize)]
@@ -115,11 +40,12 @@ pub struct UpdateMetadata {
body: Option<String>,
published_at: Option<String>,
force_update: bool,
rollback: bool,
raw_json: serde_json::Value,
}
#[derive(Default)]
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>);
pub struct PendingUpdateData(pub Mutex<UpdateTarget<(Arc<Update>, Vec<u8>)>>);
// ── Update Server API types ─────────────────────────────────────
@@ -307,7 +233,7 @@ async fn check_with_endpoints<R: Runtime>(
.header("X-Axolotl-Platform", platform)?
.header("X-Axolotl-Version", current_version)?
.version_comparator(|current, release| {
is_remote_newer(&current, &release.version)
requires_update(&current, &release.version)
});
#[cfg(target_os = "windows")]
@@ -345,7 +271,20 @@ async fn check_with_updater<R: Runtime>(
webview: &Webview<R>,
channel: &str,
) -> Result<Option<UpdateMetadata>> {
let Some(mut update) = check_with_endpoints(webview, channel).await? else {
let pending_data = webview.state::<PendingUpdateData>();
let revision = pending_data.0.lock().unwrap().begin_check();
let checked = check_with_endpoints(webview, channel).await?;
if !pending_data.0.lock().unwrap().select_checked(
revision,
checked.as_ref().map(|update| update.version.as_str()),
) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"A newer launcher update check superseded this response"
.to_string(),
))
.into());
}
let Some(mut update) = checked else {
return Ok(None);
};
update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT);
@@ -369,6 +308,10 @@ async fn check_with_updater<R: Runtime>(
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let metadata = UpdateMetadata {
rollback: is_rollback(
&Version::parse(&update.current_version).map_err(Error::from)?,
&Version::parse(&update.version).map_err(Error::from)?,
),
rid: webview.resources_table().add(update.clone()),
current_version: update.current_version.clone(),
version: update.version.clone(),
@@ -449,6 +392,12 @@ pub async fn enqueue_update_for_installation<R: Runtime>(
let update = webview.resources_table().get::<Update>(rid)?;
if !pending_data.0.lock().unwrap().is_selected(&update.version) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"The server-selected launcher version changed; check for updates again".to_string(),
)).into());
}
let progress = init_loading(
LoadingBarType::LauncherUpdate {
version: update.version.clone(),
@@ -565,11 +514,19 @@ pub async fn enqueue_update_for_installation<R: Runtime>(
let download_duration = download_start.elapsed();
tracing::info!("Downloaded update in {download_duration:?}");
pending_data
let version = update.version.clone();
if !pending_data
.0
.lock()
.unwrap()
.replace((update, update_data));
.enqueue(&version, (update, update_data))
{
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"The server-selected launcher version changed during download"
.to_string(),
))
.into());
}
Ok(())
}
@@ -577,7 +534,9 @@ pub async fn enqueue_update_for_installation<R: Runtime>(
#[tauri::command]
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
let pending_data = webview.state::<PendingUpdateData>().inner();
pending_data.0.lock().unwrap().take();
let mut pending = pending_data.0.lock().unwrap();
pending.begin_check();
pending.select(None);
}
// ── Debian / derivatives apt update ─────────────────────────────
@@ -630,6 +589,7 @@ pub async fn install_apt_package(version: &str, data: &[u8]) -> Result<()> {
.arg("apt")
.arg("install")
.arg("-y")
.arg("--allow-downgrades")
.arg(&install_path)
.output()
})
+3 -2
View File
@@ -21,6 +21,7 @@
"icons/AxolotlAssets.car"
],
"windows": {
"allowDowngrades": true,
"nsis": {
"installMode": "currentUser",
"installerHooks": "./nsis/hooks.nsi"
@@ -125,7 +126,7 @@
"script-src": "'self' 'unsafe-eval' 'wasm-unsafe-eval'",
"worker-src": "'self' blob:",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self' http://axolotl-skin.localhost axolotl-skin://localhost https://skin.starlight.cool",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self' http://axolotl-skin.localhost axolotl-skin://localhost https://skin.starlight.cool https://map.starlight.cool",
"media-src": "'self' data: https://*.githubusercontent.com"
},
"csp": {
@@ -135,7 +136,7 @@
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "'self' 'unsafe-eval' 'wasm-unsafe-eval'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self' axolotl-skin://localhost http://axolotl-skin.localhost https://skin.starlight.cool",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self' axolotl-skin://localhost http://axolotl-skin.localhost https://skin.starlight.cool https://map.starlight.cool",
"media-src": "'self' data: https://*.githubusercontent.com"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus"
# The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta7"
version = "1.0.2-beta13"
edition.workspace = true
[dependencies]
+18 -13
View File
@@ -5197,19 +5197,9 @@ fn modpack_zip_error(error: zip::result::ZipError) -> crate::Error {
}
fn safe_archive_relative_path(value: &str) -> crate::Result<String> {
let path = Path::new(value);
if value.is_empty()
|| path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(ErrorKind::InputError(
"CurseForge modpack contains an invalid override path".to_string(),
)
.into());
}
Ok(path.to_string_lossy().replace('\\', "/"))
let normalized = value.replace('\\', "/");
crate::util::io::safe_relative_join(Path::new(""), &normalized)?;
Ok(normalized)
}
pub(crate) fn loader_family(loader_id: &str) -> &str {
@@ -10933,6 +10923,21 @@ mod tests {
);
assert!(safe_archive_relative_path("../options.txt").is_err());
assert!(safe_archive_relative_path("/options.txt").is_err());
for path in [
r"..\options.txt",
"config/.. /outside",
"config/file:stream",
"C:/outside",
"C:outside",
"config/NUL",
"config/file.",
] {
assert!(safe_archive_relative_path(path).is_err(), "{path}");
}
assert_eq!(
safe_archive_relative_path(r"config\example.toml").unwrap(),
"config/example.toml"
);
}
#[test]
+64 -15
View File
@@ -728,7 +728,23 @@ async fn instance_and_root(
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let root = state.directories.instances_dir().join(&instance.path);
// Back up the directory the game actually writes to. The runtime adapter
// is the authoritative resolver used by `instance_get_full_path` and the
// launch path, so backups follow the same rules: a per-instance
// `game_dir_override` (hosted/SLS packs, external folders) resolves through
// its launcher dialect and version isolation, and direct-linked
// installations resolve into the linked `.minecraft`.
let root = crate::launcher::instance_runtime::InstanceRuntimeAdapter::
for_instance(&instance, &state.directories)
.map(|adapter| adapter.game_dir())
.unwrap_or_else(|error| {
tracing::warn!(
%error,
instance_id = %instance.id,
"Falling back to the unresolved game directory for backup root"
);
state.directories.instance_game_dir(&instance)
});
Ok((instance, root))
}
@@ -746,13 +762,6 @@ async fn eligibility_for(
if instance.is_direct_linked() {
return Ok(InstanceBackupEligibility::DirectLinkedInstance);
}
if instance
.game_dir_override
.as_deref()
.is_some_and(|path| !path.trim().is_empty())
{
return Ok(InstanceBackupEligibility::ExternalGameDirectory);
}
let metadata = match tokio::fs::symlink_metadata(&root).await {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
@@ -1658,6 +1667,42 @@ async fn create_snapshot_inner(
})
}
/// Game-body directories that live at a shared `.minecraft` root but do not
/// belong to any single instance. Excluding them keeps backups of externally
/// managed instances to their own saves, configs and mods instead of
/// re-copying several gigabytes of libraries and assets.
const SHARED_ROOT_EXCLUSIONS: [&str; 5] =
["libraries", "assets", "versions", "logs", "crash-reports"];
/// True when `path` looks like a shared `.minecraft` root rather than a
/// per-instance folder (mirrors the removal heuristic used elsewhere).
fn is_shared_minecraft_root(path: &Path) -> bool {
path.join("libraries").is_dir() || path.join("assets").is_dir()
}
/// Adds the shared game-body exclusions when the backup root is a shared
/// `.minecraft` root. Per-instance folders (hosted packs, isolated profiles)
/// are left untouched so their own `logs` or `versions` stay backed up.
fn with_shared_root_exclusions(
root: &Path,
exclusions: &[BackupExclusion],
) -> Vec<BackupExclusion> {
let mut merged = exclusions.to_vec();
if !is_shared_minecraft_root(root) {
return merged;
}
for name in SHARED_ROOT_EXCLUSIONS {
if merged.iter().any(|existing| existing.path == name) {
continue;
}
merged.push(BackupExclusion {
path: name.to_string(),
kind: BackupExclusionKind::Directory,
});
}
merged
}
async fn scan_snapshot_attempt(
root: &Path,
repository: &Path,
@@ -1680,6 +1725,8 @@ async fn scan_snapshot_attempt(
None,
)
.await;
let exclusions = with_shared_root_exclusions(root, exclusions);
let exclusions = exclusions.as_slice();
let total_bytes = measure_snapshot(root, exclusions, cancellation).await?;
emit_backup_progress(
operation_id,
@@ -2642,13 +2689,10 @@ pub(crate) async fn ensure_backup_eligible_edit(
instance_id: &str,
patch: &crate::state::EditInstance,
) -> crate::Result<()> {
// Only symlink instances stay ineligible; switching to an external game
// directory is fine because the backup root follows it.
let becomes_ineligible =
patch.symlink_target.as_ref().is_some_and(Option::is_some)
|| patch
.game_dir_override
.as_ref()
.and_then(|path| path.as_deref())
.is_some_and(|path| !path.trim().is_empty());
patch.symlink_target.as_ref().is_some_and(Option::is_some);
if !becomes_ineligible {
return Ok(());
}
@@ -3020,11 +3064,16 @@ async fn build_restore_plan(
.into());
}
}
// The snapshot never captured the shared game body (see
// `with_shared_root_exclusions`), so the current tree must be compared with
// the same exclusions or those directories would look like files the
// snapshot dropped and get deleted on restore.
let scan_exclusions = with_shared_root_exclusions(root, &exclusions);
let current = scan_restore_entries(
root,
whole_root,
&selected_roots,
&exclusions,
&scan_exclusions,
progress,
)
.await?;
+84 -18
View File
@@ -1,6 +1,6 @@
//! Shared helpers for extracting content from local modpack archives.
use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use super::detect::decode_zip_entry_name;
use crate::util::io;
@@ -13,22 +13,6 @@ fn archive_error(error: zip::result::ZipError) -> crate::Error {
.into()
}
pub(crate) fn safe_relative_path(value: &str) -> crate::Result<String> {
let path = Path::new(value);
if value.is_empty()
|| path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(crate::ErrorKind::InputError(
"Modpack archive contains an invalid file path".to_string(),
)
.into());
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
/// Extracts every file under `prefix` in the archive into `target_dir`,
/// preserving the directory structure below the prefix. Returns the number of
/// files written.
@@ -114,7 +98,8 @@ fn extract_archive_subdir_sync(
)
.into());
}
let target = target_dir.join(safe_relative_path(relative)?);
let target =
io::safe_relative_join(target_dir, &relative.replace('\\', "/"))?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io::IOError::with_path(error, parent))?;
@@ -252,6 +237,87 @@ pub(crate) async fn create_import_scratch_dir(
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn unsafe_override_paths_fail_without_writing_outside_target() {
let root = tempfile::tempdir().unwrap();
let archive_path = root.path().join("pack.zip");
let target_dir = root.path().join("server");
std::fs::create_dir(&target_dir).unwrap();
let outside = root.path().join("outside.txt");
std::fs::write(&outside, b"unchanged").unwrap();
for prefix in ["overrides/", "server/overrides/"] {
for relative in [
"../outside.txt",
r"..\outside.txt",
"config/../../outside.txt",
"/outside.txt",
"C:/outside.txt",
"C:outside.txt",
r"\\server\share\outside.txt",
"config/.. /outside.txt",
"config/mod.jar:payload",
"config/NUL",
"config/secret.",
] {
let mut archive = zip::ZipWriter::new(
std::fs::File::create(&archive_path).unwrap(),
);
archive
.start_file(
format!("{prefix}{relative}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
archive.write_all(b"malicious").unwrap();
archive.finish().unwrap();
assert!(
extract_archive_subdir_sync(
&archive_path,
prefix,
&target_dir,
None,
)
.is_err(),
"{prefix}{relative}"
);
assert_eq!(std::fs::read(&outside).unwrap(), b"unchanged");
assert_eq!(std::fs::read_dir(&target_dir).unwrap().count(), 0);
}
}
}
#[test]
fn override_paths_with_backslashes_are_normalized() {
let root = tempfile::tempdir().unwrap();
let archive_path = root.path().join("pack.zip");
let target_dir = root.path().join("server");
let mut archive =
zip::ZipWriter::new(std::fs::File::create(&archive_path).unwrap());
archive
.start_file(
r"overrides/config\example.toml",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
archive.write_all(b"valid").unwrap();
archive.finish().unwrap();
assert_eq!(
extract_archive_subdir_sync(
&archive_path,
"overrides/",
&target_dir,
None,
)
.unwrap(),
1
);
assert_eq!(
std::fs::read(target_dir.join("config/example.toml")).unwrap(),
b"valid"
);
}
struct CancelOnRead {
cancellation: CancellationToken,
+21 -31
View File
@@ -1,5 +1,7 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
mod control;
mod progress;
pub use control::{HostedTaskState, cancel, pause, resume};
mod tagged;
mod transport;
use crate::{
@@ -938,10 +940,20 @@ async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
let metadata = crate::instance::get(instance_id)
.await?
.ok_or_else(|| invalid("Unknown instance"))?;
let task = control::Registration::new(instance_id)?;
let progress =
PackProgress::new(instance_id, &metadata.instance.name).await?;
let result =
synchronize_with_progress(instance_id, metadata, &progress).await;
let result = task
.control
.run(|| {
synchronize_with_progress(
instance_id,
metadata.clone(),
&progress,
&task.control,
)
})
.await;
if let Err(error) = &result {
progress.fail(error);
}
@@ -952,6 +964,7 @@ async fn synchronize_with_progress(
instance_id: &str,
metadata: crate::state::InstanceMetadata,
progress: &PackProgress,
control: &control::Control,
) -> crate::Result<SyncResult> {
if metadata.instance.linked_launcher.is_some()
|| metadata.instance.symlink_target.is_some()
@@ -1275,7 +1288,7 @@ async fn synchronize_with_progress(
&format!("文件 0/{total_files}"),
true,
);
let results = stream::iter(pending_downloads.into_values().map(
downloaded += control::collect_downloads(stream::iter(pending_downloads.into_values().map(
|(file, url, object)| {
let current = &current;
let completed = &completed;
@@ -1342,34 +1355,14 @@ async fn synchronize_with_progress(
request = request
.with_header("Authorization", auth.clone());
}
let first_result = download_to_path(
download_to_path(
request.clone(),
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await;
if let Err(error) = first_result {
tracing::warn!(
path = %file_path,
%error,
"Parallel StarLight file download failed; retrying after the batch load has eased"
);
ensure_session(auth).await?;
tokio::time::sleep(std::time::Duration::from_millis(
750,
))
.await;
download_to_path(
request,
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
}
.await.map_err(|error| error.with_context(format!("下载整合包文件失败:{file_path}")))?;
let completed_files =
completed.fetch_add(1, Ordering::Relaxed) + 1;
let completed_bytes = {
@@ -1391,12 +1384,7 @@ async fn synchronize_with_progress(
}
},
))
.buffer_unordered(concurrency)
.collect::<Vec<_>>()
.await;
downloaded += results.into_iter().try_fold(0, |sum, result| {
Ok::<u64, crate::Error>(sum + result?)
})?;
.buffer_unordered(concurrency)).await?;
}
ensure_session(&auth).await?;
downloaded += tagged::download(
@@ -1446,6 +1434,8 @@ async fn synchronize_with_progress(
previous,
metadata,
};
// From this atomic boundary onward the rollback/runtime transaction must finish.
control.begin_finalizing()?;
write_json(&root, JOURNAL, &journal)?;
let result = async {
progress.update(0, 0, "正在安装游戏组件", true);
@@ -0,0 +1,329 @@
use futures::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use std::{
future::Future,
sync::{Arc, LazyLock, Mutex},
};
use tokio::sync::watch;
#[derive(
Clone, Copy, Debug, Default, Deserialize, Serialize, Hash, PartialEq, Eq,
)]
#[serde(rename_all = "snake_case")]
pub enum HostedTaskState {
#[default]
Running,
Paused,
Canceling,
Canceled,
Failed,
Finalizing,
}
static CONTROLS: LazyLock<dashmap::DashMap<String, Arc<Control>>> =
LazyLock::new(dashmap::DashMap::new);
pub(super) struct Control {
instance_id: String,
state: Mutex<HostedTaskState>,
changes: watch::Sender<HostedTaskState>,
}
fn stopped() -> crate::Error {
super::invalid("已停止整合包下载;已下载的缓存会保留,可重试继续")
}
impl Control {
fn new(instance_id: &str) -> Self {
Self {
instance_id: instance_id.to_owned(),
state: Mutex::new(HostedTaskState::Running),
changes: watch::channel(HostedTaskState::Running).0,
}
}
fn state(&self) -> HostedTaskState {
*self.state.lock().unwrap_or_else(|error| error.into_inner())
}
fn transition(
&self,
allowed: &[HostedTaskState],
next: HostedTaskState,
) -> crate::Result<()> {
let mut state =
self.state.lock().unwrap_or_else(|error| error.into_inner());
if !allowed.contains(&state) {
return Err(super::invalid(
"任务状态已变化或正在应用安装文件,当前操作不可用",
));
}
*state = next;
// Publish under the transition lock, so a late pause cannot overwrite finalization.
crate::event::emit::set_hosted_loading_state(&self.instance_id, next);
self.changes.send_replace(next);
Ok(())
}
fn pause(&self) -> crate::Result<()> {
self.transition(&[HostedTaskState::Running], HostedTaskState::Paused)
}
fn resume(&self) -> crate::Result<()> {
self.transition(&[HostedTaskState::Paused], HostedTaskState::Running)
}
fn cancel(&self) -> crate::Result<()> {
self.transition(
&[HostedTaskState::Running, HostedTaskState::Paused],
HostedTaskState::Canceling,
)
}
pub(super) fn begin_finalizing(&self) -> crate::Result<()> {
self.transition(
&[HostedTaskState::Running],
HostedTaskState::Finalizing,
)
}
/// The operation may be dropped only before begin_finalizing. Restarting
/// re-plans from verified cache objects and the downloader's partial files.
pub(super) async fn run<T, F, Fut>(
&self,
mut operation: F,
) -> crate::Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = crate::Result<T>>,
{
let mut changes = self.changes.subscribe();
loop {
let state = *changes.borrow_and_update();
match state {
HostedTaskState::Paused => {
changes.changed().await.map_err(|_| stopped())?;
continue;
}
HostedTaskState::Canceling => return Err(stopped()),
_ => {}
}
let work = operation();
tokio::pin!(work);
loop {
tokio::select! {
biased;
changed = changes.changed() => {
changed.map_err(|_| stopped())?;
match self.state() {
HostedTaskState::Paused => break,
HostedTaskState::Canceling => return Err(stopped()),
_ => {},
}
}
result = &mut work => {
match self.state() {
HostedTaskState::Paused => break,
HostedTaskState::Canceling => return Err(stopped()),
_ => return result,
}
}
}
}
}
}
}
pub(super) struct Registration {
instance_id: String,
pub(super) control: Arc<Control>,
}
impl Registration {
pub(super) fn new(instance_id: &str) -> crate::Result<Self> {
use dashmap::mapref::entry::Entry;
let control = Arc::new(Control::new(instance_id));
match CONTROLS.entry(instance_id.to_owned()) {
Entry::Occupied(_) => {
return Err(super::invalid("此实例已有整合包下载任务"));
}
Entry::Vacant(entry) => {
entry.insert(control.clone());
}
}
Ok(Self {
instance_id: instance_id.to_owned(),
control,
})
}
}
impl Drop for Registration {
fn drop(&mut self) {
CONTROLS.remove_if(&self.instance_id, |_, value| {
Arc::ptr_eq(value, &self.control)
});
}
}
fn lookup(instance_id: &str) -> crate::Result<Arc<Control>> {
CONTROLS
.get(instance_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| super::invalid("整合包下载任务已结束,请重试"))
}
pub async fn pause(instance_id: &str) -> crate::Result<()> {
lookup(instance_id)?.pause()
}
pub async fn resume(instance_id: &str) -> crate::Result<()> {
lookup(instance_id)?.resume()
}
pub async fn cancel(instance_id: &str) -> crate::Result<()> {
lookup(instance_id)?.cancel()
}
/// Stop the batch on its first error so stalled peers cannot hide failure.
pub(super) async fn collect_downloads<S>(downloads: S) -> crate::Result<u64>
where
S: Stream<Item = crate::Result<u64>>,
{
tokio::pin!(downloads);
let mut total = 0;
while let Some(result) = downloads.next().await {
total += result?;
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use futures::FutureExt;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::{Notify, Semaphore};
struct TransferGuard(Arc<AtomicUsize>, Arc<Notify>);
impl Drop for TransferGuard {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
self.1.notify_one();
}
}
#[tokio::test]
async fn pause_drops_live_transfers_and_resume_restarts_before_stop() {
let control = Arc::new(Control::new("pause-resume-test"));
let starts = Arc::new(Semaphore::new(0));
let drops = Arc::new(AtomicUsize::new(0));
let dropped = Arc::new(Notify::new());
let task = {
let control = control.clone();
let starts = starts.clone();
let drops = drops.clone();
let dropped = dropped.clone();
tokio::spawn(async move {
control
.run(|| {
let guard =
TransferGuard(drops.clone(), dropped.clone());
starts.add_permits(1);
async move {
let _guard = guard;
std::future::pending::<crate::Result<()>>().await
}
})
.await
})
};
starts.acquire().await.unwrap().forget();
control.pause().unwrap();
dropped.notified().await;
assert_eq!(drops.load(Ordering::SeqCst), 1);
assert_eq!(control.state(), HostedTaskState::Paused);
control.resume().unwrap();
starts.acquire().await.unwrap().forget();
control.cancel().unwrap();
let result = task.await.unwrap();
assert!(result.unwrap_err().to_string().contains("停止"));
assert_eq!(drops.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn stopping_an_already_paused_task_never_starts_a_transfer() {
let control = Arc::new(Control::new("paused-stop-test"));
control.pause().unwrap();
let task = {
let control = control.clone();
tokio::spawn(async move {
control
.run(|| async {
panic!("paused work was polled");
Ok::<(), crate::Error>(())
})
.await
})
};
control.cancel().unwrap();
assert!(task.await.unwrap().is_err());
}
#[test]
fn finalization_rejects_pause_and_stop() {
let control = Control::new("finalization-test");
control.begin_finalizing().unwrap();
assert_eq!(control.state(), HostedTaskState::Finalizing);
assert!(control.pause().is_err());
assert!(control.cancel().is_err());
}
#[test]
fn pause_and_finalization_have_only_one_winner() {
for _ in 0..32 {
let control = Arc::new(Control::new("finalization-race-test"));
let barrier = Arc::new(std::sync::Barrier::new(2));
let task = {
let control = control.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
control.pause().is_ok()
})
};
barrier.wait();
let finalized = control.begin_finalizing().is_ok();
assert_ne!(finalized, task.join().unwrap());
}
}
#[tokio::test]
async fn first_failure_drops_stalled_peer_downloads() {
let drops = Arc::new(AtomicUsize::new(0));
let guard = TransferGuard(drops.clone(), Arc::new(Notify::new()));
let downloads = futures::stream::iter(vec![
async move {
let _guard = guard;
std::future::pending::<crate::Result<u64>>().await
}
.boxed(),
async { Err(super::super::invalid("failed file")) }.boxed(),
])
.buffer_unordered(2);
let result = collect_downloads(downloads).await;
assert!(result.unwrap_err().to_string().contains("failed file"));
assert_eq!(drops.load(Ordering::SeqCst), 1);
}
#[test]
fn registration_is_unique_and_released_on_drop() {
let id = uuid::Uuid::new_v4().to_string();
let registration = Registration::new(&id).unwrap();
assert!(Registration::new(&id).is_err());
drop(registration);
assert!(Registration::new(&id).is_ok());
}
}
@@ -59,6 +59,9 @@ impl PackProgress {
instance_id: instance_id.to_owned(),
instance_name: name.to_owned(),
error: None,
state: super::HostedTaskState::Running,
can_pause: true,
can_cancel: true,
},
1.0,
"正在获取整合包信息",
@@ -681,7 +681,7 @@ pub(super) async fn download(
set_loading(&bar, 0, file.size, "等待下载")?;
work.push((file, url, object, bar));
}
let results =
control::collect_downloads(
stream::iter(work.into_iter().map(|(file, url, object, bar)| {
let current = &current;
let state = &state;
@@ -740,10 +740,7 @@ pub(super) async fn download(
result
}
}))
.buffer_unordered(concurrency)
.collect::<Vec<_>>()
.await;
results
.into_iter()
.try_fold(0, |sum, result| Ok(sum + result?))
.buffer_unordered(concurrency),
)
.await
}
+70 -18
View File
@@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::future::Future;
use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use zip::ZipArchive;
@@ -320,23 +320,13 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
"Specified version has no files".to_string(),
)
})?;
let file_name = Path::new(&pack_file.filename);
if file_name.components().count() != 1
|| !matches!(file_name.components().next(), Some(Component::Normal(_)))
{
return Err(crate::ErrorKind::InputError(
"Modrinth returned an invalid modpack file name".to_string(),
)
.into());
}
let hash = pack_file.hashes.get("sha1");
let pack_path = state
.directories
.caches_dir()
.join("modpacks")
.join(&project_id)
.join(&version_id)
.join(file_name);
let pack_path = modpack_cache_path(
&state.directories.caches_dir().join("modpacks"),
&project_id,
&version_id,
&pack_file.filename,
)?;
let details = InstallPhaseDetails::Modpack {
project_id: Some(project_id.clone()),
@@ -517,8 +507,70 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
})
}
#[tracing::instrument]
fn modpack_cache_path(
root: &Path,
project_id: &str,
version_id: &str,
filename: &str,
) -> crate::Result<PathBuf> {
let mut path = root.to_path_buf();
for segment in [project_id, version_id, filename] {
if segment.contains('/') {
return Err(crate::ErrorKind::InputError(
"Invalid modpack cache path component".to_string(),
)
.into());
}
path = crate::util::io::safe_relative_join(&path, segment)?;
}
Ok(path)
}
#[cfg(test)]
mod cache_path_tests {
use super::modpack_cache_path;
use std::path::Path;
#[test]
fn cache_components_cannot_escape_their_directories() {
let root = Path::new("modpacks");
assert_eq!(
modpack_cache_path(root, "project", "version", "pack.mrpack")
.unwrap(),
root.join("project/version/pack.mrpack")
);
for malicious in [
"../outside",
"nested/name",
r"..\outside",
"C:/outside",
"C:outside",
r"\\server\share",
".",
"",
"NUL",
"pack.mrpack:stream",
".. ",
] {
for position in 0..3 {
let mut components = ["project", "version", "pack.mrpack"];
components[position] = malicious;
assert!(
modpack_cache_path(
root,
components[0],
components[1],
components[2]
)
.is_err(),
"{position}: {malicious:?}"
);
}
}
}
}
#[tracing::instrument]
pub async fn generate_pack_from_file(
path: PathBuf,
instance_id: String,
+58 -18
View File
@@ -891,6 +891,12 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
num_files,
content_total_bytes,
};
for file in &pack.files {
io::join_within_root(
&content_context.instance_full_path,
&content_context.resolve_install_path(file),
)?;
}
let skipped_missing_content_paths = Arc::new(
reporter
.current_state()
@@ -1039,8 +1045,14 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
let project_size = project.file_size as u64;
let project_path =
content_context.resolve_install_path(&project);
let target_path =
content_context.instance_full_path.join(&project_path);
let target_path = io::join_within_root(
&content_context.instance_full_path,
&project_path,
).map_err(|error| RequiredFileFailure::new(
manifest_index,
&project_path,
&error.to_string(),
))?;
let context =
InstallErrorContext::new("download modpack content file")
.maybe_project_id(
@@ -1451,7 +1463,10 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
entry_name: filename.to_string(),
crc32: file.crc32(),
uncompressed_size: file.uncompressed_size(),
target_path: instance_full_path.join(&relative_path),
target_path: io::safe_relative_join(
&instance_full_path,
&relative_path,
)?,
relative_path,
})
})
@@ -1739,6 +1754,7 @@ fn override_relative_path(
"Invalid modpack override path: {relative}"
))
})?;
io::safe_relative_join(Path::new(""), relative.as_str())?;
Ok(relative.as_str().to_string())
}
@@ -1885,7 +1901,11 @@ pub async fn remove_all_related_files(
// Iterate over all Modrinth project file paths in the json, and remove them
// (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized)
for file in pack.files {
match io::remove_file(instance_full_path.join(file.path.as_str())).await
match io::remove_file(io::safe_relative_join(
&instance_full_path,
file.path.as_str(),
)?)
.await
{
Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (),
@@ -1904,23 +1924,14 @@ pub async fn remove_all_related_files(
for file in override_file_entries {
let entry_name = file.filename().as_str().unwrap();
let entry_name =
entry_name.strip_prefix(&archive_base).unwrap_or(entry_name);
let relative_override_file_path =
SafeRelativeUtf8UnixPathBuf::try_from(entry_name.to_string())?;
let relative_override_file_path = relative_override_file_path
.strip_prefix("overrides")
.or_else(|_| relative_override_file_path.strip_prefix("client-overrides"))
.map_err(|_| {
crate::Error::from(crate::ErrorKind::OtherError(
format!("Failed to strip override prefix from override file path: {relative_override_file_path}")
))
})?;
override_relative_path(entry_name, &archive_base)?;
// Remove this file if a corresponding one exists in the filesystem
match io::remove_file(
instance_full_path.join(relative_override_file_path.as_str()),
)
match io::remove_file(io::safe_relative_join(
&instance_full_path,
&relative_override_file_path,
)?)
.await
{
Ok(_) => (),
@@ -1939,6 +1950,35 @@ mod tests {
use async_zip::{Compression, ZipEntryBuilder};
use std::sync::Mutex as StdMutex;
#[test]
fn override_paths_reject_portable_traversal_and_windows_aliases() {
for prefix in ["overrides/", "client-overrides/"] {
assert_eq!(
override_relative_path(
&format!("{prefix}config/example.toml"),
""
)
.unwrap(),
"config/example.toml"
);
for relative in [
"../outside",
"config/.. /outside",
"config/file:stream",
"C:/outside",
"C:outside",
"config/NUL",
"config/file.",
] {
assert!(
override_relative_path(&format!("{prefix}{relative}"), "")
.is_err(),
"{prefix}{relative}"
);
}
}
}
fn override_spec(index: usize, target: &str) -> OverrideExtractionSpec {
OverrideExtractionSpec {
index,
+30 -44
View File
@@ -12,6 +12,8 @@ use crate::{ErrorKind, Result};
use super::manifest::server_path;
pub(super) use crate::util::io::safe_relative_join;
const DOWNLOAD_PROGRESS_STEP: u64 = 512 * 1024;
pub async fn read_file(server_id: &str, file: &str) -> Result<String> {
@@ -124,44 +126,7 @@ pub(super) async fn download_to_dir(
async fn resolve_server_file(server_id: &str, file: &str) -> Result<PathBuf> {
let dir = server_path(server_id).await?;
safe_join(&dir, file)
}
fn safe_join(dir: &Path, file: &str) -> Result<PathBuf> {
if file.is_empty()
|| file.contains('\\')
|| file.starts_with('/')
|| file
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
return Err(ErrorKind::InputError(format!(
"Invalid file name: {file}"
))
.as_error());
}
Ok(dir.join(file))
}
/// Joins a relative path that may contain `/` separators but never escapes the
/// server directory (no `..`, empty segments, or absolute/Windows roots).
pub(super) fn safe_relative_join(dir: &Path, rel: &str) -> Result<PathBuf> {
let is_windows_drive_root =
rel.as_bytes().get(1).is_some_and(|byte| *byte == b':')
&& rel.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
if rel.is_empty()
|| Path::new(rel).is_absolute()
|| is_windows_drive_root
|| rel.starts_with('/')
|| rel.starts_with('\\')
|| rel.split(['/', '\\']).any(|segment| {
segment.is_empty() || segment == ".." || segment == "."
})
{
return Err(ErrorKind::InputError(format!("Invalid file path: {rel}"))
.as_error());
}
Ok(dir.join(rel))
safe_relative_join(&dir, file)
}
#[cfg(test)]
@@ -169,13 +134,34 @@ mod tests {
use super::*;
#[test]
fn safe_join_rejects_traversal() {
fn safe_relative_join_rejects_unsafe_paths() {
let dir = Path::new("/tmp/servers/a");
assert!(safe_join(dir, "server.properties").is_ok());
assert!(safe_join(dir, "../secret").is_err());
assert!(safe_join(dir, "/etc/passwd").is_err());
assert!(safe_join(dir, "a//b").is_err());
assert!(safe_join(dir, "").is_err());
for path in [
"../secret",
"mods/../../secret",
r"..\secret",
r"mods/..\../secret",
"/etc/passwd",
"C:/windows/system32",
r"C:\windows\system32",
"C:secret",
r"\\server\share\secret",
r"\secret",
r"\\?\C:\secret",
"a//b",
"a/./b",
".",
"",
"mods/.. /secret",
"mods/secret.",
"mods/secret ",
"mods/mod.jar:payload",
"NUL/secret",
"mods/CON.jar",
"mods/mod\0.jar",
] {
assert!(safe_relative_join(dir, path).is_err(), "{path:?}");
}
}
#[test]
+2 -2
View File
@@ -14,7 +14,7 @@ use crate::event::emit::emit_server;
use crate::util::io::IOError;
use crate::{ErrorKind, Result};
use super::files::download_to_dir;
use super::files::{download_to_dir, safe_relative_join};
use super::manifest::{
InstallState, read_manifest, server_path, write_manifest,
};
@@ -41,6 +41,7 @@ pub async fn install_forge(
drop(manifest);
let installer_name = format!("forge-{mc_version}-{build}-installer.jar");
let installer_path = safe_relative_join(&dir, &installer_name)?;
let installer_url =
format!("{FORGE_MAVEN}/{mc_version}-{build}/{installer_name}");
@@ -52,7 +53,6 @@ pub async fn install_forge(
download_to_dir(server_id, &dir, &installer_url, &installer_name, None)
.await?;
let installer_path = dir.join(&installer_name);
let java = java_path.clone().unwrap_or_else(|| "java".to_string());
log(server_id, "Running Forge installer (this may take a while)").await?;
@@ -18,6 +18,7 @@ use crate::state::{clear_log_buffer, get_log_buffer, push_log_line};
use crate::util::io::IOError;
use crate::{ErrorKind, Result};
use super::files::safe_relative_join;
use super::logs::{
analyze_exit_reason, stream_server_output, stream_server_pty_output,
tail_server_log_file,
@@ -131,7 +132,7 @@ async fn start_inner(
forge_launch_args(&dir)?
} else {
let jar_name = resolve_jar_name(&manifest);
let jar_path = dir.join(&jar_name);
let jar_path = safe_relative_join(&dir, &jar_name)?;
if !jar_path.exists() {
return Err(ErrorKind::LauncherError(format!(
"Server jar not found: {jar_name}. Download the server files first."
@@ -275,9 +276,11 @@ async fn start_inner(
// so these lines stop the console from looking frozen.
let loader_first_run =
matches!(manifest.server_type.as_str(), "fabric" | "quilt")
&& !dir
.join(format!("{}-server-launch.jar", manifest.server_type))
.exists();
&& !safe_relative_join(
&dir,
&format!("{}-server-launch.jar", manifest.server_type),
)?
.exists();
log_server_step(
server_id,
+2 -1
View File
@@ -7,6 +7,7 @@ use crate::state::remove_log_buffer;
use crate::util::io::{self, IOError};
use crate::{ErrorKind, Result, State};
use super::files::safe_relative_join;
use super::lifecycle::is_running;
use super::manifest::{
ServerInfo, ServerManifest, build_server_info, read_manifest,
@@ -72,7 +73,7 @@ pub async fn create(
let state = State::get().await?;
let id = Uuid::new_v4().to_string();
let dir_name = format!("{}-{}", sanitize_folder_name(name), &id[..8]);
let dir = state.directories.servers_dir().join(&dir_name);
let dir = safe_relative_join(&state.directories.servers_dir(), &dir_name)?;
io::create_dir_all(&dir).await?;
let manifest = ServerManifest {
+1 -1
View File
@@ -118,7 +118,7 @@ pub(super) async fn server_path(server_id: &str) -> Result<PathBuf> {
.as_error());
}
let state = State::get().await?;
let path = state.directories.server_dir(server_id);
let path = state.directories.server_dir(server_id)?;
if !path.is_dir() {
return Err(ErrorKind::InputError(format!(
"Unknown server: {server_id}"
+187 -31
View File
@@ -33,7 +33,7 @@ use crate::util::fetch::{
use crate::util::io::IOError;
use crate::{ErrorKind, Result};
use super::files::download_to_dir;
use super::files::{download_to_dir, safe_relative_join};
use super::manifest::{
InstallState, ModpackInfo, read_manifest, server_path, write_manifest,
};
@@ -92,10 +92,52 @@ struct MrpackFile {
env: Option<MrpackEnv>,
#[serde(default)]
downloads: Vec<String>,
#[serde(default)]
#[serde(default, rename = "fileSize")]
file_size: Option<u64>,
}
impl MrpackFile {
fn integrity(&self) -> Result<Integrity> {
let sha1 = self.hashes.get("sha1").cloned();
let sha512 = self.hashes.get("sha512").cloned();
if sha1.is_none() && sha512.is_none() {
return Err(ErrorKind::InputError(format!(
"Modpack file {} is missing a SHA-1 or SHA-512 checksum",
self.path
))
.as_error());
}
for (hash, length) in [(&sha1, 40), (&sha512, 128)] {
if let Some(hash) = hash
&& (hash.len() != length
|| !hash.bytes().all(|byte| byte.is_ascii_hexdigit()))
{
return Err(ErrorKind::InputError(format!(
"Modpack file {} has an invalid checksum",
self.path
))
.as_error());
}
}
Ok(Integrity {
sha1,
sha512,
size: self.file_size,
..Integrity::default()
})
}
}
/// Validate the entire index before starting concurrent downloads, including
/// entries excluded from server installation but fetched for metadata.
fn validate_index(index: &MrpackIndex, dir: &Path) -> Result<()> {
for file in &index.files {
safe_relative_join(dir, &file.path)?;
file.integrity()?;
}
Ok(())
}
#[derive(Deserialize, Clone)]
struct MrpackEnv {
#[serde(default)]
@@ -272,7 +314,7 @@ async fn run_modpack_install(
&state,
mrpack_url,
&dir.join(MRPACK_FILENAME),
mrpack_sha1.map(str::to_string),
mrpack_sha1.map(Integrity::sha1).unwrap_or_default(),
ResourceClass::Modpack,
AggregateProgress::new(0),
)
@@ -282,6 +324,7 @@ async fn run_modpack_install(
let manifest_entry = find_manifest_entry(&archive_path).await?;
let base_folder = base_folder(&manifest_entry);
let index = parse_index(&archive_path, &manifest_entry).await?;
validate_index(&index, dir)?;
let (installable_files, excluded_files): (
Vec<&MrpackFile>,
@@ -310,19 +353,19 @@ async fn run_modpack_install(
)
.await?;
let files: Vec<(String, String, Option<String>, u64)> = installable_files
let files: Vec<(String, String, Integrity, u64)> = installable_files
.iter()
.filter_map(|file| {
file.downloads.first().map(|url| {
(
Ok((
file.path.clone(),
url.clone(),
file.hashes.get("sha1").cloned(),
file.integrity()?,
file.file_size.unwrap_or(0),
)
))
})
})
.collect();
.collect::<Result<_>>()?;
let progress = AggregateProgress::new(total_bytes);
let state_ref = state.clone();
@@ -336,14 +379,14 @@ async fn run_modpack_install(
0.0,
num_files,
None,
move |(path, url, sha1, size)| {
move |(path, url, integrity, size)| {
let server_id = server_id_ref.clone();
let dir = dir_ref.clone();
let state = state_ref.clone();
let progress = progress.clone();
async move {
log(&server_id, &format!("Downloading {path}")).await?;
let destination = dir.join(&path);
let destination = safe_relative_join(&dir, &path)?;
if let Some(parent) = destination.parent() {
crate::util::io::create_dir_all(parent).await?;
}
@@ -352,7 +395,7 @@ async fn run_modpack_install(
&state,
&url,
&destination,
sha1,
integrity,
ResourceClass::Modpack,
progress.clone(),
)
@@ -446,7 +489,7 @@ async fn download_icon(dir: &Path, icon_url: &str) -> Result<PathBuf> {
)
})
.unwrap_or_else(|| "png".to_string());
let destination = dir.join(format!("icon.{extension}"));
let destination = safe_relative_join(dir, &format!("icon.{extension}"))?;
let client = reqwest::Client::builder()
.user_agent(crate::launcher_user_agent())
@@ -476,15 +519,13 @@ async fn download_with_engine(
state: &State,
url: &str,
destination: &Path,
sha1: Option<String>,
integrity: Integrity,
resource: ResourceClass,
progress: AggregateProgress,
) -> Result<()> {
let mut request = DownloadRequest::new(url, resource);
if let Some(sha1) = &sha1 {
request = request.with_integrity(Integrity::sha1(sha1.clone()));
}
request = request.with_segmented_download(true);
let request = DownloadRequest::new(url, resource)
.with_integrity(integrity)
.with_segmented_download(true);
let server_id = server_id.to_string();
let progress = progress.clone();
@@ -579,7 +620,7 @@ async fn fetch_excluded_mod_ids(
state: &State,
excluded_files: &[&MrpackFile],
) -> Result<HashSet<String>> {
let candidates: Vec<(String, String, Option<String>)> = excluded_files
let candidates: Vec<(String, String, Integrity)> = excluded_files
.iter()
.filter_map(|file| {
let path = file.path.replace('\\', "/");
@@ -589,14 +630,14 @@ async fn fetch_excluded_mod_ids(
return None;
}
file.downloads.first().map(|url| {
(
Ok((
path.rsplit('/').next().unwrap_or(&path).to_string(),
url.clone(),
file.hashes.get("sha1").cloned(),
)
file.integrity()?,
))
})
})
.collect();
.collect::<Result<_>>()?;
if candidates.is_empty() {
return Ok(HashSet::new());
}
@@ -611,9 +652,10 @@ async fn fetch_excluded_mod_ids(
ErrorKind::FSError(format!("Failed to create temporary directory: {e}"))
})?;
let mut unavailable = HashSet::new();
for (filename, url, sha1) in candidates {
let destination = temp_dir.path().join(&filename);
match download_metadata_jar(state, &url, &destination, sha1).await {
for (filename, url, integrity) in candidates {
let destination = safe_relative_join(temp_dir.path(), &filename)?;
match download_metadata_jar(state, &url, &destination, integrity).await
{
Ok(()) => match read_mod_metadata(&destination) {
Some(metadata) => {
unavailable
@@ -647,12 +689,10 @@ async fn download_metadata_jar(
state: &State,
url: &str,
destination: &Path,
sha1: Option<String>,
integrity: Integrity,
) -> Result<()> {
let mut request = DownloadRequest::new(url, ResourceClass::Modpack);
if let Some(sha1) = &sha1 {
request = request.with_integrity(Integrity::sha1(sha1.clone()));
}
let request = DownloadRequest::new(url, ResourceClass::Modpack)
.with_integrity(integrity);
download_to_path(
request,
destination,
@@ -908,6 +948,122 @@ mod tests {
use super::*;
use std::io::Write;
fn index_file(path: &str) -> serde_json::Value {
serde_json::json!({
"path": path,
"hashes": { "sha1": "0".repeat(40) },
"downloads": ["https://example.com/mod.jar"],
"fileSize": 10
})
}
#[tokio::test]
async fn malicious_index_paths_fail_before_materializing_files() {
let temp = tempfile::tempdir().unwrap();
let server = temp.path().join("server");
std::fs::create_dir(&server).unwrap();
let outside = temp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
let archive = temp.path().join("pack.mrpack");
for path in [
"../outside.txt",
"mods/../../outside.txt",
r"..\outside.txt",
r"mods/..\../outside.txt",
"/tmp/outside.txt",
"C:/outside.txt",
r"C:\outside.txt",
"C:outside.txt",
r"\\server\share\outside.txt",
r"\outside.txt",
r"\\?\C:\outside.txt",
"mods//mod.jar",
"mods/./mod.jar",
"mods/.. /outside.txt",
"mods/mod.jar:payload",
"NUL/outside.txt",
"mods/CON.jar",
"",
] {
for environment in ["required", "unsupported"] {
let mut malicious = index_file(path);
// Filtered entries must also fail before any downloads.
malicious["env"] = serde_json::json!({"server": environment});
let contents = serde_json::json!({
"files": [index_file("mods/valid.jar"), malicious]
})
.to_string();
create_jar(&archive, &[(MRPACK_MANIFEST_ENTRY, &contents)]);
let index =
parse_index(&archive, MRPACK_MANIFEST_ENTRY).await.unwrap();
assert!(validate_index(&index, &server).is_err(), "{path:?}");
assert_eq!(
std::fs::read_to_string(&outside).unwrap(),
"unchanged"
);
assert_eq!(std::fs::read_dir(&server).unwrap().count(), 0);
}
}
}
#[test]
fn index_requires_valid_supported_checksums() {
for hashes in [
serde_json::json!({}),
serde_json::json!({"md5": "0".repeat(32)}),
serde_json::json!({"sha1": ""}),
serde_json::json!({"sha1": "0".repeat(39)}),
serde_json::json!({"sha512": "g".repeat(128)}),
serde_json::json!({"sha1": "invalid", "sha512": "0".repeat(128)}),
] {
let mut file = index_file("mods/mod.jar");
file["hashes"] = hashes;
let index: MrpackIndex =
serde_json::from_value(serde_json::json!({"files": [file]}))
.unwrap();
assert!(validate_index(&index, Path::new("server")).is_err());
}
let mut file = index_file("mods/mod.jar");
file.as_object_mut().unwrap().remove("hashes");
let index: MrpackIndex =
serde_json::from_value(serde_json::json!({"files": [file]}))
.unwrap();
assert!(validate_index(&index, Path::new("server")).is_err());
}
#[tokio::test]
async fn index_checksums_verify_file_contents() {
use crate::util::fetch::verify_file;
use sha2::{Digest, Sha512};
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("mod.jar");
let contents = b"verified mod";
std::fs::write(&target, contents).unwrap();
for hashes in [
serde_json::json!({"sha1": sha1_smol::Sha1::from(contents).digest().to_string()}),
serde_json::json!({"sha512": format!("{:x}", Sha512::digest(contents))}),
serde_json::json!({
"sha1": sha1_smol::Sha1::from(contents).digest().to_string(),
"sha512": format!("{:x}", Sha512::digest(contents))
}),
] {
let mut file = index_file("mods/mod.jar");
file["hashes"] = hashes;
file["fileSize"] = serde_json::json!(contents.len());
let index: MrpackIndex =
serde_json::from_value(serde_json::json!({"files": [file]}))
.unwrap();
validate_index(&index, temp.path()).unwrap();
let integrity = index.files[0].integrity().unwrap();
assert_eq!(integrity.size, Some(contents.len() as u64));
verify_file(&target, &integrity).await.unwrap();
std::fs::write(&target, b"tampered mod").unwrap();
assert!(verify_file(&target, &integrity).await.is_err());
std::fs::write(&target, contents).unwrap();
}
}
#[test]
fn server_only_files_are_installable() {
let installable = MrpackFile {
+79 -2
View File
@@ -232,8 +232,24 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
&& let Some(mut bar) = state.loading_bars.get_mut(&key.0)
{
match &mut bar.bar_type {
LoadingBarType::HostedPackSync { error: failure, .. }
| LoadingBarType::HostedModDownload { error: failure, .. } => {
LoadingBarType::HostedPackSync {
error: failure,
state,
can_pause,
can_cancel,
..
} => {
use crate::api::pack::hosted::HostedTaskState;
*state = if *state == HostedTaskState::Canceling {
HostedTaskState::Canceled
} else {
HostedTaskState::Failed
};
*can_pause = false;
*can_cancel = false;
*failure = Some(error.to_owned());
}
LoadingBarType::HostedModDownload { error: failure, .. } => {
*failure = Some(error.to_owned())
}
_ => {}
@@ -241,6 +257,67 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
}
}
/// Update the authoritative snapshot and notify observers even while all
/// transfer futures are paused and no byte-progress event can be produced.
pub fn set_hosted_loading_state(
instance_id: &str,
next: crate::api::pack::hosted::HostedTaskState,
) {
use crate::api::pack::hosted::HostedTaskState;
let Ok(event_state) = crate::EventState::get() else {
return;
};
for mut bar in event_state.loading_bars.iter_mut() {
let LoadingBarType::HostedPackSync {
instance_id: id,
state,
can_pause,
can_cancel,
..
} = &mut bar.bar_type
else {
continue;
};
if id != instance_id {
continue;
}
*state = next;
let controllable =
matches!(next, HostedTaskState::Running | HostedTaskState::Paused);
*can_pause = controllable;
*can_cancel = controllable;
match next {
HostedTaskState::Paused => {
bar.message = "已暂停,点击继续以恢复下载".into()
}
HostedTaskState::Canceling => {
bar.message = "正在停止整合包下载".into()
}
HostedTaskState::Running => {
bar.message = "正在继续整合包下载".into()
}
_ => {}
}
#[cfg(feature = "tauri")]
{
let _ = event_state.app.emit(
"loading",
LoadingPayload {
fraction: Some(if bar.total > 0.0 {
bar.current / bar.total
} else {
0.0
}),
message: bar.message.clone(),
event: bar.bar_type.clone(),
loader_uuid: bar.loading_bar_uuid,
total: Some(bar.total),
},
);
}
}
}
// emit_warning(message)
pub async fn emit_warning(message: &str) -> crate::Result<()> {
#[cfg(feature = "tauri")]
+6
View File
@@ -142,6 +142,12 @@ pub enum LoadingBarType {
instance_id: String,
instance_name: String,
error: Option<String>,
#[serde(default)]
state: crate::api::pack::hosted::HostedTaskState,
#[serde(default)]
can_pause: bool,
#[serde(default)]
can_cancel: bool,
},
HostedModDownload {
instance_id: String,
+24 -2
View File
@@ -171,8 +171,8 @@ impl DirectoryInfo {
/// Gets the directory of a managed dedicated server by id
#[inline]
pub fn server_dir(&self, server_id: &str) -> PathBuf {
self.servers_dir().join(server_id)
pub fn server_dir(&self, server_id: &str) -> crate::Result<PathBuf> {
crate::util::io::safe_relative_join(&self.servers_dir(), server_id)
}
#[inline]
@@ -708,6 +708,28 @@ mod resolve_game_dir_tests {
}
}
#[test]
fn server_directory_rejects_absolute_and_windows_alias_paths() {
let dirs = dirs();
assert_eq!(
dirs.server_dir("server-1234").unwrap(),
dirs.servers_dir().join("server-1234")
);
for id in [
"../outside",
"C:outside",
"C:/outside",
r"\outside",
r"\\server\share",
".",
"NUL",
"server:stream",
"server.",
] {
assert!(dirs.server_dir(id).is_err(), "{id:?}");
}
}
#[test]
fn override_used_when_set_and_absolute() {
let dirs = dirs();
+134 -13
View File
@@ -689,6 +689,46 @@ fn bounded_diagnostic_text(value: impl AsRef<str>, max_chars: usize) -> String {
value.as_ref().chars().take(max_chars).collect()
}
fn sanitize_download_error_text(value: &str) -> String {
static URLS: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)\b(?:https?|socks4a?|socks5h?)://[^\s]+")
.expect("download diagnostic URL pattern should be valid")
});
let sanitized = URLS.replace_all(value, |matched: &regex::Captures<'_>| {
let url = &matched[0];
// A malformed URL must not fall back to logging its userinfo. The
// normal helper also strips signed query strings and fragments.
if Url::parse(url).is_ok() {
sanitize_url_for_log(url)
} else {
"[redacted URL]".to_string()
}
});
bounded_diagnostic_text(sanitized, 256)
}
fn download_transport_error_detail(source: &reqwest::Error) -> String {
// reqwest's Display stops before the useful DNS/TLS/socket cause and can
// include a signed request URL. Walk only its underlying causes instead.
let mut details = Vec::new();
let mut cause = source.source();
while let Some(error) = cause {
let detail = sanitize_download_error_text(&error.to_string());
if details.last() != Some(&detail) {
details.push(detail);
}
if details.len() == 8 {
break;
}
cause = error.source();
}
if details.is_empty() {
sanitize_download_error_text(&source.to_string())
} else {
bounded_diagnostic_text(details.join(": "), 256)
}
}
fn download_error_category(error: &crate::Error) -> &'static str {
match error.raw.as_ref() {
ErrorKind::FetchError(source) => {
@@ -750,7 +790,16 @@ fn download_error_category(error: &crate::Error) -> &'static str {
fn download_error_detail(error: &crate::Error) -> String {
match error.raw.as_ref() {
ErrorKind::FetchError(source) => source.status().map_or_else(
|| format!("{} failure", download_error_category(error)),
|| {
bounded_diagnostic_text(
format!(
"{} failure: {}",
download_error_category(error),
download_transport_error_detail(source)
),
256,
)
},
|status| format!("HTTP {}", status.as_u16()),
),
ErrorKind::LabrinthError(error) => error.status.map_or_else(
@@ -929,8 +978,10 @@ fn add_starlight_direct_recovery_route(routes: &mut Vec<DownloadRoute>) {
.filter(|route| {
route.proxy == ProxyPolicy::System
&& Url::parse(&route.url).ok().is_some_and(|url| {
url.host_str() == Some("skin.starlight.cool")
&& url.path().starts_with("/starlight/mod/packs/files/")
matches!(
url.host_str(),
Some("skin.starlight.cool" | "download.starlight.cool")
) && url.path().starts_with("/starlight/mod/packs/files/")
})
})
.cloned()
@@ -6308,7 +6359,9 @@ async fn download_to_path_inner(
source = route.source.as_str(),
attempt = attempts,
max_attempts = file_attempt_budget,
error = %error,
proxy = ?route.proxy,
category = download_error_category(&error),
error = %download_error_detail(&error),
"File download request failed; trying the next source or retry"
);
last_error = Some(error);
@@ -8857,18 +8910,86 @@ mod tests {
#[test]
fn starlight_hosted_files_have_a_direct_recovery_route() {
let mut routes = vec![official_route(
for url in [
"https://skin.starlight.cool/starlight/mod/packs/files/release/hash",
ResourceClass::Modpack,
)];
"https://download.starlight.cool:20443/starlight/mod/packs/files/4/hash",
] {
let mut routes = vec![official_route(url, ResourceClass::Modpack)];
add_starlight_direct_recovery_route(&mut routes);
add_starlight_direct_recovery_route(&mut routes);
add_starlight_direct_recovery_route(&mut routes);
assert_eq!(routes.len(), 2, "{url}");
assert_eq!(routes[0].proxy, ProxyPolicy::System);
assert_eq!(routes[1].proxy, ProxyPolicy::Direct);
assert_eq!(routes[0].url, routes[1].url);
assert!(routes[1].allow_sensitive_headers);
}
}
assert_eq!(routes.len(), 2);
assert_eq!(routes[0].proxy, ProxyPolicy::System);
assert_eq!(routes[1].proxy, ProxyPolicy::Direct);
assert_eq!(routes[0].url, routes[1].url);
assert!(routes[1].allow_sensitive_headers);
#[test]
fn starlight_direct_recovery_excludes_other_paths_and_lookalike_hosts() {
for url in [
"https://download.starlight.cool:20443/starlight/mod/packs",
"https://download.starlight.cool.evil.example/starlight/mod/packs/files/4/hash",
"https://evil.example/starlight/mod/packs/files/4/hash",
"https://skin.starlight.cool/yggdrasil",
] {
let mut routes = vec![official_route(url, ResourceClass::Modpack)];
add_starlight_direct_recovery_route(&mut routes);
assert_eq!(routes.len(), 1, "{url}");
}
}
#[tokio::test]
async fn download_transport_diagnostic_preserves_causes_without_url_secrets()
{
let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let url = format!(
"http://username:password@{address}/file?token=secret#fragment"
);
let error: crate::Error = reqwest::Client::builder()
.no_proxy()
.build()
.unwrap()
.get(url)
.send()
.await
.unwrap_err()
.into();
let detail = download_error_detail(&error);
assert_eq!(download_error_category(&error), "connect");
assert!(detail.contains("connect"), "{detail}");
assert!(
detail.contains("tcp") || detail.contains("os error"),
"{detail}"
);
for secret in ["username", "password", "token", "secret", "fragment"] {
assert!(!detail.contains(secret), "{detail}");
}
}
#[test]
fn download_transport_diagnostic_scrubs_embedded_urls() {
let detail = sanitize_download_error_text(
"request https://username:password@example.com/file?token=secret#fragment failed: connecting to http://proxyuser:proxypass@proxy.example:8080/?key=proxysecret: connection refused",
);
assert!(detail.contains("https://example.com/file"), "{detail}");
assert!(detail.contains("http://proxy.example:8080/"), "{detail}");
assert!(detail.contains("connection refused"), "{detail}");
for secret in [
"username",
"password",
"token",
"secret",
"fragment",
"proxyuser",
"proxypass",
] {
assert!(!detail.contains(secret), "{detail}");
}
}
#[test]
+62 -1
View File
@@ -60,6 +60,34 @@ pub(crate) fn io_error_with_lock_info(
io_error_with_lock_info_for_paths(source, path, &[path])
}
/// Joins an untrusted Unix-style relative file path, rejecting traversal and
/// Windows aliases on every platform. This is a lexical check, not a symlink
/// sandbox.
pub(crate) fn safe_relative_join(
root: &Path,
relative: &str,
) -> crate::Result<PathBuf> {
path_util::SafeRelativeUtf8UnixPathBuf::try_from(relative.to_string())
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Invalid file path {relative}: {error}"
))
})?;
if relative.split('/').any(|segment| {
segment.is_empty()
|| segment == "."
|| segment.ends_with(['.', ' '])
|| segment.contains(':')
|| segment.chars().any(char::is_control)
}) {
return Err(crate::ErrorKind::InputError(format!(
"Invalid file path: {relative}"
))
.into());
}
Ok(root.join(relative))
}
/// Join a user-provided relative path onto `root`, rejecting traversal.
pub(crate) fn join_within_root(
root: &Path,
@@ -95,7 +123,7 @@ pub(crate) fn join_within_root(
)
.into());
}
Ok(root.join(safe))
safe_relative_join(root, &safe.to_string_lossy().replace('\\', "/"))
}
pub(crate) fn io_error_with_lock_info_for_paths(
@@ -895,6 +923,39 @@ mod windows_sharing_violation_tests {
}
}
#[cfg(test)]
mod path_join_tests {
use super::*;
#[test]
fn join_within_root_uses_portable_path_validation() {
let root = Path::new("root");
assert_eq!(
join_within_root(root, r"config\example.toml").unwrap(),
root.join("config/example.toml")
);
assert_eq!(
join_within_root(root, "./config/./example.toml").unwrap(),
root.join("config/example.toml")
);
for relative in [
"../outside",
r"..\outside",
"/outside",
"C:/outside",
"C:outside",
r"\\server\share",
"config/.. /outside",
"config/file:stream",
"config/NUL",
"config/file.",
"config/file\0",
] {
assert!(join_within_root(root, relative).is_err(), "{relative:?}");
}
}
}
#[cfg(test)]
mod copy_dir_tests {
use super::*;
@@ -49,7 +49,11 @@ try {
tag,
outputPath,
],
{ cwd: root, encoding: 'utf8' },
{
cwd: root,
encoding: 'utf8',
env: { ...process.env, GITHUB_REPOSITORY: 'Mystic-Stars/Axolotl' },
},
)
assert.equal(create.status, 0, create.stderr)
+17
View File
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises'
import { parse, TYPE } from '@formatjs/icu-messageformat-parser'
import { collectSkinSiteMessages } from './skin-site-messages.mjs'
const localePairs = [
[
@@ -10,6 +11,22 @@ const localePairs = [
]
const failures = []
const skinSiteCatalogs = Object.fromEntries(
await Promise.all(
['en-US', 'zh-CN'].map(async (locale) => [
locale,
JSON.parse(await readFile(`apps/app-frontend/src/locales/${locale}/index.json`, 'utf8')),
]),
),
)
for (const { id, file } of collectSkinSiteMessages()) {
for (const locale of ['en-US', 'zh-CN']) {
const catalogPath = `apps/app-frontend/src/locales/${locale}/index.json`
const catalog = skinSiteCatalogs[locale]
if (!catalog[id]?.message)
failures.push(`${catalogPath}: missing source message ${id} (${file})`)
}
}
const allowedUntranslatedMessages = new Set([
'Chaos Cubed',
'MINECON Earth 2017',
+229
View File
@@ -0,0 +1,229 @@
import { randomUUID } from 'node:crypto'
import { open, stat } from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
export async function publishSkinSite({
archivePath,
tag,
token,
baseUrl = 'https://skin.starlight.cool',
fetchImpl = fetch,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
now = Date.now,
timeoutMs = 30 * 60 * 1000,
pollIntervalMs = 2000,
log = console.log,
}) {
if (!token) throw new Error('SKIN_SITE_CI_UPLOAD_TOKEN is required to publish to the skin site')
if (!archivePath || !/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag ?? '')) {
throw new Error('Expected a complete update ZIP and a semantic version tag')
}
const version = tag.replace(/^v/, '')
const url = new URL(baseUrl)
if (url.protocol !== 'https:' || url.username || url.password) {
throw new Error('SKIN_SITE_URL must be an HTTPS URL without credentials')
}
const { size } = await stat(archivePath)
if (!archivePath.toLowerCase().endsWith('.zip') || size < 1 || size > 4 * 1024 ** 3) {
throw new Error('Expected a nonempty complete update ZIP no larger than 4 GiB')
}
const deadline = now() + timeoutMs
function remainingTime() {
const remaining = deadline - now()
if (remaining <= 0) throw new Error('Skin site publication timed out')
return remaining
}
async function request(route, { method = 'GET', body, chunk = false } = {}) {
const attempts = chunk ? 3 : 1
for (let attempt = 0; attempt < attempts; attempt++) {
const requestTimeout = Math.min(120_000, remainingTime())
let response
try {
response = await fetchImpl(`${baseUrl.replace(/\/$/, '')}/starlight/${route}`, {
method,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
...(body && !chunk ? { 'Content-Type': 'application/json' } : {}),
},
body: body && !chunk ? JSON.stringify(body) : body,
signal: AbortSignal.timeout(requestTimeout),
redirect: 'error',
})
} catch {
if (attempt + 1 === attempts)
throw new Error(`Skin site ${route}: network request failed or timed out`)
await sleep(500 * (attempt + 1))
continue
}
if (!response.ok) {
const details = await response.json().catch(() => null)
if ((response.status === 429 || response.status >= 500) && attempt + 1 < attempts) {
await sleep(500 * (attempt + 1))
continue
}
throw new Error(
`Skin site ${route}: HTTP ${response.status}${typeof details?.errorMessage === 'string' ? ` - ${details.errorMessage.slice(0, 1000)}` : ''}`,
)
}
let result
try {
result = await response.json()
} catch {
throw new Error(`Skin site ${route}: invalid JSON response`)
}
if (result?.success === false || result?.error || !result?.payload) {
throw new Error(
`Skin site ${route}: ${result?.errorMessage || 'request was rejected or payload is missing'}`,
)
}
return result.payload
}
}
let status = await request('launcher/admin/uploads', {
method: 'POST',
body: {
uploadId: randomUUID(),
filename: path.basename(archivePath),
size,
forceUpdate: false,
},
})
if (typeof status.id !== 'string' || !status.id)
throw new Error('Skin site returned an invalid upload ID')
const uploadRoute = `launcher/admin/uploads/${encodeURIComponent(status.id)}`
log(`Skin site upload ${status.id}: ${size} bytes`)
const file = await open(archivePath, 'r')
try {
for (;;) {
remainingTime()
if (status.state === 'ready') {
const release = status.release
if (!Number.isSafeInteger(release?.id) || release.id <= 0 || release.version !== version) {
throw new Error(
`Skin site returned an invalid release or a version other than ${version}`,
)
}
const activated = await request(`launcher/admin/${release.id}/activate`, { method: 'POST' })
if (
activated.id !== release.id ||
activated.version !== version ||
activated.active !== true
) {
throw new Error(`Skin site did not confirm activation of ${version}`)
}
log(`Skin site release ${version} published (id ${release.id})`)
return activated
}
if (status.state === 'failed' || status.state === 'cancelled') {
throw new Error(
`Skin site archive processing ${status.state}: ${status.errorMessage || 'no error details'}`,
)
}
if (status.state === 'processing') {
await sleep(Math.min(pollIntervalMs, remainingTime()))
status = await request(uploadRoute)
continue
}
if (
status.state !== 'uploading' ||
status.size !== size ||
!Number.isSafeInteger(status.chunkSize) ||
status.chunkSize < 1 ||
status.chunkSize > 64 * 1024 ** 2 ||
!Number.isSafeInteger(status.received) ||
status.received < 0 ||
status.received > size ||
!Array.isArray(status.receivedChunks)
) {
throw new Error('Skin site returned invalid upload status or chunk size')
}
const count = Math.ceil(size / status.chunkSize)
if (
status.receivedChunks.some(
(index) => !Number.isSafeInteger(index) || index < 0 || index >= count,
)
) {
throw new Error('Skin site returned invalid received chunk indices')
}
const received = new Set(status.receivedChunks)
const missing = Array.from({ length: count }, (_, index) => index).filter(
(index) => !received.has(index),
)
if (!missing.length) {
if (status.received !== size)
throw new Error('Skin site received byte count does not match the archive')
status = await request(`${uploadRoute}/complete`, { method: 'POST' })
continue
}
const chunkSize = status.chunkSize
const concurrency = Math.min(
Number.isSafeInteger(status.uploadConcurrency) && status.uploadConcurrency > 0
? status.uploadConcurrency
: 1,
6,
missing.length,
)
let cursor = 0
let uploadError
async function worker() {
try {
while (!uploadError && cursor < missing.length) {
remainingTime()
const index = missing[cursor++]
const start = index * chunkSize
const bytes = Buffer.alloc(Math.min(chunkSize, size - start))
let offset = 0
while (offset < bytes.length) {
const { bytesRead } = await file.read(
bytes,
offset,
bytes.length - offset,
start + offset,
)
if (!bytesRead) throw new Error('Update archive changed or ended during upload')
offset += bytesRead
}
const form = new FormData()
form.append('file', new Blob([bytes]), 'chunk.bin')
await request(`${uploadRoute}/chunks/${index}`, {
method: 'POST',
body: form,
chunk: true,
})
}
} catch (error) {
uploadError ??= error
}
}
await Promise.all(Array.from({ length: concurrency }, worker))
if (uploadError) throw uploadError
status = await request(uploadRoute)
log(`Skin site uploaded ${status.received}/${size} bytes`)
}
} finally {
await file.close()
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
await publishSkinSite({
archivePath: process.argv[2],
tag: process.argv[3],
token: process.env.SKIN_SITE_TOKEN,
baseUrl: process.env.SKIN_SITE_URL || 'https://skin.starlight.cool',
})
} catch (error) {
const message = String(error.message).replaceAll(
process.env.SKIN_SITE_TOKEN || '\0',
'[redacted]',
)
console.error(
`::error::${message.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')}`,
)
process.exitCode = 1
}
}
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { spawnSync } from 'node:child_process'
import { publishSkinSite } from './publish-skin-site.mjs'
async function fixture(t) {
const directory = await mkdtemp(path.join(tmpdir(), 'skin-site-publish-'))
t.after(() => rm(directory, { recursive: true, force: true }))
const archivePath = path.join(directory, 'Starlight_Launcher_1.0.2-beta10_skin-site-update.zip')
const bytes = Buffer.from('a small archive split into several chunks')
await writeFile(archivePath, bytes)
return { archivePath, bytes }
}
function apiFixture(bytes, options = {}) {
const chunks = new Map()
const events = []
const release = { id: 17, version: '1.0.2-beta10', active: false }
let state = 'uploading'
let failedOnce = false
if (options.resume) chunks.set(0, bytes.subarray(0, 8))
const status = () => ({
id: 'session-1',
chunkSize: 8,
size: bytes.length,
received: [...chunks.values()].reduce((total, part) => total + part.length, 0),
receivedChunks: [...chunks.keys()],
uploadConcurrency: 3,
state,
...(state === 'ready' ? { release } : {}),
...(state === 'failed' ? { errorMessage: 'Invalid archive manifest' } : {}),
})
// The frontend derives success from HTTP status; the wire envelope only needs payload.
const reply = (payload) => Response.json({ payload })
return {
chunks,
events,
fetch: async (url, init) => {
assert.equal(init.headers.Authorization, 'Bearer test-token')
const route = new URL(url).pathname.replace('/starlight/', '')
events.push(route)
if (route === 'launcher/admin/uploads') {
assert.equal(JSON.parse(init.body).size, bytes.length)
assert.equal(JSON.parse(init.body).forceUpdate, false)
return reply({ ...status(), ...(options.invalidChunkSize ? { chunkSize: 0 } : {}) })
}
if (route.includes('/chunks/')) {
if (options.rejectChunks) return new Response('too large', { status: 413 })
if (options.retryChunk && !failedOnce) {
failedOnce = true
return new Response('temporary failure', { status: 503 })
}
const index = Number(route.split('/').at(-1))
const file = init.body.get('file')
assert.equal(file.name, 'chunk.bin')
chunks.set(index, Buffer.from(await file.arrayBuffer()))
return reply(status())
}
if (route.endsWith('/complete')) {
assert.deepEqual(
Buffer.concat([...chunks].sort(([a], [b]) => a - b).map(([, part]) => part)),
bytes,
)
state = 'processing'
return reply(status())
}
if (route.endsWith('/activate')) {
assert.equal(state, 'ready')
return options.activationFailure
? Response.json({ success: false, errorMessage: 'Activation denied' })
: reply({ ...release, active: true })
}
if (route === 'launcher/admin/uploads/session-1') {
if (state === 'processing' && !options.stuck)
state = options.processingFailure ? 'failed' : 'ready'
return reply({
...status(),
...(options.wrongVersion && state === 'ready'
? { release: { ...release, version: '1.0.2-beta9' } }
: {}),
})
}
throw new Error(`Unexpected route ${route}`)
},
}
}
function settings(archivePath, api) {
return {
archivePath,
tag: 'v1.0.2-beta10',
token: 'test-token',
baseUrl: 'https://skin.example',
fetchImpl: api.fetch,
pollIntervalMs: 1,
sleep: async () => {},
log: () => {},
}
}
test('uploads exact bounded chunks, retries temporary errors, then completes and activates', async (t) => {
const { archivePath, bytes } = await fixture(t)
const api = apiFixture(bytes, { retryChunk: true })
const result = await publishSkinSite(settings(archivePath, api))
assert.equal(result.active, true)
assert.equal(api.chunks.size, Math.ceil(bytes.length / 8))
assert.ok([...api.chunks.values()].every((part) => part.length <= 8))
assert.equal(api.events.at(-1), 'launcher/admin/17/activate')
})
for (const [name, options, error] of [
['HTTP upload rejection', { rejectChunks: true }, /413/],
['archive processing failure', { processingFailure: true }, /Invalid archive manifest/],
['application-level activation failure', { activationFailure: true }, /Activation denied/],
['invalid server chunk size', { invalidChunkSize: true }, /invalid upload status/],
['mismatched release version', { wrongVersion: true }, /version other than/],
]) {
test(`fails explicitly on ${name}`, async (t) => {
const { archivePath, bytes } = await fixture(t)
const api = apiFixture(bytes, options)
await assert.rejects(publishSkinSite(settings(archivePath, api)), error)
if (!options.activationFailure)
assert.ok(!api.events.some((event) => event.endsWith('/activate')))
})
}
test('fails instead of hanging when processing never finishes', async (t) => {
const { archivePath, bytes } = await fixture(t)
const api = apiFixture(bytes, { stuck: true })
let now = 0
await assert.rejects(
publishSkinSite({
...settings(archivePath, api),
timeoutMs: 10,
now: () => now,
sleep: async () => {
now += 5
},
}),
/timed out/i,
)
})
test('missing credentials fail before any network request', async (t) => {
const { archivePath } = await fixture(t)
await assert.rejects(
publishSkinSite({
...settings(archivePath, { fetch: () => assert.fail('Unexpected network request') }),
token: '',
}),
/SKIN_SITE_CI_UPLOAD_TOKEN/,
)
})
test('resumes server-acknowledged chunks without uploading them again', async (t) => {
const { archivePath, bytes } = await fixture(t)
const api = apiFixture(bytes, { resume: true })
await publishSkinSite(settings(archivePath, api))
assert.ok(!api.events.some((event) => event.endsWith('/chunks/0')))
})
test('CLI emits a GitHub error and exits nonzero when credentials are missing', () => {
const result = spawnSync(
process.execPath,
['scripts/axolotl/publish-skin-site.mjs', 'missing.zip', 'v1.0.2-beta10'],
{ encoding: 'utf8', env: { ...process.env, SKIN_SITE_TOKEN: '' } },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /::error::.*SKIN_SITE_CI_UPLOAD_TOKEN/)
})
test('reports structured server errors when creating the upload session fails', async (t) => {
const { archivePath } = await fixture(t)
await assert.rejects(
publishSkinSite(
settings(archivePath, {
fetch: async () =>
Response.json({ errorMessage: 'Upload storage unavailable' }, { status: 500 }),
}),
),
/HTTP 500 - Upload storage unavailable/,
)
})
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
import { collectSkinSiteMessages } from './skin-site-messages.mjs'
test('all skin-site messages used in source exist in both release language catalogs', () => {
const messages = collectSkinSiteMessages()
assert.ok(messages.length > 100, 'The check must scan the actual UI')
for (const locale of ['en-US', 'zh-CN']) {
const catalog = JSON.parse(
fs.readFileSync(`apps/app-frontend/src/locales/${locale}/index.json`, 'utf8'),
)
const missing = messages.filter(({ id }) => !catalog[id]?.message)
assert.deepEqual(
missing.map(({ id }) => id),
[],
`${locale} has uncataloged UI messages`,
)
}
})
test('Chinese login and announcement labels do not fall back to English', () => {
const catalog = JSON.parse(
fs.readFileSync('apps/app-frontend/src/locales/zh-CN/index.json', 'utf8'),
)
for (const id of [
'app.skin-site.login.title',
'app.skin-site.login.username',
'app.skin-site.login.password-hint',
'app.skin-site.home.announcements.title',
'app.skin-site.home.announcements.failed',
]) {
assert.match(catalog[id]?.message ?? '', /\p{Script=Han}/u, id)
}
})
+59
View File
@@ -0,0 +1,59 @@
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(new URL('../../apps/app-frontend/package.json', import.meta.url))
const ts = require('typescript')
const { parse } = require('vue/compiler-sfc')
/** Read the message IDs actually used by skin-site UI, including uncataloged messages. */
export function collectSkinSiteMessages(root = 'apps/app-frontend/src') {
const messages = new Map()
function walk(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) {
if (entry.name !== 'locales') walk(file)
continue
}
if (!/\.(vue|ts)$/.test(file) || file.endsWith('.test.ts')) continue
let source = fs.readFileSync(file, 'utf8')
if (file.endsWith('.vue')) {
const { descriptor } = parse(source)
source = [descriptor.script?.content, descriptor.scriptSetup?.content]
.filter(Boolean)
.join('\n')
}
const ast = ts.createSourceFile(`${file}.ts`, source, ts.ScriptTarget.Latest, true)
function visit(node) {
if (ts.isObjectLiteralExpression(node)) {
const properties = Object.fromEntries(
node.properties
.filter(ts.isPropertyAssignment)
.map((property) => [
property.name.getText(ast).replace(/^['"]|['"]$/g, ''),
property.initializer,
]),
)
if (
properties.id &&
properties.defaultMessage &&
ts.isStringLiteralLike(properties.id) &&
ts.isStringLiteralLike(properties.defaultMessage) &&
properties.id.text.startsWith('app.skin-site.')
) {
messages.set(properties.id.text, {
id: properties.id.text,
defaultMessage: properties.defaultMessage.text,
file,
})
}
}
ts.forEachChild(node, visit)
}
visit(ast)
}
}
walk(root)
return [...messages.values()]
}