Compare commits

..
20 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
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
55 changed files with 3651 additions and 582 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
+24 -11
View File
@@ -131,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'
@@ -193,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:
@@ -283,6 +290,12 @@ 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
Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "1.0.2-beta11"
version = "1.0.2-beta13"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.2-beta11"
version = "1.0.2-beta13"
dependencies = [
"ashpd",
"async_zip",
+75 -75
View File
@@ -1,77 +1,77 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.2-beta11",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@dnd-kit/vue": "^0.5.0",
"@lobehub/icons-static-svg": "1.94.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/server": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@sfirew/minecraft-motd-parser": "^1.1.6",
"@tanstack/vue-query": "5.90.7",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"deepslate": "0.26.0",
"fflate": "0.8.2",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"intl-messageformat": "^10.7.7",
"monaco-editor": "^0.56.0",
"ofetch": "^1.3.4",
"overlayscrollbars": "^2.15.1",
"pinia": "^3.0.0",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vuedraggable": "^4.1.0",
"xxhash-wasm": "1.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@nuxt/eslint-config": "^0.5.6",
"@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.19",
"eslint": "^9.9.1",
"eslint-plugin-turbo": "^2.5.4",
"postcss": "^8.4.39",
"prettier": "^3.2.5",
"sass": "^1.74.1",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.4",
"vite": "^8.0.0",
"vite-plugin-vue-devtools": "^8.2.1",
"vue-component-type-helpers": "^3.1.8",
"vue-tsc": "^2.1.6"
},
"packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json"
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.2-beta13",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2",
"@dnd-kit/vue": "^0.5.0",
"@lobehub/icons-static-svg": "1.94.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/server": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@sfirew/minecraft-motd-parser": "^1.1.6",
"@tanstack/vue-query": "5.90.7",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"deepslate": "0.26.0",
"fflate": "0.8.2",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"intl-messageformat": "^10.7.7",
"monaco-editor": "^0.56.0",
"ofetch": "^1.3.4",
"overlayscrollbars": "^2.15.1",
"pinia": "^3.0.0",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vuedraggable": "^4.1.0",
"xxhash-wasm": "1.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@nuxt/eslint-config": "^0.5.6",
"@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.19",
"eslint": "^9.9.1",
"eslint-plugin-turbo": "^2.5.4",
"postcss": "^8.4.39",
"prettier": "^3.2.5",
"sass": "^1.74.1",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.4",
"vite": "^8.0.0",
"vite-plugin-vue-devtools": "^8.2.1",
"vue-component-type-helpers": "^3.1.8",
"vue-tsc": "^2.1.6"
},
"packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json"
}
+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
}
}
@@ -32,6 +32,61 @@ 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',
@@ -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'])
})
@@ -18,7 +18,7 @@ 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 SpinnerIcon={}',
'@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':
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { NewspaperIcon, SpinnerIcon, StarIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
@@ -81,43 +81,80 @@ onUnmounted(() => {
</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>
<div v-else-if="failed" class="flex items-center gap-2 text-sm text-secondary">
<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="m-0 text-sm text-secondary">
<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">
@@ -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>
@@ -3,9 +3,19 @@ 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 }
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)',
@@ -44,7 +54,7 @@ test.beforeEach(() => {
if (url.endsWith('/user')) return ok(user)
return ok([])
}
testGlobal.skinSiteTestFetch = async (url, options) => {
testGlobal.skinSiteTestFetch = async (url, options = {}) => {
calls.push({
path: new URL(url).pathname,
token: (options.headers as Record<string, string>)?.Authorization,
@@ -206,3 +216,50 @@ test('explicit sign-out discards a late successful login', async () => {
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)
})
@@ -275,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',
@@ -294,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 皮肤站。'))
}
@@ -319,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
}
@@ -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) => {
+72 -1
View File
@@ -3,6 +3,7 @@
"app.hosted-packs.progress.resume": { "message": "Resume" },
"app.hosted-packs.progress.stop": { "message": "Stop" },
"app.downloads.status.paused": { "message": "Paused" },
"app.skin-site.captcha.loading": { "message": "Loading captcha…" },
"app.skin-site.captcha.submit": { "message": "Submit" },
"app.skin-site.captcha.subtitle": {
@@ -25,6 +26,7 @@
},
"app.skin-site.downloads.title": { "message": "Downloads" },
"app.skin-site.downloads.uploader": { "message": "by {name}" },
"app.skin-site.downloads.signed-out": { "message": "Sign in to browse downloads." },
"app.skin-site.home.announcements.empty": {
"message": "No announcements right now."
},
@@ -64,24 +66,42 @@
"app.skin-site.map.open": { "message": "Open in browser" },
"app.skin-site.map.server": { "message": "Server" },
"app.skin-site.map.title": { "message": "Server map" },
"app.skin-site.profile-view.add-player": { "message": "New player" },
"app.skin-site.profile-view.added": { "message": "Player added." },
"app.skin-site.profile-view.back": { "message": "Back to home" },
"app.skin-site.profile-view.cancel": { "message": "Cancel" },
"app.skin-site.profile-view.cape": { "message": "Cape" },
"app.skin-site.profile-view.clear-default": { "message": "Clear default" },
"app.skin-site.profile-view.confirm": { "message": "Confirm" },
"app.skin-site.profile-view.default-set": { "message": "Default player updated." },
"app.skin-site.profile-view.delete": { "message": "Delete" },
"app.skin-site.profile-view.delete-confirm": { "message": "Delete this player? This cannot be undone." },
"app.skin-site.profile-view.deleted": { "message": "Player deleted." },
"app.skin-site.profile-view.elytra": { "message": "Elytra" },
"app.skin-site.profile-view.is-default": { "message": "Default" },
"app.skin-site.profile-view.loading": { "message": "Loading players…" },
"app.skin-site.profile-view.mojang-confirm": { "message": "Toggle Mojang status for this player?" },
"app.skin-site.profile-view.mojang-toggled": { "message": "Mojang status updated." },
"app.skin-site.profile-view.no-players": { "message": "No players yet." },
"app.skin-site.profile-view.player-name-placeholder": { "message": "Player name" },
"app.skin-site.profile-view.readonly": {
"message": "Mojang accounts are managed by Mojang; textures cannot be edited here."
},
"app.skin-site.profile-view.remove": { "message": "Remove" },
"app.skin-site.profile-view.removed": { "message": "Texture removed." },
"app.skin-site.profile-view.rename": { "message": "Rename" },
"app.skin-site.profile-view.rename-confirm": { "message": "Rename this player?" },
"app.skin-site.profile-view.renamed": { "message": "Player renamed." },
"app.skin-site.profile-view.select-hint": {
"message": "Select a player to preview and edit its textures."
},
"app.skin-site.profile-view.set-default": { "message": "Set as default" },
"app.skin-site.profile-view.signed-out": {
"message": "Sign in to manage your players."
},
"app.skin-site.profile-view.skin": { "message": "Skin" },
"app.skin-site.profile-view.title": { "message": "Your players" },
"app.skin-site.profile-view.toggle-mojang": { "message": "Toggle Mojang" },
"app.skin-site.profile-view.upload": { "message": "Upload" },
"app.skin-site.profile-view.uploaded": { "message": "Texture updated." },
"app.skin-site.profile.avatar": { "message": "Avatar" },
@@ -12331,6 +12351,13 @@
"app.skin-site.admin.launcher.active": { "message": "Active" },
"app.skin-site.admin.launcher.activate": { "message": "Activate" },
"app.skin-site.admin.launcher.activated": { "message": "Release activated." },
"app.skin-site.admin.launcher.delete": { "message": "Delete release" },
"app.skin-site.admin.launcher.confirm-delete": {
"message": "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."
},
"app.skin-site.admin.launcher.deleted": {
"message": "Release and its installation files deleted."
},
"app.skin-site.admin.launcher.upload": { "message": "Upload release" },
"app.skin-site.admin.launcher.upload-hint": {
"message": "Choose a release ZIP (max 4 GiB)."
@@ -13067,5 +13094,49 @@
},
"app.skin-site.wiki.revision": { "message": "Revision #{id}" },
"app.skin-site.wiki.categories": { "message": "Categories" },
"app.skin-site.wiki.back": { "message": "Back" }
"app.skin-site.wiki.back": { "message": "Back" },
"app.skin-site.wiki.history": { "message": "History" },
"app.skin-site.wiki.read": { "message": "Read" },
"app.skin-site.wiki.history-empty": { "message": "No revisions yet." },
"app.skin-site.wiki.no-summary": { "message": "No edit summary" },
"app.skin-site.wiki.minor": { "message": "Minor" },
"app.skin-site.wiki.view-revision": { "message": "View" },
"app.skin-site.wiki.revert-to": { "message": "Revert to this" },
"app.skin-site.wiki.confirm-revert": { "message": "Confirm revert" },
"app.skin-site.wiki.revert-done": { "message": "Page reverted to the selected revision." },
"app.skin-site.wiki.revision-preview": { "message": "Preview of revision #{id}" },
"app.skin-site.wiki.close-preview": { "message": "Close preview" },
"app.skin-site.wiki.diff-title": { "message": "Line diff against the current revision" },
"app.skin-site.wiki.cancel": { "message": "Cancel" },
"app.skin-site.wiki.browser.search-placeholder": { "message": "Search wiki pages…" },
"app.skin-site.wiki.browser.tab-search": { "message": "Search" },
"app.skin-site.wiki.browser.tab-recent": { "message": "Recent" },
"app.skin-site.wiki.browser.tab-category": { "message": "Category" },
"app.skin-site.wiki.browser.category-placeholder": { "message": "Category name" },
"app.skin-site.wiki.browser.load-more": { "message": "Load more" },
"app.skin-site.wiki.browser.empty-search": { "message": "No pages match your search." },
"app.skin-site.wiki.browser.empty-list": { "message": "Nothing here yet." },
"app.skin-site.wiki.browser.hint-search": { "message": "Type a keyword to search." },
"app.skin-site.wiki.browser.hint-category": { "message": "Enter a category name to browse." },
"app.skin-site.wiki.browser.by-author": { "message": "by {author}" },
"app.skin-site.home.donation.thanks": {
"message": "Thank you to everyone who helps keep StarLight running."
},
"app.skin-site.home.donation.empty": { "message": "No donations yet." },
"app.skin-site.home.donation.list-failed": {
"message": "Could not load donations."
},
"app.skin-site.home.donation.more": { "message": "Load more" },
"app.skin-site.home.preview.players": { "message": "Players" },
"app.skin-site.home.preview.empty": { "message": "No players yet" },
"app.skin-site.home.preview.empty-hint": {
"message": "Sign in or add a Minecraft account in the sidebar to see your skin here."
},
"app.skin-site.home.preview.unavailable": {
"message": "This player has no skin to preview."
},
"app.skin-site.home.preview.loading": { "message": "Loading players…" },
"app.skin-site.home.preview.failed": {
"message": "Could not load skin-site players."
}
}
+66 -1
View File
@@ -3,6 +3,7 @@
"app.hosted-packs.progress.resume": { "message": "继续" },
"app.hosted-packs.progress.stop": { "message": "停止" },
"app.downloads.status.paused": { "message": "已暂停" },
"app.skin-site.captcha.loading": { "message": "正在加载验证码…" },
"app.skin-site.captcha.submit": { "message": "验证" },
"app.skin-site.captcha.subtitle": {
@@ -25,6 +26,7 @@
},
"app.skin-site.downloads.title": { "message": "资源下载" },
"app.skin-site.downloads.uploader": { "message": "上传者:{name}" },
"app.skin-site.downloads.signed-out": { "message": "登录后浏览资源下载。" },
"app.skin-site.home.announcements.empty": { "message": "暂无公告。" },
"app.skin-site.home.announcements.failed": { "message": "加载公告失败。" },
"app.skin-site.home.announcements.loading": { "message": "正在加载…" },
@@ -60,24 +62,42 @@
"app.skin-site.map.open": { "message": "在浏览器中打开" },
"app.skin-site.map.server": { "message": "服务器" },
"app.skin-site.map.title": { "message": "服务器地图" },
"app.skin-site.profile-view.add-player": { "message": "新建角色" },
"app.skin-site.profile-view.added": { "message": "角色已添加。" },
"app.skin-site.profile-view.back": { "message": "返回主页" },
"app.skin-site.profile-view.cancel": { "message": "取消" },
"app.skin-site.profile-view.cape": { "message": "披风" },
"app.skin-site.profile-view.clear-default": { "message": "取消默认" },
"app.skin-site.profile-view.confirm": { "message": "确认" },
"app.skin-site.profile-view.default-set": { "message": "默认角色已更新。" },
"app.skin-site.profile-view.delete": { "message": "删除" },
"app.skin-site.profile-view.delete-confirm": { "message": "删除此角色?此操作不可撤销。" },
"app.skin-site.profile-view.deleted": { "message": "角色已删除。" },
"app.skin-site.profile-view.elytra": { "message": "鞘翅" },
"app.skin-site.profile-view.is-default": { "message": "默认" },
"app.skin-site.profile-view.loading": { "message": "正在加载角色…" },
"app.skin-site.profile-view.mojang-confirm": { "message": "切换此角色的正版状态?" },
"app.skin-site.profile-view.mojang-toggled": { "message": "正版状态已更新。" },
"app.skin-site.profile-view.no-players": { "message": "暂无角色。" },
"app.skin-site.profile-view.player-name-placeholder": { "message": "角色名" },
"app.skin-site.profile-view.readonly": {
"message": "Mojang 账号由 Mojang 管理,无法在此修改材质。"
},
"app.skin-site.profile-view.remove": { "message": "移除" },
"app.skin-site.profile-view.removed": { "message": "材质已移除。" },
"app.skin-site.profile-view.rename": { "message": "重命名" },
"app.skin-site.profile-view.rename-confirm": { "message": "重命名此角色?" },
"app.skin-site.profile-view.renamed": { "message": "角色已重命名。" },
"app.skin-site.profile-view.select-hint": {
"message": "选择角色以预览和修改其材质。"
},
"app.skin-site.profile-view.set-default": { "message": "设为默认" },
"app.skin-site.profile-view.signed-out": {
"message": "登录后管理你的角色。"
},
"app.skin-site.profile-view.skin": { "message": "皮肤" },
"app.skin-site.profile-view.title": { "message": "我的角色" },
"app.skin-site.profile-view.toggle-mojang": { "message": "切换正版" },
"app.skin-site.profile-view.upload": { "message": "上传" },
"app.skin-site.profile-view.uploaded": { "message": "材质已更新。" },
"app.skin-site.profile.avatar": { "message": "头像" },
@@ -12700,6 +12720,13 @@
"app.skin-site.admin.launcher.active": { "message": "当前版本" },
"app.skin-site.admin.launcher.activate": { "message": "设为当前" },
"app.skin-site.admin.launcher.activated": { "message": "已设为当前版本。" },
"app.skin-site.admin.launcher.delete": { "message": "删除版本" },
"app.skin-site.admin.launcher.confirm-delete": {
"message": "确定永久删除启动器 {version} 及服务器上的全部安装文件吗?此版本将无法下载或回滚,已安装的启动器不受影响。"
},
"app.skin-site.admin.launcher.deleted": {
"message": "已删除该版本及服务器上的安装文件。"
},
"app.skin-site.admin.launcher.upload": { "message": "上传版本" },
"app.skin-site.admin.launcher.upload-hint": {
"message": "选择发布包 ZIP(最大 4 GiB)。"
@@ -13328,5 +13355,43 @@
},
"app.skin-site.wiki.revision": { "message": "修订 #{id}" },
"app.skin-site.wiki.categories": { "message": "分类" },
"app.skin-site.wiki.back": { "message": "返回" }
"app.skin-site.wiki.back": { "message": "返回" },
"app.skin-site.wiki.history": { "message": "历史" },
"app.skin-site.wiki.read": { "message": "阅读" },
"app.skin-site.wiki.history-empty": { "message": "暂无修订。" },
"app.skin-site.wiki.no-summary": { "message": "无编辑摘要" },
"app.skin-site.wiki.minor": { "message": "小修改" },
"app.skin-site.wiki.view-revision": { "message": "查看" },
"app.skin-site.wiki.revert-to": { "message": "撤销到此版本" },
"app.skin-site.wiki.confirm-revert": { "message": "确认撤销" },
"app.skin-site.wiki.revert-done": { "message": "页面已撤销到所选修订。" },
"app.skin-site.wiki.revision-preview": { "message": "修订 #{id} 预览" },
"app.skin-site.wiki.close-preview": { "message": "关闭预览" },
"app.skin-site.wiki.diff-title": { "message": "与当前版本的逐行差异" },
"app.skin-site.wiki.cancel": { "message": "取消" },
"app.skin-site.wiki.browser.search-placeholder": { "message": "搜索维基页面…" },
"app.skin-site.wiki.browser.tab-search": { "message": "搜索" },
"app.skin-site.wiki.browser.tab-recent": { "message": "最近" },
"app.skin-site.wiki.browser.tab-category": { "message": "分类" },
"app.skin-site.wiki.browser.category-placeholder": { "message": "分类名" },
"app.skin-site.wiki.browser.load-more": { "message": "加载更多" },
"app.skin-site.wiki.browser.empty-search": { "message": "没有匹配的页面。" },
"app.skin-site.wiki.browser.empty-list": { "message": "这里还没有内容。" },
"app.skin-site.wiki.browser.hint-search": { "message": "输入关键词开始搜索。" },
"app.skin-site.wiki.browser.hint-category": { "message": "输入分类名开始浏览。" },
"app.skin-site.wiki.browser.by-author": { "message": "作者 {author}" },
"app.skin-site.home.donation.thanks": {
"message": "感谢每一位支持 StarLight 运行的朋友。"
},
"app.skin-site.home.donation.empty": { "message": "暂无捐赠记录。" },
"app.skin-site.home.donation.list-failed": { "message": "无法加载捐赠记录。" },
"app.skin-site.home.donation.more": { "message": "加载更多" },
"app.skin-site.home.preview.players": { "message": "玩家" },
"app.skin-site.home.preview.empty": { "message": "暂无玩家" },
"app.skin-site.home.preview.empty-hint": {
"message": "登录或在侧栏添加 Minecraft 账号,即可在这里预览皮肤。"
},
"app.skin-site.home.preview.unavailable": { "message": "此玩家没有可预览的皮肤。" },
"app.skin-site.home.preview.loading": { "message": "正在加载玩家…" },
"app.skin-site.home.preview.failed": { "message": "无法加载皮肤站玩家。" }
}
+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-beta11"
version = "1.0.2-beta13"
edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red"
+2 -1
View File
@@ -174,6 +174,7 @@
"terracotta:default",
"multiplayer:default",
"servers:default",
"planet-minecraft:default"
"planet-minecraft:default",
"fonts:default"
]
}
+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-beta11"
version = "1.0.2-beta13"
edition.workspace = true
[dependencies]
+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?;
@@ -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)
+1 -1
View File
@@ -15,7 +15,7 @@ test('all skin-site messages used in source exist in both release language catal
assert.deepEqual(
missing.map(({ id }) => id),
[],
`${locale} has uncatalogued UI messages`,
`${locale} has uncataloged UI messages`,
)
}
})
+1 -1
View File
@@ -6,7 +6,7 @@ const require = createRequire(new URL('../../apps/app-frontend/package.json', im
const ts = require('typescript')
const { parse } = require('vue/compiler-sfc')
/** Read the message IDs actually used by skin-site UI, including uncatalogued messages. */
/** 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) {