feat(pwa): 完善 iOS 启动与主题恢复
@@ -7,8 +7,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"dev:pwa": "vite --host --port 5174",
|
"dev:pwa": "vite --host --port 5174",
|
||||||
"prebuild": "npm run build:icons",
|
"prebuild": "npm run build:icons && npm run generate:pwa-splash",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
|
||||||
"preview": "vite preview --port 5050",
|
"preview": "vite preview --port 5050",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
@@ -131,6 +132,7 @@
|
|||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"postcss-html": "^1.5.0",
|
"postcss-html": "^1.5.0",
|
||||||
"prettier": "^3.9.5",
|
"prettier": "^3.9.5",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"stylelint": "^16.13.2",
|
"stylelint": "^16.13.2",
|
||||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||||
"stylelint-config-standard-scss": "^14.0.0",
|
"stylelint-config-standard-scss": "^14.0.0",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,68 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import appleSplashSpecs from './pwa-splash-specs.json' with { type: 'json' }
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const logoPath = path.join(projectRoot, 'public', 'logo.svg')
|
||||||
|
const outputDirectory = path.join(projectRoot, 'public', 'splash')
|
||||||
|
const background = '#0E1116'
|
||||||
|
|
||||||
|
async function createSplash(width, height, scaleFactor, outputPath, format) {
|
||||||
|
// Match the DOM loader's `min(160px, 36vw)` in CSS pixels, then convert it
|
||||||
|
// to physical pixels for the selected Apple launch image.
|
||||||
|
const logoSize = Math.round(Math.min(160, (width / scaleFactor) * 0.36) * scaleFactor)
|
||||||
|
const logo = await sharp(logoPath).resize(logoSize, logoSize, { fit: 'contain' }).png().toBuffer()
|
||||||
|
const image = sharp({
|
||||||
|
create: {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
channels: 4,
|
||||||
|
background,
|
||||||
|
},
|
||||||
|
}).composite([
|
||||||
|
{
|
||||||
|
input: logo,
|
||||||
|
left: Math.round((width - logoSize) / 2),
|
||||||
|
top: Math.round((height - logoSize) / 2),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
if (format === 'png') {
|
||||||
|
await image.png({ compressionLevel: 9 }).toFile(outputPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await image.flatten({ background }).jpeg({ quality: 88, progressive: true }).toFile(outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(outputDirectory, { recursive: true })
|
||||||
|
|
||||||
|
for (const { width: portraitWidth, height: portraitHeight, scaleFactor } of appleSplashSpecs) {
|
||||||
|
const landscapeWidth = portraitHeight
|
||||||
|
const landscapeHeight = portraitWidth
|
||||||
|
|
||||||
|
await createSplash(
|
||||||
|
portraitWidth,
|
||||||
|
portraitHeight,
|
||||||
|
scaleFactor,
|
||||||
|
path.join(outputDirectory, `apple-splash-${portraitWidth}-${portraitHeight}.jpg`),
|
||||||
|
'jpg',
|
||||||
|
)
|
||||||
|
await createSplash(
|
||||||
|
landscapeWidth,
|
||||||
|
landscapeHeight,
|
||||||
|
scaleFactor,
|
||||||
|
path.join(outputDirectory, `apple-splash-${landscapeWidth}-${landscapeHeight}.jpg`),
|
||||||
|
'jpg',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the previous fallback filename for older deployments and bookmarked
|
||||||
|
// entries that may still reference it. Its palette matches the new assets.
|
||||||
|
await createSplash(750, 1334, 2, path.join(outputDirectory, 'apple-splash.png'), 'png')
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Generated ${appleSplashSpecs.length * 2 + 1} PWA splash assets in ${path.relative(projectRoot, outputDirectory)}`,
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{ "width": 2048, "height": 2732, "scaleFactor": 2 },
|
||||||
|
{ "width": 1668, "height": 2388, "scaleFactor": 2 },
|
||||||
|
{ "width": 1536, "height": 2048, "scaleFactor": 2 },
|
||||||
|
{ "width": 1640, "height": 2360, "scaleFactor": 2 },
|
||||||
|
{ "width": 1668, "height": 2224, "scaleFactor": 2 },
|
||||||
|
{ "width": 1620, "height": 2160, "scaleFactor": 2 },
|
||||||
|
{ "width": 1488, "height": 2266, "scaleFactor": 2 },
|
||||||
|
{ "width": 1320, "height": 2868, "scaleFactor": 3 },
|
||||||
|
{ "width": 1206, "height": 2622, "scaleFactor": 3 },
|
||||||
|
{ "width": 1260, "height": 2736, "scaleFactor": 3 },
|
||||||
|
{ "width": 1290, "height": 2796, "scaleFactor": 3 },
|
||||||
|
{ "width": 1179, "height": 2556, "scaleFactor": 3 },
|
||||||
|
{ "width": 1170, "height": 2532, "scaleFactor": 3 },
|
||||||
|
{ "width": 1284, "height": 2778, "scaleFactor": 3 },
|
||||||
|
{ "width": 1125, "height": 2436, "scaleFactor": 3 },
|
||||||
|
{ "width": 1242, "height": 2688, "scaleFactor": 3 },
|
||||||
|
{ "width": 828, "height": 1792, "scaleFactor": 2 },
|
||||||
|
{ "width": 1242, "height": 2208, "scaleFactor": 3 },
|
||||||
|
{ "width": 750, "height": 1334, "scaleFactor": 2 },
|
||||||
|
{ "width": 640, "height": 1136, "scaleFactor": 2 }
|
||||||
|
]
|
||||||
@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
|
|||||||
localStorage.setItem('theme', name)
|
localStorage.setItem('theme', name)
|
||||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||||
|
|
||||||
|
// 自动主题下次恢复时需要一个稳定的首帧明暗结果,避免媒体查询短暂返回浅色。
|
||||||
|
if (name === 'auto') {
|
||||||
|
localStorage.setItem('materio-initial-resolved-theme', theme.current.value.dark ? 'dark' : 'light')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,58 @@ import {
|
|||||||
|
|
||||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||||
|
const LAUNCH_MIN_VISIBLE_MS = 320
|
||||||
|
const LAUNCH_MAX_WAIT_MS = 1200
|
||||||
|
const LAUNCH_EXIT_DURATION_MS = 180
|
||||||
|
|
||||||
|
function getLaunchNow() {
|
||||||
|
return globalThis.performance?.now?.() ?? Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const launchStartedAt = Number.parseFloat(document.documentElement.dataset.launchStartedAt || '') || getLaunchNow()
|
||||||
|
|
||||||
|
function getRemainingLaunchBudget() {
|
||||||
|
return Math.max(0, LAUNCH_MAX_WAIT_MS - (getLaunchNow() - launchStartedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLaunchTask(task: Promise<unknown>, timeoutMs: number, label: string) {
|
||||||
|
if (timeoutMs <= 0) return
|
||||||
|
|
||||||
|
await Promise.race([
|
||||||
|
task.catch(error => {
|
||||||
|
console.warn(`[Launch] ${label} failed`, error)
|
||||||
|
}),
|
||||||
|
new Promise<void>(resolve => window.setTimeout(resolve, timeoutMs)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMinimumLaunchVisibility() {
|
||||||
|
const remaining = LAUNCH_MIN_VISIBLE_MS - (getLaunchNow() - launchStartedAt)
|
||||||
|
if (remaining > 0) {
|
||||||
|
await new Promise<void>(resolve => window.setTimeout(resolve, remaining))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedAutoResolvedTheme() {
|
||||||
|
const cachedTheme = localStorage.getItem('materio-initial-resolved-theme')
|
||||||
|
|
||||||
|
return cachedTheme === 'dark' || cachedTheme === 'light' ? cachedTheme : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInitialThemeName(themePreference: string) {
|
||||||
|
if (themePreference === 'auto') {
|
||||||
|
return getCachedAutoResolvedTheme() || resolveThemeName(themePreference)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveThemeName(themePreference)
|
||||||
|
}
|
||||||
|
|
||||||
// 生效主题
|
// 生效主题
|
||||||
const vuetifyTheme = useTheme()
|
const vuetifyTheme = useTheme()
|
||||||
const { global: globalTheme } = vuetifyTheme
|
const { global: globalTheme } = vuetifyTheme
|
||||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||||
globalTheme.name.value = resolveThemeName(themeValue)
|
let resumeThemeSyncTimer: number | null = null
|
||||||
|
globalTheme.name.value = resolveInitialThemeName(themeValue)
|
||||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||||
|
|
||||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||||
@@ -397,10 +443,18 @@ function updateHtmlThemeAttribute(themeName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
|
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
|
||||||
function syncThemePreferenceFromStorage() {
|
function syncThemePreferenceFromStorage(preferCachedAuto = false) {
|
||||||
|
if (resumeThemeSyncTimer !== null) {
|
||||||
|
window.clearTimeout(resumeThemeSyncTimer)
|
||||||
|
resumeThemeSyncTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
themeValue = localStorage.getItem('theme') || 'auto'
|
themeValue = localStorage.getItem('theme') || 'auto'
|
||||||
|
|
||||||
const resolvedTheme = resolveThemeName(themeValue)
|
const resolvedTheme =
|
||||||
|
themeValue === 'auto' && preferCachedAuto
|
||||||
|
? getCachedAutoResolvedTheme() || resolveThemeName(themeValue)
|
||||||
|
: resolveThemeName(themeValue)
|
||||||
if (globalTheme.name.value !== resolvedTheme) {
|
if (globalTheme.name.value !== resolvedTheme) {
|
||||||
globalTheme.name.value = resolvedTheme
|
globalTheme.name.value = resolvedTheme
|
||||||
}
|
}
|
||||||
@@ -411,13 +465,20 @@ function syncThemePreferenceFromStorage() {
|
|||||||
|
|
||||||
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
|
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
|
||||||
void themeManager
|
void themeManager
|
||||||
.setTheme(themeValue)
|
.setTheme(themeValue === 'auto' ? resolvedTheme : themeValue)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('同步主题管理器失败:', error)
|
console.error('同步主题管理器失败:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (preferCachedAuto && themeValue === 'auto') {
|
||||||
|
resumeThemeSyncTimer = window.setTimeout(() => {
|
||||||
|
resumeThemeSyncTimer = null
|
||||||
|
syncThemePreferenceFromStorage()
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
|
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
|
||||||
@@ -430,7 +491,7 @@ function handleSystemThemeChange() {
|
|||||||
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
|
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
|
||||||
function handleVisibilityThemeSync() {
|
function handleVisibilityThemeSync() {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
syncThemePreferenceFromStorage()
|
syncThemePreferenceFromStorage(true)
|
||||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,7 +501,7 @@ function handlePageShowThemeSync() {
|
|||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||||
}
|
}
|
||||||
syncThemePreferenceFromStorage()
|
syncThemePreferenceFromStorage(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理背景图交叉淡入淡出定时器。
|
// 清理背景图交叉淡入淡出定时器。
|
||||||
@@ -724,7 +785,7 @@ async function animateAndRemoveLoader() {
|
|||||||
document.body.style.removeProperty('overflow')
|
document.body.style.removeProperty('overflow')
|
||||||
completeLaunchLoading()
|
completeLaunchLoading()
|
||||||
resolve()
|
resolve()
|
||||||
}, 120)
|
}, LAUNCH_EXIT_DURATION_MS)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
completeLaunchLoading()
|
completeLaunchLoading()
|
||||||
@@ -737,19 +798,27 @@ async function removeLoadingWithStateCheck() {
|
|||||||
// 设置各个组件的加载状态
|
// 设置各个组件的加载状态
|
||||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||||
|
|
||||||
// 静默检查PWA状态恢复
|
// 静默检查PWA状态恢复,但不能让恢复异常或慢请求挡住应用外壳。
|
||||||
const pwaController = (window as any).pwaStateController
|
const pwaController = (window as any).pwaStateController
|
||||||
if (pwaController) {
|
if (pwaController?.waitForStateRestore) {
|
||||||
await pwaController.waitForStateRestore()
|
await waitForLaunchTask(
|
||||||
|
Promise.resolve().then(() => pwaController.waitForStateRestore()),
|
||||||
|
getRemainingLaunchBudget(),
|
||||||
|
'PWA state restore',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||||
|
|
||||||
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
|
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
|
||||||
await initializePWA()
|
await waitForLaunchTask(initializePWA(), getRemainingLaunchBudget(), 'PWA detection')
|
||||||
await initializeAuthenticatedState()
|
|
||||||
|
|
||||||
// 等待所有加载完成
|
// 用户设置不影响首帧布局,交给应用外壳出现后继续加载。
|
||||||
await globalLoadingStateManager.waitForAllComplete()
|
void initializeAuthenticatedState().catch(error => {
|
||||||
|
console.warn('[Launch] Authenticated state initialization failed', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 快速缓存命中时至少保留短暂的稳定画面,避免 iOS 只闪过一帧。
|
||||||
|
await waitForMinimumLaunchVisibility()
|
||||||
|
|
||||||
// 移除加载界面
|
// 移除加载界面
|
||||||
await animateAndRemoveLoader()
|
await animateAndRemoveLoader()
|
||||||
@@ -824,7 +893,7 @@ onMounted(async () => {
|
|||||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||||
|
|
||||||
// 初始化主题管理器 - 统一处理主题初始化
|
// 初始化主题管理器 - 统一处理主题初始化
|
||||||
await themeManager.setTheme(themeValue)
|
await themeManager.setTheme(themeValue === 'auto' ? globalTheme.name.value : themeValue)
|
||||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||||
|
|
||||||
|
|||||||
@@ -37,4 +37,29 @@ describe('theme palette', () => {
|
|||||||
window.removeEventListener('moviepilot-theme-primary-color-change', handleFaviconChange)
|
window.removeEventListener('moviepilot-theme-primary-color-change', handleFaviconChange)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('persists the resolved auto theme for the next PWA resume', () => {
|
||||||
|
applyDocumentThemeChrome('auto', {
|
||||||
|
background: '#0E1116',
|
||||||
|
persistLoaderColors: true,
|
||||||
|
primary: '#6E66ED',
|
||||||
|
resolvedTheme: 'dark',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(localStorage.getItem('materio-initial-loader-bg')).toBe('#0E1116')
|
||||||
|
expect(localStorage.getItem('materio-initial-resolved-theme')).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the resolved theme cache when the actual theme is confirmed', () => {
|
||||||
|
localStorage.setItem('materio-initial-resolved-theme', 'dark')
|
||||||
|
|
||||||
|
applyDocumentThemeChrome('light', {
|
||||||
|
background: '#F4F5FA',
|
||||||
|
persistLoaderColors: true,
|
||||||
|
primary: '#8D51F9',
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(localStorage.getItem('materio-initial-resolved-theme')).toBe('light')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export function applyDocumentThemeChrome(
|
|||||||
if (options.persistLoaderColors) {
|
if (options.persistLoaderColors) {
|
||||||
localStorage.setItem('materio-initial-loader-bg', background)
|
localStorage.setItem('materio-initial-loader-bg', background)
|
||||||
localStorage.setItem('materio-initial-loader-color', primary)
|
localStorage.setItem('materio-initial-loader-color', primary)
|
||||||
|
localStorage.setItem('materio-initial-resolved-theme', resolvedTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import appleSplashSpecs from '../../scripts/pwa-splash-specs.json'
|
||||||
|
|
||||||
|
const projectRoot = process.cwd()
|
||||||
|
const indexHtml = readFileSync(resolve(projectRoot, 'index.html'), 'utf8')
|
||||||
|
const splashDirectory = resolve(projectRoot, 'public/splash')
|
||||||
|
|
||||||
|
describe('PWA 启动屏资源', () => {
|
||||||
|
it('为每个横竖屏资源声明静态 iOS 启动图链接', () => {
|
||||||
|
const splashAssets = readdirSync(splashDirectory)
|
||||||
|
.filter(fileName => fileName.endsWith('.jpg'))
|
||||||
|
.sort()
|
||||||
|
const parsedDocument = new DOMParser().parseFromString(indexHtml, 'text/html')
|
||||||
|
const launchLinks = [...parsedDocument.querySelectorAll<HTMLLinkElement>('link[rel="apple-touch-startup-image"]')]
|
||||||
|
const declaredAssets = launchLinks.map(link => link.getAttribute('href')?.split('/').pop()).sort()
|
||||||
|
|
||||||
|
expect(splashAssets).toHaveLength(40)
|
||||||
|
expect(declaredAssets).toEqual(splashAssets)
|
||||||
|
splashAssets.forEach(fileName => expect(existsSync(resolve(splashDirectory, fileName))).toBe(true))
|
||||||
|
|
||||||
|
appleSplashSpecs.forEach(({ width: portraitWidth, height: portraitHeight, scaleFactor }) => {
|
||||||
|
const deviceWidth = portraitWidth / scaleFactor
|
||||||
|
const deviceHeight = portraitHeight / scaleFactor
|
||||||
|
const portraitMedia = `(device-width: ${deviceWidth}px) and (device-height: ${deviceHeight}px) and (-webkit-device-pixel-ratio: ${scaleFactor}) and (orientation: portrait)`
|
||||||
|
const landscapeMedia = `(device-width: ${deviceWidth}px) and (device-height: ${deviceHeight}px) and (-webkit-device-pixel-ratio: ${scaleFactor}) and (orientation: landscape)`
|
||||||
|
|
||||||
|
expect(
|
||||||
|
launchLinks.some(
|
||||||
|
link =>
|
||||||
|
link.getAttribute('href') === `/splash/apple-splash-${portraitWidth}-${portraitHeight}.jpg` &&
|
||||||
|
link.media === portraitMedia,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
launchLinks.some(
|
||||||
|
link =>
|
||||||
|
link.getAttribute('href') === `/splash/apple-splash-${portraitHeight}-${portraitWidth}.jpg` &&
|
||||||
|
link.media === landscapeMedia,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保持启动层背景、可见回退和动效降级配置一致', () => {
|
||||||
|
expect(indexHtml.toLowerCase()).toContain('--initial-loader-bg: #0e1116')
|
||||||
|
expect(indexHtml).toContain('prefers-reduced-motion: reduce')
|
||||||
|
expect(indexHtml).toContain('inset: 0;')
|
||||||
|
expect(indexHtml).toContain('inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 48px)')
|
||||||
|
expect(indexHtml).toContain('document.documentElement.dataset.launchStartedAt')
|
||||||
|
expect(indexHtml).toContain('materio-initial-resolved-theme')
|
||||||
|
expect(indexHtml).toContain('getCachedLaunchBackground')
|
||||||
|
expect(indexHtml).toContain('opacity: 1')
|
||||||
|
expect(indexHtml).not.toContain('apple-mobile-web-app-orientations')
|
||||||
|
expect(indexHtml).not.toContain('name="screen-orientation"')
|
||||||
|
expect(indexHtml).not.toContain('name="x5-orientation"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('让原生启动图和网页启动层共享物理屏幕中心', () => {
|
||||||
|
const generatorSource = readFileSync(resolve(projectRoot, 'scripts/generate-pwa-splash.mjs'), 'utf8')
|
||||||
|
expect(generatorSource).toContain('left: Math.round((width - logoSize) / 2)')
|
||||||
|
expect(generatorSource).toContain('top: Math.round((height - logoSize) / 2)')
|
||||||
|
expect(indexHtml).toContain('Keep the brand mark in the physical screen center')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -91,7 +91,6 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
|||||||
'scope': './',
|
'scope': './',
|
||||||
'display': 'standalone',
|
'display': 'standalone',
|
||||||
'display_override': ['window-controls-overlay', 'standalone'],
|
'display_override': ['window-controls-overlay', 'standalone'],
|
||||||
'orientation': 'portrait-primary',
|
|
||||||
'lang': 'zh-CN',
|
'lang': 'zh-CN',
|
||||||
'dir': 'ltr',
|
'dir': 'ltr',
|
||||||
'categories': ['entertainment', 'multimedia', 'utilities'],
|
'categories': ['entertainment', 'multimedia', 'utilities'],
|
||||||
|
|||||||