forked from akai/readest
The window-level `overrideUserInterfaceStyle` applied by `set_system_ui_visibility` pins the WKWebView's trait collection, so the `prefers-color-scheme` media query never fires while the app stays foregrounded and `get_system_color_scheme` returned the stale pinned value. Detect appearance at the window-scene level instead — it sits above the per-window override — and push changes to JS via `window.onNativeColorSchemeChange`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+54
-3
@@ -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) {
|
||||
|
||||
@@ -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<AppService> = {}) =>
|
||||
({ 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 });
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user