From c44fb1a0a5e748b26d8e1cff5445d2eac8271a6c Mon Sep 17 00:00:00 2001 From: Disy Date: Wed, 21 Oct 2026 09:16:55 +0800 Subject: [PATCH 1/3] chore: release 1.0.2-beta12 --- Cargo.lock | 4 +- apps/app-frontend/package.json | 150 +++++------ apps/app-frontend/src/App.vue | 94 ++++--- .../app-frontend/src/announcements/catalog.ts | 28 +++ .../src/components/ui/AccountsCard.test.ts | 153 +++++++---- .../src/components/ui/AccountsCard.vue | 40 ++- .../ui/settings/settings-search.test.ts | 8 +- .../skin-site/SkinSiteAnnouncements.test.ts | 2 +- .../ui/skin-site/SkinSiteAnnouncements.vue | 77 ++++-- .../ui/skin-site/SkinSiteDonationCard.vue | 81 +++++- .../skin-site/SkinSiteDonationModal.test.ts | 133 ++++++++++ .../ui/skin-site/SkinSiteDonationModal.vue | 190 ++++++++++++++ .../components/ui/skin-site/SkinSiteHome.vue | 128 +++++----- .../ui/skin-site/SkinSiteNavCards.vue | 88 ++----- .../skin-site/SkinSitePlayerPreview.test.ts | 194 ++++++++++++++ .../ui/skin-site/SkinSitePlayerPreview.vue | 238 ++++++++++++++++++ .../ui/skin-site/SkinSiteSelectionRail.vue | 190 ++++++++++++++ .../ui/skin-site/admin/AdminLauncherPage.vue | 54 +++- .../ui/skin-site/donation-format.test.ts | 20 ++ .../ui/skin-site/donation-format.ts | 10 + .../src/composables/skin-site-session.test.ts | 61 ++++- .../src/composables/skin-site-session.ts | 6 +- .../src/helpers/app-update-policy.test.ts | 27 ++ .../src/helpers/app-update-policy.ts | 11 + .../src/helpers/instance-player.test.ts | 72 +++--- .../src/helpers/post-upgrade-notice.test.ts | 2 +- .../src/helpers/skin-armor-preview.test.ts | 18 +- .../app-frontend/src/helpers/skin-site-api.ts | 8 +- .../src/helpers/skin-site-render.test.ts | 65 +++++ .../src/helpers/skin-site-render.ts | 10 +- .../app-frontend/src/locales/en-US/index.json | 30 ++- .../app-frontend/src/locales/zh-CN/index.json | 24 +- apps/app-frontend/src/providers/app-update.ts | 1 + apps/app-frontend/src/store/theme.test.ts | 12 +- apps/app/Cargo.toml | 2 +- apps/app/src/main.rs | 5 +- apps/app/src/update_policy.rs | 227 +++++++++++++++++ apps/app/src/updater_impl.rs | 124 ++++----- apps/app/tauri.conf.json | 1 + packages/app-lib/Cargo.toml | 2 +- .../axolotl/create-update-manifest.test.mjs | 6 +- scripts/axolotl/skin-site-i18n.test.mjs | 2 +- scripts/axolotl/skin-site-messages.mjs | 2 +- 43 files changed, 2130 insertions(+), 470 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationModal.test.ts create mode 100644 apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationModal.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/SkinSitePlayerPreview.test.ts create mode 100644 apps/app-frontend/src/components/ui/skin-site/SkinSitePlayerPreview.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/SkinSiteSelectionRail.vue create mode 100644 apps/app-frontend/src/components/ui/skin-site/donation-format.test.ts create mode 100644 apps/app-frontend/src/components/ui/skin-site/donation-format.ts create mode 100644 apps/app-frontend/src/helpers/app-update-policy.test.ts create mode 100644 apps/app-frontend/src/helpers/app-update-policy.ts create mode 100644 apps/app-frontend/src/helpers/skin-site-render.test.ts create mode 100644 apps/app/src/update_policy.rs diff --git a/Cargo.lock b/Cargo.lock index eac8fb8..88081b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6841,7 +6841,7 @@ dependencies = [ [[package]] name = "theseus" -version = "1.0.2-beta11" +version = "1.0.2-beta12" dependencies = [ "ariadne", "async-minecraft-ping", @@ -6945,7 +6945,7 @@ dependencies = [ [[package]] name = "theseus_gui" -version = "1.0.2-beta11" +version = "1.0.2-beta12" dependencies = [ "ashpd", "async_zip", diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 08cca45..610814b 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -1,77 +1,77 @@ { - "name": "@modrinth/app-frontend", - "private": true, - "version": "1.0.2-beta11", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vue-tsc --noEmit && vite build", - "tsc:check": "vue-tsc --noEmit", - "lint": "eslint . && prettier --check .", - "fix": "eslint . --fix && prettier --write .", - "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", - "intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend", - "test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\"" - }, - "dependencies": { - "@dnd-kit/core": "6.3.1", - "@dnd-kit/utilities": "3.2.2", - "@dnd-kit/vue": "^0.5.0", - "@lobehub/icons-static-svg": "1.94.0", - "@modrinth/api-client": "workspace:^", - "@modrinth/assets": "workspace:*", - "@modrinth/server": "workspace:*", - "@modrinth/ui": "workspace:*", - "@modrinth/utils": "workspace:*", - "@sfirew/minecraft-motd-parser": "^1.1.6", - "@tanstack/vue-query": "5.90.7", - "@tauri-apps/api": "^2.5.0", - "@tauri-apps/plugin-dialog": "^2.2.1", - "@tauri-apps/plugin-fs": "^2.4.5", - "@tauri-apps/plugin-http": "~2.5.7", - "@tauri-apps/plugin-opener": "^2.2.6", - "@tauri-apps/plugin-os": "^2.2.1", - "@tauri-apps/plugin-updater": "^2.7.1", - "@tauri-apps/plugin-window-state": "^2.2.2", - "@types/three": "^0.172.0", - "@vueuse/core": "^11.1.0", - "dayjs": "^1.11.10", - "deepslate": "0.26.0", - "fflate": "0.8.2", - "floating-vue": "^5.2.2", - "fuse.js": "^6.6.2", - "intl-messageformat": "^10.7.7", - "monaco-editor": "^0.56.0", - "ofetch": "^1.3.4", - "overlayscrollbars": "^2.15.1", - "pinia": "^3.0.0", - "three": "^0.172.0", - "vite-svg-loader": "^5.1.0", - "vue": "^3.5.13", - "vue-i18n": "^10.0.0", - "vue-router": "^4.6.0", - "vuedraggable": "^4.1.0", - "xxhash-wasm": "1.1.0" - }, - "devDependencies": { - "@eslint/compat": "^1.1.1", - "@formatjs/cli": "^6.2.12", - "@modrinth/tooling-config": "workspace:*", - "@nuxt/eslint-config": "^0.5.6", - "@vitejs/plugin-vue": "^6.0.3", - "autoprefixer": "^10.4.19", - "eslint": "^9.9.1", - "eslint-plugin-turbo": "^2.5.4", - "postcss": "^8.4.39", - "prettier": "^3.2.5", - "sass": "^1.74.1", - "tailwindcss": "^3.4.4", - "typescript": "^5.5.4", - "vite": "^8.0.0", - "vite-plugin-vue-devtools": "^8.2.1", - "vue-component-type-helpers": "^3.1.8", - "vue-tsc": "^2.1.6" - }, - "packageManager": "pnpm@9.4.0", - "web-types": "../../web-types.json" + "name": "@modrinth/app-frontend", + "private": true, + "version": "1.0.2-beta12", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "tsc:check": "vue-tsc --noEmit", + "lint": "eslint . && prettier --check .", + "fix": "eslint . --fix && prettier --write .", + "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", + "intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend", + "test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\"" + }, + "dependencies": { + "@dnd-kit/core": "6.3.1", + "@dnd-kit/utilities": "3.2.2", + "@dnd-kit/vue": "^0.5.0", + "@lobehub/icons-static-svg": "1.94.0", + "@modrinth/api-client": "workspace:^", + "@modrinth/assets": "workspace:*", + "@modrinth/server": "workspace:*", + "@modrinth/ui": "workspace:*", + "@modrinth/utils": "workspace:*", + "@sfirew/minecraft-motd-parser": "^1.1.6", + "@tanstack/vue-query": "5.90.7", + "@tauri-apps/api": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.2.1", + "@tauri-apps/plugin-fs": "^2.4.5", + "@tauri-apps/plugin-http": "~2.5.7", + "@tauri-apps/plugin-opener": "^2.2.6", + "@tauri-apps/plugin-os": "^2.2.1", + "@tauri-apps/plugin-updater": "^2.7.1", + "@tauri-apps/plugin-window-state": "^2.2.2", + "@types/three": "^0.172.0", + "@vueuse/core": "^11.1.0", + "dayjs": "^1.11.10", + "deepslate": "0.26.0", + "fflate": "0.8.2", + "floating-vue": "^5.2.2", + "fuse.js": "^6.6.2", + "intl-messageformat": "^10.7.7", + "monaco-editor": "^0.56.0", + "ofetch": "^1.3.4", + "overlayscrollbars": "^2.15.1", + "pinia": "^3.0.0", + "three": "^0.172.0", + "vite-svg-loader": "^5.1.0", + "vue": "^3.5.13", + "vue-i18n": "^10.0.0", + "vue-router": "^4.6.0", + "vuedraggable": "^4.1.0", + "xxhash-wasm": "1.1.0" + }, + "devDependencies": { + "@eslint/compat": "^1.1.1", + "@formatjs/cli": "^6.2.12", + "@modrinth/tooling-config": "workspace:*", + "@nuxt/eslint-config": "^0.5.6", + "@vitejs/plugin-vue": "^6.0.3", + "autoprefixer": "^10.4.19", + "eslint": "^9.9.1", + "eslint-plugin-turbo": "^2.5.4", + "postcss": "^8.4.39", + "prettier": "^3.2.5", + "sass": "^1.74.1", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.4", + "vite": "^8.0.0", + "vite-plugin-vue-devtools": "^8.2.1", + "vue-component-type-helpers": "^3.1.8", + "vue-tsc": "^2.1.6" + }, + "packageManager": "pnpm@9.4.0", + "web-types": "../../web-types.json" } diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 61494f1..5194012 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -134,6 +134,7 @@ import { import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts' import { get_opening_command, initialize_state } from '@/helpers/state' import { installUiScale, setUiScale } from '@/helpers/ui-scale' +import { shouldDelayAppUpdate } from '@/helpers/app-update-policy' import { areUpdatesEnabled, backupAppDbForUpdate, @@ -1998,34 +1999,55 @@ function showDelayedUpdatePopup() { } let lastUpdateChannel = 'release' +let updateCheckRevision = 0 + +async function clearAvailableUpdate(isCurrent) { + const unlisten = unlistenUpdateDownload + unlistenUpdateDownload = null + await unlisten?.() + if (!isCurrent()) return + availableUpdate.value = null + updateSize.value = null + appUpdateDownload.progress.value = 0 + finishedDownloading.value = false + downloading.value = false + clearDelayedUpdatePopup() +} async function performUpdateCheck() { + const revision = ++updateCheckRevision + const isLatestCheck = () => revision === updateCheckRevision + const currentResult = () => (availableUpdate.value ? 'available' : 'up-to-date') const channel = await getUpdateChannel() const preferences = await getUpdatePreferences() + if (!isLatestCheck()) return currentResult() updatesPaused.value = preferences.updatesPaused if (updatesPaused.value) return 'paused' if (channel !== lastUpdateChannel) { - availableUpdate.value = null - updateSize.value = null - appUpdateDownload.progress.value = 0 - finishedDownloading.value = false - downloading.value = false + await clearAvailableUpdate(isLatestCheck) + if (!isLatestCheck()) return currentResult() lastUpdateChannel = channel } - const update = await checkAppUpdate(channel) + let update + try { + update = await checkAppUpdate(channel) + } catch (error) { + if (!isLatestCheck()) return currentResult() + throw error + } + if (!isLatestCheck()) return currentResult() + if (!update || update.version !== availableUpdate.value?.version) { + await clearAvailableUpdate(isLatestCheck) + } + if (!isLatestCheck()) return currentResult() if (!update) { console.log('No update available') return 'up-to-date' } const publishedAt = Date.parse(update.publishedAt ?? '') - if ( - channel === 'release' && - !preferences.immediateUpdateFetch && - !update.forceUpdate && - (!Number.isFinite(publishedAt) || Date.now() < publishedAt + 24 * 60 * 60 * 1000) - ) { + if (shouldDelayAppUpdate(update, channel, preferences.immediateUpdateFetch)) { console.warn( Number.isFinite(publishedAt) ? `Update ${update.version} is waiting for the release delay.` @@ -2042,6 +2064,9 @@ async function performUpdateCheck() { return 'available' } + const meteredNetwork = await isNetworkMetered() + if (!isLatestCheck()) return currentResult() + appUpdateDownload.progress.value = 0 finishedDownloading.value = false downloading.value = false @@ -2050,7 +2075,7 @@ async function performUpdateCheck() { console.log(`Update ${update.version} is available.`) - metered.value = await isNetworkMetered() + metered.value = meteredNetwork if (!metered.value) { console.log('Starting download of update') @@ -2062,7 +2087,9 @@ async function performUpdateCheck() { } getUpdateSize(update.rid) - .then((size) => (updateSize.value = size)) + .then((size) => { + if (availableUpdate.value?.rid === update.rid) updateSize.value = size + }) .catch((error) => console.warn('Failed to fetch update size', error)) return 'available' } @@ -2092,6 +2119,7 @@ async function downloadUpdate(versionToDownload) { handleError(`Failed to download update: no version available`) return } + if (availableUpdate.value?.rid !== versionToDownload.rid) return if (downloading.value || appUpdateDownload.progress.value !== 0) { console.error(`Update ${versionToDownload.version} already downloading`) @@ -2100,35 +2128,27 @@ async function downloadUpdate(versionToDownload) { console.log(`Downloading update ${versionToDownload.version} from Update Server`) downloading.value = true + const isCurrent = () => availableUpdate.value?.rid === versionToDownload.rid + let unlisten try { - enqueueUpdateForInstallation(versionToDownload.rid) - .then(() => { - downloading.value = false - finishedDownloading.value = true - unlistenUpdateDownload?.().then(() => { - unlistenUpdateDownload = null - }) - console.log('Finished downloading!') - markAppUpdateActionable(versionToDownload.version, 'downloaded') - scheduleDelayedUpdatePopup() - }) - .catch((error) => { - downloading.value = false - appUpdateDownload.progress.value = 0 - unlistenUpdateDownload?.().then(() => { - unlistenUpdateDownload = null - }) - handleError(error) - }) - unlistenUpdateDownload = await subscribeToDownloadProgress( - appUpdateDownload, - versionToDownload.version, - ) + unlisten = await subscribeToDownloadProgress(appUpdateDownload, versionToDownload.version) + if (!isCurrent()) return + unlistenUpdateDownload = unlisten + await enqueueUpdateForInstallation(versionToDownload.rid) + if (!isCurrent()) return + downloading.value = false + finishedDownloading.value = true + markAppUpdateActionable(versionToDownload.version, 'downloaded') + scheduleDelayedUpdatePopup() } catch (error) { + if (!isCurrent()) return downloading.value = false appUpdateDownload.progress.value = 0 handleError(error) + } finally { + await unlisten?.() + if (unlistenUpdateDownload === unlisten) unlistenUpdateDownload = null } } diff --git a/apps/app-frontend/src/announcements/catalog.ts b/apps/app-frontend/src/announcements/catalog.ts index 9df0a57..a230590 100644 --- a/apps/app-frontend/src/announcements/catalog.ts +++ b/apps/app-frontend/src/announcements/catalog.ts @@ -32,6 +32,34 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [ ] export const launcherAnnouncements: readonly LauncherAnnouncement[] = [ + { + id: 'launcher-1.0.2-beta12', + version: '1.0.2-beta12', + publishedAt: '2026-10-21', + title: { + 'en-US': 'Starlight Launcher 1.0.2-beta12', + 'zh-CN': 'Starlight Launcher 1.0.2-beta12', + }, + changes: { + fixed: [ + { + 'en-US': + 'Fixed automatic updates ignoring server rollbacks to an earlier launcher version.', + 'zh-CN': '修复启动器忽略服务端旧版本回滚的问题,回滚后可自动下载并在退出或重启时安装。', + }, + { + 'en-US': + 'Fixed outdated update checks and downloads overriding a newly selected update version.', + 'zh-CN': '修复旧的更新检查或下载结果覆盖新目标版本的问题。', + }, + ], + }, + notes: { + 'en-US': + 'Install this version once to enable automatic rollback support for subsequent updates.', + 'zh-CN': '需要先安装一次此版本,之后才能自动跟随服务端回滚。', + }, + }, { id: 'launcher-1.0.2-beta11', version: '1.0.2-beta11', diff --git a/apps/app-frontend/src/components/ui/AccountsCard.test.ts b/apps/app-frontend/src/components/ui/AccountsCard.test.ts index b3e8835..b895699 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.test.ts +++ b/apps/app-frontend/src/components/ui/AccountsCard.test.ts @@ -5,12 +5,6 @@ import test from 'node:test' import { createRenderer, h, nextTick, Suspense } from 'vue' -import { - receiveSkinSiteMessage, - resetSkinSiteSession, - setSkinSiteFrame, - SKIN_SITE_ORIGIN, -} from '../../composables/skin-site-session.ts' import { useNetworkStatus } from '../../composables/useNetworkStatus.ts' // Exercise the real component's setup and reactive account list. Only platform, @@ -19,6 +13,7 @@ const require = createRequire(import.meta.url) const { parse, compileScript } = require('vue/compiler-sfc') const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href const stubs: Record = { + '@tauri-apps/plugin-http': `export const fetch=(...args)=>globalThis.accountTestFetch(...args)`, '@modrinth/assets': `export const CopyIcon={}, LogInIcon={}, PlusIcon={}, RadioButtonCheckedIcon={}, RadioButtonIcon={}, RefreshCwIcon={}, SpinnerIcon={}, TrashIcon={}`, '@modrinth/ui': `export const Accordion={}, Avatar={}, ButtonStyled={}; export const defineMessages=x=>x; export const injectNotificationManager=()=>({handleError:()=>{}}); export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})`, '@tanstack/vue-query': `export const useQueryClient=()=>({refetchQueries:async()=>{}})`, @@ -26,6 +21,7 @@ const stubs: Record = { 'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`, '@/assets/netherstar.png': `export default 'logo'`, '@/components/ui/MinecraftLoginModal.vue': `export default {}`, + '@/components/ui/skin-site/SkinSiteLoginModal.vue': `export default {}`, '@/helpers/events': `export const process_listener=async()=>()=>{}`, '@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`, '@/helpers/skins': `export const get_available_skins=async()=>[]`, @@ -59,6 +55,13 @@ const hooks = registerHooks({ }, }) const { default: AccountsCard } = await import(componentUrl) +const { + resetSkinSiteSession, + signInSkinSite, + signOutSkinSite, + selectedSkinSitePlayerId, + SKIN_SITE_ORIGIN, +} = await import('../../composables/skin-site-session.ts') hooks.deregister() type TestNode = Record @@ -72,6 +75,7 @@ type CardState = { defaultUser: string | undefined selectedAccount: TestAccount | undefined setAccount: (account: TestAccount) => Promise + setSkinSitePlayer: (id: string) => Promise getAccountAvatarUrl: (account: TestAccount) => string | undefined } const renderer = createRenderer({ @@ -93,37 +97,39 @@ const flush = async () => { const firstId = '12345678-1234-1234-1234-123456789abc' const secondId = '22345678-1234-1234-1234-123456789abc' -async function fixture(hasOfficial = true) { +async function fixture(hasOfficial = true, officialId = 'official', linkedMojang = false) { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') const stored: TestAccount[] = hasOfficial - ? [{ account_type: 'microsoft', profile: { id: 'official', name: 'Official' } }] + ? [{ account_type: 'microsoft', profile: { id: officialId, name: 'Official' } }] : [] - let selected: string | undefined = hasOfficial ? 'official' : undefined + let selected: string | undefined = hasOfficial ? officialId : undefined let rejectLogin = false let loginCalls = 0 - const frame = { - postMessage(data: { type: string; requestId: string }) { - if (data.type === 'starlight-pack-token-request') - message({ - type: 'starlight-pack-token-result', - requestId: data.requestId, - token: 'fixture.jwt', - }) - if (data.type === 'starlight-skin-players-request') - message({ - type: 'starlight-skin-players-result', - requestId: data.requestId, - ok: true, - players: [ - { uuid: firstId, name: 'First', isMojang: false, skinState: 'empty' }, - { uuid: secondId, name: 'Second', isMojang: false, skinState: 'empty' }, - ], - }) + const storage = new Map() + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), }, - } as unknown as Window - function message(data: unknown) { - receiveSkinSiteMessage({ origin: SKIN_SITE_ORIGIN, source: frame, data } as MessageEvent, frame) + }) + ;( + globalThis as unknown as { accountTestFetch: (url: string) => Promise } + ).accountTestFetch = async (url) => { + const path = new URL(url).pathname + const payload = path.endsWith('/login') + ? { username: 'Owner', jwt: 'fixture.jwt' } + : path.endsWith('/user') + ? { uuid: 'owner', username: 'Owner', permissions: [] } + : path.endsWith('/player') + ? [ + { uuid: firstId, name: 'First', isMojang: linkedMojang || firstId === officialId }, + { uuid: secondId, name: 'Second', isMojang: false }, + ] + : {} + return Response.json({ payload }) } Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } }) useNetworkStatus().refreshBrowserOffline() @@ -163,22 +169,18 @@ async function fixture(hasOfficial = true) { }, }) resetSkinSiteSession() - setSkinSiteFrame(frame) const node = h({ ...AccountsCard, render: () => null }) const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) }) app.mount({}) await flush() return { state: (node.component! as unknown as { setupState: CardState }).setupState, - login() { - message({ - type: 'starlight-skin-session', - status: 'signed-in', - user: { uuid: 'owner', username: 'Owner' }, - }) + async login() { + await signInSkinSite({ username: 'Owner', password: 'password', clientId: 'test' }) + await flush() }, signOut() { - message({ type: 'starlight-skin-session', status: 'signed-out' }) + signOutSkinSite() }, failLogin(value: boolean) { rejectLogin = value @@ -192,7 +194,7 @@ async function fixture(hasOfficial = true) { close() { app.unmount() resetSkinSiteSession() - setSkinSiteFrame(null) + Reflect.deleteProperty(globalThis, 'localStorage') if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) else Reflect.deleteProperty(globalThis, 'window') if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator) @@ -205,7 +207,7 @@ test('sidebar offers every skin-site player before any game authentication or la const view = await fixture(false) try { view.failLogin(true) - view.login() + await view.login() await flush() assert.deepEqual( view.state.accounts.map((account) => account.profile.id), @@ -221,7 +223,7 @@ test('sidebar offers every skin-site player before any game authentication or la test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => { const view = await fixture() try { - view.login() + await view.login() await flush() await view.state.setAccount( view.state.accounts.find((account) => account.profile.id === secondId)!, @@ -238,7 +240,7 @@ test('choosing an unused skin-site player authenticates and persists the default test('failed player authentication preserves the default and allows retry', async () => { const view = await fixture() try { - view.login() + await view.login() await flush() view.failLogin(true) await view.state.setAccount( @@ -259,7 +261,7 @@ test('failed player authentication preserves the default and allows retry', asyn test('signing out removes unregistered choices while keeping saved accounts', async () => { const view = await fixture() try { - view.login() + await view.login() await flush() await view.state.setAccount( view.state.accounts.find((account) => account.profile.id === firstId)!, @@ -271,7 +273,7 @@ test('signing out removes unregistered choices while keeping saved accounts', as [firstId, 'official'], ) assert.equal(view.selected, firstId) - view.login() + await view.login() await flush() assert.equal(view.state.accounts.length, 3) assert.equal(view.loginCalls, 1) @@ -283,10 +285,71 @@ test('signing out removes unregistered choices while keeping saved accounts', as test('a new player without a skin can render the shared empty avatar', async () => { const view = await fixture(false) try { - view.login() + await view.login() await flush() assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined) } finally { view.close() } }) + +test('choosing a player from the home avatar rail switches the persisted account and shared skin selection', async () => { + const view = await fixture() + try { + await view.login() + await view.state.setSkinSitePlayer(secondId) + assert.equal(view.selected, secondId) + assert.equal(view.state.selectedAccount?.profile.id, secondId) + assert.equal(selectedSkinSitePlayerId.value, secondId) + await view.state.setAccount( + view.state.accounts.find((account) => account.profile.id === 'official')!, + ) + assert.equal(view.selected, 'official') + assert.equal(selectedSkinSitePlayerId.value, null) + } finally { + view.close() + } +}) + +test('failed home player selection keeps the current skin selection as well as the account', async () => { + const view = await fixture() + try { + await view.login() + await view.state.setSkinSitePlayer(firstId) + view.failLogin(true) + await view.state.setSkinSitePlayer(secondId) + assert.equal(view.selected, firstId) + assert.equal(selectedSkinSitePlayerId.value, firstId) + } finally { + view.close() + } +}) + +test('a Microsoft player linked on the skin site keeps official skin and cape management', async () => { + const view = await fixture(true, firstId) + try { + await view.login() + assert.equal(selectedSkinSitePlayerId.value, null) + await view.state.setSkinSitePlayer(secondId) + await view.state.setAccount( + view.state.accounts.find((account) => account.profile.id === firstId)!, + ) + assert.equal(view.selected, firstId) + assert.equal(selectedSkinSitePlayerId.value, null) + } finally { + view.close() + } +}) + +test('site players linked to Mojang remain selectable without a saved Microsoft credential', async () => { + const view = await fixture(false, 'official', true) + try { + await view.login() + assert.equal(view.state.accounts.length, 2) + await view.state.setSkinSitePlayer(firstId) + assert.equal(view.selected, firstId) + assert.equal(selectedSkinSitePlayerId.value, firstId) + } finally { + view.close() + } +}) diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index 7727808..1623529 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -373,7 +373,6 @@ const accounts = computed(() => { ) if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) { for (const player of skinSitePlayers.value) { - if (player.isMojang) continue const id = player.uuid.replaceAll('-', '').toLowerCase() const stored = byId.get(id) if (stored && stored.account_type !== 'yggdrasil') continue @@ -409,8 +408,12 @@ async function refreshSkinSitePlayers() { await requestSkinSitePlayers().catch(() => {}) } -function setSkinSitePlayer(playerId: string) { - selectSkinSitePlayer(playerId) +async function setSkinSitePlayer(playerId: string) { + const normalizedId = playerId.replaceAll('-', '').toLowerCase() + const account = accounts.value.find( + (candidate) => candidate.profile.id.replaceAll('-', '').toLowerCase() === normalizedId, + ) + if (account) await setAccount(account) } const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const @@ -531,6 +534,12 @@ function setLoginDisabled(value: boolean) { } defineExpose({ + accounts, + defaultUser, + accountSelectionPending, + setAccount, + getAccountSkin, + getAccountAvatarUrl, accountChangeRevision, login, refreshValues, @@ -669,7 +678,15 @@ async function setAccount(account: MinecraftCredential) { }) } await persistDefaultUser(userId) - selectSkinSitePlayer(null) + const sitePlayer = + account.account_type === 'yggdrasil' + ? skinSitePlayers.value.find( + (player) => + player.uuid.replaceAll('-', '').toLowerCase() === + userId.replaceAll('-', '').toLowerCase(), + ) + : undefined + selectSkinSitePlayer(sitePlayer?.uuid ?? null) defaultUser.value = userId equippedSkin.value = null notifyAccountChange() @@ -682,9 +699,20 @@ async function setAccount(account: MinecraftCredential) { } watch( - [skinSitePlayers, defaultUser, skinSiteUser], + [skinSitePlayers, defaultUser, skinSiteUser, accounts], ([availablePlayers, selectedLocalUser]) => { - if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) { + if (selectedLocalUser) { + const account = accounts.value.find((candidate) => candidate.profile.id === selectedLocalUser) + const match = + account?.account_type === 'yggdrasil' + ? availablePlayers.find( + (player) => + player.uuid.replaceAll('-', '').toLowerCase() === + selectedLocalUser.replaceAll('-', '').toLowerCase(), + ) + : undefined + selectSkinSitePlayer(match?.uuid ?? null) + } else if (!selectedSkinSitePlayerId.value && availablePlayers.length > 0) { selectSkinSitePlayer(availablePlayers[0].uuid) } }, diff --git a/apps/app-frontend/src/components/ui/settings/settings-search.test.ts b/apps/app-frontend/src/components/ui/settings/settings-search.test.ts index 6e2803c..2fb08bf 100644 --- a/apps/app-frontend/src/components/ui/settings/settings-search.test.ts +++ b/apps/app-frontend/src/components/ui/settings/settings-search.test.ts @@ -21,6 +21,7 @@ import { const settingsComponentFiles = { interface: ['./AppearanceSettings.vue'], 'home-navigation': ['./AppearanceSettings.vue'], + 'shortcut-settings': ['./KeybindSettings.vue'], 'language-translation': ['./LanguageSettings.vue', './TranslationSettings.vue'], ai: ['./AISettings.vue'], 'java-performance': ['./JavaSettings.vue'], @@ -30,6 +31,7 @@ const settingsComponentFiles = { 'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'], updates: ['./UpdateSettings.vue'], about: ['./AboutSettings.vue'], + logs: ['./LogsSettings.vue'], 'feature-flags': ['./FeatureFlagSettings.vue'], } as const @@ -168,8 +170,7 @@ test('developer-only settings stay out of the normal search categories', () => { ) }) - - test('settings navigation groups preserve the intended Starlight information architecture', () => { +test('settings navigation groups preserve the intended Starlight information architecture', () => { const categoriesForGroup = ( group: 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer', developerMode = false, @@ -181,6 +182,7 @@ test('developer-only settings stay out of the normal search categories', () => { assert.deepEqual(categoriesForGroup('launcher'), [ 'interface', 'home-navigation', + 'shortcut-settings', 'language-translation', 'ai', ]) @@ -191,7 +193,7 @@ test('developer-only settings stay out of the normal search categories', () => { 'network-multiplayer', ]) assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups']) - assert.deepEqual(categoriesForGroup('support'), ['updates', 'about']) + assert.deepEqual(categoriesForGroup('support'), ['updates', 'about', 'logs']) assert.deepEqual(categoriesForGroup('developer'), []) assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags']) }) diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAnnouncements.test.ts b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAnnouncements.test.ts index f98422d..18b0567 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAnnouncements.test.ts +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAnnouncements.test.ts @@ -18,7 +18,7 @@ const require = createRequire(import.meta.url) const { parse, compileScript } = require('vue/compiler-sfc') const componentUrl = new URL('./SkinSiteAnnouncements.vue', import.meta.url).href const stubs: Record = { - '@modrinth/assets': 'export const SpinnerIcon={}', + '@modrinth/assets': 'export const NewspaperIcon={}, SpinnerIcon={}, StarIcon={}', '@modrinth/ui': 'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})', '@/helpers/skin-site-api': diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAnnouncements.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAnnouncements.vue index b5acbe0..af7951c 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAnnouncements.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAnnouncements.vue @@ -1,5 +1,5 @@ + + diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationCard.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationCard.vue index 137f397..6013135 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationCard.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationCard.vue @@ -1,5 +1,5 @@ + + diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationModal.test.ts b/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationModal.test.ts new file mode 100644 index 0000000..d421279 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationModal.test.ts @@ -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 +} +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 = { + '@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 +const renderer = createRenderer({ + 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 + 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) +}) diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationModal.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationModal.vue new file mode 100644 index 0000000..1c3421f --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteDonationModal.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteHome.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteHome.vue index 23fe86a..6b5bd37 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteHome.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteHome.vue @@ -1,9 +1,9 @@ + + diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteNavCards.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteNavCards.vue index 6148490..6043798 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteNavCards.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteNavCards.vue @@ -8,12 +8,16 @@ import { UserIcon, } from '@modrinth/assets' import { defineMessages, useVIntl } from '@modrinth/ui' -import type { Component } from 'vue' +import { computed } from 'vue' + +import SkinSiteSelectionRail from './SkinSiteSelectionRail.vue' + +defineProps<{ active: string }>() const { formatMessage } = useVIntl() const emit = defineEmits<{ - (e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki'): void + (e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki' | 'players'): void }>() const messages = defineMessages({ @@ -50,96 +54,56 @@ const messages = defineMessages({ }, }) -interface NavItem { - id: string - title: string - description: string - icon: Component - href: string -} - -const SKIN_SITE = 'https://skin.starlight.cool' - -const items: NavItem[] = [ +const destinations = ['downloads', 'review', 'wiki', 'bugs', 'map', 'players'] as const +const items = computed(() => [ { - id: 'download', - title: formatMessage(messages.download), + id: 'downloads', + label: formatMessage(messages.download), description: formatMessage(messages.downloadDesc), icon: DownloadIcon, - href: `${SKIN_SITE}/download`, }, { id: 'review', - title: formatMessage(messages.review), + label: formatMessage(messages.review), description: formatMessage(messages.reviewDesc), icon: ShieldCheckIcon, - href: `${SKIN_SITE}/review`, }, { id: 'wiki', - title: formatMessage(messages.wiki), + label: formatMessage(messages.wiki), description: formatMessage(messages.wikiDesc), icon: BookIcon, - href: `${SKIN_SITE}/wiki`, }, { id: 'bugs', - title: formatMessage(messages.bugs), + label: formatMessage(messages.bugs), description: formatMessage(messages.bugsDesc), icon: BugIcon, - href: `${SKIN_SITE}/bugs`, }, { id: 'map', - title: formatMessage(messages.map), + label: formatMessage(messages.map), description: formatMessage(messages.mapDesc), icon: MapIcon, - href: `${SKIN_SITE}/map`, }, { - id: 'profile', - title: formatMessage(messages.profile), + id: 'players', + label: formatMessage(messages.profile), description: formatMessage(messages.profileDesc), icon: UserIcon, - href: `${SKIN_SITE}/profile`, }, -] - -function open(item: NavItem) { - if ( - item.id === 'download' || - item.id === 'review' || - item.id === 'bugs' || - item.id === 'map' || - item.id === 'wiki' - ) { - emit( - 'navigate', - item.id === 'download' - ? 'downloads' - : (item.id as 'review' | 'bugs' | 'map' | 'wiki'), - ) - return - } - void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href)) +]) +function navigate(id: string) { + const destination = destinations.find((value) => value === id) + if (destination) emit('navigate', destination) } diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSitePlayerPreview.test.ts b/apps/app-frontend/src/components/ui/skin-site/SkinSitePlayerPreview.test.ts new file mode 100644 index 0000000..22e61c1 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSitePlayerPreview.test.ts @@ -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 +} +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 = { + '@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 +const renderer = createRenderer({ + 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>({ + 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', + ]) +}) diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSitePlayerPreview.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSitePlayerPreview.vue new file mode 100644 index 0000000..9e57033 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSitePlayerPreview.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteSelectionRail.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteSelectionRail.vue new file mode 100644 index 0000000..0617e3c --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteSelectionRail.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminLauncherPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminLauncherPage.vue index 5bf3987..d796023 100644 --- a/apps/app-frontend/src/components/ui/skin-site/admin/AdminLauncherPage.vue +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminLauncherPage.vue @@ -5,6 +5,7 @@ import { computed, onMounted, ref } from 'vue' import { skinSiteActivateLauncherRelease, + skinSiteDeleteLauncherRelease, type SkinSiteLauncherAdminState, skinSiteLauncherAdminState, type SkinSiteLauncherRelease, @@ -32,6 +33,19 @@ const messages = defineMessages({ active: { id: 'app.skin-site.admin.launcher.active', defaultMessage: 'Active' }, activate: { id: 'app.skin-site.admin.launcher.activate', defaultMessage: 'Activate' }, activated: { id: 'app.skin-site.admin.launcher.activated', defaultMessage: 'Release activated.' }, + deleteRelease: { + id: 'app.skin-site.admin.launcher.delete', + defaultMessage: 'Delete release', + }, + confirmDelete: { + id: 'app.skin-site.admin.launcher.confirm-delete', + defaultMessage: + 'Permanently delete launcher {version} and all its installation files from the server? This version will no longer be available for download or rollback. Installed launchers will not be affected.', + }, + deleted: { + id: 'app.skin-site.admin.launcher.deleted', + defaultMessage: 'Release and its installation files deleted.', + }, upload: { id: 'app.skin-site.admin.launcher.upload', defaultMessage: 'Upload release' }, uploadHint: { id: 'app.skin-site.admin.launcher.upload-hint', @@ -64,6 +78,10 @@ const uploadPercent = ref(0) const forceUpdate = ref(false) const fileInput = ref(null) const activatingId = ref(null) +const deletingId = ref(null) +const mutating = computed( + () => uploading.value || activatingId.value !== null || deletingId.value !== null, +) const releases = computed(() => state.value?.releases ?? []) const total = computed(() => releases.value.length) @@ -105,7 +123,7 @@ function nextPage() { } async function activate(release: SkinSiteLauncherRelease) { - if (activatingId.value !== null) return + if (release.active || mutating.value || loading.value) return activatingId.value = release.id try { const result = await skinSiteActivateLauncherRelease(release.id) @@ -119,6 +137,24 @@ async function activate(release: SkinSiteLauncherRelease) { } } +async function remove(release: SkinSiteLauncherRelease) { + if (release.active || mutating.value || loading.value) return + if (!window.confirm(formatMessage(messages.confirmDelete, { version: release.version }))) return + deletingId.value = release.id + try { + const result = await skinSiteDeleteLauncherRelease(release.id) + if (!result.success) throw new Error(result.errorMessage) + if (state.value) + state.value.releases = state.value.releases.filter((item) => item.id !== release.id) + offset.value = Math.min(offset.value, Math.max(0, (pageCount.value - 1) * PAGE_SIZE)) + addNotification({ type: 'success', title: formatMessage(messages.deleted) }) + } catch (error) { + handleError(error) + } finally { + deletingId.value = null + } +} + function pickFile() { fileInput.value?.click() } @@ -127,7 +163,7 @@ async function onFileChosen(event: Event) { const input = event.target as HTMLInputElement const file = input.files?.[0] input.value = '' - if (!file || uploading.value) return + if (!file || mutating.value || loading.value) return uploading.value = true uploadPercent.value = 0 try { @@ -180,7 +216,7 @@ onMounted(() => void load()) +
diff --git a/apps/app-frontend/src/components/ui/skin-site/donation-format.test.ts b/apps/app-frontend/src/components/ui/skin-site/donation-format.test.ts new file mode 100644 index 0000000..4160fdb --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/donation-format.test.ts @@ -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), '—') +}) diff --git a/apps/app-frontend/src/components/ui/skin-site/donation-format.ts b/apps/app-frontend/src/components/ui/skin-site/donation-format.ts new file mode 100644 index 0000000..6f82164 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/donation-format.ts @@ -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)}` : '—' +} diff --git a/apps/app-frontend/src/composables/skin-site-session.test.ts b/apps/app-frontend/src/composables/skin-site-session.test.ts index 5b6980d..a70cb9b 100644 --- a/apps/app-frontend/src/composables/skin-site-session.test.ts +++ b/apps/app-frontend/src/composables/skin-site-session.test.ts @@ -3,9 +3,19 @@ import { registerHooks } from 'node:module' import test from 'node:test' type FetchHandler = (url: string, options: RequestInit) => Promise -const testGlobal = globalThis as unknown as { skinSiteTestFetch: FetchHandler } +const testGlobal = globalThis as unknown as { + skinSiteTestFetch: FetchHandler + skinSiteTestHead: (texture: string) => Promise +} const hooks = registerHooks({ resolve(specifier, context, nextResolve) { + if (specifier === '@/helpers/skin-site-render') { + const renderer = new URL('../helpers/skin-site-render.ts', import.meta.url).href + return { + url: `data:text/javascript,${encodeURIComponent(`export { fetchImageAsDataUrl } from ${JSON.stringify(renderer)}; export const renderSkinHead=texture=>globalThis.skinSiteTestHead(texture)`)}`, + shortCircuit: true, + } + } if (specifier === '@tauri-apps/plugin-http') { return { url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinSiteTestFetch(...args)', @@ -44,7 +54,7 @@ test.beforeEach(() => { if (url.endsWith('/user')) return ok(user) return ok([]) } - testGlobal.skinSiteTestFetch = async (url, options) => { + testGlobal.skinSiteTestFetch = async (url, options = {}) => { calls.push({ path: new URL(url).pathname, token: (options.headers as Record)?.Authorization, @@ -206,3 +216,50 @@ test('explicit sign-out discards a late successful login', async () => { assert.equal(session.skinSiteStatus.value, 'signed-out') assert.equal(storage.has(tokenKey), false) }) + +test('player heads are cropped from one downloaded local texture rather than a cross-origin image', async (t) => { + const textureUrl = 'https://skin.starlight.cool/yggdrasil/textures/test' + const textureData = 'data:image/png;base64,iVBORw==' + const previousReader = Object.getOwnPropertyDescriptor(globalThis, 'FileReader') + Object.defineProperty(globalThis, 'FileReader', { + configurable: true, + value: class { + result: string | null = null + onload?: () => void + readAsDataURL(blob: Blob) { + void blob.arrayBuffer().then((bytes) => { + this.result = `data:${blob.type};base64,${Buffer.from(bytes).toString('base64')}` + this.onload?.() + }) + } + }, + }) + t.after(() => { + if (previousReader) Object.defineProperty(globalThis, 'FileReader', previousReader) + else Reflect.deleteProperty(globalThis, 'FileReader') + }) + t.mock.method(globalThis, 'fetch', async () => { + throw new TypeError('Cross-origin image request blocked') + }) + testGlobal.skinSiteTestHead = async (source) => { + if (source !== textureData) throw new Error('Cross-origin canvas image blocked') + return 'data:image/png;base64,local-head' + } + respond = async (url) => { + if (url === textureUrl) + return new Response(new Uint8Array([137, 80, 78, 71]), { + headers: { 'Content-Type': 'image/png' }, + }) + if (url.endsWith('/player/skin/player')) return ok({ skin: textureUrl, model: 'slim' }) + if (url.endsWith('/player')) return ok([{ uuid: 'player', name: 'Player', isMojang: false }]) + return url.endsWith('/user') ? ok(user) : ok({ jwt: 'token' }) + } + storage.set(tokenKey, 'old-token') + await session.initSkinSiteSession() + const [player] = await session.requestSkinSitePlayers() + assert.equal(player.skinState, 'ready') + assert.equal(player.skinDataUrl, textureData) + assert.equal(player.headDataUrl, 'data:image/png;base64,local-head') + assert.equal(player.model, 'slim') + assert.equal(calls.filter((call) => call.path === '/yggdrasil/textures/test').length, 1) +}) diff --git a/apps/app-frontend/src/composables/skin-site-session.ts b/apps/app-frontend/src/composables/skin-site-session.ts index 9cae783..c369bc7 100644 --- a/apps/app-frontend/src/composables/skin-site-session.ts +++ b/apps/app-frontend/src/composables/skin-site-session.ts @@ -275,10 +275,8 @@ async function buildPlayer(entry: { if (!texture.skin) return base try { - const [headDataUrl, skinDataUrl] = await Promise.all([ - renderSkinHead(texture.skin), - fetchImageAsDataUrl(texture.skin), - ]) + const skinDataUrl = await fetchImageAsDataUrl(texture.skin) + const headDataUrl = await renderSkinHead(skinDataUrl) return { ...base, skinState: 'ready', diff --git a/apps/app-frontend/src/helpers/app-update-policy.test.ts b/apps/app-frontend/src/helpers/app-update-policy.test.ts new file mode 100644 index 0000000..006434a --- /dev/null +++ b/apps/app-frontend/src/helpers/app-update-policy.test.ts @@ -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) +}) diff --git a/apps/app-frontend/src/helpers/app-update-policy.ts b/apps/app-frontend/src/helpers/app-update-policy.ts new file mode 100644 index 0000000..2d63f1e --- /dev/null +++ b/apps/app-frontend/src/helpers/app-update-policy.ts @@ -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 +} diff --git a/apps/app-frontend/src/helpers/instance-player.test.ts b/apps/app-frontend/src/helpers/instance-player.test.ts index 25c912c..3d8ee54 100644 --- a/apps/app-frontend/src/helpers/instance-player.test.ts +++ b/apps/app-frontend/src/helpers/instance-player.test.ts @@ -1,20 +1,32 @@ import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' import test from 'node:test' -import { - receiveSkinSiteMessage, - resetSkinSiteSession, - setSkinSiteFrame, - SKIN_SITE_ORIGIN, -} from '../composables/skin-site-session.ts' +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === '@tauri-apps/plugin-http') { + return { + url: `data:text/javascript,${encodeURIComponent(`export const fetch=async url=>Response.json({payload:url.endsWith('/user/refresh')?{jwt:'fixture.jwt'}:url.endsWith('/user')?{uuid:'owner',username:'Owner',permissions:[]}:[]})`)}`, + shortCircuit: true, + } + } + if (specifier.startsWith('@/')) { + return nextResolve(new URL(`../${specifier.slice(2)}.ts`, import.meta.url).href, context) + } + return nextResolve(specifier, context) + }, +}) +const { initSkinSiteSession, resetSkinSiteSession, setSkinSiteFrame } = + await import('../composables/skin-site-session.ts') +hooks.deregister() import { useNetworkStatus } from '../composables/useNetworkStatus.ts' -import { - type InstancePlayer, +const { onInstancePlayerChanged, prepareInstancePlayer, registerInstancePlayerPicker, saveInstancePlayer, -} from './instance-player.ts' +} = await import('./instance-player.ts') +import type { InstancePlayer } from './instance-player.ts' test('instance player selection persists independently from the globally selected account', async () => { const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') @@ -84,23 +96,16 @@ test('signed-in skin site renews the saved player without prompting and only not } const calls: string[] = [] let failSave = false - const frame = { - postMessage(data: { type: string; requestId: string }) { - if (data.type === 'starlight-pack-token-request') - receiveSkinSiteMessage( - { - origin: SKIN_SITE_ORIGIN, - source: frame, - data: { - type: 'starlight-pack-token-result', - requestId: data.requestId, - token: 'fixture.jwt', - }, - } as unknown as MessageEvent, - frame, - ) + const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') + const storage = new Map([['starlight-skin-site-token', 'old.jwt']]) + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), }, - } as unknown as Window + }) Object.defineProperty(globalThis, 'window', { configurable: true, value: { @@ -128,19 +133,7 @@ test('signed-in skin site renews the saved player without prompting and only not const stop = onInstancePlayerChanged((id) => changes.push(id)) try { resetSkinSiteSession() - setSkinSiteFrame(frame) - receiveSkinSiteMessage( - { - origin: SKIN_SITE_ORIGIN, - source: frame, - data: { - type: 'starlight-skin-session', - status: 'signed-in', - user: { uuid: 'owner', username: 'Owner' }, - }, - } as unknown as MessageEvent, - frame, - ) + await initSkinSiteSession() await prepareInstancePlayer('one') assert.equal(prompts, 0) assert.ok(calls.includes('plugin:auth|login_skin_site_player')) @@ -149,6 +142,7 @@ test('signed-in skin site renews the saved player without prompting and only not failSave = true await assert.rejects(saveInstancePlayer('two', player), /Save failed/) assert.deepEqual(changes, ['one']) + storage.clear() resetSkinSiteSession() await prepareInstancePlayer('one') assert.equal(prompts, 1) @@ -157,6 +151,8 @@ test('signed-in skin site renews the saved player without prompting and only not unregister() resetSkinSiteSession() setSkinSiteFrame(null) + if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage) + else Reflect.deleteProperty(globalThis, 'localStorage') if (previous) Object.defineProperty(globalThis, 'window', previous) else Reflect.deleteProperty(globalThis, 'window') if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator) diff --git a/apps/app-frontend/src/helpers/post-upgrade-notice.test.ts b/apps/app-frontend/src/helpers/post-upgrade-notice.test.ts index 983a89e..adb1820 100644 --- a/apps/app-frontend/src/helpers/post-upgrade-notice.test.ts +++ b/apps/app-frontend/src/helpers/post-upgrade-notice.test.ts @@ -72,7 +72,7 @@ test('notice query uses the target instance value and registered Tauri command', ) const helperSource = readFileSync(new URL('./instance.ts', import.meta.url), 'utf8') const tauriSource = readFileSync( - new URL('../../../app/src/api/instance.rs', import.meta.url), + new URL('../../../app/src/api/instance/mod.rs', import.meta.url), 'utf8', ) assert.match(querySource, /\['post-upgrade-notice', instanceId\]/) diff --git a/apps/app-frontend/src/helpers/skin-armor-preview.test.ts b/apps/app-frontend/src/helpers/skin-armor-preview.test.ts index 531ac14..f4e3a9d 100644 --- a/apps/app-frontend/src/helpers/skin-armor-preview.test.ts +++ b/apps/app-frontend/src/helpers/skin-armor-preview.test.ts @@ -1,7 +1,23 @@ import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' import test from 'node:test' -import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts' +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === '@modrinth/ui') { + return nextResolve( + new URL( + '../../../../packages/ui/src/composables/skin-rendering/armor-preview-types.ts', + import.meta.url, + ).href, + context, + ) + } + return nextResolve(specifier, context) + }, +}) +const { loadSkinArmorPreview, saveSkinArmorPreview } = await import('./skin-armor-preview.ts') +hooks.deregister() const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index c3bba36..f7f44c1 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -414,8 +414,8 @@ export function skinSitePublicDonationSummary(): Promise< return skinSiteRequest('donation/summary', { auth: false }) } -export function skinSiteDonationList(offset = 0): Promise> { - return skinSiteRequest(`donation/list?limit=20&offset=${offset}`, { +export function skinSiteDonationList(offset = 0): Promise> { + return skinSiteRequest(`donation/list?limit=20&offset=${offset}`, { auth: false, }) } @@ -1214,6 +1214,10 @@ export function skinSiteActivateLauncherRelease( }) } +export function skinSiteDeleteLauncherRelease(id: number): Promise> { + return skinSiteRequest('launcher/admin/' + id, { method: 'DELETE' }) +} + export interface SkinSiteLauncherUploadStatus { id: string chunkSize: number diff --git a/apps/app-frontend/src/helpers/skin-site-render.test.ts b/apps/app-frontend/src/helpers/skin-site-render.test.ts new file mode 100644 index 0000000..6e16a6e --- /dev/null +++ b/apps/app-frontend/src/helpers/skin-site-render.test.ts @@ -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 +} +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/, + ) +}) diff --git a/apps/app-frontend/src/helpers/skin-site-render.ts b/apps/app-frontend/src/helpers/skin-site-render.ts index c1d8c91..a7b2751 100644 --- a/apps/app-frontend/src/helpers/skin-site-render.ts +++ b/apps/app-frontend/src/helpers/skin-site-render.ts @@ -1,3 +1,5 @@ +import { fetch as tauriFetch } from '@tauri-apps/plugin-http' + /** * Renders a player's skin into a base64 head image, mirroring the skin site's * own head cropper (front face at 8,8 plus the hat overlay at 40,8). @@ -28,9 +30,9 @@ function loadImage(src: string): Promise { }) } -/** Crop the head (and hat overlay) out of a skin image URL. Returns a data URL. */ -export async function renderSkinHead(skinUrl: string, scale = 2): Promise { - const img = await loadImage(skinUrl) +/** Crop the head (and hat overlay) from a locally downloaded skin data URL. */ +export async function renderSkinHead(skinDataUrl: string, scale = 2): Promise { + const img = await loadImage(skinDataUrl) const { canvas, ctx } = getBuffer() canvas.width = HEAD_BUFFER_SIZE @@ -74,7 +76,7 @@ export async function renderSkinHead(skinUrl: string, scale = 2): Promise { - const response = await fetch(url) + const response = await tauriFetch(url) if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`) const blob = await response.blob() return await new Promise((resolve, reject) => { diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 30685a3..a2512e0 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -3,6 +3,7 @@ "app.hosted-packs.progress.resume": { "message": "Resume" }, "app.hosted-packs.progress.stop": { "message": "Stop" }, "app.downloads.status.paused": { "message": "Paused" }, + "app.skin-site.captcha.loading": { "message": "Loading captcha…" }, "app.skin-site.captcha.submit": { "message": "Submit" }, "app.skin-site.captcha.subtitle": { @@ -12331,6 +12332,13 @@ "app.skin-site.admin.launcher.active": { "message": "Active" }, "app.skin-site.admin.launcher.activate": { "message": "Activate" }, "app.skin-site.admin.launcher.activated": { "message": "Release activated." }, + "app.skin-site.admin.launcher.delete": { "message": "Delete release" }, + "app.skin-site.admin.launcher.confirm-delete": { + "message": "Permanently delete launcher {version} and all its installation files from the server? This version will no longer be available for download or rollback. Installed launchers will not be affected." + }, + "app.skin-site.admin.launcher.deleted": { + "message": "Release and its installation files deleted." + }, "app.skin-site.admin.launcher.upload": { "message": "Upload release" }, "app.skin-site.admin.launcher.upload-hint": { "message": "Choose a release ZIP (max 4 GiB)." @@ -13067,5 +13075,25 @@ }, "app.skin-site.wiki.revision": { "message": "Revision #{id}" }, "app.skin-site.wiki.categories": { "message": "Categories" }, - "app.skin-site.wiki.back": { "message": "Back" } + "app.skin-site.wiki.back": { "message": "Back" }, + "app.skin-site.home.donation.thanks": { + "message": "Thank you to everyone who helps keep StarLight running." + }, + "app.skin-site.home.donation.empty": { "message": "No donations yet." }, + "app.skin-site.home.donation.list-failed": { + "message": "Could not load donations." + }, + "app.skin-site.home.donation.more": { "message": "Load more" }, + "app.skin-site.home.preview.players": { "message": "Players" }, + "app.skin-site.home.preview.empty": { "message": "No players yet" }, + "app.skin-site.home.preview.empty-hint": { + "message": "Sign in or add a Minecraft account in the sidebar to see your skin here." + }, + "app.skin-site.home.preview.unavailable": { + "message": "This player has no skin to preview." + }, + "app.skin-site.home.preview.loading": { "message": "Loading players…" }, + "app.skin-site.home.preview.failed": { + "message": "Could not load skin-site players." + } } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 525348e..dcba070 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -3,6 +3,7 @@ "app.hosted-packs.progress.resume": { "message": "继续" }, "app.hosted-packs.progress.stop": { "message": "停止" }, "app.downloads.status.paused": { "message": "已暂停" }, + "app.skin-site.captcha.loading": { "message": "正在加载验证码…" }, "app.skin-site.captcha.submit": { "message": "验证" }, "app.skin-site.captcha.subtitle": { @@ -12700,6 +12701,13 @@ "app.skin-site.admin.launcher.active": { "message": "当前版本" }, "app.skin-site.admin.launcher.activate": { "message": "设为当前" }, "app.skin-site.admin.launcher.activated": { "message": "已设为当前版本。" }, + "app.skin-site.admin.launcher.delete": { "message": "删除版本" }, + "app.skin-site.admin.launcher.confirm-delete": { + "message": "确定永久删除启动器 {version} 及服务器上的全部安装文件吗?此版本将无法下载或回滚,已安装的启动器不受影响。" + }, + "app.skin-site.admin.launcher.deleted": { + "message": "已删除该版本及服务器上的安装文件。" + }, "app.skin-site.admin.launcher.upload": { "message": "上传版本" }, "app.skin-site.admin.launcher.upload-hint": { "message": "选择发布包 ZIP(最大 4 GiB)。" @@ -13328,5 +13336,19 @@ }, "app.skin-site.wiki.revision": { "message": "修订 #{id}" }, "app.skin-site.wiki.categories": { "message": "分类" }, - "app.skin-site.wiki.back": { "message": "返回" } + "app.skin-site.wiki.back": { "message": "返回" }, + "app.skin-site.home.donation.thanks": { + "message": "感谢每一位支持 StarLight 运行的朋友。" + }, + "app.skin-site.home.donation.empty": { "message": "暂无捐赠记录。" }, + "app.skin-site.home.donation.list-failed": { "message": "无法加载捐赠记录。" }, + "app.skin-site.home.donation.more": { "message": "加载更多" }, + "app.skin-site.home.preview.players": { "message": "玩家" }, + "app.skin-site.home.preview.empty": { "message": "暂无玩家" }, + "app.skin-site.home.preview.empty-hint": { + "message": "登录或在侧栏添加 Minecraft 账号,即可在这里预览皮肤。" + }, + "app.skin-site.home.preview.unavailable": { "message": "此玩家没有可预览的皮肤。" }, + "app.skin-site.home.preview.loading": { "message": "正在加载玩家…" }, + "app.skin-site.home.preview.failed": { "message": "无法加载皮肤站玩家。" } } diff --git a/apps/app-frontend/src/providers/app-update.ts b/apps/app-frontend/src/providers/app-update.ts index dc0afe6..073bc3d 100644 --- a/apps/app-frontend/src/providers/app-update.ts +++ b/apps/app-frontend/src/providers/app-update.ts @@ -10,6 +10,7 @@ export interface AppUpdate { currentVersion?: string publishedAt?: string forceUpdate?: boolean + rollback?: boolean } interface UpdatePromptState { diff --git a/apps/app-frontend/src/store/theme.test.ts b/apps/app-frontend/src/store/theme.test.ts index 6a2549d..856180b 100644 --- a/apps/app-frontend/src/store/theme.test.ts +++ b/apps/app-frontend/src/store/theme.test.ts @@ -1,7 +1,17 @@ import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' import test from 'node:test' -import { applySystemAccentHue, DEFAULT_CUSTOM_ACCENT_COLOR, hexToHsl } from './theme.ts' +const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === '@/helpers/font-family.ts') { + return nextResolve(new URL('../helpers/font-family.ts', import.meta.url).href, context) + } + return nextResolve(specifier, context) + }, +}) +const { applySystemAccentHue, DEFAULT_CUSTOM_ACCENT_COLOR, hexToHsl } = await import('./theme.ts') +hooks.deregister() test('applies the system hue with the custom accent saturation and lightness', () => { const system = hexToHsl('#0078d4') diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index c17cdde..b927d8b 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "theseus_gui" # The actual version is set by the theseus-build workflow on tagging -version = "1.0.2-beta11" +version = "1.0.2-beta12" edition.workspace = true description = "Axolotl Launcher is a cross-platform Minecraft launcher" homepage = "https://www.ghs.red" diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 4510a65..cb70414 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -43,6 +43,8 @@ struct UpdatePreferences { #[cfg(target_os = "macos")] mod macos; +#[cfg(feature = "updater")] +mod update_policy; #[cfg(feature = "updater")] mod updater_impl; #[cfg(not(feature = "updater"))] @@ -1024,7 +1026,8 @@ fn main() { s.restart_after_pending_update.load(Ordering::Relaxed) }) .unwrap_or(false); - if let Some((update, data)) = &*update_data.0.lock().unwrap() + if let Some((update, data)) = + &update_data.0.lock().unwrap().pending { fn set_changelog_toast(version: Option) { let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move { diff --git a/apps/app/src/update_policy.rs b/apps/app/src/update_policy.rs new file mode 100644 index 0000000..5b0b52d --- /dev/null +++ b/apps/app/src/update_policy.rs @@ -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 { + target: Option, + check_revision: u64, + pub pending: Option, +} + +impl Default for UpdateTarget { + fn default() -> Self { + Self { + target: None, + check_revision: 0, + pending: None, + } + } +} + +impl UpdateTarget { + 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) { + 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::().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")); + } +} diff --git a/apps/app/src/updater_impl.rs b/apps/app/src/updater_impl.rs index 3817ee6..7159e61 100644 --- a/apps/app/src/updater_impl.rs +++ b/apps/app/src/updater_impl.rs @@ -1,4 +1,5 @@ use crate::api::Result; +use crate::update_policy::{UpdateTarget, is_rollback, requires_update}; use futures::StreamExt; use semver::Version; use serde::{Deserialize, Serialize}; @@ -27,82 +28,6 @@ const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/"; const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60); -// semver compares prerelease identifiers lexically, so "beta10" < "beta9" -// (because the character '1' sorts before '9'). Our release names use a -// "betaN" scheme where N is a decimal counter, so a strict lexical compare -// would wrongly report an older beta as the latest. Use a comparator that -// treats the trailing digits of a prerelease identifier numerically. -fn is_remote_newer(current: &Version, remote: &Version) -> bool { - use std::cmp::Ordering; - - let core = (remote.major, remote.minor, remote.patch).cmp(&( - current.major, - current.minor, - current.patch, - )); - if core != Ordering::Equal { - return core == Ordering::Greater; - } - - // Same x.y.z. A final release outranks any prerelease of that version. - match (current.pre.is_empty(), remote.pre.is_empty()) { - (true, true) => false, - (true, false) => false, - (false, true) => true, - (false, false) => { - compare_prerelease(remote.pre.as_str(), current.pre.as_str()) - == Ordering::Greater - } - } -} - -/// Split a prerelease identifier into its non-digit prefix and an optional -/// trailing decimal number (so "beta10" -> ("beta", Some(10))). -fn split_prerelease_identifier(segment: &str) -> (&str, Option) { - 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::().ok()), - None => (segment, None), - } -} - -/// Compare two prerelease strings, treating a trailing run of ASCII digits -/// as a decimal number (so "beta10" > "beta9"). All other segments fall back -/// to a plain lexical comparison. -fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering { - use std::cmp::Ordering; - - let left_segments: Vec<&str> = left.split('.').collect(); - let right_segments: Vec<&str> = right.split('.').collect(); - - for (l, r) in left_segments.iter().zip(right_segments.iter()) { - let (l_prefix, l_num) = split_prerelease_identifier(l); - let (r_prefix, r_num) = split_prerelease_identifier(r); - - match (l_num, r_num) { - (Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) { - Ordering::Equal => continue, - other => return other, - }, - _ => { - let prefix_cmp = l_prefix.cmp(r_prefix); - if prefix_cmp != Ordering::Equal { - return prefix_cmp; - } - let num_cmp = l_num.cmp(&r_num); - if num_cmp != Ordering::Equal { - return num_cmp; - } - } - } - } - - left_segments.len().cmp(&right_segments.len()) -} - // ── Shared types ───────────────────────────────────────────────── #[derive(Serialize)] @@ -115,11 +40,12 @@ pub struct UpdateMetadata { body: Option, published_at: Option, force_update: bool, + rollback: bool, raw_json: serde_json::Value, } #[derive(Default)] -pub struct PendingUpdateData(pub Mutex, Vec)>>); +pub struct PendingUpdateData(pub Mutex, Vec)>>); // ── Update Server API types ───────────────────────────────────── @@ -307,7 +233,7 @@ async fn check_with_endpoints( .header("X-Axolotl-Platform", platform)? .header("X-Axolotl-Version", current_version)? .version_comparator(|current, release| { - is_remote_newer(¤t, &release.version) + requires_update(¤t, &release.version) }); #[cfg(target_os = "windows")] @@ -345,7 +271,20 @@ async fn check_with_updater( webview: &Webview, channel: &str, ) -> Result> { - let Some(mut update) = check_with_endpoints(webview, channel).await? else { + let pending_data = webview.state::(); + let revision = pending_data.0.lock().unwrap().begin_check(); + let checked = check_with_endpoints(webview, channel).await?; + if !pending_data.0.lock().unwrap().select_checked( + revision, + checked.as_ref().map(|update| update.version.as_str()), + ) { + return Err(theseus::Error::from(theseus::ErrorKind::OtherError( + "A newer launcher update check superseded this response" + .to_string(), + )) + .into()); + } + let Some(mut update) = checked else { return Ok(None); }; update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT); @@ -369,6 +308,10 @@ async fn check_with_updater( .and_then(serde_json::Value::as_bool) .unwrap_or(false); let metadata = UpdateMetadata { + rollback: is_rollback( + &Version::parse(&update.current_version).map_err(Error::from)?, + &Version::parse(&update.version).map_err(Error::from)?, + ), rid: webview.resources_table().add(update.clone()), current_version: update.current_version.clone(), version: update.version.clone(), @@ -449,6 +392,12 @@ pub async fn enqueue_update_for_installation( let update = webview.resources_table().get::(rid)?; + if !pending_data.0.lock().unwrap().is_selected(&update.version) { + return Err(theseus::Error::from(theseus::ErrorKind::OtherError( + "The server-selected launcher version changed; check for updates again".to_string(), + )).into()); + } + let progress = init_loading( LoadingBarType::LauncherUpdate { version: update.version.clone(), @@ -565,11 +514,19 @@ pub async fn enqueue_update_for_installation( let download_duration = download_start.elapsed(); tracing::info!("Downloaded update in {download_duration:?}"); - pending_data + let version = update.version.clone(); + if !pending_data .0 .lock() .unwrap() - .replace((update, update_data)); + .enqueue(&version, (update, update_data)) + { + return Err(theseus::Error::from(theseus::ErrorKind::OtherError( + "The server-selected launcher version changed during download" + .to_string(), + )) + .into()); + } Ok(()) } @@ -577,7 +534,9 @@ pub async fn enqueue_update_for_installation( #[tauri::command] pub fn remove_enqueued_update(webview: Webview) { let pending_data = webview.state::().inner(); - pending_data.0.lock().unwrap().take(); + let mut pending = pending_data.0.lock().unwrap(); + pending.begin_check(); + pending.select(None); } // ── Debian / derivatives apt update ───────────────────────────── @@ -630,6 +589,7 @@ pub async fn install_apt_package(version: &str, data: &[u8]) -> Result<()> { .arg("apt") .arg("install") .arg("-y") + .arg("--allow-downgrades") .arg(&install_path) .output() }) diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json index 0d28be1..21bd0eb 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -21,6 +21,7 @@ "icons/AxolotlAssets.car" ], "windows": { + "allowDowngrades": true, "nsis": { "installMode": "currentUser", "installerHooks": "./nsis/hooks.nsi" diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 663be6d..ab0d302 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "theseus" # The actual version is set by the theseus-build workflow on tagging -version = "1.0.2-beta11" +version = "1.0.2-beta12" edition.workspace = true [dependencies] diff --git a/scripts/axolotl/create-update-manifest.test.mjs b/scripts/axolotl/create-update-manifest.test.mjs index 3344859..14ade90 100644 --- a/scripts/axolotl/create-update-manifest.test.mjs +++ b/scripts/axolotl/create-update-manifest.test.mjs @@ -49,7 +49,11 @@ try { tag, outputPath, ], - { cwd: root, encoding: 'utf8' }, + { + cwd: root, + encoding: 'utf8', + env: { ...process.env, GITHUB_REPOSITORY: 'Mystic-Stars/Axolotl' }, + }, ) assert.equal(create.status, 0, create.stderr) diff --git a/scripts/axolotl/skin-site-i18n.test.mjs b/scripts/axolotl/skin-site-i18n.test.mjs index c733dba..8442d34 100644 --- a/scripts/axolotl/skin-site-i18n.test.mjs +++ b/scripts/axolotl/skin-site-i18n.test.mjs @@ -15,7 +15,7 @@ test('all skin-site messages used in source exist in both release language catal assert.deepEqual( missing.map(({ id }) => id), [], - `${locale} has uncatalogued UI messages`, + `${locale} has uncataloged UI messages`, ) } }) diff --git a/scripts/axolotl/skin-site-messages.mjs b/scripts/axolotl/skin-site-messages.mjs index 776cf72..f1ce25d 100644 --- a/scripts/axolotl/skin-site-messages.mjs +++ b/scripts/axolotl/skin-site-messages.mjs @@ -6,7 +6,7 @@ const require = createRequire(new URL('../../apps/app-frontend/package.json', im const ts = require('typescript') const { parse } = require('vue/compiler-sfc') -/** Read the message IDs actually used by skin-site UI, including uncatalogued messages. */ +/** Read the message IDs actually used by skin-site UI, including uncataloged messages. */ export function collectSkinSiteMessages(root = 'apps/app-frontend/src') { const messages = new Map() function walk(directory) { From 115d31e0b4c6d4619c1e4aa867e96c69dbde9277 Mon Sep 17 00:00:00 2001 From: Disy Date: Wed, 21 Oct 2026 09:40:46 +0800 Subject: [PATCH 2/3] fix(ci): restore cross-platform Rust compilation caches --- .github/workflows/axolotl-ci.yml | 27 ++++++++++++++++------- .github/workflows/build-on-tag.yml | 35 ++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/.github/workflows/axolotl-ci.yml b/.github/workflows/axolotl-ci.yml index 5b3c736..4d53b2e 100644 --- a/.github/workflows/axolotl-ci.yml +++ b/.github/workflows/axolotl-ci.yml @@ -58,24 +58,34 @@ jobs: node-version-file: .nvmrc cache: pnpm - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - name: Restore Rust dependencies + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + shared-key: desktop-${{ runner.os }}-${{ runner.arch }} - name: Set up Rust compiler cache - if: runner.os == 'Windows' - uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7 + id: sccache-setup + continue-on-error: true + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.18.0 - name: Check Rust compiler cache availability id: sccache - if: runner.os == 'Windows' + if: steps.sccache-setup.outcome == 'success' continue-on-error: true shell: bash env: SCCACHE_GHA_ENABLED: 'true' - run: ${SCCACHE_PATH} rustc -vV + run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV' - name: Enable Rust compiler cache - if: runner.os == 'Windows' && steps.sccache.outcome == 'success' + if: steps.sccache.outcome == 'success' shell: bash run: | echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV" echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV" - echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV" + - name: Report unavailable Rust compiler cache + if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure' + shell: bash + run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.' - if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - run: pnpm install --frozen-lockfile @@ -112,9 +122,10 @@ jobs: } Write-Output "Built native NSIS installer: $($installers[0].Name)" - name: Show Rust compiler cache statistics - if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always() + if: always() && steps.sccache.outcome == 'success' + continue-on-error: true shell: bash - run: ${SCCACHE_PATH} --show-stats + run: '"${SCCACHE_PATH}" --show-stats' website: needs: guardrails diff --git a/.github/workflows/build-on-tag.yml b/.github/workflows/build-on-tag.yml index a10e884..72ffa3a 100644 --- a/.github/workflows/build-on-tag.yml +++ b/.github/workflows/build-on-tag.yml @@ -131,26 +131,38 @@ jobs: - name: Set up Rust uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - name: Restore Rust dependencies + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + shared-key: desktop-${{ runner.os }}-${{ runner.arch }} + - name: Set up Rust compiler cache - if: runner.os == 'Windows' - uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7 + id: sccache-setup + continue-on-error: true + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.18.0 - name: Check Rust compiler cache availability id: sccache - if: runner.os == 'Windows' + if: steps.sccache-setup.outcome == 'success' continue-on-error: true shell: bash env: SCCACHE_GHA_ENABLED: 'true' - run: ${SCCACHE_PATH} rustc -vV + run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV' - name: Enable Rust compiler cache - if: runner.os == 'Windows' && steps.sccache.outcome == 'success' + if: steps.sccache.outcome == 'success' shell: bash run: | echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV" echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV" - echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV" + + - name: Report unavailable Rust compiler cache + if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure' + shell: bash + run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.' - name: Add macOS universal targets if: runner.os == 'macOS' @@ -193,11 +205,6 @@ jobs: updaterJsonPreferNsis: true retryAttempts: 2 - - name: Show Rust compiler cache statistics - if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always() - shell: bash - run: ${SCCACHE_PATH} --show-stats - - name: Publish Windows distribution variants if: runner.os == 'Windows' env: @@ -283,6 +290,12 @@ jobs: throw "Binary not found at $appExe" } + - name: Show Rust compiler cache statistics + if: always() && steps.sccache.outcome == 'success' + continue-on-error: true + shell: bash + run: '"${SCCACHE_PATH}" --show-stats' + verify-and-publish: name: Verify and publish needs: build From 6b0b2814a58335a94a2d58cd2f67288eec60773f Mon Sep 17 00:00:00 2001 From: Disy Date: Wed, 21 Oct 2026 16:38:10 +0800 Subject: [PATCH 3/3] chore: release 1.0.2-beta13 --- Cargo.lock | 4 +-- apps/app-frontend/package.json | 2 +- .../app-frontend/src/announcements/catalog.ts | 27 +++++++++++++++++++ apps/app/Cargo.toml | 2 +- packages/app-lib/Cargo.toml | 2 +- 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88081b7..e8b4093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6841,7 +6841,7 @@ dependencies = [ [[package]] name = "theseus" -version = "1.0.2-beta12" +version = "1.0.2-beta13" dependencies = [ "ariadne", "async-minecraft-ping", @@ -6945,7 +6945,7 @@ dependencies = [ [[package]] name = "theseus_gui" -version = "1.0.2-beta12" +version = "1.0.2-beta13" dependencies = [ "ashpd", "async_zip", diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 610814b..2c4b491 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -1,7 +1,7 @@ { "name": "@modrinth/app-frontend", "private": true, - "version": "1.0.2-beta12", + "version": "1.0.2-beta13", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/app-frontend/src/announcements/catalog.ts b/apps/app-frontend/src/announcements/catalog.ts index a230590..1d5f802 100644 --- a/apps/app-frontend/src/announcements/catalog.ts +++ b/apps/app-frontend/src/announcements/catalog.ts @@ -32,6 +32,33 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [ ] export const launcherAnnouncements: readonly LauncherAnnouncement[] = [ + { + id: 'launcher-1.0.2-beta13', + version: '1.0.2-beta13', + publishedAt: '2026-10-21', + title: { + 'en-US': 'Starlight Launcher 1.0.2-beta13', + 'zh-CN': 'Starlight Launcher 1.0.2-beta13', + }, + changes: { + changed: [ + { + 'en-US': + 'The redesigned home page brings together the 3D skin preview, skin site and Microsoft player selection, announcements, donation acknowledgements and a compact navigation toolbar.', + 'zh-CN': + '新版首页集中展示 3D 皮肤预览、皮肤站与微软玩家切换、公告、捐赠鸣谢及底部功能工具栏。', + }, + ], + fixed: [ + { + 'en-US': + 'The home page keeps the current 3D skin preview stable when accounts or avatars refresh in the background, avoiding unnecessary texture reloads and model flicker.', + 'zh-CN': + '后台刷新账号或头像时保持当前 3D 皮肤预览稳定,避免无关刷新导致纹理重新加载和模型闪烁。', + }, + ], + }, + }, { id: 'launcher-1.0.2-beta12', version: '1.0.2-beta12', diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index b927d8b..2971523 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "theseus_gui" # The actual version is set by the theseus-build workflow on tagging -version = "1.0.2-beta12" +version = "1.0.2-beta13" edition.workspace = true description = "Axolotl Launcher is a cross-platform Minecraft launcher" homepage = "https://www.ghs.red" diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index ab0d302..3c9bfc5 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "theseus" # The actual version is set by the theseus-build workflow on tagging -version = "1.0.2-beta12" +version = "1.0.2-beta13" edition.workspace = true [dependencies]