mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
fix(glass): load cross-origin wallpapers directly (#593)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 = []
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user