From 57448ca609996f020d3ceefebfa835ef5349bf95 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:49:09 +0800 Subject: [PATCH] fix(glass): load cross-origin wallpapers directly (#593) --- src/@core/utils/__tests__/image.spec.ts | 53 +++++++++++++++++++ src/@core/utils/image.ts | 45 ++++++++++++++-- src/App.vue | 43 ++++++++------- src/pages/login.vue | 19 +++++++ src/styles/themes/glass.scss | 6 +++ src/utils/__tests__/glassOptics.spec.ts | 6 ++- src/utils/__tests__/loginPresentation.spec.ts | 7 --- src/utils/glassOptics.ts | 8 ++- src/utils/loginPresentation.ts | 6 --- 9 files changed, 152 insertions(+), 41 deletions(-) create mode 100644 src/@core/utils/__tests__/image.spec.ts diff --git a/src/@core/utils/__tests__/image.spec.ts b/src/@core/utils/__tests__/image.spec.ts new file mode 100644 index 00000000..5ad84b44 --- /dev/null +++ b/src/@core/utils/__tests__/image.spec.ts @@ -0,0 +1,53 @@ +import { vi } from 'vitest' + +vi.mock('colorthief', () => ({ + default: class ColorThief {}, +})) + +import { preloadCorsImage } from '@/@core/utils/image' +import { afterEach, describe, expect, it } from 'vitest' + +describe('preloadCorsImage', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses a CORS-clean cached response without reloading it', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + blob: vi.fn().mockResolvedValue(new Blob(['image'])), + ok: true, + }) + vi.stubGlobal('fetch', fetchMock) + + await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://image.example/wallpaper.jpg'), + expect.objectContaining({ cache: 'force-cache', credentials: 'omit', mode: 'cors' }), + ) + }) + + it('reloads a response when an earlier non-CORS cache entry blocks the first request', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce({ + blob: vi.fn().mockResolvedValue(new Blob(['image'])), + ok: true, + }) + vi.stubGlobal('fetch', fetchMock) + + await expect(preloadCorsImage('/wallpaper.jpg')).resolves.toBe(true) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + new URL('/wallpaper.jpg', window.location.href), + expect.objectContaining({ cache: 'reload', credentials: 'same-origin', mode: 'cors' }), + ) + }) + + it('returns false when the source cannot be read with CORS', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + + await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(false) + }) +}) diff --git a/src/@core/utils/image.ts b/src/@core/utils/image.ts index 8ecaf7d0..171cd85f 100644 --- a/src/@core/utils/image.ts +++ b/src/@core/utils/image.ts @@ -70,22 +70,57 @@ export async function getDominantColor( export async function preloadImage(url: string): Promise { return new Promise(resolve => { const img = new Image() + let settled = false + const finish = (available: boolean) => { + if (settled) return - img.onload = () => resolve(true) - img.onerror = () => resolve(false) + settled = true + clearTimeout(timeout) + resolve(available) + } + + img.onload = () => finish(true) + img.onerror = () => finish(false) // 设置超时,防止图片长时间加载 const timeout = setTimeout(() => { img.src = '' - resolve(false) + finish(false) }, 5000) // 5秒超时 img.src = url // 如果图片已经缓存,onload可能不会触发 if (img.complete) { - clearTimeout(timeout) - resolve(true) + finish(img.naturalWidth > 0) } }) } + +/** 在纹理加载前建立带 CORS 响应头的缓存,避免普通图片缓存污染 WebGL 读取。 */ +export async function preloadCorsImage(url: string): Promise { + const request = async (cache: RequestCache) => { + const source = new URL(url, window.location.href) + const response = await fetch(source, { + cache, + credentials: source.origin === window.location.origin ? 'same-origin' : 'omit', + mode: 'cors', + }) + if (!response.ok) return false + + await response.blob() + return true + } + + try { + if (await request('force-cache')) return true + } catch { + // 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。 + } + + try { + return await request('reload') + } catch { + return false + } +} diff --git a/src/App.vue b/src/App.vue index 93096a66..bae90a9e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,7 +7,7 @@ import { useAuthStore, useGlobalSettingsStore } from '@/stores' import { getBrowserLocale, setI18nLanguage } from './plugins/i18n' import { SupportedLocale } from '@/types/i18n' import { checkAndEmitUnreadMessages } from '@/utils/badge' -import { preloadImage } from './@core/utils/image' +import { preloadCorsImage, preloadImage } from './@core/utils/image' import { globalLoadingStateManager } from '@/utils/loadingStateManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' @@ -38,10 +38,8 @@ import { createLoginBackgroundLayers, getLoginGlassOpticalSettings, getLoginVisualProfile, - getLoginWallpaperRequestMode, prepareLoginBackgroundLayer, settleLoginBackgroundLayers, - type LoginWallpaperRequestMode, } from '@/utils/loginPresentation' const LOGIN_WALLPAPER_ROUTE = '/login' @@ -158,7 +156,6 @@ const shouldUseGlassBackgroundTreatment = computed( const shouldLoadBackgroundImages = computed( () => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value), ) -const wallpaperRequestMode = computed(() => getLoginWallpaperRequestMode(loginVisualProfile.value)) const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '') const renderedBackgroundLayers = computed(() => backgroundLayers.value) const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value)) @@ -480,13 +477,12 @@ function activateBackgroundImage(nextIndex: number) { } // 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。 -async function fetchBackgroundImages(requestMode: LoginWallpaperRequestMode) { +async function fetchBackgroundImages() { backgroundRequestController?.abort() const controller = new AbortController() backgroundRequestController = controller try { return await api.get(`/login/wallpapers`, { - params: requestMode === 'same-origin' ? { same_origin: true } : undefined, signal: controller.signal, }) } finally { @@ -498,7 +494,18 @@ async function fetchBackgroundImages(requestMode: LoginWallpaperRequestMode) { function preloadNextBackgroundImage() { if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1) return const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length - void preloadImage(backgroundImages.value[nextIndex]) + void preloadBackgroundCandidate(backgroundImages.value[nextIndex]) +} + +/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */ +async function preloadBackgroundCandidate(imageUrl: string) { + if (!shouldRenderGlassOpticalLayer.value) return preloadImage(imageUrl) + + const opticalUrl = getOpticalBackgroundImage(imageUrl) + const opticalReady = await preloadCorsImage(opticalUrl) + if (!opticalReady) return preloadImage(imageUrl) + + return opticalUrl === imageUrl ? true : preloadImage(imageUrl) } // 背景图片轮换函数 @@ -513,13 +520,13 @@ async function rotateBackgroundImage() { const nextIndex = (activeIndex + offset) % backgroundImages.value.length const nextImage = backgroundImages.value[nextIndex] const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined + if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue const imagesReady = await preloadBackgroundRotationImages({ displayUrl: nextImage, opticalUrl: opticalImage, preload: preloadImage, }) if (!imagesReady) continue - if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return activateBackgroundImage(nextIndex) @@ -531,7 +538,7 @@ async function rotateBackgroundImage() { stopBackgroundRotation() const recoveryVersion = ++backgroundLoadVersion backgroundRecoveryAttemptedVersion = recoveryVersion - void loadBackgroundImages(wallpaperRequestMode.value, recoveryVersion) + void loadBackgroundImages(recoveryVersion) } } @@ -708,16 +715,16 @@ async function removeLoadingWithStateCheck() { } // 加载背景图片 -async function loadBackgroundImages(requestMode: LoginWallpaperRequestMode, loadVersion: number, retryCount = 0) { +async function loadBackgroundImages(loadVersion: number, retryCount = 0) { const maxRetries = 3 try { - const images = await fetchBackgroundImages(requestMode) + const images = await fetchBackgroundImages() if (loadVersion !== backgroundLoadVersion) return const firstAvailableIndex = await findFirstAvailableBackground({ urls: images, canContinue: () => loadVersion === backgroundLoadVersion, - preload: preloadImage, + preload: preloadBackgroundCandidate, }) if (firstAvailableIndex === null) throw new Error('没有可用的登录壁纸') if (loadVersion !== backgroundLoadVersion) return @@ -743,7 +750,7 @@ async function loadBackgroundImages(requestMode: LoginWallpaperRequestMode, load backgroundRetryTimer = window.setTimeout(() => { backgroundRetryTimer = null if (loadVersion === backgroundLoadVersion) { - void loadBackgroundImages(requestMode, loadVersion, retryCount + 1) + void loadBackgroundImages(loadVersion, retryCount + 1) } }, retryDelay) } @@ -788,15 +795,13 @@ onMounted(async () => { window.addEventListener('focus', handlePageShowThemeSync) window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged) - // 背景范围或玻璃同源能力变化时重新加载;登录前后玻璃主题保持同一请求模式和活动壁纸。 + // 登录前后复用同一壁纸列表和活动项,主题变化只改变呈现方式。 watch( - () => [shouldLoadBackgroundImages.value, wallpaperRequestMode.value] as const, - ([shouldLoad, requestMode], previous) => { - if (previous && shouldLoad === previous[0] && requestMode === previous[1]) return - + shouldLoadBackgroundImages, + shouldLoad => { stopBackgroundLoading() if (shouldLoad) { - void loadBackgroundImages(requestMode, backgroundLoadVersion) + void loadBackgroundImages(backgroundLoadVersion) } else if (!isBackdropTheme.value) { backgroundImages.value = [] } diff --git a/src/pages/login.vue b/src/pages/login.vue index 825c572c..3ce6f2e7 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -745,6 +745,16 @@ onUnmounted(() => {