diff --git a/crates/ability/src/render/xcomponent.rs b/crates/ability/src/render/xcomponent.rs index 5f3234de..0a0c51ae 100644 --- a/crates/ability/src/render/xcomponent.rs +++ b/crates/ability/src/render/xcomponent.rs @@ -32,6 +32,12 @@ pub fn render( crate::error!("init_clipboard_tsfn failed: {}", e); } + // Initialize vibrancy ThreadsafeFunctions (set_window_blur / set_window_background_color) + // for cross-thread calls without needing the main-thread thread_local Env. + if let Err(e) = crate::window::init_vibrancy_tsfn(env) { + crate::error!("init_vibrancy_tsfn failed: {}", e); + } + // Initialize permission request threadsafe function let _ = create_permission_request_tsfn(env); diff --git a/crates/ability/src/window/mod.rs b/crates/ability/src/window/mod.rs index 1765ba77..3eb5ff5d 100644 --- a/crates/ability/src/window/mod.rs +++ b/crates/ability/src/window/mod.rs @@ -1,6 +1,9 @@ -use crate::{get_helper, get_main_thread_env}; use napi_ohos::bindgen_prelude::*; +use napi_ohos::threadsafe_function::{ThreadsafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode}; +use napi_ohos::Env; +use crate::{get_helper, get_main_thread_env}; use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::OnceLock; /// Global window ID generator to ensure unique IDs across Rust and ArkTS. static NEXT_WINDOW_ID: AtomicI64 = AtomicI64::new(1); @@ -165,26 +168,84 @@ pub fn set_window_decorations(window_id: i64, decorations: bool) -> napi_ohos::R /// `color` is in `0xAARRGGBB` format (e.g., `0x00000000` = fully transparent). /// /// Phase 3 implementation. + +// ─── TSFN for cross-thread vibrancy calls (threadsafe, no main-thread Env needed) ─── +// Fire-and-forget (NonBlocking, no return value wait): applyWindowBlur queues pendingBlurs +// (build-time inject via registerController) or calls setAllWebviewsBlurRadius (runtime +// modifier refresh), both idempotent, so no synchronous result needed. +type SetWindowBlurTsfn = ThreadsafeFunction<(i64, f64), (), FnArgs<(i64, f64)>, Status, false>; +type SetWindowBgColorTsfn = ThreadsafeFunction<(i64, u32), (), FnArgs<(i64, u32)>, Status, false>; + +static TSFN_SET_WINDOW_BLUR: OnceLock = OnceLock::new(); +static TSFN_SET_WINDOW_BG_COLOR: OnceLock = OnceLock::new(); + +/// Initialize vibrancy ThreadsafeFunctions. Must be called on ArkTS main thread (during +/// ArkHelper setup, like init_clipboard_tsfn). After init, set_window_blur / +/// set_window_background_color are callable from any thread (TSFN is threadsafe, does not +/// need the thread_local MAIN_THREAD_ENV, so no run_on_main_thread required). +pub fn init_vibrancy_tsfn(env: &Env) -> Result<()> { + if TSFN_SET_WINDOW_BLUR.get().is_some() { + return Ok(()); + } + let helper_obj = { + let helper_rc = unsafe { get_helper() }; + let helper_guard = helper_rc.borrow(); + let helper_ref = helper_guard + .as_ref() + .ok_or_else(|| Error::from_reason("ArkHelper not initialized"))?; + helper_ref.get_value(env)? + }; + + let blur_fn: Function<'_, FnArgs<(i64, f64)>, ()> = helper_obj + .get_named_property("setWindowBlur") + .map_err(|e| Error::from_reason(format!("setWindowBlur not found: {}", e)))?; + let blur_tsfn = blur_fn + .build_threadsafe_function::<(i64, f64)>() + .callee_handled::() + .build_callback(move |ctx: ThreadsafeCallContext<(i64, f64)>| { + Ok(FnArgs { data: ctx.value }) + })?; + let _ = TSFN_SET_WINDOW_BLUR.set(blur_tsfn); + + let bg_fn: Function<'_, FnArgs<(i64, u32)>, ()> = helper_obj + .get_named_property("setWindowBackgroundColor") + .map_err(|e| Error::from_reason(format!("setWindowBackgroundColor not found: {}", e)))?; + let bg_tsfn = bg_fn + .build_threadsafe_function::<(i64, u32)>() + .callee_handled::() + .build_callback(move |ctx: ThreadsafeCallContext<(i64, u32)>| { + Ok(FnArgs { data: ctx.value }) + })?; + let _ = TSFN_SET_WINDOW_BG_COLOR.set(bg_tsfn); + + Ok(()) +} + +/// Sets window background color via TSFN (threadsafe, callable from any thread). pub fn set_window_background_color(window_id: i64, color: u32) -> napi_ohos::Result<()> { - let ret = unsafe { get_helper() }; - if let Some(h) = ret.borrow().as_ref() { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let obj = h.get_value(env).map_err(|e| { - crate::error!("Failed to get helper object value: {:?}", e); - e - })?; + let tsfn = TSFN_SET_WINDOW_BG_COLOR.get() + .ok_or_else(|| Error::from_reason("set_window_background_color TSFN not initialized"))?; + let status = tsfn.call((window_id, color), ThreadsafeFunctionCallMode::NonBlocking); + if status != Status::Ok { + return Err(Error::from_reason(format!("TSFN call failed: {:?}", status))); + } + Ok(()) +} - let func = - obj.get_named_property::>("setWindowBackgroundColor")?; - func.call((window_id, color))?; - return Ok(()); - } else { - crate::error!("Main thread env not available"); - } - } else { - crate::error!("Helper object not initialized"); +/// Sets window blur radius via TSFN (threadsafe, callable from any thread). +/// +/// Calls ArkTS `setWindowBlur(windowId, radius)` handler which applies +/// `backdropBlur(radius)` to the WebView container component. +/// +/// `radius` is the blur radius in pixels (0 = no blur). +pub fn set_window_blur(window_id: i64, radius: f64) -> napi_ohos::Result<()> { + let tsfn = TSFN_SET_WINDOW_BLUR.get() + .ok_or_else(|| Error::from_reason("set_window_blur TSFN not initialized"))?; + let status = tsfn.call((window_id, radius), ThreadsafeFunctionCallMode::NonBlocking); + if status != Status::Ok { + return Err(Error::from_reason(format!("TSFN call failed: {:?}", status))); } - Err(Error::from_reason("Helper or Env not initialized")) + Ok(()) } /// Brings a Float sub-window to the front and focuses it. diff --git a/native_ability/src/main/ets/ability/ArkHelper.ets b/native_ability/src/main/ets/ability/ArkHelper.ets index 2fe530ec..653e535c 100644 --- a/native_ability/src/main/ets/ability/ArkHelper.ets +++ b/native_ability/src/main/ets/ability/ArkHelper.ets @@ -317,6 +317,9 @@ export function createArkHelper(): ArkHelper { ret.controller.setBounds = (x: number, y: number, width: number, height: number) => { applyStyle({ x, y, width, height }); }; + ret.controller.setBlurRadius = (radius: number) => { + applyStyle({ blurRadius: radius }); + }; ret.controller.dispose = () => { targetController!.removeWebview(ret.webTag); @@ -398,6 +401,9 @@ export function createArkHelper(): ArkHelper { ret.controller.setBounds = (x: number, y: number, width: number, height: number) => { applyStyle({ x, y, width, height }); }; + ret.controller.setBlurRadius = (radius: number) => { + applyStyle({ blurRadius: radius }); + }; ret.controller.dispose = () => { manager.removeWebview(ret.webTag); @@ -472,6 +478,21 @@ export function createArkHelper(): ArkHelper { } }, + setWindowBlur: (windowId: number, radius: number): void => { + try { + WindowManager.getInstance().applyWindowBlur(windowId, radius); + } catch (_err) { + // Intentionally no hilog here: this is called from Rust NAPI (NAPI-reentrant ArkTS + // context), where hilog throws "Argc mismatch" and would mask the original error. + // Note: the error is NOT propagated to Rust — set_window_blur uses TSFN fire-and-forget + // (NonBlocking, no return value wait), so Rust receives Ok regardless of ArkTS failure. + // applyWindowBlur has internal fallbacks (queue pendingBlurs for build-time inject, + // modifier refresh for runtime), so a throw here is non-fatal. If synchronous error + // propagation is needed later, switch to call_with_return_value + oneshot (like + // clipboard_write_image). + } + }, + loadUrl: (id: number, url: string): void => { try { hilog.info(DOMAIN, 'ArkHelper', 'loadUrl called for window %{public}d: %{public}s', id, url); diff --git a/native_ability/src/main/ets/ability/type.ets b/native_ability/src/main/ets/ability/type.ets index bb4c273a..f245dfdc 100644 --- a/native_ability/src/main/ets/ability/type.ets +++ b/native_ability/src/main/ets/ability/type.ets @@ -136,6 +136,7 @@ export interface ArkHelper { setWindowBackgroundColor: (windowId: number, color: number) => void; focusWindow: (windowId: number) => void; setWindowFocusable: (windowId: number, focusable: boolean) => void; + setWindowBlur: (windowId: number, radius: number) => void; loadUrl: (id: number, url: string) => void; createMenu?: (options: MenuCreateOptions) => Promise; createMenuItem?: (options: MenuItemCreateOptions) => Promise; diff --git a/native_ability/src/main/ets/webview/DefaultWebview.ets b/native_ability/src/main/ets/webview/DefaultWebview.ets index f6478eb6..5016aea6 100644 --- a/native_ability/src/main/ets/webview/DefaultWebview.ets +++ b/native_ability/src/main/ets/webview/DefaultWebview.ets @@ -5,7 +5,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit'; import { randomString } from "../helper"; import { OnDownloadStartResult, OnWindowNewResult } from "../ability/type"; import { getCookies, setCookie, setWebDebuggingAccess, isWebDebuggingAccess, JsHelper, convertRRGGBBAAtoAARRGGBB, SnapshotData } from "./Utils"; -import { WebHeader } from "@kit.ArkUI"; +import { WebHeader, AttributeUpdater } from "@kit.ArkUI"; import { router } from '@kit.ArkUI'; import { WindowManager } from '../window/WindowManager'; import { DOMAIN } from '../helper/constants'; @@ -79,6 +79,19 @@ function handleWindowNew( } } +/** + * AttributeUpdater for runtime backdropBlur refresh. + * BuilderNode.update does not refresh backdropBlur, so we use AttributeUpdater to + * directly modify the Stack's backdropBlur attribute at runtime (setWindowBlur path). + * attribute?.backdropBlur(radius) triggers an immediate component update without @State. + */ +export class BlurModifier extends AttributeUpdater { + initializeModifier(_instance: CommonAttribute): void { + // Intentionally empty: let the build-time Stack.backdropBlur(data.style.blurRadius) take + // effect. Runtime setWindowBlur refreshes via attribute?.backdropBlur(radius). + } +} + export interface WebviewStyle { x?: number | string; y?: number | string; @@ -86,6 +99,7 @@ export interface WebviewStyle { height?: number | string; backgroundColor?: string | Color | number; visible?: boolean; + blurRadius?: number; } export interface WebviewInitData { @@ -117,24 +131,29 @@ export interface WebviewInitData { interface WebviewNodeData extends WebviewInitData { controller: WebviewController; didInitialLoad?: boolean; + blurModifier?: BlurModifier; } @Builder function WebBuilder(data: WebviewNodeData) { // init with empty url and reload with loadUrl or loadData with onControllerAttach - Web({ - src: "", - controller: data.controller as web_webview.WebviewController, - renderMode: data?.transparent ? RenderMode.SYNC_RENDER : RenderMode.ASYNC_RENDER - }) - .width(data.style?.width ?? "100%") - .height(data.style?.height ?? "100%") - .position({ - x: data.style?.x ?? 0, - y: data.style?.y ?? 0, + // NOTE: data.blurModifier is initialized in addWebview (cannot assign inside @Builder). + // Runtime setWindowBlur refreshes backdropBlur via modifier.attribute?.backdropBlur(radius) + // because BuilderNode.update does not refresh backdropBlur. + Stack() { + Web({ + src: "", + controller: data.controller as web_webview.WebviewController, + renderMode: data?.transparent ? RenderMode.SYNC_RENDER : RenderMode.ASYNC_RENDER }) - .backgroundColor(convertRRGGBBAAtoAARRGGBB(data?.style?.backgroundColor)) - .visibility(data?.style?.visible === false ? Visibility.Hidden : Visibility.Visible) + .width(data.style?.width ?? "100%") + .height(data.style?.height ?? "100%") + .position({ + x: data.style?.x ?? 0, + y: data.style?.y ?? 0, + }) + .backgroundColor(convertRRGGBBAAtoAARRGGBB(data?.style?.backgroundColor)) + .visibility(data?.style?.visible === false ? Visibility.Hidden : Visibility.Visible) .javaScriptAccess(data?.javascriptEnable) .domStorageAccess(true) .mediaPlayGestureAccess( @@ -243,6 +262,12 @@ function WebBuilder(data: WebviewNodeData) { .onWindowNew((event) => { handleWindowNew(event, data, 'WebBuilder'); }); + } + .width("100%") + .height("100%") + .backgroundColor(Color.Transparent) + .backdropBlur(data?.style?.blurRadius ?? 0) + .attributeModifier(data.blurModifier) } @Builder @@ -654,6 +679,7 @@ function buildJsHelper(controller: WebviewController): JsHelper { setBackgroundColor: (_color: number) => {}, setVisible: (_visible: boolean) => {}, setBounds: (_x: number, _y: number, _width: number, _height: number) => {}, + setBlurRadius: (_radius: number) => {}, dispose: () => {}, createPdf, } as JsHelper; @@ -679,9 +705,39 @@ export class RustWebviewNodeController extends NodeController { const newEntry: WebviewNodeData = { ...entry, style: style }; this.webviewEntries.set(webTag, newEntry); + // Runtime refresh via AttributeUpdater — node.update does not refresh backdropBlur/backgroundColor, + // so Acrylic/Mica tints (set_window_background_color) and blur radius need modifier.attribute to + // trigger an immediate component update. + if (entry.blurModifier) { + if (style.backgroundColor !== undefined && style.backgroundColor !== null) { + entry.blurModifier.attribute?.backgroundColor(style.backgroundColor); + } + if (style.blurRadius !== undefined) { + entry.blurModifier.attribute?.backdropBlur(style.blurRadius); + } + } node.update(newEntry); } + /** + * Update blur radius for all webviews managed by this controller. + * Called by WindowManager.setWindowBlur() to apply window-level blur effect. + */ + setAllWebviewsBlurRadius(radius: number): void { + for (const [webTag, entry] of this.webviewEntries) { + // Runtime refresh via AttributeUpdater — BuilderNode.update does not refresh backdropBlur, + // so call modifier.attribute?.backdropBlur(radius) which triggers an immediate update. + if (entry.blurModifier) { + entry.blurModifier.attribute?.backdropBlur(radius); + } + // Keep style.blurRadius in sync so any subsequent re-build preserves the value. + if (!entry.style) { + entry.style = {}; + } + entry.style.blurRadius = radius; + } + } + private buildData(controller: WebviewController) { return buildJsHelper(controller); } @@ -699,6 +755,9 @@ export class RustWebviewNodeController extends NodeController { data.uiContext = this.uiContext; } const prepared = ensureWebviewNodeData(data); + if (!prepared.blurModifier) { + prepared.blurModifier = new BlurModifier(); + } const webTag = prepared.webTag!; if (this.rootNode === null) { this.rootNode = new FrameNode(this.uiContext!); @@ -785,6 +844,16 @@ export class EmbeddedWebviewManager { entry.content.update(newData); } + /** + * Update blur radius for all embedded webviews managed by this manager. + * NOTE: EmbeddedWebBuilder does not attach a BlurModifier and content.update does not refresh + * backdropBlur, so this is a no-op. Vibrancy is only supported via RustWebviewNodeController + * (WebBuilder path). Kept as no-op to satisfy the controller interface. + */ + setAllWebviewsBlurRadius(_radius: number): void { + // No-op: embedded webviews do not support runtime backdropBlur refresh. + } + removeWebview(webTag: string) { const entry = this.entries.get(webTag); if (!entry) { @@ -802,6 +871,7 @@ declare class WebviewController extends web_webview.WebviewController { setBackgroundColor: (color: number) => void; setVisible: (visible: boolean) => void; setBounds: (x: number, y: number, width: number, height: number) => void; + setBlurRadius: (radius: number) => void; dispose: () => void; clearAllBrowsingData: () => void; } diff --git a/native_ability/src/main/ets/webview/Utils.ets b/native_ability/src/main/ets/webview/Utils.ets index 41eef116..247e8b7d 100644 --- a/native_ability/src/main/ets/webview/Utils.ets +++ b/native_ability/src/main/ets/webview/Utils.ets @@ -77,6 +77,7 @@ export interface JsHelper { setBackgroundColor: (color: number) => void; setVisible: (visible: boolean) => void; setBounds: (x: number, y: number, width: number, height: number) => void; + setBlurRadius: (radius: number) => void; dispose: () => void; clearAllBrowsingData: () => void; webPageSnapshot: () => Promise; @@ -210,6 +211,14 @@ export class ProxyJsHelper implements JsHelper { } } + setBlurRadius(radius: number): void { + if (this.realController) { + this.realController.setBlurRadius(radius); + } else { + this.pendingOperations.push(() => this.realController!.setBlurRadius(radius)); + } + } + dispose(): void { if (this.realController) { this.realController.dispose(); diff --git a/native_ability/src/main/ets/window/WindowManager.ets b/native_ability/src/main/ets/window/WindowManager.ets index 6ae68f41..7372cea4 100644 --- a/native_ability/src/main/ets/window/WindowManager.ets +++ b/native_ability/src/main/ets/window/WindowManager.ets @@ -59,6 +59,8 @@ export class WindowManager { private pendingUrls: Map = new Map(); // Pending WebView init queue (resolves race conditions) private pendingInits: Map = new Map(); + // Pending window blur queue (vibrancy effect applied before webview controller is ready) + private pendingBlurs: Map = new Map(); private constructor(context: common.UIAbilityContext) { this.context = context; @@ -444,12 +446,27 @@ export class WindowManager { // Trigger load when page initialization is complete and Controller is registered registerController(id: number, controller: RustWebviewNodeController): void { hilog.info(DOMAIN, 'WindowManager', `Controller registered for window: %{public}d`, id); + // pendingBlurs key is normalized to number (applyWindowBlur stores Number(windowId) + // because Rust NAPI i64 arrives as BigInt). + const pb = this.pendingBlurs.get(Number(id)); + if (pb !== undefined) { + this.pendingBlurs.delete(Number(id)); + } + const injectBlur = (data: WebviewInitData): void => { + if (pb !== undefined) { + if (!data.style) { + data.style = {}; + } + data.style.blurRadius = pb; + } + }; this.controllers.set(id, controller); // 1. Process pending WebView init requests (resolves race conditions) const pendingInit = this.pendingInits.get(id); if (pendingInit) { hilog.info(DOMAIN, 'WindowManager', `Processing pending init for window ${id}`); + injectBlur(pendingInit); const result = controller.addWebview(pendingInit); this.pendingInits.delete(id); @@ -488,7 +505,9 @@ export class WindowManager { const pendingUrl = this.pendingUrls.get(id); if (pendingUrl) { hilog.info(DOMAIN, 'WindowManager', `Pending URL found! Triggering load for window ${id}: ${pendingUrl}`); - controller.addWebview({ url: pendingUrl }); + const urlData: WebviewInitData = { url: pendingUrl }; + injectBlur(urlData); + controller.addWebview(urlData); this.pendingUrls.delete(id); } else { hilog.info(DOMAIN, 'WindowManager', `No pending URL for window ${id}. Waiting for command.`); @@ -583,7 +602,24 @@ export class WindowManager { } } - // Phase 2: Hide system status bar for main window (decorations=false) + // Window vibrancy: Apply backdrop blur to WebView container component. + // NOTE: backdropBlur must be applied at webview BUILD time (BuilderNode.update + // does not refresh .backdropBlur). The blur radius is queued here and consumed + // when the webview node is built. No hilog calls inside this method — hilog + // throws "Argc mismatch" when invoked from a NAPI-reentrant ArkTS context. + applyWindowBlur(windowId: number, radius: number): void { + // windowId arrives from Rust NAPI as i64 → BigInt (1n). Normalize to number + // so it matches the ArkTS number keys used by registerController. + const id = Number(windowId); + this.pendingBlurs.set(id, radius); + // Runtime refresh: if the window's controller is already registered, apply blur immediately + // via AttributeUpdater (BuilderNode.update does not refresh backdropBlur). For not-yet-built + // windows, registerController will inject blurRadius from pendingBlurs at build time. + const controller = this.controllers.get(id); + if (controller) { + controller.setAllWebviewsBlurRadius(radius); + } + } private hideSystemBar(): void { if (!this.windowStage) return; try {