fix(glass): load cross-origin wallpapers directly (#593)

This commit is contained in:
InfinityPacer
2026-07-27 13:49:09 +08:00
committed by GitHub
parent 0f7c95cfaf
commit 57448ca609
9 changed files with 152 additions and 41 deletions
+53
View File
@@ -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)
})
})
+40 -5
View File
@@ -70,22 +70,57 @@ export async function getDominantColor(
export async function preloadImage(url: string): Promise<boolean> {
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<boolean> {
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
}
}
+24 -19
View File
@@ -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<string[], string[]>(`/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 = []
}
+19
View File
@@ -745,6 +745,16 @@ onUnmounted(() => {
<template>
<!-- 登录页面容器 -->
<div class="login-root" :data-login-visual-profile="loginVisualProfile">
<svg class="login-glass-filter-defs" aria-hidden="true">
<defs>
<filter id="login-glass-static-refraction" x="-12%" y="-12%" width="124%" height="124%">
<feTurbulence type="fractalNoise" baseFrequency="0.008 0.014" numOctaves="2" seed="8" result="noise" />
<feGaussianBlur in="noise" stdDeviation="0.35" result="softNoise" />
<feDisplacementMap in="SourceGraphic" in2="softNoise" scale="11" xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
</svg>
<!-- 经典主题保留一层低频品牌环境光透明与玻璃 profile 不挂载该装饰 -->
<div v-if="loginVisualProfile === 'classic'" class="login-ambient-light" aria-hidden="true">
<span class="login-ambient-light__wash" />
@@ -1032,6 +1042,14 @@ onUnmounted(() => {
padding-inline: 16px;
}
.login-glass-filter-defs {
position: absolute;
overflow: hidden;
block-size: 0;
inline-size: 0;
pointer-events: none;
}
/* ===================== 玻璃卡片 ===================== */
.login-card {
position: relative;
@@ -1469,6 +1487,7 @@ onUnmounted(() => {
.native-login-field {
backdrop-filter: none !important;
background: rgb(var(--v-theme-surface)) !important;
filter: none !important;
}
}
+6
View File
@@ -1108,6 +1108,12 @@ html[data-theme='glass'] body[data-theme='glass'] {
}
}
html[data-theme='glass'][data-glass-renderer-state='fallback'] body[data-theme='glass'] {
.app-wrapper--login-glass-high .login-card__surface {
filter: url('#login-glass-static-refraction');
}
}
html[data-theme='glass'][data-glass-appearance='tinted'] body[data-theme='glass'] {
.login-card__surface {
background:
+4 -2
View File
@@ -351,14 +351,16 @@ describe('glass optics geometry', () => {
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
})
it('only uploads browser-readable login wallpapers to WebGL', () => {
it('attempts browser-supported wallpaper protocols without allowing mixed content', () => {
const documentUrl = 'https://moviepilot.example/login'
expect(canUseGlassWallpaperTexture('/api/v1/login/wallpaper/0', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('https://moviepilot.example/assets/login.jpg', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('blob:https://moviepilot.example/texture', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('data:image/png;base64,AA==', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('https://image.tmdb.org/t/p/original/poster.jpg', documentUrl)).toBe(false)
expect(canUseGlassWallpaperTexture('https://image.tmdb.org/t/p/original/poster.jpg', documentUrl)).toBe(true)
expect(canUseGlassWallpaperTexture('http://image.tmdb.org/t/p/original/poster.jpg', documentUrl)).toBe(false)
expect(canUseGlassWallpaperTexture('ftp://image.tmdb.org/poster.jpg', documentUrl)).toBe(false)
expect(canUseGlassWallpaperTexture('', documentUrl)).toBe(false)
})
@@ -3,7 +3,6 @@ import {
createLoginBackgroundLayers,
getLoginGlassOpticalSettings,
getLoginVisualProfile,
getLoginWallpaperRequestMode,
prepareLoginBackgroundLayer,
settleLoginBackgroundLayers,
} from '@/utils/loginPresentation'
@@ -42,12 +41,6 @@ describe('login presentation', () => {
})
})
it('requests same-origin wallpapers only for glass', () => {
expect(getLoginWallpaperRequestMode('glass')).toBe('same-origin')
expect(getLoginWallpaperRequestMode('classic')).toBe('default')
expect(getLoginWallpaperRequestMode('transparent')).toBe('default')
})
it('keeps two stable wallpaper slots while their transition roles change', () => {
const initial = createLoginBackgroundLayers('one.jpg')
const prepared = prepareLoginBackgroundLayer(initial, 'two.jpg')
+6 -2
View File
@@ -492,7 +492,7 @@ export function reconcileGlassOpticalSurfaceSlots<TKey>(
return [...stable, ...reserved].slice(0, maxCount)
}
/** 只将同源及本地对象交给 WebGL,避免跨域纹理失败污染登录页控制台。 */
/** 过滤浏览器不会作为纹理读取的协议;跨域读取能力由实际纹理加载结果判定。 */
export function canUseGlassWallpaperTexture(url: string, documentUrl: string): boolean {
if (!url || !documentUrl) return false
@@ -500,7 +500,11 @@ export function canUseGlassWallpaperTexture(url: string, documentUrl: string): b
const source = new URL(url, documentUrl)
if (source.protocol === 'blob:' || source.protocol === 'data:') return true
return source.origin === new URL(documentUrl).origin
const document = new URL(documentUrl)
if (source.protocol !== 'http:' && source.protocol !== 'https:') return false
if (document.protocol === 'https:' && source.protocol === 'http:') return false
return true
} catch {
return false
}
-6
View File
@@ -1,7 +1,6 @@
import type { GlassAppearance, GlassOpticalPreset } from '@/utils/glassOptics'
export type LoginVisualProfile = 'classic' | 'glass' | 'transparent'
export type LoginWallpaperRequestMode = 'default' | 'same-origin'
export interface LoginGlassPreference {
/** 用户选择的玻璃材质。 */
@@ -53,11 +52,6 @@ export function getLoginGlassOpticalSettings(preference: LoginGlassPreference) {
}
}
/** 玻璃主题需要同源纹理,其余主题保留现有外链返回行为。 */
export function getLoginWallpaperRequestMode(profile: LoginVisualProfile): LoginWallpaperRequestMode {
return profile === 'glass' ? 'same-origin' : 'default'
}
/** 建立两个始终存在的背景槽位,避免角色变化时复用错误的 DOM 合成层。 */
export function createLoginBackgroundLayers(activeUrl = ''): LoginBackgroundLayer[] {
return [