diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 59f4a100..5ccc4f3d 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -463,6 +463,7 @@ class NativeBridgePlugin: Plugin { private var currentOrientationMask: UIInterfaceOrientationMask = .all private var originalDelegate: UIApplicationDelegate? private var webViewLifecycleManager: WebViewLifecycleManager? + private var traitChangeRegistered = false @objc public override func load(webview: WKWebView) { self.webView = webview @@ -472,6 +473,14 @@ class NativeBridgePlugin: Plugin { webViewLifecycleManager?.startMonitoring(webView: webview) logger.log("NativeBridgePlugin: WebView lifecycle monitoring activated") + // The WKWebView never fires the `prefers-color-scheme` media query + // `change` event while the app stays foregrounded, so observe the + // native appearance and push changes to JS instead. Registration is + // deferred because the window scene may not be connected yet. + DispatchQueue.main.async { [weak self] in + self?.registerTraitChangeObserverIfNeeded() + } + NotificationCenter.default.addObserver( self, selector: #selector(appDidBecomeActive), @@ -510,6 +519,48 @@ class NativeBridgePlugin: Plugin { if volumeKeyHandler != nil { activateVolumeKeyInterception() } + registerTraitChangeObserverIfNeeded() + // Fallback for iOS < 17 (no `registerForTraitChanges`): re-check the + // appearance whenever the app becomes active, e.g. after toggling + // dark mode from Control Center. + notifyColorSchemeChange() + } + + // Resolves the foreground window scene. Its trait collection reflects + // the real system appearance and is unaffected by the per-window + // `overrideUserInterfaceStyle` that `set_system_ui_visibility` applies. + private func foregroundWindowScene() -> UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first + } + + private func systemColorScheme() -> String { + let userInterfaceStyle = + foregroundWindowScene()?.traitCollection.userInterfaceStyle + ?? UITraitCollection.current.userInterfaceStyle + return (userInterfaceStyle == .dark) ? "dark" : "light" + } + + private func registerTraitChangeObserverIfNeeded() { + guard !traitChangeRegistered, #available(iOS 17.0, *) else { return } + guard let windowScene = foregroundWindowScene() else { return } + traitChangeRegistered = true + MainActor.assumeIsolated { + windowScene.registerForTraitChanges([UITraitUserInterfaceStyle.self]) { + [weak self] (_: UIWindowScene, _: UITraitCollection) in + self?.notifyColorSchemeChange() + } + } + } + + private func notifyColorSchemeChange() { + DispatchQueue.main.async { [weak self] in + guard let self = self, let webView = self.webView else { return } + let colorScheme = self.systemColorScheme() + webView.evaluateJavaScript( + "try { window.onNativeColorSchemeChange && window.onNativeColorSchemeChange('\(colorScheme)'); } catch (_) {}", + completionHandler: nil) + } } @objc func appDidEnterBackground() { @@ -887,9 +938,9 @@ class NativeBridgePlugin: Plugin { } @objc public func get_system_color_scheme(_ invoke: Invoke) { - let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle - let colorScheme = (userInterfaceStyle == .dark) ? "dark" : "light" - invoke.resolve(["colorScheme": colorScheme]) + DispatchQueue.main.async { [weak self] in + invoke.resolve(["colorScheme": self?.systemColorScheme() ?? "light"]) + } } @objc public func get_screen_brightness(_ invoke: Invoke) { diff --git a/apps/readest-app/src/__tests__/store/theme-store.test.ts b/apps/readest-app/src/__tests__/store/theme-store.test.ts index 6e7cf4ed..bc246587 100644 --- a/apps/readest-app/src/__tests__/store/theme-store.test.ts +++ b/apps/readest-app/src/__tests__/store/theme-store.test.ts @@ -25,11 +25,13 @@ vi.mock('@/services/environment', () => ({ isWebAppPlatform: vi.fn(() => false), })); -import { useThemeStore, loadDataTheme } from '@/store/themeStore'; +import { useThemeStore, loadDataTheme, initSystemThemeListener } from '@/store/themeStore'; +import type { AppService } from '@/types/system'; describe('themeStore', () => { beforeEach(() => { localStorage.clear(); + delete window.onNativeColorSchemeChange; // Reset store to initial state useThemeStore.setState({ themeMode: 'auto', @@ -257,6 +259,36 @@ describe('themeStore', () => { }); }); + describe('initSystemThemeListener', () => { + const makeAppService = (overrides: Partial = {}) => + ({ isIOSApp: false, hasWindow: false, isLinuxApp: false, ...overrides }) as AppService; + + test('registers window.onNativeColorSchemeChange on iOS', () => { + initSystemThemeListener(makeAppService({ isIOSApp: true })); + expect(typeof window.onNativeColorSchemeChange).toBe('function'); + }); + + test('does not register the native callback on non-iOS platforms', () => { + initSystemThemeListener(makeAppService({ isIOSApp: false })); + expect(window.onNativeColorSchemeChange).toBeUndefined(); + }); + + test('native color scheme change updates the theme store in auto mode', () => { + useThemeStore.setState({ themeMode: 'auto', systemIsDarkMode: false, isDarkMode: false }); + initSystemThemeListener(makeAppService({ isIOSApp: true })); + + window.onNativeColorSchemeChange!('dark'); + expect(useThemeStore.getState().systemIsDarkMode).toBe(true); + expect(useThemeStore.getState().isDarkMode).toBe(true); + expect(localStorage.getItem('systemIsDarkMode')).toBe('true'); + + window.onNativeColorSchemeChange!('light'); + expect(useThemeStore.getState().systemIsDarkMode).toBe(false); + expect(useThemeStore.getState().isDarkMode).toBe(false); + expect(localStorage.getItem('systemIsDarkMode')).toBe('false'); + }); + }); + describe('getIsDarkMode', () => { test('returns the current isDarkMode value', () => { useThemeStore.setState({ isDarkMode: true }); diff --git a/apps/readest-app/src/store/themeStore.ts b/apps/readest-app/src/store/themeStore.ts index 5a013fad..932fd02d 100644 --- a/apps/readest-app/src/store/themeStore.ts +++ b/apps/readest-app/src/store/themeStore.ts @@ -11,6 +11,7 @@ import { Insets } from '@/types/misc'; declare global { interface Window { __READEST_IS_EINK?: boolean; + onNativeColorSchemeChange?: (colorScheme: 'light' | 'dark') => void; } } @@ -166,6 +167,12 @@ export const initSystemThemeListener = (appService: AppService) => { if (typeof window === 'undefined' || !appService) return; const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const applySystemTheme = (systemIsDarkMode: boolean) => { + if (typeof window !== 'undefined' && localStorage) { + localStorage.setItem('systemIsDarkMode', systemIsDarkMode ? 'true' : 'false'); + } + useThemeStore.getState().handleSystemThemeChange(systemIsDarkMode); + }; const updateColorTheme = async () => { let systemIsDarkMode; if (appService.isIOSApp) { @@ -174,10 +181,7 @@ export const initSystemThemeListener = (appService: AppService) => { } else { systemIsDarkMode = mediaQuery.matches; } - if (typeof window !== 'undefined' && localStorage) { - localStorage.setItem('systemIsDarkMode', systemIsDarkMode ? 'true' : 'false'); - } - useThemeStore.getState().handleSystemThemeChange(systemIsDarkMode); + applySystemTheme(systemIsDarkMode); }; const updateWindowTheme = async () => { @@ -191,5 +195,16 @@ export const initSystemThemeListener = (appService: AppService) => { mediaQuery?.addEventListener('change', updateColorTheme); document.addEventListener('visibilitychange', updateColorTheme); window.addEventListener('resize', updateWindowTheme); + + // iOS WKWebView never fires the `prefers-color-scheme` media query + // `change` event while the app stays foregrounded (e.g. toggling dark + // mode from Control Center), so the native plugin pushes the new + // appearance through this callback instead. + if (appService.isIOSApp) { + window.onNativeColorSchemeChange = (colorScheme) => { + applySystemTheme(colorScheme === 'dark'); + }; + } + updateColorTheme(); };