feat(pwa): 完善 iOS 启动与主题恢复

This commit is contained in:
jxxghp
2026-07-29 10:06:14 +08:00
parent e5339d524b
commit 8a3b99ae51
51 changed files with 1221 additions and 716 deletions
+947 -699
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+68
View File
@@ -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)}`,
)
+22
View File
@@ -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 }
]
+5
View File
@@ -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')
}
} }
+84 -15
View File
@@ -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)
+25
View File
@@ -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')
})
}) })
+1
View File
@@ -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 {
+66
View File
@@ -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')
})
})
-1
View File
@@ -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'],