fix(ios): refresh appearance on system light/dark change, closes #4057 (#4210)

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:
Huang Xin
2026-05-18 01:11:32 +08:00
committed by GitHub
parent 952304a956
commit 689537fd78
3 changed files with 106 additions and 8 deletions
@@ -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 });
+19 -4
View File
@@ -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();
};