Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions src/apps/desktop/src/api/ohos/ohos_file_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,32 @@ pub async fn open_oh_file_dialog(options: Option<String>) -> Result<String, Stri
open_dialog_file(opts).await
}

/// Tell the HarmonyOS shell which color mode the webview should adopt.
///
/// `mode` is one of `"light"`, `"dark"`, or `"system"`:
/// - `light`/`dark` — pin the app to that appearance; the ArkTS side returns `""`.
/// - `system` — release the override (`COLOR_MODE_NOT_SET`) and the ArkTS 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. The web-ui also polls this return value to
/// follow live system theme changes.
#[tauri::command]
pub async fn set_theme_mode(theme: String) -> Result<(), String> {
let function = {
pub async fn set_theme_mode(mode: String) -> Result<String, String> {
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]
Expand Down
26 changes: 19 additions & 7 deletions src/apps/desktop/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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);

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export declare function registerArktsFunction(funcName: string, callback: ((err: Error | null, arg: string) => Promise<string>)): void;
export declare function setBuildResult(msg: string): void;
export declare function ohosMarkCleanShutdown(): void;
export declare function getAppConfigBool(path: string): boolean;
export declare function getAppConfigBool(path: string): boolean;
export declare function notifySystemColorMode(mode: string): void;
86 changes: 81 additions & 5 deletions src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
abilityAccessCtrl,
AbilityConstant,
common,
Configuration,
ConfigurationConstant,
Permissions,
Want
Expand Down Expand Up @@ -36,13 +37,56 @@ export default class EntryAbility extends RustAbility {
async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
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 {
RustModule.ohosMarkCleanShutdown();
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<void> {
AppGlobal.calendarMgr = calendarManager.getCalendarManager(this.context);
AppGlobal.mContext = this.context;
Expand Down Expand Up @@ -214,12 +258,24 @@ export default class EntryAbility extends RustAbility {
return '';
});
RustModule.registerArktsFunction('set_theme_mode', async (err: Error, arg: string): Promise<string> => {
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<string> => {
Expand Down Expand Up @@ -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) {
Expand Down
56 changes: 56 additions & 0 deletions src/crates/assembly/core/src/util/register_arkts_function.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use crate::infrastructure::events::{emit_global_event, BackendEvent};
use lazy_static::lazy_static;
use napi_derive_ohos::napi;
use napi_ohos::bindgen_prelude::Promise;
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<HashMap<String, Arc<ThreadsafeFunction<String, Promise<String>>>>> =
Default::default();
Expand All @@ -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<Runtime>` + `get_or_init`.
static SYSTEM_COLOR_MODE_RUNTIME: OnceLock<tokio::runtime::Runtime> = 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<String, String> {
let function = {
let lock = JS_THREADSAFE_FUNCTION.read();
Expand Down
27 changes: 23 additions & 4 deletions src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1102,13 +1102,32 @@ export class WorkspaceAPI {
}
}

async setThemeMode(theme: string): Promise<void> {
/**
* 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<string> {
try {
await api.invoke('set_theme_mode', {
theme
const result = await api.invoke<string>('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 });
}
}

Expand Down
Loading
Loading