From 0ae41e2eb2654ee5e8a2d12fbdc762f1e44ce0bc Mon Sep 17 00:00:00 2001 From: zhangzheng <826193720@qq.com> Date: Thu, 30 Jul 2026 15:40:32 +0800 Subject: [PATCH] fix theme --- .../desktop/src/api/ohos/ohos_file_system.rs | 23 ++- src/apps/desktop/src/theme.rs | 26 +++- .../types/libbitfun_desktop_lib/Index.d.ts | 3 +- .../main/ets/entryability/EntryAbility.ets | 86 +++++++++++- .../core/src/util/register_arkts_function.rs | 56 ++++++++ .../api/service-api/WorkspaceAPI.ts | 27 +++- .../theme/core/ThemeService.test.ts | 74 +++++++++- .../infrastructure/theme/core/ThemeService.ts | 132 +++++++++++++++--- 8 files changed, 384 insertions(+), 43 deletions(-) diff --git a/src/apps/desktop/src/api/ohos/ohos_file_system.rs b/src/apps/desktop/src/api/ohos/ohos_file_system.rs index ae7ead7c8e..22ed9dadb2 100644 --- a/src/apps/desktop/src/api/ohos/ohos_file_system.rs +++ b/src/apps/desktop/src/api/ohos/ohos_file_system.rs @@ -15,17 +15,32 @@ pub async fn open_oh_file_dialog(options: Option) -> Result Result<(), String> { - let function = { +pub async fn set_theme_mode(mode: String) -> Result { + let function = { let lock = JS_THREADSAFE_FUNCTION.read(); lock.get("set_theme_mode").cloned() }; let Some(function) = function else { return Err("The Arkts has not register the function".to_owned()); }; - function.call(Ok(theme),ThreadsafeFunctionCallMode::NonBlocking); - Ok(()) + // call_async + promise.await so the ArkTS callback's return value (the system + // color mode for `system`) reaches the web-ui. Fixed modes return "". + let promise = function + .call_async(Ok(mode)) + .await + .map_err(|e| e.to_string())?; + let result = promise.await.map_err(|e| e.to_string())?; + Ok(result) } #[tauri::command] diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 8a88f4211f..bcce006a71 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -163,11 +163,14 @@ const MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES: usize = 64 * 1024; impl Default for ThemeConfig { fn default() -> Self { - let default_light_theme_id = Self::startup_theme_bootstrap_manifest() - .default_light_theme_id + // Default to the dark theme: the app ships a dark-first appearance, so the + // native splash window and the webview bootstrap both open dark before the + // persisted user selection (if any) is read back. + let default_dark_theme_id = Self::startup_theme_bootstrap_manifest() + .default_dark_theme_id .as_str(); - let mut theme = Self::get_builtin_theme(default_light_theme_id) - .expect("startup theme bootstrap manifest must include the default light theme"); + let mut theme = Self::get_builtin_theme(default_dark_theme_id) + .expect("startup theme bootstrap manifest must include the default dark theme"); theme.selection_id = None; theme } @@ -254,7 +257,7 @@ impl ThemeConfig { .themes .as_ref() .map(|t| t.current.as_str()) - .unwrap_or("bitfun-light"); + .unwrap_or("bitfun-dark"); let resolved_id = Self::resolve_builtin_theme_id(theme_id); @@ -277,9 +280,18 @@ impl ThemeConfig { } /// Maps config `themes.current` to a built-in id for splash / window chrome. - /// `system` follows OS light/dark (aligned with web-ui `getSystemPreferredDefaultThemeId`). + /// + /// `system` follows OS light/dark: here we default to the dark builtin so the + /// native splash window opens dark (the app's default appearance) while the + /// web-ui runtime resolves the real system preference via `prefers-color-scheme`. + /// Any concrete builtin id is returned as-is so its own palette drives the splash. fn resolve_builtin_theme_id(theme_id: &str) -> &str { - "system" + if theme_id == "system" { + return Self::startup_theme_bootstrap_manifest() + .default_dark_theme_id + .as_str(); + } + theme_id } fn startup_messages_json(locale: &str) -> String { diff --git a/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts b/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts index 2a868b5cd4..047a7eb1ae 100644 --- a/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts +++ b/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts @@ -1,4 +1,5 @@ export declare function registerArktsFunction(funcName: string, callback: ((err: Error | null, arg: string) => Promise)): void; export declare function setBuildResult(msg: string): void; export declare function ohosMarkCleanShutdown(): void; -export declare function getAppConfigBool(path: string): boolean; \ No newline at end of file +export declare function getAppConfigBool(path: string): boolean; +export declare function notifySystemColorMode(mode: string): void; \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets index a5fc4ab474..68e602362f 100644 --- a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -2,6 +2,7 @@ import { abilityAccessCtrl, AbilityConstant, common, + Configuration, ConfigurationConstant, Permissions, Want @@ -36,6 +37,10 @@ export default class EntryAbility extends RustAbility { async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise { super.onCreate(want, launchParam); this.commonEventListener = new CommonEventListener(); + // Cold-start best-effort report of the current system color mode so the + // web-ui "follow system" selection resolves correctly without waiting for + // the first system change. web-ui dedups when it equals the resolved theme. + this.notifySystemColorMode(); } onDestroy(): void { @@ -43,6 +48,45 @@ export default class EntryAbility extends RustAbility { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); } + /** + * HarmonyOS calls this when the system configuration changes, including the + * light/dark color mode. We forward the new mode to the native module, which + * emits an event the web-ui listens for to re-resolve "follow system" live — + * the OHOS webview does not fire prefers-color-scheme change events on its own. + */ + onConfigurationUpdate(newConfig: Configuration): void { + const mode = this.colorModeToString(newConfig?.colorMode); + if (mode.length > 0) { + try { + RustModule.notifySystemColorMode(mode); + hilog.info(DOMAIN, 'vnext', 'onConfigurationUpdate: system color mode -> ' + mode); + } catch (e) { + hilog.warn(DOMAIN, 'vnext', 'notifySystemColorMode failed: ' + JSON.stringify(e)); + } + } + } + + /** Read the current Ability config color mode and push it to the web-ui. */ + private notifySystemColorMode(): void { + const mode = this.readSystemColorMode(); + try { + RustModule.notifySystemColorMode(mode); + } catch (e) { + hilog.warn(DOMAIN, 'vnext', 'notifySystemColorMode failed: ' + JSON.stringify(e)); + } + } + + /** Map a ConfigurationConstant.ColorMode value to 'light' | 'dark' (empty if unset). */ + private colorModeToString(colorMode: number | undefined): string { + if (colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT) { + return 'light'; + } + if (colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK) { + return 'dark'; + } + return ''; + } + onWindowStageCreate(windowStage: window.WindowStage): Promise { AppGlobal.calendarMgr = calendarManager.getCalendarManager(this.context); AppGlobal.mContext = this.context; @@ -214,12 +258,24 @@ export default class EntryAbility extends RustAbility { return ''; }); RustModule.registerArktsFunction('set_theme_mode', async (err: Error, arg: string): Promise => { - if (arg === 'bitfun-light' || arg === 'bitfun-china-style') { - this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT) - } else { - this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK) + // arg is the color mode the webview should adopt: 'light' | 'dark' | 'system'. + // - 'light'/'dark' pin the native override so the webview matches a fixed theme. + // - 'system' releases the override (COLOR_MODE_NOT_SET) and returns the real + // system color mode ('light' | 'dark') so the web-ui can resolve a concrete + // theme without relying on `prefers-color-scheme`, which the OHOS webview does + // not update live (the web-ui also polls this return value to follow system). + // Fixed themes return '' — the web-ui already knows the resolved theme id. + const appContext = this.context.getApplicationContext(); + if (arg === 'light') { + appContext.setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT); + return ''; } - return ''; + if (arg === 'dark') { + appContext.setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK); + return ''; + } + appContext.setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + return this.readSystemColorMode(); }); this.voiceInputService.setContext(this.context); RustModule.registerArktsFunction('ohos_speech_start', async (err: Error, arg: string): Promise => { @@ -248,6 +304,26 @@ export default class EntryAbility extends RustAbility { return super.onWindowStageCreate(windowStage); } + /** + * Resolve the current system color mode as a plain 'light' | 'dark' string. + * Reads the Ability config's colorMode; falls back to dark (the app's default + * appearance) when the config is unavailable or unset. Called right after + * releasing the native color override so the web-ui can resolve a concrete + * theme without depending on `prefers-color-scheme` (which the OHOS webview + * does not emit). + */ + private readSystemColorMode(): string { + try { + const mode = this.colorModeToString(this.context?.config?.colorMode); + if (mode.length > 0) { + return mode; + } + } catch (e) { + hilog.warn(DOMAIN, 'vnext', 'readSystemColorMode failed: ' + JSON.stringify(e)); + } + return 'dark'; + } + private shareListening() { hilog.info(0x0000, 'vnext', 'shareListening'); if (this.remoteUrl.length != 0 && !this.shareStatus) { diff --git a/src/crates/assembly/core/src/util/register_arkts_function.rs b/src/crates/assembly/core/src/util/register_arkts_function.rs index f001cae47e..bb79402325 100644 --- a/src/crates/assembly/core/src/util/register_arkts_function.rs +++ b/src/crates/assembly/core/src/util/register_arkts_function.rs @@ -1,3 +1,4 @@ +use crate::infrastructure::events::{emit_global_event, BackendEvent}; use lazy_static::lazy_static; use napi_derive_ohos::napi; use napi_ohos::bindgen_prelude::Promise; @@ -5,6 +6,7 @@ use napi_ohos::threadsafe_function::ThreadsafeFunction; use parking_lot::RwLock; use std::collections::HashMap; use std::sync::Arc; +use std::sync::OnceLock; lazy_static! { pub static ref JS_THREADSAFE_FUNCTION: RwLock>>>> = Default::default(); @@ -19,6 +21,60 @@ pub fn register_arkts_function( .insert(function_name, Arc::new(callback)); } +/// The OHOS-side event name the web-ui listens for to follow live system color +/// mode changes. Defined here so rust and the web-ui reference the same string. +pub const SYSTEM_COLOR_SCHEME_CHANGED_EVENT: &str = "bitfun:system-color-scheme-changed"; + +/// Dedicated single-threaded tokio runtime for `notify_system_color_mode`. The +/// `#[napi]` callback runs on a HarmonyOS thread that has no tokio runtime in +/// context, so we cannot rely on `Handle::try_current()` captured at host init +/// (the Tauri `.setup` closure is not guaranteed to run on a tokio-driven thread +/// on OHOS — unlike desktop, which is why the earlier capture-from-setup design +/// silently left this `None` and dropped every color-mode change). Instead we +/// lazily build a runtime here, mirroring the proven pattern in +/// `get_app_config_bool` (`system_api.rs`): `OnceLock` + `get_or_init`. +static SYSTEM_COLOR_MODE_RUNTIME: OnceLock = OnceLock::new(); + +/// Lazily creates (on first call) the runtime used to drive +/// `emit_global_event` from the napi callback. Keeping a persistent runtime +/// (rather than building one per call) avoids re-creating the reactor and +/// thread on every system color-mode change. +fn system_color_mode_runtime() -> &'static tokio::runtime::Runtime { + SYSTEM_COLOR_MODE_RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build system color mode runtime") + }) +} + +/// Called from ArkTS (`NativeModule.notifySystemColorMode`) when the HarmonyOS +/// system color mode changes (via `EntryAbility.onConfigurationUpdate`) or on a +/// cold-start best-effort initial report. Forwards the color mode +/// (`"light"` | `"dark"`) to the web-ui through the global event system, which +/// re-resolves the "follow system" theme without polling. Runs the emit to +/// completion on the dedicated runtime (blocking the HarmonyOS callback thread +/// briefly, same as `get_app_config_bool`); `emit_global_event` is a fast +/// channel send, so the blocking is negligible. +#[napi] +pub fn notify_system_color_mode(mode: String) { + let normalized = match mode.as_str() { + "light" | "dark" => mode, + _ => return, + }; + system_color_mode_runtime().block_on(async move { + let payload = serde_json::json!({ "scheme": normalized }); + if let Err(error) = emit_global_event(BackendEvent::Custom { + event_name: SYSTEM_COLOR_SCHEME_CHANGED_EVENT.to_string(), + payload, + }) + .await + { + log::warn!("Failed to emit system color mode change: {error}"); + } + }); +} + pub async fn open_dialog_file(options: &str) -> Result { let function = { let lock = JS_THREADSAFE_FUNCTION.read(); diff --git a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts index cdee089a9f..3ce587d191 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts @@ -1102,13 +1102,32 @@ export class WorkspaceAPI { } } - async setThemeMode(theme: string): Promise { + /** + * Tell the native shell which color mode the webview should adopt. + * + * `mode` is `"light"`, `"dark"`, or `"system"`: + * - `light`/`dark` pin the native color override so the webview matches a + * fixed user-chosen theme; the native side returns an empty string. + * - `system` releases the override and the native side returns the real + * system color mode (`"light"` or `"dark"`), so the web-ui can resolve a + * concrete theme without relying on `prefers-color-scheme` (which the OHOS + * webview does not update live). ThemeService also polls this return value + * to follow live system theme changes on platforms where matchMedia is inert. + * + * Note: this carries only the color mode, not the theme id. The full theme + * (colors, typography, etc.) is applied independently via CSS variables. + * + * @returns the resolved system color mode for `system` (`"light"`/`"dark"`), + * or `""` for fixed `light`/`dark` modes. + */ + async setThemeMode(mode: 'light' | 'dark' | 'system'): Promise { try { - await api.invoke('set_theme_mode', { - theme + const result = await api.invoke('set_theme_mode', { + mode }); + return typeof result === 'string' ? result : ''; } catch (error) { - throw createTauriCommandError('set_theme_mode', error, { theme }); + throw createTauriCommandError('set_theme_mode', error, { mode }); } } diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts index ba66b8760b..1626958876 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts @@ -1,7 +1,7 @@ import { JSDOM } from 'jsdom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { configAPI } from '@/infrastructure/api'; +import { configAPI, workspaceAPI } from '@/infrastructure/api'; import { bitfunDarkTheme, bitfunLightTheme } from '../presets'; import { PLUGIN_THEME_COLOR_KEYS, @@ -10,6 +10,12 @@ import { import { SYSTEM_THEME_ID, type ThemeConfig } from '../types'; import { ThemeService } from './ThemeService'; +// Hoisted so the ApiClient mock below can reference the same listen mock that +// tests use to capture / fire the native "system color scheme changed" event. +const mocks = vi.hoisted(() => ({ + listen: vi.fn().mockReturnValue(() => undefined), +})); + function expectThemeError( result: ReturnType, path: string, @@ -86,6 +92,13 @@ vi.mock('@/infrastructure/api', () => ({ getConfig: vi.fn(), setConfig: vi.fn().mockResolvedValue(undefined), }, + workspaceAPI: { + setThemeMode: vi.fn().mockResolvedValue('light'), + }, +})); + +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { listen: mocks.listen }, })); vi.mock('../integrations/MonacoThemeSync', () => ({ @@ -126,6 +139,8 @@ describe('ThemeService runtime theme tokens', () => { delete bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_SELECTION__; vi.mocked(configAPI.getConfig).mockResolvedValue(undefined); vi.mocked(configAPI.setConfig).mockResolvedValue(undefined); + vi.mocked(workspaceAPI.setThemeMode).mockResolvedValue('light'); + mocks.listen.mockReturnValue(() => undefined); }); afterEach(() => { @@ -236,6 +251,63 @@ describe('ThemeService runtime theme tokens', () => { expect(rootStyle.getPropertyValue('--color-overlay-black-30')).toBe('rgba(0, 0, 0, 0.3)'); }); + it('resolves "follow system" from the native shell color mode, not stale prefers-color-scheme', async () => { + // Pretend the OS is in dark mode even though the matchMedia stub reports + // light (matches: false). The native shell return value must win, because the + // OHOS webview does not update prefers-color-scheme live. + vi.mocked(workspaceAPI.setThemeMode).mockResolvedValue('dark'); + const service = new ThemeService(); + + await service.applyTheme(SYSTEM_THEME_ID); + + // Resolved theme follows the native-reported dark mode. + expect(service.getResolvedThemeId()).toBe('bitfun-dark'); + expect(service.getCurrentThemeId()).toBe(SYSTEM_THEME_ID); + expect(document.documentElement.style.getPropertyValue('--color-bg-primary')) + .toBe(bitfunDarkTheme.colors.background.primary); + // The system branch releases the native override (setThemeMode('system')) and + // skips the redundant shell call inside applyResolvedTheme. + expect(workspaceAPI.setThemeMode).toHaveBeenCalledTimes(1); + expect(workspaceAPI.setThemeMode).toHaveBeenCalledWith('system'); + }); + + it('falls back to prefers-color-scheme when the native shell returns no color mode', async () => { + vi.mocked(workspaceAPI.setThemeMode).mockResolvedValue(''); + const service = new ThemeService(); + + await service.applyTheme(SYSTEM_THEME_ID); + + // matchMedia reports light (matches: false) → bitfun-light. + expect(service.getResolvedThemeId()).toBe('bitfun-light'); + }); + + it('re-resolves "follow system" when the native shell emits a color-mode event', async () => { + // Start in light (native returns light). + vi.mocked(workspaceAPI.setThemeMode).mockResolvedValue('light'); + const service = new ThemeService(); + await service.applyTheme(SYSTEM_THEME_ID); + expect(service.getResolvedThemeId()).toBe('bitfun-light'); + + // The native shell registered a listener via api.listen — capture it. + expect(mocks.listen).toHaveBeenCalledWith( + 'bitfun:system-color-scheme-changed', + expect.any(Function), + ); + const calls = mocks.listen.mock.calls; + const [, listener] = calls[calls.length - 1] as [string, (p: { scheme?: string }) => void]; + + // OS switches to dark. After re-resolve, setThemeMode is NOT called again + // (the shell already knows the mode; applyResolvedTheme uses skipShell). + listener({ scheme: 'dark' }); + // applyResolvedTheme is async (hooks/emit/monaco); flush the microtask chain. + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(service.getResolvedThemeId()).toBe('bitfun-dark'); + expect(document.documentElement.style.getPropertyValue('--color-bg-primary')) + .toBe(bitfunDarkTheme.colors.background.primary); + expect(workspaceAPI.setThemeMode).toHaveBeenCalledTimes(1); + }); + it('initializes from bootstrap theme selection without reading or writing themes.current', async () => { bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_ID__ = 'bitfun-slate'; bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_SELECTION__ = 'bitfun-slate'; diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts index f2e4806c60..d1b61ef2ad 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts @@ -13,14 +13,30 @@ import { SYSTEM_THEME_ID, ThemeSelectionId, } from '../types'; -import { builtinThemes, getSystemPreferredDefaultThemeId } from '../presets'; +import { + builtinThemes, + getSystemPreferredDefaultThemeId, + DEFAULT_LIGHT_THEME_ID, + DEFAULT_DARK_THEME_ID, +} from '../presets'; import { configAPI, workspaceAPI } from '@/infrastructure/api'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { themeValidator } from '../utils/ThemeValidator'; import { monacoThemeSync } from '../integrations/MonacoThemeSync'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('ThemeService'); +/** + * Native→web event carrying the current system color mode (`{ scheme: 'light' | 'dark' }`). + * Emitted by the OHOS shell (EntryAbility.onConfigurationUpdate → rust + * notify_system_color_mode → global event system) whenever the OS switches + * light/dark — the OHOS webview does not fire `prefers-color-scheme` `change` + * events, so this is the live-follow signal on that platform. Desktop relies on + * the matchMedia listener; this event is a harmless backstop there. + */ +const SYSTEM_COLOR_SCHEME_CHANGED_EVENT = 'bitfun:system-color-scheme-changed'; + const FLOW_CHAT_LINK_COLORS = { dark: { default: '#60a5fa', @@ -525,28 +541,84 @@ export class ThemeService { } private attachSystemThemeListener(): void { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - return; - } if (this.systemThemeCleanup) { return; } - const mq = window.matchMedia('(prefers-color-scheme: dark)'); - const handler = () => { - if (this.themeSelection !== SYSTEM_THEME_ID) { - return; - } - const next = getSystemPreferredDefaultThemeId(); - if (next === this.resolvedThemeId) { - return; + const cleanups: Array<() => void> = []; + + // Desktop fast path: matchMedia fires `change` when the OS switches mode. + if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => { + if (this.themeSelection !== SYSTEM_THEME_ID) { + return; + } + const next = getSystemPreferredDefaultThemeId(); + if (next === this.resolvedThemeId) { + return; + } + void this.applyResolvedTheme(next, 'system'); + }; + mq.addEventListener('change', handler); + cleanups.push(() => mq.removeEventListener('change', handler)); + } + + // OHOS (and harmless backstop on desktop): the native shell emits a custom + // event carrying the OS color mode, because the OHOS webview does not fire + // `prefers-color-scheme` `change` events. The shell pushes it from + // EntryAbility.onConfigurationUpdate → rust notify_system_color_mode. + const unlisten = api.listen<{ scheme?: 'light' | 'dark' }>( + SYSTEM_COLOR_SCHEME_CHANGED_EVENT, + (payload) => { + if (this.themeSelection !== SYSTEM_THEME_ID) { + return; + } + const scheme = payload?.scheme; + if (scheme !== 'light' && scheme !== 'dark') { + return; + } + const next = scheme === 'dark' ? DEFAULT_DARK_THEME_ID : DEFAULT_LIGHT_THEME_ID; + if (next === this.resolvedThemeId) { + return; + } + // The shell already released the native override when it read the color + // mode, so skip the redundant shell call in applyResolvedTheme. + void this.applyResolvedTheme(next, 'system', { skipShell: true }); + }, + ); + cleanups.push(() => unlisten()); + + this.systemThemeCleanup = () => cleanups.forEach(fn => fn()); + } + + /** + * Resolve the current system color mode by asking the native shell to release + * its color override and report back. Falls back to `prefers-color-scheme` + * (the desktop fast path) when the native shell is unavailable or errors. + * Returns the color mode plus the concrete built-in theme id it maps to. + */ + private async discoverSystemTheme(): Promise<{ color: 'light' | 'dark'; themeId: ThemeId }> { + let color: 'light' | 'dark' | '' = ''; + try { + const raw = await workspaceAPI.setThemeMode('system'); + if (raw === 'light' || raw === 'dark') { + color = raw; } - void this.applyResolvedTheme(next); - }; - mq.addEventListener('change', handler); - this.systemThemeCleanup = () => mq.removeEventListener('change', handler); + } catch (error) { + log.warn('Failed to query system color mode from native shell', error); + } + if (color !== 'light' && color !== 'dark') { + // Desktop / non-native: trust prefers-color-scheme. + color = getSystemPreferredDefaultThemeId() === DEFAULT_DARK_THEME_ID ? 'dark' : 'light'; + } + return { color, themeId: color === 'dark' ? DEFAULT_DARK_THEME_ID : DEFAULT_LIGHT_THEME_ID }; } - private async applyResolvedTheme(resolvedId: ThemeId): Promise { + private async applyResolvedTheme( + resolvedId: ThemeId, + mode: 'light' | 'dark' | 'system', + options: { skipShell?: boolean } = {}, + ): Promise { const theme = this.themes.get(resolvedId); if (!theme) { log.error('Theme not found', { id: resolvedId }); @@ -565,7 +637,20 @@ export class ThemeService { this.injectCSSVariables(theme); - await workspaceAPI.setThemeMode(resolvedId); + // Push the color mode to the native shell. `system` releases the native + // override so prefers-color-scheme follows the OS; light/dark pin it so + // the webview matches a fixed user-chosen theme. Callers that already + // released the override (and read back the system color) pass skipShell + // to avoid a redundant round trip. This is best-effort: on platforms with + // no native color-mode shell (e.g. desktop wry), the call errors and we + // carry on — the CSS variables above already drove the appearance. + if (!options.skipShell) { + try { + await workspaceAPI.setThemeMode(mode); + } catch (error) { + log.warn('Native setThemeMode unavailable or failed; CSS variables still applied', error); + } + } try { monacoThemeSync.syncTheme(theme); @@ -604,8 +689,12 @@ export class ThemeService { this.lastSavedSelection = SYSTEM_THEME_ID; } this.attachSystemThemeListener(); - const resolved = getSystemPreferredDefaultThemeId(); - await this.applyResolvedTheme(resolved); + // Release the native override and read back the *real* system color mode + // before injecting CSS variables — the OHOS webview does not update + // prefers-color-scheme live, so reading matchMedia here would return the + // stale pinned value from a previously selected fixed theme. + const { themeId: resolved } = await this.discoverSystemTheme(); + await this.applyResolvedTheme(resolved, 'system', { skipShell: true }); } else { this.themeSelection = themeId; if (options.persist) { @@ -613,7 +702,8 @@ export class ThemeService { } else { this.lastSavedSelection = themeId; } - await this.applyResolvedTheme(themeId); + const resolvedTheme = this.themes.get(themeId); + await this.applyResolvedTheme(themeId, resolvedTheme?.type ?? 'dark'); } }