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 1222 additions and 717 deletions

View File

@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
localStorage.setItem('theme', name)
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
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')
}
}

View File

@@ -51,12 +51,58 @@ import {
const LOGIN_WALLPAPER_ROUTE = '/login'
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 { global: globalTheme } = vuetifyTheme
let themeValue = localStorage.getItem('theme') || 'auto'
globalTheme.name.value = resolveThemeName(themeValue)
let resumeThemeSyncTimer: number | null = null
globalTheme.name.value = resolveInitialThemeName(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
@@ -397,10 +443,18 @@ function updateHtmlThemeAttribute(themeName: string) {
}
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
function syncThemePreferenceFromStorage() {
function syncThemePreferenceFromStorage(preferCachedAuto = false) {
if (resumeThemeSyncTimer !== null) {
window.clearTimeout(resumeThemeSyncTimer)
resumeThemeSyncTimer = null
}
themeValue = localStorage.getItem('theme') || 'auto'
const resolvedTheme = resolveThemeName(themeValue)
const resolvedTheme =
themeValue === 'auto' && preferCachedAuto
? getCachedAutoResolvedTheme() || resolveThemeName(themeValue)
: resolveThemeName(themeValue)
if (globalTheme.name.value !== resolvedTheme) {
globalTheme.name.value = resolvedTheme
}
@@ -411,13 +465,20 @@ function syncThemePreferenceFromStorage() {
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
void themeManager
.setTheme(themeValue)
.setTheme(themeValue === 'auto' ? resolvedTheme : themeValue)
.then(() => {
updateHtmlThemeAttribute(globalTheme.name.value)
})
.catch(error => {
console.error('同步主题管理器失败:', error)
})
if (preferCachedAuto && themeValue === 'auto') {
resumeThemeSyncTimer = window.setTimeout(() => {
resumeThemeSyncTimer = null
syncThemePreferenceFromStorage()
}, 180)
}
}
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
@@ -430,7 +491,7 @@ function handleSystemThemeChange() {
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
function handleVisibilityThemeSync() {
if (document.visibilityState === 'visible') {
syncThemePreferenceFromStorage()
syncThemePreferenceFromStorage(true)
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
}
}
@@ -440,7 +501,7 @@ function handlePageShowThemeSync() {
if (document.visibilityState === 'visible') {
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
}
syncThemePreferenceFromStorage()
syncThemePreferenceFromStorage(true)
}
// 清理背景图交叉淡入淡出定时器。
@@ -724,7 +785,7 @@ async function animateAndRemoveLoader() {
document.body.style.removeProperty('overflow')
completeLaunchLoading()
resolve()
}, 120)
}, LAUNCH_EXIT_DURATION_MS)
})
} else {
completeLaunchLoading()
@@ -737,19 +798,27 @@ async function removeLoadingWithStateCheck() {
// 设置各个组件的加载状态
globalLoadingStateManager.setLoadingState('pwa-state', true)
// 静默检查PWA状态恢复
// 静默检查PWA状态恢复,但不能让恢复异常或慢请求挡住应用外壳。
const pwaController = (window as any).pwaStateController
if (pwaController) {
await pwaController.waitForStateRestore()
if (pwaController?.waitForStateRestore) {
await waitForLaunchTask(
Promise.resolve().then(() => pwaController.waitForStateRestore()),
getRemainingLaunchBudget(),
'PWA state restore',
)
}
globalLoadingStateManager.setLoadingState('pwa-state', false)
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
await initializePWA()
await initializeAuthenticatedState()
await waitForLaunchTask(initializePWA(), getRemainingLaunchBudget(), 'PWA detection')
// 等待所有加载完成
await globalLoadingStateManager.waitForAllComplete()
// 用户设置不影响首帧布局,交给应用外壳出现后继续加载。
void initializeAuthenticatedState().catch(error => {
console.warn('[Launch] Authenticated state initialization failed', error)
})
// 快速缓存命中时至少保留短暂的稳定画面,避免 iOS 只闪过一帧。
await waitForMinimumLaunchVisibility()
// 移除加载界面
await animateAndRemoveLoader()
@@ -824,7 +893,7 @@ onMounted(async () => {
updateHtmlThemeAttribute(globalTheme.name.value)
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
await themeManager.setTheme(themeValue === 'auto' ? globalTheme.name.value : themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(globalTheme.name.value)

View File

@@ -37,4 +37,29 @@ describe('theme palette', () => {
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')
})
})

View File

@@ -133,6 +133,7 @@ export function applyDocumentThemeChrome(
if (options.persistLoaderColors) {
localStorage.setItem('materio-initial-loader-bg', background)
localStorage.setItem('materio-initial-loader-color', primary)
localStorage.setItem('materio-initial-resolved-theme', resolvedTheme)
}
return {