From 790d6b161cad69c3a548d3c1a0daa0db6523017b Mon Sep 17 00:00:00 2001 From: richerfu Date: Mon, 10 Aug 2026 20:15:21 +0800 Subject: [PATCH 1/5] refactor: serialize ability plugin lifecycle --- crates/ability/src/bridge/mod.rs | 199 +++++++- crates/derive/src/lib.rs | 6 +- .../src/main/ets/bridge/DemoNodePlugin.ets | 15 + .../main/ets/entryability/EntryAbility.ets | 22 +- docs/plugin-development-standard.md | 17 + .../src/main/ets/ability/NativeAbility.ets | 474 +++++++++++------- .../src/main/ets/bridge/BridgeHost.ets | 408 +++++++++++---- .../main/ets/components/DefaultXComponent.ets | 36 +- .../src/main/ets/runtime/SerialTaskQueue.ets | 27 + native_ability/src/test/LocalUnit.test.ets | 42 ++ .../webview/src/main/ets/WebviewPlugin.ets | 40 +- 11 files changed, 933 insertions(+), 353 deletions(-) create mode 100644 native_ability/src/main/ets/runtime/SerialTaskQueue.ets diff --git a/crates/ability/src/bridge/mod.rs b/crates/ability/src/bridge/mod.rs index ab7ed0cb..e871b278 100644 --- a/crates/ability/src/bridge/mod.rs +++ b/crates/ability/src/bridge/mod.rs @@ -391,6 +391,9 @@ impl BridgeContextReadiness { struct RegisteredPluginEntry { plugin: Arc, required_contexts: &'static [BridgeContextRequirement], + /// Once a plugin becomes ready in one Ability session it keeps receiving that session's + /// teardown events even after its required context has already disappeared. + activated: bool, } #[derive(Default)] @@ -398,6 +401,7 @@ struct BridgePluginRegistryState { plugins: BTreeMap, readiness: BridgeContextReadiness, lifecycle_history: Vec, + session_active: bool, } /// Registration point for Rust facades that consume ArkTS plugin events and lifecycle changes. @@ -428,7 +432,8 @@ impl BridgePluginRegistry { P::ID ))); } - let replay = if state.readiness.supports(P::REQUIRED_CONTEXTS) { + let activated = state.session_active && state.readiness.supports(P::REQUIRED_CONTEXTS); + let replay = if activated { state.lifecycle_history.clone() } else { Vec::new() @@ -438,6 +443,7 @@ impl BridgePluginRegistry { RegisteredPluginEntry { plugin: Arc::clone(&plugin), required_contexts: P::REQUIRED_CONTEXTS, + activated, }, ); replay @@ -483,37 +489,64 @@ impl BridgePluginRegistry { .state .write() .map_err(|_| Error::from_reason("Failed to read bridge plugin registry"))?; - let previous = state.readiness; + + // The OpenHarmony process may keep the native module loaded while recreating the + // Ability. Lifecycle replay is session-scoped: never expose events from the previous + // Ability instance to a plugin activated in the next one. + if matches!(event, PluginLifecycleEvent::AbilityCreated { .. }) && !state.session_active + { + state.readiness = BridgeContextReadiness::default(); + state.lifecycle_history.clear(); + state.session_active = true; + for entry in state.plugins.values_mut() { + entry.activated = false; + } + } + state.readiness.observe(&event); if state.lifecycle_history.len() >= MAX_LIFECYCLE_HISTORY { state.lifecycle_history.remove(0); } state.lifecycle_history.push(event.clone()); - state - .plugins - .values() - .filter_map(|entry| { - let was_ready = previous.supports(entry.required_contexts); - let is_ready = state.readiness.supports(entry.required_contexts); - let events = if !was_ready && is_ready { - state.lifecycle_history.clone() - } else if was_ready { - vec![event.clone()] - } else { - Vec::new() - }; - (!events.is_empty()).then(|| (Arc::clone(&entry.plugin), events)) - }) - .collect::>() + let readiness = state.readiness; + let history = state.lifecycle_history.clone(); + let session_active = state.session_active; + let mut deliveries = Vec::new(); + for entry in state.plugins.values_mut() { + let events = if entry.activated { + vec![event.clone()] + } else if session_active && readiness.supports(entry.required_contexts) { + entry.activated = true; + history.clone() + } else { + Vec::new() + }; + if !events.is_empty() { + deliveries.push((Arc::clone(&entry.plugin), events)); + } + } + + if matches!(event, PluginLifecycleEvent::AbilityDestroyed) { + state.session_active = false; + } + deliveries }; + let mut first_error = None; for (plugin, events) in deliveries { for event in events { - plugin.on_lifecycle(&event)?; + if let Err(error) = plugin.on_lifecycle(&event) { + // A faulty lifecycle subscriber must not prevent the other plugins from + // observing teardown. Preserve the first error for diagnostics after every + // delivery has had a chance to run. + if first_error.is_none() { + first_error = Some(error); + } + } } } - Ok(()) + first_error.map_or(Ok(()), Err) } #[cfg(test)] @@ -1204,7 +1237,10 @@ fn validate_identifier(label: &str, value: &str) -> Result<()> { #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; use super::{ validate_identifier, validate_wire_call, AsyncBridge, BridgeCallOptions, @@ -1237,6 +1273,52 @@ mod tests { } } + struct RecordingUiContextPlugin { + events: Arc>>, + } + + impl BridgePlugin for RecordingUiContextPlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "test.recording-ui-context"; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::UiContext]; + + fn on_lifecycle(&self, event: &PluginLifecycleEvent) -> Result<(), napi_ohos::Error> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } + } + + struct FailingLifecyclePlugin; + + impl BridgePlugin for FailingLifecyclePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "test.a-failing-lifecycle"; + + fn on_lifecycle(&self, _event: &PluginLifecycleEvent) -> Result<(), napi_ohos::Error> { + Err(napi_ohos::Error::from_reason( + "intentional lifecycle failure", + )) + } + } + + struct HealthyLifecyclePlugin { + deliveries: Arc, + } + + impl BridgePlugin for HealthyLifecyclePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "test.z-healthy-lifecycle"; + + fn on_lifecycle(&self, _event: &PluginLifecycleEvent) -> Result<(), napi_ohos::Error> { + self.deliveries.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + #[test] fn accepts_versioned_plugin_identifiers() { assert!(validate_identifier("plugin id", "auth.login_v2").is_ok()); @@ -1301,4 +1383,79 @@ mod tests { .unwrap(); assert_eq!(UI_CONTEXT_LIFECYCLES.load(Ordering::SeqCst), 2); } + + #[test] + fn activated_plugin_receives_teardown_and_next_session_has_fresh_history() { + let events = Arc::new(Mutex::new(Vec::new())); + let registry = BridgePluginRegistry::default(); + registry + .register(RecordingUiContextPlugin { + events: Arc::clone(&events), + }) + .unwrap(); + + registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityCreated { + restored_state: "first".to_owned(), + }) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::WindowStageCreated) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextReady) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextDestroyed) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::WindowStageDestroyed) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityDestroyed) + .unwrap(); + + assert_eq!(events.lock().unwrap().len(), 6); + + registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityCreated { + restored_state: "second".to_owned(), + }) + .unwrap(); + assert_eq!(events.lock().unwrap().len(), 6); + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextReady) + .unwrap(); + + let events = events.lock().unwrap(); + assert_eq!(events.len(), 8); + assert_eq!( + events[6..], + [ + PluginLifecycleEvent::AbilityCreated { + restored_state: "second".to_owned(), + }, + PluginLifecycleEvent::UiContextReady, + ] + ); + } + + #[test] + fn lifecycle_failure_does_not_block_other_plugins() { + let healthy_deliveries = Arc::new(AtomicUsize::new(0)); + let registry = BridgePluginRegistry::default(); + registry.register(FailingLifecyclePlugin).unwrap(); + registry + .register(HealthyLifecyclePlugin { + deliveries: Arc::clone(&healthy_deliveries), + }) + .unwrap(); + + assert!(registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityCreated { + restored_state: String::new(), + }) + .is_err()); + assert_eq!(healthy_deliveries.load(Ordering::SeqCst), 1); + } } diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs index e4cfa1ac..4ae9f1b1 100644 --- a/crates/derive/src/lib.rs +++ b/crates/derive/src/lib.rs @@ -43,6 +43,7 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { static APP: std::sync::LazyLock = std::sync::LazyLock::new(openharmony_ability::OpenHarmonyApp::new); + static APP_CONFIGURED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); thread_local! { pub static ROOT_NODE: std::cell::RefCell> = std::cell::RefCell::new(None); @@ -61,8 +62,11 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { ) -> napi_ohos::Result> { let init_context = openharmony_ability::AbilityInitContext::from_object(context.as_ref())?; (*APP).set_init_context(init_context); + // A native module can outlive one UIAbility instance. Configure its process-wide + // Rust plugin registry exactly once, while still refreshing the per-session init + // context and lifecycle handle on every Ability recreation. + APP_CONFIGURED.get_or_init(|| #fn_name((*APP).clone())); let lifecycle_handle = openharmony_ability::create_lifecycle_handle(env, (*APP).clone())?; - #fn_name((*APP).clone()); Ok(lifecycle_handle) } diff --git a/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets b/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets index 93fb8f6b..dd20aa1e 100644 --- a/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets +++ b/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets @@ -5,6 +5,7 @@ import { BridgePluginContext, AsyncPluginBase, BridgeContextRequirement, + BridgeLifecycleEvent, } from "@ohos-rs/ability"; interface BadgeData { @@ -35,6 +36,20 @@ export class DemoNodePlugin extends AsyncPluginBase { private mounted = false; onInstall(context: BridgePluginContext): void { + this.mount(context); + } + + override onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginContext): void { + if (event.kind === "ui-context-destroy") { + // BridgeHost owns the detached tree and runs its cleanup; do not address a replacement + // window through this plugin's session-scoped context. + this.mounted = false; + } else if (event.kind === "ui-context-ready" && !this.mounted) { + this.mount(context); + } + } + + private mount(context: BridgePluginContext): void { // The session root is guaranteed to exist before ui-context-ready, so plugins mount their // nodes directly without slots, registries or readiness waiters. const node = new BuilderNode<[BadgeData]>(context.getUIContext()); diff --git a/demo/entry/src/main/ets/entryability/EntryAbility.ets b/demo/entry/src/main/ets/entryability/EntryAbility.ets index 7fc8646b..380c60b6 100644 --- a/demo/entry/src/main/ets/entryability/EntryAbility.ets +++ b/demo/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,6 +1,4 @@ import { EagerPlugin, LazyPlugin, NativeAbility } from "@ohos-rs/ability"; -import Want from "@ohos.app.ability.Want"; -import { AbilityConstant } from "@kit.AbilityKit"; import window from "@ohos.window"; import { AppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; import { FilesPlugin } from "@ohos-rs/ability-plugin-files"; @@ -37,25 +35,19 @@ export default class EntryAbility extends NativeAbility { new LazyPlugin(() => new DemoNodePlugin()), ]; - async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise { - super.onCreate(want, launchParam); - } - - async onWindowStageCreate(windowStage: window.WindowStage): Promise { - try { - demoWindowStage = windowStage; + onWindowStageCreate(windowStage: window.WindowStage): void { + demoWindowStage = windowStage; + super.onWindowStageCreate(windowStage); + void this.enqueueLifecycleOperation("demo-window-content", async (): Promise => { const mainWindow = windowStage.getMainWindowSync(); // 全屏布局:内容延伸到状态栏下方(状态栏变透明),避免顶部出现系统绘制的黑色状态栏条。 await mainWindow.setWindowLayoutFullScreen(true); - super.onWindowStageCreate(windowStage); await windowStage.loadContent("pages/Index"); - } catch (error) { - throw new Error(`Unable to initialize the demo window: ${String(error)}`); - } + }); } - async onWindowStageDestroy(): Promise { + onWindowStageDestroy(): void { demoWindowStage = null; - await super.onWindowStageDestroy(); + super.onWindowStageDestroy(); } } diff --git a/docs/plugin-development-standard.md b/docs/plugin-development-standard.md index b7a1533b..af9d59f7 100644 --- a/docs/plugin-development-standard.md +++ b/docs/plugin-development-standard.md @@ -325,6 +325,13 @@ ArkTS 平台回调进入 Rust 的 `on_main_thread_event` 是**入站 scoped call 生命周期的调用顺序必须保留为下列链路;插件只能订阅它,不能把原 native module 的 lifecycle callback 替换掉: +OpenHarmony SDK 中 `UIAbility.onCreate`、`onWindowStageCreate` 和 `onWindowStageDestroy` 是同步 +`void` 回调,平台不会等待它们返回的 Promise;只有 `onDestroy` 允许返回 Promise。因此这些系统入口 +必须同步捕获参数并把异步工作放入同一个 Ability 级 FIFO,不能把 `async onCreate` 等方法本身当作 +生命周期屏障。BridgeHost 内部也必须按 module/session 串行生命周期任务,使配置、内存、WindowStage、 +UIContext 和销毁事件不能相互穿插。单个插件的 lifecycle/onDispose 失败只能记录,不能中断后续插件; +session 开始关闭后必须拒绝新调用并取消未完成调用。 + 1. `NativeAbility.onCreate` 打开 module/session 对应的 `BridgeHost`,创建 factory,并发出 `ability-create`。 2. `NativeAbility.onWindowStageCreate` 先提供 `WindowStage`,再发出 `window-stage-create`;窗口事件 @@ -341,6 +348,11 @@ callback 替换掉: 5. `configuration-updated`、`memory-level`、window-stage event 等保持由 `NativeAbility` 原有链路 分发,同时作为受控 lifecycle event 交给已安装插件。 +Rust 侧插件在一个 Ability session 中首次满足 requirements 后视为已激活:即使 UIContext 或 +WindowStage 已先销毁,它仍必须收到该 session 后续的 `ui-context-destroy`、 +`window-stage-destroy` 和 `ability-destroy`。下一次 `ability-create` 必须清空上一 session 的 readiness +和 lifecycle history,再从新会话开始重放,禁止把旧 Ability 事件带入新实例。 + ArkTS context 是 module + session 范围的。插件不得假设多个 module 共用一个 controller、根节点或 状态表;所有跨页面状态键必须至少包含 `sessionId` 与 `moduleName`。 @@ -513,6 +525,11 @@ fn configure_ability(app: OpenHarmonyApp) { } ``` +native module 可能跨越多个 UIAbility 实例继续存活,因此 `#[ability]` 初始化器对同一 module 的 +进程级 `OpenHarmonyApp` 只执行一次;每次 Ability 重建仍会刷新 `AbilityInitContext` 并创建新的 +lifecycle handle。初始化器应只做插件、protocol 和 run loop 等进程级配置,session 资源必须通过 +lifecycle 创建与释放,不能依赖重复执行初始化器。 + ArkTS HAR 导出唯一 factory,应用通过 `NativeAbility.bridgePlugins` 显式装配: ```ts diff --git a/native_ability/src/main/ets/ability/NativeAbility.ets b/native_ability/src/main/ets/ability/NativeAbility.ets index 1933b6aa..267b8198 100644 --- a/native_ability/src/main/ets/ability/NativeAbility.ets +++ b/native_ability/src/main/ets/ability/NativeAbility.ets @@ -3,6 +3,8 @@ import common from "@ohos.app.ability.common"; import window from "@ohos.window"; import * as Entry from "../components/MainPage"; import { BridgeHostRegistry } from "../bridge/BridgeHost"; +import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; +import { SerialTaskQueue } from "../runtime/SerialTaskQueue"; import { AbilityInitContext, ApplicationLifecycle, @@ -14,20 +16,32 @@ import { BridgeWindowStageEventLifecyclePayload, Module, } from "./type"; -import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; export const STATE_KEY = "ohos.rs.ability.application.state"; +interface LoadedNativeModule { + moduleName: string; + module: Module; +} + +interface NativeModuleRuntime extends LoadedNativeModule { + lifecycle: ApplicationLifecycle; +} + export class NativeAbility extends UIAbility { public moduleName: string | string[] = ""; public defaultPage: boolean = true; public loadMode: "async" | "sync" = "async"; /** Application-provided capability plugins. NativeAbility itself has no concrete plugin imports. */ public bridgePlugins: BridgePluginFactory[] = []; - private nativeModules: Module[] = []; - private lifecycles: ApplicationLifecycle[] = []; - private loadedModuleNames: string[] = []; + + private moduleRuntimes: NativeModuleRuntime[] = []; private bridgeSessionId: string = ""; + private readonly lifecycleQueue = new SerialTaskQueue((label: string, error: Error): void => { + console.error(`[NativeAbility] lifecycle operation '${label}' failed: ${String(error)}`); + }); + private acceptingLifecycle: boolean = false; + private windowStageActive: boolean = false; protected resolveModuleNames(): string[] { const moduleNames = NativeModuleLoader.resolveModuleNames(this.moduleName); @@ -38,13 +52,26 @@ export class NativeAbility extends UIAbility { } protected forEachLifecycle(handler: (lifecycle: ApplicationLifecycle) => void): void { - for (const lifecycle of this.lifecycles) { + for (const runtime of this.moduleRuntimes) { try { - handler(lifecycle); + handler(runtime.lifecycle); } catch {} } } + /** + * Adds framework or subclass work to the one Ability-scoped lifecycle queue. OpenHarmony does + * not await onCreate/onWindowStageCreate/onWindowStageDestroy, so those callbacks only capture + * their arguments synchronously; this queue is the actual ordering barrier. + */ + protected enqueueLifecycleOperation( + label: string, + operation: () => Promise, + ): Promise { + void this.lifecycleQueue.enqueue(label, operation); + return this.lifecycleQueue.settled(); + } + protected parseSavedStateMap(rawState: string): Record { if (!rawState) { return {}; @@ -53,8 +80,7 @@ export class NativeAbility extends UIAbility { try { const parsed = JSON.parse(rawState) as Record; const mapped: Record = {}; - const keys = Object.keys(parsed); - for (const key of keys) { + for (const key of Object.keys(parsed)) { const value = parsed[key]; mapped[key] = typeof value === "string" ? value : String(value); } @@ -75,228 +101,299 @@ export class NativeAbility extends UIAbility { }; } + private updateAppStorage(key: string, value: T): void { + AppStorage.setOrCreate(key, value); + AppStorage.set(key, value); + } + /** - * Wires the ArkTS -> Rust main-thread event sink for one native module. Called right after - * `module.init` in onCreate so plugins that only require `ability` can emit inbound events on - * `ability-create`; the render-time attach in DefaultXComponent overrides it with the same - * module object (idempotent, NativeModuleLoader caches instances). + * Wires both scoped ArkTS -> Rust ports immediately after module.init. The lifecycle sink lets + * BridgeHost serialize UI readiness with ArkTS plugin installation instead of relying on + * Promise microtask timing in DefaultXComponent. */ - private attachBridgeEventSink(moduleName: string, module: Module): void { - if (this.bridgeSessionId === undefined) { - return; - } - if (typeof module.onBridgeSyncEvent !== "function") { - return; - } - BridgeHostRegistry.attachEventSink( - this.bridgeSessionId, - moduleName, - ( - pluginId: string, - event: string, - requestTypeName: string, - responseTypeName: string, - value: ESObject, - ): ESObject => { - return module.onBridgeSyncEvent!(pluginId, event, requestTypeName, responseTypeName, value); - }, - ); + private attachBridgeSinks(sessionId: string, moduleName: string, module: Module): void { + const mainThreadSink = + typeof module.onBridgeSyncEvent === "function" + ? ( + pluginId: string, + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject => + module.onBridgeSyncEvent!(pluginId, event, requestTypeName, responseTypeName, value) + : undefined; + const lifecycleSink = + typeof module.onBridgeLifecycle === "function" + ? (kind: string): void => module.onBridgeLifecycle!(kind) + : undefined; + BridgeHostRegistry.attachEventSink(sessionId, moduleName, mainThreadSink, lifecycleSink); } protected async notifyBridgeLifecycle(event: BridgeLifecycleEvent): Promise { - if (!this.bridgeSessionId) { + const sessionId = this.bridgeSessionId; + if (!sessionId) { return; } - for (const moduleName of this.loadedModuleNames) { - await BridgeHostRegistry.emitLifecycle(this.bridgeSessionId, moduleName, event); + for (const runtime of this.moduleRuntimes) { + try { + await BridgeHostRegistry.emitLifecycle(sessionId, runtime.moduleName, event); + } catch (error) { + console.error( + `[NativeAbility] bridge lifecycle '${event.kind}' failed for ${runtime.moduleName}: ${String(error)}`, + ); + } } } - protected async setBridgeWindowStage(windowStage: window.WindowStage): Promise { - if (!this.bridgeSessionId) { - return; + private async initializeSession( + requestedModules: string[], + restoredStateMap: Record, + fallbackState: string, + ): Promise { + const previousSessionId = this.bridgeSessionId; + if (previousSessionId) { + BridgeHostRegistry.beginClosing(previousSessionId); + await BridgeHostRegistry.dispose(previousSessionId); } - for (const moduleName of this.loadedModuleNames) { - await BridgeHostRegistry.setWindowStage(this.bridgeSessionId, moduleName, windowStage); - } - } + this.bridgeSessionId = ""; + this.moduleRuntimes = []; + this.windowStageActive = false; + this.updateAppStorage("bridgeSessionId", ""); + + const sessionId = await BridgeHostRegistry.open( + requestedModules, + this.context as common.UIAbilityContext, + this.bridgePlugins, + ); + this.bridgeSessionId = sessionId; + this.updateAppStorage("bridgeSessionId", sessionId); - protected async clearBridgeWindowStage(): Promise { - if (!this.bridgeSessionId) { - return; - } - for (const moduleName of this.loadedModuleNames) { - await BridgeHostRegistry.clearWindowStage(this.bridgeSessionId, moduleName); + const loadedModules: LoadedNativeModule[] = []; + const initializedRuntimes: NativeModuleRuntime[] = []; + try { + // Loading is transactional. Parallel arrays and partial "continue after error" states made + // a module name point at the wrong lifecycle object after one dynamic import failed. + for (const moduleName of requestedModules) { + const module = await NativeModuleLoader.load(moduleName, this.loadMode); + loadedModules.push({ moduleName, module }); + } + + for (const loaded of loadedModules) { + const lifecycle = loaded.module.init(this.createInitContext(loaded.moduleName)); + const runtime: NativeModuleRuntime = { + moduleName: loaded.moduleName, + module: loaded.module, + lifecycle, + }; + initializedRuntimes.push(runtime); + this.attachBridgeSinks(sessionId, loaded.moduleName, loaded.module); + + const restoredState = restoredStateMap[loaded.moduleName] ?? fallbackState; + try { + lifecycle.windowStageEventCallback.onAbilityCreate(restoredState); + } catch (error) { + console.error( + `[NativeAbility] Rust ability-create failed for ${loaded.moduleName}: ${String(error)}`, + ); + } + } + + this.moduleRuntimes = initializedRuntimes; + for (const runtime of initializedRuntimes) { + await BridgeHostRegistry.emitLifecycle(sessionId, runtime.moduleName, { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload( + restoredStateMap[runtime.moduleName] ?? fallbackState, + ), + }); + } + } catch (error) { + for (const runtime of initializedRuntimes.slice().reverse()) { + try { + runtime.lifecycle.windowStageEventCallback.onAbilityDestroy(); + } catch {} + } + BridgeHostRegistry.beginClosing(sessionId); + await BridgeHostRegistry.dispose(sessionId); + if (this.bridgeSessionId === sessionId) { + this.bridgeSessionId = ""; + this.moduleRuntimes = []; + this.updateAppStorage("bridgeSessionId", ""); + } + throw new Error(`Unable to initialize native Ability session: ${String(error)}`); } } - async onCreate(want: Want, _launchParam: AbilityConstant.LaunchParam): Promise { + onCreate(want: Want, _launchParam: AbilityConstant.LaunchParam): void { const isRestore: boolean = (want.parameters?.["ohos.ability.params.abilityRecoveryRestart"] as boolean) ?? false; const savedState = want.parameters?.[STATE_KEY]; const state = isRestore && savedState !== undefined ? savedState.toString() : ""; + const requestedModules = this.resolveModuleNames(); + const restoredStateMap = this.parseSavedStateMap(state); + + this.acceptingLifecycle = true; + this.updateAppStorage("moduleName", this.moduleName); + this.updateAppStorage("loadMode", this.loadMode); + void this.enqueueLifecycleOperation("ability-create", async (): Promise => { + await this.initializeSession(requestedModules, restoredStateMap, state); + }); + } - AppStorage.setOrCreate("moduleName", this.moduleName); - AppStorage.setOrCreate("loadMode", this.loadMode); - - const requestedModules: string[] = this.resolveModuleNames(); - const restoredStateMap: Record = this.parseSavedStateMap(state); - if (this.bridgeSessionId) { - await BridgeHostRegistry.dispose(this.bridgeSessionId); - } - try { - this.bridgeSessionId = await BridgeHostRegistry.open( - requestedModules, - this.context as common.UIAbilityContext, - this.bridgePlugins, - ); - } catch (error) { - // A plugin factory/install failure here aborts onCreate before module.init runs, - // which later surfaces as "OpenHarmony app not initialized" from every napi call. - console.error(`[NativeAbility] BridgeHostRegistry.open failed: ${String(error)}`); + onWindowStageCreate(windowStage: window.WindowStage): void { + if (!this.acceptingLifecycle) { + return; } - AppStorage.setOrCreate("bridgeSessionId", this.bridgeSessionId); - - this.nativeModules = []; - this.lifecycles = []; - this.loadedModuleNames = []; + void this.enqueueLifecycleOperation("window-stage-create", async (): Promise => { + const sessionId = this.bridgeSessionId; + if (!sessionId) { + throw new Error("Bridge session is unavailable during window-stage-create"); + } - for (const moduleName of requestedModules) { - try { - const module: Module = await NativeModuleLoader.load(moduleName, this.loadMode); - this.nativeModules.push(module); - this.loadedModuleNames.push(moduleName); - } catch (error) { - // A dynamic import failure aborts onCreate before module.init runs; the Rust-side - // INNER_APP is then never set and every napi call reports "OpenHarmony app not - // initialized". A stale/incompatible .so is the usual cause. - console.error( - `[NativeAbility] failed to load native module ${moduleName}: ${String(error)}`, - ); + // First inject the ArkTS WindowStage object. Rust readiness is then advanced before ArkTS + // plugin installation/lifecycle delivery can emit a synchronous event back into Rust. + for (const runtime of this.moduleRuntimes) { + await BridgeHostRegistry.setWindowStage(sessionId, runtime.moduleName, windowStage); } - } + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onWindowStageCreate(), + ); + this.windowStageActive = true; + await this.notifyBridgeLifecycle({ + kind: "window-stage-create", + payload: new BridgeEmptyLifecyclePayload(), + }); - for (let i = 0; i < this.nativeModules.length; i++) { - const module: Module = this.nativeModules[i]; - const moduleName: string = this.loadedModuleNames[i]; try { - const lifecycle: ApplicationLifecycle = module.init(this.createInitContext(moduleName)); - this.lifecycles.push(lifecycle); + windowStage.on("windowStageEvent", (event: window.WindowStageEventType) => { + if (!this.acceptingLifecycle) { + return; + } + void this.enqueueLifecycleOperation("window-stage-event", async (): Promise => { + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onWindowStageEvent(event), + ); + await this.notifyBridgeLifecycle({ + kind: "window-stage-event", + payload: new BridgeWindowStageEventLifecyclePayload(event), + }); + }); + }); + } catch {} - // Attach the inbound event sink right after init, not at UI render time: the native - // module is loaded and the Rust app (plus its registered plugins) exists from here on. - // Plugins that only require `ability` can therefore push ArkTS -> Rust main-thread - // events on `ability-create` instead of waiting for `ui-context-ready`. The render-time - // attach in DefaultXComponent remains as an idempotent fallback for the same module. - this.attachBridgeEventSink(moduleName, module); + let win: window.Window | null = null; + try { + win = await windowStage.getMainWindow(); + } catch { + win = null; + } - const restoredState = restoredStateMap[moduleName] ?? state; + if (win) { try { - lifecycle.windowStageEventCallback.onAbilityCreate(restoredState); + win.on("windowSizeChange", (size: window.Size) => { + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onWindowSizeChange(size), + ); + }); + win.on("windowRectChange", (options: window.RectChangeOptions) => { + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onWindowRectChange(options), + ); + }); + win.on("avoidAreaChange", (options: window.AvoidAreaOptions) => { + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onAvoidAreaChange(options), + ); + }); + win.on("keyboardHeightChange", (height) => { + this.forEachLifecycle((lifecycle) => + lifecycle.keyboardEventCallback.onKeyboardHeightChange(height), + ); + }); } catch {} - } catch (error) { - // `#[ability] init` sets the Rust-side INNER_APP; when it fails, every later - // napi call reports "OpenHarmony app not initialized". Log the exact failure - // so the root cause (stale .so, context mismatch, bridge open failure) is visible. - console.error(`[NativeAbility] module.init failed for ${moduleName}: ${String(error)}`); } - } - for (const moduleName of requestedModules) { - await BridgeHostRegistry.emitLifecycle(this.bridgeSessionId, moduleName, { - kind: "ability-create", - payload: new BridgeAbilityCreateLifecyclePayload(restoredStateMap[moduleName] ?? state), - }); - } + if (this.defaultPage) { + await windowStage.loadContentByName(Entry.RouteName); + } + }); } - async onWindowStageCreate(windowStage: window.WindowStage): Promise { - await this.setBridgeWindowStage(windowStage); - this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onWindowStageCreate()); - - try { - windowStage.on("windowStageEvent", (event: window.WindowStageEventType) => { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onWindowStageEvent(event), - ); - this.notifyBridgeLifecycle({ - kind: "window-stage-event", - payload: new BridgeWindowStageEventLifecyclePayload(event), - }).catch(() => {}); - }); - } catch {} - - let win: window.Window | null = null; - try { - win = await windowStage.getMainWindow(); - } catch { - win = null; + onWindowStageDestroy(): void { + if (!this.acceptingLifecycle) { + return; } + void this.enqueueLifecycleOperation("window-stage-destroy", async (): Promise => { + await this.destroyWindowStageIfActive(); + }); + } - if (win) { - try { - win.on("windowSizeChange", (size: window.Size) => { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onWindowSizeChange(size), - ); - }); - win.on("windowRectChange", (options: window.RectChangeOptions) => { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onWindowRectChange(options), - ); - }); - win.on("avoidAreaChange", (options: window.AvoidAreaOptions) => { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onAvoidAreaChange(options), - ); - }); - win.on("keyboardHeightChange", (height) => { - this.forEachLifecycle((lifecycle) => - lifecycle.keyboardEventCallback.onKeyboardHeightChange(height), - ); - }); - } catch {} + private async destroyWindowStageIfActive(): Promise { + if (!this.windowStageActive) { + return; } - - if (this.defaultPage) { - try { - await windowStage.loadContentByName(Entry.RouteName); - } catch {} + this.windowStageActive = false; + const sessionId = this.bridgeSessionId; + if (sessionId) { + for (const runtime of this.moduleRuntimes) { + await BridgeHostRegistry.clearWindowStage(sessionId, runtime.moduleName); + } } - } - - async onWindowStageDestroy(): Promise { this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onWindowStageDestroy()); - await this.clearBridgeWindowStage(); } onMemoryLevel(level: AbilityConstant.MemoryLevel): void { - this.forEachLifecycle((lifecycle) => lifecycle.environmentCallback.onMemoryLevel(level)); - this.notifyBridgeLifecycle({ - kind: "memory-level", - payload: new BridgeMemoryLevelLifecyclePayload(level), - }).catch(() => {}); + if (!this.acceptingLifecycle) { + return; + } + void this.enqueueLifecycleOperation("memory-level", async (): Promise => { + this.forEachLifecycle((lifecycle) => lifecycle.environmentCallback.onMemoryLevel(level)); + await this.notifyBridgeLifecycle({ + kind: "memory-level", + payload: new BridgeMemoryLevelLifecyclePayload(level), + }); + }); } - async onDestroy(): Promise { - await this.notifyBridgeLifecycle({ - kind: "ability-destroy", - payload: new BridgeEmptyLifecyclePayload(), + onDestroy(): Promise { + this.acceptingLifecycle = false; + return this.enqueueLifecycleOperation("ability-destroy", async (): Promise => { + const sessionId = this.bridgeSessionId; + if (!sessionId) { + return; + } + BridgeHostRegistry.beginClosing(sessionId); + try { + await this.destroyWindowStageIfActive(); + await this.notifyBridgeLifecycle({ + kind: "ability-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); + this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onAbilityDestroy()); + } finally { + this.bridgeSessionId = ""; + this.moduleRuntimes = []; + this.windowStageActive = false; + this.updateAppStorage("bridgeSessionId", ""); + await BridgeHostRegistry.dispose(sessionId); + } }); - this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onAbilityDestroy()); - const bridgeSessionId = this.bridgeSessionId; - this.bridgeSessionId = ""; - await BridgeHostRegistry.dispose(bridgeSessionId); } onConfigurationUpdate(newConfig: Configuration): void { - this.forEachLifecycle((lifecycle) => - lifecycle.environmentCallback.onConfigurationUpdated(newConfig), - ); - this.notifyBridgeLifecycle({ - kind: "configuration-updated", - payload: new BridgeEmptyLifecyclePayload(), - }).catch(() => {}); + if (!this.acceptingLifecycle) { + return; + } + void this.enqueueLifecycleOperation("configuration-updated", async (): Promise => { + this.forEachLifecycle((lifecycle) => + lifecycle.environmentCallback.onConfigurationUpdated(newConfig), + ); + await this.notifyBridgeLifecycle({ + kind: "configuration-updated", + payload: new BridgeEmptyLifecyclePayload(), + }); + }); } onSaveState( @@ -305,15 +402,10 @@ export class NativeAbility extends UIAbility { ): AbilityConstant.OnSaveResult { const stateMap: Record = {}; - for (let i = 0; i < this.lifecycles.length; i++) { - const lifecycle = this.lifecycles[i]; - const moduleName = this.loadedModuleNames[i]; - if (!moduleName) { - continue; - } - + for (const runtime of this.moduleRuntimes) { try { - stateMap[moduleName] = lifecycle.windowStageEventCallback.onAbilitySaveState() ?? ""; + stateMap[runtime.moduleName] = + runtime.lifecycle.windowStageEventCallback.onAbilitySaveState() ?? ""; } catch {} } diff --git a/native_ability/src/main/ets/bridge/BridgeHost.ets b/native_ability/src/main/ets/bridge/BridgeHost.ets index ed1c8cbd..b2de14f1 100644 --- a/native_ability/src/main/ets/bridge/BridgeHost.ets +++ b/native_ability/src/main/ets/bridge/BridgeHost.ets @@ -15,6 +15,7 @@ import { BridgePluginFactory, BridgeWindowScope, } from "../ability/type"; +import { SerialTaskQueue } from "../runtime/SerialTaskQueue"; const MAX_TIMEOUT_MS = 60_000; const MAX_PAYLOAD_BYTES = 512 * 1024; @@ -28,10 +29,14 @@ type BridgeMainThreadEventSink = ( value: ESObject, ) => ESObject; +type BridgeLifecycleSink = (kind: string) => void; + interface HostedPlugin { factory: BridgePluginFactory; plugin: BridgePlugin; installed: boolean; + installAttempted: boolean; + installError?: string; installing?: Promise; } @@ -183,11 +188,17 @@ class NodeAcknowledgement { } interface BridgeCallState { + pluginId: string; cancelled: boolean; cancelListeners: Array<() => void>; rejectCancellation?: (reason: Error) => void; } +interface PendingWindowAttachment { + state: HostWindowState; + cancelled: boolean; +} + function isBridgeIdentifier(value: string): boolean { return value.length > 0 && /^[A-Za-z0-9._-]+$/.test(value); } @@ -216,8 +227,19 @@ class BridgeHost { // collection implementation for a handful of in-flight bridge calls. private readonly activeCalls: BridgeCallState[] = []; private mainThreadEventSink?: BridgeMainThreadEventSink; + private lifecycleSink?: BridgeLifecycleSink; + private eventSinkToken: number = 0; private windowStage?: window.WindowStage; + private windowStageReady: boolean = false; + private uiContextReady: boolean = false; + private closing: boolean = false; private disposed: boolean = false; + private disposePromise?: Promise; + private readonly lifecycleQueue = new SerialTaskQueue((label: string, error: Error): void => { + console.error( + `[BridgeHost:${this.moduleName}] lifecycle operation '${label}' failed: ${String(error)}`, + ); + }); private readonly sessionId: string; private readonly moduleName: string; private readonly abilityContext: common.UIAbilityContext; @@ -226,6 +248,7 @@ class BridgeHost { // never overwrite the main window's. Each window owns its keyed mounts and the opaque handle // table of the built-in ohos.node plugin. private readonly windows: Map = new Map(); + private readonly pendingWindows: Map = new Map(); constructor(sessionId: string, moduleName: string, abilityContext: common.UIAbilityContext) { this.sessionId = sessionId; @@ -250,7 +273,12 @@ class BridgeHost { throw new Error(`Duplicate bridge plugin '${plugin.id}' in module ${this.moduleName}`); } plugin.attachContext?.(this.pluginContext(plugin.id)); - this.plugins.set(plugin.id, { factory, plugin, installed: false }); + this.plugins.set(plugin.id, { + factory, + plugin, + installed: false, + installAttempted: false, + }); } await this.activateReadyPlugins(); } @@ -265,6 +293,7 @@ class BridgeHost { factory: { create: (): BridgePlugin => plugin }, plugin, installed: false, + installAttempted: false, }); } @@ -273,29 +302,53 @@ class BridgeHost { * after creating its root. Attaching the `"main"` window emits `ui-context-ready` (and wakes * context waiters); sub-window attachments only register state. */ - attachWindow(windowKey: string, uiContext: UIContext, root: FrameNode): void { + attachWindow(windowKey: string, uiContext: UIContext, root: FrameNode): Promise { this.assertActive(); this.assertWindowKey(windowKey); - if (this.windows.has(windowKey)) { + if (this.windows.has(windowKey) || this.pendingWindows.has(windowKey)) { throw new Error( `Bridge window '${windowKey}' is already attached for '${this.moduleName}'; each DefaultXComponent needs a unique windowKey`, ); } - this.windows.set(windowKey, { - uiContext, - rootFrameNode: root, - mountedChildren: new Map(), - nodeHandles: new Map(), - nodeParents: new Map(), - nextNodeHandle: 1, - }); - if (windowKey === MAIN_WINDOW_KEY) { + const pending: PendingWindowAttachment = { + state: { + uiContext, + rootFrameNode: root, + mountedChildren: new Map(), + nodeHandles: new Map(), + nodeParents: new Map(), + nextNodeHandle: 1, + }, + cancelled: false, + }; + this.pendingWindows.set(windowKey, pending); + + return this.enqueueLifecycle(`attach window '${windowKey}'`, async (): Promise => { + this.pendingWindows.delete(windowKey); + if (pending.cancelled) { + return; + } + this.assertActive(); + if (windowKey === MAIN_WINDOW_KEY && !this.windowStageReady) { + throw new Error( + `Bridge main window cannot attach without an active WindowStage for '${this.moduleName}'`, + ); + } + this.windows.set(windowKey, pending.state); + if (windowKey !== MAIN_WINDOW_KEY) { + return; + } + + this.uiContextReady = true; this.notifyContextChanged(); - void this.emitLifecycle({ + // Rust readiness must be visible before an ArkTS plugin's onInstall can synchronously emit + // a typed platform event back into Rust. + this.notifyRustLifecycle("ui-context-ready"); + await this.deliverLifecycle({ kind: "ui-context-ready", payload: new BridgeEmptyLifecyclePayload(), }); - } + }); } /** @@ -307,18 +360,38 @@ class BridgeHost { if (this.disposed) { return; } + const pending = this.pendingWindows.get(windowKey); + if (pending !== undefined) { + pending.cancelled = true; + this.pendingWindows.delete(windowKey); + await this.enqueueLifecycle(`cancel window attachment '${windowKey}'`, async () => {}); + return; + } const state = this.windows.get(windowKey); if (state === undefined) { return; } + this.windows.delete(windowKey); if (windowKey === MAIN_WINDOW_KEY) { - await this.emitLifecycle({ - kind: "ui-context-destroy", - payload: new BridgeEmptyLifecyclePayload(), - }); + // Make new calls fail immediately. The queued lifecycle hook still owns the old state and + // releases it before a later attachment can become ready. + this.uiContextReady = false; + this.cancelCallsWithMissingContext(); + this.notifyContextChanged(); } - this.disposeWindowState(state); - this.windows.delete(windowKey); + + await this.enqueueLifecycle(`detach window '${windowKey}'`, async (): Promise => { + if (windowKey === MAIN_WINDOW_KEY) { + await this.deliverLifecycle({ + kind: "ui-context-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); + } + this.disposeWindowState(state); + if (windowKey === MAIN_WINDOW_KEY) { + this.notifyRustLifecycle("ui-context-destroy"); + } + }); } /** Creates an empty container FrameNode in `windowKey` and returns its opaque handle (ohos.node). */ @@ -485,11 +558,11 @@ class BridgeHost { async setWindowStage(windowStage: window.WindowStage): Promise { this.assertActive(); - this.windowStage = windowStage; - this.notifyContextChanged(); - await this.emitLifecycle({ - kind: "window-stage-create", - payload: new BridgeEmptyLifecyclePayload(), + await this.enqueueLifecycle("window-stage-create", async (): Promise => { + this.assertActive(); + this.windowStage = windowStage; + this.windowStageReady = true; + this.notifyContextChanged(); }); } @@ -497,31 +570,66 @@ class BridgeHost { if (this.disposed) { return; } - // The WindowStage belongs to the Ability (main window). Detach every registered window - // surface; only the main window emits `ui-context-destroy`. - const windowKeys = Array.from(this.windows.keys()); - for (const windowKey of windowKeys) { - await this.detachWindow(windowKey); - } - await this.emitLifecycle({ - kind: "window-stage-destroy", - payload: new BridgeEmptyLifecyclePayload(), + this.windowStageReady = false; + this.cancelCallsWithMissingContext(); + this.notifyContextChanged(); + await this.enqueueLifecycle("window-stage-destroy", async (): Promise => { + this.windowStageReady = false; + // The WindowStage belongs to the Ability (main window). Detach every registered window + // surface; only the main window emits `ui-context-destroy`. + const windowEntries = Array.from(this.windows.entries()); + this.windows.clear(); + if (this.uiContextReady) { + this.uiContextReady = false; + this.cancelCallsWithMissingContext(); + this.notifyContextChanged(); + } + for (const [windowKey, state] of windowEntries) { + if (windowKey === MAIN_WINDOW_KEY) { + await this.deliverLifecycle({ + kind: "ui-context-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); + } + this.disposeWindowState(state); + if (windowKey === MAIN_WINDOW_KEY) { + this.notifyRustLifecycle("ui-context-destroy"); + } + } + await this.deliverLifecycle({ + kind: "window-stage-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); + this.windowStage = undefined; }); - this.windowStage = undefined; } async emitLifecycle(event: BridgeLifecycleEvent): Promise { if (this.disposed) { return; } - await this.activateReadyPlugins(); + await this.enqueueLifecycle(event.kind, async (): Promise => { + await this.deliverLifecycle(event); + }); + } + + private async deliverLifecycle(event: BridgeLifecycleEvent): Promise { + if (!this.closing) { + await this.activateReadyPlugins(false); + } this.recordLifecycle(event); const entries = Array.from(this.plugins.values()); for (const entry of entries) { if (!entry.installed || !entry.plugin.onLifecycle) { continue; } - await entry.plugin.onLifecycle(event, this.pluginContext(entry.plugin.id)); + try { + await entry.plugin.onLifecycle(event, this.pluginContext(entry.plugin.id)); + } catch (error) { + console.error( + `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed lifecycle '${event.kind}': ${String(error)}`, + ); + } } } @@ -545,7 +653,7 @@ class BridgeHost { const asyncPlugin: AsyncBridgePlugin = entry.plugin as AsyncBridgePlugin; const boundedTimeoutMs = clampTimeout(timeoutMs); - const callState: BridgeCallState = { cancelled: false, cancelListeners: [] }; + const callState: BridgeCallState = { pluginId, cancelled: false, cancelListeners: [] }; const cancellation = new Promise((_resolve, reject) => { callState.rejectCancellation = reject; }); @@ -607,7 +715,11 @@ class BridgeHost { } const syncPlugin: MainThreadSyncBridgePlugin = entry.plugin as MainThreadSyncBridgePlugin; - const syncCallState: BridgeCallState = { cancelled: false, cancelListeners: [] }; + const syncCallState: BridgeCallState = { + pluginId, + cancelled: false, + cancelListeners: [], + }; const result = syncPlugin.invokeSync( action, request, @@ -643,50 +755,82 @@ class BridgeHost { return sink(pluginId, event, requestTypeName, responseTypeName, value); } - attachEventSink(mainThreadSink: BridgeMainThreadEventSink): void { + attachEventSink( + mainThreadSink: BridgeMainThreadEventSink | undefined, + lifecycleSink: BridgeLifecycleSink | undefined, + ): number { this.assertActive(); this.mainThreadEventSink = mainThreadSink; + this.lifecycleSink = lifecycleSink; + this.eventSinkToken += 1; + return this.eventSinkToken; } - detachEventSink(): void { + detachEventSink(token?: number): void { + if (token !== undefined && token !== this.eventSinkToken) { + return; + } this.mainThreadEventSink = undefined; + this.lifecycleSink = undefined; } - async dispose(): Promise { - if (this.disposed) { + beginClosing(): void { + if (this.closing || this.disposed) { return; } - this.disposed = true; + this.closing = true; const activeCalls = this.activeCalls.splice(0, this.activeCalls.length); for (const callState of activeCalls) { this.cancelCall( callState, - new Error(`Bridge session '${this.sessionId}' was disposed while a call was pending`), + new Error(`Bridge session '${this.sessionId}' is closing while a call is pending`), ); } this.notifyContextChanged(); - const entries = Array.from(this.plugins.values()); - for (const entry of entries) { - try { - if (entry.installed && entry.plugin.onDispose) { - await entry.plugin.onDispose(this.pluginContext(entry.plugin.id)); + } + + async dispose(): Promise { + if (this.disposePromise !== undefined) { + await this.disposePromise; + return; + } + this.beginClosing(); + this.disposePromise = this.enqueueLifecycle("dispose", async (): Promise => { + const entries = Array.from(this.plugins.values()); + for (const entry of entries) { + try { + if (entry.installing !== undefined) { + try { + await entry.installing; + } catch {} + } + if (entry.installAttempted && entry.plugin.onDispose) { + await entry.plugin.onDispose(this.pluginContext(entry.plugin.id)); + } + } catch { + // Teardown must not prevent another plugin from releasing its resources. } - } catch { - // Teardown must not prevent another plugin from releasing its resources. } - } - this.plugins.clear(); - this.mainThreadEventSink = undefined; - // Release every window surface: keyed mounts first, then handle-owned nodes. The root - // FrameNodes themselves are owned and disposed by their DefaultXComponent instances. - const windowKeys = Array.from(this.windows.keys()); - for (const windowKey of windowKeys) { - const state = this.windows.get(windowKey); - if (state !== undefined) { + this.plugins.clear(); + this.mainThreadEventSink = undefined; + this.lifecycleSink = undefined; + for (const pending of this.pendingWindows.values()) { + pending.cancelled = true; + } + this.pendingWindows.clear(); + // Release every window surface: keyed mounts first, then handle-owned nodes. The root + // FrameNodes themselves are owned and disposed by their DefaultXComponent instances. + for (const state of this.windows.values()) { this.disposeWindowState(state); } - } - this.windows.clear(); + this.windows.clear(); + this.uiContextReady = false; + this.windowStage = undefined; + this.windowStageReady = false; + this.disposed = true; + this.notifyContextChanged(); + }); + await this.disposePromise; } private async ensureActive(entry: HostedPlugin, callState?: BridgeCallState): Promise { @@ -699,11 +843,20 @@ class BridgeHost { this.assertCallStateActive(callState); } - private async activateReadyPlugins(): Promise { + private async activateReadyPlugins(failFast: boolean = true): Promise { const entries = Array.from(this.plugins.values()); for (const entry of entries) { if (this.requirementsReady(entry)) { - await this.activate(entry); + try { + await this.activate(entry); + } catch (error) { + if (failFast) { + throw error; + } + console.error( + `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed to install: ${String(error)}`, + ); + } } } } @@ -715,14 +868,25 @@ class BridgeHost { if (!this.requirementsReady(entry)) { return; } + if (entry.installError !== undefined) { + throw new Error(entry.installError); + } if (!entry.installing) { entry.installing = Promise.resolve() .then(async () => { - if (entry.plugin.onInstall) { - await entry.plugin.onInstall(this.pluginContext(entry.plugin.id)); + entry.installAttempted = true; + try { + if (entry.plugin.onInstall) { + await entry.plugin.onInstall(this.pluginContext(entry.plugin.id)); + } + entry.installed = true; + await this.replayLifecycle(entry); + } catch (error) { + if (!entry.installed) { + entry.installError = `Bridge plugin '${entry.plugin.id}' install failed: ${String(error)}`; + } + throw error; } - entry.installed = true; - await this.replayLifecycle(entry); }) .finally(() => { entry.installing = undefined; @@ -736,7 +900,13 @@ class BridgeHost { return; } for (const event of this.lifecycleHistory) { - await entry.plugin.onLifecycle(event, this.pluginContext(entry.plugin.id)); + try { + await entry.plugin.onLifecycle(event, this.pluginContext(entry.plugin.id)); + } catch (error) { + console.error( + `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed replay lifecycle '${event.kind}': ${String(error)}`, + ); + } } } @@ -747,10 +917,10 @@ class BridgeHost { private missingRequirements(entry: HostedPlugin): BridgeContextRequirement[] { const missing: BridgeContextRequirement[] = []; for (const requirement of normalizedRequirements(entry.plugin.requires)) { - if (requirement === "window-stage" && this.windowStage === undefined) { + if (requirement === "window-stage" && !this.windowStageReady) { missing.push(requirement); } - if (requirement === "ui-context" && !this.windows.has(MAIN_WINDOW_KEY)) { + if (requirement === "ui-context" && !this.uiContextReady) { missing.push(requirement); } } @@ -764,7 +934,7 @@ class BridgeHost { moduleName: this.moduleName, sessionId: this.sessionId, abilityContext: this.abilityContext, - isActive: (): boolean => !this.disposed, + isActive: (): boolean => !this.closing && !this.disposed, getWindowStage: (): window.WindowStage => { if (this.windowStage === undefined) { throw new Error(`Bridge plugin '${pluginId}' requires a WindowStage that is not ready`); @@ -802,8 +972,12 @@ class BridgeHost { abilityContext: this.abilityContext, action, timeoutMs, - isActive: (): boolean => !this.disposed && !callState.cancelled, - isCancelled: (): boolean => this.disposed || callState.cancelled, + isActive: (): boolean => + !this.closing && + !this.disposed && + !callState.cancelled && + this.pluginRequirementsReady(pluginId), + isCancelled: (): boolean => this.closing || this.disposed || callState.cancelled, onCancel: (listener: () => void): (() => void) => this.subscribeCancellation(callState, listener), getWindowStage: (): window.WindowStage => context.getWindowStage(), @@ -838,6 +1012,43 @@ class BridgeHost { } } + private enqueueLifecycle(label: string, operation: () => Promise): Promise { + return this.lifecycleQueue.enqueue(label, operation); + } + + private notifyRustLifecycle(kind: string): void { + const sink = this.lifecycleSink; + if (sink === undefined) { + return; + } + try { + sink(kind); + } catch (error) { + console.error( + `[BridgeHost:${this.moduleName}] Rust lifecycle '${kind}' failed: ${String(error)}`, + ); + } + } + + private pluginRequirementsReady(pluginId: string): boolean { + const entry = this.plugins.get(pluginId); + return entry !== undefined && this.requirementsReady(entry); + } + + private cancelCallsWithMissingContext(): void { + for (const callState of this.activeCalls.slice()) { + if (this.pluginRequirementsReady(callState.pluginId)) { + continue; + } + this.cancelCall( + callState, + new Error( + `Bridge plugin '${callState.pluginId}' lost its required context while a call was pending`, + ), + ); + } + } + private async waitForContextChange(callState?: BridgeCallState): Promise { await new Promise((resolve) => { let settled = false; @@ -862,8 +1073,8 @@ class BridgeHost { } private assertCallStateActive(callState?: BridgeCallState): void { - if (this.disposed) { - throw new Error(`Bridge session '${this.sessionId}' is already disposed`); + if (this.closing || this.disposed) { + throw new Error(`Bridge session '${this.sessionId}' is closing or already disposed`); } if (callState?.cancelled) { throw new Error(`Bridge call for session '${this.sessionId}' was cancelled`); @@ -876,7 +1087,12 @@ class BridgeHost { callState: BridgeCallState, phase: string, ): void { - if (this.disposed || callState.cancelled) { + if ( + this.closing || + this.disposed || + callState.cancelled || + !this.pluginRequirementsReady(pluginId) + ) { throw new Error(`Bridge call '${pluginId}.${action}' was cancelled ${phase}`); } } @@ -902,7 +1118,7 @@ class BridgeHost { } private subscribeCancellation(callState: BridgeCallState, listener: () => void): () => void { - if (callState.cancelled || this.disposed) { + if (callState.cancelled || this.closing || this.disposed) { listener(); return (): void => {}; } @@ -984,8 +1200,8 @@ class BridgeHost { } private assertActive(): void { - if (this.disposed) { - throw new Error(`Bridge session '${this.sessionId}' is already disposed`); + if (this.closing || this.disposed) { + throw new Error(`Bridge session '${this.sessionId}' is closing or already disposed`); } } @@ -1125,14 +1341,14 @@ export class BridgeHostRegistry { * window emits `ui-context-ready`, so plugins can mount nodes during `onInstall` without any * waiting; sub-window attachments only register state. */ - static attachWindow( + static async attachWindow( sessionId: string, moduleName: string, windowKey: string, uiContext: UIContext, root: FrameNode, - ): void { - BridgeHostRegistry.host(sessionId, moduleName).attachWindow(windowKey, uiContext, root); + ): Promise { + await BridgeHostRegistry.host(sessionId, moduleName).attachWindow(windowKey, uiContext, root); } /** Unregisters one window's node surface. The root node itself is disposed by its owner. */ @@ -1156,14 +1372,28 @@ export class BridgeHostRegistry { static attachEventSink( sessionId: string, moduleName: string, - mainThreadSink: BridgeMainThreadEventSink, - ): void { - BridgeHostRegistry.host(sessionId, moduleName).attachEventSink(mainThreadSink); + mainThreadSink: BridgeMainThreadEventSink | undefined, + lifecycleSink?: BridgeLifecycleSink, + ): number { + return BridgeHostRegistry.host(sessionId, moduleName).attachEventSink( + mainThreadSink, + lifecycleSink, + ); } - static detachEventSink(sessionId: string, moduleName: string): void { + static detachEventSink(sessionId: string, moduleName: string, token?: number): void { const host = BridgeHostRegistry.sessions.get(sessionId)?.get(moduleName); - host?.detachEventSink(); + host?.detachEventSink(token); + } + + static beginClosing(sessionId: string): void { + const hosts = BridgeHostRegistry.sessions.get(sessionId); + if (hosts === undefined) { + return; + } + for (const host of hosts.values()) { + host.beginClosing(); + } } static async dispose(sessionId: string): Promise { diff --git a/native_ability/src/main/ets/components/DefaultXComponent.ets b/native_ability/src/main/ets/components/DefaultXComponent.ets index 1118a1a0..5eb1bae5 100644 --- a/native_ability/src/main/ets/components/DefaultXComponent.ets +++ b/native_ability/src/main/ets/components/DefaultXComponent.ets @@ -1,7 +1,7 @@ import { NodeContent } from "@kit.ArkUI"; import { FrameNode, NodeController } from "@ohos.arkui.node"; import { UIContext } from "@ohos.arkui.UIContext"; -import { BridgeBindings, Module } from "../ability/type"; +import { BridgeBindings } from "../ability/type"; import { BridgeHostRegistry, MAIN_WINDOW_KEY } from "../bridge/BridgeHost"; import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; @@ -23,7 +23,6 @@ export struct DefaultXComponent { @Prop windowKey: string = MAIN_WINDOW_KEY; private rootSlot = new NodeContent(); private nodeController = new BridgeRootNodeController(this.getUIContext()); - private nativeModule?: Module; @StorageProp("bridgeSessionId") bridgeSessionId: string = ""; @StorageProp("loadMode") loadMode: "async" | "sync" = "async"; @@ -80,7 +79,6 @@ export struct DefaultXComponent { } const nativeModule = await NativeModuleLoader.load(moduleName, this.loadMode); - this.nativeModule = nativeModule; // Idempotent fallback: NativeAbility.onCreate already attached the sink right after // module.init (same cached Module instance), so `ability`-only plugins can emit inbound // events on `ability-create`. Re-attaching here covers modules loaded without onCreate. @@ -94,14 +92,10 @@ export struct DefaultXComponent { responseTypeName: string, value: ESObject, ): ESObject => { - const nativeModuleForSyncEvent = this.nativeModule; - if ( - nativeModuleForSyncEvent === undefined || - typeof nativeModuleForSyncEvent.onBridgeSyncEvent !== "function" - ) { + if (typeof nativeModule.onBridgeSyncEvent !== "function") { throw new Error("Native module does not export onBridgeSyncEvent"); } - return nativeModuleForSyncEvent.onBridgeSyncEvent( + return nativeModule.onBridgeSyncEvent( pluginId, event, requestTypeName, @@ -109,6 +103,9 @@ export struct DefaultXComponent { value, ); }, + (kind: string): void => { + nativeModule.onBridgeLifecycle?.(kind); + }, ); nativeModule.render(this.bindings, this.rootSlot); @@ -117,39 +114,28 @@ export struct DefaultXComponent { // registries or readiness waiters. Attaching the `"main"` window emits `ui-context-ready`; // sub-window instances (unique `windowKey`) only register their own surface. const root = this.nodeController.makeNode(this.getUIContext()); - BridgeHostRegistry.attachWindow( + await BridgeHostRegistry.attachWindow( this.bridgeSessionId, moduleName, this.windowKey, this.getUIContext(), root, ); - - // Rust must observe readiness before ArkTS plugin installation can emit an event. - if (this.windowKey === MAIN_WINDOW_KEY) { - nativeModule.onBridgeLifecycle?.("ui-context-ready"); - } } aboutToDisappear(): void { const moduleName = this.moduleName.trim(); if (this.bridgeSessionId && moduleName) { - const nativeModule = this.nativeModule; + const sessionId = this.bridgeSessionId; const windowKey = this.windowKey; - BridgeHostRegistry.detachWindow(this.bridgeSessionId, moduleName, windowKey) - .then(() => { - if (windowKey === MAIN_WINDOW_KEY) { - nativeModule?.onBridgeLifecycle?.("ui-context-destroy"); - } - }) + BridgeHostRegistry.detachWindow(sessionId, moduleName, windowKey) .catch(() => {}) .finally(() => { this.nodeController.disposeRoot(); - if (windowKey === MAIN_WINDOW_KEY) { - BridgeHostRegistry.detachEventSink(this.bridgeSessionId, moduleName); - } }); + return; } + this.nodeController.disposeRoot(); } build() { diff --git a/native_ability/src/main/ets/runtime/SerialTaskQueue.ets b/native_ability/src/main/ets/runtime/SerialTaskQueue.ets new file mode 100644 index 00000000..ee4486f5 --- /dev/null +++ b/native_ability/src/main/ets/runtime/SerialTaskQueue.ets @@ -0,0 +1,27 @@ +export type SerialTaskErrorHandler = (label: string, error: Error) => void; + +/** + * A small failure-isolating FIFO for lifecycle work. Callers enqueue from synchronous platform + * callbacks; Promise work runs in event order, and one rejection is reported without poisoning + * the rest of the lifecycle chain. + */ +export class SerialTaskQueue { + private tail: Promise = Promise.resolve(); + private readonly onError: SerialTaskErrorHandler; + + constructor(onError: SerialTaskErrorHandler) { + this.onError = onError; + } + + enqueue(label: string, operation: () => Promise): Promise { + const result = this.tail.then(operation); + this.tail = result.catch((error: Error): void => { + this.onError(label, error); + }); + return result; + } + + settled(): Promise { + return this.tail; + } +} diff --git a/native_ability/src/test/LocalUnit.test.ets b/native_ability/src/test/LocalUnit.test.ets index a8e45e09..bf80bc84 100644 --- a/native_ability/src/test/LocalUnit.test.ets +++ b/native_ability/src/test/LocalUnit.test.ets @@ -1,4 +1,5 @@ import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from "@ohos/hypium"; +import { SerialTaskQueue } from "../main/ets/runtime/SerialTaskQueue"; export default function localUnitTest() { describe("localUnitTest", () => { @@ -29,5 +30,46 @@ export default function localUnitTest() { expect(a).assertContain(b); expect(a).assertEqual(a); }); + it("serialTaskQueuePreservesLifecycleOrder", 0, async () => { + const events: string[] = []; + let release: () => void = (): void => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const queue = new SerialTaskQueue((): void => {}); + + void queue.enqueue("create", async (): Promise => { + events.push("create-start"); + await gate; + events.push("create-end"); + }); + void queue.enqueue("window", async (): Promise => { + events.push("window"); + }); + + await Promise.resolve(); + expect(events.join(",")).assertEqual("create-start"); + release(); + await queue.settled(); + expect(events.join(",")).assertEqual("create-start,create-end,window"); + }); + it("serialTaskQueueContinuesAfterFailure", 0, async () => { + const errors: string[] = []; + const events: string[] = []; + const queue = new SerialTaskQueue((label: string): void => { + errors.push(label); + }); + + void queue.enqueue("broken", async (): Promise => { + throw new Error("expected failure"); + }); + void queue.enqueue("destroy", async (): Promise => { + events.push("destroy"); + }); + await queue.settled(); + + expect(errors.join(",")).assertEqual("broken"); + expect(events.join(",")).assertEqual("destroy"); + }); }); } diff --git a/plugins/webview/src/main/ets/WebviewPlugin.ets b/plugins/webview/src/main/ets/WebviewPlugin.ets index d444fbff..a492e72c 100644 --- a/plugins/webview/src/main/ets/WebviewPlugin.ets +++ b/plugins/webview/src/main/ets/WebviewPlugin.ets @@ -7,6 +7,7 @@ import { BridgePluginContext, AsyncPluginBase, BridgeContextRequirement, + BridgeLifecycleEvent, } from "@ohos-rs/ability"; const CREATE_REQUEST_TYPE = "ohos.webview.CreateRequest"; @@ -917,21 +918,23 @@ class WebviewSurface { } } - remove(id: string, expected?: WebviewEntry): boolean { + remove(id: string, expected?: WebviewEntry, detachNode: boolean = true): boolean { const entry = this.entries.get(id); if (!entry || (expected !== undefined && entry !== expected)) { return false; } this.entries.delete(id); entry.failController(`WebView '${id}' was removed before its controller attached`); - const scope = this.pluginContext.windowScope(entry.data.windowKey); - const parentHandle = entry.data.mountParentHandle; - if (parentHandle !== null) { - try { - scope.getFrameNode(parentHandle).removeChild(entry.node.getFrameNode()); - } catch {} - } else { - scope.removeChild(mountKeyOf(id)); + if (detachNode) { + const scope = this.pluginContext.windowScope(entry.data.windowKey); + const parentHandle = entry.data.mountParentHandle; + if (parentHandle !== null) { + try { + scope.getFrameNode(parentHandle).removeChild(entry.node.getFrameNode()); + } catch {} + } else { + scope.removeChild(mountKeyOf(id)); + } } try { notifyNative( @@ -959,13 +962,13 @@ class WebviewSurface { } } - dispose(): void { + dispose(detachNodes: boolean = true): void { if (this.disposed) { return; } this.disposed = true; for (const id of Array.from(this.entries.keys())) { - this.remove(id); + this.remove(id, undefined, detachNodes); } } @@ -1004,6 +1007,21 @@ export class WebviewPlugin extends AsyncPluginBase { this.surface = new WebviewSurface(context); } + override onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginContext): void { + if (event.kind === "ui-context-destroy") { + const surface = this.surface; + this.surface = undefined; + // BridgeHost already detached the old window from its lookup table and owns the old node + // tree cleanup. Release controllers/waiters without accidentally addressing a newly + // attached main-window root. + surface?.dispose(false); + return; + } + if (event.kind === "ui-context-ready" && this.surface === undefined) { + this.surface = new WebviewSurface(context); + } + } + async invokeAsync( action: string, payload: BridgeTypedValue, From 27d224584950db441a5f7d72efb309ddd3eca9d4 Mon Sep 17 00:00:00 2001 From: richerfu Date: Tue, 11 Aug 2026 09:08:06 +0800 Subject: [PATCH 2/5] refactor: harden plugin lifecycle ownership --- crates/ability/src/app.rs | 9 + crates/ability/src/bridge/mod.rs | 142 ++++++- crates/ability/src/lifecycle.rs | 3 + crates/derive/README.md | 8 +- crates/derive/src/lib.rs | 29 +- crates/plugin-permission/README.md | 2 +- crates/plugin-resource/README.md | 6 +- .../main/cpp/types/libdemo_native/Index.d.ts | 6 +- .../src/main/ets/bridge/DemoLoginPlugin.ets | 6 +- .../src/main/ets/bridge/DemoNodePlugin.ets | 7 +- .../main/ets/entryability/EntryAbility.ets | 13 +- demo/entry/src/main/ets/pages/Index.ets | 2 +- docs/plugin-development-standard.md | 41 +- native_ability/CHANGELOG.md | 8 + native_ability/README.md | 16 +- native_ability/index.ets | 3 +- .../src/main/ets/ability/NativeAbility.ets | 266 ++++++++---- native_ability/src/main/ets/ability/type.ets | 83 ++-- .../src/main/ets/bridge/BridgeHost.ets | 389 +++++++++++++++--- .../main/ets/components/DefaultXComponent.ets | 201 ++++++--- .../main/ets/runtime/AbilityStateCodec.ets | 41 ++ .../main/ets/runtime/CancellableTaskScope.ets | 93 +++++ native_ability/src/test/LocalUnit.test.ets | 163 ++++++++ plugins/files/src/main/ets/FilesPlugin.ets | 9 +- plugins/resource/CHANGELOG.md | 7 + plugins/resource/README.md | 12 +- .../resource/src/main/ets/ResourcePlugin.ets | 8 +- .../webview/src/main/ets/WebviewPlugin.ets | 34 +- 28 files changed, 1301 insertions(+), 306 deletions(-) create mode 100644 native_ability/src/main/ets/runtime/AbilityStateCodec.ets create mode 100644 native_ability/src/main/ets/runtime/CancellableTaskScope.ets diff --git a/crates/ability/src/app.rs b/crates/ability/src/app.rs index 14279ed1..7f9c1e47 100644 --- a/crates/ability/src/app.rs +++ b/crates/ability/src/app.rs @@ -379,6 +379,15 @@ impl OpenHarmonyApp { } } + pub(crate) fn clear_bridge_bindings(&self) { + if let Ok(mut guard) = self.bridge_runtime.write() { + guard.take(); + } + if let Ok(mut guard) = self.bridge_main_thread.write() { + guard.take(); + } + } + pub fn show_keyboard(&self) { let _guard = self .is_keyboard_show diff --git a/crates/ability/src/bridge/mod.rs b/crates/ability/src/bridge/mod.rs index e871b278..dd5d356e 100644 --- a/crates/ability/src/bridge/mod.rs +++ b/crates/ability/src/bridge/mod.rs @@ -472,6 +472,12 @@ impl BridgePluginRegistry { event.plugin_id() )) })?; + if !state.session_active { + return Err(Error::from_reason(format!( + "Bridge plugin '{}' received a main-thread event outside an active Ability session", + event.plugin_id() + ))); + } if !state.readiness.supports(entry.required_contexts) { return Err(Error::from_reason(format!( "Bridge plugin '{}' received a main-thread event before its required context was ready", @@ -493,19 +499,36 @@ impl BridgePluginRegistry { // The OpenHarmony process may keep the native module loaded while recreating the // Ability. Lifecycle replay is session-scoped: never expose events from the previous // Ability instance to a plugin activated in the next one. - if matches!(event, PluginLifecycleEvent::AbilityCreated { .. }) && !state.session_active - { + if matches!(event, PluginLifecycleEvent::AbilityCreated { .. }) { state.readiness = BridgeContextReadiness::default(); state.lifecycle_history.clear(); state.session_active = true; for entry in state.plugins.values_mut() { entry.activated = false; } + } else if !state.session_active { + // A closing ArkTS hook or stale TSFN may complete after AbilityDestroyed. Late + // events belong to no session and must never reach process-wide Rust plugins. + return Ok(()); } state.readiness.observe(&event); if state.lifecycle_history.len() >= MAX_LIFECYCLE_HISTORY { - state.lifecycle_history.remove(0); + if let Some(index) = state.lifecycle_history.iter().position(|recorded| { + matches!( + recorded, + PluginLifecycleEvent::ConfigurationUpdated + | PluginLifecycleEvent::MemoryLevel { .. } + | PluginLifecycleEvent::WindowStageEvent { .. } + ) + }) { + state.lifecycle_history.remove(index); + } else { + // Preserve AbilityCreated at index 0 when possible, while keeping the replay + // buffer genuinely bounded even across repeated structural context cycles. + let index = usize::from(state.lifecycle_history.len() > 1); + state.lifecycle_history.remove(index); + } } state.lifecycle_history.push(event.clone()); @@ -529,6 +552,10 @@ impl BridgePluginRegistry { if matches!(event, PluginLifecycleEvent::AbilityDestroyed) { state.session_active = false; + state.lifecycle_history.clear(); + for entry in state.plugins.values_mut() { + entry.activated = false; + } } deliveries }; @@ -1440,6 +1467,115 @@ mod tests { ); } + #[test] + fn lifecycle_replay_keeps_session_anchors_during_transient_event_pressure() { + let events = Arc::new(Mutex::new(Vec::new())); + let registry = BridgePluginRegistry::default(); + registry + .register(RecordingUiContextPlugin { + events: Arc::clone(&events), + }) + .unwrap(); + + registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityCreated { + restored_state: "anchor".to_owned(), + }) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::WindowStageCreated) + .unwrap(); + for event_type in 0..32 { + registry + .dispatch_lifecycle(PluginLifecycleEvent::WindowStageEvent { event_type }) + .unwrap(); + } + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextReady) + .unwrap(); + + let events = events.lock().unwrap(); + assert!(matches!( + events.first(), + Some(PluginLifecycleEvent::AbilityCreated { restored_state }) if restored_state == "anchor" + )); + assert_eq!( + events.get(1), + Some(&PluginLifecycleEvent::WindowStageCreated) + ); + assert_eq!(events.last(), Some(&PluginLifecycleEvent::UiContextReady)); + } + + #[test] + fn lifecycle_replay_remains_bounded_during_context_recreation() { + let registry = BridgePluginRegistry::default(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityCreated { + restored_state: "bounded".to_owned(), + }) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::WindowStageCreated) + .unwrap(); + for _ in 0..32 { + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextReady) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextDestroyed) + .unwrap(); + } + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextReady) + .unwrap(); + + let events = Arc::new(Mutex::new(Vec::new())); + registry + .register(RecordingUiContextPlugin { + events: Arc::clone(&events), + }) + .unwrap(); + + let events = events.lock().unwrap(); + assert!(events.len() <= super::MAX_LIFECYCLE_HISTORY); + assert!(matches!( + events.first(), + Some(PluginLifecycleEvent::AbilityCreated { restored_state }) if restored_state == "bounded" + )); + assert_eq!(events.last(), Some(&PluginLifecycleEvent::UiContextReady)); + } + + #[test] + fn lifecycle_registry_ignores_events_after_ability_destroy() { + let events = Arc::new(Mutex::new(Vec::new())); + let registry = BridgePluginRegistry::default(); + registry + .register(RecordingUiContextPlugin { + events: Arc::clone(&events), + }) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityCreated { + restored_state: String::new(), + }) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextReady) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityDestroyed) + .unwrap(); + let deliveries_after_destroy = events.lock().unwrap().len(); + + registry + .dispatch_lifecycle(PluginLifecycleEvent::WindowStageCreated) + .unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextReady) + .unwrap(); + assert_eq!(events.lock().unwrap().len(), deliveries_after_destroy); + } + #[test] fn lifecycle_failure_does_not_block_other_plugins() { let healthy_deliveries = Arc::new(AtomicUsize::new(0)); diff --git a/crates/ability/src/lifecycle.rs b/crates/ability/src/lifecycle.rs index a37efd1c..d56f8dea 100644 --- a/crates/ability/src/lifecycle.rs +++ b/crates/ability/src/lifecycle.rs @@ -262,6 +262,9 @@ pub fn create_lifecycle_handle<'a>( if let Some(ref mut h) = *on_ability_destroy_app.event_loop.borrow_mut() { h(Event::Destroy) } + // The native module is process-wide, but bridge endpoints are Ability-session scoped. + // Drop TSFN/FunctionRef bindings before a recreated Ability can observe stale hosts. + on_ability_destroy_app.clear_bridge_bindings(); Ok(()) })?; diff --git a/crates/derive/README.md b/crates/derive/README.md index 92ca9949..10e6f782 100644 --- a/crates/derive/README.md +++ b/crates/derive/README.md @@ -23,9 +23,15 @@ making them framework render modes. A business that needs custom protocol interc it through `openharmony-ability-plugin-webview::WebviewProtocol` and `WebviewClient::custom_protocol` rather than restoring a macro branch. +The generated `render(bindings, slot, render_owner)` export retains one Rust `RootNode` per +appearance owner, allowing main and sub-window components to coexist. The matching +`dispose_render(render_owner)` export releases only that appearance during synchronous component +teardown; `dispose_all_renders()` is the WindowStage-destroy fallback for any component that did +not receive `aboutToDisappear`. + The generated `init(context)` forwards ArkTS init data into native code. Read it through `app.init_context()`, `app.module_name()`, `app.base_path()`, `app.pref_path()`, and `app.preferred_locales()`. The resource manager is a plugin capability: register `openharmony_ability_plugin_resource::ResourceBridgePlugin` in the `#[ability]` initializer and read it via the `ResourceExt` trait (`app.resource_manager()`); the ArkTS side must install -`@ohos-rs/ability-plugin-resource` (typically as `new EagerPlugin(new ResourcePlugin())`). +`@ohos-rs/ability-plugin-resource` as a session-scoped `new LazyPlugin(() => new ResourcePlugin())`. diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs index 4ae9f1b1..05d719e7 100644 --- a/crates/derive/src/lib.rs +++ b/crates/derive/src/lib.rs @@ -28,11 +28,36 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { env: &'a napi_ohos::Env, bindings: napi_ohos::bindgen_prelude::ObjectRef, #[napi(ts_arg_type = "NodeContent")] slot: openharmony_ability::arkui::ArkUIHandle, + render_owner: String, ) -> napi_ohos::Result<()> { + if render_owner.is_empty() { + return Err(napi_ohos::Error::from_reason("renderOwner must not be empty")); + } let root = openharmony_ability::render(env, bindings, slot, (*APP).clone())?; - ROOT_NODE.replace(Some(root)); + ROOT_NODES.with(|nodes| { + let mut nodes = nodes.borrow_mut(); + if let Some(index) = nodes.iter().position(|(owner, _)| owner == &render_owner) { + nodes.remove(index); + } + nodes.push((render_owner, root)); + }); Ok(()) } + + #[napi_derive_ohos::napi] + pub fn dispose_render(render_owner: String) { + ROOT_NODES.with(|nodes| { + let mut nodes = nodes.borrow_mut(); + if let Some(index) = nodes.iter().position(|(owner, _)| owner == &render_owner) { + nodes.remove(index); + } + }); + } + + #[napi_derive_ohos::napi] + pub fn dispose_all_renders() { + ROOT_NODES.with(|nodes| nodes.borrow_mut().clear()); + } }; let expanded = quote::quote! { @@ -46,7 +71,7 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { static APP_CONFIGURED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); thread_local! { - pub static ROOT_NODE: std::cell::RefCell> = std::cell::RefCell::new(None); + pub static ROOT_NODES: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); } #[napi_derive_ohos::napi] diff --git a/crates/plugin-permission/README.md b/crates/plugin-permission/README.md index 9ada34cf..24a260e6 100644 --- a/crates/plugin-permission/README.md +++ b/crates/plugin-permission/README.md @@ -94,7 +94,7 @@ async fn request_camera(app: &OpenHarmonyApp) -> Result<()> { ArkTS 侧必须校验输入 `typeName === "ohos.permission.PermissionRequest"`,并返回 `"ohos.permission.PermissionResponse"`。新增 action 或修改字段时,Rust 和 ArkTS 的 typeName、插件版本、 -`requires`、执行模式必须同步更新;不得使用 `JSON.stringify` / `JSON.parse` 传输 payload。 +`requires`、执行模式必须同步更新;不得使用 JSON 序列化 API 传输 payload。 完整的线程、生命周期、契约升级和验收要求见 [插件开发规范](../../docs/plugin-development-standard.md)。ArkTS 实现与装配细节见 diff --git a/crates/plugin-resource/README.md b/crates/plugin-resource/README.md index 64111ccf..e2a672bb 100644 --- a/crates/plugin-resource/README.md +++ b/crates/plugin-resource/README.md @@ -37,15 +37,15 @@ ArkTS 对象引用跨线程。 } ``` -2. ArkTS 侧以共享实例安装 wrapper(resource manager 是进程级单例,无需每 session 实例): +2. ArkTS 侧为每个 module/session 创建独立 wrapper;进程级 native pointer 由 Rust 持有: ```ts - import { EagerPlugin } from "@ohos-rs/ability"; + import { LazyPlugin } from "@ohos-rs/ability"; import { ResourcePlugin } from "@ohos-rs/ability-plugin-resource"; // in NativeAbility subclass: public bridgePlugins = [ - new EagerPlugin(new ResourcePlugin()), + new LazyPlugin(() => new ResourcePlugin()), ]; ``` diff --git a/demo/entry/src/main/cpp/types/libdemo_native/Index.d.ts b/demo/entry/src/main/cpp/types/libdemo_native/Index.d.ts index dcc51fd6..b83a7943 100644 --- a/demo/entry/src/main/cpp/types/libdemo_native/Index.d.ts +++ b/demo/entry/src/main/cpp/types/libdemo_native/Index.d.ts @@ -289,7 +289,11 @@ export declare function onBridgeSyncEvent( value: unknown, ): unknown; -export declare function render(bindings: object, slot: NodeContent): void; +export declare function render(bindings: object, slot: NodeContent, renderOwner: string): void; + +export declare function disposeRender(renderOwner: string): void; + +export declare function disposeAllRenders(): void; export declare function setBackgroundColor(color: string): Promise; diff --git a/demo/entry/src/main/ets/bridge/DemoLoginPlugin.ets b/demo/entry/src/main/ets/bridge/DemoLoginPlugin.ets index 0059a304..fdd88e1e 100644 --- a/demo/entry/src/main/ets/bridge/DemoLoginPlugin.ets +++ b/demo/entry/src/main/ets/bridge/DemoLoginPlugin.ets @@ -1,7 +1,7 @@ import { BridgeCallContext, BridgeTypedValue, - BridgePluginContext, + BridgePluginHookContext, AsyncPluginBase, BridgeContextRequirement, SyncPluginBase, @@ -126,7 +126,7 @@ export class DemoLoginPlugin extends AsyncPluginBase { this.provider = provider; } - override async onInstall(context: BridgePluginContext): Promise { + override async onInstall(context: BridgePluginHookContext): Promise { // This would throw before UIContext readiness; BridgeHost guarantees it is ready here. context.getUIContext(); AppStorage.setOrCreate("bridgeLoginStatus", "ready"); @@ -175,7 +175,7 @@ export class DemoMainThreadPlugin extends SyncPluginBase { readonly version: number = 1; readonly requires: BridgeContextRequirement[] = ["ui-context"]; - override onInstall(context: BridgePluginContext): void { + override onInstall(context: BridgePluginHookContext): void { context.getUIContext(); } diff --git a/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets b/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets index dd20aa1e..b424eca0 100644 --- a/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets +++ b/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets @@ -3,6 +3,7 @@ import { BridgeCallContext, BridgeTypedValue, BridgePluginContext, + BridgePluginHookContext, AsyncPluginBase, BridgeContextRequirement, BridgeLifecycleEvent, @@ -35,11 +36,11 @@ export class DemoNodePlugin extends AsyncPluginBase { private mounted = false; - onInstall(context: BridgePluginContext): void { + onInstall(context: BridgePluginHookContext): void { this.mount(context); } - override onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginContext): void { + override onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginHookContext): void { if (event.kind === "ui-context-destroy") { // BridgeHost owns the detached tree and runs its cleanup; do not address a replacement // window through this plugin's session-scoped context. @@ -71,7 +72,7 @@ export class DemoNodePlugin extends AsyncPluginBase { throw new Error(`Unsupported demo.node-badge action '${action}'`); } - onDispose(context: BridgePluginContext): void { + onDispose(context: BridgePluginHookContext): void { if (this.mounted) { context.removeChild("demo.node-badge"); this.mounted = false; diff --git a/demo/entry/src/main/ets/entryability/EntryAbility.ets b/demo/entry/src/main/ets/entryability/EntryAbility.ets index 380c60b6..8412f629 100644 --- a/demo/entry/src/main/ets/entryability/EntryAbility.ets +++ b/demo/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,4 +1,4 @@ -import { EagerPlugin, LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; import window from "@ohos.window"; import { AppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; import { FilesPlugin } from "@ohos-rs/ability-plugin-files"; @@ -25,7 +25,7 @@ export default class EntryAbility extends NativeAbility { new LazyPlugin(() => new PermissionPlugin()), new LazyPlugin(() => new AppControlPlugin()), new LazyPlugin(() => new FilesPlugin()), - new EagerPlugin(new ResourcePlugin()), + new LazyPlugin(() => new ResourcePlugin()), new LazyPlugin(() => new WindowPlugin()), new LazyPlugin(() => new WebviewPlugin()), new LazyPlugin(() => new UrlPlugin()), @@ -35,15 +35,16 @@ export default class EntryAbility extends NativeAbility { new LazyPlugin(() => new DemoNodePlugin()), ]; - onWindowStageCreate(windowStage: window.WindowStage): void { + protected override async loadWindowStageContent(windowStage: window.WindowStage): Promise { demoWindowStage = windowStage; - super.onWindowStageCreate(windowStage); - void this.enqueueLifecycleOperation("demo-window-content", async (): Promise => { + try { const mainWindow = windowStage.getMainWindowSync(); // 全屏布局:内容延伸到状态栏下方(状态栏变透明),避免顶部出现系统绘制的黑色状态栏条。 await mainWindow.setWindowLayoutFullScreen(true); await windowStage.loadContent("pages/Index"); - }); + } catch (error) { + throw new Error(`Unable to load demo WindowStage content: ${String(error)}`); + } } onWindowStageDestroy(): void { diff --git a/demo/entry/src/main/ets/pages/Index.ets b/demo/entry/src/main/ets/pages/Index.ets index ec0431a3..4f6ef0ac 100644 --- a/demo/entry/src/main/ets/pages/Index.ets +++ b/demo/entry/src/main/ets/pages/Index.ets @@ -216,7 +216,7 @@ struct Index { Math.max(24, Math.floor((mainRect.height - height) / 2)), ); await subWindow.setUIContent("pages/SubWindowPage"); - await subWindow.show(); + await subWindow.showWindow(); this.webviewStatus = '✓ sub window opened: it owns its own node tree (windowKey="' + SUB_WINDOW_KEY + '")'; } catch (error) { diff --git a/docs/plugin-development-standard.md b/docs/plugin-development-standard.md index af9d59f7..3455fbda 100644 --- a/docs/plugin-development-standard.md +++ b/docs/plugin-development-standard.md @@ -332,19 +332,25 @@ OpenHarmony SDK 中 `UIAbility.onCreate`、`onWindowStageCreate` 和 `onWindowSt UIContext 和销毁事件不能相互穿插。单个插件的 lifecycle/onDispose 失败只能记录,不能中断后续插件; session 开始关闭后必须拒绝新调用并取消未完成调用。 -1. `NativeAbility.onCreate` 打开 module/session 对应的 `BridgeHost`,创建 factory,并发出 - `ability-create`。 +1. `NativeAbility.onCreate` 先预创建 module/session 对应的 `BridgeHost` 和 plugin instance,但不执行 + hook;native module 完成 `init`、Rust lifecycle/event sink 均已 attach、Rust 已收到 + `AbilityCreated` 后,Host 才把 `ability` 标记为 ready,执行 `onInstall` 并发出 + `ability-create`。因此 ability-only plugin 的 `onInstall` 可以安全调用 `invokeNativeSync`。 2. `NativeAbility.onWindowStageCreate` 先提供 `WindowStage`,再发出 `window-stage-create`;窗口事件 - 仍要同时转发给原 native module lifecycle。 -3. `DefaultXComponent.aboutToAppear` 先挂接 native event sink,再按 `windowKey` 注册窗口表面 + 仍要同时转发给原 native module lifecycle。Stage create/destroy 使用 generation token:已入队的 + create 在 destroy 后不得重新把 context 标记为 ready。自定义页面通过 + `loadWindowStageContent` 加入这个受控事务,不得从平台回调启动脱离队列的 Promise。 +3. `DefaultXComponent.aboutToAppear` 先挂接 native event sink,以本次 appearance 唯一的 + `renderOwner` 保存 Rust `RootNode`,再按 `windowKey` 注册窗口表面 (UIContext + 根 `FrameNode`,根先于 `ui-context-ready` 存在);`"main"` 窗口注册后通知 Rust `ui-context-ready`。这样 plugin `onInstall` 期间已经可以安全发起 scoped 回调或挂载节点,无需 任何等待。子窗口实例(唯一 `windowKey`)只登记自己的表面,不重发 ready。 -4. UI 消失时,`detachWindow` 先发出 `ui-context-destroy`(仅 `"main"`),再卸载该窗口的 keyed - 节点与句柄节点并 detach event sink(仅 `"main"`);WindowStage 销毁时 detach 所有窗口并发出 +4. UI 消失时,`detachWindow` 先发出带 `windowKey` 的 `window-detached`,再发出 + `ui-context-destroy`(仅 `"main"`),并卸载该窗口的 keyed + 节点与句柄节点。WindowStage 销毁时 detach 所有窗口并发出 `window-stage-destroy`;Ability 销毁时发出 `ability-destroy` 并 dispose 整个 session(session 销毁时由 `BridgeHost` 级联卸载全部窗口的节点,根 `FrameNode` 本身由各 `DefaultXComponent` - 销毁)。 + 在等待 Host 清理屏障后销毁)。Event sink 属于 module/session,只在 session dispose 时解除。 5. `configuration-updated`、`memory-level`、window-stage event 等保持由 `NativeAbility` 原有链路 分发,同时作为受控 lifecycle event 交给已安装插件。 @@ -365,6 +371,9 @@ ArkTS context 是 module + session 范围的。插件不得假设多个 module callback 重试。 - `onDispose` 必须幂等,负责移除平台 delegate、取消订阅、卸载节点、清空 controller/tag 映射。 单个插件释放失败不能阻断其余插件释放。 +- `onInstall` / `onLifecycle` / `onDispose` 在独立的 bounded hook scope 中执行;scope 通过 + `BridgePluginHookContext.onCancel` 通知取消,默认 watchdog 为 5 秒。插件不得忽略取消后继续挂载 + 节点或回写平台状态;单个 hook 超时只会把该插件标记失败并继续 session teardown。 - 禁止用 `setTimeout`、轮询或固定延迟猜测页面、controller 或 context 是否已经就绪。等待条件必须由 生命周期或真正的平台完成事件驱动。 - 节点挂载无需等待:session 根在 `ui-context-ready` 之前已注入,`onInstall` 内即可挂载。创建到 @@ -383,9 +392,9 @@ context.appendChild( ## 6. ArkUI 节点树与挂载(一棵树模型) 需要渲染内容的插件(WebView、地图、相机、视频等)都是 **FrameNode 提供者**:它们把节点挂进 -session 唯一一棵根树,不写进 `DefaultXComponent`,也没有 WebView 专用插槽。 +目标窗口唯一的一棵根树,不写进 `DefaultXComponent`,也没有 WebView 专用插槽。 -- 每个 module/session 只有一棵根树。`DefaultXComponent` 在 `aboutToAppear` 中先创建根 +- 每个 module/session/windowKey 只有一棵根树。`DefaultXComponent` 在 `aboutToAppear` 中先创建根 `FrameNode` 并注入 `BridgeHost`,再发出 `ui-context-ready`;因此插件在 `onInstall` 里可以直接 `context.appendChild(...)`,**不存在命名插槽、注册表、waitFor/require 或就绪计时器**。 - `context.appendChild(key, node, cleanup)` / `context.removeChild(key)`:key 必须以插件 ID 为前缀 @@ -418,6 +427,12 @@ Stack() { - 只有 `"main"` 窗口的注册会发出 `ui-context-ready` / `ui-context-destroy`(插件安装与 session 生命周期仍以主窗口为准);子窗口注册只登记状态。 +- 每个窗口都会发出 `window-attached` / `window-detached`,payload 携带 `windowKey`。拥有 controller、 + delegate 或异步 waiter 的插件必须按该 key 建表并在 detach 时清理对应窗口,禁止用主窗口的 + `ui-context-destroy` 一次性清空其他仍存活窗口。 +- 每次 `DefaultXComponent` appearance 都有独立 `renderOwner`;Rust derive 层按 owner 保存多个 + `RootNode`。组件快速消失会使 generation 失效并取消 pending attach,旧异步 continuation 不得重新 + 挂载已经消失的窗口。 - 插件默认操作 `"main"` 窗口;子窗口内容用 `context.windowScope(windowKey)` 获取窗口作用域: `getUIContext()` / `getRootFrameNode()` / `appendChild` / `removeChild` / `getFrameNode`。 - Rust 侧:`ohos.node` 的四个 action 与 `WebviewCreateRequest` 都支持 `window_key` 字段(缺省 @@ -553,10 +568,10 @@ export default class EntryAbility extends NativeAbility { factory 可以用 `modules` 限制适用的 native module。未装配、版本不匹配、模式不匹配和类型不匹配 都应在桥接边界确定性报错,不得悄悄回退到 helper 或 JSON 兼容路径。 -`LazyPlugin`(默认)在 `BridgeHost.install` 时为每个 native module 和 Ability session 创建独立 -实例;`EagerPlugin` 共享一个调用方构造的实例,用于持有进程级全局状态的插件(例如只做一次 -native wrapper 推送的 `ohos.resource`)。共享实例会被重复 `attachContext`,必须容忍重复的 -lifecycle 通知与 dispose。 +`LazyPlugin` 在 `BridgeHost.registerFactories` 时为每个 native module 和 Ability session 创建独立 +实例。禁止跨 module/session 共享 ArkTS plugin instance:`attachContext`、hook cancellation 和 +controller/window 映射都是 session 状态。真正的进程级资源必须由 native/Rust 单例持有,ArkTS +wrapper 仍保持 session-scoped。 ## 9. 实现、Demo 与验收 diff --git a/native_ability/CHANGELOG.md b/native_ability/CHANGELOG.md index 59447a5b..be439fee 100644 --- a/native_ability/CHANGELOG.md +++ b/native_ability/CHANGELOG.md @@ -1,5 +1,13 @@ # 1.0.0-beta.1 +- **Breaking**: remove `EagerPlugin`; every ArkTS plugin instance is now scoped to one + module/session. Process-wide resources stay in Rust/native singletons. +- **Breaking**: plugin hooks receive `BridgePluginHookContext` with cancellation, and native + `render` receives a per-appearance `renderOwner` plus optional `disposeRender` cleanup. +- Serialize Ability/WindowStage/UI lifecycle with generation guards, bounded hook watchdogs and + prepare-then-activate startup so Rust sinks exist before ability plugin installation. +- Add per-window attach/detach lifecycle, independent Rust render roots and window-scoped WebView + controller cleanup. - **Breaking**: normalized node mounting — the named-slot model (`BridgeNodeSlot` / `BridgeNodeHost` / `slotId`) is gone. WebView `FrameNode`s mount into the session root tree (`context.appendChild`, key `ohos.webview.`), full-bleed by default. diff --git a/native_ability/README.md b/native_ability/README.md index 7294d4ba..a99059dc 100644 --- a/native_ability/README.md +++ b/native_ability/README.md @@ -133,21 +133,21 @@ JSON transport type. ```ts import { NativeAbility } from "@ohos-rs/ability"; -import Want from "@ohos.app.ability.Want"; -import { AbilityConstant } from "@kit.AbilityKit"; import window from "@ohos.window"; export default class EntryAbility extends NativeAbility { public moduleName: string = "demo_native"; public defaultPage: boolean = false; - async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise { - super.onCreate(want, launchParam); - } - - async onWindowStageCreate(windowStage: window.WindowStage): Promise { - super.onWindowStageCreate(windowStage); + protected override async loadWindowStageContent( + windowStage: window.WindowStage, + ): Promise { await windowStage.loadContent("pages/Index"); } } ``` + +OpenHarmony does not await `onCreate` or `onWindowStageCreate`. Override the framework hook above +for custom page loading; it runs inside the serialized, generation-checked WindowStage transaction. +Declaring the platform callback itself `async` is not an ordering barrier and can render +`DefaultXComponent` before its bridge session exists. diff --git a/native_ability/index.ets b/native_ability/index.ets index 76e6b42d..b71aa644 100644 --- a/native_ability/index.ets +++ b/native_ability/index.ets @@ -1,11 +1,11 @@ export { NativeAbility } from "./src/main/ets/ability/NativeAbility"; export { AsyncPluginBase, - EagerPlugin, LazyPlugin, BridgeAbilityCreateLifecyclePayload, BridgeEmptyLifecyclePayload, BridgeMemoryLevelLifecyclePayload, + BridgeWindowLifecyclePayload, BridgeWindowStageEventLifecyclePayload, PluginBase, SyncPluginBase, @@ -23,6 +23,7 @@ export type { BridgePluginContext, BridgePluginExecution, BridgePluginFactory, + BridgePluginHookContext, BridgeWindowScope, MainThreadSyncBridgePlugin, } from "./src/main/ets/ability/type"; diff --git a/native_ability/src/main/ets/ability/NativeAbility.ets b/native_ability/src/main/ets/ability/NativeAbility.ets index 267b8198..b3776cb5 100644 --- a/native_ability/src/main/ets/ability/NativeAbility.ets +++ b/native_ability/src/main/ets/ability/NativeAbility.ets @@ -4,6 +4,7 @@ import window from "@ohos.window"; import * as Entry from "../components/MainPage"; import { BridgeHostRegistry } from "../bridge/BridgeHost"; import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; +import { AbilityStateCodec } from "../runtime/AbilityStateCodec"; import { SerialTaskQueue } from "../runtime/SerialTaskQueue"; import { AbilityInitContext, @@ -41,7 +42,58 @@ export class NativeAbility extends UIAbility { console.error(`[NativeAbility] lifecycle operation '${label}' failed: ${String(error)}`); }); private acceptingLifecycle: boolean = false; + private abilityGeneration: number = 0; + private windowStageGeneration: number = 0; private windowStageActive: boolean = false; + private observedWindowStage?: window.WindowStage; + private observedMainWindow?: window.Window; + + private readonly onWindowStageEvent = (event: window.WindowStageEventType): void => { + if (!this.acceptingLifecycle || this.observedWindowStage === undefined) { + return; + } + void this.enqueueLifecycleOperation("window-stage-event", async (): Promise => { + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onWindowStageEvent(event), + ); + await this.notifyBridgeLifecycle({ + kind: "window-stage-event", + payload: new BridgeWindowStageEventLifecyclePayload(event), + }); + }); + }; + + private readonly onWindowSizeChange = (size: window.Size): void => { + if (this.acceptingLifecycle && this.observedMainWindow !== undefined) { + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onWindowSizeChange(size), + ); + } + }; + + private readonly onWindowRectChange = (options: window.RectChangeOptions): void => { + if (this.acceptingLifecycle && this.observedMainWindow !== undefined) { + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onWindowRectChange(options), + ); + } + }; + + private readonly onAvoidAreaChange = (options: window.AvoidAreaOptions): void => { + if (this.acceptingLifecycle && this.observedMainWindow !== undefined) { + this.forEachLifecycle((lifecycle) => + lifecycle.windowStageEventCallback.onAvoidAreaChange(options), + ); + } + }; + + private readonly onKeyboardHeightChange = (height: number): void => { + if (this.acceptingLifecycle && this.observedMainWindow !== undefined) { + this.forEachLifecycle((lifecycle) => + lifecycle.keyboardEventCallback.onKeyboardHeightChange(height), + ); + } + }; protected resolveModuleNames(): string[] { const moduleNames = NativeModuleLoader.resolveModuleNames(this.moduleName); @@ -73,21 +125,11 @@ export class NativeAbility extends UIAbility { } protected parseSavedStateMap(rawState: string): Record { - if (!rawState) { - return {}; - } + return AbilityStateCodec.decode(rawState); + } - try { - const parsed = JSON.parse(rawState) as Record; - const mapped: Record = {}; - for (const key of Object.keys(parsed)) { - const value = parsed[key]; - mapped[key] = typeof value === "string" ? value : String(value); - } - return mapped; - } catch { - return {}; - } + protected serializeSavedStateMap(stateMap: Record): string { + return AbilityStateCodec.encode(stateMap); } protected createInitContext(moduleName: string): AbilityInitContext { @@ -101,6 +143,20 @@ export class NativeAbility extends UIAbility { }; } + /** + * Loads application content inside the framework's WindowStage lifecycle transaction. Override + * this instead of starting detached Promise work from the platform callback. + */ + protected async loadWindowStageContent(windowStage: window.WindowStage): Promise { + if (this.defaultPage) { + try { + await windowStage.loadContentByName(Entry.RouteName); + } catch (error) { + throw new Error(`Unable to load default WindowStage content: ${String(error)}`); + } + } + } + private updateAppStorage(key: string, value: T): void { AppStorage.setOrCreate(key, value); AppStorage.set(key, value); @@ -135,14 +191,24 @@ export class NativeAbility extends UIAbility { if (!sessionId) { return; } + const deliveries: Promise[] = []; for (const runtime of this.moduleRuntimes) { - try { - await BridgeHostRegistry.emitLifecycle(sessionId, runtime.moduleName, event); - } catch (error) { - console.error( - `[NativeAbility] bridge lifecycle '${event.kind}' failed for ${runtime.moduleName}: ${String(error)}`, - ); - } + deliveries.push(this.notifyModuleBridgeLifecycle(sessionId, runtime.moduleName, event)); + } + await Promise.all(deliveries); + } + + private async notifyModuleBridgeLifecycle( + sessionId: string, + moduleName: string, + event: BridgeLifecycleEvent, + ): Promise { + try { + await BridgeHostRegistry.emitLifecycle(sessionId, moduleName, event); + } catch (error) { + console.error( + `[NativeAbility] bridge lifecycle '${event.kind}' failed for ${moduleName}: ${String(error)}`, + ); } } @@ -150,18 +216,22 @@ export class NativeAbility extends UIAbility { requestedModules: string[], restoredStateMap: Record, fallbackState: string, + generation: number, ): Promise { const previousSessionId = this.bridgeSessionId; if (previousSessionId) { BridgeHostRegistry.beginClosing(previousSessionId); await BridgeHostRegistry.dispose(previousSessionId); } + this.assertInitializationActive(generation); this.bridgeSessionId = ""; this.moduleRuntimes = []; this.windowStageActive = false; this.updateAppStorage("bridgeSessionId", ""); - const sessionId = await BridgeHostRegistry.open( + // Prepare hosts without activating `ability` plugins. Their onInstall hooks are allowed to + // call Rust synchronously, so module.init and both sinks must exist first. + const sessionId = BridgeHostRegistry.prepare( requestedModules, this.context as common.UIAbilityContext, this.bridgePlugins, @@ -176,6 +246,7 @@ export class NativeAbility extends UIAbility { // a module name point at the wrong lifecycle object after one dynamic import failed. for (const moduleName of requestedModules) { const module = await NativeModuleLoader.load(moduleName, this.loadMode); + this.assertInitializationActive(generation); loadedModules.push({ moduleName, module }); } @@ -200,14 +271,19 @@ export class NativeAbility extends UIAbility { } this.moduleRuntimes = initializedRuntimes; + const abilityActivations: Promise[] = []; for (const runtime of initializedRuntimes) { - await BridgeHostRegistry.emitLifecycle(sessionId, runtime.moduleName, { - kind: "ability-create", - payload: new BridgeAbilityCreateLifecyclePayload( - restoredStateMap[runtime.moduleName] ?? fallbackState, - ), - }); + abilityActivations.push( + BridgeHostRegistry.activateAbility(sessionId, runtime.moduleName, { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload( + restoredStateMap[runtime.moduleName] ?? fallbackState, + ), + }), + ); } + await Promise.all(abilityActivations); + this.assertInitializationActive(generation); } catch (error) { for (const runtime of initializedRuntimes.slice().reverse()) { try { @@ -225,19 +301,31 @@ export class NativeAbility extends UIAbility { } } + private assertInitializationActive(generation: number): void { + if (!this.acceptingLifecycle || generation !== this.abilityGeneration) { + throw new Error("Native Ability session initialization was cancelled by lifecycle teardown"); + } + } + onCreate(want: Want, _launchParam: AbilityConstant.LaunchParam): void { const isRestore: boolean = (want.parameters?.["ohos.ability.params.abilityRecoveryRestart"] as boolean) ?? false; const savedState = want.parameters?.[STATE_KEY]; const state = isRestore && savedState !== undefined ? savedState.toString() : ""; const requestedModules = this.resolveModuleNames(); - const restoredStateMap = this.parseSavedStateMap(state); + // A single module owns its state string verbatim, even if business data happens to begin with + // the framework's multi-module prefix. Only multi-module sessions interpret the state map. + let restoredStateMap: Record = {}; + if (requestedModules.length > 1) { + restoredStateMap = this.parseSavedStateMap(state); + } this.acceptingLifecycle = true; + const generation = ++this.abilityGeneration; this.updateAppStorage("moduleName", this.moduleName); this.updateAppStorage("loadMode", this.loadMode); void this.enqueueLifecycleOperation("ability-create", async (): Promise => { - await this.initializeSession(requestedModules, restoredStateMap, state); + await this.initializeSession(requestedModules, restoredStateMap, state, generation); }); } @@ -245,7 +333,9 @@ export class NativeAbility extends UIAbility { if (!this.acceptingLifecycle) { return; } + const generation = ++this.windowStageGeneration; void this.enqueueLifecycleOperation("window-stage-create", async (): Promise => { + this.assertWindowStageActive(generation); const sessionId = this.bridgeSessionId; if (!sessionId) { throw new Error("Bridge session is unavailable during window-stage-create"); @@ -253,9 +343,14 @@ export class NativeAbility extends UIAbility { // First inject the ArkTS WindowStage object. Rust readiness is then advanced before ArkTS // plugin installation/lifecycle delivery can emit a synchronous event back into Rust. + const stageAssignments: Promise[] = []; for (const runtime of this.moduleRuntimes) { - await BridgeHostRegistry.setWindowStage(sessionId, runtime.moduleName, windowStage); + stageAssignments.push( + BridgeHostRegistry.setWindowStage(sessionId, runtime.moduleName, windowStage), + ); } + await Promise.all(stageAssignments); + this.assertWindowStageActive(generation); this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onWindowStageCreate(), ); @@ -264,22 +359,11 @@ export class NativeAbility extends UIAbility { kind: "window-stage-create", payload: new BridgeEmptyLifecyclePayload(), }); + this.assertWindowStageActive(generation); try { - windowStage.on("windowStageEvent", (event: window.WindowStageEventType) => { - if (!this.acceptingLifecycle) { - return; - } - void this.enqueueLifecycleOperation("window-stage-event", async (): Promise => { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onWindowStageEvent(event), - ); - await this.notifyBridgeLifecycle({ - kind: "window-stage-event", - payload: new BridgeWindowStageEventLifecyclePayload(event), - }); - }); - }); + this.observedWindowStage = windowStage; + windowStage.on("windowStageEvent", this.onWindowStageEvent); } catch {} let win: window.Window | null = null; @@ -288,42 +372,41 @@ export class NativeAbility extends UIAbility { } catch { win = null; } + this.assertWindowStageActive(generation); if (win) { try { - win.on("windowSizeChange", (size: window.Size) => { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onWindowSizeChange(size), - ); - }); - win.on("windowRectChange", (options: window.RectChangeOptions) => { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onWindowRectChange(options), - ); - }); - win.on("avoidAreaChange", (options: window.AvoidAreaOptions) => { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onAvoidAreaChange(options), - ); - }); - win.on("keyboardHeightChange", (height) => { - this.forEachLifecycle((lifecycle) => - lifecycle.keyboardEventCallback.onKeyboardHeightChange(height), - ); - }); + this.observedMainWindow = win; + win.on("windowSizeChange", this.onWindowSizeChange); + win.on("windowRectChange", this.onWindowRectChange); + win.on("avoidAreaChange", this.onAvoidAreaChange); + win.on("keyboardHeightChange", this.onKeyboardHeightChange); } catch {} } - if (this.defaultPage) { - await windowStage.loadContentByName(Entry.RouteName); - } + await this.loadWindowStageContent(windowStage); + this.assertWindowStageActive(generation); }); } + private assertWindowStageActive(generation: number): void { + if (!this.acceptingLifecycle || generation !== this.windowStageGeneration) { + throw new Error("WindowStage creation was cancelled by lifecycle teardown"); + } + } + onWindowStageDestroy(): void { if (!this.acceptingLifecycle) { return; } + this.windowStageGeneration += 1; + this.detachWindowListeners(); + const sessionId = this.bridgeSessionId; + if (sessionId) { + for (const runtime of this.moduleRuntimes) { + BridgeHostRegistry.invalidateWindowStage(sessionId, runtime.moduleName); + } + } void this.enqueueLifecycleOperation("window-stage-destroy", async (): Promise => { await this.destroyWindowStageIfActive(); }); @@ -334,15 +417,50 @@ export class NativeAbility extends UIAbility { return; } this.windowStageActive = false; + this.detachWindowListeners(); const sessionId = this.bridgeSessionId; if (sessionId) { + const stageClears: Promise[] = []; for (const runtime of this.moduleRuntimes) { - await BridgeHostRegistry.clearWindowStage(sessionId, runtime.moduleName); + stageClears.push(BridgeHostRegistry.clearWindowStage(sessionId, runtime.moduleName)); } + await Promise.all(stageClears); + } + for (const runtime of this.moduleRuntimes) { + try { + runtime.module.disposeAllRenders?.(); + } catch {} } this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onWindowStageDestroy()); } + private detachWindowListeners(): void { + const windowStage = this.observedWindowStage; + this.observedWindowStage = undefined; + if (windowStage !== undefined) { + try { + windowStage.off("windowStageEvent", this.onWindowStageEvent); + } catch {} + } + + const win = this.observedMainWindow; + this.observedMainWindow = undefined; + if (win !== undefined) { + try { + win.off("windowSizeChange", this.onWindowSizeChange); + } catch {} + try { + win.off("windowRectChange", this.onWindowRectChange); + } catch {} + try { + win.off("avoidAreaChange", this.onAvoidAreaChange); + } catch {} + try { + win.off("keyboardHeightChange", this.onKeyboardHeightChange); + } catch {} + } + } + onMemoryLevel(level: AbilityConstant.MemoryLevel): void { if (!this.acceptingLifecycle) { return; @@ -358,6 +476,14 @@ export class NativeAbility extends UIAbility { onDestroy(): Promise { this.acceptingLifecycle = false; + this.abilityGeneration += 1; + this.windowStageGeneration += 1; + this.detachWindowListeners(); + if (this.bridgeSessionId) { + // Reject new calls and cancel an active plugin hook immediately; the queued teardown below + // still preserves lifecycle ordering for state mutation and platform callbacks. + BridgeHostRegistry.beginClosing(this.bridgeSessionId); + } return this.enqueueLifecycleOperation("ability-destroy", async (): Promise => { const sessionId = this.bridgeSessionId; if (!sessionId) { @@ -414,7 +540,7 @@ export class NativeAbility extends UIAbility { if (stateValues.length === 1) { stateToSave = stateValues[0]; } else if (stateValues.length > 1) { - stateToSave = JSON.stringify(stateMap); + stateToSave = this.serializeSavedStateMap(stateMap); } wantParam[STATE_KEY] = new String(stateToSave); diff --git a/native_ability/src/main/ets/ability/type.ets b/native_ability/src/main/ets/ability/type.ets index 11035d46..56788d56 100644 --- a/native_ability/src/main/ets/ability/type.ets +++ b/native_ability/src/main/ets/ability/type.ets @@ -73,7 +73,15 @@ export interface BridgeBindings { export interface Module { init: (context?: AbilityInitContext) => ApplicationLifecycle; - render: (bindings: BridgeBindings, slot: NodeContent) => void; + /** + * Mounts one Rust render tree into `slot`. `renderOwner` is unique per DefaultXComponent + * appearance, so rendering a sub-window never replaces another window's RootNode. + */ + render: (bindings: BridgeBindings, slot: NodeContent, renderOwner: string) => void; + /** Releases only the render tree owned by `renderOwner`. Older native modules may omit it. */ + disposeRender?: (renderOwner: string) => void; + /** Releases every remaining render tree when the WindowStage is torn down. */ + disposeAllRenders?: () => void; /** Synchronous page-back decision exported by the native module, when it has one. */ onBackPressIntercept?: () => boolean; /** Legacy N-API spelling retained for modules built before camel-case code generation. */ @@ -102,6 +110,8 @@ export type BridgeLifecycleKind = | "window-stage-event" | "ui-context-ready" | "ui-context-destroy" + | "window-attached" + | "window-detached" | "configuration-updated" | "memory-level"; @@ -131,15 +141,24 @@ export class BridgeMemoryLevelLifecyclePayload { } } +export class BridgeWindowLifecyclePayload { + readonly windowKey: string; + + constructor(windowKey: string) { + this.windowKey = windowKey; + } +} + export type BridgeLifecyclePayload = | BridgeEmptyLifecyclePayload | BridgeAbilityCreateLifecyclePayload | BridgeWindowStageEventLifecyclePayload - | BridgeMemoryLevelLifecyclePayload; + | BridgeMemoryLevelLifecyclePayload + | BridgeWindowLifecyclePayload; export interface BridgeLifecycleEvent { - kind: BridgeLifecycleKind; - payload: BridgeLifecyclePayload; + readonly kind: BridgeLifecycleKind; + readonly payload: BridgeLifecyclePayload; } /** @@ -225,6 +244,15 @@ export interface BridgeCallContext extends BridgePluginContext { onCancel: (listener: () => void) => () => void; } +/** + * A bounded execution scope for one plugin hook. BridgeHost cancels the scope when the session + * closes or the hook exceeds its watchdog, allowing teardown to continue with the next plugin. + */ +export interface BridgePluginHookContext extends BridgePluginContext { + isCancelled: () => boolean; + onCancel: (listener: () => void) => () => void; +} + interface BridgePluginBase { id: string; version: number; @@ -238,28 +266,31 @@ export interface AsyncBridgePlugin extends BridgePluginBase { * (before onInstall). PluginBase implements it; interface-only plugins may omit it. */ attachContext?: (context: BridgePluginContext) => void; - onInstall?: (context: BridgePluginContext) => void | Promise; - onLifecycle?: (event: BridgeLifecycleEvent, context: BridgePluginContext) => void | Promise; + onInstall?: (context: BridgePluginHookContext) => void | Promise; + onLifecycle?: ( + event: BridgeLifecycleEvent, + context: BridgePluginHookContext, + ) => void | Promise; invokeAsync: ( action: string, request: BridgeTypedValue, context: BridgeCallContext, ) => Promise; - onDispose?: (context: BridgePluginContext) => void | Promise; + onDispose?: (context: BridgePluginHookContext) => void | Promise; } export interface MainThreadSyncBridgePlugin extends BridgePluginBase { execution: "sync-main-thread"; /** See [`AsyncBridgePlugin.attachContext`]. */ attachContext?: (context: BridgePluginContext) => void; - onInstall?: (context: BridgePluginContext) => void; - onLifecycle?: (event: BridgeLifecycleEvent, context: BridgePluginContext) => void; + onInstall?: (context: BridgePluginHookContext) => void; + onLifecycle?: (event: BridgeLifecycleEvent, context: BridgePluginHookContext) => void; invokeSync: ( action: string, request: BridgeTypedValue, context: BridgeCallContext, ) => BridgeTypedValue; - onDispose?: (context: BridgePluginContext) => void; + onDispose?: (context: BridgePluginHookContext) => void; } export type BridgePlugin = AsyncBridgePlugin | MainThreadSyncBridgePlugin; @@ -277,9 +308,12 @@ export abstract class PluginBase { abstract readonly requires: BridgeContextRequirement[]; private context?: BridgePluginContext; - onInstall(_context: BridgePluginContext): void | Promise {} - onLifecycle(_event: BridgeLifecycleEvent, _context: BridgePluginContext): void | Promise {} - onDispose(_context: BridgePluginContext): void | Promise {} + onInstall(_context: BridgePluginHookContext): void | Promise {} + onLifecycle( + _event: BridgeLifecycleEvent, + _context: BridgePluginHookContext, + ): void | Promise {} + onDispose(_context: BridgePluginHookContext): void | Promise {} /** * Called once by `BridgeHost` right after creation, before `onInstall`. Subclasses that @@ -351,26 +385,3 @@ export class LazyPlugin implements BridgePluginFactory { return this.createFn(); } } - -/** - * Eager plugin registration entry: shares one caller-constructed plugin instance across every - * native module and Ability session, and optionally filters by native module. - * - * Use this for plugins that own process-wide global state (for example a singleton native - * wrapper) where per-session instances would be redundant or lossy. The instance receives - * `attachContext` for every module/session it is installed into and must tolerate repeated - * lifecycle and dispose notifications. - */ -export class EagerPlugin implements BridgePluginFactory { - readonly modules?: string[]; - private readonly instance: BridgePlugin; - - constructor(instance: BridgePlugin, modules?: string[]) { - this.instance = instance; - this.modules = modules; - } - - create(): BridgePlugin { - return this.instance; - } -} diff --git a/native_ability/src/main/ets/bridge/BridgeHost.ets b/native_ability/src/main/ets/bridge/BridgeHost.ets index b2de14f1..201ba1da 100644 --- a/native_ability/src/main/ets/bridge/BridgeHost.ets +++ b/native_ability/src/main/ets/bridge/BridgeHost.ets @@ -13,13 +13,18 @@ import { BridgePlugin, BridgePluginContext, BridgePluginFactory, + BridgePluginHookContext, + BridgeWindowLifecyclePayload, BridgeWindowScope, } from "../ability/type"; import { SerialTaskQueue } from "../runtime/SerialTaskQueue"; +import { CancellableTaskScope, TaskCancellationSignal } from "../runtime/CancellableTaskScope"; const MAX_TIMEOUT_MS = 60_000; const MAX_PAYLOAD_BYTES = 512 * 1024; const MAX_LIFECYCLE_HISTORY = 16; +const PLUGIN_HOOK_TIMEOUT_MS = 5_000; +const PLUGIN_HOOK_TIMEOUT_PREFIX = "bridge-plugin-hook-timeout:"; type BridgeMainThreadEventSink = ( pluginId: string, @@ -32,11 +37,11 @@ type BridgeMainThreadEventSink = ( type BridgeLifecycleSink = (kind: string) => void; interface HostedPlugin { - factory: BridgePluginFactory; plugin: BridgePlugin; installed: boolean; installAttempted: boolean; installError?: string; + hookError?: string; installing?: Promise; } @@ -194,6 +199,11 @@ interface BridgeCallState { rejectCancellation?: (reason: Error) => void; } +interface ActivePluginHook { + label: string; + scope: CancellableTaskScope; +} + interface PendingWindowAttachment { state: HostWindowState; cancelled: boolean; @@ -226,10 +236,14 @@ class BridgeHost { // A small list is intentional here: ArkTS compatibility is better than depending on a // collection implementation for a handful of in-flight bridge calls. private readonly activeCalls: BridgeCallState[] = []; + private readonly activeHooks: ActivePluginHook[] = []; private mainThreadEventSink?: BridgeMainThreadEventSink; private lifecycleSink?: BridgeLifecycleSink; private eventSinkToken: number = 0; private windowStage?: window.WindowStage; + private windowStageGeneration: number = 0; + private windowStageClearPromise?: Promise; + private abilityReady: boolean = false; private windowStageReady: boolean = false; private uiContextReady: boolean = false; private closing: boolean = false; @@ -256,7 +270,8 @@ class BridgeHost { this.abilityContext = abilityContext; } - async install(factories: BridgePluginFactory[]): Promise { + /** Registers plugin instances without activating hooks. NativeAbility attaches Rust sinks first. */ + registerFactories(factories: BridgePluginFactory[]): void { this.installNodeSurfacePlugin(); for (const factory of factories) { const modules = factory.modules; @@ -274,13 +289,11 @@ class BridgeHost { } plugin.attachContext?.(this.pluginContext(plugin.id)); this.plugins.set(plugin.id, { - factory, plugin, installed: false, installAttempted: false, }); } - await this.activateReadyPlugins(); } /** @@ -290,7 +303,6 @@ class BridgeHost { private installNodeSurfacePlugin(): void { const plugin = new NodeSurfacePlugin(this); this.plugins.set(plugin.id, { - factory: { create: (): BridgePlugin => plugin }, plugin, installed: false, installAttempted: false, @@ -329,12 +341,16 @@ class BridgeHost { return; } this.assertActive(); - if (windowKey === MAIN_WINDOW_KEY && !this.windowStageReady) { + if (!this.windowStageReady) { throw new Error( - `Bridge main window cannot attach without an active WindowStage for '${this.moduleName}'`, + `Bridge window '${windowKey}' cannot attach without an active WindowStage for '${this.moduleName}'`, ); } this.windows.set(windowKey, pending.state); + await this.deliverLifecycle({ + kind: "window-attached", + payload: new BridgeWindowLifecyclePayload(windowKey), + }); if (windowKey !== MAIN_WINDOW_KEY) { return; } @@ -369,6 +385,10 @@ class BridgeHost { } const state = this.windows.get(windowKey); if (state === undefined) { + const windowStageClearPromise = this.windowStageClearPromise; + if (windowStageClearPromise !== undefined) { + await windowStageClearPromise; + } return; } this.windows.delete(windowKey); @@ -381,6 +401,10 @@ class BridgeHost { } await this.enqueueLifecycle(`detach window '${windowKey}'`, async (): Promise => { + await this.deliverLifecycle({ + kind: "window-detached", + payload: new BridgeWindowLifecyclePayload(windowKey), + }); if (windowKey === MAIN_WINDOW_KEY) { await this.deliverLifecycle({ kind: "ui-context-destroy", @@ -408,7 +432,7 @@ class BridgeHost { const state = this.requireWindow(windowKey); const parent = this.lookupNodeHandle(state, parentHandle, "append-child parent"); const child = this.lookupNodeHandle(state, childHandle, "append-child child"); - parent.appendChild(child); + this.appendFrameNode(parent, child, `append child handle '${childHandle}'`); state.nodeParents.set(childHandle, parent); } @@ -417,10 +441,18 @@ class BridgeHost { const state = this.requireWindow(windowKey); const node = this.lookupNodeHandle(state, handle, "mount-into-root"); const root = this.requireRootFrameNode(state); - root.appendChild(node); + this.appendFrameNode(root, node, `mount handle '${handle}' into '${windowKey}'`); state.nodeParents.set(handle, root); } + private appendFrameNode(parent: FrameNode, child: FrameNode, operation: string): void { + try { + parent.appendChild(child); + } catch (error) { + throw new Error(`Unable to ${operation}: ${String(error)}`); + } + } + /** Disposes a handle-owned node: detaches it from its recorded parent and frees the handle. */ disposeNode(windowKey: string, handle: number): void { const state = this.windows.get(windowKey); @@ -502,7 +534,11 @@ class BridgeHost { } this.removeChild(windowKey, key); const state = this.requireWindow(windowKey); - this.requireRootFrameNode(state).appendChild(node); + this.appendFrameNode( + this.requireRootFrameNode(state), + node, + `mount '${key}' into '${windowKey}'`, + ); state.mountedChildren.set(key, { node, cleanup }); } @@ -556,10 +592,25 @@ class BridgeHost { } } + /** Marks the Ability context ready only after the native module and both Rust sinks exist. */ + async activateAbility(event: BridgeLifecycleEvent): Promise { + this.assertActive(); + await this.enqueueLifecycle("ability-create", async (): Promise => { + this.assertActive(); + this.abilityReady = true; + this.notifyContextChanged(); + await this.deliverLifecycle(event); + }); + } + async setWindowStage(windowStage: window.WindowStage): Promise { this.assertActive(); + const generation = ++this.windowStageGeneration; await this.enqueueLifecycle("window-stage-create", async (): Promise => { this.assertActive(); + if (generation !== this.windowStageGeneration) { + return; + } this.windowStage = windowStage; this.windowStageReady = true; this.notifyContextChanged(); @@ -570,10 +621,13 @@ class BridgeHost { if (this.disposed) { return; } - this.windowStageReady = false; - this.cancelCallsWithMissingContext(); - this.notifyContextChanged(); - await this.enqueueLifecycle("window-stage-destroy", async (): Promise => { + this.invalidateWindowStage(); + const activeClear = this.windowStageClearPromise; + if (activeClear !== undefined) { + await activeClear; + return; + } + const clearPromise = this.enqueueLifecycle("window-stage-destroy", async (): Promise => { this.windowStageReady = false; // The WindowStage belongs to the Ability (main window). Detach every registered window // surface; only the main window emits `ui-context-destroy`. @@ -584,7 +638,13 @@ class BridgeHost { this.cancelCallsWithMissingContext(); this.notifyContextChanged(); } - for (const [windowKey, state] of windowEntries) { + for (const windowEntry of windowEntries) { + const windowKey = windowEntry[0]; + const state = windowEntry[1]; + await this.deliverLifecycle({ + kind: "window-detached", + payload: new BridgeWindowLifecyclePayload(windowKey), + }); if (windowKey === MAIN_WINDOW_KEY) { await this.deliverLifecycle({ kind: "ui-context-destroy", @@ -602,6 +662,25 @@ class BridgeHost { }); this.windowStage = undefined; }); + this.windowStageClearPromise = clearPromise; + try { + await clearPromise; + } finally { + if (this.windowStageClearPromise === clearPromise) { + this.windowStageClearPromise = undefined; + } + } + } + + invalidateWindowStage(): void { + if (this.disposed) { + return; + } + this.windowStageGeneration += 1; + this.windowStageReady = false; + this.windowStage = undefined; + this.cancelCallsWithMissingContext(); + this.notifyContextChanged(); } async emitLifecycle(event: BridgeLifecycleEvent): Promise { @@ -615,21 +694,37 @@ class BridgeHost { private async deliverLifecycle(event: BridgeLifecycleEvent): Promise { if (!this.closing) { - await this.activateReadyPlugins(false); + await this.activateReadyPlugins(); } this.recordLifecycle(event); const entries = Array.from(this.plugins.values()); + const deliveries: Promise[] = []; for (const entry of entries) { - if (!entry.installed || !entry.plugin.onLifecycle) { + if (!entry.installed || entry.hookError !== undefined || !entry.plugin.onLifecycle) { continue; } - try { - await entry.plugin.onLifecycle(event, this.pluginContext(entry.plugin.id)); - } catch (error) { - console.error( - `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed lifecycle '${event.kind}': ${String(error)}`, - ); - } + deliveries.push(this.deliverPluginLifecycle(entry, event)); + } + await Promise.all(deliveries); + } + + private async deliverPluginLifecycle( + entry: HostedPlugin, + event: BridgeLifecycleEvent, + ): Promise { + try { + await this.runPluginHook( + entry.plugin.id, + `lifecycle '${event.kind}'`, + async (context: BridgePluginHookContext): Promise => { + await entry.plugin.onLifecycle!(event, context); + }, + ); + } catch (error) { + this.recordHookFailure(entry, error); + console.error( + `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed lifecycle '${event.kind}': ${String(error)}`, + ); } } @@ -752,6 +847,10 @@ class BridgeHost { "Main-thread event sink is unavailable because the native module is not rendered", ); } + const entry = this.plugins.get(pluginId); + if (entry?.hookError !== undefined) { + throw new Error(entry.hookError); + } return sink(pluginId, event, requestTypeName, responseTypeName, value); } @@ -786,6 +885,14 @@ class BridgeHost { new Error(`Bridge session '${this.sessionId}' is closing while a call is pending`), ); } + const activeHooks = this.activeHooks.slice(); + for (const hook of activeHooks) { + hook.scope.cancel( + new Error( + `Bridge session '${this.sessionId}' is closing during plugin hook '${hook.label}'`, + ), + ); + } this.notifyContextChanged(); } @@ -797,20 +904,9 @@ class BridgeHost { this.beginClosing(); this.disposePromise = this.enqueueLifecycle("dispose", async (): Promise => { const entries = Array.from(this.plugins.values()); - for (const entry of entries) { - try { - if (entry.installing !== undefined) { - try { - await entry.installing; - } catch {} - } - if (entry.installAttempted && entry.plugin.onDispose) { - await entry.plugin.onDispose(this.pluginContext(entry.plugin.id)); - } - } catch { - // Teardown must not prevent another plugin from releasing its resources. - } - } + await Promise.all( + entries.map(async (entry: HostedPlugin): Promise => await this.disposePlugin(entry)), + ); this.plugins.clear(); this.mainThreadEventSink = undefined; this.lifecycleSink = undefined; @@ -824,6 +920,7 @@ class BridgeHost { this.disposeWindowState(state); } this.windows.clear(); + this.abilityReady = false; this.uiContextReady = false; this.windowStage = undefined; this.windowStageReady = false; @@ -833,6 +930,29 @@ class BridgeHost { await this.disposePromise; } + private async disposePlugin(entry: HostedPlugin): Promise { + try { + if (entry.installing !== undefined) { + try { + await entry.installing; + } catch {} + } + if (entry.installAttempted && entry.plugin.onDispose) { + await this.runPluginHook( + entry.plugin.id, + "dispose", + async (context: BridgePluginHookContext): Promise => { + await entry.plugin.onDispose!(context); + }, + ); + } + } catch (error) { + console.error( + `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed dispose: ${String(error)}`, + ); + } + } + private async ensureActive(entry: HostedPlugin, callState?: BridgeCallState): Promise { while (!this.requirementsReady(entry)) { this.assertCallStateActive(callState); @@ -843,22 +963,28 @@ class BridgeHost { this.assertCallStateActive(callState); } - private async activateReadyPlugins(failFast: boolean = true): Promise { + private async activateReadyPlugins(): Promise { const entries = Array.from(this.plugins.values()); + const activations: Promise[] = []; for (const entry of entries) { + if (entry.installError !== undefined || entry.hookError !== undefined) { + continue; + } if (this.requirementsReady(entry)) { - try { - await this.activate(entry); - } catch (error) { - if (failFast) { - throw error; - } - console.error( - `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed to install: ${String(error)}`, - ); - } + activations.push(this.activateReadyPlugin(entry)); } } + await Promise.all(activations); + } + + private async activateReadyPlugin(entry: HostedPlugin): Promise { + try { + await this.activate(entry); + } catch (error) { + console.error( + `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed to install: ${String(error)}`, + ); + } } private async activate(entry: HostedPlugin): Promise { @@ -871,13 +997,22 @@ class BridgeHost { if (entry.installError !== undefined) { throw new Error(entry.installError); } + if (entry.hookError !== undefined) { + throw new Error(entry.hookError); + } if (!entry.installing) { entry.installing = Promise.resolve() .then(async () => { entry.installAttempted = true; try { if (entry.plugin.onInstall) { - await entry.plugin.onInstall(this.pluginContext(entry.plugin.id)); + await this.runPluginHook( + entry.plugin.id, + "install", + async (context: BridgePluginHookContext): Promise => { + await entry.plugin.onInstall!(context); + }, + ); } entry.installed = true; await this.replayLifecycle(entry); @@ -885,7 +1020,7 @@ class BridgeHost { if (!entry.installed) { entry.installError = `Bridge plugin '${entry.plugin.id}' install failed: ${String(error)}`; } - throw error; + throw new Error(String(error)); } }) .finally(() => { @@ -901,11 +1036,21 @@ class BridgeHost { } for (const event of this.lifecycleHistory) { try { - await entry.plugin.onLifecycle(event, this.pluginContext(entry.plugin.id)); + await this.runPluginHook( + entry.plugin.id, + `replay lifecycle '${event.kind}'`, + async (context: BridgePluginHookContext): Promise => { + await entry.plugin.onLifecycle!(event, context); + }, + ); } catch (error) { + this.recordHookFailure(entry, error); console.error( `[BridgeHost:${this.moduleName}] plugin '${entry.plugin.id}' failed replay lifecycle '${event.kind}': ${String(error)}`, ); + if (entry.hookError !== undefined) { + return; + } } } } @@ -916,6 +1061,11 @@ class BridgeHost { private missingRequirements(entry: HostedPlugin): BridgeContextRequirement[] { const missing: BridgeContextRequirement[] = []; + // Every plugin belongs to an Ability session, even when it declares no additional platform + // objects. This also guarantees Rust sinks exist before the first onInstall callback. + if (!this.abilityReady) { + missing.push("ability"); + } for (const requirement of normalizedRequirements(entry.plugin.requires)) { if (requirement === "window-stage" && !this.windowStageReady) { missing.push(requirement); @@ -934,7 +1084,8 @@ class BridgeHost { moduleName: this.moduleName, sessionId: this.sessionId, abilityContext: this.abilityContext, - isActive: (): boolean => !this.closing && !this.disposed, + isActive: (): boolean => + this.abilityReady && !this.closing && !this.disposed && this.pluginOperational(pluginId), getWindowStage: (): window.WindowStage => { if (this.windowStage === undefined) { throw new Error(`Bridge plugin '${pluginId}' requires a WindowStage that is not ready`); @@ -998,13 +1149,113 @@ class BridgeHost { }; } + private pluginHookContext( + pluginId: string, + signal: TaskCancellationSignal, + ): BridgePluginHookContext { + const context = this.pluginContext(pluginId); + return { + pluginId, + moduleName: this.moduleName, + sessionId: this.sessionId, + abilityContext: this.abilityContext, + isActive: (): boolean => context.isActive() && !signal.isCancelled(), + isCancelled: (): boolean => signal.isCancelled() || this.disposed, + onCancel: (listener: () => void): (() => void) => signal.onCancel(listener), + getWindowStage: (): window.WindowStage => context.getWindowStage(), + getUIContext: (): UIContext => context.getUIContext(), + getRootFrameNode: (): FrameNode => context.getRootFrameNode(), + appendChild: (key: string, node: FrameNode, cleanup?: () => void): void => + context.appendChild(key, node, cleanup), + removeChild: (key: string): void => context.removeChild(key), + getFrameNode: (handle: number): FrameNode => context.getFrameNode(handle), + windowScope: (windowKey: string): BridgeWindowScope => context.windowScope(windowKey), + invokeNativeSync: ( + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject => + this.invokeNativeSync(pluginId, event, requestTypeName, responseTypeName, value), + }; + } + + private async runPluginHook( + pluginId: string, + label: string, + operation: (context: BridgePluginHookContext) => Promise, + ): Promise { + const scope = new CancellableTaskScope( + `Bridge plugin '${pluginId}' hook '${label}'`, + PLUGIN_HOOK_TIMEOUT_MS, + ); + const activeHook: ActivePluginHook = { + label, + scope, + }; + this.activeHooks.push(activeHook); + try { + await scope.run(async (signal: TaskCancellationSignal): Promise => { + await operation(this.pluginHookContext(pluginId, signal)); + }); + } catch (error) { + if (scope.didTimeOut()) { + throw new Error(`${PLUGIN_HOOK_TIMEOUT_PREFIX}${String(error)}`); + } + throw new Error(String(error)); + } finally { + const index = this.activeHooks.indexOf(activeHook); + if (index >= 0) { + this.activeHooks.splice(index, 1); + } + } + } + private recordLifecycle(event: BridgeLifecycleEvent): void { + const needsReplay = Array.from(this.plugins.values()).some( + (entry: HostedPlugin): boolean => + !entry.installed && entry.installError === undefined && entry.hookError === undefined, + ); + if (!needsReplay) { + this.lifecycleHistory.splice(0, this.lifecycleHistory.length); + return; + } if (this.lifecycleHistory.length >= MAX_LIFECYCLE_HISTORY) { - this.lifecycleHistory.shift(); + const evictableIndex = this.lifecycleHistory.findIndex( + (recorded: BridgeLifecycleEvent): boolean => + recorded.kind === "configuration-updated" || + recorded.kind === "memory-level" || + recorded.kind === "window-stage-event" || + recorded.kind === "window-detached", + ); + if (evictableIndex >= 0) { + this.lifecycleHistory.splice(evictableIndex, 1); + } else { + // Preserve the session's ability-create anchor when possible, but never let a long-lived + // sequence of structural events turn the replay buffer into unbounded process state. + this.lifecycleHistory.splice(this.lifecycleHistory.length > 1 ? 1 : 0, 1); + } } this.lifecycleHistory.push(event); } + private recordHookFailure(entry: HostedPlugin, error: ESObject): void { + if (String(error).indexOf(PLUGIN_HOOK_TIMEOUT_PREFIX) < 0 || entry.hookError !== undefined) { + return; + } + entry.hookError = `Bridge plugin '${entry.plugin.id}' was disabled after a lifecycle hook timeout: ${String(error)}`; + for (const callState of this.activeCalls.slice()) { + if (callState.pluginId === entry.plugin.id) { + this.cancelCall(callState, new Error(entry.hookError)); + } + } + } + + private pluginOperational(pluginId: string): boolean { + const entry = this.plugins.get(pluginId); + return entry !== undefined && entry.hookError === undefined; + } + private notifyContextChanged(): void { const waiters = this.contextWaiters.splice(0, this.contextWaiters.length); for (const resolve of waiters) { @@ -1032,7 +1283,7 @@ class BridgeHost { private pluginRequirementsReady(pluginId: string): boolean { const entry = this.plugins.get(pluginId); - return entry !== undefined && this.requirementsReady(entry); + return entry !== undefined && entry.hookError === undefined && this.requirementsReady(entry); } private cancelCallsWithMissingContext(): void { @@ -1144,6 +1395,9 @@ class BridgeHost { if (!entry) { throw new Error(`Bridge plugin '${pluginId}' is not installed for '${this.moduleName}'`); } + if (entry.hookError !== undefined) { + throw new Error(entry.hookError); + } if (entry.plugin.version !== pluginVersion) { throw new Error( `Bridge plugin '${pluginId}' version mismatch: Rust=${pluginVersion}, ArkTS=${entry.plugin.version}`, @@ -1259,29 +1513,34 @@ export class BridgeHostRegistry { private static readonly sessions: Map> = new Map(); private static nextSession: number = 1; - static async open( + static prepare( moduleNames: string[], abilityContext: common.UIAbilityContext, factories: BridgePluginFactory[], - ): Promise { + ): string { const sessionId = `bridge-${Date.now()}-${BridgeHostRegistry.nextSession++}`; const hosts: Map = new Map(); try { for (const moduleName of moduleNames) { const host = new BridgeHost(sessionId, moduleName, abilityContext); hosts.set(moduleName, host); - // Register before installation so a partially-installed plugin is disposed if an - // ability-context onInstall hook rejects. - await host.install(factories); + host.registerFactories(factories); } } catch (error) { - await BridgeHostRegistry.disposeHosts(hosts); throw new Error(String(error)); } BridgeHostRegistry.sessions.set(sessionId, hosts); return sessionId; } + static async activateAbility( + sessionId: string, + moduleName: string, + event: BridgeLifecycleEvent, + ): Promise { + await BridgeHostRegistry.host(sessionId, moduleName).activateAbility(event); + } + static async invokeAsync( sessionId: string, moduleName: string, @@ -1336,6 +1595,10 @@ export class BridgeHostRegistry { await BridgeHostRegistry.host(sessionId, moduleName).clearWindowStage(); } + static invalidateWindowStage(sessionId: string, moduleName: string): void { + BridgeHostRegistry.sessions.get(sessionId)?.get(moduleName)?.invalidateWindowStage(); + } + /** * Registers one window's node surface (`windowKey` defaults to `"main"`). Attaching the main * window emits `ui-context-ready`, so plugins can mount nodes during `onInstall` without any @@ -1417,9 +1680,7 @@ export class BridgeHostRegistry { private static async disposeHosts(hosts: Map): Promise { const values = Array.from(hosts.values()); - for (const host of values) { - await host.dispose(); - } + await Promise.all(values.map(async (host: BridgeHost): Promise => await host.dispose())); hosts.clear(); } } diff --git a/native_ability/src/main/ets/components/DefaultXComponent.ets b/native_ability/src/main/ets/components/DefaultXComponent.ets index 5eb1bae5..faf37088 100644 --- a/native_ability/src/main/ets/components/DefaultXComponent.ets +++ b/native_ability/src/main/ets/components/DefaultXComponent.ets @@ -1,12 +1,22 @@ import { NodeContent } from "@kit.ArkUI"; import { FrameNode, NodeController } from "@ohos.arkui.node"; import { UIContext } from "@ohos.arkui.UIContext"; -import { BridgeBindings } from "../ability/type"; +import { BridgeBindings, Module } from "../ability/type"; import { BridgeHostRegistry, MAIN_WINDOW_KEY } from "../bridge/BridgeHost"; import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; export const RouteName = "NativeAbility"; +let nextRenderOwnerId: number = 1; + +interface ComponentAttachment { + sessionId: string; + moduleName: string; + windowKey: string; + renderOwner: string; + nativeModule: Module; +} + /** * Mounts only the Rust XComponent and generic bridge bindings. Platform capabilities deliberately * do not live here: a capability that needs ArkUI content (for example WebView) owns a plugin @@ -25,65 +35,84 @@ export struct DefaultXComponent { private nodeController = new BridgeRootNodeController(this.getUIContext()); @StorageProp("bridgeSessionId") bridgeSessionId: string = ""; @StorageProp("loadMode") loadMode: "async" | "sync" = "async"; + private appearanceGeneration: number = 0; + private attachment?: ComponentAttachment; + private releasePromise: Promise = Promise.resolve(); - private bindings: BridgeBindings = { - bridgeInvoke: async ( - pluginId: string, - pluginVersion: number, - action: string, - requestTypeName: string, - responseTypeName: string, - value: ESObject, - timeoutMs: number, - ): Promise => { - return await BridgeHostRegistry.invokeAsync( - this.bridgeSessionId, - this.moduleName.trim(), - pluginId, - pluginVersion, - action, - requestTypeName, - responseTypeName, - value, - timeoutMs, - ); - }, - bridgeInvokeSync: ( - pluginId: string, - pluginVersion: number, - action: string, - requestTypeName: string, - responseTypeName: string, - value: ESObject, - ): ESObject => { - return BridgeHostRegistry.invokeSync( - this.bridgeSessionId, - this.moduleName.trim(), - pluginId, - pluginVersion, - action, - requestTypeName, - responseTypeName, - value, - ); - }, - // The Rust MainThreadScheduler executes its closure inside this TSFN callback before this - // no-op function is invoked. It carries no capability-specific behavior. - bridgeDispatch: (): void => {}, - }; + private createBindings(sessionId: string, moduleName: string): BridgeBindings { + return { + bridgeInvoke: async ( + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + timeoutMs: number, + ): Promise => { + return await BridgeHostRegistry.invokeAsync( + sessionId, + moduleName, + pluginId, + pluginVersion, + action, + requestTypeName, + responseTypeName, + value, + timeoutMs, + ); + }, + bridgeInvokeSync: ( + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject => { + return BridgeHostRegistry.invokeSync( + sessionId, + moduleName, + pluginId, + pluginVersion, + action, + requestTypeName, + responseTypeName, + value, + ); + }, + // The Rust MainThreadScheduler executes its closure inside this TSFN callback before this + // no-op function is invoked. It carries no capability-specific behavior. + bridgeDispatch: (): void => {}, + }; + } async aboutToAppear(): Promise { + const generation = ++this.appearanceGeneration; + await this.releasePromise; + if (generation !== this.appearanceGeneration) { + return; + } const moduleName = this.moduleName.trim(); if (!moduleName) { throw new Error("DefaultXComponent.moduleName is required"); } const nativeModule = await NativeModuleLoader.load(moduleName, this.loadMode); + if (generation !== this.appearanceGeneration) { + return; + } + const sessionId = this.bridgeSessionId; + if (!sessionId) { + throw new Error( + "DefaultXComponent cannot render before NativeAbility finishes creating its bridge session", + ); + } // Idempotent fallback: NativeAbility.onCreate already attached the sink right after // module.init (same cached Module instance), so `ability`-only plugins can emit inbound // events on `ability-create`. Re-attaching here covers modules loaded without onCreate. BridgeHostRegistry.attachEventSink( - this.bridgeSessionId, + sessionId, moduleName, ( pluginId: string, @@ -107,35 +136,77 @@ export struct DefaultXComponent { nativeModule.onBridgeLifecycle?.(kind); }, ); - nativeModule.render(this.bindings, this.rootSlot); + const renderOwner = `${sessionId}:${moduleName}:${this.windowKey}:${nextRenderOwnerId++}`; + nativeModule.render(this.createBindings(sessionId, moduleName), this.rootSlot, renderOwner); + + if (generation !== this.appearanceGeneration) { + nativeModule.disposeRender?.(renderOwner); + return; + } // Create the window root eagerly (NodeContainer asks the controller lazily) so it exists // before `ui-context-ready`. Every plugin then mounts into this one tree without slots, // registries or readiness waiters. Attaching the `"main"` window emits `ui-context-ready`; // sub-window instances (unique `windowKey`) only register their own surface. const root = this.nodeController.makeNode(this.getUIContext()); - await BridgeHostRegistry.attachWindow( - this.bridgeSessionId, + const attachment: ComponentAttachment = { + sessionId, moduleName, - this.windowKey, - this.getUIContext(), - root, - ); + windowKey: this.windowKey, + renderOwner, + nativeModule, + }; + this.attachment = attachment; + try { + await BridgeHostRegistry.attachWindow( + sessionId, + moduleName, + attachment.windowKey, + this.getUIContext(), + root, + ); + if (generation !== this.appearanceGeneration) { + await this.releasePromise; + } + } catch (error) { + if (generation !== this.appearanceGeneration) { + await this.releasePromise; + return; + } + const release = this.releaseAttachment(attachment); + this.releasePromise = release; + await release; + throw new Error(String(error)); + } } aboutToDisappear(): void { - const moduleName = this.moduleName.trim(); - if (this.bridgeSessionId && moduleName) { - const sessionId = this.bridgeSessionId; - const windowKey = this.windowKey; - BridgeHostRegistry.detachWindow(sessionId, moduleName, windowKey) - .catch(() => {}) - .finally(() => { - this.nodeController.disposeRoot(); - }); - return; + this.appearanceGeneration += 1; + this.releasePromise = this.releaseAttachment(this.attachment); + } + + private releaseAttachment(expected?: ComponentAttachment): Promise { + if (expected !== undefined && this.attachment !== expected) { + return this.releasePromise; + } + const attachment = expected ?? this.attachment; + this.attachment = undefined; + if (attachment === undefined) { + this.nodeController.disposeRoot(); + return Promise.resolve(); } - this.nodeController.disposeRoot(); + // RootNode owns the ContentSlot handle and must be dropped synchronously while this component + // still owns a live NodeContent. Host/plugin cleanup may continue asynchronously afterwards. + attachment.nativeModule.disposeRender?.(attachment.renderOwner); + return BridgeHostRegistry.detachWindow( + attachment.sessionId, + attachment.moduleName, + attachment.windowKey, + ) + .catch(() => {}) + .finally(() => { + this.nodeController.disposeRoot(); + }); } build() { diff --git a/native_ability/src/main/ets/runtime/AbilityStateCodec.ets b/native_ability/src/main/ets/runtime/AbilityStateCodec.ets new file mode 100644 index 00000000..77ee587a --- /dev/null +++ b/native_ability/src/main/ets/runtime/AbilityStateCodec.ets @@ -0,0 +1,41 @@ +const STATE_MAP_PREFIX = "ohos.rs.ability.state-map.v1:"; + +/** Deterministic, non-bridge encoding for per-module Ability recovery state. */ +export class AbilityStateCodec { + static decode(rawState: string): Record { + if (!rawState.startsWith(STATE_MAP_PREFIX)) { + return {}; + } + + try { + const mapped: Record = {}; + const encodedEntries = rawState.slice(STATE_MAP_PREFIX.length); + if (!encodedEntries) { + return mapped; + } + for (const entry of encodedEntries.split("&")) { + const separator = entry.indexOf("="); + if (separator <= 0) { + return {}; + } + const moduleName = decodeURIComponent(entry.slice(0, separator)); + const state = decodeURIComponent(entry.slice(separator + 1)); + if (!moduleName) { + return {}; + } + mapped[moduleName] = state; + } + return mapped; + } catch { + return {}; + } + } + + static encode(stateMap: Record): string { + const entries: string[] = []; + for (const moduleName of Object.keys(stateMap).sort()) { + entries.push(`${encodeURIComponent(moduleName)}=${encodeURIComponent(stateMap[moduleName])}`); + } + return STATE_MAP_PREFIX + entries.join("&"); + } +} diff --git a/native_ability/src/main/ets/runtime/CancellableTaskScope.ets b/native_ability/src/main/ets/runtime/CancellableTaskScope.ets new file mode 100644 index 00000000..88dcb1ff --- /dev/null +++ b/native_ability/src/main/ets/runtime/CancellableTaskScope.ets @@ -0,0 +1,93 @@ +export interface TaskCancellationSignal { + isCancelled: () => boolean; + onCancel: (listener: () => void) => () => void; +} + +/** One-shot bounded task scope used to keep teardown independent from a stuck plugin Promise. */ +export class CancellableTaskScope { + private cancelled: boolean = false; + private completed: boolean = false; + private running: boolean = false; + private timedOut: boolean = false; + private cancellationReason?: Error; + private rejectCancellation?: (reason: Error) => void; + private readonly cancelListeners: Array<() => void> = []; + private readonly label: string; + private readonly timeoutMs: number; + + constructor(label: string, timeoutMs: number) { + this.label = label; + this.timeoutMs = Math.max(1, Math.floor(timeoutMs)); + } + + async run(operation: (signal: TaskCancellationSignal) => Promise): Promise { + if (this.running) { + throw new Error(`Cancellable task '${this.label}' can only run once`); + } + this.running = true; + const cancellation = new Promise((_resolve, reject) => { + this.rejectCancellation = reject; + if (this.cancelled) { + reject( + this.cancellationReason ?? new Error(`Cancellable task '${this.label}' was cancelled`), + ); + } + }); + const timer = setTimeout(() => { + this.timedOut = true; + this.cancel( + new Error(`Cancellable task '${this.label}' timed out after ${this.timeoutMs}ms`), + ); + }, this.timeoutMs); + try { + const task = Promise.resolve().then(async (): Promise => { + await operation({ + isCancelled: (): boolean => this.cancelled, + onCancel: (listener: () => void): (() => void) => this.subscribe(listener), + }); + }); + await Promise.race([task, cancellation]); + } finally { + clearTimeout(timer); + this.rejectCancellation = undefined; + this.completed = true; + } + } + + cancel(reason: Error): void { + if (this.cancelled || this.completed) { + return; + } + this.cancelled = true; + this.cancellationReason = reason; + const rejectCancellation = this.rejectCancellation; + this.rejectCancellation = undefined; + rejectCancellation?.(reason); + const listeners = this.cancelListeners.splice(0, this.cancelListeners.length); + for (const listener of listeners) { + try { + listener(); + } catch { + // Cancellation is best-effort; every listener still gets a chance to release its wait. + } + } + } + + didTimeOut(): boolean { + return this.timedOut; + } + + private subscribe(listener: () => void): () => void { + if (this.cancelled || this.completed) { + listener(); + return (): void => {}; + } + this.cancelListeners.push(listener); + return (): void => { + const index = this.cancelListeners.indexOf(listener); + if (index >= 0) { + this.cancelListeners.splice(index, 1); + } + }; + } +} diff --git a/native_ability/src/test/LocalUnit.test.ets b/native_ability/src/test/LocalUnit.test.ets index bf80bc84..a213768f 100644 --- a/native_ability/src/test/LocalUnit.test.ets +++ b/native_ability/src/test/LocalUnit.test.ets @@ -1,5 +1,74 @@ import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from "@ohos/hypium"; import { SerialTaskQueue } from "../main/ets/runtime/SerialTaskQueue"; +import { AbilityStateCodec } from "../main/ets/runtime/AbilityStateCodec"; +import { CancellableTaskScope } from "../main/ets/runtime/CancellableTaskScope"; +import common from "@ohos.app.ability.common"; +import window from "@ohos.window"; +import { + AsyncPluginBase, + BridgeAbilityCreateLifecyclePayload, + BridgeCallContext, + BridgeContextRequirement, + BridgeEmptyLifecyclePayload, + BridgePluginHookContext, + BridgeTypedValue, + LazyPlugin, +} from "../main/ets/ability/type"; +import { BridgeHostRegistry } from "../main/ets/bridge/BridgeHost"; + +let installProbeCount: number = 0; +let installProbeSinkCount: number = 0; +let stageInstallProbeCount: number = 0; + +class InstallProbeRequest { + readonly accepted: boolean = true; +} + +class InstallProbeResponse { + readonly accepted: boolean = true; +} + +class InstallProbePlugin extends AsyncPluginBase { + readonly id = "test.install-probe"; + readonly version = 1; + readonly requires: BridgeContextRequirement[] = ["ability"]; + + override onInstall(context: BridgePluginHookContext): void { + installProbeCount += 1; + context.invokeNativeSync( + "install-ready", + "test.InstallRequest", + "test.InstallResponse", + new InstallProbeRequest(), + ); + } + + async invokeAsync( + _action: string, + request: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + return request; + } +} + +class StageInstallProbePlugin extends AsyncPluginBase { + readonly id = "test.stage-install-probe"; + readonly version = 1; + readonly requires: BridgeContextRequirement[] = ["window-stage"]; + + override onInstall(): void { + stageInstallProbeCount += 1; + } + + async invokeAsync( + _action: string, + request: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + return request; + } +} export default function localUnitTest() { describe("localUnitTest", () => { @@ -71,5 +140,99 @@ export default function localUnitTest() { expect(errors.join(",")).assertEqual("broken"); expect(events.join(",")).assertEqual("destroy"); }); + it("abilityStateCodecRoundTripsDeterministically", 0, () => { + const stateMap: Record = {}; + stateMap.beta = "state&with=delimiters"; + stateMap.alpha = "中文/space value"; + const encoded = AbilityStateCodec.encode(stateMap); + const decoded = AbilityStateCodec.decode(encoded); + + expect(encoded.indexOf("alpha=") < encoded.indexOf("beta=")).assertTrue(); + expect(decoded.alpha).assertEqual("中文/space value"); + expect(decoded.beta).assertEqual("state&with=delimiters"); + expect(Object.keys(AbilityStateCodec.decode("legacy-state")).length).assertEqual(0); + }); + it("cancellableTaskScopeCancelsAStuckHook", 0, async () => { + const scope = new CancellableTaskScope("test hook", 1_000); + let cancellationObserved = false; + const task = scope.run( + async (signal): Promise => + await new Promise((resolve) => { + signal.onCancel((): void => { + cancellationObserved = true; + resolve(); + }); + }), + ); + await Promise.resolve(); + scope.cancel(new Error("session closing")); + + let rejected = false; + try { + await task; + } catch { + rejected = true; + } + expect(cancellationObserved).assertTrue(); + expect(rejected).assertTrue(); + }); + it("cancellableTaskScopeTimesOutAStuckHook", 0, async () => { + const scope = new CancellableTaskScope("timeout hook", 5); + let errorText = ""; + try { + await scope.run(async (): Promise => await new Promise(() => {})); + } catch (error) { + errorText = String(error); + } + expect(errorText.indexOf("timed out") >= 0).assertTrue(); + }); + it("abilityPluginActivatesOnlyAfterRustSinkIsAttached", 0, async () => { + installProbeCount = 0; + installProbeSinkCount = 0; + const sessionId = BridgeHostRegistry.prepare(["test_native"], {} as common.UIAbilityContext, [ + new LazyPlugin(() => new InstallProbePlugin()), + ]); + expect(installProbeCount).assertEqual(0); + + BridgeHostRegistry.attachEventSink(sessionId, "test_native", (): InstallProbeResponse => { + installProbeSinkCount += 1; + return new InstallProbeResponse(); + }); + await BridgeHostRegistry.activateAbility(sessionId, "test_native", { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload(""), + }); + + expect(installProbeCount).assertEqual(1); + expect(installProbeSinkCount).assertEqual(1); + BridgeHostRegistry.beginClosing(sessionId); + await BridgeHostRegistry.dispose(sessionId); + }); + it("invalidatedWindowStageCannotBecomeReadyFromAQueuedCreate", 0, async () => { + stageInstallProbeCount = 0; + const sessionId = BridgeHostRegistry.prepare(["test_native"], {} as common.UIAbilityContext, [ + new LazyPlugin(() => new StageInstallProbePlugin()), + ]); + await BridgeHostRegistry.activateAbility(sessionId, "test_native", { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload(""), + }); + + const pendingCreate = BridgeHostRegistry.setWindowStage( + sessionId, + "test_native", + {} as window.WindowStage, + ); + BridgeHostRegistry.invalidateWindowStage(sessionId, "test_native"); + await pendingCreate; + await BridgeHostRegistry.emitLifecycle(sessionId, "test_native", { + kind: "configuration-updated", + payload: new BridgeEmptyLifecyclePayload(), + }); + + expect(stageInstallProbeCount).assertEqual(0); + BridgeHostRegistry.beginClosing(sessionId); + await BridgeHostRegistry.dispose(sessionId); + }); }); } diff --git a/plugins/files/src/main/ets/FilesPlugin.ets b/plugins/files/src/main/ets/FilesPlugin.ets index ddff6dd1..89b8063d 100644 --- a/plugins/files/src/main/ets/FilesPlugin.ets +++ b/plugins/files/src/main/ets/FilesPlugin.ets @@ -25,6 +25,10 @@ interface FileDialogOptions { filters: FileDialogFilter[]; } +interface DocumentViewPickerWithSelectedIndex { + getSelectedIndex(): number; +} + class FileDialogResponse { readonly files: string[]; readonly filter: number; @@ -108,7 +112,10 @@ async function showFileDialog( const files = await documentPicker.save(saveOptions); let filter = -1; if (deviceInfo.sdkApiVersion >= 14 && supportsFolderSelection()) { - filter = documentPicker.getSelectedIndex(); + // Keep the package compatible with API 12 while using the API 14 method only after the + // runtime capability check. The narrow adapter prevents the static checker from rejecting + // an otherwise guarded call solely because compatibleSdkVersion is lower than 14. + filter = (documentPicker as DocumentViewPickerWithSelectedIndex).getSelectedIndex(); } return new FileDialogResponse(files, filter); } diff --git a/plugins/resource/CHANGELOG.md b/plugins/resource/CHANGELOG.md index f9225dfc..11fa80e1 100644 --- a/plugins/resource/CHANGELOG.md +++ b/plugins/resource/CHANGELOG.md @@ -1,3 +1,10 @@ +# Unreleased + +- **Breaking**: replace the shared `EagerPlugin` wrapper with a session-scoped `LazyPlugin` + instance; Rust continues to own the process-wide native manager pointer. + +--- + # 1.0.0-beta.0 - Initial release: inbound-only `ohos.resource` plugin wrapping the native `ResourceManager`. - ArkTS wrapper pushes the resource manager on `ability-create` through a scoped native event; no outbound actions. diff --git a/plugins/resource/README.md b/plugins/resource/README.md index a99f3856..f38b0347 100644 --- a/plugins/resource/README.md +++ b/plugins/resource/README.md @@ -12,7 +12,7 @@ ohpm install @ohos-rs/ability-plugin-resource ## 职责 - 持有 `abilityContext.resourceManager` 平台对象; -- 在 `ui-context-ready` 时经 `context.invokeNativeSync("resource-manager-ready", ...)` 把对象 +- 在 `ability-create` 时经 `context.invokeNativeSync("resource-manager-ready", ...)` 把对象 推送给 Rust facade; - 不执行任何资源读取逻辑 —— 所有读取由 Rust 侧通过 `ohos-resource-manager-binding` 直连 OpenHarmony C API 完成。 @@ -20,18 +20,18 @@ ohpm install @ohos-rs/ability-plugin-resource ## 接入 ```ts -import { EagerPlugin } from "@ohos-rs/ability"; +import { LazyPlugin } from "@ohos-rs/ability"; import { ResourcePlugin } from "@ohos-rs/ability-plugin-resource"; // in NativeAbility subclass: public bridgePlugins = [ - new EagerPlugin(new ResourcePlugin()), + new LazyPlugin(() => new ResourcePlugin()), ]; ``` -使用 `EagerPlugin`(共享单例实例)而非 `LazyPlugin`:native resource manager 是进程级全局状态, -每个 session 创建独立 wrapper 实例是冗余的。wrapper 被重复 `attachContext` 时只是重复推送同一 -对象,天然幂等。 +ArkTS wrapper 必须是 module/session 级实例,避免 `attachContext` 覆盖另一个 session 的 hook +上下文。native resource manager 的进程级 pointer 仍由 Rust/C API 层持有,不需要共享 ArkTS +plugin instance。 ## 契约 diff --git a/plugins/resource/src/main/ets/ResourcePlugin.ets b/plugins/resource/src/main/ets/ResourcePlugin.ets index d32d8c63..d0891149 100644 --- a/plugins/resource/src/main/ets/ResourcePlugin.ets +++ b/plugins/resource/src/main/ets/ResourcePlugin.ets @@ -3,7 +3,7 @@ import { BridgeCallContext, BridgeContextRequirement, BridgeLifecycleEvent, - BridgePluginContext, + BridgePluginHookContext, BridgeTypedValue, } from "@ohos-rs/ability"; @@ -20,15 +20,15 @@ const RESOURCE_MANAGER_READY_RESPONSE_TYPE = "ohos.resource.ResourceManagerReady * object to a native `NativeResourceManager` pointer inside the same N-API callback and * performs every subsequent read through the OpenHarmony C API — no ArkTS call is involved. * - * The plugin is registered as a shared instance (`EagerPlugin`) because the native resource - * manager is a process-wide singleton; per-session instances would be redundant. + * The ArkTS wrapper remains session-scoped. Rust owns the process-wide native manager pointer; + * sharing one mutable ArkTS plugin instance across sessions would overwrite its hook context. */ export class ResourcePlugin extends AsyncPluginBase { readonly id = "ohos.resource"; readonly version = 1; readonly requires: BridgeContextRequirement[] = ["ability"]; - onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginContext): void { + onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginHookContext): void { if (event.kind !== "ability-create") { return; } diff --git a/plugins/webview/src/main/ets/WebviewPlugin.ets b/plugins/webview/src/main/ets/WebviewPlugin.ets index a492e72c..ec97121c 100644 --- a/plugins/webview/src/main/ets/WebviewPlugin.ets +++ b/plugins/webview/src/main/ets/WebviewPlugin.ets @@ -5,9 +5,11 @@ import { BridgeCallContext, BridgeTypedValue, BridgePluginContext, + BridgePluginHookContext, AsyncPluginBase, BridgeContextRequirement, BridgeLifecycleEvent, + BridgeWindowLifecyclePayload, } from "@ohos-rs/ability"; const CREATE_REQUEST_TYPE = "ohos.webview.CreateRequest"; @@ -27,11 +29,7 @@ const ENGINE_LIFECYCLE_EVENT_TYPE = "ohos.webview.EngineLifecycleEvent"; const CONTROLLER_EVENT_TYPE = "ohos.webview.ControllerEvent"; const EVENT_ACKNOWLEDGEMENT_TYPE = "ohos.webview.EventAcknowledgement"; -/** - * `setWebDebuggingAccess` is only present on newer ArkWeb SDKs. The plugin keeps its API 12 - * baseline by resolving this optional static API at runtime instead of exposing an API-20-only - * symbol through the HAR type surface. - */ +/** Optional static ArkWeb API resolved dynamically to preserve the API-12 package baseline. */ interface WebviewDebuggingApi { setWebDebuggingAccess?: (enabled: boolean) => void; } @@ -972,6 +970,16 @@ class WebviewSurface { } } + disposeWindow(windowKey: string, detachNodes: boolean = true): void { + for (const entryPair of Array.from(this.entries.entries())) { + const id = entryPair[0]; + const entry = entryPair[1]; + if (entry.data.windowKey === windowKey) { + this.remove(id, entry, detachNodes); + } + } + } + private assertActive(): void { if (this.disposed) { throw new Error("WebView surface is already disposed"); @@ -1002,19 +1010,17 @@ export class WebviewPlugin extends AsyncPluginBase { readonly requires: BridgeContextRequirement[] = ["ui-context"]; private surface?: WebviewSurface; - override onInstall(context: BridgePluginContext): void { + override onInstall(context: BridgePluginHookContext): void { ensureWebEngineInitialized(context); this.surface = new WebviewSurface(context); } - override onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginContext): void { - if (event.kind === "ui-context-destroy") { - const surface = this.surface; - this.surface = undefined; - // BridgeHost already detached the old window from its lookup table and owns the old node - // tree cleanup. Release controllers/waiters without accidentally addressing a newly - // attached main-window root. - surface?.dispose(false); + override onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginHookContext): void { + if (event.kind === "window-detached") { + const payload = event.payload as BridgeWindowLifecyclePayload; + // BridgeHost has already removed this window from lookup and owns its node-tree cleanup. + // Release only controllers belonging to that window without addressing a replacement root. + this.surface?.disposeWindow(payload.windowKey, false); return; } if (event.kind === "ui-context-ready" && this.surface === undefined) { From d0a0cc6aefe6413b9abdc3ae1095fc53b2e6ada8 Mon Sep 17 00:00:00 2001 From: richerfu Date: Tue, 11 Aug 2026 14:50:22 +0800 Subject: [PATCH 3/5] refactor: bind each xcomponent to one native module --- Cargo.lock | 15 + crates/ability/src/app.rs | 291 ++++++++- crates/ability/src/bridge/mod.rs | 117 +++- crates/ability/src/input/ime.rs | 18 +- crates/ability/src/lifecycle.rs | 3 - crates/ability/src/node.rs | 83 +-- crates/ability/src/render/xcomponent.rs | 111 ++-- crates/derive/src/lib.rs | 82 ++- crates/plugin-resource/src/lib.rs | 98 +-- crates/plugin-webview/src/callbacks.rs | 59 +- crates/plugin-webview/src/controller.rs | 108 ++++ crates/plugin-webview/src/js_proxy.rs | 69 ++- crates/plugin-webview/src/lib.rs | 247 ++++++-- crates/plugin-webview/src/protocol.rs | 264 ++++++-- crates/plugin-window/src/lib.rs | 19 +- demo/entry/oh-package-lock.json5 | 7 + demo/entry/oh-package.json5 | 1 + .../main/cpp/types/libdemo_native/Index.d.ts | 224 +++++-- .../cpp/types/libdemo_sub_native/Index.d.ts | 306 +++++++++ .../types/libdemo_sub_native/oh-package.json5 | 6 + .../src/main/ets/bridge/DemoNodePlugin.ets | 2 +- .../main/ets/entryability/EntryAbility.ets | 31 +- demo/entry/src/main/ets/pages/Index.ets | 13 +- .../src/main/ets/pages/SubWindowPage.ets | 27 +- native_ability/index.ets | 2 - .../src/main/ets/ability/NativeAbility.ets | 176 +++--- native_ability/src/main/ets/ability/type.ets | 94 +-- .../src/main/ets/bridge/BridgeHost.ets | 580 ++++++++++++------ .../main/ets/components/DefaultXComponent.ets | 138 +---- .../main/ets/runtime/NativeModuleLoader.ets | 32 +- native_ability/src/test/LocalUnit.test.ets | 288 +++++++++ .../resource/src/main/ets/ResourcePlugin.ets | 14 +- .../webview/src/main/ets/WebviewPlugin.ets | 257 +++++--- plugins/window/src/main/ets/WindowPlugin.ets | 7 +- rust_example/demo_native/Cargo.toml | 2 + rust_example/demo_native/src/lib.rs | 36 +- rust_example/demo_sub_native/Cargo.toml | 19 + rust_example/demo_sub_native/build.rs | 3 + rust_example/demo_sub_native/src/lib.rs | 50 ++ 39 files changed, 2916 insertions(+), 983 deletions(-) create mode 100644 crates/plugin-webview/src/controller.rs create mode 100644 demo/entry/src/main/cpp/types/libdemo_sub_native/Index.d.ts create mode 100644 demo/entry/src/main/cpp/types/libdemo_sub_native/oh-package.json5 create mode 100644 rust_example/demo_sub_native/Cargo.toml create mode 100644 rust_example/demo_sub_native/build.rs create mode 100644 rust_example/demo_sub_native/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 6f871f35..b4115dca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,11 +57,26 @@ dependencies = [ "ohos-hilog-binding", "openharmony-ability", "openharmony-ability-derive", + "openharmony-ability-plugin-app-control", "openharmony-ability-plugin-files", "openharmony-ability-plugin-permission", "openharmony-ability-plugin-resource", "openharmony-ability-plugin-url", "openharmony-ability-plugin-webview", + "openharmony-ability-plugin-window", +] + +[[package]] +name = "demo_sub_native" +version = "0.1.0" +dependencies = [ + "napi-build-ohos", + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", + "openharmony-ability-derive", + "openharmony-ability-plugin-webview", + "openharmony-ability-plugin-window", ] [[package]] diff --git a/crates/ability/src/app.rs b/crates/ability/src/app.rs index 7f9c1e47..f1a2cd3a 100644 --- a/crates/ability/src/app.rs +++ b/crates/ability/src/app.rs @@ -53,6 +53,9 @@ impl AbilityInitContext { pub struct OpenHarmonyAppInner { pub(crate) raw_window: Option, pub(crate) xcomponent: Option, + /// Owner token of this native module's one active DefaultXComponent render. + render_owner: Option, + surface_active: bool, state: Vec, save_state: bool, @@ -110,6 +113,8 @@ impl OpenHarmonyAppInner { OpenHarmonyAppInner { raw_window: None, xcomponent: None, + render_owner: None, + surface_active: false, state: vec![], save_state: false, id, @@ -153,6 +158,64 @@ impl OpenHarmonyAppInner { } } + fn claim_render_owner(&mut self, owner: &str) -> Result<()> { + if self.render_owner.is_some() { + return Err(Error::from_reason( + "This native module already has an active DefaultXComponent render owner", + )); + } + self.render_owner = Some(owner.to_owned()); + self.surface_active = false; + Ok(()) + } + + fn owns_render(&self, owner: &str) -> bool { + self.render_owner.as_deref() == Some(owner) + } + + fn activate_surface(&mut self, owner: &str, raw_window: Option, rect: Rect) -> bool { + if !self.owns_render(owner) || self.surface_active { + return false; + } + self.raw_window = raw_window; + self.rect = rect; + self.surface_active = true; + true + } + + fn update_surface_rect(&mut self, owner: &str, rect: Rect) -> bool { + if !self.owns_render(owner) || !self.surface_active { + return false; + } + self.rect = rect; + true + } + + fn deactivate_surface(&mut self, owner: &str) -> bool { + if !self.owns_render(owner) || !self.surface_active { + return false; + } + self.raw_window = None; + self.rect = Rect::default(); + self.surface_active = false; + true + } + + fn release_render_owner(&mut self, owner: &str) -> Option { + if !self.owns_render(owner) { + return None; + } + let surface_was_active = self.surface_active; + self.render_owner = None; + self.surface_active = false; + self.raw_window = None; + self.xcomponent = None; + self.rect = Rect::default(); + self.window_rect = Rect::default(); + self.avoid_areas.clear(); + Some(surface_was_active) + } + pub fn content_rect(&self) -> Rect { self.rect } @@ -189,14 +252,21 @@ impl OpenHarmonyAppInner { type EventLoop = Arc>>>; type BackPressInterceptor = Arc bool + Sync + Send>>>>; +/// Transport endpoints owned by one NativeAbility/module session. This lifetime is deliberately +/// independent from the module's optional DefaultXComponent render surface. +struct ActiveBridgeSession { + owner: String, + runtime: BridgeRuntime, + main_thread_endpoint: MainThreadBridgeEndpoint, +} + #[derive(Clone)] pub struct OpenHarmonyApp { pub(crate) inner: Arc>, pub(crate) event_loop: EventLoop, pub(crate) back_press_interceptor: BackPressInterceptor, pub(crate) ime: Arc>>, - bridge_runtime: Arc>>, - bridge_main_thread: Arc>>, + bridge_session: Arc>>, bridge_plugins: Arc, is_keyboard_show: Arc>, } @@ -248,8 +318,7 @@ impl OpenHarmonyApp { back_press_interceptor: Arc::new(RefCell::new(None)), #[allow(clippy::arc_with_non_send_sync)] ime: Arc::new(RefCell::new(None)), - bridge_runtime: Arc::new(RwLock::new(None)), - bridge_main_thread: Arc::new(RwLock::new(None)), + bridge_session: Arc::new(RwLock::new(None)), bridge_plugins: Arc::new(BridgePluginRegistry::default()), is_keyboard_show: Arc::new(Mutex::new(false)), } @@ -295,19 +364,103 @@ impl OpenHarmonyApp { self.init_context().preferred_locales } + pub(crate) fn begin_render(&self, owner: &str, xcomponent: XComponent) -> Result<()> { + let bridge_active = self + .bridge_session + .read() + .map_err(|_| Error::from_reason("Failed to read native module bridge session"))? + .is_some(); + if !bridge_active { + return Err(Error::from_reason( + "A DefaultXComponent cannot render outside an active NativeAbility module session", + )); + } + let mut inner = self + .inner + .write() + .map_err(|_| Error::from_reason("Failed to claim native render owner"))?; + inner.claim_render_owner(owner)?; + inner.xcomponent = Some(xcomponent); + Ok(()) + } + + pub(crate) fn activate_render_surface( + &self, + owner: &str, + raw_window: Option, + rect: Rect, + ) -> bool { + self.inner + .write() + .map(|mut inner| inner.activate_surface(owner, raw_window, rect)) + .unwrap_or(false) + } + + pub(crate) fn update_render_surface_rect(&self, owner: &str, rect: Rect) -> bool { + self.inner + .write() + .map(|mut inner| inner.update_surface_rect(owner, rect)) + .unwrap_or(false) + } + + pub(crate) fn is_render_surface_active(&self, owner: &str) -> bool { + self.inner + .read() + .map(|inner| inner.owns_render(owner) && inner.surface_active) + .unwrap_or(false) + } + + pub(crate) fn deactivate_render_surface(&self, owner: &str) -> bool { + let deactivated = self + .inner + .write() + .map(|mut inner| inner.deactivate_surface(owner)) + .unwrap_or(false); + if deactivated { + self.ime.borrow_mut().take(); + } + deactivated + } + + /// Releases one generated `#[ability]` render. A stale owner is ignored, so delayed cleanup + /// from an old DefaultXComponent cannot clear a replacement component's native state. + #[doc(hidden)] + pub fn release_render(&self, owner: &str) { + let surface_was_active = self + .inner + .write() + .ok() + .and_then(|mut inner| inner.release_render_owner(owner)); + let Some(surface_was_active) = surface_was_active else { + return; + }; + self.ime.borrow_mut().take(); + if surface_was_active { + self.dispatch_surface_destroy(); + } + } + + pub(crate) fn dispatch_surface_destroy(&self) { + if let Some(ref mut handler) = *self.event_loop.borrow_mut() { + handler(Event::SurfaceDestroy); + } + } + /// Returns the generic ArkTS bridge for this native module. /// - /// The runtime is initialized when the module is rendered. Calls can be made from a worker - /// thread; they are always marshalled back to ArkTS through a ThreadsafeFunction. + /// The runtime is initialized with the NativeAbility/module session, before any + /// DefaultXComponent is required. Calls can be made from a worker thread; they are always + /// marshalled back to ArkTS through a ThreadsafeFunction. Individual plugins still enforce + /// their declared Ability, WindowStage, or UIContext readiness. pub fn bridge(&self) -> Result { - self.bridge_runtime + self.bridge_session .read() .map_err(|_| Error::from_reason("Failed to read bridge runtime"))? .as_ref() - .cloned() + .map(|session| session.runtime.clone()) .ok_or_else(|| { Error::from_reason( - "Bridge runtime is not ready. Call it after the NativeAbility XComponent is rendered.", + "Bridge runtime is not ready. Call it during an active NativeAbility session.", ) }) } @@ -331,14 +484,17 @@ impl OpenHarmonyApp { operation: impl FnOnce(BridgeMainThread<'_>) -> Result, ) -> Result { let bridge = self - .bridge_main_thread + .bridge_session .read() .map_err(|_| Error::from_reason("Failed to read main-thread bridge"))?; - let endpoint = bridge.as_ref().ok_or_else(|| { - Error::from_reason( - "Synchronous bridge is not ready. Call it after the NativeAbility XComponent is rendered.", + let endpoint = bridge + .as_ref() + .map(|session| &session.main_thread_endpoint) + .ok_or_else(|| { + Error::from_reason( + "Synchronous bridge is not ready. Call it during an active NativeAbility session.", ) - })?; + })?; operation(BridgeMainThread::new(env, endpoint)) } @@ -353,6 +509,14 @@ impl OpenHarmonyApp { self.bridge_plugins.register(plugin) } + /// Returns the concrete Rust plugin instance registered for this native module. + pub fn registered_plugin

(&self) -> Result>> + where + P: BridgePlugin, + { + self.bridge_plugins.registered::

() + } + #[doc(hidden)] pub fn dispatch_bridge_main_thread_event<'env>( &self, @@ -366,25 +530,46 @@ impl OpenHarmonyApp { self.bridge_plugins.dispatch_lifecycle(event) } - pub(crate) fn set_bridge_bindings( + pub(crate) fn begin_bridge_session( &self, + owner: &str, runtime: BridgeRuntime, main_thread_endpoint: MainThreadBridgeEndpoint, - ) { - if let Ok(mut guard) = self.bridge_runtime.write() { - guard.replace(runtime); + ) -> Result<()> { + if owner.is_empty() { + return Err(Error::from_reason("Bridge session owner must not be empty")); } - if let Ok(mut guard) = self.bridge_main_thread.write() { - guard.replace(main_thread_endpoint); + let mut session = self + .bridge_session + .write() + .map_err(|_| Error::from_reason("Failed to claim bridge session"))?; + if session.is_some() { + return Err(Error::from_reason( + "This native module already belongs to an active NativeAbility bridge session", + )); } + *session = Some(ActiveBridgeSession { + owner: owner.to_owned(), + runtime, + main_thread_endpoint, + }); + Ok(()) } - pub(crate) fn clear_bridge_bindings(&self) { - if let Ok(mut guard) = self.bridge_runtime.write() { - guard.take(); - } - if let Ok(mut guard) = self.bridge_main_thread.write() { - guard.take(); + /// Releases only the matching Ability/module transport. A delayed stale teardown cannot + /// clear endpoints installed for a later session. + #[doc(hidden)] + pub fn release_bridge_session(&self, owner: &str) { + let released = self.bridge_session.write().ok().and_then(|mut session| { + if session.as_ref().map(|active| active.owner.as_str()) != Some(owner) { + return None; + } + session.take() + }); + if released.is_some() { + if let Ok(mut inner) = self.inner.write() { + inner.set_init_context(AbilityInitContext::default()); + } } } @@ -508,3 +693,57 @@ impl<'a> SaveLoader<'a> { self.app.load() } } + +#[cfg(test)] +mod tests { + use super::OpenHarmonyAppInner; + use crate::{AvoidArea, AvoidAreaType, Rect}; + + #[test] + fn render_owner_rejects_overlap_and_ignores_stale_surface_callbacks() { + let mut inner = OpenHarmonyAppInner::new(); + inner.claim_render_owner("owner-a").unwrap(); + assert!(inner.claim_render_owner("owner-b").is_err()); + assert!(!inner.activate_surface("owner-b", None, Rect::default())); + assert!(inner.activate_surface("owner-a", None, Rect::default())); + assert_eq!(inner.release_render_owner("owner-b"), None); + assert_eq!(inner.release_render_owner("owner-a"), Some(true)); + + inner.claim_render_owner("owner-b").unwrap(); + assert!(inner.activate_surface("owner-b", None, Rect::default())); + assert!(!inner.deactivate_surface("owner-a")); + assert_eq!(inner.release_render_owner("owner-a"), None); + assert!(inner.owns_render("owner-b")); + assert!(inner.surface_active); + } + + #[test] + fn surface_recreation_keeps_the_same_render_owner() { + let mut inner = OpenHarmonyAppInner::new(); + inner.claim_render_owner("owner").unwrap(); + assert!(inner.activate_surface("owner", None, Rect::default())); + assert!(inner.deactivate_surface("owner")); + assert!(inner.owns_render("owner")); + assert!(inner.activate_surface("owner", None, Rect::default())); + assert_eq!(inner.release_render_owner("owner"), Some(true)); + } + + #[test] + fn releasing_a_component_clears_its_window_scoped_cache() { + let mut inner = OpenHarmonyAppInner::new(); + inner.claim_render_owner("owner").unwrap(); + inner.window_rect = Rect { + top: 1, + left: 2, + width: 3, + height: 4, + }; + inner + .avoid_areas + .insert(AvoidAreaType::Keyboard, AvoidArea::default()); + + assert_eq!(inner.release_render_owner("owner"), Some(false)); + assert_eq!(inner.window_rect, Rect::default()); + assert!(inner.avoid_areas.is_empty()); + } +} diff --git a/crates/ability/src/bridge/mod.rs b/crates/ability/src/bridge/mod.rs index dd5d356e..2bbd8471 100644 --- a/crates/ability/src/bridge/mod.rs +++ b/crates/ability/src/bridge/mod.rs @@ -3,7 +3,7 @@ //! `BridgeRuntime` is the worker-safe half of the bridge: it owns only N-API //! `ThreadsafeFunction`s and can therefore turn an ArkTS `Promise` into a Rust future. //! `BridgeMainThread` is deliberately a separate, non-cloneable capability. It is constructed -//! only from an N-API `Env` on the render thread and is the sole route for synchronous plugins. +//! only from an N-API `Env` on the Ability main thread and is the sole route for synchronous plugins. //! This split makes it impossible to accidentally invoke a synchronous ArkTS plugin from a Rust //! worker through the public typed API. @@ -305,6 +305,18 @@ pub trait BridgePlugin: Send + Sync + 'static { const VERSION: u32 = 1; const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = &[]; + /// Context gate for one ArkTS -> Rust main-thread event. + /// + /// Most events use the plugin-wide requirement. A plugin may narrow this only for an event + /// that provably does not touch the later platform object (for example process-global engine + /// registration performed after Ability creation but before any UI component exists). + fn required_contexts_for_main_thread_event( + &self, + _event_name: &str, + ) -> &'static [BridgeContextRequirement] { + Self::REQUIRED_CONTEXTS + } + /// Handles a direct event emitted from ArkTS while its N-API environment is active. /// /// This hook is not an outbound plugin call: it is the only Rust callback path permitted to @@ -331,6 +343,10 @@ pub trait BridgePlugin: Send + Sync + 'static { } trait RegisteredBridgePlugin: Send + Sync { + fn required_contexts_for_main_thread_event( + &self, + event_name: &str, + ) -> &'static [BridgeContextRequirement]; fn on_main_thread_event<'env>( &self, event: BridgeMainThreadEvent<'env>, @@ -342,6 +358,13 @@ impl

RegisteredBridgePlugin for P where P: BridgePlugin, { + fn required_contexts_for_main_thread_event( + &self, + event_name: &str, + ) -> &'static [BridgeContextRequirement] { + BridgePlugin::required_contexts_for_main_thread_event(self, event_name) + } + fn on_main_thread_event<'env>( &self, event: BridgeMainThreadEvent<'env>, @@ -390,6 +413,7 @@ impl BridgeContextReadiness { struct RegisteredPluginEntry { plugin: Arc, + typed: Arc, required_contexts: &'static [BridgeContextRequirement], /// Once a plugin becomes ready in one Ability session it keeps receiving that session's /// teardown events even after its required context has already disappeared. @@ -420,7 +444,9 @@ impl BridgePluginRegistry { P: BridgePlugin, { validate_plugin_contract::

()?; - let plugin: Arc = Arc::new(plugin); + let plugin = Arc::new(plugin); + let registered: Arc = plugin.clone(); + let typed: Arc = plugin.clone(); let replay = { let mut state = self .state @@ -441,7 +467,8 @@ impl BridgePluginRegistry { state.plugins.insert( P::ID.to_owned(), RegisteredPluginEntry { - plugin: Arc::clone(&plugin), + plugin: Arc::clone(®istered), + typed, required_contexts: P::REQUIRED_CONTEXTS, activated, }, @@ -450,11 +477,38 @@ impl BridgePluginRegistry { }; for event in replay { - plugin.on_lifecycle(&event)?; + registered.on_lifecycle(&event)?; } Ok(()) } + /// Returns the concrete registered plugin while the registry retains module ownership. + pub fn registered

(&self) -> Result>> + where + P: BridgePlugin, + { + validate_plugin_contract::

()?; + let typed = { + let state = self + .state + .read() + .map_err(|_| Error::from_reason("Failed to read bridge plugin registry"))?; + state + .plugins + .get(P::ID) + .map(|entry| Arc::clone(&entry.typed)) + }; + let Some(typed) = typed else { + return Ok(None); + }; + Arc::downcast::

(typed).map(Some).map_err(|_| { + Error::from_reason(format!( + "Bridge plugin '{}' is registered with a different Rust implementation type", + P::ID + )) + }) + } + /// Delivers an ArkTS-originated direct event to its Rust plugin without allowing its N-API /// value to leave the current main-thread callback. pub fn dispatch_main_thread_event<'env>( @@ -478,7 +532,10 @@ impl BridgePluginRegistry { event.plugin_id() ))); } - if !state.readiness.supports(entry.required_contexts) { + let event_requirements = entry + .plugin + .required_contexts_for_main_thread_event(event.name()); + if !state.readiness.supports(event_requirements) { return Err(Error::from_reason(format!( "Bridge plugin '{}' received a main-thread event before its required context was ready", event.plugin_id() @@ -1074,7 +1131,7 @@ impl<'env> BridgeMainThread<'env> { } } -/// Per-module worker-safe runtime. A re-render replaces it on the N-API main thread. +/// Per-module worker-safe runtime owned by one NativeAbility bridge session. #[derive(Clone)] pub struct BridgeRuntime { client: BridgeClient, @@ -1086,6 +1143,19 @@ pub(crate) struct BridgeBindings { pub(crate) main_thread_endpoint: MainThreadBridgeEndpoint, } +/// Builds and installs one module's Ability-session transport before any component render is +/// required. Kept public only for code generated by `#[ability]` in downstream crates. +#[doc(hidden)] +pub fn attach_bridge_session( + env: &Env, + bindings: napi_ohos::bindgen_prelude::ObjectRef, + owner: &str, + app: &crate::OpenHarmonyApp, +) -> Result<()> { + let bindings = BridgeRuntime::from_bindings(env, &bindings)?; + app.begin_bridge_session(owner, bindings.runtime, bindings.main_thread_endpoint) +} + impl BridgeRuntime { pub(crate) fn from_bindings( env: &Env, @@ -1283,6 +1353,24 @@ mod tests { const ID: &'static str = "test.plugin"; } + struct StatefulPlugin { + value: AtomicUsize, + } + + impl BridgePlugin for StatefulPlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "test.stateful"; + } + + struct WrongStatefulPluginType; + + impl BridgePlugin for WrongStatefulPluginType { + type Mode = AsyncBridge; + + const ID: &'static str = "test.stateful"; + } + static UI_CONTEXT_LIFECYCLES: AtomicUsize = AtomicUsize::new(0); struct UiContextPlugin; @@ -1394,6 +1482,23 @@ mod tests { assert!(registry.register(TestPlugin).is_err()); } + #[test] + fn registry_returns_the_same_typed_plugin_instance() { + let registry = BridgePluginRegistry::default(); + registry + .register(StatefulPlugin { + value: AtomicUsize::new(7), + }) + .unwrap(); + + let first = registry.registered::().unwrap().unwrap(); + let second = registry.registered::().unwrap().unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + first.value.store(9, Ordering::SeqCst); + assert_eq!(second.value.load(Ordering::SeqCst), 9); + assert!(registry.registered::().is_err()); + } + #[test] fn registry_replays_lifecycle_only_after_required_context_is_ready() { UI_CONTEXT_LIFECYCLES.store(0, Ordering::SeqCst); diff --git a/crates/ability/src/input/ime.rs b/crates/ability/src/input/ime.rs index b0ea99c1..41f3cfbf 100644 --- a/crates/ability/src/input/ime.rs +++ b/crates/ability/src/input/ime.rs @@ -14,11 +14,15 @@ type ImeCallback = ( ThreadsafeFunction, ); -pub fn ime_ts_fn(env: &Env, app: OpenHarmonyApp) -> Result { +pub fn ime_ts_fn(env: &Env, app: OpenHarmonyApp, render_owner: String) -> Result { // insert event let on_insert_text_app = app.clone(); + let on_insert_text_owner = render_owner.clone(); let insert_text_callback: Function = env.create_function_from_closure("ime_insert_callback", move |ctx| { + if !on_insert_text_app.is_render_surface_active(&on_insert_text_owner) { + return Ok(()); + } let s = ctx.first_arg::().unwrap(); if let Some(ref mut h) = *on_insert_text_app.event_loop.borrow_mut() { h(Event::Input(InputEvent::ImeEvent( @@ -35,8 +39,12 @@ pub fn ime_ts_fn(env: &Env, app: OpenHarmonyApp) -> Result { // keyboard status event let on_ime_hide_app = app.clone(); + let on_ime_hide_owner = render_owner.clone(); let on_ime_hide_callback: Function = env.create_function_from_closure("ime_hide_callback", move |ctx| { + if !on_ime_hide_app.is_render_surface_active(&on_ime_hide_owner) { + return Ok(()); + } let value = ctx.first_arg::().unwrap(); let status = KeyboardStatus::from(value); @@ -58,8 +66,12 @@ pub fn ime_ts_fn(env: &Env, app: OpenHarmonyApp) -> Result { .build()?; let on_backspace_app = app.clone(); + let on_backspace_owner = render_owner.clone(); let on_backspace_callback: Function = env.create_function_from_closure("on_backspace_callback", move |ctx| { + if !on_backspace_app.is_render_surface_active(&on_backspace_owner) { + return Ok(()); + } let value = ctx.first_arg::().unwrap(); if let Some(ref mut h) = *on_backspace_app.event_loop.borrow_mut() { h(Event::Input(InputEvent::ImeEvent( @@ -75,8 +87,12 @@ pub fn ime_ts_fn(env: &Env, app: OpenHarmonyApp) -> Result { .build()?; let on_ime_enter_app = app.clone(); + let on_ime_enter_owner = render_owner; let on_ime_enter_callback: Function = env.create_function_from_closure("on_ime_enter_callback", move |ctx| { + if !on_ime_enter_app.is_render_surface_active(&on_ime_enter_owner) { + return Ok(()); + } let value = ctx.first_arg::().unwrap(); if let Some(ref mut h) = *on_ime_enter_app.event_loop.borrow_mut() { h(Event::Input(InputEvent::ImeEvent(ImeEvent::EnterEvent( diff --git a/crates/ability/src/lifecycle.rs b/crates/ability/src/lifecycle.rs index d56f8dea..a37efd1c 100644 --- a/crates/ability/src/lifecycle.rs +++ b/crates/ability/src/lifecycle.rs @@ -262,9 +262,6 @@ pub fn create_lifecycle_handle<'a>( if let Some(ref mut h) = *on_ability_destroy_app.event_loop.borrow_mut() { h(Event::Destroy) } - // The native module is process-wide, but bridge endpoints are Ability-session scoped. - // Drop TSFN/FunctionRef bindings before a recreated Ability can observe stale hosts. - on_ability_destroy_app.clear_bridge_bindings(); Ok(()) })?; diff --git a/crates/ability/src/node.rs b/crates/ability/src/node.rs index 9b4a896b..f48fcb94 100644 --- a/crates/ability/src/node.rs +++ b/crates/ability/src/node.rs @@ -1,9 +1,9 @@ //! Built-in `ohos.node` surface plugin. //! //! This is the normalized replacement for the former WebView-mode dichotomy and the old slot -//! registry: every session has exactly one root `FrameNode` tree, and plugins (WebView included) -//! are just FrameNode providers. Rust composes that tree through opaque handles — `FrameNode` -//! values themselves never cross the N-API boundary. +//! registry: every native module is bound to exactly one `DefaultXComponent` root tree at a time, +//! and plugins (WebView included) are FrameNode providers for that tree. An Ability hosts multiple +//! components by using multiple native modules. `FrameNode` values never cross N-API. //! //! The ArkTS half lives in `BridgeHost` (`native_ability`) and is installed automatically ahead //! of business plugins, so no registration or factory is required on either side. @@ -19,16 +19,10 @@ use crate::{ /// Plugin identity shared with the ArkTS built-in surface plugin. pub const NODE_SURFACE_PLUGIN_ID: &str = "ohos.node"; -/// Window surface key the default window registers under. -pub const MAIN_WINDOW_KEY: &str = "main"; - -/// Window-scoped request marker for `create-container`: the response carries the new handle. +/// Request marker for `create-container`: the response carries the new handle. #[napi(object)] #[derive(Clone, Debug, Default)] -pub struct NodeCreateContainerRequest { - /// Window surface key; defaults to `"main"`. - pub window_key: Option, -} +pub struct NodeCreateContainerRequest {} impl_bridge_napi_type!( NodeCreateContainerRequest, @@ -41,19 +35,15 @@ impl_bridge_napi_type!( pub struct NodeAppendChildRequest { pub parent_handle: u32, pub child_handle: u32, - /// Window surface key; defaults to `"main"`. Parent and child must share the same window. - pub window_key: Option, } impl_bridge_napi_type!(NodeAppendChildRequest, "ohos.node.AppendChildRequest"); -/// Appends a handle-owned node to the window root. +/// Appends a handle-owned node to this module's component root. #[napi(object)] #[derive(Clone, Debug)] pub struct NodeMountIntoRootRequest { pub handle: u32, - /// Window surface key; defaults to `"main"`. - pub window_key: Option, } impl_bridge_napi_type!(NodeMountIntoRootRequest, "ohos.node.MountIntoRootRequest"); @@ -63,8 +53,6 @@ impl_bridge_napi_type!(NodeMountIntoRootRequest, "ohos.node.MountIntoRootRequest #[derive(Clone, Debug)] pub struct NodeDisposeRequest { pub handle: u32, - /// Window surface key; defaults to `"main"`. - pub window_key: Option, } impl_bridge_napi_type!(NodeDisposeRequest, "ohos.node.DisposeRequest"); @@ -107,12 +95,12 @@ impl BridgePlugin for NodeSurfaceBridgePlugin { type Mode = AsyncBridge; const ID: &'static str = NODE_SURFACE_PLUGIN_ID; - const VERSION: u32 = 1; + const VERSION: u32 = 2; const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = &[BridgeContextRequirement::UiContext]; } -/// Outbound facade for composing the session FrameNode tree from Rust. +/// Outbound facade for composing this native module's component FrameNode tree from Rust. #[derive(Clone)] pub struct NodeSurface { bridge: BridgeRuntime, @@ -123,46 +111,27 @@ impl NodeSurface { Self { bridge } } - /// Creates an empty container `FrameNode` in the main window and returns its opaque handle. + /// Creates an empty container `FrameNode` and returns its opaque handle. pub async fn create_container(&self) -> Result { - self.create_container_in_window(None).await - } - - /// Creates an empty container `FrameNode` in `window_key` and returns its opaque handle. - pub async fn create_container_in_window(&self, window_key: Option<&str>) -> Result { let response = self .bridge .call_async::( "create-container", - NodeCreateContainerRequest { - window_key: window_key.map(str::to_owned), - }, + NodeCreateContainerRequest {}, BridgeCallOptions::default(), ) .await?; Ok(response.handle) } - /// Appends the node of `child_handle` under the node of `parent_handle` (main window). + /// Appends the node of `child_handle` under the node of `parent_handle`. pub async fn append_child(&self, parent_handle: u32, child_handle: u32) -> Result<()> { - self.append_child_in_window(None, parent_handle, child_handle) - .await - } - - /// Appends the node of `child_handle` under the node of `parent_handle` in `window_key`. - pub async fn append_child_in_window( - &self, - window_key: Option<&str>, - parent_handle: u32, - child_handle: u32, - ) -> Result<()> { self.bridge .call_async::( "append-child", NodeAppendChildRequest { parent_handle, child_handle, - window_key: window_key.map(str::to_owned), }, BridgeCallOptions::default(), ) @@ -170,44 +139,24 @@ impl NodeSurface { .ensure() } - /// Appends a handle-owned node to the main window root. + /// Appends a handle-owned node to this module's component root. pub async fn mount_into_root(&self, handle: u32) -> Result<()> { - self.mount_into_root_in_window(None, handle).await - } - - /// Appends a handle-owned node to the `window_key` root. - pub async fn mount_into_root_in_window( - &self, - window_key: Option<&str>, - handle: u32, - ) -> Result<()> { self.bridge .call_async::( "mount-into-root", - NodeMountIntoRootRequest { - handle, - window_key: window_key.map(str::to_owned), - }, + NodeMountIntoRootRequest { handle }, BridgeCallOptions::default(), ) .await? .ensure() } - /// Detaches a handle-owned node from its parent and disposes it (main window). + /// Detaches a handle-owned node from its parent and disposes it. pub async fn dispose(&self, handle: u32) -> Result<()> { - self.dispose_in_window(None, handle).await - } - - /// Detaches a handle-owned node in `window_key` from its parent and disposes it. - pub async fn dispose_in_window(&self, window_key: Option<&str>, handle: u32) -> Result<()> { self.bridge .call_async::( "dispose", - NodeDisposeRequest { - handle, - window_key: window_key.map(str::to_owned), - }, + NodeDisposeRequest { handle }, BridgeCallOptions::default(), ) .await? @@ -262,7 +211,7 @@ mod tests { #[test] fn plugin_identity_is_the_builtin_contract() { assert_eq!(NodeSurfaceBridgePlugin::ID, "ohos.node"); - assert_eq!(NodeSurfaceBridgePlugin::VERSION, 1); + assert_eq!(NodeSurfaceBridgePlugin::VERSION, 2); assert_eq!( NodeSurfaceBridgePlugin::REQUIRED_CONTEXTS, &[BridgeContextRequirement::UiContext] diff --git a/crates/ability/src/render/xcomponent.rs b/crates/ability/src/render/xcomponent.rs index 2fab4216..373de9ee 100644 --- a/crates/ability/src/render/xcomponent.rs +++ b/crates/ability/src/render/xcomponent.rs @@ -1,27 +1,18 @@ use napi_ohos::threadsafe_function::ThreadsafeFunctionCallMode::NonBlocking; -use napi_ohos::{bindgen_prelude::ObjectRef, Env, Error, Result}; +use napi_ohos::{Env, Error, Result}; use ohos_arkui_binding::component::attribute::ArkUICommonAttribute; use ohos_arkui_binding::{ArkUIHandle, RootNode, XComponent}; use ohos_ime_binding::IME; -use crate::{input, BridgeRuntime, Event, InputEvent, IntervalInfo, OpenHarmonyApp, Rect, Size}; +use crate::{input, Event, InputEvent, IntervalInfo, OpenHarmonyApp, Rect, Size}; /// create lifecycle object and return to arkts pub fn render( env: &Env, - bindings: ObjectRef, slot: ArkUIHandle, + render_owner: String, app: OpenHarmonyApp, ) -> Result { - // Worker transport owns only TSFNs. The separate synchronous endpoint is a FunctionRef that - // is borrowed only by an Env-scoped main-thread call; no worker receives it or an ArkTS - // ObjectRef/Env handle. - let bridge_bindings = BridgeRuntime::from_bindings(env, &bindings)?; - app.set_bridge_bindings( - bridge_bindings.runtime, - bridge_bindings.main_thread_endpoint, - ); - let mut root = RootNode::new(slot); let xcomponent_native = XComponent::new().map_err(|e| Error::from_reason(e.reason.to_string()))?; @@ -29,16 +20,12 @@ pub fn render( .background_color(0x0000_0000) .map_err(|e| Error::from_reason(e.reason.to_string()))?; - { - let mut inner = app.inner.write().unwrap(); - inner.xcomponent = Some(xcomponent_native.clone()); - } - let xcomponent = xcomponent_native.native_xcomponent(); let xc = xcomponent.clone(); let on_surface_created_app = app.clone(); + let on_surface_created_owner = render_owner.clone(); let insert_text_app = app.clone(); let redraw_app = app.clone(); @@ -47,27 +34,28 @@ pub fn render( on_ime_hide_callback_tsfn, on_backspace_callback_tsfn, on_ime_enter_callback_tsfn, - ) = input::ime_ts_fn(env, app.clone())?; + ) = input::ime_ts_fn(env, app.clone(), render_owner.clone())?; xcomponent.on_surface_created(move |xc_raw, win| { - { - let size = xc_raw.size(win).unwrap(); - let offset = xc_raw.offset(win).unwrap(); - on_surface_created_app.inner.write().unwrap().rect = Rect { - top: offset.y as _, - left: offset.x as _, - width: size.width as _, - height: size.height as _, - }; + let size = xc_raw.size(win).unwrap(); + let offset = xc_raw.offset(win).unwrap(); + let rect = Rect { + top: offset.y as _, + left: offset.x as _, + width: size.width as _, + height: size.height as _, + }; + if !on_surface_created_app.activate_render_surface( + &on_surface_created_owner, + xc.native_window(), + rect, + ) { + return Ok(()); } - { - let raw_window = xc.native_window(); - on_surface_created_app.inner.write().unwrap().raw_window = raw_window; - // We need to create IME instance when app is foucsed - let ime = IME::new(Default::default()); - *on_surface_created_app.ime.borrow_mut() = Some(ime); - } + // We need to create IME instance when app is focused. + let ime = IME::new(Default::default()); + *on_surface_created_app.ime.borrow_mut() = Some(ime); if let Some(b_ime) = insert_text_app.ime.borrow().as_ref() { // // run in other thread @@ -92,7 +80,11 @@ pub fn render( } let inner_redraw_app = redraw_app.clone(); + let inner_redraw_owner = on_surface_created_owner.clone(); xc.on_frame_callback(move |_xcomponent, _time, _time_stamp| { + if !inner_redraw_app.is_render_surface_active(&inner_redraw_owner) { + return Ok(()); + } if let Some(ref mut h) = *inner_redraw_app.event_loop.borrow_mut() { h(Event::WindowRedraw(IntervalInfo { time_stamp: _time_stamp as _, @@ -105,36 +97,44 @@ pub fn render( }); let on_surface_destroyed_app = app.clone(); + let on_surface_destroyed_owner = render_owner.clone(); xcomponent.on_surface_destroyed(move |_, _| { - if let Some(ref mut h) = *on_surface_destroyed_app.event_loop.borrow_mut() { - h(Event::SurfaceDestroy) + if on_surface_destroyed_app.deactivate_render_surface(&on_surface_destroyed_owner) { + on_surface_destroyed_app.dispatch_surface_destroy(); } Ok(()) }); let on_surface_changed_app = app.clone(); + let on_surface_changed_owner = render_owner.clone(); xcomponent.on_surface_changed(move |xc, win| { - if let Some(ref mut h) = *on_surface_changed_app.event_loop.borrow_mut() { - let size = xc.size(win).unwrap(); - let offset = xc.offset(win).unwrap(); - { - on_surface_changed_app.inner.write().unwrap().rect = Rect { - top: offset.y as _, - left: offset.x as _, + let size = xc.size(win).unwrap(); + let offset = xc.offset(win).unwrap(); + if on_surface_changed_app.update_render_surface_rect( + &on_surface_changed_owner, + Rect { + top: offset.y as _, + left: offset.x as _, + width: size.width as _, + height: size.height as _, + }, + ) { + if let Some(ref mut h) = *on_surface_changed_app.event_loop.borrow_mut() { + h(Event::WindowResize(Size { width: size.width as _, height: size.height as _, - }; + })) } - h(Event::WindowResize(Size { - width: size.width as _, - height: size.height as _, - })) } Ok(()) }); let on_touch_event_app = app.clone(); + let on_touch_event_owner = render_owner.clone(); xcomponent.on_touch_event(move |_, _, data| { + if !on_touch_event_app.is_render_surface_active(&on_touch_event_owner) { + return Ok(()); + } if let Some(ref mut h) = *on_touch_event_app.event_loop.borrow_mut() { h(Event::Input(InputEvent::TouchEvent(data))) } @@ -142,7 +142,11 @@ pub fn render( }); let on_key_event_app = app.clone(); + let on_key_event_owner = render_owner.clone(); let _ = xcomponent.on_key_event(move |_, _, data| { + if !on_key_event_app.is_render_surface_active(&on_key_event_owner) { + return Ok(()); + } if let Some(ref mut h) = *on_key_event_app.event_loop.borrow_mut() { h(Event::Input(InputEvent::KeyEvent(data))); } @@ -150,7 +154,11 @@ pub fn render( }); let on_mouse_event_app = app.clone(); + let on_mouse_event_owner = render_owner.clone(); xcomponent.on_mouse_event(move |_, _, data| { + if !on_mouse_event_app.is_render_surface_active(&on_mouse_event_owner) { + return Ok(()); + } if let Some(ref mut h) = *on_mouse_event_app.event_loop.borrow_mut() { h(Event::Input(InputEvent::MouseEvent(data))); } @@ -160,8 +168,11 @@ pub fn render( xcomponent.register_callback()?; - root.mount(xcomponent_native) - .map_err(|e| Error::from_reason(e.reason.to_string()))?; + app.begin_render(&render_owner, xcomponent_native.clone())?; + if let Err(error) = root.mount(xcomponent_native) { + app.release_render(&render_owner); + return Err(Error::from_reason(error.reason.to_string())); + } Ok(root) } diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs index 05d719e7..3b64a1e3 100644 --- a/crates/derive/src/lib.rs +++ b/crates/derive/src/lib.rs @@ -5,7 +5,8 @@ use syn::ItemFn; /// Defines one native ability module. /// /// The attribute no longer accepts `webview` or `protocol` arguments. WebView is an application -/// plugin with an explicit ArkTS host slot, rather than a framework-level render special case. +/// plugin that mounts into this module's component root, rather than a framework-level render +/// special case. #[proc_macro_attribute] pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { if !attr.is_empty() { @@ -26,37 +27,52 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { #[napi_derive_ohos::napi] pub fn render<'a>( env: &'a napi_ohos::Env, - bindings: napi_ohos::bindgen_prelude::ObjectRef, #[napi(ts_arg_type = "NodeContent")] slot: openharmony_ability::arkui::ArkUIHandle, render_owner: String, ) -> napi_ohos::Result<()> { if render_owner.is_empty() { return Err(napi_ohos::Error::from_reason("renderOwner must not be empty")); } - let root = openharmony_ability::render(env, bindings, slot, (*APP).clone())?; - ROOT_NODES.with(|nodes| { - let mut nodes = nodes.borrow_mut(); - if let Some(index) = nodes.iter().position(|(owner, _)| owner == &render_owner) { - nodes.remove(index); - } - nodes.push((render_owner, root)); - }); + if ROOT_NODE.with(|node| node.borrow().is_some()) { + return Err(napi_ohos::Error::from_reason( + "This native module is already rendered by another DefaultXComponent; use a distinct native module for every active component", + )); + } + let root = openharmony_ability::render( + env, + slot, + render_owner.clone(), + (*APP).clone(), + )?; + ROOT_NODE.with(|node| *node.borrow_mut() = Some((render_owner, root))); Ok(()) } #[napi_derive_ohos::napi] pub fn dispose_render(render_owner: String) { - ROOT_NODES.with(|nodes| { - let mut nodes = nodes.borrow_mut(); - if let Some(index) = nodes.iter().position(|(owner, _)| owner == &render_owner) { - nodes.remove(index); + ROOT_NODE.with(|node| { + let owns_render = node + .borrow() + .as_ref() + .map(|(owner, _)| owner == &render_owner) + .unwrap_or(false); + if owns_render { + let root = node.borrow_mut().take(); + drop(root); + (*APP).release_render(&render_owner); } }); } #[napi_derive_ohos::napi] pub fn dispose_all_renders() { - ROOT_NODES.with(|nodes| nodes.borrow_mut().clear()); + ROOT_NODE.with(|node| { + let root = node.borrow_mut().take(); + if let Some((owner, root)) = root { + drop(root); + (*APP).release_render(&owner); + } + }); } }; @@ -70,8 +86,30 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { std::sync::LazyLock::new(openharmony_ability::OpenHarmonyApp::new); static APP_CONFIGURED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + struct BridgeSessionInitGuard { + owner: Option, + } + + impl BridgeSessionInitGuard { + fn new(owner: String) -> Self { + Self { owner: Some(owner) } + } + + fn disarm(&mut self) { + self.owner = None; + } + } + + impl Drop for BridgeSessionInitGuard { + fn drop(&mut self) { + if let Some(owner) = self.owner.take() { + (*APP).release_bridge_session(&owner); + } + } + } + thread_local! { - pub static ROOT_NODES: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + pub static ROOT_NODE: std::cell::RefCell> = std::cell::RefCell::new(None); } #[napi_derive_ohos::napi] @@ -82,19 +120,31 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { #[napi_derive_ohos::napi] pub fn init<'a>( env: &'a napi_ohos::Env, + bindings: napi_ohos::bindgen_prelude::ObjectRef, + bridge_owner: String, #[napi(ts_arg_type = "AbilityInitContext")] context: Option>, ) -> napi_ohos::Result> { let init_context = openharmony_ability::AbilityInitContext::from_object(context.as_ref())?; + openharmony_ability::attach_bridge_session(env, bindings, &bridge_owner, &APP)?; + let mut bridge_guard = BridgeSessionInitGuard::new(bridge_owner); (*APP).set_init_context(init_context); // A native module can outlive one UIAbility instance. Configure its process-wide // Rust plugin registry exactly once, while still refreshing the per-session init // context and lifecycle handle on every Ability recreation. APP_CONFIGURED.get_or_init(|| #fn_name((*APP).clone())); let lifecycle_handle = openharmony_ability::create_lifecycle_handle(env, (*APP).clone())?; + bridge_guard.disarm(); Ok(lifecycle_handle) } + /// Releases the Ability-session transport without touching this module's independent + /// DefaultXComponent render owner. Stale owners are ignored. + #[napi_derive_ohos::napi] + pub fn dispose_bridge(bridge_owner: String) { + (*APP).release_bridge_session(&bridge_owner); + } + /// Synchronous ArkTS platform callback -> Rust plugin decision port. /// /// The N-API value is scoped to this call and the returned value must be produced diff --git a/crates/plugin-resource/src/lib.rs b/crates/plugin-resource/src/lib.rs index 59fc8019..fed7cd15 100644 --- a/crates/plugin-resource/src/lib.rs +++ b/crates/plugin-resource/src/lib.rs @@ -3,18 +3,16 @@ //! The ArkTS wrapper (`plugins/resource`) owns the HarmonyOS `resourceManager` platform object //! and hands it to Rust through the inbound `resource-manager-ready` main-thread event. Rust //! converts the object to a native `NativeResourceManager` pointer **inside the same N-API -//! callback** ([`ResourceManagerRef::from_bridge_value`]) and stores it globally; the ArkTS -//! object is never retained. Every subsequent read (raw files, media, drawables, strings) calls -//! the OpenHarmony C API directly through `ohos-resource-manager-binding` — no ArkTS round-trip -//! is involved. +//! callback** ([`ResourceManagerRef::from_bridge_value`]) and stores it in this native module's +//! registered Rust plugin instance; the ArkTS object is never retained. Every subsequent read +//! calls the OpenHarmony C API directly through `ohos-resource-manager-binding`. //! -//! The wrapper pushes on the `ability-create` lifecycle event: the inbound event sink is -//! attached right after `module.init` in `NativeAbility.onCreate` (not at UI render time), so -//! plugins that only require `ability` can emit ArkTS → Rust events before rendering. +//! The wrapper pushes from its Ability-scoped `onInstall` hook. It does not depend on a +//! WindowStage or DefaultXComponent. use std::{ ops::Deref, - sync::{Arc, LazyLock, RwLock}, + sync::{Arc, RwLock}, }; use napi_derive_ohos::napi; @@ -23,19 +21,15 @@ use ohos_resource_manager_binding::ResourceManager as NativeResourceManager; use ohos_resource_manager_sys::OH_ResourceManager_InitNativeResourceManager; use openharmony_ability::{ impl_bridge_napi_type, AsyncBridge, BridgeContextRequirement, BridgeMainThreadEvent, - BridgeNapiType, BridgePlugin, OpenHarmonyApp, + BridgeNapiType, BridgePlugin, OpenHarmonyApp, PluginLifecycleEvent, }; pub use ohos_resource_manager_binding::ScreenDensity as ResourceScreenDensity; pub use ohos_resource_manager_binding::{IconType, RawDir, RawFile, RawFile64, RawFileError}; -/// Inbound event name emitted by the ArkTS wrapper when the native module is rendered. +/// Inbound event name emitted when the Ability-scoped ArkTS wrapper is installed. pub const RESOURCE_MANAGER_READY_EVENT: &str = "resource-manager-ready"; -type ResourceManagerState = LazyLock>>; - -static RESOURCE_MANAGER: ResourceManagerState = LazyLock::new(|| RwLock::new(None)); - /// Cloneable handle to the HarmonyOS native resource manager installed by the `ohos.resource` /// plugin. Read operations deref to `ohos_resource_manager_binding::ResourceManager`. /// @@ -44,7 +38,7 @@ static RESOURCE_MANAGER: ResourceManagerState = LazyLock::new(|| RwLock::new(Non /// The underlying `NativeResourceManager` methods are **not thread-safe** (documented by /// `ohos-resource-manager-binding`). The handle is cloneable across threads, but concurrent /// reads from multiple threads must be serialized by the caller (for example through a -/// `Mutex`); this mirrors the pre-plugin global singleton semantics. +/// `Mutex`). #[derive(Clone)] pub struct ResourceManager(Arc); @@ -66,32 +60,40 @@ impl Deref for ResourceManager { } } -/// Returns the global resource manager installed by the `ohos.resource` plugin, if the ArkTS -/// wrapper has already pushed it on `ui-context-ready`. -pub fn resource_manager() -> Option { - RESOURCE_MANAGER - .read() - .ok() - .and_then(|guard| guard.as_ref().cloned()) +/// Rust facade receiving and owning the native resource manager for one native module. +#[derive(Default)] +pub struct ResourceBridgePlugin { + resource_manager: RwLock>, } -fn set_resource_manager(resource_manager: Option) { - if let Ok(mut guard) = RESOURCE_MANAGER.write() { - *guard = resource_manager; +impl ResourceBridgePlugin { + pub fn new() -> Self { + Self::default() + } + + pub fn resource_manager(&self) -> Option { + self.resource_manager + .read() + .ok() + .and_then(|guard| guard.as_ref().cloned()) } -} -/// Rust facade receiving the ArkTS `resourceManager` object. -pub struct ResourceBridgePlugin; + fn replace_resource_manager(&self, resource_manager: Option) -> Result<()> { + let mut state = self + .resource_manager + .write() + .map_err(|_| Error::from_reason("Failed to update native resource manager"))?; + *state = resource_manager; + Ok(()) + } +} impl BridgePlugin for ResourceBridgePlugin { type Mode = AsyncBridge; const ID: &'static str = "ohos.resource"; const VERSION: u32 = 1; - // The wrapper pushes the platform object on `ability-create`. The inbound event sink is - // attached right after `module.init` in `NativeAbility.onCreate` and the Rust registry has - // observed `AbilityCreated` before ArkTS emits `ability-create`, so the gate is satisfied. + // The wrapper pushes during its Ability-scoped onInstall hook, before any component is needed. const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = &[BridgeContextRequirement::Ability]; @@ -102,7 +104,7 @@ impl BridgePlugin for ResourceBridgePlugin { match event.name() { RESOURCE_MANAGER_READY_EVENT => { let ready = event.decode::()?; - set_resource_manager(Some(ready.into_manager())); + self.replace_resource_manager(Some(ready.into_manager()))?; event.respond(ResourceManagerReadyResponse { accepted: true }) } other => Err(Error::from_reason(format!( @@ -110,6 +112,16 @@ impl BridgePlugin for ResourceBridgePlugin { ))), } } + + fn on_lifecycle(&self, event: &PluginLifecycleEvent) -> Result<()> { + if matches!( + event, + PluginLifecycleEvent::AbilityCreated { .. } | PluginLifecycleEvent::AbilityDestroyed + ) { + self.replace_resource_manager(None)?; + } + Ok(()) + } } /// Inbound wire marker for the ArkTS `resourceManager` object. @@ -158,7 +170,7 @@ impl_bridge_napi_type!( "ohos.resource.ResourceManagerReadyResponse" ); -/// Extension trait exposing the global resource manager on `OpenHarmonyApp`. +/// Extension trait exposing this native module's registered resource manager. /// /// ```no_run /// use openharmony_ability::OpenHarmonyApp; @@ -176,14 +188,20 @@ pub trait ResourceExt { impl ResourceExt for OpenHarmonyApp { fn resource_manager(&self) -> Option { - resource_manager() + self.registered_plugin::() + .ok() + .flatten() + .and_then(|plugin| plugin.resource_manager()) } } #[cfg(test)] mod tests { - use super::{ResourceManagerReadyResponse, ResourceManagerRef, RESOURCE_MANAGER_READY_EVENT}; - use openharmony_ability::BridgeNapiType; + use super::{ + ResourceBridgePlugin, ResourceExt, ResourceManagerReadyResponse, ResourceManagerRef, + RESOURCE_MANAGER_READY_EVENT, + }; + use openharmony_ability::{BridgeNapiType, OpenHarmonyApp}; #[test] fn resource_uses_stable_named_napi_contracts() { @@ -201,6 +219,12 @@ mod tests { #[test] fn resource_manager_is_unset_before_the_wrapper_pushes() { - assert!(super::resource_manager().is_none()); + let app = OpenHarmonyApp::new(); + app.register_plugin(ResourceBridgePlugin::new()).unwrap(); + assert!(app.resource_manager().is_none()); + assert!(app + .registered_plugin::() + .unwrap() + .is_some()); } } diff --git a/crates/plugin-webview/src/callbacks.rs b/crates/plugin-webview/src/callbacks.rs index 0e71c26d..b52e6c53 100644 --- a/crates/plugin-webview/src/callbacks.rs +++ b/crates/plugin-webview/src/callbacks.rs @@ -1,6 +1,6 @@ //! Rust-owned WebView callback declarations. //! -//! The registry stores only Rust closures keyed by WebView tag. ArkTS receives a boolean +//! The registry stores only Rust closures keyed by module-local WebView ID. ArkTS receives a boolean //! subscription snapshot as part of create, then invokes every ArkWeb callback through a scoped //! named N-API event. No ArkTS Function, ObjectRef, or JSON event payload is kept by Rust. @@ -12,7 +12,7 @@ use std::{ use napi_ohos::{Error, Result}; use super::{ - WebviewCallbackOptions, WebviewDownloadEndEvent, WebviewDownloadStartRequest, + controller, WebviewCallbackOptions, WebviewDownloadEndEvent, WebviewDownloadStartRequest, WebviewDownloadStartResponse, WebviewNavigationRequest, WebviewNavigationResponse, WebviewTitleChangeEvent, }; @@ -55,7 +55,7 @@ static CALLBACKS: LazyLock>> = /// Builder for Rust-owned WebView lifecycle and platform callbacks. /// -/// Build this before WebviewClient::create. Calling build for the same tag replaces the previous +/// Build this before WebviewClient::create. Calling build for the same ID replaces the previous /// declaration and callback declarations remain valid across a remove and create cycle. #[derive(Default)] pub struct WebviewCallbacksBuilder { @@ -140,6 +140,9 @@ pub(crate) fn options_for(webview_id: &str) -> Result { pub(crate) fn navigation_decision( request: WebviewNavigationRequest, ) -> Result { + if !controller::is_current(&request.id, &request.native_tag)? { + return Ok(WebviewNavigationResponse { intercept: false }); + } let callback = CALLBACKS .read() .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? @@ -154,6 +157,9 @@ pub(crate) fn navigation_decision( pub(crate) fn download_start_decision( request: WebviewDownloadStartRequest, ) -> Result { + if !controller::is_current(&request.id, &request.native_tag)? { + return Ok(WebviewDownloadStartResponse::cancel()); + } let callback = CALLBACKS .read() .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? @@ -166,6 +172,9 @@ pub(crate) fn download_start_decision( } pub(crate) fn dispatch_download_end(event: WebviewDownloadEndEvent) -> Result<()> { + if !controller::is_current(&event.id, &event.native_tag)? { + return Ok(()); + } let callback = CALLBACKS .read() .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? @@ -179,6 +188,9 @@ pub(crate) fn dispatch_download_end(event: WebviewDownloadEndEvent) -> Result<() } pub(crate) fn dispatch_title_change(event: WebviewTitleChangeEvent) -> Result<()> { + if !controller::is_current(&event.id, &event.native_tag)? { + return Ok(()); + } let callback = CALLBACKS .read() .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? @@ -193,10 +205,49 @@ pub(crate) fn dispatch_title_change(event: WebviewTitleChangeEvent) -> Result<() #[cfg(test)] mod tests { - use super::WebviewCallbacksBuilder; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + use super::{navigation_decision, WebviewCallbacksBuilder}; + use crate::{controller, WebviewNavigationRequest}; #[test] fn callback_builder_rejects_an_empty_declaration() { assert!(WebviewCallbacksBuilder::new("webview").build().is_err()); } + + #[test] + fn stale_controller_callback_cannot_reach_a_replacement_webview() { + let calls = Arc::new(AtomicUsize::new(0)); + let callback_calls = Arc::clone(&calls); + WebviewCallbacksBuilder::new("stale-callback-test") + .on_navigation_request(move |_| { + callback_calls.fetch_add(1, Ordering::Relaxed); + true + }) + .build() + .unwrap(); + controller::on_attached("stale-callback-test", "native-new").unwrap(); + + let stale = navigation_decision(WebviewNavigationRequest { + id: "stale-callback-test".to_owned(), + native_tag: "native-old".to_owned(), + url: "https://stale.example".to_owned(), + }) + .unwrap(); + assert!(!stale.intercept); + assert_eq!(calls.load(Ordering::Relaxed), 0); + + let current = navigation_decision(WebviewNavigationRequest { + id: "stale-callback-test".to_owned(), + native_tag: "native-new".to_owned(), + url: "https://current.example".to_owned(), + }) + .unwrap(); + assert!(current.intercept); + assert_eq!(calls.load(Ordering::Relaxed), 1); + controller::on_removed("stale-callback-test", "native-new").unwrap(); + } } diff --git a/crates/plugin-webview/src/controller.rs b/crates/plugin-webview/src/controller.rs new file mode 100644 index 00000000..64bfd43d --- /dev/null +++ b/crates/plugin-webview/src/controller.rs @@ -0,0 +1,108 @@ +//! Module-local business WebView ID -> process-unique ArkWeb tag mapping. + +use std::{ + collections::BTreeMap, + sync::{LazyLock, RwLock}, +}; + +use napi_ohos::{Error, Result}; + +#[derive(Default)] +struct ControllerState { + native_tags: BTreeMap, +} + +impl ControllerState { + fn attach(&mut self, webview_id: &str, native_tag: &str) { + self.native_tags + .insert(webview_id.to_owned(), native_tag.to_owned()); + } + + fn remove(&mut self, webview_id: &str, native_tag: &str) -> bool { + if self.native_tags.get(webview_id).map(String::as_str) != Some(native_tag) { + return false; + } + self.native_tags.remove(webview_id); + true + } + + fn native_tag(&self, webview_id: &str) -> Option { + self.native_tags.get(webview_id).cloned() + } + + fn is_current(&self, webview_id: &str, native_tag: &str) -> bool { + self.native_tags.get(webview_id).map(String::as_str) == Some(native_tag) + } + + fn clear(&mut self) { + self.native_tags.clear(); + } +} + +static CONTROLLERS: LazyLock> = + LazyLock::new(|| RwLock::new(ControllerState::default())); + +pub(crate) fn on_attached(webview_id: &str, native_tag: &str) -> Result<()> { + CONTROLLERS + .write() + .map_err(|_| Error::from_reason("Failed to update WebView controller tag registry"))? + .attach(webview_id, native_tag); + Ok(()) +} + +pub(crate) fn on_removed(webview_id: &str, native_tag: &str) -> Result<()> { + CONTROLLERS + .write() + .map_err(|_| Error::from_reason("Failed to update WebView controller tag registry"))? + .remove(webview_id, native_tag); + Ok(()) +} + +pub(crate) fn native_tag_for(webview_id: &str) -> Result { + CONTROLLERS + .read() + .map_err(|_| Error::from_reason("Failed to read WebView controller tag registry"))? + .native_tag(webview_id) + .ok_or_else(|| { + Error::from_reason(format!( + "WebView '{webview_id}' has no attached ArkWeb controller" + )) + }) +} + +pub(crate) fn is_current(webview_id: &str, native_tag: &str) -> Result { + Ok(CONTROLLERS + .read() + .map_err(|_| Error::from_reason("Failed to read WebView controller tag registry"))? + .is_current(webview_id, native_tag)) +} + +pub(crate) fn clear_attached() -> Result<()> { + CONTROLLERS + .write() + .map_err(|_| Error::from_reason("Failed to clear WebView controller tag registry"))? + .clear(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::ControllerState; + + #[test] + fn stale_controller_removal_cannot_clear_a_replacement_tag() { + let mut state = ControllerState::default(); + state.attach("detail", "native-a"); + state.attach("detail", "native-b"); + assert!(!state.is_current("detail", "native-a")); + assert!(state.is_current("detail", "native-b")); + assert!(!state.remove("detail", "native-a")); + assert_eq!(state.native_tag("detail").as_deref(), Some("native-b")); + assert!(state.remove("detail", "native-b")); + assert!(state.native_tag("detail").is_none()); + + state.attach("detail", "native-c"); + state.clear(); + assert!(state.native_tag("detail").is_none()); + } +} diff --git a/crates/plugin-webview/src/js_proxy.rs b/crates/plugin-webview/src/js_proxy.rs index 834a8d7f..8e27af1b 100644 --- a/crates/plugin-webview/src/js_proxy.rs +++ b/crates/plugin-webview/src/js_proxy.rs @@ -1,12 +1,13 @@ //! Web-page JavaScript → Rust proxy support owned by the WebView plugin. //! //! ArkWeb requires a JavaScript proxy to be registered after its controller has attached. The -//! public builder therefore queues declarations by WebView tag and `WebviewBridgePlugin` flushes +//! public builder therefore queues declarations by module-local WebView ID and +//! `WebviewBridgePlugin` flushes //! them when the ArkTS Web component reports `controller-attached`. This keeps the page callback //! in native ArkWeb while avoiding an ArkTS object or N-API function reference on a Rust worker. use std::{ - collections::{BTreeMap, BTreeSet}, + collections::BTreeMap, sync::{Arc, LazyLock, Mutex}, }; @@ -30,7 +31,8 @@ struct ProxyDeclaration { #[derive(Default)] struct ProxyState { - attached_webviews: BTreeSet, + /// Business WebView ID -> process-unique ArkWeb controller tag. + attached_webviews: BTreeMap, declarations: BTreeMap>, } @@ -40,9 +42,9 @@ static PROXY_STATE: LazyLock> = /// Builder for a persistent JavaScript object exposed to a WebView page. /// /// The registered object is available as `window.` and each declared method receives -/// the ArkWeb tag plus stringified page arguments. Calling [`Self::build`] before `create` is the -/// preferred path: the declaration is installed exactly when the controller attaches, before the -/// initial document is loaded. +/// the module-local business WebView ID plus stringified page arguments. Calling [`Self::build`] +/// before `create` is the preferred path: the declaration is installed exactly when the +/// process-unique ArkWeb controller attaches, before the initial document is loaded. pub struct WebviewJavascriptProxyBuilder { webview_id: String, object_name: String, @@ -72,23 +74,26 @@ impl WebviewJavascriptProxyBuilder { /// Queues the declaration until the WebView controller attaches, or installs it immediately /// and reloads the page when the controller is already attached. Declarations survive a - /// remove/create cycle for the same WebView tag. + /// remove/create cycle for the same WebView ID. pub fn build(self) -> Result<()> { let declaration = self.into_declaration()?; let declaration_to_install = { let mut state = PROXY_STATE .lock() .map_err(|_| Error::from_reason("Failed to lock WebView JavaScript proxy state"))?; - let attached = state.attached_webviews.contains(&declaration.webview_id); + let native_tag = state + .attached_webviews + .get(&declaration.webview_id) + .cloned(); state .declarations .entry(declaration.webview_id.clone()) .or_default() .push(declaration.clone()); - attached.then_some(declaration) + native_tag.map(|native_tag| (declaration, native_tag)) }; - if let Some(declaration) = declaration_to_install { - install(declaration, true)?; + if let Some((declaration, native_tag)) = declaration_to_install { + install(declaration, &native_tag, true)?; } Ok(()) } @@ -123,12 +128,15 @@ impl WebviewJavascriptProxyBuilder { } /// Flushes queued page-to-Rust proxies once ArkTS has attached the native controller. -pub(crate) fn on_controller_attached(webview_id: &str) -> Result<()> { +pub(crate) fn on_controller_attached(webview_id: &str, native_tag: &str) -> Result<()> { let declarations = { let mut state = PROXY_STATE .lock() .map_err(|_| Error::from_reason("Failed to lock WebView JavaScript proxy state"))?; - if !state.attached_webviews.insert(webview_id.to_owned()) { + let previous_tag = state + .attached_webviews + .insert(webview_id.to_owned(), native_tag.to_owned()); + if previous_tag.as_deref() == Some(native_tag) { return Ok(()); } state @@ -139,29 +147,48 @@ pub(crate) fn on_controller_attached(webview_id: &str) -> Result<()> { }; for declaration in declarations { - install(declaration, false)?; + install(declaration, native_tag, false)?; } Ok(()) } /// Marks a controller detached. Declarations remain queued for a future controller using the -/// same WebView tag, while ArkWeb owns proxies that were already installed. -pub(crate) fn on_controller_removed(webview_id: &str) -> Result<()> { +/// same WebView ID, while ArkWeb owns proxies that were already installed. +pub(crate) fn on_controller_removed(webview_id: &str, native_tag: &str) -> Result<()> { let mut state = PROXY_STATE .lock() .map_err(|_| Error::from_reason("Failed to lock WebView JavaScript proxy state"))?; + if state.attached_webviews.get(webview_id).map(String::as_str) != Some(native_tag) { + return Ok(()); + } state.attached_webviews.remove(webview_id); Ok(()) } -fn install(declaration: ProxyDeclaration, refresh_after_install: bool) -> Result<()> { - let mut builder = - ArkWebProxyBuilder::new(declaration.webview_id.clone(), declaration.object_name); +/// Clears controller-generation state at component/session teardown. Proxy declarations remain +/// available for a later controller created with the same module-local business ID. +pub(crate) fn clear_attached() -> Result<()> { + PROXY_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to clear WebView JavaScript proxy state"))? + .attached_webviews + .clear(); + Ok(()) +} + +fn install( + declaration: ProxyDeclaration, + native_tag: &str, + refresh_after_install: bool, +) -> Result<()> { + let webview_id = declaration.webview_id; + let mut builder = ArkWebProxyBuilder::new(native_tag.to_owned(), declaration.object_name); for method in declaration.methods { let callback = Arc::clone(&method.callback); - builder = builder.add_method(method.name, move |webview_id, arguments| { + let callback_webview_id = webview_id.clone(); + builder = builder.add_method(method.name, move |_native_tag, arguments| { if let Ok(mut callback) = callback.lock() { - callback(webview_id, arguments); + callback(callback_webview_id.clone(), arguments); } }); } diff --git a/crates/plugin-webview/src/lib.rs b/crates/plugin-webview/src/lib.rs index f43569db..4ed65f11 100644 --- a/crates/plugin-webview/src/lib.rs +++ b/crates/plugin-webview/src/lib.rs @@ -12,9 +12,11 @@ use ohos_web_binding::Web; use openharmony_ability::{ impl_bridge_napi_type, AsyncBridge, BridgeCallOptions, BridgeContextRequirement, BridgeMainThreadEvent, BridgeNapiType, BridgePlugin, BridgeRuntime, OpenHarmonyApp, + PluginLifecycleEvent, }; mod callbacks; +mod controller; mod js_proxy; mod protocol; @@ -25,52 +27,74 @@ pub use protocol::{ WebviewProtocolRequest, WebviewProtocolResponder, WebviewProtocolResponse, }; +const BEFORE_ENGINE_INIT_EVENT: &str = "before-engine-init"; +const SEAL_ENGINE_SCHEMES_EVENT: &str = "seal-engine-schemes"; +const ENGINE_INITIALIZED_EVENT: &str = "engine-initialized"; +const CONTROLLER_ATTACHED_EVENT: &str = "controller-attached"; + pub struct WebviewBridgePlugin; impl BridgePlugin for WebviewBridgePlugin { type Mode = AsyncBridge; const ID: &'static str = "ohos.webview"; - const VERSION: u32 = 1; + const VERSION: u32 = 2; const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = &[BridgeContextRequirement::UiContext]; + fn required_contexts_for_main_thread_event( + &self, + event_name: &str, + ) -> &'static [BridgeContextRequirement] { + match event_name { + SEAL_ENGINE_SCHEMES_EVENT | BEFORE_ENGINE_INIT_EVENT | ENGINE_INITIALIZED_EVENT => { + &[BridgeContextRequirement::Ability] + } + _ => Self::REQUIRED_CONTEXTS, + } + } + fn on_main_thread_event<'env>( &self, event: BridgeMainThreadEvent<'env>, ) -> Result> { match event.name() { - "before-engine-init" => { - expect_engine_phase( - event.decode::()?, - "before-engine-init", - )?; + SEAL_ENGINE_SCHEMES_EVENT => { + let lifecycle = event.decode::()?; + expect_engine_phase(&lifecycle, SEAL_ENGINE_SCHEMES_EVENT)?; + WebviewProtocol::seal_before_engine_init()?; + event.respond(engine_lifecycle_response()?) + } + BEFORE_ENGINE_INIT_EVENT => { + let lifecycle = event.decode::()?; + expect_engine_phase(&lifecycle, BEFORE_ENGINE_INIT_EVENT)?; + WebviewProtocol::validate_process_schemes(&engine_scheme_pairs(&lifecycle))?; WebviewProtocol::flush_before_engine_init()?; - event.respond(WebviewEventAcknowledgement { accepted: true }) + event.respond(engine_lifecycle_response()?) } - "engine-initialized" => { - expect_engine_phase( - event.decode::()?, - "engine-initialized", - )?; - WebviewProtocol::mark_engine_initialized()?; - event.respond(WebviewEventAcknowledgement { accepted: true }) + ENGINE_INITIALIZED_EVENT => { + let lifecycle = event.decode::()?; + expect_engine_phase(&lifecycle, ENGINE_INITIALIZED_EVENT)?; + WebviewProtocol::mark_engine_initialized(&engine_scheme_pairs(&lifecycle))?; + event.respond(engine_lifecycle_response()?) } - "controller-attached" => { + CONTROLLER_ATTACHED_EVENT => { let controller = event.decode::()?; - let webview_id = webview_id_from_controller_event(controller)?; + let (webview_id, native_tag) = controller_identity(controller)?; + controller::on_attached(&webview_id, &native_tag)?; // Both registries own Rust closures only. Flush them on the scoped main-thread // event after ArkWeb has created its BrowserContext and before ArkTS begins the // first navigation. - protocol::on_controller_attached(&webview_id)?; - js_proxy::on_controller_attached(&webview_id)?; + protocol::on_controller_attached(&webview_id, &native_tag)?; + js_proxy::on_controller_attached(&webview_id, &native_tag)?; event.respond(WebviewEventAcknowledgement { accepted: true }) } "controller-removed" => { let controller = event.decode::()?; - let webview_id = webview_id_from_controller_event(controller)?; - protocol::on_controller_removed(&webview_id)?; - js_proxy::on_controller_removed(&webview_id)?; + let (webview_id, native_tag) = controller_identity(controller)?; + protocol::on_controller_removed(&webview_id, &native_tag)?; + js_proxy::on_controller_removed(&webview_id, &native_tag)?; + controller::on_removed(&webview_id, &native_tag)?; event.respond(WebviewEventAcknowledgement { accepted: true }) } "navigation-request" => { @@ -97,9 +121,35 @@ impl BridgePlugin for WebviewBridgePlugin { ))), } } + + fn on_lifecycle(&self, event: &PluginLifecycleEvent) -> Result<()> { + if matches!( + event, + PluginLifecycleEvent::UiContextDestroyed | PluginLifecycleEvent::AbilityDestroyed + ) { + clear_attached_webview_state()?; + } + Ok(()) + } } -fn expect_engine_phase(event: WebviewEngineLifecycleEvent, expected: &str) -> Result<()> { +fn clear_attached_webview_state() -> Result<()> { + let mut first_error = None; + for result in [ + controller::clear_attached(), + protocol::clear_attached(), + js_proxy::clear_attached(), + ] { + if let Err(error) = result { + if first_error.is_none() { + first_error = Some(error); + } + } + } + first_error.map_or(Ok(()), Err) +} + +fn expect_engine_phase(event: &WebviewEngineLifecycleEvent, expected: &str) -> Result<()> { if event.phase == expected { Ok(()) } else { @@ -110,13 +160,36 @@ fn expect_engine_phase(event: WebviewEngineLifecycleEvent, expected: &str) -> Re } } -fn webview_id_from_controller_event(event: WebviewControllerEvent) -> Result { +fn engine_scheme_pairs(event: &WebviewEngineLifecycleEvent) -> Vec<(String, u32)> { + event + .schemes + .iter() + .map(|declaration| (declaration.scheme.clone(), declaration.options)) + .collect() +} + +fn engine_lifecycle_response() -> Result { + Ok(WebviewEngineLifecycleResponse { + accepted: true, + schemes: WebviewProtocol::declared_schemes()? + .into_iter() + .map(|(scheme, options)| WebviewSchemeDeclaration { scheme, options }) + .collect(), + }) +} + +fn controller_identity(event: WebviewControllerEvent) -> Result<(String, String)> { if event.id.trim().is_empty() { return Err(Error::from_reason( "WebView controller event id must not be empty", )); } - Ok(event.id) + if event.native_tag.trim().is_empty() { + return Err(Error::from_reason( + "WebView controller event nativeTag must not be empty", + )); + } + Ok((event.id, event.native_tag)) } #[napi(object)] @@ -140,6 +213,10 @@ pub struct WebviewStyle { #[derive(Clone, Debug)] pub struct WebviewEngineLifecycleEvent { pub phase: String, + /// Process-global scheme set sealed before ArkWeb initialization. A module activated after + /// the engine started may join only when every local declaration already exists in this set + /// with identical options. + pub schemes: Vec, } impl_bridge_napi_type!( @@ -147,11 +224,35 @@ impl_bridge_napi_type!( "ohos.webview.EngineLifecycleEvent" ); +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewSchemeDeclaration { + pub scheme: String, + pub options: u32, +} + +impl_bridge_napi_type!(WebviewSchemeDeclaration, "ohos.webview.SchemeDeclaration"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewEngineLifecycleResponse { + pub accepted: bool, + pub schemes: Vec, +} + +impl_bridge_napi_type!( + WebviewEngineLifecycleResponse, + "ohos.webview.EngineLifecycleResponse" +); + /// Controller lifecycle signal delivered directly from the ArkTS WebView host. #[napi(object)] #[derive(Clone, Debug)] pub struct WebviewControllerEvent { pub id: String, + /// Process-unique ArkWeb controller tag generated by the ArkTS host. The public WebView ID + /// remains module-local and is never used as a process-global platform key. + pub native_tag: String, } impl_bridge_napi_type!(WebviewControllerEvent, "ohos.webview.ControllerEvent"); @@ -181,13 +282,9 @@ pub struct WebviewInitializationScript { #[derive(Clone, Debug)] pub struct WebviewCreateRequest { pub id: String, - /// Optional window surface key the WebView mounts into. Defaults to `"main"` (the default - /// window's `DefaultXComponent`); sub-window instances register under their own `windowKey`. - pub window_key: Option, /// Optional opaque container handle issued by the built-in `ohos.node` plugin. When provided, - /// the ArkTS host appends the WebView FrameNode under that container instead of the window - /// root, so an RS-layer node tree can adopt WebViews as children. Absent = full-bleed window - /// root mount. + /// the ArkTS host appends the WebView FrameNode under that container instead of this native + /// module's DefaultXComponent root. pub parent_handle: Option, pub url: Option, pub html: Option, @@ -211,7 +308,6 @@ impl WebviewCreateRequest { pub fn new(id: impl Into) -> Self { Self { id: id.into(), - window_key: None, parent_handle: None, url: None, html: None, @@ -228,18 +324,12 @@ impl WebviewCreateRequest { } /// Mounts the WebView FrameNode under the given `ohos.node` container handle instead of the - /// window root, so an RS-layer node tree can adopt WebViews as children. + /// component root, so an RS-layer node tree can adopt WebViews as children. pub fn parent_node(mut self, handle: u32) -> Self { self.parent_handle = Some(handle); self } - /// Mounts into the window surface registered under `window_key` instead of the `"main"` one. - pub fn window_key(mut self, window_key: impl Into) -> Self { - self.window_key = Some(window_key.into()); - self - } - pub fn url(mut self, url: impl Into) -> Self { self.url = Some(url.into()); self @@ -273,11 +363,6 @@ impl WebviewCreateRequest { if self.id.trim().is_empty() { return Err(Error::from_reason("WebView id must not be empty")); } - if let Some(window_key) = &self.window_key { - if window_key.is_empty() { - return Err(Error::from_reason("WebView windowKey must not be empty")); - } - } if self.url.is_some() == self.html.is_some() { return Err(Error::from_reason( "WebView requires exactly one source: url or html", @@ -292,6 +377,8 @@ impl WebviewCreateRequest { #[derive(Clone, Debug)] pub struct WebviewNavigationRequest { pub id: String, + /// Process-unique controller generation used to reject callbacks from a replaced WebView. + pub native_tag: String, pub url: String, } @@ -311,6 +398,8 @@ impl_bridge_napi_type!(WebviewNavigationResponse, "ohos.webview.NavigationRespon #[derive(Clone, Debug)] pub struct WebviewDownloadStartRequest { pub id: String, + /// Process-unique controller generation used to reject callbacks from a replaced WebView. + pub native_tag: String, pub url: String, pub temp_path: Option, } @@ -354,6 +443,8 @@ impl WebviewDownloadStartResponse { #[derive(Clone, Debug)] pub struct WebviewDownloadEndEvent { pub id: String, + /// Process-unique controller generation used to reject callbacks from a replaced WebView. + pub native_tag: String, pub url: String, pub temp_path: Option, pub success: bool, @@ -364,6 +455,8 @@ pub struct WebviewDownloadEndEvent { #[derive(Clone, Debug)] pub struct WebviewTitleChangeEvent { pub id: String, + /// Process-unique controller generation used to reject callbacks from a replaced WebView. + pub native_tag: String, pub title: String, } @@ -420,7 +513,7 @@ impl WebviewClient { } } - /// Declares a custom-scheme handler by WebView tag before the ArkTS node is created. + /// Declares a custom-scheme handler by module-local WebView ID before the ArkTS node is created. /// /// The Rust closure is queued and attached from the controller-attached main-thread event, /// before the initial URL is loaded. This is the preferred route when the first URL uses that @@ -522,7 +615,7 @@ impl WebviewHandle { .value) } - /// Declares a handler for this controller tag. For a first custom-scheme load, prefer + /// Declares a handler for this controller ID. For a first custom-scheme load, prefer /// [`WebviewClient::custom_protocol`] before calling [`WebviewClient::create`]. pub fn custom_protocol(&self, scheme: S, callback: F) -> Result<()> where @@ -544,12 +637,13 @@ impl WebviewHandle { .custom_protocol_async(&self.id, scheme, callback) } - /// Registers a native ArkWeb controller-attached callback for this WebView tag. + /// Registers a native ArkWeb controller-attached callback for the currently attached + /// controller. The public ID is resolved to the process-unique native tag first. pub fn on_controller_attach(&self, callback: F) -> Result<()> where F: FnMut() + 'static, { - Web::new(self.id.clone()) + Web::new(controller::native_tag_for(&self.id)?) .on_controller_attach(callback) .map_err(|error| { Error::from_reason(format!( @@ -562,7 +656,7 @@ impl WebviewHandle { where F: FnMut() + 'static, { - Web::new(self.id.clone()) + Web::new(controller::native_tag_for(&self.id)?) .on_page_begin(callback) .map_err(|error| { Error::from_reason(format!( @@ -575,7 +669,7 @@ impl WebviewHandle { where F: FnMut() + 'static, { - Web::new(self.id.clone()) + Web::new(controller::native_tag_for(&self.id)?) .on_page_end(callback) .map_err(|error| { Error::from_reason(format!( @@ -588,7 +682,7 @@ impl WebviewHandle { where F: FnMut() + 'static, { - Web::new(self.id.clone()) + Web::new(controller::native_tag_for(&self.id)?) .on_destroy(callback) .map_err(|error| { Error::from_reason(format!( @@ -820,12 +914,10 @@ mod tests { fn create_request_retains_optional_value_semantics() { let request = WebviewCreateRequest::new("webview") .parent_node(7) - .window_key("float") .transparent(true) .url("https://example.test"); assert_eq!(request.id, "webview"); assert_eq!(request.parent_handle, Some(7)); - assert_eq!(request.window_key.as_deref(), Some("float")); assert_eq!(request.url.as_deref(), Some("https://example.test")); assert!(request.html.is_none()); assert!(request.headers.is_none()); @@ -838,6 +930,28 @@ mod tests { assert!(request.parent_handle.is_none()); } + #[test] + fn webview_id_remains_an_opaque_business_identifier() { + let request = WebviewCreateRequest::new("window 2 / detail#1").html("

hi

"); + assert!(request.validate().is_ok()); + } + + #[test] + fn controller_event_separates_business_id_from_process_native_tag() { + let identity = controller_identity(WebviewControllerEvent { + id: "detail".to_owned(), + native_tag: "ohos.webview.bridge-1.demo-native.7".to_owned(), + }) + .unwrap(); + assert_eq!(identity.0, "detail"); + assert_eq!(identity.1, "ohos.webview.bridge-1.demo-native.7"); + assert!(controller_identity(WebviewControllerEvent { + id: "detail".to_owned(), + native_tag: " ".to_owned(), + }) + .is_err()); + } + #[test] fn webview_actions_have_named_napi_contracts() { assert_eq!( @@ -872,6 +986,14 @@ mod tests { ::TYPE_NAME, "ohos.webview.EngineLifecycleEvent" ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.SchemeDeclaration" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.EngineLifecycleResponse" + ); assert_eq!( ::TYPE_NAME, "ohos.webview.ControllerEvent" @@ -905,4 +1027,25 @@ mod tests { "ohos.webview.EventAcknowledgement" ); } + + #[test] + fn engine_events_are_ability_scoped_but_controller_events_require_ui() { + let plugin = WebviewBridgePlugin; + assert_eq!( + plugin.required_contexts_for_main_thread_event(SEAL_ENGINE_SCHEMES_EVENT), + &[BridgeContextRequirement::Ability] + ); + assert_eq!( + plugin.required_contexts_for_main_thread_event(BEFORE_ENGINE_INIT_EVENT), + &[BridgeContextRequirement::Ability] + ); + assert_eq!( + plugin.required_contexts_for_main_thread_event(ENGINE_INITIALIZED_EVENT), + &[BridgeContextRequirement::Ability] + ); + assert_eq!( + plugin.required_contexts_for_main_thread_event(CONTROLLER_ATTACHED_EVENT), + &[BridgeContextRequirement::UiContext] + ); + } } diff --git a/crates/plugin-webview/src/protocol.rs b/crates/plugin-webview/src/protocol.rs index 119ef6c3..c2acbe2f 100644 --- a/crates/plugin-webview/src/protocol.rs +++ b/crates/plugin-webview/src/protocol.rs @@ -30,15 +30,18 @@ struct ProtocolDeclaration { #[derive(Default)] struct ProtocolState { schemes: BTreeMap, + sealed: bool, flushed: bool, engine_initialized: bool, - /// Rust-owned declarations survive a controller remove/create cycle for the same WebView tag. + /// Rust-owned declarations survive a controller remove/create cycle for the same WebView ID. declarations: BTreeMap>, /// A controller-attached event is the earliest point where ArkWeb guarantees that a /// BrowserContext exists for a concrete Web component. - attached_webviews: BTreeSet, - /// Per-controller installation bookkeeping prevents a concurrent declaration and - /// controller-attached callback from registering the same handler twice. + /// Business WebView ID -> process-unique ArkWeb controller tag. + attached_webviews: BTreeMap, + /// Per-native-tag installation bookkeeping prevents a concurrent declaration and + /// controller-attached callback from registering the same handler twice, without allowing a + /// stale controller completion to mark its replacement as installed. installing_schemes: BTreeMap>, installed_schemes: BTreeMap>, } @@ -64,24 +67,8 @@ impl WebviewProtocol { let mut state = PROTOCOL_STATE .lock() .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; - if state.engine_initialized { - return Err(Error::from_reason(format!( - "WebView scheme '{scheme}' must be registered before Web engine initialization" - ))); - } - if state.flushed { - return Err(Error::from_reason(format!( - "WebView scheme '{scheme}' must be registered before WebviewBridgePlugin begins Web engine initialization" - ))); - } - let options_bits = options.bits(); - if let Some(existing) = state.schemes.get(scheme) { - if *existing != options_bits { - return Err(Error::from_reason(format!( - "WebView scheme '{scheme}' was already registered with different options" - ))); - } + if !scheme_registration_needed(&state, scheme, options_bits)? { return Ok(()); } @@ -97,24 +84,59 @@ impl WebviewProtocol { if state.engine_initialized || state.flushed { return Ok(()); } + if !state.sealed { + return Err(Error::from_reason( + "WebView scheme declarations must be sealed before platform registration", + )); + } CustomProtocol::register(); state.flushed = true; Ok(()) } - pub(crate) fn mark_engine_initialized() -> Result<()> { + pub(crate) fn seal_before_engine_init() -> Result<()> { let mut state = PROTOCOL_STATE .lock() .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; - if !state.flushed { - return Err(Error::from_reason( - "WebView engine initialized before custom scheme declarations were flushed", - )); + state.sealed = true; + Ok(()) + } + + pub(crate) fn validate_process_schemes(registered_schemes: &[(String, u32)]) -> Result<()> { + let state = PROTOCOL_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; + ensure_schemes_registered(&state, registered_schemes) + } + + pub(crate) fn mark_engine_initialized(registered_schemes: &[(String, u32)]) -> Result<()> { + let mut state = PROTOCOL_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; + ensure_schemes_registered(&state, registered_schemes)?; + if state.engine_initialized { + return Ok(()); } + // A native module can be activated after another module initialized ArkWeb. Matching + // schemes are already registered process-wide, so this module joins without calling the + // pre-init platform API again. New or conflicting schemes were rejected above. + state.sealed = true; + state.flushed = true; state.engine_initialized = true; Ok(()) } + pub(crate) fn declared_schemes() -> Result> { + let state = PROTOCOL_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; + Ok(state + .schemes + .iter() + .map(|(scheme, options)| (scheme.clone(), *options)) + .collect()) + } + fn require_declared(scheme: &str) -> Result<()> { validate_scheme(scheme)?; let state = PROTOCOL_STATE @@ -129,6 +151,54 @@ impl WebviewProtocol { } } +fn ensure_schemes_registered( + state: &ProtocolState, + registered_schemes: &[(String, u32)], +) -> Result<()> { + for (scheme, options) in &state.schemes { + if registered_schemes + .iter() + .any(|(registered, registered_options)| { + registered == scheme && registered_options == options + }) + { + continue; + } + return Err(Error::from_reason(format!( + "WebView scheme '{scheme}' from this native module was not registered with matching options before the process-global engine initialized" + ))); + } + Ok(()) +} + +fn scheme_registration_needed( + state: &ProtocolState, + scheme: &str, + options_bits: u32, +) -> Result { + if let Some(existing) = state.schemes.get(scheme) { + if *existing != options_bits { + return Err(Error::from_reason(format!( + "WebView scheme '{scheme}' was already registered with different options" + ))); + } + // Native module statics survive Ability recreation. Repeating the same declaration is a + // no-op even after the process-global engine has started. + return Ok(false); + } + if state.engine_initialized { + return Err(Error::from_reason(format!( + "WebView scheme '{scheme}' must be registered before Web engine initialization" + ))); + } + if state.sealed || state.flushed { + return Err(Error::from_reason(format!( + "WebView scheme '{scheme}' must be registered before WebviewBridgePlugin begins Web engine initialization" + ))); + } + Ok(true) +} + /// An HTTP-style request delivered for a custom WebView scheme. pub type WebviewProtocolRequest = NativeRequest; @@ -152,7 +222,7 @@ impl WebviewProtocolResponder { } } -/// Declares a custom-scheme handler for a WebView tag. +/// Declares a custom-scheme handler for a module-local WebView ID. /// /// The declaration may be made before the ArkTS node exists. The handler is attached only when /// the Web component reports `controller-attached`, after ArkWeb has created its BrowserContext @@ -213,20 +283,24 @@ where true } }; - if is_new_declaration - && state.attached_webviews.contains(&webview_id) - && reserve_installation(&mut state, &webview_id, &scheme) - { - Some(declaration) - } else { - // Declarations are persistent. Treat a retry as idempotent so an application can - // retry a failed create without replacing a closure that an existing controller uses. - None + let native_tag = state.attached_webviews.get(&webview_id).cloned(); + match native_tag { + Some(native_tag) + if is_new_declaration && reserve_installation(&mut state, &native_tag, &scheme) => + { + Some((declaration, native_tag)) + } + _ => { + // Declarations are persistent. Treat a retry as idempotent so an application can + // retry a failed create without replacing a closure that an existing controller + // uses. + None + } } }; - if let Some(declaration) = declaration_to_install { - install_and_record(&webview_id, declaration)?; + if let Some((declaration, native_tag)) = declaration_to_install { + install_and_record(&webview_id, &native_tag, declaration)?; } Ok(()) } @@ -236,8 +310,9 @@ where /// This is called from the scoped named N-API `controller-attached` event. It must complete before /// ArkTS starts the initial load so a custom-scheme document and every first-page subresource are /// handled by native Rust code. -pub(crate) fn on_controller_attached(webview_id: &str) -> Result<()> { +pub(crate) fn on_controller_attached(webview_id: &str, native_tag: &str) -> Result<()> { validate_webview_id(webview_id)?; + validate_webview_id(native_tag)?; let declarations = { let mut state = PROTOCOL_STATE .lock() @@ -247,7 +322,13 @@ pub(crate) fn on_controller_attached(webview_id: &str) -> Result<()> { "WebView custom protocol handler cannot attach before Web engine initialization", )); } - state.attached_webviews.insert(webview_id.to_owned()); + let previous_tag = state + .attached_webviews + .insert(webview_id.to_owned(), native_tag.to_owned()); + if let Some(previous_tag) = previous_tag.filter(|previous| previous != native_tag) { + state.installing_schemes.remove(&previous_tag); + state.installed_schemes.remove(&previous_tag); + } let declared = state .declarations .get(webview_id) @@ -255,84 +336,109 @@ pub(crate) fn on_controller_attached(webview_id: &str) -> Result<()> { .unwrap_or_default(); declared .into_iter() - .filter(|declaration| reserve_installation(&mut state, webview_id, &declaration.scheme)) + .filter(|declaration| reserve_installation(&mut state, native_tag, &declaration.scheme)) .collect::>() }; for declaration in declarations { - install_and_record(webview_id, declaration)?; + install_and_record(webview_id, native_tag, declaration)?; } Ok(()) } /// Marks a controller detached while retaining declarations for a future controller with the same -/// WebView tag. -pub(crate) fn on_controller_removed(webview_id: &str) -> Result<()> { +/// WebView ID and matching native tag. +pub(crate) fn on_controller_removed(webview_id: &str, native_tag: &str) -> Result<()> { let mut state = PROTOCOL_STATE .lock() .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; + if state.attached_webviews.get(webview_id).map(String::as_str) != Some(native_tag) { + return Ok(()); + } state.attached_webviews.remove(webview_id); - state.installing_schemes.remove(webview_id); - state.installed_schemes.remove(webview_id); + state.installing_schemes.remove(native_tag); + state.installed_schemes.remove(native_tag); + Ok(()) +} + +/// Clears controller-generation state at component/session teardown while retaining declarations +/// and the process-global engine/scheme state for a later appearance. +pub(crate) fn clear_attached() -> Result<()> { + let mut state = PROTOCOL_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to clear WebView protocol controller state"))?; + state.attached_webviews.clear(); + state.installing_schemes.clear(); + state.installed_schemes.clear(); Ok(()) } -fn reserve_installation(state: &mut ProtocolState, webview_id: &str, scheme: &str) -> bool { +fn reserve_installation(state: &mut ProtocolState, native_tag: &str, scheme: &str) -> bool { if state .installed_schemes - .get(webview_id) + .get(native_tag) .is_some_and(|schemes| schemes.contains(scheme)) || state .installing_schemes - .get(webview_id) + .get(native_tag) .is_some_and(|schemes| schemes.contains(scheme)) { return false; } state .installing_schemes - .entry(webview_id.to_owned()) + .entry(native_tag.to_owned()) .or_default() .insert(scheme.to_owned()) } -fn install_and_record(webview_id: &str, declaration: ProtocolDeclaration) -> Result<()> { +fn install_and_record( + webview_id: &str, + native_tag: &str, + declaration: ProtocolDeclaration, +) -> Result<()> { let scheme = declaration.scheme.clone(); - match install_declaration(webview_id, declaration) { - Ok(()) => finish_installation(webview_id, &scheme, true), + match install_declaration(native_tag, declaration) { + Ok(()) => finish_installation(webview_id, native_tag, &scheme, true), Err(error) => { - let _ = finish_installation(webview_id, &scheme, false); + let _ = finish_installation(webview_id, native_tag, &scheme, false); Err(error) } } } -fn finish_installation(webview_id: &str, scheme: &str, installed: bool) -> Result<()> { +fn finish_installation( + webview_id: &str, + native_tag: &str, + scheme: &str, + installed: bool, +) -> Result<()> { let mut state = PROTOCOL_STATE .lock() .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; let remove_installing_entry = state .installing_schemes - .get_mut(webview_id) + .get_mut(native_tag) .map(|schemes| { schemes.remove(scheme); schemes.is_empty() }) .unwrap_or(false); if remove_installing_entry { - state.installing_schemes.remove(webview_id); + state.installing_schemes.remove(native_tag); } - if installed { + if installed && state.attached_webviews.get(webview_id).map(String::as_str) == Some(native_tag) + { state .installed_schemes - .entry(webview_id.to_owned()) + .entry(native_tag.to_owned()) .or_default() .insert(scheme.to_owned()); } Ok(()) } -fn install_declaration(webview_id: &str, declaration: ProtocolDeclaration) -> Result<()> { +fn install_declaration(native_tag: &str, declaration: ProtocolDeclaration) -> Result<()> { let ProtocolDeclaration { scheme, callback } = declaration; let handler = CustomProtocolHandler::new(); handler.on_request_start(move |request, request_handle| { @@ -364,7 +470,7 @@ fn install_declaration(webview_id: &str, declaration: ProtocolDeclaration) -> Re true }); - let attached = Web::new(webview_id.to_owned()) + let attached = Web::new(native_tag.to_owned()) .custom_protocol(scheme, handler) .map_err(|error| { Error::from_reason(format!("Failed to bind WebView custom protocol: {error}")) @@ -449,7 +555,10 @@ fn validate_webview_id(webview_id: &str) -> Result<()> { #[cfg(test)] mod tests { - use super::{reserve_installation, validate_scheme, validate_webview_id, ProtocolState}; + use super::{ + ensure_schemes_registered, reserve_installation, scheme_registration_needed, + validate_scheme, validate_webview_id, ProtocolState, + }; #[test] fn protocol_declarations_validate_scheme_and_webview_tag_before_arkweb() { @@ -463,15 +572,38 @@ mod tests { #[test] fn per_controller_installation_is_reserved_only_once() { let mut state = ProtocolState::default(); - assert!(reserve_installation(&mut state, "article", "asset")); - assert!(!reserve_installation(&mut state, "article", "asset")); + assert!(reserve_installation(&mut state, "native-tag-a", "asset")); + assert!(!reserve_installation(&mut state, "native-tag-a", "asset")); + assert!(reserve_installation(&mut state, "native-tag-b", "asset")); state.installing_schemes.clear(); state .installed_schemes - .entry("article".to_owned()) + .entry("native-tag-a".to_owned()) .or_default() .insert("asset".to_owned()); - assert!(!reserve_installation(&mut state, "article", "asset")); + assert!(!reserve_installation(&mut state, "native-tag-a", "asset")); + } + + #[test] + fn identical_scheme_registration_is_idempotent_after_engine_initialization() { + let mut state = ProtocolState::default(); + state.schemes.insert("asset".to_owned(), 7); + state.flushed = true; + state.engine_initialized = true; + + assert!(!scheme_registration_needed(&state, "asset", 7).unwrap()); + assert!(scheme_registration_needed(&state, "asset", 8).is_err()); + assert!(scheme_registration_needed(&state, "late", 7).is_err()); + } + + #[test] + fn late_module_can_join_only_with_process_registered_schemes() { + let mut state = ProtocolState::default(); + state.schemes.insert("asset".to_owned(), 7); + + assert!(ensure_schemes_registered(&state, &[("asset".to_owned(), 7)]).is_ok()); + assert!(ensure_schemes_registered(&state, &[("asset".to_owned(), 8)]).is_err()); + assert!(ensure_schemes_registered(&state, &[]).is_err()); } } diff --git a/crates/plugin-window/src/lib.rs b/crates/plugin-window/src/lib.rs index 0f6e629b..41f57633 100644 --- a/crates/plugin-window/src/lib.rs +++ b/crates/plugin-window/src/lib.rs @@ -13,9 +13,9 @@ impl BridgePlugin for WindowBridgePlugin { type Mode = MainThreadSyncBridge; const ID: &'static str = "ohos.window"; - const VERSION: u32 = 1; + const VERSION: u32 = 2; const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = - &[BridgeContextRequirement::WindowStage]; + &[BridgeContextRequirement::UiContext]; } #[napi(object)] @@ -98,8 +98,19 @@ impl WindowExt for OpenHarmonyApp { #[cfg(test)] mod tests { - use super::{AvoidAreaRequest, AvoidAreaResponse, RawAvoidArea, RawRect}; - use openharmony_ability::{AvoidArea, BridgeNapiType, Rect}; + use super::{AvoidAreaRequest, AvoidAreaResponse, RawAvoidArea, RawRect, WindowBridgePlugin}; + use openharmony_ability::{ + AvoidArea, BridgeContextRequirement, BridgeNapiType, BridgePlugin, Rect, + }; + + #[test] + fn window_plugin_targets_the_component_window() { + assert_eq!(WindowBridgePlugin::VERSION, 2); + assert_eq!( + WindowBridgePlugin::REQUIRED_CONTEXTS, + &[BridgeContextRequirement::UiContext] + ); + } #[test] fn avoid_area_uses_stable_named_napi_contracts() { diff --git a/demo/entry/oh-package-lock.json5 b/demo/entry/oh-package-lock.json5 index 951f5251..e3c86a2e 100644 --- a/demo/entry/oh-package-lock.json5 +++ b/demo/entry/oh-package-lock.json5 @@ -15,6 +15,7 @@ "@ohos-rs/ability-plugin-window@../../plugins/window": "@ohos-rs/ability-plugin-window@../../plugins/window", "@ohos-rs/ability@../../native_ability": "@ohos-rs/ability@../../native_ability", "libdemo_native.so@src/main/cpp/types/libdemo_native": "libdemo_native.so@src/main/cpp/types/libdemo_native", + "libdemo_sub_native.so@src/main/cpp/types/libdemo_sub_native": "libdemo_sub_native.so@src/main/cpp/types/libdemo_sub_native", "libentry.so@src/main/cpp/types/libentry": "libentry.so@src/main/cpp/types/libentry" }, "packages": { @@ -93,6 +94,12 @@ "resolved": "src/main/cpp/types/libdemo_native", "registryType": "local" }, + "libdemo_sub_native.so@src/main/cpp/types/libdemo_sub_native": { + "name": "libdemo_sub_native.so", + "version": "1.0.0", + "resolved": "src/main/cpp/types/libdemo_sub_native", + "registryType": "local" + }, "libentry.so@src/main/cpp/types/libentry": { "name": "libentry.so", "version": "1.0.0", diff --git a/demo/entry/oh-package.json5 b/demo/entry/oh-package.json5 index efd13272..3387d532 100644 --- a/demo/entry/oh-package.json5 +++ b/demo/entry/oh-package.json5 @@ -8,6 +8,7 @@ "dependencies": { "libentry.so": "file:./src/main/cpp/types/libentry", "libdemo_native.so": "file:./src/main/cpp/types/libdemo_native", + "libdemo_sub_native.so": "file:./src/main/cpp/types/libdemo_sub_native", "@ohos-rs/ability": "file:../../native_ability", "@ohos-rs/ability-plugin-app-control": "file:../../plugins/app-control", "@ohos-rs/ability-plugin-permission": "file:../../plugins/permission", diff --git a/demo/entry/src/main/cpp/types/libdemo_native/Index.d.ts b/demo/entry/src/main/cpp/types/libdemo_native/Index.d.ts index b83a7943..5bffbf2d 100644 --- a/demo/entry/src/main/cpp/types/libdemo_native/Index.d.ts +++ b/demo/entry/src/main/cpp/types/libdemo_native/Index.d.ts @@ -4,6 +4,7 @@ export interface PermissionRequestPayload { permissions: Array; } + export interface PermissionResponsePayload { codes: Array; } @@ -28,6 +29,11 @@ export interface WebviewCallbackOptions { /** Controller lifecycle signal delivered directly from the ArkTS WebView host. */ export interface WebviewControllerEvent { id: string; + /** + * Process-unique ArkWeb controller tag generated by the ArkTS host. The public WebView ID + * remains module-local and is never used as a process-global platform key. + */ + nativeTag: string; } export interface WebviewControllerRequest { @@ -43,13 +49,9 @@ export interface WebviewControllerRequest { export interface WebviewCreateRequest { id: string; /** - * Optional window surface key the WebView mounts into. Defaults to "main" (the default - * window's DefaultXComponent); sub-window instances register under their own windowKey. - */ - windowKey?: string; - /** - * Optional opaque container handle issued by the built-in ohos.node plugin. When provided the - * WebView FrameNode is appended under that container instead of the window root. + * Optional opaque container handle issued by the built-in `ohos.node` plugin. When provided, + * the ArkTS host appends the WebView FrameNode under that container instead of this native + * module's DefaultXComponent root. */ parentHandle?: number; url?: string; @@ -76,6 +78,8 @@ export interface WebviewCreateResponse { /** Completion notification delivered directly through a named N-API callback. */ export interface WebviewDownloadEndEvent { id: string; + /** Process-unique controller generation used to reject callbacks from a replaced WebView. */ + nativeTag: string; url: string; tempPath?: string; success: boolean; @@ -84,6 +88,8 @@ export interface WebviewDownloadEndEvent { /** Request delivered synchronously before ArkWeb starts a download. */ export interface WebviewDownloadStartRequest { id: string; + /** Process-unique controller generation used to reject callbacks from a replaced WebView. */ + nativeTag: string; url: string; tempPath?: string; } @@ -97,6 +103,17 @@ export interface WebviewDownloadStartResponse { /** Engine lifecycle signal delivered directly from the ArkTS WebView host. */ export interface WebviewEngineLifecycleEvent { phase: string; + /** + * Process-global scheme set sealed before ArkWeb initialization. A module activated after + * the engine started may join only when every local declaration already exists in this set + * with identical options. + */ + schemes: Array; +} + +export interface WebviewEngineLifecycleResponse { + accepted: boolean; + schemes: Array; } /** Response used by one-way named N-API notifications sent directly from ArkTS. */ @@ -113,6 +130,8 @@ export interface WebviewInitializationScript { /** Request delivered synchronously when ArkWeb asks whether a navigation should be intercepted. */ export interface WebviewNavigationRequest { id: string; + /** Process-unique controller generation used to reject callbacks from a replaced WebView. */ + nativeTag: string; url: string; } @@ -121,6 +140,11 @@ export interface WebviewNavigationResponse { intercept: boolean; } +export interface WebviewSchemeDeclaration { + scheme: string; + options: number; +} + export interface WebviewScriptRequest { id: string; script: string; @@ -137,9 +161,16 @@ export interface WebviewStringResponse { export interface WebviewStyle { x?: number | string; y?: number | string; - /** Optional width override; defaults to the full container. Numbers are vp, strings are ArkUI lengths. */ + /** + * Optional width override. Defaults to the full container size; numbers are vp, strings are + * ArkUI length expressions (for example "30%"). + */ width?: number | string; - /** Optional height override; defaults to the full container. Numbers are vp, strings are ArkUI lengths. */ + /** + * Optional height override. Defaults to the full container size; numbers are vp, strings are + * ArkUI length expressions. Combined with `y` (for example y = "70%", height = "30%") a + * WebView can be rendered in a corner or along one edge instead of full-screen. + */ height?: number | string; visible?: boolean; backgroundColor?: string; @@ -148,6 +179,8 @@ export interface WebviewStyle { /** Title-change notification delivered directly through a named N-API callback. */ export interface WebviewTitleChangeEvent { id: string; + /** Process-unique controller generation used to reject callbacks from a replaced WebView. */ + nativeTag: string; title: string; } @@ -189,29 +222,34 @@ export interface MainThreadInspectResponse { } /** - * Creates a WebView through the WebView plugin. The ArkTS plugin mounts the WebView FrameNode - * into the session root (full-bleed default). + * Renders a WebView pinned to the bottom edge of the session surface instead of full-screen: + * `y = "70%"` + `height = "30%"` keeps the full-bleed default intact for WebViews without an + * explicit style. This proves the normalized model gives the caller full layout control. */ -export declare function createDemoWebview(): Promise; +export declare function createBottomDemoWebview(): Promise; /** - * Creates a WebView under an ohos.node container and mounts the container tree into the session - * root, demonstrating the normalized composition model (Rust composes the node tree by handle). + * Demonstrates the normalized composition model: an RS-layer container node is created through + * the built-in ohos.node plugin, the WebView FrameNode is attached under it via + * `parent_node(...)`, and the whole tree is mounted into the module/component root. */ export declare function createComposedDemoWebview(): Promise; /** - * Creates a WebView pinned to the bottom edge (style y = "70%", height = "30%") instead of - * full-screen, proving the caller owns WebView layout in the normalized model. + * Creates a WebView through the WebView plugin. Without a parent container handle the ArkTS + * host mounts the WebView FrameNode into this module's component root (full-bleed default); it never touches + * DefaultXComponent internals. */ -export declare function createBottomDemoWebview(): Promise; +export declare function createDemoWebview(): Promise; -/** - * Creates a WebView inside the sub-window surface (windowKey = "sub"). The sub window page - * places a second DefaultXComponent({ windowKey: "sub" }) and this WebView mounts into that - * window's own node tree, proving per-window plugin surfaces. - */ -export declare function createSubWindowWebview(): Promise; +/** PR #65 capability demo: open-file dialog through `ohos.files`. */ +export declare function demoFileDialogOpen(): Promise>; + +/** PR #65 capability demo: save-file dialog through `ohos.files`. */ +export declare function demoFileDialogSave(): Promise>; + +/** PR #65 capability demo: open an external URL through `ohos.url`. */ +export declare function demoOpenUrl(): Promise; /** Bytes travel through the bridge as a Uint8Array, not a JSON number array or Base64 string. */ export declare function demoPluginBytes(): Promise>; @@ -232,34 +270,35 @@ export declare function demoPluginString(): Promise; export declare function demoPluginSyncContext(): string; /** - * Worker -> TSFN -> ArkTS sync plugin. The same `demo.main-thread` plugin is invoked from a - * Rust worker; execution still happens on the ArkTS main thread and the named response is - * marshalled back over TSFN. + * Worker -> TSFN -> ArkTS sync plugin -> Rust future. The same `demo.main-thread` plugin is + * invoked from a Rust worker; execution still happens on the ArkTS main thread and the named + * response is marshalled back over TSFN. */ export declare function demoPluginSyncFromWorker(): Promise; -/** PR #65: opens an external URL through `ohos.url` / `context.openLink`. */ -export declare function demoOpenUrl(): Promise; - -/** PR #65: open-file dialog through `ohos.files` (multi-select + text/image filters). */ -export declare function demoFileDialogOpen(): Promise>; - -/** PR #65: save-file dialog through `ohos.files` with a PDF filter. */ -export declare function demoFileDialogSave(): Promise>; +export declare function demoRequestPermissionFromMainThread(): Promise>; /** - * `ohos.resource` plugin demo: whether the ArkTS wrapper has pushed the native resource manager - * on `ability-create` (installed through the inbound `resource-manager-ready` event). + * Demo: reports whether the `ohos.resource` wrapper has pushed the native resource manager + * (it is installed from the Ability-scoped ArkTS `onInstall`, before UI rendering is required). */ export declare function demoResourceManagerReady(): boolean; /** - * `ohos.resource` plugin demo: top-level raw file directory entry count read through the native - * resource manager, or -1 before the wrapper has pushed. + * Demo: reads the top-level raw file directory through the native resource manager. Returns + * the number of entries, or -1 when the `ohos.resource` plugin has not installed yet. */ export declare function demoResourceRawDirCount(): number; -export declare function demoRequestPermissionFromMainThread(): Promise>; +export declare function disposeAllRenders(): void; + +/** + * r" Releases the Ability-session transport without touching this module's independent + * r" DefaultXComponent render owner. Stale owners are ignored. + */ +export declare function disposeBridge(bridgeOwner: string): void; + +export declare function disposeRender(renderOwner: string): void; /** * Proves the Rust → WebView JavaScript path. The bridge waits for `onControllerAttached` before @@ -267,7 +306,11 @@ export declare function demoRequestPermissionFromMainThread(): Promise; -export declare function init(context?: AbilityInitContext): ApplicationLifecycle; +export declare function init( + bindings: object, + bridgeOwner: string, + context?: AbilityInitContext, +): ApplicationLifecycle; export declare function onBackPressIntercept(): boolean; @@ -289,11 +332,7 @@ export declare function onBridgeSyncEvent( value: unknown, ): unknown; -export declare function render(bindings: object, slot: NodeContent, renderOwner: string): void; - -export declare function disposeRender(renderOwner: string): void; - -export declare function disposeAllRenders(): void; +export declare function render(slot: NodeContent, renderOwner: string): void; export declare function setBackgroundColor(color: string): Promise; @@ -301,6 +340,42 @@ export declare function setVisible(visible: boolean): Promise; export declare function toggleBackPressIntercept(): boolean; +export interface UrlOpenRequest { + url: string; +} + +export interface UrlOpenResponse { + accepted: boolean; +} + +/** + * One suffix filter group: a display name plus `;`-separated suffixes (e.g. `"md"`). + * The pattern stays structured; the ArkTS plugin converts it to the picker grammar. + */ +export interface FileDialogFilter { + name?: string; + pattern?: string; +} + +export interface FileDialogOptions { + /** One of [`dialog_type`] constants. */ + dialogType: string; + allowMany: boolean; + defaultLocation?: string; + filters: Array; +} + +export interface FileDialogResponse { + /** Selected file URIs. */ + files: Array; + /** Selected filter index, or -1 when the platform does not report one. */ + filter: number; +} + +export interface ResourceManagerReadyResponse { + accepted: boolean; +} + export interface AbilityInitContext { basePath?: string; prefPath?: string; @@ -323,6 +398,34 @@ export interface KeyboardCallback { onKeyboardHeightChange: (arg: number) => void; } +export interface NodeAcknowledgement { + accepted: boolean; +} + +/** Appends the node of `child_handle` under the node of `parent_handle`. */ +export interface NodeAppendChildRequest { + parentHandle: number; + childHandle: number; +} + +/** Request marker for `create-container`: the response carries the new handle. */ +export interface NodeCreateContainerRequest {} + +/** Detaches a handle-owned node from its parent and disposes it. */ +export interface NodeDisposeRequest { + handle: number; +} + +/** Opaque handle of a container `FrameNode` created in ArkTS. */ +export interface NodeHandleResponse { + handle: number; +} + +/** Appends a handle-owned node to this module's component root. */ +export interface NodeMountIntoRootRequest { + handle: number; +} + export interface WindowStageEventCallback { onWindowStageCreate: () => void; onWindowStageDestroy: () => void; @@ -335,3 +438,34 @@ export interface WindowStageEventCallback { onWindowRectChange: (arg: object) => void; onAvoidAreaChange: (arg: object) => void; } + +export interface AvoidAreaRequest { + areaType: number; +} + +export interface AvoidAreaResponse { + area: RawAvoidArea; +} + +export interface RawAvoidArea { + visible: boolean; + leftRect: RawRect; + topRect: RawRect; + rightRect: RawRect; + bottomRect: RawRect; +} + +export interface RawRect { + top: number; + left: number; + width: number; + height: number; +} + +export interface TerminateRequest { + code: number; +} + +export interface TerminateResponse { + accepted: boolean; +} diff --git a/demo/entry/src/main/cpp/types/libdemo_sub_native/Index.d.ts b/demo/entry/src/main/cpp/types/libdemo_sub_native/Index.d.ts new file mode 100644 index 00000000..685fb876 --- /dev/null +++ b/demo/entry/src/main/cpp/types/libdemo_sub_native/Index.d.ts @@ -0,0 +1,306 @@ +/* auto-generated by OHOS-RS */ +/* eslint-disable */ + +export interface WebviewAcknowledgement { + accepted: boolean; +} + +/** + * Event subscriptions derived from Rust callback declarations before a WebView is created. + * + * This is transport state, not an ArkTS callback reference. The ArkTS host uses it only to bind + * the corresponding ArkWeb delegate/event hooks for the new controller. + */ +export interface WebviewCallbackOptions { + navigationIntercept: boolean; + downloadStart: boolean; + downloadEnd: boolean; + titleChange: boolean; +} + +/** Controller lifecycle signal delivered directly from the ArkTS WebView host. */ +export interface WebviewControllerEvent { + id: string; + /** + * Process-unique ArkWeb controller tag generated by the ArkTS host. The public WebView ID + * remains module-local and is never used as a process-global platform key. + */ + nativeTag: string; +} + +export interface WebviewControllerRequest { + id: string; + visible?: boolean; + color?: string; + url?: string; + html?: string; + headers?: Record; + zoom?: number; +} + +export interface WebviewCreateRequest { + id: string; + /** + * Optional opaque container handle issued by the built-in `ohos.node` plugin. When provided, + * the ArkTS host appends the WebView FrameNode under that container instead of this native + * module's DefaultXComponent root. + */ + parentHandle?: number; + url?: string; + html?: string; + style: WebviewStyle; + javascriptEnabled?: boolean; + devtools?: boolean; + userAgent?: string; + autoplay?: boolean; + initializationScripts?: Array; + headers?: Record; + /** + * Restores the legacy creation-time transparent-background policy. An explicit style + * background color takes precedence. + */ + transparent?: boolean; + eventOptions: WebviewCallbackOptions; +} + +export interface WebviewCreateResponse { + id: string; +} + +/** Completion notification delivered directly through a named N-API callback. */ +export interface WebviewDownloadEndEvent { + id: string; + /** Process-unique controller generation used to reject callbacks from a replaced WebView. */ + nativeTag: string; + url: string; + tempPath?: string; + success: boolean; +} + +/** Request delivered synchronously before ArkWeb starts a download. */ +export interface WebviewDownloadStartRequest { + id: string; + /** Process-unique controller generation used to reject callbacks from a replaced WebView. */ + nativeTag: string; + url: string; + tempPath?: string; +} + +/** Immediate download admission and optional replacement destination. */ +export interface WebviewDownloadStartResponse { + allow: boolean; + tempPath?: string; +} + +/** Engine lifecycle signal delivered directly from the ArkTS WebView host. */ +export interface WebviewEngineLifecycleEvent { + phase: string; + /** + * Process-global scheme set sealed before ArkWeb initialization. A module activated after + * the engine started may join only when every local declaration already exists in this set + * with identical options. + */ + schemes: Array; +} + +export interface WebviewEngineLifecycleResponse { + accepted: boolean; + schemes: Array; +} + +/** Response used by one-way named N-API notifications sent directly from ArkTS. */ +export interface WebviewEventAcknowledgement { + accepted: boolean; +} + +/** A document-start script and the URL rules for pages where ArkWeb may inject it. */ +export interface WebviewInitializationScript { + script: string; + scriptRules: Array; +} + +/** Request delivered synchronously when ArkWeb asks whether a navigation should be intercepted. */ +export interface WebviewNavigationRequest { + id: string; + /** Process-unique controller generation used to reject callbacks from a replaced WebView. */ + nativeTag: string; + url: string; +} + +/** Synchronous navigation decision. A true value retains ArkWeb's existing intercept semantics. */ +export interface WebviewNavigationResponse { + intercept: boolean; +} + +export interface WebviewSchemeDeclaration { + scheme: string; + options: number; +} + +export interface WebviewScriptRequest { + id: string; + script: string; +} + +export interface WebviewScriptResponse { + result?: string; +} + +export interface WebviewStringResponse { + value?: string; +} + +export interface WebviewStyle { + x?: number | string; + y?: number | string; + /** + * Optional width override. Defaults to the full container size; numbers are vp, strings are + * ArkUI length expressions (for example "30%"). + */ + width?: number | string; + /** + * Optional height override. Defaults to the full container size; numbers are vp, strings are + * ArkUI length expressions. Combined with `y` (for example y = "70%", height = "30%") a + * WebView can be rendered in a corner or along one edge instead of full-screen. + */ + height?: number | string; + visible?: boolean; + backgroundColor?: string; +} + +/** Title-change notification delivered directly through a named N-API callback. */ +export interface WebviewTitleChangeEvent { + id: string; + /** Process-unique controller generation used to reject callbacks from a replaced WebView. */ + nativeTag: string; + title: string; +} + +export interface AbilityInitContext { + basePath?: string; + prefPath?: string; + preferredLocales?: string; + moduleName?: string; +} + +export interface ApplicationLifecycle { + environmentCallback: EnvironmentCallback; + windowStageEventCallback: WindowStageEventCallback; + keyboardEventCallback: KeyboardCallback; +} + +export interface EnvironmentCallback { + onConfigurationUpdated: () => void; + onMemoryLevel: (arg: number) => void; +} + +export interface KeyboardCallback { + onKeyboardHeightChange: (arg: number) => void; +} + +export interface NodeAcknowledgement { + accepted: boolean; +} + +/** Appends the node of `child_handle` under the node of `parent_handle`. */ +export interface NodeAppendChildRequest { + parentHandle: number; + childHandle: number; +} + +/** Request marker for `create-container`: the response carries the new handle. */ +export interface NodeCreateContainerRequest {} + +/** Detaches a handle-owned node from its parent and disposes it. */ +export interface NodeDisposeRequest { + handle: number; +} + +/** Opaque handle of a container `FrameNode` created in ArkTS. */ +export interface NodeHandleResponse { + handle: number; +} + +/** Appends a handle-owned node to this module's component root. */ +export interface NodeMountIntoRootRequest { + handle: number; +} + +export interface WindowStageEventCallback { + onWindowStageCreate: () => void; + onWindowStageDestroy: () => void; + onAbilityCreate: (arg: string) => void; + onAbilityDestroy: () => void; + onAbilitySaveState: () => void; + onAbilityRestoreState: () => void; + onWindowStageEvent: (arg: number) => void; + onWindowSizeChange: (arg: object) => void; + onWindowRectChange: (arg: object) => void; + onAvoidAreaChange: (arg: object) => void; +} + +export interface AvoidAreaRequest { + areaType: number; +} + +export interface AvoidAreaResponse { + area: RawAvoidArea; +} + +export interface RawAvoidArea { + visible: boolean; + leftRect: RawRect; + topRect: RawRect; + rightRect: RawRect; + bottomRect: RawRect; +} + +export interface RawRect { + top: number; + left: number; + width: number; + height: number; +} + +export declare function createSubWindowWebview(): Promise; + +export declare function disposeAllRenders(): void; + +/** + * r" Releases the Ability-session transport without touching this module's independent + * r" DefaultXComponent render owner. Stale owners are ignored. + */ +export declare function disposeBridge(bridgeOwner: string): void; + +export declare function disposeRender(renderOwner: string): void; + +export declare function init( + bindings: object, + bridgeOwner: string, + context?: AbilityInitContext, +): ApplicationLifecycle; + +export declare function onBackPressIntercept(): boolean; + +/** r" ArkTS-only lifecycle transitions, currently UI-context readiness transitions. */ +export declare function onBridgeLifecycle(kind: string): void; + +/** + * r" Synchronous ArkTS platform callback -> Rust plugin decision port. + * r" + * r" The N-API value is scoped to this call and the returned value must be produced + * r" before ArkTS resumes the originating platform callback. It is the dedicated + * r" typed inbound event port for ArkTS plugins. + */ +export declare function onBridgeSyncEvent( + pluginId: string, + event: string, + requestTypeName: string, + responseTypeName: string, + value: unknown, +): unknown; + +export declare function render(slot: NodeContent, renderOwner: string): void; + +/** Queries the window that owns this module's DefaultXComponent, not the Ability main window. */ +export declare function subWindowKeyboardInset(): number; diff --git a/demo/entry/src/main/cpp/types/libdemo_sub_native/oh-package.json5 b/demo/entry/src/main/cpp/types/libdemo_sub_native/oh-package.json5 new file mode 100644 index 00000000..7994a008 --- /dev/null +++ b/demo/entry/src/main/cpp/types/libdemo_sub_native/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libdemo_sub_native.so", + "version": "1.0.0", + "description": "Type declarations for the secondary native module demo", + "main": "Index.d.ts", +} diff --git a/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets b/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets index b424eca0..7bf8ade9 100644 --- a/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets +++ b/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets @@ -51,7 +51,7 @@ export class DemoNodePlugin extends AsyncPluginBase { } private mount(context: BridgePluginContext): void { - // The session root is guaranteed to exist before ui-context-ready, so plugins mount their + // This module's component root exists before ui-context-ready, so plugins mount their // nodes directly without slots, registries or readiness waiters. const node = new BuilderNode<[BadgeData]>(context.getUIContext()); node.build(badgeBuilder, { title: "ordinary BuilderNode plugin" }); diff --git a/demo/entry/src/main/ets/entryability/EntryAbility.ets b/demo/entry/src/main/ets/entryability/EntryAbility.ets index 8412f629..a98d535c 100644 --- a/demo/entry/src/main/ets/entryability/EntryAbility.ets +++ b/demo/entry/src/main/ets/entryability/EntryAbility.ets @@ -18,21 +18,26 @@ import { DemoTypedPlugin } from "../bridge/DemoRawPlugin"; /** The main WindowStage, shared with the demo pages so the Index page can open a sub window. */ export let demoWindowStage: window.WindowStage | null = null; +const MAIN_NATIVE_MODULE = "demo_native"; +export const SUB_WINDOW_NATIVE_MODULE = "demo_sub_native"; +const MAIN_ONLY = [MAIN_NATIVE_MODULE]; +const WEBVIEW_MODULES = [MAIN_NATIVE_MODULE, SUB_WINDOW_NATIVE_MODULE]; + export default class EntryAbility extends NativeAbility { - public moduleName: string = "demo_native"; + public moduleName: string[] = WEBVIEW_MODULES; public defaultPage: boolean = false; public bridgePlugins = [ - new LazyPlugin(() => new PermissionPlugin()), - new LazyPlugin(() => new AppControlPlugin()), - new LazyPlugin(() => new FilesPlugin()), - new LazyPlugin(() => new ResourcePlugin()), - new LazyPlugin(() => new WindowPlugin()), - new LazyPlugin(() => new WebviewPlugin()), - new LazyPlugin(() => new UrlPlugin()), - new LazyPlugin(() => new DemoLoginPlugin(new DemoIdentityProvider())), - new LazyPlugin(() => new DemoMainThreadPlugin()), - new LazyPlugin(() => new DemoTypedPlugin()), - new LazyPlugin(() => new DemoNodePlugin()), + new LazyPlugin(() => new PermissionPlugin(), MAIN_ONLY), + new LazyPlugin(() => new AppControlPlugin(), MAIN_ONLY), + new LazyPlugin(() => new FilesPlugin(), MAIN_ONLY), + new LazyPlugin(() => new ResourcePlugin(), MAIN_ONLY), + new LazyPlugin(() => new WindowPlugin(), WEBVIEW_MODULES), + new LazyPlugin(() => new WebviewPlugin(), WEBVIEW_MODULES), + new LazyPlugin(() => new UrlPlugin(), MAIN_ONLY), + new LazyPlugin(() => new DemoLoginPlugin(new DemoIdentityProvider()), MAIN_ONLY), + new LazyPlugin(() => new DemoMainThreadPlugin(), MAIN_ONLY), + new LazyPlugin(() => new DemoTypedPlugin(), MAIN_ONLY), + new LazyPlugin(() => new DemoNodePlugin(), MAIN_ONLY), ]; protected override async loadWindowStageContent(windowStage: window.WindowStage): Promise { @@ -48,7 +53,7 @@ export default class EntryAbility extends NativeAbility { } onWindowStageDestroy(): void { - demoWindowStage = null; super.onWindowStageDestroy(); + demoWindowStage = null; } } diff --git a/demo/entry/src/main/ets/pages/Index.ets b/demo/entry/src/main/ets/pages/Index.ets index 4f6ef0ac..1e1a94a7 100644 --- a/demo/entry/src/main/ets/pages/Index.ets +++ b/demo/entry/src/main/ets/pages/Index.ets @@ -1,6 +1,6 @@ import { DefaultXComponent, invokeNativeBackPressInterceptor } from "@ohos-rs/ability"; -import { demoWindowStage } from "../entryability/EntryAbility"; -import { SUB_WINDOW_KEY, SUB_WINDOW_NAME } from "./SubWindowPage"; +import { demoWindowStage, SUB_WINDOW_NATIVE_MODULE } from "../entryability/EntryAbility"; +import { SUB_WINDOW_NAME } from "./SubWindowPage"; import { createBottomDemoWebview, createComposedDemoWebview, @@ -161,7 +161,7 @@ struct Index { this.webviewStatus = ""; try { await createDemoWebview(); - this.webviewStatus = "✓ WebView mounted into the session root"; + this.webviewStatus = "✓ WebView mounted into demo_native's component root"; } catch (error) { this.webviewStatus = `✗ ${String(error)}`; } finally { @@ -217,8 +217,7 @@ struct Index { ); await subWindow.setUIContent("pages/SubWindowPage"); await subWindow.showWindow(); - this.webviewStatus = - '✓ sub window opened: it owns its own node tree (windowKey="' + SUB_WINDOW_KEY + '")'; + this.webviewStatus = `✓ sub window opened: its DefaultXComponent owns module '${SUB_WINDOW_NATIVE_MODULE}'`; } catch (error) { this.webviewStatus = `✗ ${String(error)}`; } finally { @@ -521,8 +520,8 @@ struct Index { ], }) Stack() { - // Layer order is declaration order: business content below and above the single session - // node tree. The WebView plugin (and any other plugin) mounts its FrameNode into that + // Layer order is declaration order: business content below and above this module's + // component tree. The WebView plugin (and any other plugin) mounts its FrameNode into that // tree; there is no BridgeNodeHost or named slot anymore. this.WebviewUnderlay() DefaultXComponent({ moduleName: MODULE_NAME }) diff --git a/demo/entry/src/main/ets/pages/SubWindowPage.ets b/demo/entry/src/main/ets/pages/SubWindowPage.ets index 0c53f9cb..8610131a 100644 --- a/demo/entry/src/main/ets/pages/SubWindowPage.ets +++ b/demo/entry/src/main/ets/pages/SubWindowPage.ets @@ -1,9 +1,8 @@ import { DefaultXComponent } from "@ohos-rs/ability"; import window from "@ohos.window"; -import { createSubWindowWebview } from "libdemo_native.so"; +import { createSubWindowWebview, subWindowKeyboardInset } from "libdemo_sub_native.so"; +import { SUB_WINDOW_NATIVE_MODULE } from "../entryability/EntryAbility"; -/** Must match the `window_key` used by `create_sub_window_webview` on the Rust side. */ -export const SUB_WINDOW_KEY = "sub"; export const SUB_WINDOW_NAME = "demo_sub_window"; @Entry @@ -25,6 +24,17 @@ struct SubWindowPage { } } + handleQueryAvoidArea() { + this.busy = true; + try { + this.status = `✓ sub-window keyboard bottom inset=${subWindowKeyboardInset()}`; + } catch (error) { + this.status = `✗ avoid-area query failed: ${String(error)}`; + } finally { + this.busy = false; + } + } + async handleClose() { this.busy = true; try { @@ -43,8 +53,8 @@ struct SubWindowPage { .fontSize(18) .fontWeight(FontWeight.Bold) Text( - "This page owns a second DefaultXComponent with a unique windowKey, so its plugin " + - "tree never touches the main window's.", + "This page owns a second DefaultXComponent backed by demo_sub_native, so its plugin " + + "tree and Rust runtime are independent from demo_native.", ) .fontSize(12) .fontColor("#666") @@ -54,6 +64,11 @@ struct SubWindowPage { .onClick(() => { this.handleCreateWebview(); }) + Button("query this window's keyboard inset") + .enabled(!this.busy) + .onClick(() => { + this.handleQueryAvoidArea(); + }) Button("close window") .backgroundColor("#aa3333") .enabled(!this.busy) @@ -64,7 +79,7 @@ struct SubWindowPage { .fontSize(12) .fontColor(this.status.startsWith("✗") ? "#aa3333" : "#227722") .textAlign(TextAlign.Center) - DefaultXComponent({ moduleName: "demo_native", windowKey: SUB_WINDOW_KEY }) + DefaultXComponent({ moduleName: SUB_WINDOW_NATIVE_MODULE }) .width("100%") .layoutWeight(1) .borderRadius(12) diff --git a/native_ability/index.ets b/native_ability/index.ets index b71aa644..6f984453 100644 --- a/native_ability/index.ets +++ b/native_ability/index.ets @@ -5,7 +5,6 @@ export { BridgeAbilityCreateLifecyclePayload, BridgeEmptyLifecyclePayload, BridgeMemoryLevelLifecyclePayload, - BridgeWindowLifecyclePayload, BridgeWindowStageEventLifecyclePayload, PluginBase, SyncPluginBase, @@ -24,7 +23,6 @@ export type { BridgePluginExecution, BridgePluginFactory, BridgePluginHookContext, - BridgeWindowScope, MainThreadSyncBridgePlugin, } from "./src/main/ets/ability/type"; export { DefaultXComponent } from "./src/main/ets/components/DefaultXComponent"; diff --git a/native_ability/src/main/ets/ability/NativeAbility.ets b/native_ability/src/main/ets/ability/NativeAbility.ets index b3776cb5..200c3fab 100644 --- a/native_ability/src/main/ets/ability/NativeAbility.ets +++ b/native_ability/src/main/ets/ability/NativeAbility.ets @@ -9,6 +9,7 @@ import { SerialTaskQueue } from "../runtime/SerialTaskQueue"; import { AbilityInitContext, ApplicationLifecycle, + BridgeBindings, BridgeAbilityCreateLifecyclePayload, BridgeEmptyLifecyclePayload, BridgeLifecycleEvent, @@ -27,6 +28,7 @@ interface LoadedNativeModule { interface NativeModuleRuntime extends LoadedNativeModule { lifecycle: ApplicationLifecycle; + bridgeOwner: string; } export class NativeAbility extends UIAbility { @@ -46,7 +48,6 @@ export class NativeAbility extends UIAbility { private windowStageGeneration: number = 0; private windowStageActive: boolean = false; private observedWindowStage?: window.WindowStage; - private observedMainWindow?: window.Window; private readonly onWindowStageEvent = (event: window.WindowStageEventType): void => { if (!this.acceptingLifecycle || this.observedWindowStage === undefined) { @@ -63,38 +64,6 @@ export class NativeAbility extends UIAbility { }); }; - private readonly onWindowSizeChange = (size: window.Size): void => { - if (this.acceptingLifecycle && this.observedMainWindow !== undefined) { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onWindowSizeChange(size), - ); - } - }; - - private readonly onWindowRectChange = (options: window.RectChangeOptions): void => { - if (this.acceptingLifecycle && this.observedMainWindow !== undefined) { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onWindowRectChange(options), - ); - } - }; - - private readonly onAvoidAreaChange = (options: window.AvoidAreaOptions): void => { - if (this.acceptingLifecycle && this.observedMainWindow !== undefined) { - this.forEachLifecycle((lifecycle) => - lifecycle.windowStageEventCallback.onAvoidAreaChange(options), - ); - } - }; - - private readonly onKeyboardHeightChange = (height: number): void => { - if (this.acceptingLifecycle && this.observedMainWindow !== undefined) { - this.forEachLifecycle((lifecycle) => - lifecycle.keyboardEventCallback.onKeyboardHeightChange(height), - ); - } - }; - protected resolveModuleNames(): string[] { const moduleNames = NativeModuleLoader.resolveModuleNames(this.moduleName); if (moduleNames.length === 0) { @@ -186,6 +155,71 @@ export class NativeAbility extends UIAbility { BridgeHostRegistry.attachEventSink(sessionId, moduleName, mainThreadSink, lifecycleSink); } + /** + * Creates the Ability-session transport for one native module. It deliberately lives here, + * not in DefaultXComponent: ability-only plugins must work before a render surface appears and + * must survive a component disappear/reappear cycle within the same Ability. + */ + private createBridgeBindings(sessionId: string, moduleName: string): BridgeBindings { + return { + bridgeInvoke: async ( + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + timeoutMs: number, + ): Promise => { + return await BridgeHostRegistry.invokeAsync( + sessionId, + moduleName, + pluginId, + pluginVersion, + action, + requestTypeName, + responseTypeName, + value, + timeoutMs, + ); + }, + bridgeInvokeSync: ( + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject => { + return BridgeHostRegistry.invokeSync( + sessionId, + moduleName, + pluginId, + pluginVersion, + action, + requestTypeName, + responseTypeName, + value, + ); + }, + // MainThreadScheduler runs the Rust closure inside this TSFN callback before invoking this + // no-op function. It intentionally carries no component or capability state. + bridgeDispatch: (): void => {}, + }; + } + + private releaseModuleBridges(runtimes: NativeModuleRuntime[]): void { + for (const runtime of runtimes.slice().reverse()) { + try { + runtime.module.disposeBridge(runtime.bridgeOwner); + } catch (error) { + console.error( + `[NativeAbility] failed to release bridge for ${runtime.moduleName}: ${String(error)}`, + ); + } + } + } + protected async notifyBridgeLifecycle(event: BridgeLifecycleEvent): Promise { const sessionId = this.bridgeSessionId; if (!sessionId) { @@ -221,7 +255,24 @@ export class NativeAbility extends UIAbility { const previousSessionId = this.bridgeSessionId; if (previousSessionId) { BridgeHostRegistry.beginClosing(previousSessionId); - await BridgeHostRegistry.dispose(previousSessionId); + try { + await this.destroyWindowStageIfActive(); + await this.notifyBridgeLifecycle({ + kind: "ability-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); + this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onAbilityDestroy()); + } finally { + // A duplicated platform create is not expected, but treating replacement as a complete + // teardown prevents an old component root from surviving into the new bridge session. + for (const runtime of this.moduleRuntimes) { + try { + runtime.module.disposeAllRenders(); + } catch {} + } + this.releaseModuleBridges(this.moduleRuntimes); + await BridgeHostRegistry.dispose(previousSessionId); + } } this.assertInitializationActive(generation); this.bridgeSessionId = ""; @@ -237,7 +288,6 @@ export class NativeAbility extends UIAbility { this.bridgePlugins, ); this.bridgeSessionId = sessionId; - this.updateAppStorage("bridgeSessionId", sessionId); const loadedModules: LoadedNativeModule[] = []; const initializedRuntimes: NativeModuleRuntime[] = []; @@ -251,13 +301,20 @@ export class NativeAbility extends UIAbility { } for (const loaded of loadedModules) { - const lifecycle = loaded.module.init(this.createInitContext(loaded.moduleName)); + const bridgeOwner = `${sessionId}:${loaded.moduleName}`; + const lifecycle = loaded.module.init( + this.createBridgeBindings(sessionId, loaded.moduleName), + bridgeOwner, + this.createInitContext(loaded.moduleName), + ); const runtime: NativeModuleRuntime = { moduleName: loaded.moduleName, module: loaded.module, lifecycle, + bridgeOwner, }; initializedRuntimes.push(runtime); + BridgeHostRegistry.attachApplicationLifecycle(sessionId, loaded.moduleName, lifecycle); this.attachBridgeSinks(sessionId, loaded.moduleName, loaded.module); const restoredState = restoredStateMap[loaded.moduleName] ?? fallbackState; @@ -284,13 +341,19 @@ export class NativeAbility extends UIAbility { } await Promise.all(abilityActivations); this.assertInitializationActive(generation); + // Publish only a fully initialized session. DefaultXComponent must never observe a host + // whose native module has not attached its bridge endpoints and lifecycle sinks yet. + this.updateAppStorage("bridgeSessionId", sessionId); } catch (error) { + // Promise.all may reject while another module's install hook is still active. Close every + // host first so no late hook can enter Rust while its bridge is being released below. + BridgeHostRegistry.beginClosing(sessionId); for (const runtime of initializedRuntimes.slice().reverse()) { try { runtime.lifecycle.windowStageEventCallback.onAbilityDestroy(); } catch {} } - BridgeHostRegistry.beginClosing(sessionId); + this.releaseModuleBridges(initializedRuntimes); await BridgeHostRegistry.dispose(sessionId); if (this.bridgeSessionId === sessionId) { this.bridgeSessionId = ""; @@ -366,24 +429,6 @@ export class NativeAbility extends UIAbility { windowStage.on("windowStageEvent", this.onWindowStageEvent); } catch {} - let win: window.Window | null = null; - try { - win = await windowStage.getMainWindow(); - } catch { - win = null; - } - this.assertWindowStageActive(generation); - - if (win) { - try { - this.observedMainWindow = win; - win.on("windowSizeChange", this.onWindowSizeChange); - win.on("windowRectChange", this.onWindowRectChange); - win.on("avoidAreaChange", this.onAvoidAreaChange); - win.on("keyboardHeightChange", this.onKeyboardHeightChange); - } catch {} - } - await this.loadWindowStageContent(windowStage); this.assertWindowStageActive(generation); }); @@ -428,7 +473,7 @@ export class NativeAbility extends UIAbility { } for (const runtime of this.moduleRuntimes) { try { - runtime.module.disposeAllRenders?.(); + runtime.module.disposeAllRenders(); } catch {} } this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onWindowStageDestroy()); @@ -442,23 +487,6 @@ export class NativeAbility extends UIAbility { windowStage.off("windowStageEvent", this.onWindowStageEvent); } catch {} } - - const win = this.observedMainWindow; - this.observedMainWindow = undefined; - if (win !== undefined) { - try { - win.off("windowSizeChange", this.onWindowSizeChange); - } catch {} - try { - win.off("windowRectChange", this.onWindowRectChange); - } catch {} - try { - win.off("avoidAreaChange", this.onAvoidAreaChange); - } catch {} - try { - win.off("keyboardHeightChange", this.onKeyboardHeightChange); - } catch {} - } } onMemoryLevel(level: AbilityConstant.MemoryLevel): void { @@ -498,6 +526,8 @@ export class NativeAbility extends UIAbility { }); this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onAbilityDestroy()); } finally { + const runtimes = this.moduleRuntimes; + this.releaseModuleBridges(runtimes); this.bridgeSessionId = ""; this.moduleRuntimes = []; this.windowStageActive = false; diff --git a/native_ability/src/main/ets/ability/type.ets b/native_ability/src/main/ets/ability/type.ets index 56788d56..dd431da6 100644 --- a/native_ability/src/main/ets/ability/type.ets +++ b/native_ability/src/main/ets/ability/type.ets @@ -72,16 +72,27 @@ export interface BridgeBindings { } export interface Module { - init: (context?: AbilityInitContext) => ApplicationLifecycle; /** - * Mounts one Rust render tree into `slot`. `renderOwner` is unique per DefaultXComponent - * appearance, so rendering a sub-window never replaces another window's RootNode. + * Opens this native module's Ability-session bridge before any component is rendered. The + * owner is unique per Ability session and guards a later session from stale cleanup. */ - render: (bindings: BridgeBindings, slot: NodeContent, renderOwner: string) => void; - /** Releases only the render tree owned by `renderOwner`. Older native modules may omit it. */ - disposeRender?: (renderOwner: string) => void; - /** Releases every remaining render tree when the WindowStage is torn down. */ - disposeAllRenders?: () => void; + init: ( + bindings: BridgeBindings, + bridgeOwner: string, + context?: AbilityInitContext, + ) => ApplicationLifecycle; + /** Releases only the matching Ability-session bridge; stale owners are ignored. */ + disposeBridge: (bridgeOwner: string) => void; + /** + * Mounts this native module's one Rust render tree into `slot`. `renderOwner` is unique per + * appearance and protects a later render from stale cleanup; another concurrently active + * DefaultXComponent must use a different native module. + */ + render: (slot: NodeContent, renderOwner: string) => void; + /** Releases only the render tree owned by `renderOwner`. */ + disposeRender: (renderOwner: string) => void; + /** Releases the remaining render tree when the WindowStage is torn down. */ + disposeAllRenders: () => void; /** Synchronous page-back decision exported by the native module, when it has one. */ onBackPressIntercept?: () => boolean; /** Legacy N-API spelling retained for modules built before camel-case code generation. */ @@ -110,8 +121,6 @@ export type BridgeLifecycleKind = | "window-stage-event" | "ui-context-ready" | "ui-context-destroy" - | "window-attached" - | "window-detached" | "configuration-updated" | "memory-level"; @@ -141,48 +150,26 @@ export class BridgeMemoryLevelLifecyclePayload { } } -export class BridgeWindowLifecyclePayload { - readonly windowKey: string; - - constructor(windowKey: string) { - this.windowKey = windowKey; - } -} - export type BridgeLifecyclePayload = | BridgeEmptyLifecyclePayload | BridgeAbilityCreateLifecyclePayload | BridgeWindowStageEventLifecyclePayload - | BridgeMemoryLevelLifecyclePayload - | BridgeWindowLifecyclePayload; + | BridgeMemoryLevelLifecyclePayload; export interface BridgeLifecycleEvent { readonly kind: BridgeLifecycleKind; readonly payload: BridgeLifecyclePayload; } -/** - * Window-scoped node surface. Each `DefaultXComponent` instance (one per window) owns its own - * root FrameNode; the default window key is `"main"`. Plugins mount into a window scope so - * sub-window plugin trees never overwrite the main window's. - */ -export interface BridgeWindowScope { - /** The window key this scope is bound to (`"main"` for the default window). */ - windowKey: string; - getUIContext: () => UIContext; - getRootFrameNode: () => FrameNode; - appendChild: (key: string, node: FrameNode, cleanup?: () => void) => void; - removeChild: (key: string) => void; - getFrameNode: (handle: number) => FrameNode; -} - /** * Context is intentionally ArkTS-only. It is scoped to one native module and one Ability * session; Rust gets lifecycle notifications and direct named N-API events, not retained ArkTS * object references. * - * Every plugin shares one root FrameNode per window (owned by `DefaultXComponent`). There are no - * named slots, registries or readiness waiters: the main window root exists before + * A native module is bound to exactly one `DefaultXComponent` at a time, and every plugin for + * that module shares the component's root FrameNode. An Ability may host several components by + * using a distinct native module for each one. There are no named slots or registries: the root + * exists before * `ui-context-ready`, so plugins can mount nodes during `onInstall`. `appendChild`/`removeChild` * use a caller-chosen key as the plugin's own cleanup credential; handle-based composition * (`getFrameNode`) is the Rust-facing surface of the built-in `ohos.node` plugin. @@ -193,16 +180,20 @@ export interface BridgePluginContext { sessionId: string; abilityContext: common.UIAbilityContext; isActive: () => boolean; + /** Returns the Ability-level WindowStage. This is not necessarily the component's window. */ getWindowStage: () => window.WindowStage; + /** Resolves the actual Window that owns this module's DefaultXComponent. */ + getWindow: () => window.Window; getUIContext: () => UIContext; /** - * Returns the session root FrameNode that `DefaultXComponent` attaches before + * Returns this module's component root FrameNode, attached before * `ui-context-ready`. Plugins mount their own nodes here. */ getRootFrameNode: () => FrameNode; /** - * Appends a FrameNode to the session root. `key` must be unique per plugin (prefix it with the - * plugin id) and is the cleanup credential for `removeChild`. An optional `cleanup` runs when + * Appends a FrameNode to this module's component root. `key` must be unique per plugin + * (normally prefixed by the plugin id) and is the cleanup credential for `removeChild`. An + * optional `cleanup` runs when * the node is removed or the bridge session disposes. */ appendChild: (key: string, node: FrameNode, cleanup?: () => void) => void; @@ -213,12 +204,6 @@ export interface BridgePluginContext { * container created from Rust) into its ArkTS FrameNode. */ getFrameNode: (handle: number) => FrameNode; - /** - * Returns a window-scoped node surface. The default context methods operate on the `"main"` - * window; sub-window `DefaultXComponent` instances register under their own `windowKey` and - * plugins use this method to mount into them. - */ - windowScope: (windowKey: string) => BridgeWindowScope; /** * Makes an immediate, named N-API request to the matching Rust plugin. It is valid only while * the current ArkTS/N-API callback is active; do not retain the input or result. @@ -229,6 +214,18 @@ export interface BridgePluginContext { responseTypeName: string, value: ESObject, ) => ESObject; + /** + * Sends the same named event to this plugin's Rust facade in every active native module in the + * application process. This is reserved for genuinely process-global platform transitions + * (for example ArkWeb engine initialization); ordinary callbacks must use `invokeNativeSync` + * and remain module-scoped. + */ + invokeNativeSyncAcrossModules: ( + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ) => ESObject[]; } export interface BridgeCallContext extends BridgePluginContext { @@ -320,6 +317,11 @@ export abstract class PluginBase { * need the context outside the lifecycle hooks can read it via [`PluginBase.getContext`]. */ attachContext(context: BridgePluginContext): void { + if (this.context !== undefined) { + throw new Error( + `Bridge plugin '${this.id}' instance cannot be reused across native modules or Ability sessions`, + ); + } this.context = context; } diff --git a/native_ability/src/main/ets/bridge/BridgeHost.ets b/native_ability/src/main/ets/bridge/BridgeHost.ets index 201ba1da..e437f2f5 100644 --- a/native_ability/src/main/ets/bridge/BridgeHost.ets +++ b/native_ability/src/main/ets/bridge/BridgeHost.ets @@ -3,6 +3,7 @@ import { FrameNode } from "@ohos.arkui.node"; import { UIContext } from "@ohos.arkui.UIContext"; import window from "@ohos.window"; import { + ApplicationLifecycle, BridgeCallContext, BridgeContextRequirement, BridgeEmptyLifecyclePayload, @@ -14,8 +15,6 @@ import { BridgePluginContext, BridgePluginFactory, BridgePluginHookContext, - BridgeWindowLifecyclePayload, - BridgeWindowScope, } from "../ability/type"; import { SerialTaskQueue } from "../runtime/SerialTaskQueue"; import { CancellableTaskScope, TaskCancellationSignal } from "../runtime/CancellableTaskScope"; @@ -25,6 +24,7 @@ const MAX_PAYLOAD_BYTES = 512 * 1024; const MAX_LIFECYCLE_HISTORY = 16; const PLUGIN_HOOK_TIMEOUT_MS = 5_000; const PLUGIN_HOOK_TIMEOUT_PREFIX = "bridge-plugin-hook-timeout:"; +const CLAIMED_PLUGIN_INSTANCES: WeakSet = new WeakSet(); type BridgeMainThreadEventSink = ( pluginId: string, @@ -51,20 +51,28 @@ interface MountedChild { } /** - * Per-window node surface state. Every `DefaultXComponent` instance (one per window) attaches its - * own root FrameNode, so sub-window plugin trees never overwrite the main window's. + * Node state for this module's one attached `DefaultXComponent`. */ -interface HostWindowState { +interface HostComponentState { + owner: string; uiContext?: UIContext; rootFrameNode?: FrameNode; mountedChildren: Map; nodeHandles: Map; nodeParents: Map; nextNodeHandle: number; + windowBinding?: ComponentWindowBinding; +} + +interface ComponentWindowBinding { + componentWindow: window.Window; + onSizeChange: (size: window.Size) => void; + onRectChange: (options: window.RectChangeOptions) => void; + onAvoidAreaChange: (options: window.AvoidAreaOptions) => void; + onKeyboardHeightChange: (height: number) => void; } const NODE_SURFACE_PLUGIN_ID = "ohos.node"; -export const MAIN_WINDOW_KEY = "main"; /** * Built-in session-surface plugin. It is installed by every BridgeHost before business plugins @@ -74,7 +82,7 @@ export const MAIN_WINDOW_KEY = "main"; */ class NodeSurfacePlugin implements AsyncBridgePlugin { readonly id = NODE_SURFACE_PLUGIN_ID; - readonly version = 1; + readonly version = 2; readonly requires: BridgeContextRequirement[] = ["ui-context"]; readonly execution: "async" = "async"; private readonly host: BridgeHost; @@ -89,16 +97,10 @@ class NodeSurfacePlugin implements AsyncBridgePlugin { _context: BridgeCallContext, ): Promise { if (action === "create-container") { - const payload = this.expectRequest( - request, - "ohos.node.CreateContainerRequest", - action, - ); + this.expectRequest(request, "ohos.node.CreateContainerRequest", action); return { typeName: "ohos.node.HandleResponse", - value: new NodeHandleResponse( - this.host.createContainerNode(payload.windowKey ?? MAIN_WINDOW_KEY), - ), + value: new NodeHandleResponse(this.host.createContainerNode()), }; } if (action === "append-child") { @@ -107,11 +109,7 @@ class NodeSurfacePlugin implements AsyncBridgePlugin { "ohos.node.AppendChildRequest", action, ); - this.host.appendChildByHandle( - payload.windowKey ?? MAIN_WINDOW_KEY, - payload.parentHandle, - payload.childHandle, - ); + this.host.appendChildByHandle(payload.parentHandle, payload.childHandle); return { typeName: "ohos.node.Acknowledgement", value: new NodeAcknowledgement(true), @@ -123,7 +121,7 @@ class NodeSurfacePlugin implements AsyncBridgePlugin { "ohos.node.MountIntoRootRequest", action, ); - this.host.mountIntoRoot(payload.windowKey ?? MAIN_WINDOW_KEY, payload.handle); + this.host.mountIntoRoot(payload.handle); return { typeName: "ohos.node.Acknowledgement", value: new NodeAcknowledgement(true), @@ -135,7 +133,7 @@ class NodeSurfacePlugin implements AsyncBridgePlugin { "ohos.node.DisposeRequest", action, ); - this.host.disposeNode(payload.windowKey ?? MAIN_WINDOW_KEY, payload.handle); + this.host.disposeNode(payload.handle); return { typeName: "ohos.node.Acknowledgement", value: new NodeAcknowledgement(true), @@ -156,24 +154,17 @@ class NodeSurfacePlugin implements AsyncBridgePlugin { } } -interface NodeCreateContainerPayload { - windowKey?: string | null; -} - interface NodeAppendChildPayload { parentHandle: number; childHandle: number; - windowKey?: string | null; } interface NodeMountIntoRootPayload { handle: number; - windowKey?: string | null; } interface NodeDisposePayload { handle: number; - windowKey?: string | null; } class NodeHandleResponse { @@ -204,8 +195,8 @@ interface ActivePluginHook { scope: CancellableTaskScope; } -interface PendingWindowAttachment { - state: HostWindowState; +interface PendingComponentAttachment { + state: HostComponentState; cancelled: boolean; } @@ -257,12 +248,11 @@ class BridgeHost { private readonly sessionId: string; private readonly moduleName: string; private readonly abilityContext: common.UIAbilityContext; - // Per-window node surfaces: every `DefaultXComponent` instance (one per window) registers its - // own root FrameNode under its `windowKey` (`"main"` by default), so sub-window plugin trees - // never overwrite the main window's. Each window owns its keyed mounts and the opaque handle - // table of the built-in ohos.node plugin. - private readonly windows: Map = new Map(); - private readonly pendingWindows: Map = new Map(); + private applicationLifecycle?: ApplicationLifecycle; + // One native module owns one DefaultXComponent/root tree. Multiple components in an Ability + // use distinct native modules and therefore distinct BridgeHost instances. + private component?: HostComponentState; + private pendingComponent?: PendingComponentAttachment; constructor(sessionId: string, moduleName: string, abilityContext: common.UIAbilityContext) { this.sessionId = sessionId; @@ -287,6 +277,12 @@ class BridgeHost { if (this.plugins.has(plugin.id)) { throw new Error(`Duplicate bridge plugin '${plugin.id}' in module ${this.moduleName}`); } + if (CLAIMED_PLUGIN_INSTANCES.has(plugin)) { + throw new Error( + `Bridge plugin '${plugin.id}' instance cannot be reused across native modules or Ability sessions`, + ); + } + CLAIMED_PLUGIN_INSTANCES.add(plugin); plugin.attachContext?.(this.pluginContext(plugin.id)); this.plugins.set(plugin.id, { plugin, @@ -296,9 +292,17 @@ class BridgeHost { } } + attachApplicationLifecycle(lifecycle: ApplicationLifecycle): void { + this.assertActive(); + if (this.applicationLifecycle !== undefined) { + throw new Error(`Native module '${this.moduleName}' already attached its Rust lifecycle`); + } + this.applicationLifecycle = lifecycle; + } + /** * Installs the built-in `ohos.node` surface plugin ahead of business plugins. It closes over - * this host so its actions operate on the session root and handle table directly. + * this host so its actions operate on the module/component root and handle table directly. */ private installNodeSurfacePlugin(): void { const plugin = new NodeSurfacePlugin(this); @@ -309,21 +313,20 @@ class BridgeHost { }); } - /** - * Registers one window's node surface. `DefaultXComponent` calls this from `aboutToAppear` - * after creating its root. Attaching the `"main"` window emits `ui-context-ready` (and wakes - * context waiters); sub-window attachments only register state. - */ - attachWindow(windowKey: string, uiContext: UIContext, root: FrameNode): Promise { + /** Registers this native module's single DefaultXComponent root. */ + attachComponent(componentOwner: string, uiContext: UIContext, root: FrameNode): Promise { this.assertActive(); - this.assertWindowKey(windowKey); - if (this.windows.has(windowKey) || this.pendingWindows.has(windowKey)) { + if (!componentOwner) { + throw new Error("DefaultXComponent render owner must not be empty"); + } + if (this.component !== undefined || this.pendingComponent !== undefined) { throw new Error( - `Bridge window '${windowKey}' is already attached for '${this.moduleName}'; each DefaultXComponent needs a unique windowKey`, + `Native module '${this.moduleName}' is already bound to a DefaultXComponent; every component must use a distinct native module`, ); } - const pending: PendingWindowAttachment = { + const pending: PendingComponentAttachment = { state: { + owner: componentOwner, uiContext, rootFrameNode: root, mountedChildren: new Map(), @@ -333,28 +336,23 @@ class BridgeHost { }, cancelled: false, }; - this.pendingWindows.set(windowKey, pending); + this.pendingComponent = pending; - return this.enqueueLifecycle(`attach window '${windowKey}'`, async (): Promise => { - this.pendingWindows.delete(windowKey); + return this.enqueueLifecycle("attach component", async (): Promise => { + if (this.pendingComponent === pending) { + this.pendingComponent = undefined; + } if (pending.cancelled) { return; } this.assertActive(); if (!this.windowStageReady) { throw new Error( - `Bridge window '${windowKey}' cannot attach without an active WindowStage for '${this.moduleName}'`, + `DefaultXComponent cannot attach without an active WindowStage for '${this.moduleName}'`, ); } - this.windows.set(windowKey, pending.state); - await this.deliverLifecycle({ - kind: "window-attached", - payload: new BridgeWindowLifecyclePayload(windowKey), - }); - if (windowKey !== MAIN_WINDOW_KEY) { - return; - } - + this.attachComponentWindow(pending.state); + this.component = pending.state; this.uiContextReady = true; this.notifyContextChanged(); // Rust readiness must be visible before an ArkTS plugin's onInstall can synchronously emit @@ -368,22 +366,24 @@ class BridgeHost { } /** - * Unregisters one window's node surface, unmounting its keyed children and disposing its - * handle-owned nodes. The root FrameNode itself is disposed by its owner - * (`DefaultXComponent`). Detaching the `"main"` window emits `ui-context-destroy`. + * Unregisters this module's DefaultXComponent, unmounting keyed children and handle-owned + * nodes. The root FrameNode itself is disposed by the component. */ - async detachWindow(windowKey: string): Promise { + async detachComponent(componentOwner: string): Promise { if (this.disposed) { return; } - const pending = this.pendingWindows.get(windowKey); + const pending = this.pendingComponent; if (pending !== undefined) { + if (pending.state.owner !== componentOwner) { + return; + } pending.cancelled = true; - this.pendingWindows.delete(windowKey); - await this.enqueueLifecycle(`cancel window attachment '${windowKey}'`, async () => {}); + this.pendingComponent = undefined; + await this.enqueueLifecycle("cancel component attachment", async () => {}); return; } - const state = this.windows.get(windowKey); + const state = this.component; if (state === undefined) { const windowStageClearPromise = this.windowStageClearPromise; if (windowStageClearPromise !== undefined) { @@ -391,36 +391,29 @@ class BridgeHost { } return; } - this.windows.delete(windowKey); - if (windowKey === MAIN_WINDOW_KEY) { - // Make new calls fail immediately. The queued lifecycle hook still owns the old state and - // releases it before a later attachment can become ready. - this.uiContextReady = false; - this.cancelCallsWithMissingContext(); - this.notifyContextChanged(); + if (state.owner !== componentOwner) { + return; } + this.component = undefined; + // Make new calls fail immediately. The queued lifecycle hook still owns the old state and + // releases it before a later attachment can become ready. + this.uiContextReady = false; + this.cancelCallsWithMissingContext(); + this.notifyContextChanged(); - await this.enqueueLifecycle(`detach window '${windowKey}'`, async (): Promise => { + await this.enqueueLifecycle("detach component", async (): Promise => { await this.deliverLifecycle({ - kind: "window-detached", - payload: new BridgeWindowLifecyclePayload(windowKey), + kind: "ui-context-destroy", + payload: new BridgeEmptyLifecyclePayload(), }); - if (windowKey === MAIN_WINDOW_KEY) { - await this.deliverLifecycle({ - kind: "ui-context-destroy", - payload: new BridgeEmptyLifecyclePayload(), - }); - } - this.disposeWindowState(state); - if (windowKey === MAIN_WINDOW_KEY) { - this.notifyRustLifecycle("ui-context-destroy"); - } + this.disposeComponentState(state); + this.notifyRustLifecycle("ui-context-destroy"); }); } - /** Creates an empty container FrameNode in `windowKey` and returns its opaque handle (ohos.node). */ - createContainerNode(windowKey: string): number { - const state = this.requireWindow(windowKey); + /** Creates an empty container FrameNode and returns its opaque handle (ohos.node). */ + createContainerNode(): number { + const state = this.requireComponent(); const node = new FrameNode(this.requireUiContext(state)); const handle = state.nextNodeHandle++; state.nodeHandles.set(handle, node); @@ -428,20 +421,20 @@ class BridgeHost { } /** Appends the node of `childHandle` under the node of `parentHandle` (ohos.node). */ - appendChildByHandle(windowKey: string, parentHandle: number, childHandle: number): void { - const state = this.requireWindow(windowKey); + appendChildByHandle(parentHandle: number, childHandle: number): void { + const state = this.requireComponent(); const parent = this.lookupNodeHandle(state, parentHandle, "append-child parent"); const child = this.lookupNodeHandle(state, childHandle, "append-child child"); this.appendFrameNode(parent, child, `append child handle '${childHandle}'`); state.nodeParents.set(childHandle, parent); } - /** Appends a handle-owned node to the `windowKey` root (ohos.node). */ - mountIntoRoot(windowKey: string, handle: number): void { - const state = this.requireWindow(windowKey); + /** Appends a handle-owned node to this module's component root (ohos.node). */ + mountIntoRoot(handle: number): void { + const state = this.requireComponent(); const node = this.lookupNodeHandle(state, handle, "mount-into-root"); const root = this.requireRootFrameNode(state); - this.appendFrameNode(root, node, `mount handle '${handle}' into '${windowKey}'`); + this.appendFrameNode(root, node, `mount handle '${handle}' into module root`); state.nodeParents.set(handle, root); } @@ -454,8 +447,8 @@ class BridgeHost { } /** Disposes a handle-owned node: detaches it from its recorded parent and frees the handle. */ - disposeNode(windowKey: string, handle: number): void { - const state = this.windows.get(windowKey); + disposeNode(handle: number): void { + const state = this.component; if (state === undefined) { return; } @@ -476,7 +469,7 @@ class BridgeHost { } catch {} } - private lookupNodeHandle(state: HostWindowState, handle: number, role: string): FrameNode { + private lookupNodeHandle(state: HostComponentState, handle: number, role: string): FrameNode { const node = state.nodeHandles.get(handle); if (!node) { throw new Error(`Unknown ohos.node handle '${handle}' for ${role}`); @@ -484,7 +477,7 @@ class BridgeHost { return node; } - private requireRootFrameNode(state: HostWindowState): FrameNode { + private requireRootFrameNode(state: HostComponentState): FrameNode { const root = state.rootFrameNode; if (root === undefined) { throw new Error(`Bridge root FrameNode is not attached for '${this.moduleName}'`); @@ -492,7 +485,7 @@ class BridgeHost { return root; } - private requireUiContext(state: HostWindowState): UIContext { + private requireUiContext(state: HostComponentState): UIContext { const uiContext = state.uiContext; if (uiContext === undefined) { throw new Error(`Bridge session '${this.sessionId}' has no UIContext`); @@ -500,51 +493,139 @@ class BridgeHost { return uiContext; } - private requireWindow(windowKey: string): HostWindowState { - this.assertWindowKey(windowKey); - const state = this.windows.get(windowKey); - if (state === undefined) { + private requireComponentWindow(state: HostComponentState): window.Window { + const boundWindow = state.windowBinding?.componentWindow; + if (boundWindow !== undefined) { + return boundWindow; + } + const windowName = this.requireUiContext(state).getWindowName(); + if (!windowName) { + throw new Error(`Native module '${this.moduleName}' has no component Window`); + } + try { + return window.findWindow(windowName); + } catch (error) { throw new Error( - `Bridge window '${windowKey}' is not attached for '${this.moduleName}'; place DefaultXComponent with a matching windowKey before invoking this plugin`, + `Unable to resolve Window '${windowName}' for native module '${this.moduleName}': ${String(error)}`, ); } - return state; } - /** Returns a window-scoped node surface used by `BridgePluginContext.windowScope`. */ - windowScope(windowKey: string): BridgeWindowScope { - this.assertWindowKey(windowKey); - return { - windowKey, - getUIContext: (): UIContext => this.requireUiContext(this.requireWindow(windowKey)), - getRootFrameNode: (): FrameNode => this.requireRootFrameNode(this.requireWindow(windowKey)), - appendChild: (key: string, node: FrameNode, cleanup?: () => void): void => - this.appendChild(windowKey, key, node, cleanup), - removeChild: (key: string): void => this.removeChild(windowKey, key), - getFrameNode: (handle: number): FrameNode => - this.lookupNodeHandle(this.requireWindow(windowKey), handle, "context"), + private attachComponentWindow(state: HostComponentState): void { + // NativeAbility always attaches the Rust lifecycle before page content is loaded. A host with + // no lifecycle consumer (for example a transport-only unit test) does not need Window event + // subscriptions; getWindow() still resolves lazily if a plugin explicitly requests it. + if (this.applicationLifecycle === undefined) { + return; + } + const componentWindow = this.requireComponentWindow(state); + const onSizeChange = (size: window.Size): void => { + if (this.component !== state || this.closing || this.disposed) { + return; + } + try { + this.applicationLifecycle?.windowStageEventCallback.onWindowSizeChange(size); + } catch {} + }; + const onRectChange = (options: window.RectChangeOptions): void => { + if (this.component !== state || this.closing || this.disposed) { + return; + } + try { + this.applicationLifecycle?.windowStageEventCallback.onWindowRectChange(options); + } catch {} + }; + const onAvoidAreaChange = (options: window.AvoidAreaOptions): void => { + if (this.component !== state || this.closing || this.disposed) { + return; + } + try { + this.applicationLifecycle?.windowStageEventCallback.onAvoidAreaChange(options); + } catch {} + }; + const onKeyboardHeightChange = (height: number): void => { + if (this.component !== state || this.closing || this.disposed) { + return; + } + try { + this.applicationLifecycle?.keyboardEventCallback.onKeyboardHeightChange(height); + } catch {} + }; + state.windowBinding = { + componentWindow, + onSizeChange, + onRectChange, + onAvoidAreaChange, + onKeyboardHeightChange, }; + try { + componentWindow.on("windowSizeChange", onSizeChange); + } catch {} + try { + componentWindow.on("windowRectChange", onRectChange); + } catch {} + try { + componentWindow.on("avoidAreaChange", onAvoidAreaChange); + } catch {} + try { + componentWindow.on("keyboardHeightChange", onKeyboardHeightChange); + } catch {} } - /** Plugin-context mount: appends an arbitrary FrameNode under the `windowKey` root. */ - appendChild(windowKey: string, key: string, node: FrameNode, cleanup?: () => void): void { + private detachComponentWindow(state: HostComponentState): void { + const binding = state.windowBinding; + state.windowBinding = undefined; + if (binding === undefined) { + return; + } + try { + binding.componentWindow.off("windowSizeChange", binding.onSizeChange); + } catch {} + try { + binding.componentWindow.off("windowRectChange", binding.onRectChange); + } catch {} + try { + binding.componentWindow.off("avoidAreaChange", binding.onAvoidAreaChange); + } catch {} + try { + binding.componentWindow.off("keyboardHeightChange", binding.onKeyboardHeightChange); + } catch {} + } + + private requireComponent(): HostComponentState { + const state = this.component; + if (state === undefined) { + throw new Error(`Native module '${this.moduleName}' has no attached DefaultXComponent`); + } + return state; + } + + /** Plugin-context mount: appends an arbitrary FrameNode under this module's component root. */ + appendChild(key: string, node: FrameNode, cleanup?: () => void): void { this.assertActive(); if (!isBridgeIdentifier(key)) { throw new Error("Bridge node mount key must contain only letters, digits, '.', '_' or '-'"); } - this.removeChild(windowKey, key); - const state = this.requireWindow(windowKey); - this.appendFrameNode( - this.requireRootFrameNode(state), - node, - `mount '${key}' into '${windowKey}'`, - ); - state.mountedChildren.set(key, { node, cleanup }); + this.removeChild(key); + try { + const state = this.requireComponent(); + this.appendFrameNode( + this.requireRootFrameNode(state), + node, + `mount '${key}' into module root`, + ); + state.mountedChildren.set(key, { node, cleanup }); + } catch (error) { + try { + cleanup?.(); + } catch {} + throw new Error(String(error)); + } } - /** Plugin-context unmount: removes the node mounted under `key` in `windowKey` and runs its cleanup. */ - removeChild(windowKey: string, key: string): void { - const state = this.windows.get(windowKey); + /** Plugin-context unmount: removes the node mounted under `key` and runs its cleanup. */ + removeChild(key: string): void { + const state = this.component; if (state === undefined) { return; } @@ -561,7 +642,8 @@ class BridgeHost { } catch {} } - private disposeWindowState(state: HostWindowState): void { + private disposeComponentState(state: HostComponentState): void { + this.detachComponentWindow(state); const mountedKeys = Array.from(state.mountedChildren.keys()); for (const key of mountedKeys) { const mounted = state.mountedChildren.get(key); @@ -586,12 +668,6 @@ class BridgeHost { state.nodeParents.clear(); } - private assertWindowKey(windowKey: string): void { - if (!isBridgeIdentifier(windowKey)) { - throw new Error("Bridge windowKey must contain only letters, digits, '.', '_' or '-'"); - } - } - /** Marks the Ability context ready only after the native module and both Rust sinks exist. */ async activateAbility(event: BridgeLifecycleEvent): Promise { this.assertActive(); @@ -627,34 +703,25 @@ class BridgeHost { await activeClear; return; } + // Claim the component before scheduling cleanup. Otherwise an aboutToDisappear callback in + // the same event-loop turn can take the state while this operation is waiting on the queue, + // making window-stage-destroy overtake ui-context-destroy. + const state = this.component; + this.component = undefined; + if (this.uiContextReady) { + this.uiContextReady = false; + this.cancelCallsWithMissingContext(); + this.notifyContextChanged(); + } const clearPromise = this.enqueueLifecycle("window-stage-destroy", async (): Promise => { this.windowStageReady = false; - // The WindowStage belongs to the Ability (main window). Detach every registered window - // surface; only the main window emits `ui-context-destroy`. - const windowEntries = Array.from(this.windows.entries()); - this.windows.clear(); - if (this.uiContextReady) { - this.uiContextReady = false; - this.cancelCallsWithMissingContext(); - this.notifyContextChanged(); - } - for (const windowEntry of windowEntries) { - const windowKey = windowEntry[0]; - const state = windowEntry[1]; + if (state !== undefined) { await this.deliverLifecycle({ - kind: "window-detached", - payload: new BridgeWindowLifecyclePayload(windowKey), + kind: "ui-context-destroy", + payload: new BridgeEmptyLifecyclePayload(), }); - if (windowKey === MAIN_WINDOW_KEY) { - await this.deliverLifecycle({ - kind: "ui-context-destroy", - payload: new BridgeEmptyLifecyclePayload(), - }); - } - this.disposeWindowState(state); - if (windowKey === MAIN_WINDOW_KEY) { - this.notifyRustLifecycle("ui-context-destroy"); - } + this.disposeComponentState(state); + this.notifyRustLifecycle("ui-context-destroy"); } await this.deliverLifecycle({ kind: "window-stage-destroy", @@ -679,6 +746,11 @@ class BridgeHost { this.windowStageGeneration += 1; this.windowStageReady = false; this.windowStage = undefined; + const pending = this.pendingComponent; + if (pending !== undefined) { + pending.cancelled = true; + this.pendingComponent = undefined; + } this.cancelCallsWithMissingContext(); this.notifyContextChanged(); } @@ -844,7 +916,7 @@ class BridgeHost { const sink = this.mainThreadEventSink; if (sink === undefined) { throw new Error( - "Main-thread event sink is unavailable because the native module is not rendered", + "Main-thread event sink is unavailable because the native module did not attach its Rust bridge export", ); } const entry = this.plugins.get(pluginId); @@ -854,6 +926,20 @@ class BridgeHost { return sink(pluginId, event, requestTypeName, responseTypeName, value); } + /** Returns no response when this host/plugin is outside an active Ability session. */ + invokeNativeSyncIfActive( + pluginId: string, + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject | undefined { + if (!this.abilityReady || this.closing || this.disposed || !this.plugins.has(pluginId)) { + return undefined; + } + return this.invokeNativeSync(pluginId, event, requestTypeName, responseTypeName, value); + } + attachEventSink( mainThreadSink: BridgeMainThreadEventSink | undefined, lifecycleSink: BridgeLifecycleSink | undefined, @@ -910,16 +996,19 @@ class BridgeHost { this.plugins.clear(); this.mainThreadEventSink = undefined; this.lifecycleSink = undefined; - for (const pending of this.pendingWindows.values()) { + this.applicationLifecycle = undefined; + const pending = this.pendingComponent; + if (pending !== undefined) { pending.cancelled = true; } - this.pendingWindows.clear(); - // Release every window surface: keyed mounts first, then handle-owned nodes. The root - // FrameNodes themselves are owned and disposed by their DefaultXComponent instances. - for (const state of this.windows.values()) { - this.disposeWindowState(state); + this.pendingComponent = undefined; + // Release keyed mounts first, then handle-owned nodes. The root FrameNode itself is owned + // and disposed by this module's DefaultXComponent. + const state = this.component; + if (state !== undefined) { + this.disposeComponentState(state); } - this.windows.clear(); + this.component = undefined; this.abilityReady = false; this.uiContextReady = false; this.windowStage = undefined; @@ -1078,7 +1167,6 @@ class BridgeHost { } private pluginContext(pluginId: string): BridgePluginContext { - const mainScope = this.windowScope(MAIN_WINDOW_KEY); return { pluginId, moduleName: this.moduleName, @@ -1092,13 +1180,14 @@ class BridgeHost { } return this.windowStage; }, - getUIContext: (): UIContext => mainScope.getUIContext(), - getRootFrameNode: (): FrameNode => mainScope.getRootFrameNode(), + getWindow: (): window.Window => this.requireComponentWindow(this.requireComponent()), + getUIContext: (): UIContext => this.requireUiContext(this.requireComponent()), + getRootFrameNode: (): FrameNode => this.requireRootFrameNode(this.requireComponent()), appendChild: (key: string, node: FrameNode, cleanup?: () => void): void => - mainScope.appendChild(key, node, cleanup), - removeChild: (key: string): void => mainScope.removeChild(key), - getFrameNode: (handle: number): FrameNode => mainScope.getFrameNode(handle), - windowScope: (windowKey: string): BridgeWindowScope => this.windowScope(windowKey), + this.appendChild(key, node, cleanup), + removeChild: (key: string): void => this.removeChild(key), + getFrameNode: (handle: number): FrameNode => + this.lookupNodeHandle(this.requireComponent(), handle, "context"), invokeNativeSync: ( event: string, requestTypeName: string, @@ -1106,6 +1195,19 @@ class BridgeHost { value: ESObject, ): ESObject => this.invokeNativeSync(pluginId, event, requestTypeName, responseTypeName, value), + invokeNativeSyncAcrossModules: ( + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject[] => + BridgeHostRegistry.invokeNativeSyncAcrossModules( + pluginId, + event, + requestTypeName, + responseTypeName, + value, + ), }; } @@ -1132,13 +1234,13 @@ class BridgeHost { onCancel: (listener: () => void): (() => void) => this.subscribeCancellation(callState, listener), getWindowStage: (): window.WindowStage => context.getWindowStage(), + getWindow: (): window.Window => context.getWindow(), getUIContext: (): UIContext => context.getUIContext(), getRootFrameNode: (): FrameNode => context.getRootFrameNode(), appendChild: (key: string, node: FrameNode, cleanup?: () => void): void => context.appendChild(key, node, cleanup), removeChild: (key: string): void => context.removeChild(key), getFrameNode: (handle: number): FrameNode => context.getFrameNode(handle), - windowScope: (windowKey: string): BridgeWindowScope => context.windowScope(windowKey), invokeNativeSync: ( event: string, requestTypeName: string, @@ -1146,6 +1248,13 @@ class BridgeHost { value: ESObject, ): ESObject => this.invokeNativeSync(pluginId, event, requestTypeName, responseTypeName, value), + invokeNativeSyncAcrossModules: ( + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject[] => + context.invokeNativeSyncAcrossModules(event, requestTypeName, responseTypeName, value), }; } @@ -1163,13 +1272,13 @@ class BridgeHost { isCancelled: (): boolean => signal.isCancelled() || this.disposed, onCancel: (listener: () => void): (() => void) => signal.onCancel(listener), getWindowStage: (): window.WindowStage => context.getWindowStage(), + getWindow: (): window.Window => context.getWindow(), getUIContext: (): UIContext => context.getUIContext(), getRootFrameNode: (): FrameNode => context.getRootFrameNode(), appendChild: (key: string, node: FrameNode, cleanup?: () => void): void => context.appendChild(key, node, cleanup), removeChild: (key: string): void => context.removeChild(key), getFrameNode: (handle: number): FrameNode => context.getFrameNode(handle), - windowScope: (windowKey: string): BridgeWindowScope => context.windowScope(windowKey), invokeNativeSync: ( event: string, requestTypeName: string, @@ -1177,6 +1286,13 @@ class BridgeHost { value: ESObject, ): ESObject => this.invokeNativeSync(pluginId, event, requestTypeName, responseTypeName, value), + invokeNativeSyncAcrossModules: ( + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject[] => + context.invokeNativeSyncAcrossModules(event, requestTypeName, responseTypeName, value), }; } @@ -1225,8 +1341,7 @@ class BridgeHost { (recorded: BridgeLifecycleEvent): boolean => recorded.kind === "configuration-updated" || recorded.kind === "memory-level" || - recorded.kind === "window-stage-event" || - recorded.kind === "window-detached", + recorded.kind === "window-stage-event", ); if (evictableIndex >= 0) { this.lifecycleHistory.splice(evictableIndex, 1); @@ -1511,6 +1626,8 @@ class BridgeHost { */ export class BridgeHostRegistry { private static readonly sessions: Map> = new Map(); + /** One loaded native module may belong to only one active Ability session. */ + private static readonly moduleOwners: Map = new Map(); private static nextSession: number = 1; static prepare( @@ -1520,6 +1637,25 @@ export class BridgeHostRegistry { ): string { const sessionId = `bridge-${Date.now()}-${BridgeHostRegistry.nextSession++}`; const hosts: Map = new Map(); + if (moduleNames.length === 0) { + throw new Error("NativeAbility must declare at least one native module"); + } + const requestedModules: Map = new Map(); + for (const moduleName of moduleNames) { + if (!isBridgeIdentifier(moduleName)) { + throw new Error(`Invalid native module name '${moduleName}'`); + } + if (requestedModules.has(moduleName)) { + throw new Error(`Native module '${moduleName}' is declared more than once for one Ability`); + } + requestedModules.set(moduleName, true); + const owner = BridgeHostRegistry.moduleOwners.get(moduleName); + if (owner !== undefined) { + throw new Error( + `Native module '${moduleName}' already belongs to active Ability session '${owner}'; use a distinct native module for another Ability`, + ); + } + } try { for (const moduleName of moduleNames) { const host = new BridgeHost(sessionId, moduleName, abilityContext); @@ -1530,6 +1666,9 @@ export class BridgeHostRegistry { throw new Error(String(error)); } BridgeHostRegistry.sessions.set(sessionId, hosts); + for (const moduleName of moduleNames) { + BridgeHostRegistry.moduleOwners.set(moduleName, sessionId); + } return sessionId; } @@ -1541,6 +1680,14 @@ export class BridgeHostRegistry { await BridgeHostRegistry.host(sessionId, moduleName).activateAbility(event); } + static attachApplicationLifecycle( + sessionId: string, + moduleName: string, + lifecycle: ApplicationLifecycle, + ): void { + BridgeHostRegistry.host(sessionId, moduleName).attachApplicationLifecycle(lifecycle); + } + static async invokeAsync( sessionId: string, moduleName: string, @@ -1583,6 +1730,35 @@ export class BridgeHostRegistry { ); } + /** + * Broadcasts one named direct event to every active native module that registered `pluginId`. + * This is intentionally process-wide for platform resources whose initialization is global. + */ + static invokeNativeSyncAcrossModules( + pluginId: string, + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject[] { + const responses: ESObject[] = []; + for (const hosts of BridgeHostRegistry.sessions.values()) { + for (const host of hosts.values()) { + const response: ESObject | undefined = host.invokeNativeSyncIfActive( + pluginId, + event, + requestTypeName, + responseTypeName, + value, + ); + if (response !== undefined) { + responses.push(response); + } + } + } + return responses; + } + static async setWindowStage( sessionId: string, moduleName: string, @@ -1599,29 +1775,29 @@ export class BridgeHostRegistry { BridgeHostRegistry.sessions.get(sessionId)?.get(moduleName)?.invalidateWindowStage(); } - /** - * Registers one window's node surface (`windowKey` defaults to `"main"`). Attaching the main - * window emits `ui-context-ready`, so plugins can mount nodes during `onInstall` without any - * waiting; sub-window attachments only register state. - */ - static async attachWindow( + /** Binds the module to its one DefaultXComponent/root tree. */ + static async attachComponent( sessionId: string, moduleName: string, - windowKey: string, + componentOwner: string, uiContext: UIContext, root: FrameNode, ): Promise { - await BridgeHostRegistry.host(sessionId, moduleName).attachWindow(windowKey, uiContext, root); + await BridgeHostRegistry.host(sessionId, moduleName).attachComponent( + componentOwner, + uiContext, + root, + ); } - /** Unregisters one window's node surface. The root node itself is disposed by its owner. */ - static async detachWindow( + /** Unbinds the module's component. The root node itself is disposed by its owner. */ + static async detachComponent( sessionId: string, moduleName: string, - windowKey: string, + componentOwner: string, ): Promise { const host = BridgeHostRegistry.sessions.get(sessionId)?.get(moduleName); - await host?.detachWindow(windowKey); + await host?.detachComponent(componentOwner); } static async emitLifecycle( @@ -1665,7 +1841,16 @@ export class BridgeHostRegistry { return; } BridgeHostRegistry.sessions.delete(sessionId); - await BridgeHostRegistry.disposeHosts(hosts); + try { + await BridgeHostRegistry.disposeHosts(hosts); + } finally { + for (const moduleName of hosts.keys()) { + if (BridgeHostRegistry.moduleOwners.get(moduleName) === sessionId) { + BridgeHostRegistry.moduleOwners.delete(moduleName); + } + } + hosts.clear(); + } } private static host(sessionId: string, moduleName: string): BridgeHost { @@ -1681,6 +1866,5 @@ export class BridgeHostRegistry { private static async disposeHosts(hosts: Map): Promise { const values = Array.from(hosts.values()); await Promise.all(values.map(async (host: BridgeHost): Promise => await host.dispose())); - hosts.clear(); } } diff --git a/native_ability/src/main/ets/components/DefaultXComponent.ets b/native_ability/src/main/ets/components/DefaultXComponent.ets index faf37088..87ead5a5 100644 --- a/native_ability/src/main/ets/components/DefaultXComponent.ets +++ b/native_ability/src/main/ets/components/DefaultXComponent.ets @@ -1,8 +1,8 @@ import { NodeContent } from "@kit.ArkUI"; import { FrameNode, NodeController } from "@ohos.arkui.node"; import { UIContext } from "@ohos.arkui.UIContext"; -import { BridgeBindings, Module } from "../ability/type"; -import { BridgeHostRegistry, MAIN_WINDOW_KEY } from "../bridge/BridgeHost"; +import { Module } from "../ability/type"; +import { BridgeHostRegistry } from "../bridge/BridgeHost"; import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; export const RouteName = "NativeAbility"; @@ -12,81 +12,30 @@ let nextRenderOwnerId: number = 1; interface ComponentAttachment { sessionId: string; moduleName: string; - windowKey: string; renderOwner: string; nativeModule: Module; } /** - * Mounts only the Rust XComponent and generic bridge bindings. Platform capabilities deliberately - * do not live here: a capability that needs ArkUI content (for example WebView) owns a plugin - * and mounts its own FrameNode into the session root this component provides. + * Mounts only the Rust XComponent render surface and module root. The generic bridge transport + * already belongs to the NativeAbility/module session, so ability-only capabilities do not wait + * for this component. A capability that needs ArkUI content (for example WebView) owns a plugin + * and mounts its own FrameNode into the root this component provides. * - * Multi-window: each instance registers its own window surface under `windowKey` (`"main"` by - * default). Sub-window pages place another `DefaultXComponent` with a unique `windowKey` (for - * example `windowId`), and plugins address it through `BridgePluginContext.windowScope`. + * Every instance must use its own native module. One Ability may host multiple instances (in the + * same window or different windows) by declaring and assigning a distinct module to each one. */ @Component export struct DefaultXComponent { moduleName: string = ""; - /** Unique key of the window surface this instance owns; `"main"` is the default window. */ - @Prop windowKey: string = MAIN_WINDOW_KEY; private rootSlot = new NodeContent(); - private nodeController = new BridgeRootNodeController(this.getUIContext()); + private nodeController = new BridgeRootNodeController(); @StorageProp("bridgeSessionId") bridgeSessionId: string = ""; @StorageProp("loadMode") loadMode: "async" | "sync" = "async"; private appearanceGeneration: number = 0; private attachment?: ComponentAttachment; private releasePromise: Promise = Promise.resolve(); - private createBindings(sessionId: string, moduleName: string): BridgeBindings { - return { - bridgeInvoke: async ( - pluginId: string, - pluginVersion: number, - action: string, - requestTypeName: string, - responseTypeName: string, - value: ESObject, - timeoutMs: number, - ): Promise => { - return await BridgeHostRegistry.invokeAsync( - sessionId, - moduleName, - pluginId, - pluginVersion, - action, - requestTypeName, - responseTypeName, - value, - timeoutMs, - ); - }, - bridgeInvokeSync: ( - pluginId: string, - pluginVersion: number, - action: string, - requestTypeName: string, - responseTypeName: string, - value: ESObject, - ): ESObject => { - return BridgeHostRegistry.invokeSync( - sessionId, - moduleName, - pluginId, - pluginVersion, - action, - requestTypeName, - responseTypeName, - value, - ); - }, - // The Rust MainThreadScheduler executes its closure inside this TSFN callback before this - // no-op function is invoked. It carries no capability-specific behavior. - bridgeDispatch: (): void => {}, - }; - } - async aboutToAppear(): Promise { const generation = ++this.appearanceGeneration; await this.releasePromise; @@ -108,60 +57,32 @@ export struct DefaultXComponent { "DefaultXComponent cannot render before NativeAbility finishes creating its bridge session", ); } - // Idempotent fallback: NativeAbility.onCreate already attached the sink right after - // module.init (same cached Module instance), so `ability`-only plugins can emit inbound - // events on `ability-create`. Re-attaching here covers modules loaded without onCreate. - BridgeHostRegistry.attachEventSink( - sessionId, - moduleName, - ( - pluginId: string, - event: string, - requestTypeName: string, - responseTypeName: string, - value: ESObject, - ): ESObject => { - if (typeof nativeModule.onBridgeSyncEvent !== "function") { - throw new Error("Native module does not export onBridgeSyncEvent"); - } - return nativeModule.onBridgeSyncEvent( - pluginId, - event, - requestTypeName, - responseTypeName, - value, - ); - }, - (kind: string): void => { - nativeModule.onBridgeLifecycle?.(kind); - }, - ); - const renderOwner = `${sessionId}:${moduleName}:${this.windowKey}:${nextRenderOwnerId++}`; - nativeModule.render(this.createBindings(sessionId, moduleName), this.rootSlot, renderOwner); + // NativeAbility owns the module/session sinks from ability-create onward. The component only + // supplies the render surface and UI root; ability-only plugins never depend on appearance. + const renderOwner = `${sessionId}:${moduleName}:${nextRenderOwnerId++}`; + nativeModule.render(this.rootSlot, renderOwner); if (generation !== this.appearanceGeneration) { - nativeModule.disposeRender?.(renderOwner); + nativeModule.disposeRender(renderOwner); return; } - // Create the window root eagerly (NodeContainer asks the controller lazily) so it exists - // before `ui-context-ready`. Every plugin then mounts into this one tree without slots, - // registries or readiness waiters. Attaching the `"main"` window emits `ui-context-ready`; - // sub-window instances (unique `windowKey`) only register their own surface. - const root = this.nodeController.makeNode(this.getUIContext()); const attachment: ComponentAttachment = { sessionId, moduleName, - windowKey: this.windowKey, renderOwner, nativeModule, }; this.attachment = attachment; try { - await BridgeHostRegistry.attachWindow( + // Create this module's root eagerly (NodeContainer asks the controller lazily) so it exists + // before `ui-context-ready`. Keep it inside the render transaction: FrameNode construction + // failure must release the native RootNode/render owner as well. + const root = this.nodeController.makeNode(this.getUIContext()); + await BridgeHostRegistry.attachComponent( sessionId, moduleName, - attachment.windowKey, + renderOwner, this.getUIContext(), root, ); @@ -197,11 +118,11 @@ export struct DefaultXComponent { } // RootNode owns the ContentSlot handle and must be dropped synchronously while this component // still owns a live NodeContent. Host/plugin cleanup may continue asynchronously afterwards. - attachment.nativeModule.disposeRender?.(attachment.renderOwner); - return BridgeHostRegistry.detachWindow( + attachment.nativeModule.disposeRender(attachment.renderOwner); + return BridgeHostRegistry.detachComponent( attachment.sessionId, attachment.moduleName, - attachment.windowKey, + attachment.renderOwner, ) .catch(() => {}) .finally(() => { @@ -229,17 +150,11 @@ export struct DefaultXComponent { } /** - * Owns the single session root FrameNode. Plugins never touch this controller directly; they + * Owns this native module's single component root FrameNode. Plugins never touch this controller directly; they * receive the root through `BridgePluginContext.getRootFrameNode()`. */ class BridgeRootNodeController extends NodeController { private root: FrameNode | null = null; - private readonly uiContext: UIContext; - - constructor(uiContext: UIContext) { - super(); - this.uiContext = uiContext; - } makeNode(uiContext: UIContext): FrameNode { if (this.root === null) { @@ -249,7 +164,10 @@ class BridgeRootNodeController extends NodeController { } disposeRoot(): void { - this.root?.dispose(); + const root = this.root; this.root = null; + try { + root?.dispose(); + } catch {} } } diff --git a/native_ability/src/main/ets/runtime/NativeModuleLoader.ets b/native_ability/src/main/ets/runtime/NativeModuleLoader.ets index 59098146..838d40da 100644 --- a/native_ability/src/main/ets/runtime/NativeModuleLoader.ets +++ b/native_ability/src/main/ets/runtime/NativeModuleLoader.ets @@ -4,14 +4,31 @@ import { Module } from "../ability/type"; export class NativeModuleLoader { private static modules: Record = {}; + private static isCurrentModule(value: ESObject): boolean { + return ( + value !== null && + typeof value === "object" && + typeof value.init === "function" && + typeof value.disposeBridge === "function" && + typeof value.render === "function" && + typeof value.disposeRender === "function" && + typeof value.disposeAllRenders === "function" + ); + } + static resolveModuleNames(name: string | string[]): string[] { const names = Array.isArray(name) ? name : [name]; const resolved: string[] = []; for (const item of names) { const moduleName = item.trim(); - if (!moduleName || resolved.indexOf(moduleName) !== -1) { - continue; + if (!moduleName) { + throw new Error("moduleName entries must not be empty"); + } + if (resolved.indexOf(moduleName) !== -1) { + throw new Error( + `Native module '${moduleName}' is declared more than once; every DefaultXComponent needs a distinct module`, + ); } if (moduleName.startsWith("lib") || moduleName.endsWith(".so")) { throw new Error(`moduleName must not include lib/.so wrapper: ${moduleName}`); @@ -42,17 +59,16 @@ export class NativeModuleLoader { } let module: Module | null = null; - if (typeof currentModule?.render === "function" && typeof currentModule?.init === "function") { + if (NativeModuleLoader.isCurrentModule(currentModule)) { module = currentModule as Module; - } else if ( - typeof currentModule?.default?.render === "function" && - typeof currentModule?.default?.init === "function" - ) { + } else if (NativeModuleLoader.isCurrentModule(currentModule?.default)) { module = currentModule.default as Module; } if (!module) { - throw new Error(`${libraryName} is not a valid dynamic library`); + throw new Error( + `${libraryName} does not implement the current native module session/render contract`, + ); } NativeModuleLoader.modules[resolvedModuleName] = module; diff --git a/native_ability/src/test/LocalUnit.test.ets b/native_ability/src/test/LocalUnit.test.ets index a213768f..70b88764 100644 --- a/native_ability/src/test/LocalUnit.test.ets +++ b/native_ability/src/test/LocalUnit.test.ets @@ -2,14 +2,19 @@ import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from import { SerialTaskQueue } from "../main/ets/runtime/SerialTaskQueue"; import { AbilityStateCodec } from "../main/ets/runtime/AbilityStateCodec"; import { CancellableTaskScope } from "../main/ets/runtime/CancellableTaskScope"; +import { NativeModuleLoader } from "../main/ets/runtime/NativeModuleLoader"; import common from "@ohos.app.ability.common"; import window from "@ohos.window"; +import { FrameNode } from "@ohos.arkui.node"; +import { UIContext } from "@ohos.arkui.UIContext"; import { AsyncPluginBase, BridgeAbilityCreateLifecyclePayload, BridgeCallContext, BridgeContextRequirement, BridgeEmptyLifecyclePayload, + BridgeLifecycleEvent, + BridgePluginContext, BridgePluginHookContext, BridgeTypedValue, LazyPlugin, @@ -19,6 +24,8 @@ import { BridgeHostRegistry } from "../main/ets/bridge/BridgeHost"; let installProbeCount: number = 0; let installProbeSinkCount: number = 0; let stageInstallProbeCount: number = 0; +let lifecycleOrderProbe: string[] = []; +let broadcastProbeContext: BridgePluginContext | undefined; class InstallProbeRequest { readonly accepted: boolean = true; @@ -70,6 +77,59 @@ class StageInstallProbePlugin extends AsyncPluginBase { } } +class AbilityEchoPlugin extends AsyncPluginBase { + readonly id = "test.ability-echo"; + readonly version = 1; + readonly requires: BridgeContextRequirement[] = ["ability"]; + + async invokeAsync( + _action: string, + request: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + return request; + } +} + +class LifecycleOrderProbePlugin extends AsyncPluginBase { + readonly id = "test.lifecycle-order-probe"; + readonly version = 1; + readonly requires: BridgeContextRequirement[] = ["ui-context"]; + + override onLifecycle(event: BridgeLifecycleEvent, _context: BridgePluginHookContext): void { + lifecycleOrderProbe.push(event.kind); + } + + async invokeAsync( + _action: string, + request: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + return request; + } +} + +class BroadcastProbePlugin extends AsyncPluginBase { + readonly id = "test.broadcast-probe"; + readonly version = 1; + readonly requires: BridgeContextRequirement[] = ["ability"]; + + override attachContext(context: BridgePluginContext): void { + super.attachContext(context); + if (broadcastProbeContext === undefined) { + broadcastProbeContext = context; + } + } + + async invokeAsync( + _action: string, + request: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + return request; + } +} + export default function localUnitTest() { describe("localUnitTest", () => { // Defines a test suite. Two parameters are supported: test suite name and test suite function. @@ -152,6 +212,17 @@ export default function localUnitTest() { expect(decoded.beta).assertEqual("state&with=delimiters"); expect(Object.keys(AbilityStateCodec.decode("legacy-state")).length).assertEqual(0); }); + it("nativeModuleDeclarationsMustBeDistinct", 0, () => { + let duplicateError = ""; + try { + NativeModuleLoader.resolveModuleNames(["module_a", "module_a"]); + } catch (error) { + duplicateError = String(error); + } + expect( + duplicateError.indexOf("every DefaultXComponent needs a distinct module") >= 0, + ).assertTrue(); + }); it("cancellableTaskScopeCancelsAStuckHook", 0, async () => { const scope = new CancellableTaskScope("test hook", 1_000); let cancellationObserved = false; @@ -208,6 +279,47 @@ export default function localUnitTest() { BridgeHostRegistry.beginClosing(sessionId); await BridgeHostRegistry.dispose(sessionId); }); + it("abilityPluginDoesNotDependOnDefaultXComponentLifetime", 0, async () => { + const moduleName = "ability_only_module"; + const sessionId = BridgeHostRegistry.prepare([moduleName], {} as common.UIAbilityContext, [ + new LazyPlugin(() => new AbilityEchoPlugin()), + ]); + await BridgeHostRegistry.activateAbility(sessionId, moduleName, { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload(""), + }); + + const invoke = async (): Promise => + await BridgeHostRegistry.invokeAsync( + sessionId, + moduleName, + "test.ability-echo", + 1, + "echo", + "test.InstallRequest", + "test.InstallRequest", + new InstallProbeRequest(), + 1_000, + ); + expect(((await invoke()) as InstallProbeRequest).accepted).assertTrue(); + + await BridgeHostRegistry.setWindowStage(sessionId, moduleName, {} as window.WindowStage); + await BridgeHostRegistry.attachComponent( + sessionId, + moduleName, + "ability-only-owner", + {} as UIContext, + {} as FrameNode, + ); + await BridgeHostRegistry.detachComponent(sessionId, moduleName, "ability-only-owner"); + + // Removing this module's UI root gates ui-context plugins only. The Ability-session + // transport and ability-only plugins remain available until NativeAbility.onDestroy. + expect(((await invoke()) as InstallProbeRequest).accepted).assertTrue(); + await BridgeHostRegistry.clearWindowStage(sessionId, moduleName); + BridgeHostRegistry.beginClosing(sessionId); + await BridgeHostRegistry.dispose(sessionId); + }); it("invalidatedWindowStageCannotBecomeReadyFromAQueuedCreate", 0, async () => { stageInstallProbeCount = 0; const sessionId = BridgeHostRegistry.prepare(["test_native"], {} as common.UIAbilityContext, [ @@ -234,5 +346,181 @@ export default function localUnitTest() { BridgeHostRegistry.beginClosing(sessionId); await BridgeHostRegistry.dispose(sessionId); }); + it("oneNativeModuleCannotBelongToTwoActiveAbilities", 0, async () => { + const firstSession = BridgeHostRegistry.prepare( + ["module_owner_test"], + {} as common.UIAbilityContext, + [], + ); + let duplicateError = ""; + try { + BridgeHostRegistry.prepare(["module_owner_test"], {} as common.UIAbilityContext, []); + } catch (error) { + duplicateError = String(error); + } + expect(duplicateError.indexOf("already belongs to active Ability") >= 0).assertTrue(); + + BridgeHostRegistry.beginClosing(firstSession); + await BridgeHostRegistry.dispose(firstSession); + const replacementSession = BridgeHostRegistry.prepare( + ["module_owner_test"], + {} as common.UIAbilityContext, + [], + ); + BridgeHostRegistry.beginClosing(replacementSession); + await BridgeHostRegistry.dispose(replacementSession); + }); + it("pluginInstanceCannotBeSharedAcrossNativeModules", 0, () => { + const singleton = new InstallProbePlugin(); + let reuseError = ""; + try { + BridgeHostRegistry.prepare( + ["singleton_module_a", "singleton_module_b"], + {} as common.UIAbilityContext, + [new LazyPlugin(() => singleton)], + ); + } catch (error) { + reuseError = String(error); + } + expect(reuseError.indexOf("cannot be reused") >= 0).assertTrue(); + }); + it("oneNativeModuleRejectsASecondDefaultXComponent", 0, async () => { + const moduleName = "single_component_module"; + const sessionId = BridgeHostRegistry.prepare([moduleName], {} as common.UIAbilityContext, []); + await BridgeHostRegistry.activateAbility(sessionId, moduleName, { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload(""), + }); + await BridgeHostRegistry.setWindowStage(sessionId, moduleName, {} as window.WindowStage); + await BridgeHostRegistry.attachComponent( + sessionId, + moduleName, + "owner-a", + {} as UIContext, + {} as FrameNode, + ); + + let duplicateError = ""; + try { + await BridgeHostRegistry.attachComponent( + sessionId, + moduleName, + "owner-b", + {} as UIContext, + {} as FrameNode, + ); + } catch (error) { + duplicateError = String(error); + } + expect( + duplicateError.indexOf("every component must use a distinct native module") >= 0, + ).assertTrue(); + + // Cleanup from the rejected component must not detach the valid owner. + await BridgeHostRegistry.detachComponent(sessionId, moduleName, "owner-b"); + let staleCleanupDetachedOwner = false; + try { + await BridgeHostRegistry.attachComponent( + sessionId, + moduleName, + "owner-c", + {} as UIContext, + {} as FrameNode, + ); + staleCleanupDetachedOwner = true; + } catch {} + expect(staleCleanupDetachedOwner).assertFalse(); + await BridgeHostRegistry.detachComponent(sessionId, moduleName, "owner-a"); + BridgeHostRegistry.beginClosing(sessionId); + await BridgeHostRegistry.dispose(sessionId); + }); + it("oneAbilityCanAttachComponentsBackedByDifferentModules", 0, async () => { + const modules = ["component_module_a", "component_module_b"]; + const sessionId = BridgeHostRegistry.prepare(modules, {} as common.UIAbilityContext, []); + for (const moduleName of modules) { + await BridgeHostRegistry.activateAbility(sessionId, moduleName, { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload(""), + }); + await BridgeHostRegistry.setWindowStage(sessionId, moduleName, {} as window.WindowStage); + await BridgeHostRegistry.attachComponent( + sessionId, + moduleName, + `owner-${moduleName}`, + {} as UIContext, + {} as FrameNode, + ); + } + + for (const moduleName of modules) { + await BridgeHostRegistry.detachComponent(sessionId, moduleName, `owner-${moduleName}`); + } + BridgeHostRegistry.beginClosing(sessionId); + await BridgeHostRegistry.dispose(sessionId); + }); + it("processGlobalPluginEventReachesEveryActiveNativeModule", 0, async () => { + broadcastProbeContext = undefined; + const modules = ["broadcast_module_a", "broadcast_module_b"]; + const receivedBy: string[] = []; + const sessionId = BridgeHostRegistry.prepare(modules, {} as common.UIAbilityContext, [ + new LazyPlugin(() => new BroadcastProbePlugin()), + ]); + for (const moduleName of modules) { + BridgeHostRegistry.attachEventSink(sessionId, moduleName, (): InstallProbeResponse => { + receivedBy.push(moduleName); + return new InstallProbeResponse(); + }); + await BridgeHostRegistry.activateAbility(sessionId, moduleName, { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload(""), + }); + } + + const context = broadcastProbeContext; + if (context === undefined) { + throw new Error("broadcast probe was not installed"); + } + const responses = context.invokeNativeSyncAcrossModules( + "engine-event", + "test.InstallRequest", + "test.InstallResponse", + new InstallProbeRequest(), + ); + expect(responses.length).assertEqual(2); + expect(receivedBy.join(",")).assertEqual(modules.join(",")); + + BridgeHostRegistry.beginClosing(sessionId); + await BridgeHostRegistry.dispose(sessionId); + broadcastProbeContext = undefined; + }); + it("windowStageCleanupCannotOvertakeComponentCleanup", 0, async () => { + lifecycleOrderProbe = []; + const moduleName = "lifecycle_order_module"; + const sessionId = BridgeHostRegistry.prepare([moduleName], {} as common.UIAbilityContext, [ + new LazyPlugin(() => new LifecycleOrderProbePlugin()), + ]); + await BridgeHostRegistry.activateAbility(sessionId, moduleName, { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload(""), + }); + await BridgeHostRegistry.setWindowStage(sessionId, moduleName, {} as window.WindowStage); + await BridgeHostRegistry.attachComponent( + sessionId, + moduleName, + "owner-a", + {} as UIContext, + {} as FrameNode, + ); + lifecycleOrderProbe = []; + + // Reproduce platform WindowStage teardown and component disappearance in one turn. + const clearStage = BridgeHostRegistry.clearWindowStage(sessionId, moduleName); + const detachComponent = BridgeHostRegistry.detachComponent(sessionId, moduleName, "owner-a"); + await Promise.all([clearStage, detachComponent]); + expect(lifecycleOrderProbe.join(",")).assertEqual("ui-context-destroy,window-stage-destroy"); + + BridgeHostRegistry.beginClosing(sessionId); + await BridgeHostRegistry.dispose(sessionId); + }); }); } diff --git a/plugins/resource/src/main/ets/ResourcePlugin.ets b/plugins/resource/src/main/ets/ResourcePlugin.ets index d0891149..aaeb4096 100644 --- a/plugins/resource/src/main/ets/ResourcePlugin.ets +++ b/plugins/resource/src/main/ets/ResourcePlugin.ets @@ -2,7 +2,6 @@ import { AsyncPluginBase, BridgeCallContext, BridgeContextRequirement, - BridgeLifecycleEvent, BridgePluginHookContext, BridgeTypedValue, } from "@ohos-rs/ability"; @@ -15,23 +14,20 @@ const RESOURCE_MANAGER_READY_RESPONSE_TYPE = "ohos.resource.ResourceManagerReady * Resource manager wrapper plugin. * * This wrapper owns only the HarmonyOS `resourceManager` platform object. It pushes the object - * to Rust once, on `ability-create`: the inbound event sink is attached right after - * `module.init` in `NativeAbility.onCreate`, so no UI render is required. Rust converts the + * to Rust once from `onInstall`: the inbound event sink is attached before Ability activation, + * so no WindowStage or DefaultXComponent is required. Rust converts the * object to a native `NativeResourceManager` pointer inside the same N-API callback and * performs every subsequent read through the OpenHarmony C API — no ArkTS call is involved. * - * The ArkTS wrapper remains session-scoped. Rust owns the process-wide native manager pointer; - * sharing one mutable ArkTS plugin instance across sessions would overwrite its hook context. + * The ArkTS wrapper remains module/session-scoped. Its matching registered Rust plugin instance + * owns the native manager, so multiple native modules cannot overwrite each other's state. */ export class ResourcePlugin extends AsyncPluginBase { readonly id = "ohos.resource"; readonly version = 1; readonly requires: BridgeContextRequirement[] = ["ability"]; - onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginHookContext): void { - if (event.kind !== "ability-create") { - return; - } + override onInstall(context: BridgePluginHookContext): void { const manager = context.abilityContext?.resourceManager; if (manager === undefined) { throw new Error("ohos.resource requires abilityContext.resourceManager"); diff --git a/plugins/webview/src/main/ets/WebviewPlugin.ets b/plugins/webview/src/main/ets/WebviewPlugin.ets index ec97121c..3e38bfda 100644 --- a/plugins/webview/src/main/ets/WebviewPlugin.ets +++ b/plugins/webview/src/main/ets/WebviewPlugin.ets @@ -9,7 +9,6 @@ import { AsyncPluginBase, BridgeContextRequirement, BridgeLifecycleEvent, - BridgeWindowLifecyclePayload, } from "@ohos-rs/ability"; const CREATE_REQUEST_TYPE = "ohos.webview.CreateRequest"; @@ -26,6 +25,7 @@ const DOWNLOAD_START_RESPONSE_TYPE = "ohos.webview.DownloadStartResponse"; const DOWNLOAD_END_EVENT_TYPE = "ohos.webview.DownloadEndEvent"; const TITLE_CHANGE_EVENT_TYPE = "ohos.webview.TitleChangeEvent"; const ENGINE_LIFECYCLE_EVENT_TYPE = "ohos.webview.EngineLifecycleEvent"; +const ENGINE_LIFECYCLE_RESPONSE_TYPE = "ohos.webview.EngineLifecycleResponse"; const CONTROLLER_EVENT_TYPE = "ohos.webview.ControllerEvent"; const EVENT_ACKNOWLEDGEMENT_TYPE = "ohos.webview.EventAcknowledgement"; @@ -68,16 +68,10 @@ interface WebviewEventOptions { interface WebviewCreatePayload { id: string; - /** - * Window surface key the WebView mounts into. Defaults to `"main"` (the default window's - * `DefaultXComponent`); sub-window instances register under their own `windowKey` and the - * plugin mounts into that scope. - */ - windowKey?: string | null; /** * Optional opaque container handle issued by the built-in ohos.node plugin. When provided the - * WebView FrameNode is appended under that container instead of the window root, so an - * RS-layer node tree can adopt WebViews as children. Absent = full-bleed window root mount. + * WebView FrameNode is appended under that container instead of this module's component root, + * so an RS-layer node tree can adopt WebViews as children. */ parentHandle?: number | null; url?: string | null; @@ -110,6 +104,8 @@ interface ScriptPayload { interface ManagedWebview extends WebviewCreatePayload { controller: WebviewController; + /** Process-unique ArkWeb controller tag; distinct from the module-local public ID. */ + nativeTag: string; style: WebviewStyle; eventOptions: WebviewEventOptions; didInitialLoad: boolean; @@ -121,10 +117,10 @@ interface ManagedWebview extends WebviewCreatePayload { onTitleChange: (title: string) => void; onDownloadStart: (url: string, tempPath: string | null) => WebviewDownloadStartResponse; onDownloadEnd: (url: string, tempPath: string | null, success: boolean) => void; - /** How this WebView was mounted: a parent-handle container (ohos.node) or the window root. */ + /** How this WebView was mounted: a parent-handle container or the component root. */ mountParentHandle: number | null; - /** The window surface key this WebView mounts into (`"main"` by default). */ - windowKey: string; + /** Host-internal cleanup key; WebView IDs remain opaque business identifiers. */ + mountKey: string; } interface ControllerWaiter { @@ -256,10 +252,12 @@ class WebviewStringResponse { class WebviewNavigationRequest { readonly id: string; + readonly nativeTag: string; readonly url: string; - constructor(id: string, url: string) { + constructor(id: string, nativeTag: string, url: string) { this.id = id; + this.nativeTag = nativeTag; this.url = url; } } @@ -270,11 +268,13 @@ interface WebviewNavigationResponse { class WebviewDownloadStartRequest { readonly id: string; + readonly nativeTag: string; readonly url: string; readonly tempPath: string | null; - constructor(id: string, url: string, tempPath: string | null) { + constructor(id: string, nativeTag: string, url: string, tempPath: string | null) { this.id = id; + this.nativeTag = nativeTag; this.url = url; this.tempPath = tempPath; } @@ -287,12 +287,20 @@ interface WebviewDownloadStartResponse { class WebviewDownloadEndEvent { readonly id: string; + readonly nativeTag: string; readonly url: string; readonly tempPath: string | null; readonly success: boolean; - constructor(id: string, url: string, tempPath: string | null, success: boolean) { + constructor( + id: string, + nativeTag: string, + url: string, + tempPath: string | null, + success: boolean, + ) { this.id = id; + this.nativeTag = nativeTag; this.url = url; this.tempPath = tempPath; this.success = success; @@ -301,27 +309,33 @@ class WebviewDownloadEndEvent { class WebviewTitleChangeEvent { readonly id: string; + readonly nativeTag: string; readonly title: string; - constructor(id: string, title: string) { + constructor(id: string, nativeTag: string, title: string) { this.id = id; + this.nativeTag = nativeTag; this.title = title; } } class WebviewEngineLifecycleEvent { readonly phase: string; + readonly schemes: WebviewSchemeDeclaration[]; - constructor(phase: string) { + constructor(phase: string, schemes: WebviewSchemeDeclaration[]) { this.phase = phase; + this.schemes = schemes; } } class WebviewControllerEvent { readonly id: string; + readonly nativeTag: string; - constructor(id: string) { + constructor(id: string, nativeTag: string) { this.id = id; + this.nativeTag = nativeTag; } } @@ -329,7 +343,19 @@ interface WebviewEventAcknowledgement { accepted: boolean; } +interface WebviewSchemeDeclaration { + scheme: string; + options: number; +} + +interface WebviewEngineLifecycleResponse { + accepted: boolean; + schemes: WebviewSchemeDeclaration[]; +} + let webEngineInitialized = false; +let webEngineSchemes: WebviewSchemeDeclaration[] = []; +let nextWebviewNativeTagId = 1; /** * Custom schemes must be registered by Rust before this call. The two events let the Rust @@ -337,22 +363,32 @@ let webEngineInitialized = false; * declaration set afterwards. */ function ensureWebEngineInitialized(context: BridgePluginContext): void { - if (webEngineInitialized) { - return; - } - notifyNative( - context, - "before-engine-init", - ENGINE_LIFECYCLE_EVENT_TYPE, - new WebviewEngineLifecycleEvent("before-engine-init") as ESObject, - ); - webview.WebviewController.initializeWebEngine(); - webEngineInitialized = true; - notifyNative( + if (!webEngineInitialized) { + // Seal and validate every module's declarations before any module mutates ArkWeb's global + // scheme registry. + const sealedSchemes = notifyWebEngineAcrossModules( + context, + "seal-engine-schemes", + ENGINE_LIFECYCLE_EVENT_TYPE, + new WebviewEngineLifecycleEvent("seal-engine-schemes", []) as ESObject, + ); + notifyWebEngineAcrossModules( + context, + "before-engine-init", + ENGINE_LIFECYCLE_EVENT_TYPE, + new WebviewEngineLifecycleEvent("before-engine-init", sealedSchemes) as ESObject, + ); + webview.WebviewController.initializeWebEngine(); + webEngineSchemes = sealedSchemes; + webEngineInitialized = true; + } + // ArkWeb is process-global, while every native module has independent Rust static state. + // Notify every active module, including those whose DefaultXComponent has not appeared yet. + notifyWebEngineAcrossModules( context, "engine-initialized", ENGINE_LIFECYCLE_EVENT_TYPE, - new WebviewEngineLifecycleEvent("engine-initialized") as ESObject, + new WebviewEngineLifecycleEvent("engine-initialized", webEngineSchemes) as ESObject, ); } @@ -434,13 +470,6 @@ function ensureCreatePayload(payload: WebviewCreatePayload): WebviewCreatePayloa ) { throw new Error("WebView parentHandle must be a positive integer ohos.node handle"); } - if ( - payload.windowKey !== undefined && - payload.windowKey !== null && - !/^[A-Za-z0-9._-]+$/.test(payload.windowKey) - ) { - throw new Error("WebView windowKey must contain only letters, digits, '.', '_' or '-'"); - } normalizeEventOptions(payload.eventOptions); return payload; } @@ -539,17 +568,18 @@ function headersOf(headers?: Record | null): WebHeader[] { return result; } -function mountKeyOf(id: string): string { - return `ohos.webview.${id}`; -} - -function navigationDecision(context: BridgePluginContext, id: string, url: string): boolean { +function navigationDecision( + context: BridgePluginContext, + id: string, + nativeTag: string, + url: string, +): boolean { try { const response = context.invokeNativeSync( "navigation-request", NAVIGATION_REQUEST_TYPE, NAVIGATION_RESPONSE_TYPE, - new WebviewNavigationRequest(id, url) as ESObject, + new WebviewNavigationRequest(id, nativeTag, url) as ESObject, ) as WebviewNavigationResponse; if ( response === null || @@ -570,6 +600,7 @@ function navigationDecision(context: BridgePluginContext, id: string, url: strin function downloadStartDecision( context: BridgePluginContext, id: string, + nativeTag: string, url: string, tempPath: string | null, ): WebviewDownloadStartResponse { @@ -578,7 +609,7 @@ function downloadStartDecision( "download-start", DOWNLOAD_START_REQUEST_TYPE, DOWNLOAD_START_RESPONSE_TYPE, - new WebviewDownloadStartRequest(id, url, tempPath) as ESObject, + new WebviewDownloadStartRequest(id, nativeTag, url, tempPath) as ESObject, ) as WebviewDownloadStartResponse; if ( response === null || @@ -614,6 +645,61 @@ function notifyNative( } } +function notifyWebEngineAcrossModules( + context: BridgePluginContext, + event: string, + requestTypeName: string, + value: ESObject, +): WebviewSchemeDeclaration[] { + const responses: ESObject[] = context.invokeNativeSyncAcrossModules( + event, + requestTypeName, + ENGINE_LIFECYCLE_RESPONSE_TYPE, + value, + ) as ESObject[]; + if (responses.length === 0) { + throw new Error(`WebView native notification '${event}' had no active module receiver`); + } + const schemeOptions: Map = new Map(); + for (let index: number = 0; index < responses.length; index++) { + const responseValue: ESObject = responses[index]; + const response = responseValue as WebviewEngineLifecycleResponse; + if ( + response === null || + typeof response !== "object" || + response.accepted !== true || + !Array.isArray(response.schemes) + ) { + throw new Error(`WebView native notification '${event}' was not accepted by every module`); + } + for (const declaration of response.schemes) { + if ( + declaration === null || + typeof declaration !== "object" || + !declaration.scheme || + !Number.isInteger(declaration.options) || + declaration.options < 0 + ) { + throw new Error(`WebView native notification '${event}' returned an invalid scheme`); + } + if ( + schemeOptions.has(declaration.scheme) && + schemeOptions.get(declaration.scheme) !== declaration.options + ) { + throw new Error( + `WebView scheme '${declaration.scheme}' has conflicting options across native modules`, + ); + } + schemeOptions.set(declaration.scheme, declaration.options); + } + } + const declarations: WebviewSchemeDeclaration[] = []; + for (const entry of schemeOptions.entries()) { + declarations.push({ scheme: entry[0], options: entry[1] }); + } + return declarations; +} + function setupDownloadDelegate(data: ManagedWebview): void { if (data.eventOptions.downloadStart !== true && data.eventOptions.downloadEnd !== true) { return; @@ -651,7 +737,7 @@ function BuildWebview(data: ManagedWebview) { .width(data.style.width ?? "100%") .height(data.style.height ?? "100%") .position({ x: data.style.x ?? 0, y: data.style.y ?? 0 }) - .backgroundColor(data.style.backgroundColor) + .backgroundColor(data.style.backgroundColor ?? undefined) .visibility(data.style.visible === false ? Visibility.Hidden : Visibility.Visible) .javaScriptAccess(data.javascriptEnabled ?? true) .mediaPlayGestureAccess(data.autoplay === true ? false : true) @@ -689,12 +775,13 @@ const webviewBuilder = wrapBuilder<[ManagedWebview]>(BuildWebview); /** * One surface per WebviewPlugin instance (per session and module). It owns no root node of its - * own: every WebView is a FrameNode mounted either into the session root (full-bleed default) or + * own: every WebView is mounted either into this module's component root (full-bleed default) or * under a caller-provided container handle from the built-in ohos.node plugin. */ class WebviewSurface { private readonly entries: Map = new Map(); private disposed = false; + private nextMountKey = 1; private readonly pluginContext: BridgePluginContext; constructor(pluginContext: BridgePluginContext) { @@ -708,11 +795,14 @@ class WebviewSurface { const style = normalizeStyle(payload.style, payload.transparent); const eventOptions = normalizeEventOptions(payload.eventOptions); const parentHandle = payload.parentHandle ?? null; - const windowKey = payload.windowKey ?? "main"; + const instanceId = this.nextMountKey++; + const nativeTagId = nextWebviewNativeTagId++; + const nativeTag = `ohos.webview.${this.pluginContext.sessionId}.${this.pluginContext.moduleName}.${nativeTagId}`; const data: ManagedWebview = { id: payload.id, mountParentHandle: parentHandle, - windowKey, + mountKey: `ohos.webview.${instanceId}`, + nativeTag, url: payload.url, html: payload.html, javascriptEnabled: payload.javascriptEnabled, @@ -723,7 +813,7 @@ class WebviewSurface { headers: payload.headers, transparent: payload.transparent, eventOptions, - controller: new webview.WebviewController(payload.id) as WebviewController, + controller: new webview.WebviewController(nativeTag) as WebviewController, style, didInitialLoad: false, beginControllerAttach: (): boolean => false, @@ -734,7 +824,7 @@ class WebviewSurface { if (eventOptions.navigationIntercept !== true) { return false; } - return navigationDecision(this.pluginContext, payload.id, url); + return navigationDecision(this.pluginContext, payload.id, nativeTag, url); }, onTitleChange: (title: string): void => { if (eventOptions.titleChange !== true) { @@ -745,14 +835,14 @@ class WebviewSurface { this.pluginContext, "title-change", TITLE_CHANGE_EVENT_TYPE, - new WebviewTitleChangeEvent(payload.id, title) as ESObject, + new WebviewTitleChangeEvent(payload.id, nativeTag, title) as ESObject, ); } catch (error) { console.error("WebView title notification failed: " + String(error)); } }, onDownloadStart: (url: string, tempPath: string | null): WebviewDownloadStartResponse => { - return downloadStartDecision(this.pluginContext, payload.id, url, tempPath); + return downloadStartDecision(this.pluginContext, payload.id, nativeTag, url, tempPath); }, onDownloadEnd: (url: string, tempPath: string | null, success: boolean): void => { if (eventOptions.downloadEnd !== true) { @@ -763,13 +853,14 @@ class WebviewSurface { this.pluginContext, "download-end", DOWNLOAD_END_EVENT_TYPE, - new WebviewDownloadEndEvent(payload.id, url, tempPath, success) as ESObject, + new WebviewDownloadEndEvent(payload.id, nativeTag, url, tempPath, success) as ESObject, ); } catch (error) { console.error("WebView download-end notification failed: " + String(error)); } }, }; + ensureWebEngineInitialized(callContext); if (payload.devtools) { enableWebDebuggingIfAvailable(); } @@ -781,7 +872,7 @@ class WebviewSurface { this.pluginContext, "controller-attached", CONTROLLER_EVENT_TYPE, - new WebviewControllerEvent(data.id) as ESObject, + new WebviewControllerEvent(data.id, data.nativeTag) as ESObject, ); }; data.markControllerReady = (): void => entry.markControllerReady(); @@ -795,11 +886,14 @@ class WebviewSurface { if (frameNode === null) { throw new Error(`Unable to build WebView '${payload.id}': no FrameNode`); } - const scope = this.pluginContext.windowScope(windowKey); if (parentHandle !== null) { - scope.getFrameNode(parentHandle).appendChild(frameNode); + this.pluginContext.getFrameNode(parentHandle).appendChild(frameNode); } else { - scope.appendChild(mountKeyOf(payload.id), frameNode); + this.pluginContext.appendChild(data.mountKey, frameNode, (): void => { + try { + frameNode.dispose(); + } catch {} + }); } } catch (error) { if (registered) { @@ -924,14 +1018,19 @@ class WebviewSurface { this.entries.delete(id); entry.failController(`WebView '${id}' was removed before its controller attached`); if (detachNode) { - const scope = this.pluginContext.windowScope(entry.data.windowKey); const parentHandle = entry.data.mountParentHandle; if (parentHandle !== null) { - try { - scope.getFrameNode(parentHandle).removeChild(entry.node.getFrameNode()); - } catch {} + const frameNode = entry.node.getFrameNode(); + if (frameNode !== null) { + try { + this.pluginContext.getFrameNode(parentHandle).removeChild(frameNode); + } catch {} + try { + frameNode.dispose(); + } catch {} + } } else { - scope.removeChild(mountKeyOf(id)); + this.pluginContext.removeChild(entry.data.mountKey); } } try { @@ -939,7 +1038,7 @@ class WebviewSurface { this.pluginContext, "controller-removed", CONTROLLER_EVENT_TYPE, - new WebviewControllerEvent(id) as ESObject, + new WebviewControllerEvent(id, entry.data.nativeTag) as ESObject, ); } catch (error) { console.error("WebView controller-removed notification failed: " + String(error)); @@ -970,16 +1069,6 @@ class WebviewSurface { } } - disposeWindow(windowKey: string, detachNodes: boolean = true): void { - for (const entryPair of Array.from(this.entries.entries())) { - const id = entryPair[0]; - const entry = entryPair[1]; - if (entry.data.windowKey === windowKey) { - this.remove(id, entry, detachNodes); - } - } - } - private assertActive(): void { if (this.disposed) { throw new Error("WebView surface is already disposed"); @@ -1006,25 +1095,23 @@ class WebviewSurface { export class WebviewPlugin extends AsyncPluginBase { readonly id = "ohos.webview"; - readonly version = 1; + readonly version = 2; readonly requires: BridgeContextRequirement[] = ["ui-context"]; private surface?: WebviewSurface; - override onInstall(context: BridgePluginHookContext): void { - ensureWebEngineInitialized(context); - this.surface = new WebviewSurface(context); + override onInstall(_context: BridgePluginHookContext): void { + this.surface = new WebviewSurface(this.getContext()); } - override onLifecycle(event: BridgeLifecycleEvent, context: BridgePluginHookContext): void { - if (event.kind === "window-detached") { - const payload = event.payload as BridgeWindowLifecyclePayload; - // BridgeHost has already removed this window from lookup and owns its node-tree cleanup. - // Release only controllers belonging to that window without addressing a replacement root. - this.surface?.disposeWindow(payload.windowKey, false); + override onLifecycle(event: BridgeLifecycleEvent, _context: BridgePluginHookContext): void { + if (event.kind === "ui-context-destroy") { + // BridgeHost already detached this module's component and owns its node-tree cleanup. + this.surface?.dispose(false); + this.surface = undefined; return; } if (event.kind === "ui-context-ready" && this.surface === undefined) { - this.surface = new WebviewSurface(context); + this.surface = new WebviewSurface(this.getContext()); } } diff --git a/plugins/window/src/main/ets/WindowPlugin.ets b/plugins/window/src/main/ets/WindowPlugin.ets index 8576c310..bf15ad4e 100644 --- a/plugins/window/src/main/ets/WindowPlugin.ets +++ b/plugins/window/src/main/ets/WindowPlugin.ets @@ -64,8 +64,8 @@ function parseAvoidAreaRequest(payload: BridgeTypedValue): AvoidAreaRequest { export class WindowPlugin extends SyncPluginBase { readonly id = "ohos.window"; - readonly version = 1; - readonly requires: BridgeContextRequirement[] = ["window-stage"]; + readonly version = 2; + readonly requires: BridgeContextRequirement[] = ["ui-context"]; invokeSync( action: string, @@ -77,9 +77,8 @@ export class WindowPlugin extends SyncPluginBase { } const request = parseAvoidAreaRequest(payload); try { - const mainWindow = context.getWindowStage().getMainWindowSync(); const areaType = request.areaType as window.AvoidAreaType; - const response = new AvoidAreaResponse(mainWindow.getWindowAvoidArea(areaType)); + const response = new AvoidAreaResponse(context.getWindow().getWindowAvoidArea(areaType)); return { typeName: AVOID_AREA_RESPONSE_TYPE, value: response }; } catch (error) { throw new Error(`Unable to get window avoid area: ${String(error)}`); diff --git a/rust_example/demo_native/Cargo.toml b/rust_example/demo_native/Cargo.toml index 8c83d7eb..e4814700 100755 --- a/rust_example/demo_native/Cargo.toml +++ b/rust_example/demo_native/Cargo.toml @@ -13,11 +13,13 @@ napi-derive-ohos = { workspace = true } openharmony-ability = { workspace = true } openharmony-ability-derive = { workspace = true } +openharmony-ability-plugin-app-control = { workspace = true } openharmony-ability-plugin-files = { workspace = true } openharmony-ability-plugin-permission = { workspace = true } openharmony-ability-plugin-resource = { workspace = true } openharmony-ability-plugin-url = { workspace = true } openharmony-ability-plugin-webview = { workspace = true } +openharmony-ability-plugin-window = { workspace = true } ohos-hilog-binding = { workspace = true } futures-channel = { workspace = true } futures-executor = "0.3" diff --git a/rust_example/demo_native/src/lib.rs b/rust_example/demo_native/src/lib.rs index be66d3b5..55334dcd 100755 --- a/rust_example/demo_native/src/lib.rs +++ b/rust_example/demo_native/src/lib.rs @@ -19,10 +19,11 @@ use napi_ohos::{Either, Env, Error, Result}; use ohos_hilog_binding::hilog_info; use openharmony_ability::{Event, InputEvent, NodeExt, OpenHarmonyApp}; use openharmony_ability_derive::ability; +use openharmony_ability_plugin_app_control::AppControlBridgePlugin; use openharmony_ability_plugin_files::{ dialog_type, FileDialogFilter, FileDialogOptions, FilesExt, }; -use openharmony_ability_plugin_permission::PermissionExt; +use openharmony_ability_plugin_permission::{PermissionBridgePlugin, PermissionExt}; use openharmony_ability_plugin_resource::{ResourceBridgePlugin, ResourceExt}; use openharmony_ability_plugin_url::UrlExt; use openharmony_ability_plugin_webview::{ @@ -30,6 +31,7 @@ use openharmony_ability_plugin_webview::{ WebviewDownloadStartResponse, WebviewExt, WebviewJavascriptProxyBuilder, WebviewProtocol, WebviewProtocolOptions, WebviewStyle, }; +use openharmony_ability_plugin_window::WindowBridgePlugin; static INNER_APP: LazyLock>> = LazyLock::new(|| RwLock::new(None)); static PERMISSION_REQUESTED: AtomicBool = AtomicBool::new(false); @@ -49,7 +51,6 @@ static WEBVIEW_BINDINGS: LazyLock> = const WEB_TAG: &str = "demo_webview"; const COMPOSED_WEB_TAG: &str = "demo_composed_webview"; const BOTTOM_WEB_TAG: &str = "demo_bottom_webview"; -const SUB_WINDOW_WEB_TAG: &str = "demo_sub_window_webview"; const WEB_SCHEME: &str = "demoweb"; const WEB_URL: &str = "demoweb://index"; const INDEX: &str = include_str!("index.html"); @@ -123,7 +124,7 @@ fn ensure_demo_webview_bindings(client: &WebviewClient) -> Result<()> { } /// Demo: reports whether the `ohos.resource` wrapper has pushed the native resource manager -/// (it is installed on `ui-context-ready` after the native module is rendered). +/// (it is installed from the Ability-scoped ArkTS `onInstall`, before UI rendering is required). #[napi] pub fn demo_resource_manager_ready() -> bool { current_app() @@ -252,7 +253,7 @@ pub fn toggle_back_press_intercept() -> bool { } /// Creates a WebView through the WebView plugin. Without a parent container handle the ArkTS -/// host mounts the WebView FrameNode into the session root (full-bleed default); it never touches +/// host mounts the WebView FrameNode into this module's component root (full-bleed default); it never touches /// DefaultXComponent internals. #[napi] pub async fn create_demo_webview() -> Result<()> { @@ -270,7 +271,7 @@ pub async fn create_demo_webview() -> Result<()> { /// Demonstrates the normalized composition model: an RS-layer container node is created through /// the built-in ohos.node plugin, the WebView FrameNode is attached under it via -/// `parent_node(...)`, and the whole tree is mounted into the session root. +/// `parent_node(...)`, and the whole tree is mounted into the module/component root. #[napi] pub async fn create_composed_demo_webview() -> Result<()> { let client = current_app()?.webview()?; @@ -317,23 +318,6 @@ pub async fn create_bottom_demo_webview() -> Result<()> { Ok(()) } -/// Creates a WebView inside the sub-window surface (`window_key = "sub"`). The sub window page -/// places a second `DefaultXComponent({ windowKey: "sub" })`, and this WebView mounts into that -/// window's own node tree instead of the main window's. -#[napi] -pub async fn create_sub_window_webview() -> Result<()> { - let client = current_app()?.webview()?; - client - .create( - WebviewCreateRequest::new(SUB_WINDOW_WEB_TAG) - .window_key("sub") - .transparent(true) - .url(WEB_URL), - ) - .await?; - Ok(()) -} - /// Proves the Rust → WebView JavaScript path. The bridge waits for `onControllerAttached` before /// calling ArkTS `WebviewController.runJavaScript`. #[napi] @@ -385,15 +369,21 @@ fn openharmony_app(app: OpenHarmonyApp) { if let Err(error) = app.register_plugin(raw_bridge::DemoTypedPlugin) { hilog_info!(format!("failed to register demo.raw facade: {error}").as_str()); } + app.register_plugin(PermissionBridgePlugin) + .expect("demo permission facade must be registered"); + app.register_plugin(AppControlBridgePlugin) + .expect("demo app-control facade must be registered"); app.register_plugin(WebviewBridgePlugin) .expect("demo WebView facade must be registered"); + app.register_plugin(WindowBridgePlugin) + .expect("demo Window facade must be registered"); if let Err(error) = app.register_plugin(openharmony_ability_plugin_url::UrlBridgePlugin) { hilog_info!(format!("failed to register url facade: {error}").as_str()); } if let Err(error) = app.register_plugin(openharmony_ability_plugin_files::FilesBridgePlugin) { hilog_info!(format!("failed to register files facade: {error}").as_str()); } - if let Err(error) = app.register_plugin(ResourceBridgePlugin) { + if let Err(error) = app.register_plugin(ResourceBridgePlugin::new()) { hilog_info!(format!("failed to register resource facade: {error}").as_str()); } hilog_info!(format!( diff --git a/rust_example/demo_sub_native/Cargo.toml b/rust_example/demo_sub_native/Cargo.toml new file mode 100644 index 00000000..f2591273 --- /dev/null +++ b/rust_example/demo_sub_native/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "demo_sub_native" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi-ohos = { workspace = true, features = ["tokio_rt"] } +napi-derive-ohos = { workspace = true } +openharmony-ability = { workspace = true } +openharmony-ability-derive = { workspace = true } +openharmony-ability-plugin-webview = { workspace = true } +openharmony-ability-plugin-window = { workspace = true } + +[build-dependencies] +napi-build-ohos = { workspace = true } diff --git a/rust_example/demo_sub_native/build.rs b/rust_example/demo_sub_native/build.rs new file mode 100644 index 00000000..a6e46239 --- /dev/null +++ b/rust_example/demo_sub_native/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build_ohos::setup(); +} diff --git a/rust_example/demo_sub_native/src/lib.rs b/rust_example/demo_sub_native/src/lib.rs new file mode 100644 index 00000000..e4c25d9e --- /dev/null +++ b/rust_example/demo_sub_native/src/lib.rs @@ -0,0 +1,50 @@ +use std::sync::{LazyLock, RwLock}; + +use napi_derive_ohos::napi; +use napi_ohos::{Env, Error, Result}; +use openharmony_ability::{AvoidAreaType, OpenHarmonyApp}; +use openharmony_ability_derive::ability; +use openharmony_ability_plugin_webview::{WebviewBridgePlugin, WebviewCreateRequest, WebviewExt}; +use openharmony_ability_plugin_window::{WindowBridgePlugin, WindowExt}; + +static APP: LazyLock>> = LazyLock::new(|| RwLock::new(None)); + +fn current_app() -> Result { + APP.read() + .map_err(|_| Error::from_reason("Failed to read sub-window application state"))? + .as_ref() + .cloned() + .ok_or_else(|| Error::from_reason("Sub-window native module is not initialized")) +} + +#[napi] +pub async fn create_sub_window_webview() -> Result<()> { + current_app()? + .webview()? + .create( + WebviewCreateRequest::new("demo_sub_window_webview") + .transparent(true) + .url("https://example.com"), + ) + .await?; + Ok(()) +} + +/// Queries the window that owns this module's DefaultXComponent, not the Ability main window. +#[napi] +pub fn sub_window_keyboard_inset(env: Env) -> Result { + Ok(current_app()? + .query_avoid_area(&env, AvoidAreaType::Keyboard)? + .bottom_rect + .height) +} + +#[ability] +fn openharmony_app(app: OpenHarmonyApp) { + APP.write().unwrap().replace(app.clone()); + app.register_plugin(WebviewBridgePlugin) + .expect("sub-window WebView facade must be registered"); + app.register_plugin(WindowBridgePlugin) + .expect("sub-window Window facade must be registered"); + app.run_loop(|_event| {}); +} From 1ce9ef125e1744313d0622317cf3c9db8e43f777 Mon Sep 17 00:00:00 2001 From: richerfu Date: Tue, 11 Aug 2026 14:50:44 +0800 Subject: [PATCH 4/5] docs: define module-owned component lifecycle --- AGENTS.md | 17 +-- crates/ability/README.md | 2 +- crates/derive/README.md | 22 +++- crates/plugin-app-control/README.md | 13 +- crates/plugin-files/README.md | 2 +- crates/plugin-permission/README.md | 8 +- crates/plugin-resource/README.md | 18 +-- crates/plugin-url/README.md | 2 +- crates/plugin-webview/README.md | 38 ++++-- crates/plugin-window/README.md | 18 +-- docs/plugin-development-standard.md | 192 +++++++++++++++++----------- native_ability/CHANGELOG.md | 32 ++++- native_ability/README.md | 36 ++++-- plugins/app-control/README.md | 8 +- plugins/files/README.md | 2 +- plugins/permission/README.md | 10 +- plugins/resource/CHANGELOG.md | 5 +- plugins/resource/README.md | 13 +- plugins/url/README.md | 2 +- plugins/webview/CHANGELOG.md | 19 ++- plugins/webview/README.md | 35 +++-- plugins/window/CHANGELOG.md | 6 + plugins/window/README.md | 21 +-- 23 files changed, 326 insertions(+), 195 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e740b3a9..7c59e56d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,11 +35,11 @@ rg -n "BridgeJson|call_json|bridgeJson|requireBridgeJson|JSON\.stringify|JSON\.p ## Architecture ``` -ArkTS UI (UIAbility / DefaultXComponent / BridgeNodeHost) +ArkTS UI (UIAbility / DefaultXComponent) │ owns platform objects: UIAbilityContext, WindowStage, UIContext, │ WebviewController, FrameNodes, lifecycle listeners ▼ -NativeAbility (BridgeHost, BridgeNodeSlot, BridgePluginFactory registry) +NativeAbility (BridgeHost, per-module component root, BridgePluginFactory registry) │ async via TSFN (Promise→future); worker→main sync via TSFN; events inside active napi_env │ transport: named N-API values only (typeName-validated at the boundary) ▼ @@ -61,17 +61,17 @@ Rust plugin facades (BridgePlugin) + application business code (run_loop) | `crates/plugin-webview` | `ohos.webview` — WebView create, controller, custom protocol, JS proxy, callbacks | | `crates/plugin-files` | `ohos.files` — file dialogs (open/save/folder) | | `crates/plugin-url` | `ohos.url` — `context.openLink` | -| `crates/plugin-resource` | `ohos.resource` — inbound-only: ArkTS pushes `resourceManager` at ability-create; no outbound actions | +| `crates/plugin-resource` | `ohos.resource` — inbound-only: ArkTS pushes `resourceManager` from Ability-scoped `onInstall`; no outbound actions | Every `crates/plugin-` is paired with an ArkTS HAR in `plugins/` that exports the matching `BridgePluginFactory`; core (`crates/ability`) never imports any `plugin-*` crate. ### Startup Flow -1. `NativeAbility.onCreate` opens the module/session `BridgeHost`, creates factories, emits `ability-create`. +1. `NativeAbility.onCreate` opens each module/session `BridgeHost`, injects that module's bridge transport independently from rendering, creates factories, then emits `ability-create`. 2. `NativeAbility.onWindowStageCreate` provides the `WindowStage`, emits `window-stage-create`. -3. `DefaultXComponent.aboutToAppear` attaches the native event sink and default node slot `xcomponent-overlay`, emits `ui-context-ready` (plugins install here and may immediately use scoped callbacks or mount nodes). +3. Each `DefaultXComponent.aboutToAppear` binds one distinct native module, resolves and observes that component's actual `Window`, injects that module's root `FrameNode`, then emits its `ui-context-ready`. One Ability may host multiple components/modules, including across windows; the same module cannot back two components concurrently. 4. Rust entry: `#[ability] fn init(app: OpenHarmonyApp)` → `app.register_plugin(P)…` then `app.run_loop(|event| …)`. -5. Teardown order: `ui-context-destroy` → detach slots/sink → `window-stage-destroy` → `ability-destroy` → dispose session. +5. Per-module teardown order: `ui-context-destroy` → component detach → `window-stage-destroy` → `ability-destroy` → dispose host/session. ### Key Patterns @@ -79,8 +79,9 @@ Every `crates/plugin-` is paired with an ArkTS HAR in `plugins/` tha - **`impl_bridge_napi_type!(T, "ohos..")`** — pins a stable ABI typeName for `#[napi(object)]` structs; ArkTS validates the same string at parse and backfills it on response. - **Async mode** — Rust worker calls `BridgeRuntime::call_async::("action", req, options)`; data must be `Send + 'static`; the TSFN turns the ArkTS Promise into a future. - **Sync mode** — main thread: inside an active N-API callback, `app.with_main_thread_bridge(env, |b| b.call_sync::(…))`; workers: `BridgeRuntime::call_sync_from_worker` (TSFN, execution still on the main thread, must not be called from the N-API main thread). `BridgeMainThread` is `!Send + !Sync`, never cached. -- **Platform callbacks** — ArkTS calls `context.invokeNativeSync(event, reqTypeName, respTypeName, value)`; Rust answers in `BridgePlugin::on_main_thread_event` within the same callback. Fail-open (navigation) vs fail-closed (download) per event. -- **One session node tree** — `DefaultXComponent` owns a single root `FrameNode`, injected before `ui-context-ready`; plugins mount via `context.appendChild(key, node, cleanup)` / `removeChild(key)`. Built-in `ohos.node` plugin (`create-container` / `append-child` / `mount-into-root` / `dispose`) gives Rust opaque u32 handles to compose trees; `FrameNode` values never cross N-API. No slots, registries, or readiness waiters. +- **Platform callbacks** — ArkTS normally calls module-scoped `context.invokeNativeSync(event, reqTypeName, respTypeName, value)`; Rust answers in `BridgePlugin::on_main_thread_event` within the same callback. Only genuinely process-global transitions (ArkWeb engine initialization) use `invokeNativeSyncAcrossModules`. Fail-open (navigation) vs fail-closed (download) per event. +- **One component tree per native module** — each `DefaultXComponent` owns the single root `FrameNode` for its module, injected before that host's `ui-context-ready`; plugins mount via `context.appendChild(key, node, cleanup)` / `removeChild(key)`. Multiple components/windows use multiple modules, while multiple WebViews in one component use distinct IDs. Built-in `ohos.node` gives Rust opaque u32 handles; `FrameNode` values never cross N-API. +- **Component-window routing** — `windowStageEvent` remains Ability-scoped, but size/rect/avoid-area/keyboard listeners are attached to the actual `Window` resolved from each component's `UIContext`; those events are never broadcast from the main window to sub-window modules. ## Plugin Contract Rules diff --git a/crates/ability/README.md b/crates/ability/README.md index f7339563..3e883794 100644 --- a/crates/ability/README.md +++ b/crates/ability/README.md @@ -6,7 +6,7 @@ openharmony-ability is the Rust runtime crate in this repository. It provides li ## Runtime Context -`NativeAbility` passes the ArkTS init context into native code during `init(context)`. In the Rust runtime, `OpenHarmonyApp` can read `moduleName`, `basePath`, `prefPath`, and `preferredLocales` via `init_context()`, `module_name()`, `base_path()`, `pref_path()`, and `preferred_locales()`. The Harmony `resourceManager` is a plugin capability: the ArkTS wrapper `@ohos-rs/ability-plugin-resource` pushes the platform object to the Rust facade `openharmony-ability-plugin-resource`, which stores a native pointer globally. Access it through `openharmony_ability_plugin_resource::resource_manager()` or the `ResourceExt` extension trait on `OpenHarmonyApp`. +`NativeAbility` opens the module/session bridge and passes the ArkTS init context into native code before any component render. In the Rust runtime, `OpenHarmonyApp` can read `moduleName`, `basePath`, `prefPath`, and `preferredLocales` via `init_context()`, `module_name()`, `base_path()`, `pref_path()`, and `preferred_locales()`. The Harmony `resourceManager` is a plugin capability: its registered `ResourceBridgePlugin` instance owns the native pointer for this module. Access it through the `ResourceExt` extension trait on `OpenHarmonyApp`. ## License diff --git a/crates/derive/README.md b/crates/derive/README.md index 10e6f782..62f05c60 100644 --- a/crates/derive/README.md +++ b/crates/derive/README.md @@ -23,15 +23,23 @@ making them framework render modes. A business that needs custom protocol interc it through `openharmony-ability-plugin-webview::WebviewProtocol` and `WebviewClient::custom_protocol` rather than restoring a macro branch. -The generated `render(bindings, slot, render_owner)` export retains one Rust `RootNode` per -appearance owner, allowing main and sub-window components to coexist. The matching -`dispose_render(render_owner)` export releases only that appearance during synchronous component -teardown; `dispose_all_renders()` is the WindowStage-destroy fallback for any component that did -not receive `aboutToDisappear`. +The generated `render(slot, render_owner)` export tags the Rust `RootNode` with its +component appearance owner. A native module may have only one attached `DefaultXComponent`; main +and sub-window components use different modules. The matching `dispose_render(render_owner)` +prevents stale teardown from releasing a newer appearance; `dispose_all_renders()` is the +WindowStage-destroy fallback. The same owner gates Rust surface, input and frame callbacks, so a +delayed callback from an old component cannot overwrite a replacement component's raw window, +IME or geometry state. -The generated `init(context)` forwards ArkTS init data into native code. Read it through +Before that optional render, generated `init(bindings, bridge_owner, context)` opens the native +module's Ability-session transport. Generated `dispose_bridge(bridge_owner)` releases only the +matching session, so component disappear/reappear does not disable ability-only plugins and stale +teardown cannot clear a replacement session. These transport arguments are driven by +`NativeAbility`; applications do not call them directly. + +The `context` argument forwards ArkTS init data into native code. Read it through `app.init_context()`, `app.module_name()`, `app.base_path()`, `app.pref_path()`, and `app.preferred_locales()`. The resource manager is a plugin capability: register -`openharmony_ability_plugin_resource::ResourceBridgePlugin` in the `#[ability]` initializer and +`openharmony_ability_plugin_resource::ResourceBridgePlugin::new()` in the `#[ability]` initializer and read it via the `ResourceExt` trait (`app.resource_manager()`); the ArkTS side must install `@ohos-rs/ability-plugin-resource` as a session-scoped `new LazyPlugin(() => new ResourcePlugin())`. diff --git a/crates/plugin-app-control/README.md b/crates/plugin-app-control/README.md index 03e6787a..9bbe944c 100644 --- a/crates/plugin-app-control/README.md +++ b/crates/plugin-app-control/README.md @@ -32,15 +32,15 @@ fn configure_ability(app: OpenHarmonyApp) { ``` ```ts -import { NativeAbility } from "@ohos-rs/ability"; -import { createAppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { AppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; export default class EntryAbility extends NativeAbility { - public bridgePlugins = [createAppControlPlugin()]; + public bridgePlugins = [new LazyPlugin(() => new AppControlPlugin())]; } ``` -同时在应用 `oh-package.json5` 添加 `@ohos-rs/ability-plugin-app-control`。HAR 的具体依赖和 factory 说明见 +同时在应用 `oh-package.json5` 添加 `@ohos-rs/ability-plugin-app-control`。HAR 的具体依赖和 plugin 说明见 [ArkTS README](../../plugins/app-control/README.md)。 ## Rust 使用方式 @@ -64,8 +64,9 @@ pub fn terminate_application(env: Env, code: i32) -> Result<()> { ## 线程与生命周期限制 - 该插件不是 async API:禁止在 Rust worker、`async` future、`spawn` 任务或 `block_on` 中调用。 -- `BridgeMainThread` 会校验活跃 N-API environment;若 `DefaultXComponent` 尚未 render、Ability context - 未就绪或 `Env` 不匹配,调用会立即报错。 +- `BridgeMainThread` 会校验活跃 N-API environment;bridge transport 在 native module session 初始化时 + 建立,不依赖 `DefaultXComponent`。若 Ability context 未就绪、session 已关闭或 `Env` 不匹配,调用会 + 立即报错。 - ArkTS 调用 `process.ProcessManager.exit(code)`。一旦系统实际结束进程,后续业务逻辑不应依赖继续执行。 - `accepted = false` 会被 Rust facade 转换为错误,不能静默忽略。 diff --git a/crates/plugin-files/README.md b/crates/plugin-files/README.md index 01c88563..122d8fda 100644 --- a/crates/plugin-files/README.md +++ b/crates/plugin-files/README.md @@ -40,7 +40,7 @@ export default class EntryAbility extends NativeAbility { } ``` -同时在应用 `oh-package.json5` 添加 `@ohos-rs/ability-plugin-files`。HAR 的具体依赖和 factory 说明见 +同时在应用 `oh-package.json5` 添加 `@ohos-rs/ability-plugin-files`。HAR 的具体依赖和 plugin 说明见 [ArkTS README](../../plugins/files/README.md)。 ## Rust 使用方式 diff --git a/crates/plugin-permission/README.md b/crates/plugin-permission/README.md index 24a260e6..312d21ec 100644 --- a/crates/plugin-permission/README.md +++ b/crates/plugin-permission/README.md @@ -36,14 +36,14 @@ ArkTS HAR `@ohos-rs/ability-plugin-permission` 成对使用:Rust 发起强类 ``` 2. 在应用的 `oh-package.json5` 中加入 `@ohos-rs/ability-plugin-permission`,并在继承 - `NativeAbility` 的入口显式装配 `createPermissionPlugin()`: + `NativeAbility` 的入口通过 `LazyPlugin` 显式装配 `PermissionPlugin`: ```ts - import { NativeAbility } from "@ohos-rs/ability"; - import { createPermissionPlugin } from "@ohos-rs/ability-plugin-permission"; + import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; + import { PermissionPlugin } from "@ohos-rs/ability-plugin-permission"; export default class EntryAbility extends NativeAbility { - public bridgePlugins = [createPermissionPlugin()]; + public bridgePlugins = [new LazyPlugin(() => new PermissionPlugin())]; } ``` diff --git a/crates/plugin-resource/README.md b/crates/plugin-resource/README.md index e2a672bb..755e5d0c 100644 --- a/crates/plugin-resource/README.md +++ b/crates/plugin-resource/README.md @@ -1,8 +1,9 @@ # openharmony-ability-plugin-resource `openharmony-ability-plugin-resource` 是 HarmonyOS `resourceManager` 能力的 Rust facade。它与 -ArkTS HAR `@ohos-rs/ability-plugin-resource` 成对使用:ArkTS wrapper 持有平台对象并在 `ui-context-ready` -时经入站事件推送给 Rust,Rust 在同一 N-API callback 内把对象转成 native 指针存全局;之后所有 +ArkTS HAR `@ohos-rs/ability-plugin-resource` 成对使用:ArkTS wrapper 在 Ability-scoped `onInstall` +经入站事件推送平台对象,Rust 在同一 N-API callback 内把它转成 native 指针,存入本 module 注册的 +`ResourceBridgePlugin` instance;之后所有 读取(raw file、media、drawable 等)通过 `ohos-resource-manager-binding` 直连 OpenHarmony C API, 不经过 ArkTS。 @@ -32,12 +33,13 @@ ArkTS 对象引用跨线程。 #[ability] fn configure_ability(app: OpenHarmonyApp) { - app.register_plugin(ResourceBridgePlugin) + app.register_plugin(ResourceBridgePlugin::new()) .expect("resource Rust facade must be registered exactly once"); } ``` -2. ArkTS 侧为每个 module/session 创建独立 wrapper;进程级 native pointer 由 Rust 持有: +2. ArkTS 侧为每个 module/session 创建独立 wrapper;module 级 native pointer 由对应 Rust plugin + instance 持有: ```ts import { LazyPlugin } from "@ohos-rs/ability"; @@ -62,13 +64,13 @@ ArkTS 对象引用跨线程。 ## 时序说明 -推送发生在 `ability-create`:入站事件 sink 在 `NativeAbility.onCreate` 中 `module.init` 之后立即 -attach(不依赖 UI 渲染),Rust registry 也在 `ability-create` 事件到达 ArkTS 插件之前已经收到 -`AbilityCreated`。因此只依赖 `ability` 的插件可以在渲染前收到入站事件;需要 `ui-context` 的插件 +推送发生在 `onInstall`:入站事件 sink 已 attach,Rust registry 也已经收到 `AbilityCreated`,但不 +依赖 WindowStage 或 DefaultXComponent。因此只依赖 `ability` 的插件可以在渲染前收到入站事件; +需要 `ui-context` 的插件 (如 webview)仍按其自身 requires 等到对应上下文就绪。 ## 线程安全 `NativeResourceManager` 的读取方法本身**非线程安全**(`ohos-resource-manager-binding` 文档 明确说明)。`ResourceManager` 可跨线程 Clone,但并发读取需要调用方自行串行化(例如 -`Mutex`)——这与插件化之前的全局单例语义一致。 +`Mutex`)。Ability 重建时 plugin 会先清空旧 manager,再接收新 wrapper 的 handle。 diff --git a/crates/plugin-url/README.md b/crates/plugin-url/README.md index 51bb2479..8687841d 100644 --- a/crates/plugin-url/README.md +++ b/crates/plugin-url/README.md @@ -40,7 +40,7 @@ export default class EntryAbility extends NativeAbility { } ``` -同时在应用 `oh-package.json5` 添加 `@ohos-rs/ability-plugin-url`。HAR 的具体依赖和 factory 说明见 +同时在应用 `oh-package.json5` 添加 `@ohos-rs/ability-plugin-url`。HAR 的具体依赖和 plugin 说明见 [ArkTS README](../../plugins/url/README.md)。 ## Rust 使用方式 diff --git a/crates/plugin-webview/README.md b/crates/plugin-webview/README.md index bee39fc3..c58d9719 100644 --- a/crates/plugin-webview/README.md +++ b/crates/plugin-webview/README.md @@ -5,7 +5,7 @@ ArkWeb delegate;Rust 只持有 controller ID、具名 N-API 数据及 Rust-owned callback/protocol closure。 插件不把 WebView 写进 framework 的 `DefaultXComponent`。默认情况下 WebView 的 `FrameNode` 挂进 -session 根树(全屏);需要组合时,`WebviewCreateRequest::parent_node(handle)` 把它挂到 +当前 native module 唯一组件的根树(全屏);需要组合时,`parent_node(handle)` 把它挂到 `ohos.node` 容器句柄之下,从而保留 WebView、XComponent 和自定义 ArkUI 节点的混合布局。 ## 契约 @@ -14,10 +14,10 @@ session 根树(全屏);需要组合时,`WebviewCreateRequest::parent_nod | --- | --- | | Rust crate | `openharmony-ability-plugin-webview` | | ArkTS HAR | `@ohos-rs/ability-plugin-webview` | -| 插件 ID / bridge 版本 | `ohos.webview` / `1` | +| 插件 ID / bridge 版本 | `ohos.webview` / `2` | | 执行模式 | 异步:`AsyncBridge` / `invokeAsync` | | 前置 context | `ui-context` | -| 挂载 | session 根树(默认全屏);可选 `parentHandle` 挂到 `ohos.node` 容器 | +| 挂载 | 当前 module/component 根树(默认全屏);可选 `parentHandle` 挂到 `ohos.node` 容器 | | 核心 action | `create`、控制器操作、`evaluate-script` | 所有出站 action 和所有 ArkWeb 反向事件都是具名 N-API 契约,不使用 JSON。`create` 返回 controller @@ -44,14 +44,15 @@ fn configure_ability(app: OpenHarmonyApp) { } ``` -应用侧在 `oh-package.json5` 添加 `@ohos-rs/ability-plugin-webview`,并在 `NativeAbility` 中显式装配 factory: +应用侧在 `oh-package.json5` 添加 `@ohos-rs/ability-plugin-webview`,并在 `NativeAbility` 中通过 +`LazyPlugin` 显式装配新实例: ```ts -import { NativeAbility } from "@ohos-rs/ability"; -import { createWebviewPlugin } from "@ohos-rs/ability-plugin-webview"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { WebviewPlugin } from "@ohos-rs/ability-plugin-webview"; export default class EntryAbility extends NativeAbility { - public bridgePlugins = [createWebviewPlugin()]; + public bridgePlugins = [new LazyPlugin(() => new WebviewPlugin())]; } ``` @@ -111,7 +112,12 @@ user agent、autoplay、document-start initialization scripts、headers 和 `tra ## 挂载与生命周期 -- 默认全屏挂入 session 根树;需要组合时用 `parent_node(container)` 把 WebView 挂到 +- 一个 `DefaultXComponent` 对应一个 native module;跨窗口的第二个组件必须使用另一个 module, + `WebviewCreateRequest` 不接受 window/surface key。 +- 同一个 module/component 可用不同 WebView ID 同时创建多个实例;每个实例拥有独立 mount key 和 + controller,同 ID recreate 才替换旧实例。ID 只在当前 module 内唯一;ArkTS 会生成进程唯一的 + 内部 ArkWeb tag,跨 module 使用相同业务 ID 不会冲突。 +- 默认全屏挂入当前 component 根树;需要组合时用 `parent_node(container)` 把 WebView 挂到 `ohos.node` 容器之下(容器最终也由 Rust 决定挂不挂根)。 - 异步 `create` 等待 controller attach 和首次导航启动;等待由 lifecycle/cancel 驱动,不使用 固定 timer 轮询。 @@ -120,7 +126,7 @@ user agent、autoplay、document-start initialization scripts、headers 和 `tra ## WebView 回调 -在 `create` 前用 `WebviewCallbacksBuilder` 按 WebView tag 声明回调: +在 `create` 前用 `WebviewCallbacksBuilder` 按 module-local WebView ID 声明回调: ```rust use openharmony_ability_plugin_webview::{ @@ -137,8 +143,13 @@ WebviewCallbacksBuilder::new("article") ArkTS 只接收“是否订阅”的创建快照,实际 closure 始终在 Rust。导航回调未订阅或失败时默认 `intercept = false`(fail-open);下载开始回调未订阅时默认取消下载(fail-closed);下载结束和标题 -变更是通知型事件。所有 callback 在当前 N-API callback 内运行,应快速返回;耗时工作只能复制数据后 -投递给 worker。 +变更是通知型事件。每个事件还携带内部 `native_tag`,facade 会先校验它仍是该业务 ID 的当前 +controller;same-ID recreate 前的延迟事件不能命中新实例。所有 callback 在当前 N-API callback 内 +运行,应快速返回;耗时工作只能复制数据后投递给 worker。 + +`ui-context-destroy` / `ability-destroy` 会兜底清空 controller attachment/tag 状态,但保留 callback、 +protocol 和 proxy 声明,供同 module 后续 appearance 重建 controller。该兜底不依赖 closing 阶段还能 +成功执行 ArkTS → Rust 清理通知。 ## 自定义 protocol 与页面 JavaScript @@ -168,7 +179,10 @@ WebviewJavascriptProxyBuilder::new("article", "native") .build()?; ``` -`WebviewProtocol::register` 只能在 engine 初始化前调用。tag handler 和 JS proxy 应优先在 `create` +`WebviewProtocol::register` 的新声明只能在进程级 engine 初始化前调用。不同 native module 拥有 +独立 Rust 状态,但共享 ArkWeb engine;第一个 WebView create 会让所有已激活且装配该插件的 module +先 flush 声明,再初始化 engine。Ability 重建时重复相同 scheme + options 是幂等操作;engine 启动 +后才加载的 module 只能复用进程已注册且 options 相同的 scheme,不能再新增 custom scheme。ID handler 和 JS proxy 应优先在 `create` 前声明;controller attach 后插件会先安装 protocol/proxy/delegate,再开始首次导航。需要异步回复 custom protocol 时使用 `custom_protocol_async` / `WebviewProtocolResponder`。 diff --git a/crates/plugin-window/README.md b/crates/plugin-window/README.md index dbef6a30..8b4da514 100644 --- a/crates/plugin-window/README.md +++ b/crates/plugin-window/README.md @@ -9,9 +9,9 @@ | --- | --- | | Rust crate | `openharmony-ability-plugin-window` | | ArkTS HAR | `@ohos-rs/ability-plugin-window` | -| 插件 ID / bridge 版本 | `ohos.window` / `1` | +| 插件 ID / bridge 版本 | `ohos.window` / `2` | | 执行模式 | 主线程同步:`MainThreadSyncBridge` / `invokeSync` | -| 前置 context | `window-stage` | +| 前置 context | `ui-context` | | action | `get-avoid-area` | | request → response | `ohos.window.AvoidAreaRequest` → `ohos.window.AvoidAreaResponse` | @@ -33,11 +33,11 @@ fn configure_ability(app: OpenHarmonyApp) { ``` ```ts -import { NativeAbility } from "@ohos-rs/ability"; -import { createWindowPlugin } from "@ohos-rs/ability-plugin-window"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { WindowPlugin } from "@ohos-rs/ability-plugin-window"; export default class EntryAbility extends NativeAbility { - public bridgePlugins = [createWindowPlugin()]; + public bridgePlugins = [new LazyPlugin(() => new WindowPlugin())]; } ``` @@ -66,10 +66,10 @@ pub fn keyboard_insets(env: Env) -> Result { ## 调用限制 - 查询是同步主线程调用:`Env` 必须来自当前导出的 N-API callback,不能从 worker 保存后使用。 -- `window-stage` 必须已经建立。应在 `NativeAbility.onWindowStageCreate` 之后、且 native bridge 已 render - 后的 callback 中调用;未就绪时同步失败而不是等待 Promise。 -- ArkTS 使用 `context.getWindowStage().getMainWindowSync()` 与 - `getWindowAvoidArea(areaType)`,平台错误会原样变成 bridge 错误。 +- 当前 module 的 `DefaultXComponent` 必须已经建立 `UIContext`。同步调用不会等待组件出现,未就绪时 + 直接失败。 +- ArkTS 通过 `context.getWindow()` 获取该组件实际所在窗口,再调用 + `getWindowAvoidArea(areaType)`;因此主窗口和 sub window 会各自返回自己的避让区。 - request/response 为具名 N-API object;Rust `area_type` 会映射为 ArkTS `areaType`,不使用 JSON。 完整的线程、生命周期和契约变更要求见 diff --git a/docs/plugin-development-standard.md b/docs/plugin-development-standard.md index 3455fbda..68c320a7 100644 --- a/docs/plugin-development-standard.md +++ b/docs/plugin-development-standard.md @@ -31,6 +31,11 @@ - 应用入口显式同时组合 Rust 插件 facade 与 ArkTS factory;core 不能反向 import 任意 `plugin-*` crate/HAR。 - 一个插件只能管理自己的资源、回调和节点。布局、业务页面状态和其他插件资源仍由应用拥有。 +- 一个 `DefaultXComponent` 必须对应一个独立 native module/动态库;同一 native module 同时只能归属 + 一个活动 Ability session,且在该 session 中最多绑定一个组件。一个 Ability 可以通过多个 module + 放置多个 `DefaultXComponent`,这些组件既可以位于同一窗口,也可以分布在多个窗口。 +- 一个 module/component 内可以创建多个 WebView;WebView 的复数能力由唯一 controller ID 和节点 + mount key 实现,不能通过给同一个 module 再挂第二个 `DefaultXComponent` 实现。 目录骨架如下: @@ -193,15 +198,8 @@ class LoginPlugin implements AsyncBridgePlugin { } } -export function createLoginPlugin(): BridgePluginFactory { - return { - id: "account.login", - version: 1, - execution: "async", - requires: ["ability"], - create: (_context: BridgePluginContext): AsyncBridgePlugin => new LoginPlugin(), - }; -} +// HAR exports LoginPlugin. The Ability creates a fresh instance through LazyPlugin. +export { LoginPlugin }; ``` ### 3.3 既有内置插件的契约基线 @@ -213,7 +211,7 @@ export function createLoginPlugin(): BridgePluginFactory { | --- | --- | --- | | `ohos.app-control` / `terminate` | `ohos.app_control.TerminateRequest { code }` → `ohos.app_control.TerminateResponse { accepted }` | sync / `ability` | | `ohos.permission` / `request` | `ohos.permission.PermissionRequest { permissions }` → `ohos.permission.PermissionResponse { codes }` | async / `ability` | -| `ohos.window` / `get-avoid-area` | `ohos.window.AvoidAreaRequest { areaType }` → `ohos.window.AvoidAreaResponse { area }` | sync / `window-stage` | +| `ohos.window` / `get-avoid-area` | `ohos.window.AvoidAreaRequest { areaType }` → `ohos.window.AvoidAreaResponse { area }` | sync / `ui-context`;查询当前 module/component 所在窗口 | | `ohos.webview` / `create` | `ohos.webview.CreateRequest { id, parentHandle? }` → `ohos.webview.CreateResponse { id }` | async / `ui-context` | | `ohos.node`(内置) / `create-container` | `ohos.node.CreateContainerRequest` → `ohos.node.HandleResponse { handle }` | async / `ui-context` | | `ohos.node`(内置) / `append-child` | `ohos.node.AppendChildRequest { parentHandle, childHandle }` → `ohos.node.Acknowledgement` | async / `ui-context` | @@ -315,8 +313,8 @@ ArkTS 平台回调进入 Rust 的 `on_main_thread_event` 是**入站 scoped call | requirement | 就绪时点 | 适合的能力 | | --- | --- | --- | | `ability` | `NativeAbility.onCreate` 已建立 Ability context | 权限、应用控制、登录会话 | -| `window-stage` | `NativeAbility.onWindowStageCreate` | 窗口与避让区 | -| `ui-context` | `DefaultXComponent.aboutToAppear` 已建立 UI context 并注入 session 根 `FrameNode` | WebView、任意 ArkUI/FrameNode 插件 | +| `window-stage` | `NativeAbility.onWindowStageCreate` | 只依赖 Ability `WindowStage` 的 stage 级能力 | +| `ui-context` | 该 native module 唯一的 `DefaultXComponent` 已建立 UI context、实际 Window 并注入根 `FrameNode` | 组件窗口/避让区、WebView、任意 ArkUI/FrameNode 插件 | `BridgeHost` 只会在 requirements 都就绪后调用 `onInstall`,并向延迟激活的插件重放有限的生命周期 历史。插件如需监听销毁、配置或内存事件,应在 ArkTS `onLifecycle` 或 Rust @@ -333,24 +331,30 @@ UIContext 和销毁事件不能相互穿插。单个插件的 lifecycle/onDispos session 开始关闭后必须拒绝新调用并取消未完成调用。 1. `NativeAbility.onCreate` 先预创建 module/session 对应的 `BridgeHost` 和 plugin instance,但不执行 - hook;native module 完成 `init`、Rust lifecycle/event sink 均已 attach、Rust 已收到 + hook;随后把通用 `BridgeRuntime`/主线程 endpoint 作为 module/session transport 注入 native + module,再完成 `init`。该 transport 与组件 render 生命周期解耦,使用独立 `bridgeOwner` 防止旧 + session 清理新 endpoint;Rust lifecycle/event sink 均已 attach、Rust 已收到 `AbilityCreated` 后,Host 才把 `ability` 标记为 ready,执行 `onInstall` 并发出 - `ability-create`。因此 ability-only plugin 的 `onInstall` 可以安全调用 `invokeNativeSync`。 -2. `NativeAbility.onWindowStageCreate` 先提供 `WindowStage`,再发出 `window-stage-create`;窗口事件 - 仍要同时转发给原 native module lifecycle。Stage create/destroy 使用 generation token:已入队的 - create 在 destroy 后不得重新把 context 标记为 ready。自定义页面通过 + `ability-create`。因此 ability-only plugin 的 `onInstall` 可以安全调用 `invokeNativeSync`,Rust + ability-only 出站调用也不需要等待 `DefaultXComponent` appearance。 +2. `NativeAbility.onWindowStageCreate` 先提供 Ability 级 `WindowStage`,再发出 + `window-stage-create`;`windowStageEvent` 仍分发给每个 module。size/rect/avoid-area/keyboard + 不是 Stage 广播:每个 Host 必须从自己组件的 `UIContext.getWindowName()` 解析实际 Window,独立 + 注册监听,并只转发给该 module 的原 Rust lifecycle。Stage create/destroy 使用 generation token: + 已入队的 create 在 destroy 后不得重新把 context 标记为 ready。自定义页面通过 `loadWindowStageContent` 加入这个受控事务,不得从平台回调启动脱离队列的 Promise。 -3. `DefaultXComponent.aboutToAppear` 先挂接 native event sink,以本次 appearance 唯一的 - `renderOwner` 保存 Rust `RootNode`,再按 `windowKey` 注册窗口表面 - (UIContext + 根 `FrameNode`,根先于 `ui-context-ready` 存在);`"main"` 窗口注册后通知 Rust - `ui-context-ready`。这样 plugin `onInstall` 期间已经可以安全发起 scoped 回调或挂载节点,无需 - 任何等待。子窗口实例(唯一 `windowKey`)只登记自己的表面,不重发 ready。 -4. UI 消失时,`detachWindow` 先发出带 `windowKey` 的 `window-detached`,再发出 - `ui-context-destroy`(仅 `"main"`),并卸载该窗口的 keyed - 节点与句柄节点。WindowStage 销毁时 detach 所有窗口并发出 +3. generic bridge transport 与 native event/lifecycle sink 均由 `NativeAbility` 按 module/session 管理, + 不依赖组件 appearance;组件 detach 也不得清空 transport。 + `DefaultXComponent.aboutToAppear` 只以本次 appearance 唯一的 `renderOwner` 保存 Rust `RootNode`, + 再向该 module 的 `BridgeHost` 注入 UIContext + 根 `FrameNode` 并通知 `ui-context-ready`。Host 必须 + 拒绝第二个组件并提示改用不同 native module。一个 Ability 的多个 module/Host 各自拥有独立 ready + 状态和根树。 +4. UI 消失时,`detachComponent` 发出该 module 的 `ui-context-destroy`,并卸载 keyed 节点与句柄 + 节点。WindowStage 销毁时每个 module 都 detach 自己的组件并发出 `window-stage-destroy`;Ability 销毁时发出 `ability-destroy` 并 dispose 整个 session(session - 销毁时由 `BridgeHost` 级联卸载全部窗口的节点,根 `FrameNode` 本身由各 `DefaultXComponent` - 在等待 Host 清理屏障后销毁)。Event sink 属于 module/session,只在 session dispose 时解除。 + 销毁时由各 `BridgeHost` 级联卸载本 module 的节点,根 `FrameNode` 本身由 `DefaultXComponent` + 在等待 Host 清理屏障后销毁)。Event sink 和 bridge transport 都属于 module/session,只在 session + dispose 时分别解除;transport 的 `bridgeOwner` 与组件的 `renderOwner` 不得混用。 5. `configuration-updated`、`memory-level`、window-stage event 等保持由 `NativeAbility` 原有链路 分发,同时作为受控 lifecycle event 交给已安装插件。 @@ -359,8 +363,9 @@ WindowStage 已先销毁,它仍必须收到该 session 后续的 `ui-context-d `window-stage-destroy` 和 `ability-destroy`。下一次 `ability-create` 必须清空上一 session 的 readiness 和 lifecycle history,再从新会话开始重放,禁止把旧 Ability 事件带入新实例。 -ArkTS context 是 module + session 范围的。插件不得假设多个 module 共用一个 controller、根节点或 -状态表;所有跨页面状态键必须至少包含 `sessionId` 与 `moduleName`。 +ArkTS context 是 module + session 范围的。插件不得假设多个 module 共用 controller、根节点或 +状态表;一个 Host 的 context 永远只指向该 module 的唯一组件。所有跨页面状态键必须至少包含 +`sessionId` 与 `moduleName`。 规则如下: @@ -371,6 +376,9 @@ ArkTS context 是 module + session 范围的。插件不得假设多个 module callback 重试。 - `onDispose` 必须幂等,负责移除平台 delegate、取消订阅、卸载节点、清空 controller/tag 映射。 单个插件释放失败不能阻断其余插件释放。 +- 普通反向事件必须使用 module-scoped `invokeNativeSync`。只有 ArkWeb engine 这类平台明确为进程级的 + 状态转换,才可使用 `invokeNativeSyncAcrossModules` 同步通知所有已激活且装配同一插件的 native + module;广播仍必须使用具名 request/response,且任一 module 拒绝都应中止初始化。 - `onInstall` / `onLifecycle` / `onDispose` 在独立的 bounded hook scope 中执行;scope 通过 `BridgePluginHookContext.onCancel` 通知取消,默认 watchdog 为 5 秒。插件不得忽略取消后继续挂载 节点或回写平台状态;单个 hook 超时只会把该插件标记失败并继续 session teardown。 @@ -389,12 +397,12 @@ context.appendChild( ); ``` -## 6. ArkUI 节点树与挂载(一棵树模型) +## 6. ArkUI 节点树与挂载(每 module/component 一棵树) 需要渲染内容的插件(WebView、地图、相机、视频等)都是 **FrameNode 提供者**:它们把节点挂进 -目标窗口唯一的一棵根树,不写进 `DefaultXComponent`,也没有 WebView 专用插槽。 +目标 native module 唯一组件的根树,不写进 `DefaultXComponent`,也没有 WebView 专用插槽。 -- 每个 module/session/windowKey 只有一棵根树。`DefaultXComponent` 在 `aboutToAppear` 中先创建根 +- 每个 module/session 只有一棵根树和至多一个已 attach 的 `DefaultXComponent`。组件先创建根 `FrameNode` 并注入 `BridgeHost`,再发出 `ui-context-ready`;因此插件在 `onInstall` 里可以直接 `context.appendChild(...)`,**不存在命名插槽、注册表、waitFor/require 或就绪计时器**。 - `context.appendChild(key, node, cleanup)` / `context.removeChild(key)`:key 必须以插件 ID 为前缀 @@ -419,26 +427,35 @@ Stack() { 这套规则同时保留 WebView 与 XComponent 的混合接入,并允许任意插件(以及 Rust 组树)接入 node 节点;没有命名插槽、注册表或对业务布局的隐式所有权。 -### 6.1 多窗口 - -每个窗口各有一个 `DefaultXComponent` 实例,各自持有独立的节点树。`DefaultXComponent` 通过 -`windowKey` 属性(缺省 `"main"`)注册窗口表面;`BridgeHost` 按窗口键分桶持有 UIContext、根节点、 -挂载表与 `ohos.node` 句柄表,互不覆盖。 - -- 只有 `"main"` 窗口的注册会发出 `ui-context-ready` / `ui-context-destroy`(插件安装与 session - 生命周期仍以主窗口为准);子窗口注册只登记状态。 -- 每个窗口都会发出 `window-attached` / `window-detached`,payload 携带 `windowKey`。拥有 controller、 - delegate 或异步 waiter 的插件必须按该 key 建表并在 detach 时清理对应窗口,禁止用主窗口的 - `ui-context-destroy` 一次性清空其他仍存活窗口。 -- 每次 `DefaultXComponent` appearance 都有独立 `renderOwner`;Rust derive 层按 owner 保存多个 - `RootNode`。组件快速消失会使 generation 失效并取消 pending attach,旧异步 continuation 不得重新 - 挂载已经消失的窗口。 -- 插件默认操作 `"main"` 窗口;子窗口内容用 `context.windowScope(windowKey)` 获取窗口作用域: - `getUIContext()` / `getRootFrameNode()` / `appendChild` / `removeChild` / `getFrameNode`。 -- Rust 侧:`ohos.node` 的四个 action 与 `WebviewCreateRequest` 都支持 `window_key` 字段(缺省 - `main`)。`app.node()?.create_container_in_window(Some("float"), ...)` 在子窗口建容器。 -- 子窗口页面放置第二个 `DefaultXComponent` 时必须传唯一 `windowKey`(如 `windowId` 字符串), - 否则 `attachWindow` 拒绝重复注册。 +### 6.1 多 XComponent 与多窗口 + +多组件由多 native module 实现,不在一个 Host 内再建立 window/surface 子注册表。例如 Ability 声明 +`moduleName = ["main_native", "sub_native"]`,主窗口组件使用 `main_native`,子窗口组件使用 +`sub_native`。两个 module 各自拥有独立 Rust `OpenHarmonyApp`、BridgeHost、插件实例、UIContext、 +根节点、挂载表和 `ohos.node` 句柄表。 + +- `DefaultXComponent` 不提供 `windowKey`/`surfaceKey`;`moduleName` 就是组件的唯一所有权边界。 +- 同一个 module 的第二次并发 attach 必须失败;组件正常 disappear 完成 detach 后可以由同 module + 后续 appearance 重新 attach。 +- 每个 module 独立发出 `ui-context-ready` / `ui-context-destroy`。一个窗口/组件销毁不得清空其他 + module 仍存活的 WebView、controller 或节点。 +- 每次 `DefaultXComponent` appearance 都有独立 `renderOwner`;Rust derive 层只保存当前 module + 唯一的 `RootNode`,并在 native 导出边界拒绝第二次并发 render。owner 只用于防止旧组件的清理误删 + 后续 appearance,同时必须贯穿 XComponent surface/input/frame callback,防止旧 surface 的延迟 + 回调覆盖新组件的 raw window、IME 或尺寸。组件快速消失会使 generation 失效并取消 pending + attach,旧异步 continuation 不得重新挂载已经消失的组件。 +- `BridgePluginContext` 的 `getUIContext` / `getRootFrameNode` / `appendChild` / `removeChild` / + `getFrameNode` 均只操作当前 module 的组件,不接受窗口 key;`getWindow()` 通过该 UIContext 定位 + 组件实际所在窗口,不能用 Ability 的主窗口替代 sub window。 +- Window size/rect/avoid-area/keyboard listener 与组件 attach/detach 同寿命,回调必须校验当前 + 组件状态;主窗口事件不得广播给 sub-window module,旧窗口的延迟回调也不得命中新 appearance。 +- `ohos.node` action 与 `WebviewCreateRequest` 不携带 `window_key`;要在另一个窗口操作,调用该窗口 + 对应 native module 导出的 Rust API。 +- 一个 module/component 内的 `WebviewSurface.entries` 按 WebView ID 保存多个 controller;不同 ID + 必须并存并使用独立 mount key,同 ID 的重新 create 才替换旧实例。所有 controller 平台回调都要 + 携带内部 native tag,并在 Rust 分发前校验当前 generation,禁止旧实例的延迟回调命中替代实例。 +- ArkTS factory 可用 `LazyPlugin(..., modules)` 限定适用 module;多 module Ability 不应把会主动向 + Rust 发送事件的插件装到没有注册对应 Rust facade 的 module。 ## 7. 平台回调与 WebView 特例 @@ -461,6 +478,10 @@ WebView 插件还必须遵守: - 自定义 scheme 通过 `WebviewProtocol::register` 在 Web engine 初始化前声明;初始化后不允许 再新增 scheme。 +- ArkWeb engine 是进程级资源,而每个 native module 有独立 Rust static 状态。首次 WebView create + 负责初始化 engine;之后每个创建 WebView 的 module 仍必须收到 `engine-initialized`。晚于 engine + 启动才加载的 module 只能复用进程已注册且 options 完全相同的 scheme;新增或冲突声明必须确定性 + 失败,普通 WebView/controller 不受影响。 - tag 对应的自定义 protocol、JavaScript proxy 和回调订阅应在 `create` 前声明。controller attach 后, 必须先安装 delegate/protocol/proxy,再启动首次导航。 - 自定义 protocol 处理 URL 请求;页面 JS proxy 处理 `window..()` 调用,两者不能 @@ -472,13 +493,13 @@ WebView 插件还必须遵守: ### 7.1 WebView 回调契约与失败策略 -WebView 的 callback builder 必须在 `WebviewClient::create` 前按 webview tag 声明。Rust 保存的是 +WebView 的 callback builder 必须在 `WebviewClient::create` 前按 module-local WebView ID 声明。Rust 保存的是 `Send + Sync + 'static` closure,而不是 ArkTS 函数;ArkTS 在创建时只拿到订阅快照,以决定是否安装 对应 ArkWeb delegate。 | ArkWeb 时点 | Rust 事件/契约 | 默认或错误语义 | | --- | --- | --- | -| engine 初始化前/后 | `EngineLifecycleEvent` → `EventAcknowledgement` | 初始化前 flush scheme,初始化后封存 scheme 声明 | +| engine 初始化前/后 | `EngineLifecycleEvent` → `EngineLifecycleResponse` | 初始化前 flush scheme,跨 module 校验同名 scheme options,初始化后封存声明 | | controller attach/remove | `ControllerEvent` → `EventAcknowledgement` | attach 时安装 proxy/protocol,remove 时清理状态 | | `onLoadIntercept` | `NavigationRequest` → `NavigationResponse` | 未订阅或 handler 失败时 `intercept = false`,fail-open | | `WebDownloadDelegate.onBeforeDownload` | `DownloadStartRequest` → `DownloadStartResponse` | 失败时取消下载,fail-closed;允许改写临时保存路径 | @@ -496,10 +517,20 @@ WebView 的 callback builder 必须在 `WebviewClient::create` 前按 webview ta 1. 应用在 `#[ability]` 初始化期间通过 `WebviewProtocol::register` 声明 scheme 及 option;该步骤 必须早于 `WebviewController.initializeWebEngine()`。 -2. ArkTS `WebviewPlugin.onInstall` 发出 `before-engine-init`;Rust flush 全部 scheme 声明后,ArkTS - 才初始化 engine,并以 `engine-initialized` 封存声明集。封存后新增 scheme 必须确定性失败。 +2. 第一个 WebView create 在初始化 ArkWeb engine 前,通过 `invokeNativeSyncAcrossModules` 向所有已 + 激活、装配 `ohos.webview` 的 native module 广播 `seal-engine-schemes`,先冻结并聚合校验所有 + scheme/options;校验通过后才广播 `before-engine-init`,由各 module 的 Rust facade flush 自己的 + scheme 声明。ArkTS 随后初始化 engine,再广播 `engine-initialized` 封存声明集。 + `EngineLifecycleEvent` 必须携带已封存的进程级 scheme/options 集合。该 engine 事件只依赖 + `ability`,controller 等其余事件仍依赖 `ui-context`。封存后新增 scheme 必须确定性失败;Ability + 重建或后加载 module 重复声明完全相同的 scheme + options 是幂等操作。每个 module + 必须在具名 `EngineLifecycleResponse` 返回自己的 scheme/options;同名 scheme 的 options 不一致时 + ArkTS 必须在调用 `initializeWebEngine()` 前确定性中止。 3. 业务在 `WebviewClient::create` 前按 tag 调用 `custom_protocol`、注册 JS proxy 和 callback;Rust - 只保存 tag/scheme/closure 声明,不能保存 ArkTS controller。 + 只保存业务 ID/scheme/closure 声明,不能保存 ArkTS controller。业务 ID 只需在当前 native module + 内唯一;ArkTS host 必须为每次 controller 创建生成包含 session + module 的进程唯一 native tag, + 并通过具名 `ControllerEvent { id, nativeTag }` 让 Rust 用 native tag 安装 ArkWeb protocol/proxy。 + 平台回调继续向业务暴露原 ID,禁止把 native tag 泄漏成公共 controller ID。 4. controller attach 后先通过 scoped direct event 安装 protocol、proxy 与 delegate,再开始首次 `loadUrl`。若 handler 在 controller 存在后增量注册,应立即绑定;JS proxy 如需重新生效则刷新页面。 @@ -516,7 +547,7 @@ WebView 的 callback builder 必须在 `WebviewClient::create` 前按 webview ta | --- | --- | --- | | `requestPermission` | `plugin-permission` / `ohos.permission` | async + `ability`;结果顺序与失败码保持不变 | | `exit` | `plugin-app-control` / `ohos.app-control` | sync + 当前主线程 `Env` | -| `getWindowAvoidArea` | `plugin-window` / `ohos.window` | sync + `window-stage`;返回完整避让区 | +| `getWindowAvoidArea` | `plugin-window` / `ohos.window` | sync + `ui-context`;查询 module/component 所在窗口并返回完整避让区 | | `createWebview`、嵌入式 WebView、custom protocol、导航/下载/标题回调 | `plugin-webview` / `ohos.webview` | 出站 async + `ui-context`;入站为 scoped 主线程具名 N-API;scheme 在 engine 初始化前声明 | | `Loadable` | `runtime/NativeModuleLoader` | framework 内部 runtime,不是能力 bridge | | `openURL` | `plugin-url` / `ohos.url` | async + `ability`;`context.openLink` | @@ -540,38 +571,47 @@ fn configure_ability(app: OpenHarmonyApp) { } ``` -native module 可能跨越多个 UIAbility 实例继续存活,因此 `#[ability]` 初始化器对同一 module 的 -进程级 `OpenHarmonyApp` 只执行一次;每次 Ability 重建仍会刷新 `AbilityInitContext` 并创建新的 +native module 可能跨越同一 UIAbility 的多次重建继续存活,因此 `#[ability]` 初始化器对同一 module +的 `OpenHarmonyApp` 只执行一次;每次 Ability 重建仍会刷新 `AbilityInitContext` 并创建新的 lifecycle handle。初始化器应只做插件、protocol 和 run loop 等进程级配置,session 资源必须通过 lifecycle 创建与释放,不能依赖重复执行初始化器。 -ArkTS HAR 导出唯一 factory,应用通过 `NativeAbility.bridgePlugins` 显式装配: +同一个 module 不得同时服务多个 Ability,也不得同时绑定两个 `DefaultXComponent`。多个 Ability 或 +同一 Ability 内的多个组件都要使用不同 module 名称/动态库;`NativeAbility.moduleName` 数组负责预加载 +本 Ability 的所有 module,每个组件再通过自己的 `moduleName` 选择对应 Host。 + +ArkTS HAR 导出 plugin class,应用通过 `LazyPlugin` 为每个 module/session 创建独立实例: ```ts -import { createAppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; -import { createLoginPlugin } from "@ohos-rs/ability-plugin-login"; -import { createPermissionPlugin } from "@ohos-rs/ability-plugin-permission"; -import { createWebviewPlugin } from "@ohos-rs/ability-plugin-webview"; -import { createWindowPlugin } from "@ohos-rs/ability-plugin-window"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { AppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; +import { LoginPlugin } from "@ohos-rs/ability-plugin-login"; +import { PermissionPlugin } from "@ohos-rs/ability-plugin-permission"; +import { WebviewPlugin } from "@ohos-rs/ability-plugin-webview"; +import { WindowPlugin } from "@ohos-rs/ability-plugin-window"; export default class EntryAbility extends NativeAbility { bridgePlugins = [ - createPermissionPlugin(), - createAppControlPlugin(), - createWindowPlugin(), - createWebviewPlugin(), - createLoginPlugin(), + new LazyPlugin(() => new PermissionPlugin()), + new LazyPlugin(() => new AppControlPlugin()), + new LazyPlugin(() => new WindowPlugin()), + new LazyPlugin(() => new WebviewPlugin()), + new LazyPlugin(() => new LoginPlugin()), ]; } ``` -factory 可以用 `modules` 限制适用的 native module。未装配、版本不匹配、模式不匹配和类型不匹配 +`LazyPlugin` 的第二个参数可以用 `modules` 限制适用的 native module。未装配、版本不匹配、模式不匹配和类型不匹配 都应在桥接边界确定性报错,不得悄悄回退到 helper 或 JSON 兼容路径。 `LazyPlugin` 在 `BridgeHost.registerFactories` 时为每个 native module 和 Ability session 创建独立 实例。禁止跨 module/session 共享 ArkTS plugin instance:`attachContext`、hook cancellation 和 -controller/window 映射都是 session 状态。真正的进程级资源必须由 native/Rust 单例持有,ArkTS -wrapper 仍保持 session-scoped。 +controller 映射都是 session 状态,`PluginBase.attachContext` 会拒绝复用。module 级状态应由注册到 +该 `OpenHarmonyApp` 的具体 Rust plugin instance 持有;例如 ResourceManager 通过 +`registered_plugin::()` 读取,而不是使用跨 module 全局变量。只有平台本身明确 +进程级的资源才使用进程级状态。即使 `requires = []`,插件仍在 Ability ready 后安装并属于当前 +module/session;空 requirements 只表示不额外依赖 WindowStage/UIContext,不表示可以恢复共享 +`EagerPlugin` 实例。 ## 9. 实现、Demo 与验收 @@ -583,11 +623,13 @@ wrapper 仍保持 session-scoped。 | 异步 action | Rust worker 可以发起调用;ArkTS Promise 完成后 Rust 收到具名 response,不存在 JSON encode/decode | | 主线程同步 action(如有) | 在 `#[napi]` callback 的 `Env` 内成功;`call_sync` 不能从 worker 直接调用(无 `Env`);没有 Promise 或阻塞等待 | | 子线程同步 action(TSFN,如有) | 从 Rust worker 调用 `call_sync_from_worker` 成功拿到具名 response;从 N-API 主线程调用被立即拒绝(防死锁) | -| context 延迟 | async 调用会等待真正的 context/根节点就绪;sync 调用在未就绪时立即失败 | +| context 延迟 | async 调用会等待本 module 唯一组件的 context/根节点就绪;sync 调用在未就绪时立即失败 | | 生命周期销毁 | timeout、Ability/session destroy 会取消调用;UI/WindowStage detach 会触发生命周期 cleanup,临时节点、delegate、waiter 和映射被释放或失效 | | 原生节点(如有) | 插件能 `appendChild` 到 session 根或经 `ohos.node` 句柄组合子树;业务 underlay/foreground 由页面 `Stack` 声明顺序决定,行为不变 | | 平台回调(如有) | 在当前回调栈完成 Rust 决策,并覆盖明确的 fail-open/fail-closed 语义 | | WebView(如有) | custom scheme、首次导航前安装、透明背景、导航、下载、标题和 JS script/proxy 均覆盖 | +| 多组件/多窗口 | 一个 Ability 以两个不同 native module 挂两个 `DefaultXComponent`;同 module 第二次并发 attach 被拒绝,两个 module 的销毁互不影响 | +| 多 WebView | 同一 module/component 内不同 WebView ID 可同时存在、独立控制和清理;同 ID recreate 语义明确 | 建议在 demo 中同时保留三个最小参考能力:异步登录、主线程同步调用、以及 `String`、`Vec`、 `#[napi(object)]` 三种具名 N-API 值的 raw transport。这样新插件可以直接验证类型边界而非依赖 JSON。 diff --git a/native_ability/CHANGELOG.md b/native_ability/CHANGELOG.md index be439fee..db430c6e 100644 --- a/native_ability/CHANGELOG.md +++ b/native_ability/CHANGELOG.md @@ -1,20 +1,38 @@ # 1.0.0-beta.1 - **Breaking**: remove `EagerPlugin`; every ArkTS plugin instance is now scoped to one - module/session. Process-wide resources stay in Rust/native singletons. + module/session, and `PluginBase` rejects instance reuse. Module-owned resources stay in the + corresponding registered Rust plugin instance. - **Breaking**: plugin hooks receive `BridgePluginHookContext` with cancellation, and native - `render` receives a per-appearance `renderOwner` plus optional `disposeRender` cleanup. + `render(slot, renderOwner)` receives a per-appearance owner plus `disposeRender` cleanup. +- **Breaking**: generic bridge bindings move from component `render` to + `init(bindings, bridgeOwner, context)` / `disposeBridge(bridgeOwner)`. The transport now follows + the module's Ability session, so ability-only plugins do not depend on XComponent appearance. +- Propagate `renderOwner` through Rust XComponent surface/input/frame callbacks so stale native + callbacks cannot mutate a replacement component's window, IME or geometry state. - Serialize Ability/WindowStage/UI lifecycle with generation guards, bounded hook watchdogs and prepare-then-activate startup so Rust sinks exist before ability plugin installation. -- Add per-window attach/detach lifecycle, independent Rust render roots and window-scoped WebView - controller cleanup. +- **Breaking**: enforce one `DefaultXComponent` per native module. One Ability supports multiple + components/windows through distinct modules; the same module cannot belong to two active + Ability sessions. Duplicate/empty `moduleName` entries fail configuration immediately, and the + generated native `render` export also rejects a second concurrent root. - **Breaking**: normalized node mounting — the named-slot model (`BridgeNodeSlot` / - `BridgeNodeHost` / `slotId`) is gone. WebView `FrameNode`s mount into the session root tree - (`context.appendChild`, key `ohos.webview.`), full-bleed by default. + `BridgeNodeHost` / `slotId`) is gone. WebView `FrameNode`s mount into the module root tree + (`context.appendChild`, host-owned unique key), full-bleed by default. - **Breaking**: `CreateRequest`/`ControllerRequest`/`ScriptRequest`/`CreateResponse` drop `slotId`; `CreateRequest` gains optional `parentHandle` (`ohos.node` container handle) so an RS-layer node tree can adopt WebViews as children. -- No readiness waiting: the session root exists before `ui-context-ready`; `onInstall` can mount. +- **Breaking**: remove `windowKey`/`windowScope` and the `window_key` fields from `ohos.node` and + WebView create contracts; both bridge versions are now 2. Multiple WebViews use distinct IDs in + the same module/component. +- **Breaking**: `BridgePluginContext.getWindow()` resolves the Window that owns the current + module/component. `ohos.window` version 2 now requires `ui-context`, so sub-window queries no + longer fall back to the Ability's main window. +- Route size/rect/avoid-area/keyboard callbacks from each component's actual Window to only its + native module; only `windowStageEvent` remains Ability-wide. +- The module/component root exists before `ui-context-ready`; `onInstall` can mount. +- Add named `invokeNativeSyncAcrossModules` for process-global plugin transitions; ArkWeb engine + initialization uses it to coordinate all active native modules before the first WebView. - Business layering is page `Stack` declaration order; `underlay`/`foreground` hosts are gone. --- diff --git a/native_ability/README.md b/native_ability/README.md index a99059dc..41e9fbc0 100644 --- a/native_ability/README.md +++ b/native_ability/README.md @@ -34,6 +34,11 @@ Notes: 1. Every lifecycle override should call the `super` implementation first. 2. `moduleName` is the bare module name; the runtime resolves it to `lib.so`. 3. `moduleName` can also be `string[]` when one ability needs multiple native modules. +4. Each `DefaultXComponent` uses exactly one distinct module. A module cannot be attached to two + components or two active Ability sessions at the same time. +5. The module bridge transport is opened during `NativeAbility.onCreate`, independently from its + optional component render. Ability-only plugins therefore work before appearance and across a + component disappear/reappear cycle; UI plugins still wait for `ui-context-ready`. ### `loadMode` @@ -46,9 +51,10 @@ When using `sync`, add the corresponding library to `build-profile.json5` runtim ### `DefaultXComponent` -`DefaultXComponent` loads the native module, binds the default native rendering surface, and owns -the single session node tree that capability plugins mount into. The tree exists before -`ui-context-ready`, so plugins can mount during `onInstall` without slots, registries or waiters. +`DefaultXComponent` loads one native module, binds its rendering surface, and owns that module's +single node tree. An Ability can place several components in one or several windows by declaring +several modules and assigning a different module to each component. A module's second concurrent +component attachment is rejected. ```ts import { DefaultXComponent } from "@ohos-rs/ability"; @@ -68,20 +74,26 @@ struct Index { } ``` -### Plugins and the single node tree +### Plugins and module-owned node trees Compose ArkTS plugin factories explicitly in `NativeAbility.bridgePlugins`. A capability that -needs UI nodes mounts a `FrameNode` into the session root tree (`context.appendChild`); the +needs UI nodes mounts a `FrameNode` into its module's component root (`context.appendChild`); the framework never embeds a WebView special case. Rust composes trees through opaque `ohos.node` -handles; `FrameNode` values never cross the N-API boundary. +handles; `FrameNode` values never cross the N-API boundary. One component may host multiple +WebViews, distinguished by controller ID and mount key. + +`WindowStage` lifecycle remains Ability-wide. Window size, rect, avoid-area and keyboard events do +not: each component host resolves the actual `Window` from its `UIContext` and forwards those +events only to that component's native module. A sub-window module therefore never receives main +window geometry by mistake. ```ts -import { DefaultXComponent, NativeAbility } from "@ohos-rs/ability"; -import { createWebviewPlugin } from "@ohos-rs/ability-plugin-webview"; +import { DefaultXComponent, LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { WebviewPlugin } from "@ohos-rs/ability-plugin-webview"; export default class EntryAbility extends NativeAbility { - public moduleName = "demo_native"; - public bridgePlugins = [createWebviewPlugin()]; + public moduleName = ["demo_native", "demo_sub_native"]; + public bridgePlugins = [new LazyPlugin(() => new WebviewPlugin())]; } @Entry @@ -93,9 +105,9 @@ struct Page { build() { Stack() { - // Layer order is declaration order: business content below and above the single session - // node tree. There is no BridgeNodeHost or named slot. + // Each component uses a distinct module and owns an independent tree. DefaultXComponent({ moduleName: "demo_native" }) + DefaultXComponent({ moduleName: "demo_sub_native" }) this.BusinessOverlay() } } diff --git a/plugins/app-control/README.md b/plugins/app-control/README.md index f8d9b84e..f0399ad8 100644 --- a/plugins/app-control/README.md +++ b/plugins/app-control/README.md @@ -22,11 +22,11 @@ ohpm install @ohos-rs/ability-plugin-app-control ``` ```ts -import { NativeAbility } from "@ohos-rs/ability"; -import { createAppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { AppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; export default class EntryAbility extends NativeAbility { - public bridgePlugins = [createAppControlPlugin()]; + public bridgePlugins = [new LazyPlugin(() => new AppControlPlugin())]; } ``` @@ -34,7 +34,7 @@ Rust 必须同时注册 `AppControlBridgePlugin`,并只在当前 N-API callbac `AppControlExt::terminate`。完整 Rust 用法见 [Rust facade README](../../crates/plugin-app-control/README.md)。 -## Factory 契约 +## Plugin 契约 | 字段 | 值 | | --- | --- | diff --git a/plugins/files/README.md b/plugins/files/README.md index e3e629d5..36408fe4 100644 --- a/plugins/files/README.md +++ b/plugins/files/README.md @@ -33,7 +33,7 @@ export default class EntryAbility extends NativeAbility { Rust 侧还需注册 `FilesBridgePlugin`,并通过 `FilesExt::show_file_dialog` 发起调用。使用示例见 [Rust facade README](../../crates/plugin-files/README.md)。 -## Factory 契约 +## Plugin 契约 | 字段 | 值 | | --- | --- | diff --git a/plugins/permission/README.md b/plugins/permission/README.md index fed3d91c..bc79622d 100644 --- a/plugins/permission/README.md +++ b/plugins/permission/README.md @@ -23,21 +23,21 @@ ohpm install @ohos-rs/ability-plugin-permission } ``` -在入口 Ability 显式注册 factory,并继续调用 `NativeAbility` 的生命周期实现: +在入口 Ability 通过 `LazyPlugin` 显式注册新 plugin 实例,并继续调用 `NativeAbility` 的生命周期实现: ```ts -import { NativeAbility } from "@ohos-rs/ability"; -import { createPermissionPlugin } from "@ohos-rs/ability-plugin-permission"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { PermissionPlugin } from "@ohos-rs/ability-plugin-permission"; export default class EntryAbility extends NativeAbility { - public bridgePlugins = [createPermissionPlugin()]; + public bridgePlugins = [new LazyPlugin(() => new PermissionPlugin())]; } ``` Rust 侧也必须在 `#[ability]` 初始化器注册 `PermissionBridgePlugin`。两端装配方式见 [Rust facade README](../../crates/plugin-permission/README.md)。 -## Factory 契约 +## Plugin 契约 | 字段 | 值 | | --- | --- | diff --git a/plugins/resource/CHANGELOG.md b/plugins/resource/CHANGELOG.md index 11fa80e1..dc23cd14 100644 --- a/plugins/resource/CHANGELOG.md +++ b/plugins/resource/CHANGELOG.md @@ -1,7 +1,10 @@ # Unreleased - **Breaking**: replace the shared `EagerPlugin` wrapper with a session-scoped `LazyPlugin` - instance; Rust continues to own the process-wide native manager pointer. + instance; `PluginBase` rejects reuse across modules/sessions. +- Push the manager from Ability-scoped `onInstall`, without requiring a WindowStage or component. +- **Breaking**: move the native manager from a cross-module global into the registered Rust + `ResourceBridgePlugin` instance. --- diff --git a/plugins/resource/README.md b/plugins/resource/README.md index f38b0347..6a64ea61 100644 --- a/plugins/resource/README.md +++ b/plugins/resource/README.md @@ -12,7 +12,7 @@ ohpm install @ohos-rs/ability-plugin-resource ## 职责 - 持有 `abilityContext.resourceManager` 平台对象; -- 在 `ability-create` 时经 `context.invokeNativeSync("resource-manager-ready", ...)` 把对象 +- 在 Ability-scoped `onInstall` 经 `context.invokeNativeSync("resource-manager-ready", ...)` 把对象 推送给 Rust facade; - 不执行任何资源读取逻辑 —— 所有读取由 Rust 侧通过 `ohos-resource-manager-binding` 直连 OpenHarmony C API 完成。 @@ -29,9 +29,9 @@ public bridgePlugins = [ ]; ``` -ArkTS wrapper 必须是 module/session 级实例,避免 `attachContext` 覆盖另一个 session 的 hook -上下文。native resource manager 的进程级 pointer 仍由 Rust/C API 层持有,不需要共享 ArkTS -plugin instance。 +ArkTS wrapper 必须是 module/session 级实例;`attachContext` 会拒绝跨 module/session 复用。 +native resource manager 由该 module 注册的 Rust `ResourceBridgePlugin` instance 持有,不共享 ArkTS +plugin instance,也不使用跨 module 全局 pointer。 ## 契约 @@ -44,6 +44,5 @@ plugin instance。 ## 时序 -推送点在 `ability-create`。入站事件 sink 在 `NativeAbility.onCreate` 中 `module.init` 之后立即 -attach(`attachBridgeEventSink`),不再等到 UI 渲染;`DefaultXComponent.aboutToAppear` 中的 -attach 保留为同一 module 对象的幂等兜底。 +推送点在 `onInstall`。此时入站事件 sink 和 Rust `AbilityCreated` 都已就绪,但不需要 WindowStage、 +UIContext 或 DefaultXComponent。 diff --git a/plugins/url/README.md b/plugins/url/README.md index 2cb32626..cfe033ac 100644 --- a/plugins/url/README.md +++ b/plugins/url/README.md @@ -32,7 +32,7 @@ export default class EntryAbility extends NativeAbility { Rust 侧还需注册 `UrlBridgePlugin`,并通过 `UrlExt::open_url` 发起调用。使用示例见 [Rust facade README](../../crates/plugin-url/README.md)。 -## Factory 契约 +## Plugin 契约 | 字段 | 值 | | --- | --- | diff --git a/plugins/webview/CHANGELOG.md b/plugins/webview/CHANGELOG.md index bd4877bf..f3f2bbb6 100644 --- a/plugins/webview/CHANGELOG.md +++ b/plugins/webview/CHANGELOG.md @@ -1,12 +1,25 @@ # 1.0.0-beta.1 +- **Breaking**: bridge version 2 removes `windowKey`; each native module owns one + `DefaultXComponent`, while multiple WebViews coexist by controller ID. Multiple windows use + distinct native modules. - **Breaking**: normalized node mounting — the named-slot model (`BridgeNodeSlot` / - `BridgeNodeHost` / `slotId`) is gone. WebView `FrameNode`s mount into the session root tree - (`context.appendChild`, key `ohos.webview.`), full-bleed by default. + `BridgeNodeHost` / `slotId`) is gone. WebView `FrameNode`s mount into the module root tree + (`context.appendChild`, host-owned unique key), full-bleed by default. WebView IDs remain opaque + business identifiers rather than becoming node keys. - **Breaking**: `CreateRequest`/`ControllerRequest`/`ScriptRequest`/`CreateResponse` drop `slotId`; `CreateRequest` gains optional `parentHandle` (`ohos.node` container handle) so an RS-layer node tree can adopt WebViews as children. -- No readiness waiting: the session root exists before `ui-context-ready`; `onInstall` can mount. +- The module root exists before `ui-context-ready`; controller creation is event-driven. +- Coordinate the process-global ArkWeb engine across every active native module before first + initialization; identical scheme declarations remain idempotent across Ability recreation, and + a later module may join only with schemes already registered process-wide using the same options. +- Keep public WebView IDs module-local while generating a process-unique ArkWeb controller tag; + protocol and JavaScript proxy installation use the named `{ id, nativeTag }` event. Navigation, + download and title events also carry the tag internally so a replaced controller's delayed + callback cannot target its same-ID replacement. +- Clear Rust controller attachment state again on UI/Ability teardown, so closing-state rejection + of an ArkTS cleanup notification cannot leak a stale tag into the next appearance. - Business layering is page `Stack` declaration order; `underlay`/`foreground` hosts are gone. # 1.0.0-beta.0 diff --git a/plugins/webview/README.md b/plugins/webview/README.md index c53b5318..75a2d970 100644 --- a/plugins/webview/README.md +++ b/plugins/webview/README.md @@ -1,7 +1,7 @@ # @ohos-rs/ability-plugin-webview 这是 `openharmony-ability-plugin-webview` 的 ArkTS HAR。它创建 ArkWeb `Web` / `WebviewController`、 -把 WebView 的 `FrameNode` 挂进 session 根树(或调用方指定的 `ohos.node` 容器),并把所有 +把 WebView 的 `FrameNode` 挂进当前 native module 对应组件的根树(或 `ohos.node` 容器),并把所有 controller 操作和 ArkWeb callback 转换为具名 N-API bridge 调用。 业务不直接保存 controller,也不应把 WebView 接口重新写回 `DefaultXComponent`。Rust facade 使用 @@ -25,11 +25,11 @@ ohpm install @ohos-rs/ability-plugin-webview ``` ```ts -import { NativeAbility } from "@ohos-rs/ability"; -import { createWebviewPlugin } from "@ohos-rs/ability-plugin-webview"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { WebviewPlugin } from "@ohos-rs/ability-plugin-webview"; export default class EntryAbility extends NativeAbility { - public bridgePlugins = [createWebviewPlugin()]; + public bridgePlugins = [new LazyPlugin(() => new WebviewPlugin())]; } ``` @@ -37,14 +37,14 @@ Rust 侧必须在 `#[ability]` 初始化器注册 `WebviewBridgePlugin`;自定 `WebviewProtocol::register` 声明。完整 Rust 用法见 [Rust facade README](../../crates/plugin-webview/README.md)。 -## Factory 与 action +## Plugin 与 action | 项目 | 值 | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` / `version` | `ohos.webview` / `1` | +| `id` / `version` | `ohos.webview` / `2` | | `execution` | `async` | | `requires` | `["ui-context"]` | -| 挂载 | session 根树(默认全屏);可选 `parentHandle`(`ohos.node` 容器) | +| 挂载 | 当前 module/component 根树(默认全屏);可选 `parentHandle`(`ohos.node` 容器) | | 创建 | `create`:`CreateRequest { id, parentHandle? }` → `CreateResponse { id }` | | 控制器操作 | `set-visible`、`set-background-color`、`remove`、`load-url`、`load-html`、`set-zoom`、`reload`、`focus`、`get-url`、`cookies-with-url`、`clear-all-browsing-data`、`evaluate-script` | @@ -54,7 +54,7 @@ controller attach、delegate/protocol/proxy 安装和首次 load 启动;它不 ## 挂载与混合布局 -WebView 的 `FrameNode` 默认挂进 session 根树(`context.appendChild`,key 为 `ohos.webview.`), +WebView 的 `FrameNode` 默认以 host 内部唯一 key 挂进当前 module 的组件根树, 全屏显示。Rust 需要组合时,先用内置 `ohos.node` 插件创建容器,把容器句柄作为 `parentHandle` 传入 create request,WebView 节点就会挂到该容器下;容器最终由 Rust `mount-into-root` 整体挂载。 @@ -68,7 +68,12 @@ Stack() { } ``` -节点挂载没有等待语义:session 根在 `ui-context-ready` 前已存在,`onInstall` 内即可挂载。 +一个 `DefaultXComponent` 必须使用一个独立 native module;跨窗口组件不能共享 module,也不使用 +`windowKey`/`surfaceKey`。同一 module/component 可按不同 ID 同时持有多个 WebView。WebView ID 是 +不透明、module-local 的业务标识,不会直接拼接成节点 key 或进程级 ArkWeb tag;HAR 会用 +session + module + instance 序号生成内部唯一 native tag,因此不同 module 可安全复用同一业务 ID。 + +节点挂载没有计时等待:module 根在 `ui-context-ready` 前已存在,`onInstall` 后即可挂载。 Ability/session dispose 时,HAR 必须卸载自己创建的节点与 controller 状态(`remove` 走 `context.removeChild` 或从父容器摘除),不能影响业务节点或其他插件。 @@ -78,9 +83,15 @@ Ability/session dispose 时,HAR 必须卸载自己创建的节点与 controlle 下载结束、标题变化、engine/controller lifecycle 全部通过 `context.invokeNativeSync` 回到 Rust `on_main_thread_event`。 - 导航未订阅或 callback 出错时 fail-open(不拦截);下载开始未订阅或出错时 fail-closed(取消下载)。 - 下载结束、标题等通知型 callback 只记录失败。 -- custom scheme 必须在 engine 初始化前注册;controller attach 后先安装 tag 对应的 protocol、JS proxy - 与 delegate,再进行首次导航。 + 下载结束、标题等通知型 callback 只记录失败。所有 controller 平台事件同时携带内部 native tag; + Rust 会先校验当前 ID 对应的 controller generation,旧实例的延迟导航回调 fail-open、下载开始 + fail-closed,通知型回调直接丢弃。 +- custom scheme 必须在进程级 engine 初始化前注册;第一个 WebView create 会在初始化前后向所有已 + 激活、装配本插件的 native module 广播具名 engine 事件,因此尚未出现组件的 module 也会先 seal、 + 聚合校验并 flush 自己的 Rust scheme 状态。Ability 重建时可幂等重复相同 scheme + options;engine 启动后新增 scheme + 会确定性失败;后加载 module 只能复用已注册且 options 完全相同的 scheme;不同 module 对同名 + scheme 声明不同 options 时也会在 engine 初始化前失败。 + controller attach 后先安装 tag 对应的 protocol、JS proxy 与 delegate,再进行首次导航。 - URL custom protocol 与 `window..()` JavaScript proxy 是不同机制;前者处理资源请求, 后者处理页面到 Rust 的方法调用。 - `.transparent(true)` 的创建语义由 `CreateRequest` 保留:没有显式 background color 时使用透明背景。 diff --git a/plugins/window/CHANGELOG.md b/plugins/window/CHANGELOG.md index eb55b948..7eb7dc98 100644 --- a/plugins/window/CHANGELOG.md +++ b/plugins/window/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.0.0-beta.1 +- **Breaking**: bridge version 2 requires `ui-context` and resolves the Window that owns this + module's `DefaultXComponent`, so sub-window modules no longer query the Ability's main window. + +--- + # 1.0.0-beta.0 - Initial release: typed `ohos.window` plugin for window avoid-area queries. - Main-thread sync `get-avoid-area` action returning the complete avoid area. diff --git a/plugins/window/README.md b/plugins/window/README.md index 0160c4d2..a65fea6b 100644 --- a/plugins/window/README.md +++ b/plugins/window/README.md @@ -1,7 +1,8 @@ # @ohos-rs/ability-plugin-window 这是窗口避让区能力的 ArkTS HAR,对应 Rust crate `openharmony-ability-plugin-window`。它在当前 -`WindowStage` 的主窗口上同步调用 `getWindowAvoidArea`,并将完整结果返回 Rust。 +native module 的 `DefaultXComponent` 所在窗口上同步调用 `getWindowAvoidArea`,并将完整结果返回 +Rust。 ## Install @@ -21,11 +22,11 @@ ohpm install @ohos-rs/ability-plugin-window ``` ```ts -import { NativeAbility } from "@ohos-rs/ability"; -import { createWindowPlugin } from "@ohos-rs/ability-plugin-window"; +import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; +import { WindowPlugin } from "@ohos-rs/ability-plugin-window"; export default class EntryAbility extends NativeAbility { - public bridgePlugins = [createWindowPlugin()]; + public bridgePlugins = [new LazyPlugin(() => new WindowPlugin())]; } ``` @@ -33,13 +34,13 @@ Rust 侧还需注册 `WindowBridgePlugin`,并通过当前 N-API callback 的 ` `WindowExt::query_avoid_area`。使用示例见 [Rust facade README](../../crates/plugin-window/README.md)。 -## Factory 契约 +## Plugin 契约 | 字段 | 值 | | --- | --- | -| `id` / `version` | `ohos.window` / `1` | +| `id` / `version` | `ohos.window` / `2` | | `execution` | `sync-main-thread` | -| `requires` | `["window-stage"]` | +| `requires` | `["ui-context"]` | | 支持 action | `get-avoid-area` | | request → response | `ohos.window.AvoidAreaRequest` → `ohos.window.AvoidAreaResponse` | @@ -49,9 +50,9 @@ ArkTS request 是 `{ areaType: number }`,response 是 ## 运行限制 -- 必须在 `NativeAbility.onWindowStageCreate` 后才会激活;同步调用不等待 `WindowStage`。 -- `invokeSync` 通过 `context.getWindowStage().getMainWindowSync()` 获取主窗口。取主窗口或查询平台 API - 失败时,抛出明确错误给 Rust。 +- 当前 module 的 `DefaultXComponent` 注入 `UIContext` 后才会激活;同步调用不等待组件就绪。 +- `invokeSync` 通过 `context.getWindow()` 获取该组件实际所在的主窗口或 sub window。定位窗口或查询 + 平台 API 失败时,抛出明确错误给 Rust。 - `areaType` 必须是整数,且 request/response typeName 必须精确匹配;不支持 JSON 或动态对象兼容层。 - 该插件不保存 `WindowStage`、`UIContext` 或 N-API object 到 worker。 From 8fed7143f3cdf8307ef89d5eca5c3064de1621b8 Mon Sep 17 00:00:00 2001 From: richerfu Date: Tue, 11 Aug 2026 14:51:00 +0800 Subject: [PATCH 5/5] fix(build): drop unused native ability resources --- .../src/main/resources/base/element/string.json | 8 -------- .../src/main/resources/en_US/element/string.json | 8 -------- .../src/main/resources/zh_CN/element/string.json | 8 -------- 3 files changed, 24 deletions(-) delete mode 100644 native_ability/src/main/resources/base/element/string.json delete mode 100644 native_ability/src/main/resources/en_US/element/string.json delete mode 100644 native_ability/src/main/resources/zh_CN/element/string.json diff --git a/native_ability/src/main/resources/base/element/string.json b/native_ability/src/main/resources/base/element/string.json deleted file mode 100644 index f51a9c84..00000000 --- a/native_ability/src/main/resources/base/element/string.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "string": [ - { - "name": "page_show", - "value": "page from package" - } - ] -} diff --git a/native_ability/src/main/resources/en_US/element/string.json b/native_ability/src/main/resources/en_US/element/string.json deleted file mode 100644 index f51a9c84..00000000 --- a/native_ability/src/main/resources/en_US/element/string.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "string": [ - { - "name": "page_show", - "value": "page from package" - } - ] -} diff --git a/native_ability/src/main/resources/zh_CN/element/string.json b/native_ability/src/main/resources/zh_CN/element/string.json deleted file mode 100644 index f51a9c84..00000000 --- a/native_ability/src/main/resources/zh_CN/element/string.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "string": [ - { - "name": "page_show", - "value": "page from package" - } - ] -}