chore: release 1.0.2-beta12

This commit is contained in:
Disy committed 2026-10-21 09:16:55 +08:00
1 parent bf95d715a4
commit c44fb1a0a5
43 files changed
+2130 -470

No files matched your search

+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-beta12",
"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,34 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
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>
@@ -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>
@@ -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>
@@ -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>
@@ -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)}` : '—'
}
@@ -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',
@@ -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')
@@ -414,8 +414,8 @@ export function skinSitePublicDonationSummary(): Promise<
return skinSiteRequest<SkinSitePublicDonationSummary>('donation/summary', { auth: false })
}
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSiteDonationPage>> {
return skinSiteRequest<SkinSiteDonationPage>(`donation/list?limit=20&offset=${offset}`, {
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSitePublicDonationPage>> {
return skinSiteRequest<SkinSitePublicDonationPage>(`donation/list?limit=20&offset=${offset}`, {
auth: false,
})
}
@@ -1214,6 +1214,10 @@ 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
@@ -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) => {
+29 -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": {
@@ -12331,6 +12332,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 +13075,25 @@
},
"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.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."
}
}
+23 -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": {
@@ -12700,6 +12701,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 +13336,19 @@
},
"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.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": "无法加载皮肤站玩家。" }
}
@@ -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')