merge: 同步 main 到 feat/home

This commit is contained in:
AxTps committed 2026-10-21 21:34:49 +08:00
commit 0aa901ad48
43 files changed
+8543 -10784

No files matched your search

+19 -8
View File
@@ -58,24 +58,34 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: pnpm cache: pnpm
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Restore Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: desktop-${{ runner.os }}-${{ runner.arch }}
- name: Set up Rust compiler cache - name: Set up Rust compiler cache
if: runner.os == 'Windows' id: sccache-setup
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7 continue-on-error: true
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Check Rust compiler cache availability - name: Check Rust compiler cache availability
id: sccache id: sccache
if: runner.os == 'Windows' if: steps.sccache-setup.outcome == 'success'
continue-on-error: true continue-on-error: true
shell: bash shell: bash
env: env:
SCCACHE_GHA_ENABLED: 'true' SCCACHE_GHA_ENABLED: 'true'
run: ${SCCACHE_PATH} rustc -vV run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV'
- name: Enable Rust compiler cache - name: Enable Rust compiler cache
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' if: steps.sccache.outcome == 'success'
shell: bash shell: bash
run: | run: |
echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV" echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV"
echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV" echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV" - name: Report unavailable Rust compiler cache
if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure'
shell: bash
run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.'
- if: runner.os == 'Linux' - if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
@@ -112,9 +122,10 @@ jobs:
} }
Write-Output "Built native NSIS installer: $($installers[0].Name)" Write-Output "Built native NSIS installer: $($installers[0].Name)"
- name: Show Rust compiler cache statistics - name: Show Rust compiler cache statistics
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always() if: always() && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash shell: bash
run: ${SCCACHE_PATH} --show-stats run: '"${SCCACHE_PATH}" --show-stats'
website: website:
needs: guardrails needs: guardrails
+24 -11
View File
@@ -131,26 +131,38 @@ jobs:
- name: Set up Rust - name: Set up Rust
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
- name: Restore Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: desktop-${{ runner.os }}-${{ runner.arch }}
- name: Set up Rust compiler cache - name: Set up Rust compiler cache
if: runner.os == 'Windows' id: sccache-setup
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7 continue-on-error: true
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Check Rust compiler cache availability - name: Check Rust compiler cache availability
id: sccache id: sccache
if: runner.os == 'Windows' if: steps.sccache-setup.outcome == 'success'
continue-on-error: true continue-on-error: true
shell: bash shell: bash
env: env:
SCCACHE_GHA_ENABLED: 'true' SCCACHE_GHA_ENABLED: 'true'
run: ${SCCACHE_PATH} rustc -vV run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV'
- name: Enable Rust compiler cache - name: Enable Rust compiler cache
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' if: steps.sccache.outcome == 'success'
shell: bash shell: bash
run: | run: |
echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV" echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV"
echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV" echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV"
- name: Report unavailable Rust compiler cache
if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure'
shell: bash
run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.'
- name: Add macOS universal targets - name: Add macOS universal targets
if: runner.os == 'macOS' if: runner.os == 'macOS'
@@ -193,11 +205,6 @@ jobs:
updaterJsonPreferNsis: true updaterJsonPreferNsis: true
retryAttempts: 2 retryAttempts: 2
- name: Show Rust compiler cache statistics
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always()
shell: bash
run: ${SCCACHE_PATH} --show-stats
- name: Publish Windows distribution variants - name: Publish Windows distribution variants
if: runner.os == 'Windows' if: runner.os == 'Windows'
env: env:
@@ -283,6 +290,12 @@ jobs:
throw "Binary not found at $appExe" throw "Binary not found at $appExe"
} }
- name: Show Rust compiler cache statistics
if: always() && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: '"${SCCACHE_PATH}" --show-stats'
verify-and-publish: verify-and-publish:
name: Verify and publish name: Verify and publish
needs: build needs: build
Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "1.0.2-beta11" version = "1.0.2-beta13"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"async-minecraft-ping", "async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "1.0.2-beta11" version = "1.0.2-beta13"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async_zip", "async_zip",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.2-beta11", "version": "1.0.2-beta13",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+54 -34
View File
@@ -134,6 +134,7 @@ import {
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts' import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
import { get_opening_command, initialize_state } from '@/helpers/state' import { get_opening_command, initialize_state } from '@/helpers/state'
import { installUiScale, setUiScale } from '@/helpers/ui-scale' import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { shouldDelayAppUpdate } from '@/helpers/app-update-policy'
import { import {
areUpdatesEnabled, areUpdatesEnabled,
backupAppDbForUpdate, backupAppDbForUpdate,
@@ -1998,34 +1999,55 @@ function showDelayedUpdatePopup() {
} }
let lastUpdateChannel = 'release' let lastUpdateChannel = 'release'
let updateCheckRevision = 0
async function performUpdateCheck() { async function clearAvailableUpdate(isCurrent) {
const channel = await getUpdateChannel() const unlisten = unlistenUpdateDownload
const preferences = await getUpdatePreferences() unlistenUpdateDownload = null
updatesPaused.value = preferences.updatesPaused await unlisten?.()
if (updatesPaused.value) return 'paused' if (!isCurrent()) return
if (channel !== lastUpdateChannel) {
availableUpdate.value = null availableUpdate.value = null
updateSize.value = null updateSize.value = null
appUpdateDownload.progress.value = 0 appUpdateDownload.progress.value = 0
finishedDownloading.value = false finishedDownloading.value = false
downloading.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) {
await clearAvailableUpdate(isLatestCheck)
if (!isLatestCheck()) return currentResult()
lastUpdateChannel = channel 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) { if (!update) {
console.log('No update available') console.log('No update available')
return 'up-to-date' return 'up-to-date'
} }
const publishedAt = Date.parse(update.publishedAt ?? '') const publishedAt = Date.parse(update.publishedAt ?? '')
if ( if (shouldDelayAppUpdate(update, channel, preferences.immediateUpdateFetch)) {
channel === 'release' &&
!preferences.immediateUpdateFetch &&
!update.forceUpdate &&
(!Number.isFinite(publishedAt) || Date.now() < publishedAt + 24 * 60 * 60 * 1000)
) {
console.warn( console.warn(
Number.isFinite(publishedAt) Number.isFinite(publishedAt)
? `Update ${update.version} is waiting for the release delay.` ? `Update ${update.version} is waiting for the release delay.`
@@ -2042,6 +2064,9 @@ async function performUpdateCheck() {
return 'available' return 'available'
} }
const meteredNetwork = await isNetworkMetered()
if (!isLatestCheck()) return currentResult()
appUpdateDownload.progress.value = 0 appUpdateDownload.progress.value = 0
finishedDownloading.value = false finishedDownloading.value = false
downloading.value = false downloading.value = false
@@ -2050,7 +2075,7 @@ async function performUpdateCheck() {
console.log(`Update ${update.version} is available.`) console.log(`Update ${update.version} is available.`)
metered.value = await isNetworkMetered() metered.value = meteredNetwork
if (!metered.value) { if (!metered.value) {
console.log('Starting download of update') console.log('Starting download of update')
@@ -2062,7 +2087,9 @@ async function performUpdateCheck() {
} }
getUpdateSize(update.rid) 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)) .catch((error) => console.warn('Failed to fetch update size', error))
return 'available' return 'available'
} }
@@ -2092,6 +2119,7 @@ async function downloadUpdate(versionToDownload) {
handleError(`Failed to download update: no version available`) handleError(`Failed to download update: no version available`)
return return
} }
if (availableUpdate.value?.rid !== versionToDownload.rid) return
if (downloading.value || appUpdateDownload.progress.value !== 0) { if (downloading.value || appUpdateDownload.progress.value !== 0) {
console.error(`Update ${versionToDownload.version} already downloading`) console.error(`Update ${versionToDownload.version} already downloading`)
@@ -2100,35 +2128,27 @@ async function downloadUpdate(versionToDownload) {
console.log(`Downloading update ${versionToDownload.version} from Update Server`) console.log(`Downloading update ${versionToDownload.version} from Update Server`)
downloading.value = true downloading.value = true
const isCurrent = () => availableUpdate.value?.rid === versionToDownload.rid
let unlisten
try { try {
enqueueUpdateForInstallation(versionToDownload.rid) unlisten = await subscribeToDownloadProgress(appUpdateDownload, versionToDownload.version)
.then(() => { if (!isCurrent()) return
unlistenUpdateDownload = unlisten
await enqueueUpdateForInstallation(versionToDownload.rid)
if (!isCurrent()) return
downloading.value = false downloading.value = false
finishedDownloading.value = true finishedDownloading.value = true
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
console.log('Finished downloading!')
markAppUpdateActionable(versionToDownload.version, 'downloaded') markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup() scheduleDelayedUpdatePopup()
})
.catch((error) => {
downloading.value = false
appUpdateDownload.progress.value = 0
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
handleError(error)
})
unlistenUpdateDownload = await subscribeToDownloadProgress(
appUpdateDownload,
versionToDownload.version,
)
} catch (error) { } catch (error) {
if (!isCurrent()) return
downloading.value = false downloading.value = false
appUpdateDownload.progress.value = 0 appUpdateDownload.progress.value = 0
handleError(error) handleError(error)
} finally {
await unlisten?.()
if (unlistenUpdateDownload === unlisten) unlistenUpdateDownload = null
} }
} }
@@ -32,6 +32,61 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
] ]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta13',
version: '1.0.2-beta13',
publishedAt: '2026-10-21',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta13',
'zh-CN': 'Starlight Launcher 1.0.2-beta13',
},
changes: {
changed: [
{
'en-US':
'The redesigned home page brings together the 3D skin preview, skin site and Microsoft player selection, announcements, donation acknowledgements and a compact navigation toolbar.',
'zh-CN':
'新版首页集中展示 3D 皮肤预览、皮肤站与微软玩家切换、公告、捐赠鸣谢及底部功能工具栏。',
},
],
fixed: [
{
'en-US':
'The home page keeps the current 3D skin preview stable when accounts or avatars refresh in the background, avoiding unnecessary texture reloads and model flicker.',
'zh-CN':
'后台刷新账号或头像时保持当前 3D 皮肤预览稳定,避免无关刷新导致纹理重新加载和模型闪烁。',
},
],
},
},
{
id: 'launcher-1.0.2-beta12',
version: '1.0.2-beta12',
publishedAt: '2026-10-21',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta12',
'zh-CN': 'Starlight Launcher 1.0.2-beta12',
},
changes: {
fixed: [
{
'en-US':
'Fixed automatic updates ignoring server rollbacks to an earlier launcher version.',
'zh-CN': '修复启动器忽略服务端旧版本回滚的问题,回滚后可自动下载并在退出或重启时安装。',
},
{
'en-US':
'Fixed outdated update checks and downloads overriding a newly selected update version.',
'zh-CN': '修复旧的更新检查或下载结果覆盖新目标版本的问题。',
},
],
},
notes: {
'en-US':
'Install this version once to enable automatic rollback support for subsequent updates.',
'zh-CN': '需要先安装一次此版本,之后才能自动跟随服务端回滚。',
},
},
{ {
id: 'launcher-1.0.2-beta11', id: 'launcher-1.0.2-beta11',
version: '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 { 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' import { useNetworkStatus } from '../../composables/useNetworkStatus.ts'
// Exercise the real component's setup and reactive account list. Only platform, // 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 { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href
const stubs: Record<string, string> = { 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/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})`, '@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()=>{}})`, '@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()=>{}})`, 'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`,
'@/assets/netherstar.png': `export default 'logo'`, '@/assets/netherstar.png': `export default 'logo'`,
'@/components/ui/MinecraftLoginModal.vue': `export default {}`, '@/components/ui/MinecraftLoginModal.vue': `export default {}`,
'@/components/ui/skin-site/SkinSiteLoginModal.vue': `export default {}`,
'@/helpers/events': `export const process_listener=async()=>()=>{}`, '@/helpers/events': `export const process_listener=async()=>()=>{}`,
'@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`, '@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`,
'@/helpers/skins': `export const get_available_skins=async()=>[]`, '@/helpers/skins': `export const get_available_skins=async()=>[]`,
@@ -59,6 +55,13 @@ const hooks = registerHooks({
}, },
}) })
const { default: AccountsCard } = await import(componentUrl) const { default: AccountsCard } = await import(componentUrl)
const {
resetSkinSiteSession,
signInSkinSite,
signOutSkinSite,
selectedSkinSitePlayerId,
SKIN_SITE_ORIGIN,
} = await import('../../composables/skin-site-session.ts')
hooks.deregister() hooks.deregister()
type TestNode = Record<string, unknown> type TestNode = Record<string, unknown>
@@ -72,6 +75,7 @@ type CardState = {
defaultUser: string | undefined defaultUser: string | undefined
selectedAccount: TestAccount | undefined selectedAccount: TestAccount | undefined
setAccount: (account: TestAccount) => Promise<void> setAccount: (account: TestAccount) => Promise<void>
setSkinSitePlayer: (id: string) => Promise<void>
getAccountAvatarUrl: (account: TestAccount) => string | undefined getAccountAvatarUrl: (account: TestAccount) => string | undefined
} }
const renderer = createRenderer<TestNode, TestNode>({ const renderer = createRenderer<TestNode, TestNode>({
@@ -93,37 +97,39 @@ const flush = async () => {
const firstId = '12345678-1234-1234-1234-123456789abc' const firstId = '12345678-1234-1234-1234-123456789abc'
const secondId = '22345678-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 previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
const stored: TestAccount[] = hasOfficial 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 rejectLogin = false
let loginCalls = 0 let loginCalls = 0
const frame = { const storage = new Map<string, string>()
postMessage(data: { type: string; requestId: string }) { Object.defineProperty(globalThis, 'localStorage', {
if (data.type === 'starlight-pack-token-request') configurable: true,
message({ value: {
type: 'starlight-pack-token-result', getItem: (key: string) => storage.get(key) ?? null,
requestId: data.requestId, setItem: (key: string, value: string) => storage.set(key, value),
token: 'fixture.jwt', removeItem: (key: string) => storage.delete(key),
})
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' },
],
})
}, },
} 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 } }) Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline() useNetworkStatus().refreshBrowserOffline()
@@ -163,22 +169,18 @@ async function fixture(hasOfficial = true) {
}, },
}) })
resetSkinSiteSession() resetSkinSiteSession()
setSkinSiteFrame(frame)
const node = h({ ...AccountsCard, render: () => null }) const node = h({ ...AccountsCard, render: () => null })
const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) }) const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) })
app.mount({}) app.mount({})
await flush() await flush()
return { return {
state: (node.component! as unknown as { setupState: CardState }).setupState, state: (node.component! as unknown as { setupState: CardState }).setupState,
login() { async login() {
message({ await signInSkinSite({ username: 'Owner', password: 'password', clientId: 'test' })
type: 'starlight-skin-session', await flush()
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
})
}, },
signOut() { signOut() {
message({ type: 'starlight-skin-session', status: 'signed-out' }) signOutSkinSite()
}, },
failLogin(value: boolean) { failLogin(value: boolean) {
rejectLogin = value rejectLogin = value
@@ -192,7 +194,7 @@ async function fixture(hasOfficial = true) {
close() { close() {
app.unmount() app.unmount()
resetSkinSiteSession() resetSkinSiteSession()
setSkinSiteFrame(null) Reflect.deleteProperty(globalThis, 'localStorage')
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window') else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator) 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) const view = await fixture(false)
try { try {
view.failLogin(true) view.failLogin(true)
view.login() await view.login()
await flush() await flush()
assert.deepEqual( assert.deepEqual(
view.state.accounts.map((account) => account.profile.id), 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 () => { test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => {
const view = await fixture() const view = await fixture()
try { try {
view.login() await view.login()
await flush() await flush()
await view.state.setAccount( await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!, 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 () => { test('failed player authentication preserves the default and allows retry', async () => {
const view = await fixture() const view = await fixture()
try { try {
view.login() await view.login()
await flush() await flush()
view.failLogin(true) view.failLogin(true)
await view.state.setAccount( 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 () => { test('signing out removes unregistered choices while keeping saved accounts', async () => {
const view = await fixture() const view = await fixture()
try { try {
view.login() await view.login()
await flush() await flush()
await view.state.setAccount( await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === firstId)!, 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'], [firstId, 'official'],
) )
assert.equal(view.selected, firstId) assert.equal(view.selected, firstId)
view.login() await view.login()
await flush() await flush()
assert.equal(view.state.accounts.length, 3) assert.equal(view.state.accounts.length, 3)
assert.equal(view.loginCalls, 1) 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 () => { test('a new player without a skin can render the shared empty avatar', async () => {
const view = await fixture(false) const view = await fixture(false)
try { try {
view.login() await view.login()
await flush() await flush()
assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined) assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined)
} finally { } finally {
view.close() 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) { if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
for (const player of skinSitePlayers.value) { for (const player of skinSitePlayers.value) {
if (player.isMojang) continue
const id = player.uuid.replaceAll('-', '').toLowerCase() const id = player.uuid.replaceAll('-', '').toLowerCase()
const stored = byId.get(id) const stored = byId.get(id)
if (stored && stored.account_type !== 'yggdrasil') continue if (stored && stored.account_type !== 'yggdrasil') continue
@@ -409,8 +408,12 @@ async function refreshSkinSitePlayers() {
await requestSkinSitePlayers().catch(() => {}) await requestSkinSitePlayers().catch(() => {})
} }
function setSkinSitePlayer(playerId: string) { async function setSkinSitePlayer(playerId: string) {
selectSkinSitePlayer(playerId) 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 const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
@@ -531,6 +534,12 @@ function setLoginDisabled(value: boolean) {
} }
defineExpose({ defineExpose({
accounts,
defaultUser,
accountSelectionPending,
setAccount,
getAccountSkin,
getAccountAvatarUrl,
accountChangeRevision, accountChangeRevision,
login, login,
refreshValues, refreshValues,
@@ -669,7 +678,15 @@ async function setAccount(account: MinecraftCredential) {
}) })
} }
await persistDefaultUser(userId) 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 defaultUser.value = userId
equippedSkin.value = null equippedSkin.value = null
notifyAccountChange() notifyAccountChange()
@@ -682,9 +699,20 @@ async function setAccount(account: MinecraftCredential) {
} }
watch( watch(
[skinSitePlayers, defaultUser, skinSiteUser], [skinSitePlayers, defaultUser, skinSiteUser, accounts],
([availablePlayers, selectedLocalUser]) => { ([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) selectSkinSitePlayer(availablePlayers[0].uuid)
} }
}, },
@@ -21,6 +21,7 @@ import {
const settingsComponentFiles = { const settingsComponentFiles = {
interface: ['./AppearanceSettings.vue'], interface: ['./AppearanceSettings.vue'],
'home-navigation': ['./AppearanceSettings.vue'], 'home-navigation': ['./AppearanceSettings.vue'],
'shortcut-settings': ['./KeybindSettings.vue'],
'language-translation': ['./LanguageSettings.vue', './TranslationSettings.vue'], 'language-translation': ['./LanguageSettings.vue', './TranslationSettings.vue'],
ai: ['./AISettings.vue'], ai: ['./AISettings.vue'],
'java-performance': ['./JavaSettings.vue'], 'java-performance': ['./JavaSettings.vue'],
@@ -30,6 +31,7 @@ const settingsComponentFiles = {
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'], 'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
updates: ['./UpdateSettings.vue'], updates: ['./UpdateSettings.vue'],
about: ['./AboutSettings.vue'], about: ['./AboutSettings.vue'],
logs: ['./LogsSettings.vue'],
'feature-flags': ['./FeatureFlagSettings.vue'], 'feature-flags': ['./FeatureFlagSettings.vue'],
} as const } as const
@@ -168,7 +170,6 @@ 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 = ( const categoriesForGroup = (
group: 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer', group: 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer',
@@ -181,6 +182,7 @@ test('developer-only settings stay out of the normal search categories', () => {
assert.deepEqual(categoriesForGroup('launcher'), [ assert.deepEqual(categoriesForGroup('launcher'), [
'interface', 'interface',
'home-navigation', 'home-navigation',
'shortcut-settings',
'language-translation', 'language-translation',
'ai', 'ai',
]) ])
@@ -191,7 +193,7 @@ test('developer-only settings stay out of the normal search categories', () => {
'network-multiplayer', 'network-multiplayer',
]) ])
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups']) 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'), [])
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags']) 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 { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSiteAnnouncements.vue', import.meta.url).href const componentUrl = new URL('./SkinSiteAnnouncements.vue', import.meta.url).href
const stubs: Record<string, string> = { const stubs: Record<string, string> = {
'@modrinth/assets': 'export const SpinnerIcon={}', '@modrinth/assets': 'export const NewspaperIcon={}, SpinnerIcon={}, StarIcon={}',
'@modrinth/ui': '@modrinth/ui':
'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})', 'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@/helpers/skin-site-api': '@/helpers/skin-site-api':
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets' import { NewspaperIcon, SpinnerIcon, StarIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui' import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue' import { onUnmounted, ref, watch } from 'vue'
@@ -81,43 +81,80 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="flex flex-col gap-2"> <section class="announcements">
<h3 class="m-0 text-base font-semibold text-contrast"> <header class="flex items-center gap-2 border-0 border-b border-solid border-surface-5 pb-4">
{{ formatMessage(messages.title) }} <NewspaperIcon class="size-5 text-brand" />
</h3> <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" /> <SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }} {{ formatMessage(messages.loading) }}
</div> </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> <p class="m-0">{{ formatMessage(messages.failed) }}</p>
<button type="button" class="text-brand hover:underline" @click="retry"> <button type="button" class="text-brand hover:underline" @click="retry">
{{ formatMessage(commonMessages.retryButton) }} {{ formatMessage(commonMessages.retryButton) }}
</button> </button>
</div> </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) }} {{ formatMessage(messages.signedOut) }}
</p> </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) }} {{ formatMessage(messages.empty) }}
</p> </p>
<div v-else class="flex flex-col gap-2"> <div v-else class="flex flex-col">
<div <article v-for="item in announcements" :key="item.id" class="announcement-entry">
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 class="flex items-start gap-2"> <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"> <span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.title }} {{ item.title }}
</span> </span>
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span> <span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
</div> </div>
<span v-if="item.subtitle" class="truncate text-xs text-secondary">{{ <p v-if="item.subtitle" class="m-0 text-sm leading-relaxed text-secondary">
item.subtitle {{ item.subtitle }}
}}</span> </p>
</div> </article>
</div>
</div> </div>
</section>
</template> </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"> <script setup lang="ts">
import { HeartIcon, SpinnerIcon } from '@modrinth/assets' import { ChevronRightIcon, HeartIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui' import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
@@ -8,6 +8,9 @@ import {
skinSitePublicDonationSummary, skinSitePublicDonationSummary,
} from '@/helpers/skin-site-api' } from '@/helpers/skin-site-api'
import { formatDonationAmount } from './donation-format'
import SkinSiteDonationModal from './SkinSiteDonationModal.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const messages = defineMessages({ const messages = defineMessages({
@@ -24,6 +27,7 @@ const messages = defineMessages({
const summary = ref<SkinSitePublicDonationSummary | null>(null) const summary = ref<SkinSitePublicDonationSummary | null>(null)
const loading = ref(true) const loading = ref(true)
const failed = ref(false) const failed = ref(false)
const donationModal = ref<InstanceType<typeof SkinSiteDonationModal>>()
onMounted(async () => { onMounted(async () => {
try { try {
@@ -39,10 +43,8 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div <button type="button" class="donation-card" @click="donationModal?.show()">
class="flex items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3" <span class="donation-icon"><HeartIcon class="size-6" /></span>
>
<HeartIcon class="size-5 shrink-0 text-brand" />
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<span class="text-sm font-semibold text-contrast">{{ formatMessage(messages.title) }}</span> <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"> <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"> <span v-else-if="failed" class="text-xs text-secondary">
{{ formatMessage(messages.unavailable) }} {{ formatMessage(messages.unavailable) }}
</span> </span>
<span v-else-if="summary" class="text-xs text-secondary"> <span v-else-if="summary" class="text-xs text-secondary">{{
{{ formatMessage(messages.total) }}: {{ summary.totalAmount }} · formatMessage(messages.donors, { count: summary.donorCount })
{{ formatMessage(messages.donors, { count: summary.donorCount }) }} }}</span>
</span>
</div>
</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> </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"> <script setup lang="ts">
import { CopyIcon } from '@modrinth/assets' import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' import { computed, ref } from 'vue'
import { ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session'
import SkinSiteAccountCard from './SkinSiteAccountCard.vue'
import SkinSiteAdminView from './SkinSiteAdminView.vue' import SkinSiteAdminView from './SkinSiteAdminView.vue'
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue' import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
import SkinSiteBugCreateModal from './SkinSiteBugCreateModal.vue' import SkinSiteBugCreateModal from './SkinSiteBugCreateModal.vue'
@@ -12,30 +12,26 @@ import SkinSiteBugListView from './SkinSiteBugListView.vue'
import SkinSiteBugNotificationsView from './SkinSiteBugNotificationsView.vue' import SkinSiteBugNotificationsView from './SkinSiteBugNotificationsView.vue'
import SkinSiteDonationCard from './SkinSiteDonationCard.vue' import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue' import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue'
import SkinSiteLoginModal from './SkinSiteLoginModal.vue'
import SkinSiteMapView from './SkinSiteMapView.vue' import SkinSiteMapView from './SkinSiteMapView.vue'
import SkinSiteNavCards from './SkinSiteNavCards.vue' import SkinSiteNavCards from './SkinSiteNavCards.vue'
import SkinSitePlayerPreview from './SkinSitePlayerPreview.vue'
import SkinSiteProfileModal from './SkinSiteProfileModal.vue' import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
import SkinSiteProfileView from './SkinSiteProfileView.vue' import SkinSiteProfileView from './SkinSiteProfileView.vue'
import SkinSiteReviewView from './SkinSiteReviewView.vue' import SkinSiteReviewView from './SkinSiteReviewView.vue'
import WikiView from './WikiView.vue' import WikiView from './WikiView.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'app.skin-site.home.title', defaultMessage: 'StarLight Skin Site' }, account: { id: 'app.skin-site.home.settings', defaultMessage: 'Account' },
subtitle: { admin: { id: 'app.skin-site.home.admin', defaultMessage: 'Admin' },
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.' },
}) })
const YGGDRASIL_URL = 'https://skin.starlight.cool/yggdrasil'
const loginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>() const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
const isAdmin = computed(() =>
skinSiteUser.value?.permissions.some(
(permission) => permission === 'WEB_ADMIN' || permission === 'SERVER_ADMIN',
),
)
const view = ref< const view = ref<
| 'home' | 'home'
| 'players' | 'players'
@@ -69,21 +65,26 @@ function openBugNotifications() {
function onBugCreated(id: number) { function onBugCreated(id: number) {
openBug(id) 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> </script>
<template> <template>
<div class="flex h-full min-h-0 w-full flex-col overflow-y-auto"> <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"> <div class="skin-site-home mx-auto flex w-full max-w-6xl flex-col gap-6 p-6">
<SkinSiteProfileView v-if="view === 'players'" @back="view = 'home'" /> <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'" /> <SkinSiteDownloadsView v-else-if="view === 'downloads'" @back="view = 'home'" />
<SkinSiteMapView v-else-if="view === 'map'" @back="view = 'home'" /> <SkinSiteMapView v-else-if="view === 'map'" @back="view = 'home'" />
<SkinSiteReviewView v-else-if="view === 'review'" @back="view = 'home'" /> <SkinSiteReviewView v-else-if="view === 'review'" @back="view = 'home'" />
@@ -109,46 +110,49 @@ async function copyAddress() {
/> />
<template v-else> <template v-else>
<header class="flex flex-col items-center gap-1 text-center"> <div class="home-overview">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1> <SkinSitePlayerPreview />
<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)" />
<SkinSiteAnnouncements /> <SkinSiteAnnouncements />
</div> </div>
<SkinSiteDonationCard /> <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> </template>
<div class="home-toolbar">
<SkinSiteNavCards
:active="view === 'bug-detail' || view === 'bug-notifications' ? 'bugs' : view"
@navigate="(target) => (view = target === view ? 'home' : target)"
/>
</div>
</div> </div>
<SkinSiteLoginModal ref="loginModal" />
<SkinSiteProfileModal ref="profileModal" />
<SkinSiteBugCreateModal ref="bugCreateModal" @created="onBugCreated" /> <SkinSiteBugCreateModal ref="bugCreateModal" @created="onBugCreated" />
<SkinSiteProfileModal ref="profileModal" />
</div> </div>
</template> </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, UserIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui' 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 { formatMessage } = useVIntl()
const emit = defineEmits<{ 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({ const messages = defineMessages({
@@ -50,96 +54,56 @@ const messages = defineMessages({
}, },
}) })
interface NavItem { const destinations = ['downloads', 'review', 'wiki', 'bugs', 'map', 'players'] as const
id: string const items = computed(() => [
title: string
description: string
icon: Component
href: string
}
const SKIN_SITE = 'https://skin.starlight.cool'
const items: NavItem[] = [
{ {
id: 'download', id: 'downloads',
title: formatMessage(messages.download), label: formatMessage(messages.download),
description: formatMessage(messages.downloadDesc), description: formatMessage(messages.downloadDesc),
icon: DownloadIcon, icon: DownloadIcon,
href: `${SKIN_SITE}/download`,
}, },
{ {
id: 'review', id: 'review',
title: formatMessage(messages.review), label: formatMessage(messages.review),
description: formatMessage(messages.reviewDesc), description: formatMessage(messages.reviewDesc),
icon: ShieldCheckIcon, icon: ShieldCheckIcon,
href: `${SKIN_SITE}/review`,
}, },
{ {
id: 'wiki', id: 'wiki',
title: formatMessage(messages.wiki), label: formatMessage(messages.wiki),
description: formatMessage(messages.wikiDesc), description: formatMessage(messages.wikiDesc),
icon: BookIcon, icon: BookIcon,
href: `${SKIN_SITE}/wiki`,
}, },
{ {
id: 'bugs', id: 'bugs',
title: formatMessage(messages.bugs), label: formatMessage(messages.bugs),
description: formatMessage(messages.bugsDesc), description: formatMessage(messages.bugsDesc),
icon: BugIcon, icon: BugIcon,
href: `${SKIN_SITE}/bugs`,
}, },
{ {
id: 'map', id: 'map',
title: formatMessage(messages.map), label: formatMessage(messages.map),
description: formatMessage(messages.mapDesc), description: formatMessage(messages.mapDesc),
icon: MapIcon, icon: MapIcon,
href: `${SKIN_SITE}/map`,
}, },
{ {
id: 'profile', id: 'players',
title: formatMessage(messages.profile), label: formatMessage(messages.profile),
description: formatMessage(messages.profileDesc), description: formatMessage(messages.profileDesc),
icon: UserIcon, icon: UserIcon,
href: `${SKIN_SITE}/profile`,
}, },
] ])
function navigate(id: string) {
function open(item: NavItem) { const destination = destinations.find((value) => value === id)
if ( if (destination) emit('navigate', destination)
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))
} }
</script> </script>
<template> <template>
<div class="flex flex-col gap-3"> <SkinSiteSelectionRail
<h3 class="m-0 text-base font-semibold text-contrast">{{ formatMessage(messages.title) }}</h3> :items="items"
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3"> :selected="active"
<button :label="formatMessage(messages.title)"
v-for="item in items" @select="navigate"
: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>
</template> </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 { import {
skinSiteActivateLauncherRelease, skinSiteActivateLauncherRelease,
skinSiteDeleteLauncherRelease,
type SkinSiteLauncherAdminState, type SkinSiteLauncherAdminState,
skinSiteLauncherAdminState, skinSiteLauncherAdminState,
type SkinSiteLauncherRelease, type SkinSiteLauncherRelease,
@@ -32,6 +33,19 @@ const messages = defineMessages({
active: { id: 'app.skin-site.admin.launcher.active', defaultMessage: 'Active' }, active: { id: 'app.skin-site.admin.launcher.active', defaultMessage: 'Active' },
activate: { id: 'app.skin-site.admin.launcher.activate', defaultMessage: 'Activate' }, activate: { id: 'app.skin-site.admin.launcher.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.launcher.activated', defaultMessage: 'Release activated.' }, 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' }, upload: { id: 'app.skin-site.admin.launcher.upload', defaultMessage: 'Upload release' },
uploadHint: { uploadHint: {
id: 'app.skin-site.admin.launcher.upload-hint', id: 'app.skin-site.admin.launcher.upload-hint',
@@ -64,6 +78,10 @@ const uploadPercent = ref(0)
const forceUpdate = ref(false) const forceUpdate = ref(false)
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const activatingId = ref<number | 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 releases = computed(() => state.value?.releases ?? [])
const total = computed(() => releases.value.length) const total = computed(() => releases.value.length)
@@ -105,7 +123,7 @@ function nextPage() {
} }
async function activate(release: SkinSiteLauncherRelease) { async function activate(release: SkinSiteLauncherRelease) {
if (activatingId.value !== null) return if (release.active || mutating.value || loading.value) return
activatingId.value = release.id activatingId.value = release.id
try { try {
const result = await skinSiteActivateLauncherRelease(release.id) 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() { function pickFile() {
fileInput.value?.click() fileInput.value?.click()
} }
@@ -127,7 +163,7 @@ async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
input.value = '' input.value = ''
if (!file || uploading.value) return if (!file || mutating.value || loading.value) return
uploading.value = true uploading.value = true
uploadPercent.value = 0 uploadPercent.value = 0
try { try {
@@ -180,7 +216,7 @@ onMounted(() => void load())
<button <button
type="button" type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50" 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" @click="pickFile"
> >
{{ formatMessage(messages.upload) }} {{ formatMessage(messages.upload) }}
@@ -244,11 +280,21 @@ onMounted(() => void load())
v-if="!release.active" v-if="!release.active"
type="button" 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" 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)" @click="activate(release)"
> >
{{ formatMessage(messages.activate) }} {{ formatMessage(messages.activate) }}
</button> </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>
<div class="flex items-center justify-center gap-3 pt-2"> <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' import test from 'node:test'
type FetchHandler = (url: string, options: RequestInit) => Promise<Response> 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({ const hooks = registerHooks({
resolve(specifier, context, nextResolve) { 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') { if (specifier === '@tauri-apps/plugin-http') {
return { return {
url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinSiteTestFetch(...args)', url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinSiteTestFetch(...args)',
@@ -44,7 +54,7 @@ test.beforeEach(() => {
if (url.endsWith('/user')) return ok(user) if (url.endsWith('/user')) return ok(user)
return ok([]) return ok([])
} }
testGlobal.skinSiteTestFetch = async (url, options) => { testGlobal.skinSiteTestFetch = async (url, options = {}) => {
calls.push({ calls.push({
path: new URL(url).pathname, path: new URL(url).pathname,
token: (options.headers as Record<string, string>)?.Authorization, 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(session.skinSiteStatus.value, 'signed-out')
assert.equal(storage.has(tokenKey), false) 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 if (!texture.skin) return base
try { try {
const [headDataUrl, skinDataUrl] = await Promise.all([ const skinDataUrl = await fetchImageAsDataUrl(texture.skin)
renderSkinHead(texture.skin), const headDataUrl = await renderSkinHead(skinDataUrl)
fetchImageAsDataUrl(texture.skin),
])
return { return {
...base, ...base,
skinState: 'ready', 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 assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test' import test from 'node:test'
import { const hooks = registerHooks({
receiveSkinSiteMessage, resolve(specifier, context, nextResolve) {
resetSkinSiteSession, if (specifier === '@tauri-apps/plugin-http') {
setSkinSiteFrame, return {
SKIN_SITE_ORIGIN, 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:[]}:[]})`)}`,
} from '../composables/skin-site-session.ts' 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 { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import { const {
type InstancePlayer,
onInstancePlayerChanged, onInstancePlayerChanged,
prepareInstancePlayer, prepareInstancePlayer,
registerInstancePlayerPicker, registerInstancePlayerPicker,
saveInstancePlayer, 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 () => { test('instance player selection persists independently from the globally selected account', async () => {
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') 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[] = [] const calls: string[] = []
let failSave = false let failSave = false
const frame = { const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
postMessage(data: { type: string; requestId: string }) { const storage = new Map([['starlight-skin-site-token', 'old.jwt']])
if (data.type === 'starlight-pack-token-request') Object.defineProperty(globalThis, 'localStorage', {
receiveSkinSiteMessage( configurable: true,
{ value: {
origin: SKIN_SITE_ORIGIN, getItem: (key: string) => storage.get(key) ?? null,
source: frame, setItem: (key: string, value: string) => storage.set(key, value),
data: { removeItem: (key: string) => storage.delete(key),
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'fixture.jwt',
}, },
} as unknown as MessageEvent, })
frame,
)
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', { Object.defineProperty(globalThis, 'window', {
configurable: true, configurable: true,
value: { 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)) const stop = onInstancePlayerChanged((id) => changes.push(id))
try { try {
resetSkinSiteSession() resetSkinSiteSession()
setSkinSiteFrame(frame) await initSkinSiteSession()
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
},
} as unknown as MessageEvent,
frame,
)
await prepareInstancePlayer('one') await prepareInstancePlayer('one')
assert.equal(prompts, 0) assert.equal(prompts, 0)
assert.ok(calls.includes('plugin:auth|login_skin_site_player')) 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 failSave = true
await assert.rejects(saveInstancePlayer('two', player), /Save failed/) await assert.rejects(saveInstancePlayer('two', player), /Save failed/)
assert.deepEqual(changes, ['one']) assert.deepEqual(changes, ['one'])
storage.clear()
resetSkinSiteSession() resetSkinSiteSession()
await prepareInstancePlayer('one') await prepareInstancePlayer('one')
assert.equal(prompts, 1) assert.equal(prompts, 1)
@@ -157,6 +151,8 @@ test('signed-in skin site renews the saved player without prompting and only not
unregister() unregister()
resetSkinSiteSession() resetSkinSiteSession()
setSkinSiteFrame(null) setSkinSiteFrame(null)
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage)
else Reflect.deleteProperty(globalThis, 'localStorage')
if (previous) Object.defineProperty(globalThis, 'window', previous) if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window') else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator) 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 helperSource = readFileSync(new URL('./instance.ts', import.meta.url), 'utf8')
const tauriSource = readFileSync( 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', 'utf8',
) )
assert.match(querySource, /\['post-upgrade-notice', instanceId\]/) assert.match(querySource, /\['post-upgrade-notice', instanceId\]/)
@@ -1,7 +1,23 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test' 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') const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
@@ -414,8 +414,8 @@ export function skinSitePublicDonationSummary(): Promise<
return skinSiteRequest<SkinSitePublicDonationSummary>('donation/summary', { auth: false }) return skinSiteRequest<SkinSitePublicDonationSummary>('donation/summary', { auth: false })
} }
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSiteDonationPage>> { export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSitePublicDonationPage>> {
return skinSiteRequest<SkinSiteDonationPage>(`donation/list?limit=20&offset=${offset}`, { return skinSiteRequest<SkinSitePublicDonationPage>(`donation/list?limit=20&offset=${offset}`, {
auth: false, 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 { export interface SkinSiteLauncherUploadStatus {
id: string id: string
chunkSize: number 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 * 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). * 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. */ /** Crop the head (and hat overlay) from a locally downloaded skin data URL. */
export async function renderSkinHead(skinUrl: string, scale = 2): Promise<string> { export async function renderSkinHead(skinDataUrl: string, scale = 2): Promise<string> {
const img = await loadImage(skinUrl) const img = await loadImage(skinDataUrl)
const { canvas, ctx } = getBuffer() const { canvas, ctx } = getBuffer()
canvas.width = HEAD_BUFFER_SIZE 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). */ /** Fetch an image URL as a data URL (used to keep full skin textures local). */
export async function fetchImageAsDataUrl(url: string): Promise<string> { 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})`) if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`)
const blob = await response.blob() const blob = await response.blob()
return await new Promise<string>((resolve, reject) => { return await new Promise<string>((resolve, reject) => {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -10,6 +10,7 @@ export interface AppUpdate {
currentVersion?: string currentVersion?: string
publishedAt?: string publishedAt?: string
forceUpdate?: boolean forceUpdate?: boolean
rollback?: boolean
} }
interface UpdatePromptState { interface UpdatePromptState {
+11 -1
View File
@@ -1,7 +1,17 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test' 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', () => { test('applies the system hue with the custom accent saturation and lightness', () => {
const system = hexToHsl('#0078d4') const system = hexToHsl('#0078d4')
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta11" version = "1.0.2-beta13"
edition.workspace = true edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher" description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red" homepage = "https://www.ghs.red"
+4 -1
View File
@@ -43,6 +43,8 @@ struct UpdatePreferences {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos; mod macos;
#[cfg(feature = "updater")]
mod update_policy;
#[cfg(feature = "updater")] #[cfg(feature = "updater")]
mod updater_impl; mod updater_impl;
#[cfg(not(feature = "updater"))] #[cfg(not(feature = "updater"))]
@@ -1024,7 +1026,8 @@ fn main() {
s.restart_after_pending_update.load(Ordering::Relaxed) s.restart_after_pending_update.load(Ordering::Relaxed)
}) })
.unwrap_or(false); .unwrap_or(false);
if let Some((update, data)) = &*update_data.0.lock().unwrap() if let Some((update, data)) =
&update_data.0.lock().unwrap().pending
{ {
fn set_changelog_toast(version: Option<String>) { fn set_changelog_toast(version: Option<String>) {
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move { let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
+227
View File
@@ -0,0 +1,227 @@
use semver::Version;
pub fn requires_update(current: &Version, target: &Version) -> bool {
current != target
}
pub fn is_rollback(current: &Version, target: &Version) -> bool {
requires_update(current, target) && !is_remote_newer(current, target)
}
pub struct UpdateTarget<T> {
target: Option<String>,
check_revision: u64,
pub pending: Option<T>,
}
impl<T> Default for UpdateTarget<T> {
fn default() -> Self {
Self {
target: None,
check_revision: 0,
pending: None,
}
}
}
impl<T> UpdateTarget<T> {
pub fn begin_check(&mut self) -> u64 {
self.check_revision = self.check_revision.wrapping_add(1);
self.check_revision
}
pub fn select_checked(
&mut self,
revision: u64,
version: Option<&str>,
) -> bool {
if revision != self.check_revision {
return false;
}
self.select(version);
true
}
pub fn select(&mut self, version: Option<&str>) {
if self.target.as_deref() != version {
self.target = version.map(str::to_owned);
self.pending = None;
}
}
pub fn is_selected(&self, version: &str) -> bool {
self.target.as_deref() == Some(version)
}
pub fn enqueue(&mut self, version: &str, download: T) -> bool {
if !self.is_selected(version) {
return false;
}
self.pending = Some(download);
true
}
}
// semver compares prerelease identifiers lexically, so "beta10" < "beta9"
// (because the character '1' sorts before '9'). Our release names use a
// "betaN" scheme where N is a decimal counter, so a strict lexical compare
// would wrongly report an older beta as the latest. Use a comparator that
// treats the trailing digits of a prerelease identifier numerically.
fn is_remote_newer(current: &Version, remote: &Version) -> bool {
use std::cmp::Ordering;
let core = (remote.major, remote.minor, remote.patch).cmp(&(
current.major,
current.minor,
current.patch,
));
if core != Ordering::Equal {
return core == Ordering::Greater;
}
// Same x.y.z. A final release outranks any prerelease of that version.
match (current.pre.is_empty(), remote.pre.is_empty()) {
(true, true) => false,
(true, false) => false,
(false, true) => true,
(false, false) => {
compare_prerelease(remote.pre.as_str(), current.pre.as_str())
== Ordering::Greater
}
}
}
/// Split a prerelease identifier into its non-digit prefix and an optional
/// trailing decimal number (so "beta10" -> ("beta", Some(10))).
fn split_prerelease_identifier(segment: &str) -> (&str, Option<u64>) {
let digit_start = segment
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
match digit_start {
Some(i) => (&segment[..i], segment[i..].parse::<u64>().ok()),
None => (segment, None),
}
}
/// Compare two prerelease strings, treating a trailing run of ASCII digits
/// as a decimal number (so "beta10" > "beta9"). All other segments fall back
/// to a plain lexical comparison.
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let left_segments: Vec<&str> = left.split('.').collect();
let right_segments: Vec<&str> = right.split('.').collect();
for (l, r) in left_segments.iter().zip(right_segments.iter()) {
let (l_prefix, l_num) = split_prerelease_identifier(l);
let (r_prefix, r_num) = split_prerelease_identifier(r);
match (l_num, r_num) {
(Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
},
_ => {
let prefix_cmp = l_prefix.cmp(r_prefix);
if prefix_cmp != Ordering::Equal {
return prefix_cmp;
}
let num_cmp = l_num.cmp(&r_num);
if num_cmp != Ordering::Equal {
return num_cmp;
}
}
}
}
left_segments.len().cmp(&right_segments.len())
}
#[cfg(test)]
mod tests {
use super::*;
use semver::Version;
fn version(value: &str) -> Version {
Version::parse(value).unwrap()
}
#[test]
fn follows_the_server_target_in_both_directions() {
for (current, target) in [
("1.0.2-beta12", "1.0.2-beta9"),
("1.0.2-beta9", "1.0.2-beta12"),
("1.2.0", "1.1.0"),
("1.1.0", "1.2.0"),
("1.1.0", "1.1.0-beta1"),
] {
assert!(requires_update(&version(current), &version(target)));
}
assert!(!requires_update(
&version("1.0.2-beta12"),
&version("1.0.2-beta12")
));
}
#[test]
fn rollback_direction_uses_numeric_beta_suffixes() {
assert!(is_rollback(
&version("1.0.2-beta12"),
&version("1.0.2-beta9")
));
assert!(!is_rollback(
&version("1.0.2-beta9"),
&version("1.0.2-beta12")
));
assert!(is_rollback(&version("1.2.0"), &version("1.1.0")));
assert!(!is_rollback(&version("1.1.0"), &version("1.2.0")));
assert!(!is_rollback(&version("1.2.0"), &version("1.2.0")));
}
#[test]
fn changing_the_target_discards_an_already_downloaded_update() {
let mut state = UpdateTarget::default();
state.select(Some("1.0.2-beta12"));
assert!(state.enqueue("1.0.2-beta12", "new installer"));
state.select(Some("1.0.2-beta9"));
assert!(state.pending.is_none());
assert!(state.enqueue("1.0.2-beta9", "rollback installer"));
assert_eq!(state.pending, Some("rollback installer"));
}
#[test]
fn a_stale_download_cannot_replace_the_selected_rollback() {
let mut state = UpdateTarget::default();
state.select(Some("1.0.2-beta12"));
state.select(Some("1.0.2-beta9"));
assert!(state.enqueue("1.0.2-beta9", "rollback installer"));
assert!(!state.enqueue("1.0.2-beta12", "late installer"));
assert_eq!(state.pending, Some("rollback installer"));
}
#[test]
fn late_checks_cannot_reauthorize_a_superseded_version() {
let mut state = UpdateTarget::default();
let old_check = state.begin_check();
let rollback_check = state.begin_check();
assert!(state.select_checked(rollback_check, Some("1.0.2-beta9")));
assert!(state.enqueue("1.0.2-beta9", "rollback installer"));
assert!(!state.select_checked(old_check, Some("1.0.2-beta12")));
assert!(!state.enqueue("1.0.2-beta12", "late installer"));
assert_eq!(state.pending, Some("rollback installer"));
}
#[test]
fn repeated_checks_preserve_the_matching_download_and_no_update_clears_it()
{
let mut state = UpdateTarget::default();
state.select(Some("1.0.2-beta9"));
state.enqueue("1.0.2-beta9", "rollback installer");
state.select(Some("1.0.2-beta9"));
assert_eq!(state.pending, Some("rollback installer"));
state.select(None);
assert!(state.pending.is_none());
assert!(!state.enqueue("1.0.2-beta9", "late installer"));
}
}
+42 -82
View File
@@ -1,4 +1,5 @@
use crate::api::Result; use crate::api::Result;
use crate::update_policy::{UpdateTarget, is_rollback, requires_update};
use futures::StreamExt; use futures::StreamExt;
use semver::Version; use semver::Version;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -27,82 +28,6 @@ const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/";
const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration = const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15 * 60); std::time::Duration::from_secs(15 * 60);
// semver compares prerelease identifiers lexically, so "beta10" < "beta9"
// (because the character '1' sorts before '9'). Our release names use a
// "betaN" scheme where N is a decimal counter, so a strict lexical compare
// would wrongly report an older beta as the latest. Use a comparator that
// treats the trailing digits of a prerelease identifier numerically.
fn is_remote_newer(current: &Version, remote: &Version) -> bool {
use std::cmp::Ordering;
let core = (remote.major, remote.minor, remote.patch).cmp(&(
current.major,
current.minor,
current.patch,
));
if core != Ordering::Equal {
return core == Ordering::Greater;
}
// Same x.y.z. A final release outranks any prerelease of that version.
match (current.pre.is_empty(), remote.pre.is_empty()) {
(true, true) => false,
(true, false) => false,
(false, true) => true,
(false, false) => {
compare_prerelease(remote.pre.as_str(), current.pre.as_str())
== Ordering::Greater
}
}
}
/// Split a prerelease identifier into its non-digit prefix and an optional
/// trailing decimal number (so "beta10" -> ("beta", Some(10))).
fn split_prerelease_identifier(segment: &str) -> (&str, Option<u64>) {
let digit_start = segment
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
match digit_start {
Some(i) => (&segment[..i], segment[i..].parse::<u64>().ok()),
None => (segment, None),
}
}
/// Compare two prerelease strings, treating a trailing run of ASCII digits
/// as a decimal number (so "beta10" > "beta9"). All other segments fall back
/// to a plain lexical comparison.
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let left_segments: Vec<&str> = left.split('.').collect();
let right_segments: Vec<&str> = right.split('.').collect();
for (l, r) in left_segments.iter().zip(right_segments.iter()) {
let (l_prefix, l_num) = split_prerelease_identifier(l);
let (r_prefix, r_num) = split_prerelease_identifier(r);
match (l_num, r_num) {
(Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
},
_ => {
let prefix_cmp = l_prefix.cmp(r_prefix);
if prefix_cmp != Ordering::Equal {
return prefix_cmp;
}
let num_cmp = l_num.cmp(&r_num);
if num_cmp != Ordering::Equal {
return num_cmp;
}
}
}
}
left_segments.len().cmp(&right_segments.len())
}
// ── Shared types ───────────────────────────────────────────────── // ── Shared types ─────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
@@ -115,11 +40,12 @@ pub struct UpdateMetadata {
body: Option<String>, body: Option<String>,
published_at: Option<String>, published_at: Option<String>,
force_update: bool, force_update: bool,
rollback: bool,
raw_json: serde_json::Value, raw_json: serde_json::Value,
} }
#[derive(Default)] #[derive(Default)]
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>); pub struct PendingUpdateData(pub Mutex<UpdateTarget<(Arc<Update>, Vec<u8>)>>);
// ── Update Server API types ───────────────────────────────────── // ── Update Server API types ─────────────────────────────────────
@@ -307,7 +233,7 @@ async fn check_with_endpoints<R: Runtime>(
.header("X-Axolotl-Platform", platform)? .header("X-Axolotl-Platform", platform)?
.header("X-Axolotl-Version", current_version)? .header("X-Axolotl-Version", current_version)?
.version_comparator(|current, release| { .version_comparator(|current, release| {
is_remote_newer(&current, &release.version) requires_update(&current, &release.version)
}); });
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -345,7 +271,20 @@ async fn check_with_updater<R: Runtime>(
webview: &Webview<R>, webview: &Webview<R>,
channel: &str, channel: &str,
) -> Result<Option<UpdateMetadata>> { ) -> Result<Option<UpdateMetadata>> {
let Some(mut update) = check_with_endpoints(webview, channel).await? else { let pending_data = webview.state::<PendingUpdateData>();
let revision = pending_data.0.lock().unwrap().begin_check();
let checked = check_with_endpoints(webview, channel).await?;
if !pending_data.0.lock().unwrap().select_checked(
revision,
checked.as_ref().map(|update| update.version.as_str()),
) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"A newer launcher update check superseded this response"
.to_string(),
))
.into());
}
let Some(mut update) = checked else {
return Ok(None); return Ok(None);
}; };
update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT); update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT);
@@ -369,6 +308,10 @@ async fn check_with_updater<R: Runtime>(
.and_then(serde_json::Value::as_bool) .and_then(serde_json::Value::as_bool)
.unwrap_or(false); .unwrap_or(false);
let metadata = UpdateMetadata { let metadata = UpdateMetadata {
rollback: is_rollback(
&Version::parse(&update.current_version).map_err(Error::from)?,
&Version::parse(&update.version).map_err(Error::from)?,
),
rid: webview.resources_table().add(update.clone()), rid: webview.resources_table().add(update.clone()),
current_version: update.current_version.clone(), current_version: update.current_version.clone(),
version: update.version.clone(), version: update.version.clone(),
@@ -449,6 +392,12 @@ pub async fn enqueue_update_for_installation<R: Runtime>(
let update = webview.resources_table().get::<Update>(rid)?; let update = webview.resources_table().get::<Update>(rid)?;
if !pending_data.0.lock().unwrap().is_selected(&update.version) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"The server-selected launcher version changed; check for updates again".to_string(),
)).into());
}
let progress = init_loading( let progress = init_loading(
LoadingBarType::LauncherUpdate { LoadingBarType::LauncherUpdate {
version: update.version.clone(), version: update.version.clone(),
@@ -565,11 +514,19 @@ pub async fn enqueue_update_for_installation<R: Runtime>(
let download_duration = download_start.elapsed(); let download_duration = download_start.elapsed();
tracing::info!("Downloaded update in {download_duration:?}"); tracing::info!("Downloaded update in {download_duration:?}");
pending_data let version = update.version.clone();
if !pending_data
.0 .0
.lock() .lock()
.unwrap() .unwrap()
.replace((update, update_data)); .enqueue(&version, (update, update_data))
{
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"The server-selected launcher version changed during download"
.to_string(),
))
.into());
}
Ok(()) Ok(())
} }
@@ -577,7 +534,9 @@ pub async fn enqueue_update_for_installation<R: Runtime>(
#[tauri::command] #[tauri::command]
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) { pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
let pending_data = webview.state::<PendingUpdateData>().inner(); let pending_data = webview.state::<PendingUpdateData>().inner();
pending_data.0.lock().unwrap().take(); let mut pending = pending_data.0.lock().unwrap();
pending.begin_check();
pending.select(None);
} }
// ── Debian / derivatives apt update ───────────────────────────── // ── Debian / derivatives apt update ─────────────────────────────
@@ -630,6 +589,7 @@ pub async fn install_apt_package(version: &str, data: &[u8]) -> Result<()> {
.arg("apt") .arg("apt")
.arg("install") .arg("install")
.arg("-y") .arg("-y")
.arg("--allow-downgrades")
.arg(&install_path) .arg(&install_path)
.output() .output()
}) })
+1
View File
@@ -21,6 +21,7 @@
"icons/AxolotlAssets.car" "icons/AxolotlAssets.car"
], ],
"windows": { "windows": {
"allowDowngrades": true,
"nsis": { "nsis": {
"installMode": "currentUser", "installMode": "currentUser",
"installerHooks": "./nsis/hooks.nsi" "installerHooks": "./nsis/hooks.nsi"
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus" name = "theseus"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta11" version = "1.0.2-beta13"
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
@@ -49,7 +49,11 @@ try {
tag, tag,
outputPath, outputPath,
], ],
{ cwd: root, encoding: 'utf8' }, {
cwd: root,
encoding: 'utf8',
env: { ...process.env, GITHUB_REPOSITORY: 'Mystic-Stars/Axolotl' },
},
) )
assert.equal(create.status, 0, create.stderr) assert.equal(create.status, 0, create.stderr)