From f1ba0568007f1bbee9047a21eac6f025a5bd51f9 Mon Sep 17 00:00:00 2001 From: richerfu Date: Mon, 3 Aug 2026 18:54:28 +0800 Subject: [PATCH 01/18] feat: pluginize native bridge capabilities --- .agents/skills/named-napi-contracts/SKILL.md | 206 +++ .../references/contract-table.md | 53 + Cargo.lock | 74 +- Cargo.toml | 11 +- crates/ability/Cargo.toml | 8 +- crates/ability/src/app.rs | 284 ++-- crates/ability/src/bridge/mod.rs | 1125 ++++++++++++++++ crates/ability/src/helper/mod.rs | 40 - crates/ability/src/helper/permission.rs | 133 -- crates/ability/src/helper/webview.rs | 483 ------- crates/ability/src/helper/window_info.rs | 5 - crates/ability/src/lib.rs | 16 +- crates/ability/src/lifecycle.rs | 37 +- crates/ability/src/render/xcomponent.rs | 20 +- crates/ability/src/webview/drag.rs | 6 - crates/ability/src/webview/mod.rs | 334 ----- crates/derive/README.md | 102 +- crates/derive/src/lib.rs | 165 +-- crates/plugin-app-control/Cargo.toml | 11 + crates/plugin-app-control/README.md | 78 ++ crates/plugin-app-control/src/lib.rs | 78 ++ crates/plugin-permission/Cargo.toml | 11 + crates/plugin-permission/README.md | 101 ++ crates/plugin-permission/src/lib.rs | 194 +++ crates/plugin-webview/Cargo.toml | 13 + crates/plugin-webview/README.md | 157 +++ crates/plugin-webview/src/callbacks.rs | 202 +++ crates/plugin-webview/src/js_proxy.rs | 202 +++ crates/plugin-webview/src/lib.rs | 889 +++++++++++++ crates/plugin-webview/src/protocol.rs | 477 +++++++ crates/plugin-window/Cargo.toml | 11 + crates/plugin-window/README.md | 76 ++ crates/plugin-window/src/lib.rs | 185 +++ demo/build-profile.json5 | 33 +- demo/entry/oh-package-lock.json5 | 69 +- demo/entry/oh-package.json5 | 5 +- .../src/main/cpp/types/libark_web/Index.d.ts | 1 - .../cpp/types/libark_web/oh-package.json5 | 6 - .../main/cpp/types/libdemo_native/Index.d.ts | 268 +++- .../src/main/ets/bridge/DemoLoginPlugin.ets | 239 ++++ .../src/main/ets/bridge/DemoNodePlugin.ets | 80 ++ .../src/main/ets/bridge/DemoRawPlugin.ets | 79 ++ .../main/ets/entryability/EntryAbility.ets | 30 +- .../entrybackupability/EntryBackupAbility.ets | 2 +- demo/entry/src/main/ets/pages/Index.ets | 162 ++- demo/oh-package-lock.json5 | 4 +- docs/plugin-development-standard.md | 544 ++++++++ native_ability/.gitignore | 3 +- native_ability/BuildProfile.ets | 2 +- native_ability/index.ets | 31 +- native_ability/oh-package.json5 | 2 +- .../src/main/ets/ability/NativeAbility.ets | 135 +- native_ability/src/main/ets/ability/type.ets | 224 +++- .../src/main/ets/bridge/BridgeHost.ets | 825 ++++++++++++ .../src/main/ets/bridge/BridgeNodeSlot.ets | 313 +++++ .../main/ets/components/DefaultXComponent.ets | 267 ++-- .../src/main/ets/components/MainPage.ets | 16 +- native_ability/src/main/ets/helper/index.ets | 7 - .../src/main/ets/helper/loadable.ets | 69 - native_ability/src/main/ets/helper/object.ts | 11 - native_ability/src/main/ets/helper/os.ets | 9 - .../src/main/ets/helper/permission.ets | 54 - native_ability/src/main/ets/helper/random.ets | 9 - .../src/main/ets/runtime/BackPress.ets | 10 + .../main/ets/runtime/NativeModuleLoader.ets | 84 ++ .../src/main/ets/webview/DefaultWebview.ets | 405 ------ native_ability/src/main/ets/webview/Utils.ets | 27 - package/README.md | 70 +- package/index.ets | 28 +- plugins/app-control/.gitignore | 3 + plugins/app-control/BuildProfile.ets | 17 + plugins/app-control/README.md | 52 + plugins/app-control/build-profile.json5 | 9 + plugins/app-control/hvigorfile.ts | 6 + plugins/app-control/index.ets | 1 + plugins/app-control/oh-package-lock.json5 | 29 + plugins/app-control/oh-package.json5 | 10 + .../src/main/ets/AppControlPlugin.ets | 65 + plugins/app-control/src/main/module.json5 | 7 + plugins/permission/.gitignore | 3 + plugins/permission/BuildProfile.ets | 17 + plugins/permission/README.md | 59 + plugins/permission/build-profile.json5 | 9 + plugins/permission/hvigorfile.ts | 6 + plugins/permission/index.ets | 1 + plugins/permission/oh-package-lock.json5 | 29 + plugins/permission/oh-package.json5 | 10 + .../src/main/ets/PermissionPlugin.ets | 93 ++ plugins/permission/src/main/module.json5 | 7 + plugins/webview/.gitignore | 3 + plugins/webview/BuildProfile.ets | 17 + plugins/webview/README.md | 83 ++ plugins/webview/build-profile.json5 | 9 + plugins/webview/hvigorfile.ts | 6 + plugins/webview/index.ets | 1 + plugins/webview/oh-package-lock.json5 | 29 + plugins/webview/oh-package.json5 | 10 + .../webview/src/main/ets/WebviewPlugin.ets | 1156 +++++++++++++++++ plugins/webview/src/main/module.json5 | 7 + plugins/window/.gitignore | 3 + plugins/window/BuildProfile.ets | 17 + plugins/window/README.md | 53 + plugins/window/build-profile.json5 | 9 + plugins/window/hvigorfile.ts | 6 + plugins/window/index.ets | 1 + plugins/window/oh-package-lock.json5 | 29 + plugins/window/oh-package.json5 | 10 + plugins/window/src/main/ets/WindowPlugin.ets | 100 ++ plugins/window/src/main/module.json5 | 7 + prek.toml | 2 +- rust_example/demo_native/Cargo.toml | 8 +- rust_example/demo_native/src/index.html | 3 +- rust_example/demo_native/src/lib.rs | 330 +++-- rust_example/demo_native/src/login_bridge.rs | 100 ++ .../demo_native/src/main_thread_bridge.rs | 57 + rust_example/demo_native/src/raw_bridge.rs | 59 + 116 files changed, 10023 insertions(+), 2435 deletions(-) create mode 100644 .agents/skills/named-napi-contracts/SKILL.md create mode 100644 .agents/skills/named-napi-contracts/references/contract-table.md create mode 100644 crates/ability/src/bridge/mod.rs delete mode 100644 crates/ability/src/helper/mod.rs delete mode 100644 crates/ability/src/helper/permission.rs delete mode 100644 crates/ability/src/helper/webview.rs delete mode 100644 crates/ability/src/helper/window_info.rs delete mode 100644 crates/ability/src/webview/drag.rs delete mode 100644 crates/ability/src/webview/mod.rs create mode 100644 crates/plugin-app-control/Cargo.toml create mode 100644 crates/plugin-app-control/README.md create mode 100644 crates/plugin-app-control/src/lib.rs create mode 100644 crates/plugin-permission/Cargo.toml create mode 100644 crates/plugin-permission/README.md create mode 100644 crates/plugin-permission/src/lib.rs create mode 100644 crates/plugin-webview/Cargo.toml create mode 100644 crates/plugin-webview/README.md create mode 100644 crates/plugin-webview/src/callbacks.rs create mode 100644 crates/plugin-webview/src/js_proxy.rs create mode 100644 crates/plugin-webview/src/lib.rs create mode 100644 crates/plugin-webview/src/protocol.rs create mode 100644 crates/plugin-window/Cargo.toml create mode 100644 crates/plugin-window/README.md create mode 100644 crates/plugin-window/src/lib.rs delete mode 100644 demo/entry/src/main/cpp/types/libark_web/Index.d.ts delete mode 100644 demo/entry/src/main/cpp/types/libark_web/oh-package.json5 create mode 100644 demo/entry/src/main/ets/bridge/DemoLoginPlugin.ets create mode 100644 demo/entry/src/main/ets/bridge/DemoNodePlugin.ets create mode 100644 demo/entry/src/main/ets/bridge/DemoRawPlugin.ets create mode 100644 docs/plugin-development-standard.md create mode 100644 native_ability/src/main/ets/bridge/BridgeHost.ets create mode 100644 native_ability/src/main/ets/bridge/BridgeNodeSlot.ets delete mode 100644 native_ability/src/main/ets/helper/index.ets delete mode 100644 native_ability/src/main/ets/helper/loadable.ets delete mode 100644 native_ability/src/main/ets/helper/object.ts delete mode 100644 native_ability/src/main/ets/helper/os.ets delete mode 100644 native_ability/src/main/ets/helper/permission.ets delete mode 100644 native_ability/src/main/ets/helper/random.ets create mode 100644 native_ability/src/main/ets/runtime/BackPress.ets create mode 100644 native_ability/src/main/ets/runtime/NativeModuleLoader.ets delete mode 100644 native_ability/src/main/ets/webview/DefaultWebview.ets delete mode 100644 native_ability/src/main/ets/webview/Utils.ets create mode 100644 plugins/app-control/.gitignore create mode 100644 plugins/app-control/BuildProfile.ets create mode 100644 plugins/app-control/README.md create mode 100644 plugins/app-control/build-profile.json5 create mode 100644 plugins/app-control/hvigorfile.ts create mode 100644 plugins/app-control/index.ets create mode 100644 plugins/app-control/oh-package-lock.json5 create mode 100644 plugins/app-control/oh-package.json5 create mode 100644 plugins/app-control/src/main/ets/AppControlPlugin.ets create mode 100644 plugins/app-control/src/main/module.json5 create mode 100644 plugins/permission/.gitignore create mode 100644 plugins/permission/BuildProfile.ets create mode 100644 plugins/permission/README.md create mode 100644 plugins/permission/build-profile.json5 create mode 100644 plugins/permission/hvigorfile.ts create mode 100644 plugins/permission/index.ets create mode 100644 plugins/permission/oh-package-lock.json5 create mode 100644 plugins/permission/oh-package.json5 create mode 100644 plugins/permission/src/main/ets/PermissionPlugin.ets create mode 100644 plugins/permission/src/main/module.json5 create mode 100644 plugins/webview/.gitignore create mode 100644 plugins/webview/BuildProfile.ets create mode 100644 plugins/webview/README.md create mode 100644 plugins/webview/build-profile.json5 create mode 100644 plugins/webview/hvigorfile.ts create mode 100644 plugins/webview/index.ets create mode 100644 plugins/webview/oh-package-lock.json5 create mode 100644 plugins/webview/oh-package.json5 create mode 100644 plugins/webview/src/main/ets/WebviewPlugin.ets create mode 100644 plugins/webview/src/main/module.json5 create mode 100644 plugins/window/.gitignore create mode 100644 plugins/window/BuildProfile.ets create mode 100644 plugins/window/README.md create mode 100644 plugins/window/build-profile.json5 create mode 100644 plugins/window/hvigorfile.ts create mode 100644 plugins/window/index.ets create mode 100644 plugins/window/oh-package-lock.json5 create mode 100644 plugins/window/oh-package.json5 create mode 100644 plugins/window/src/main/ets/WindowPlugin.ets create mode 100644 plugins/window/src/main/module.json5 create mode 100644 rust_example/demo_native/src/login_bridge.rs create mode 100644 rust_example/demo_native/src/main_thread_bridge.rs create mode 100644 rust_example/demo_native/src/raw_bridge.rs diff --git a/.agents/skills/named-napi-contracts/SKILL.md b/.agents/skills/named-napi-contracts/SKILL.md new file mode 100644 index 00000000..90410ded --- /dev/null +++ b/.agents/skills/named-napi-contracts/SKILL.md @@ -0,0 +1,206 @@ +--- +name: named-napi-contracts +description: >- + openharmony-activity 仓库内置插件(crates/plugin-* 与 plugins/* 成对开发)的编码规范: + 实现或修改插件 action 的 request/response 时,必须优先使用具名 N-API 类型 + (#[napi(object)] + impl_bridge_napi_type!),不得使用 JSON 协议 + (BridgeJson / call_json / bridgeJson / requireBridgeJson)。 + ArkTS → Rust 的反向平台事件同样必须使用具名 N-API 类型;并给出 Rust 侧与 ArkTS 侧的 + 完整实现步骤与验证清单。 + 当任务涉及新增插件、新增 action、修改现有内置插件契约、或 review 插件代码时使用。 +--- + +# 具名 N-API 契约优先(Named N-API Contracts) + +本仓库(openharmony-ability)在 0.4.0-beta.7 → 1.0.0 重构后,所有内置插件的 +request/response 已从 JSON envelope 迁移为**具名 N-API 类型**。新代码必须遵守同样规则。 + +## 1. 为什么不用 JSON + +- JSON envelope 丢失类型信息:Rust 侧 `String`、`Vec`、`#[napi(object)]` 在桥上是 + 不同的 N-API value,JSON 会把它们全部压平成字符串,且无法区分。 +- `typeName` 是稳定 ABI 契约:Rust 与 ArkTS 两端按名字校验,类型不匹配在边界上 + **确定性报错**,而不是运行时悄悄丢字段。 +- 零序列化开销:具名类型直接以真实 N-API value 传输(object / string / Uint8Array), + 不经过 JSON.stringify / JSON.parse。 +- 文档依据:`docs/plugin-development-standard.md` §3「请求与响应:具名 N-API 类型,不使用 JSON」。 + +## 2. 必须遵守的规则 + +### 规则 1:内置插件 request/response 一律具名 N-API + +新建或修改任何内置插件 action(permission、app-control、window、webview 及未来插件), +请求与响应**都必须是** `#[napi(object)]` + `impl_bridge_napi_type!`,禁止: + +- Rust 侧 `BridgeJson` / `BridgeClient::call_json` +- ArkTS 侧 `bridgeJson` / `requireBridgeJson` +- 任何把 payload 包成 JSON 字符串再传输的做法 + +### 规则 2:没有 JSON 例外 + +当前 bridge 没有供插件使用的 JSON event port。request、response 以及 ArkTS → Rust 的平台回调 +全部使用具名 N-API value;历史 `BridgeJson`、`call_json`、`bridgeJson`、`requireBridgeJson` 和 +`context.emit(..., JSON.stringify(...))` 一律不得在新代码中出现。反向回调使用 +`context.invokeNativeSync(event, requestTypeName, responseTypeName, value)`,Rust 在 +`BridgePlugin::on_main_thread_event` 中同步处理。 + +### 规则 3:typeName 命名约定 + +- 内置插件:以完整 plugin ID 为前缀,如 `ohos.webview.CreateRequest`、 + `ohos.permission.PermissionResponse`、`ohos.window.AvoidAreaResponse`、 + `ohos.app_control.TerminateRequest`。 +- 业务类型:`.`,如 `account.LoginToken`、`demo.Profile`。 +- 内置标量:`std.string`、`std.bytes`、`std.bool`、`std.i32`、`std.f64`。 +- typeName 字符集:`^[A-Za-z0-9._-]+$`(`validate_identifier` 强制)。 + +### 规则 4:action 命名与版本 + +- action 使用 kebab-case 动词短语:`create`、`set-visible`、`load-url`、 + `get-avoid-area`、`evaluate-script`、`clear-all-browsing-data`。 +- 每个插件 `VERSION` 必须与 ArkTS 侧 `version` 完全一致(BridgeHost.lookup 强校验), + 改契约时必须双端同步 bump。 + +## 3. Rust 侧实现步骤 + +以 `crates/plugin-permission/src/lib.rs` 为模板: + +```rust +#[napi(object)] +#[derive(Clone, Debug)] +pub struct PermissionRequestPayload { + pub permissions: Vec, +} + +impl_bridge_napi_type!(PermissionRequestPayload, "ohos.permission.PermissionRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct PermissionResponsePayload { + pub codes: Vec, +} + +impl_bridge_napi_type!(PermissionResponsePayload, "ohos.permission.PermissionResponse"); +``` + +要点: + +1. 字段用 **snake_case**(N-API 自动映射为 ArkTS camelCase)。 +2. 调用入口是 `BridgeRuntime::call_async::`,`Req`/`Resp` 由类型系统 + 固定 typeName,Rust 侧无需运行时校验 typeName。 +3. 业务校验放 Rust facade:非空、非法值、结果长度匹配 + (参考 `validate_request` 与 `codes.len() != permissions.len()` 检查)。 +4. 每个契约类型都要有单元测试断言 TYPE_NAME,防止意外改名: + +```rust +#[test] +fn permission_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.permission.PermissionRequest" + ); +} +``` + +## 4. ArkTS 侧实现步骤 + +以 `plugins/permission/src/main/ets/PermissionPlugin.ets` 为模板: + +1. 顶部定义 typeName 常量: + +```ts +const PERMISSION_REQUEST_TYPE = "ohos.permission.PermissionRequest"; +const PERMISSION_RESPONSE_TYPE = "ohos.permission.PermissionResponse"; +``` + +2. 定义 camelCase 接口与响应类: + +```ts +interface PermissionRequestPayload { + permissions: string[]; +} + +class PermissionResponse { + readonly codes: number[]; + constructor(codes: number[]) { + this.codes = codes; + } +} +``` + +3. 解析入口**必须校验 typeName**,不匹配直接 throw(不能静默接收): + +```ts +function parsePermissions(payload: BridgeTypedValue): string[] { + if (payload.typeName !== PERMISSION_REQUEST_TYPE || typeof payload.value !== "object") { + throw new Error(`requires bridge type ${PERMISSION_REQUEST_TYPE}`); + } + // ...语义校验(非空数组、非空字符串) +} +``` + +4. 返回时**必须回填响应 typeName**,且响应类型名与 Rust 侧 `impl_bridge_napi_type!` + 的第二个参数一字不差: + +```ts +return { typeName: PERMISSION_RESPONSE_TYPE, value: new PermissionResponse(codes) }; +``` + +5. `invokeAsync` / `invokeSync` 开头校验 `context.isActive()`,action 不支持时 + `throw new Error(\`Unsupported action '${action}'\`)`。 + +## 5. ArkTS → Rust 平台回调 + +平台回调若需要 Rust 立即决策,ArkTS 使用具名 N-API direct event: + +```ts +const response = context.invokeNativeSync( + "navigation-request", + "ohos.webview.NavigationRequest", + "ohos.webview.NavigationResponse", + request, +); +``` + +Rust 在 `BridgePlugin::on_main_thread_event` 中立即解码 request 并返回对应 response。不得保存 +ArkTS object/function,也不得将响应交给 worker 后再返回。完整线程与生命周期规则见 +`docs/plugin-development-standard.md` §4、§5、§7。 + +## 6. 新增插件的完整落地清单 + +- [ ] Rust crate:`crates/plugin-/src/lib.rs` + - [ ] `#[napi(object)]` 请求/响应结构体 + `impl_bridge_napi_type!` + - [ ] `impl BridgePlugin`:`type Mode`、`ID`、`VERSION`、`REQUIRED_CONTEXTS` + - [ ] 扩展 trait(如 `PermissionExt`)挂在 `OpenHarmonyApp` 上,core 不感知 + - [ ] `validate_*` 业务校验 + - [ ] 单测断言 typeName / 请求校验 / 响应字段完整 +- [ ] ArkTS HAR:`plugins//src/main/ets/Plugin.ets` + - [ ] `createPlugin(): BridgePluginFactory`(id/version/execution/requires 与 Rust 一致) + - [ ] typeName 常量 + 解析校验 + 响应回填 + - [ ] `execution` 与 Rust `type Mode` 一致:async → `invokeAsync`,sync → `invokeSync` +- [ ] `Cargo.toml` workspace 与 `native_ability/oh-package.json5` 登记 +- [ ] demo 接入(如 `demo/entry/.../EntryAbility.ets` 的 `bridgePlugins` 数组) +- [ ] 文档:`docs/plugin-development-standard.md` §3.3 契约基线补充新行 +- [ ] 验证:`cargo check --workspace`、`cargo clippy --workspace --all-targets`、 + `cargo test -p openharmony-ability-plugin- --lib`、`pnpm run format:check` + +## 7. Review 时快速排查 + +```bash +# 新插件/新文件里出现 JSON 桥用法 = 违规 +rg -n "BridgeJson|call_json|bridgeJson|requireBridgeJson" crates/plugin-* plugins/*/src + +# 确认 request/response 都有 impl_bridge_napi_type +rg -n "impl_bridge_napi_type" crates/plugin-*/src + +# 确认 ArkTS 侧解析入口校验了 typeName +rg -n "typeName !==|typeName ===" plugins/*/src +``` + +## 参考 + +- 现有契约表:见 [references/contract-table.md](references/contract-table.md) +- 开发规范:`docs/plugin-development-standard.md`(§3 命名契约、§4 线程模式) +- 核心实现:`crates/ability/src/bridge/mod.rs`(`impl_bridge_napi_type!`、 + `BridgeNapiType`、`BridgePlugin`) +- 完整范例:`crates/plugin-permission` + `plugins/permission`(异步、ability 上下文)、 + `crates/plugin-app-control` + `plugins/app-control`(同步、主线程 Env) diff --git a/.agents/skills/named-napi-contracts/references/contract-table.md b/.agents/skills/named-napi-contracts/references/contract-table.md new file mode 100644 index 00000000..60c1e133 --- /dev/null +++ b/.agents/skills/named-napi-contracts/references/contract-table.md @@ -0,0 +1,53 @@ +# 内置插件具名 N-API 契约表 + +来源:`docs/plugin-development-standard.md` §3 与各插件源码。新增/修改 action 时必须与本表 +保持命名一致(typeName 两端一字不差)。 + +## 通用约定 + +- 调用载荷不是 JSON envelope,而是 `{ typeName, value }` 的 `BridgeTypedValue`。 +- `#[napi(object)]` 的 snake_case 字段按 N-API 规则映射为 ArkTS camelCase 字段。 +- ArkTS → Rust 的反向事件使用 `context.invokeNativeSync(event, requestTypeName, + responseTypeName, value)`,同样传递具名 N-API value,不使用 JSON event port。 +- 内置标量类型:`std.string`、`std.bytes`、`std.bool`、`std.i32`、`std.f64`。 +- C++ N-API 插件必须使用相同的 `(pluginId, version, action, requestTypeName, + responseTypeName, value)` 边界,不得跨 worker 保存 `napi_env`/`napi_ref`/ArkTS 对象。 + +## 契约总表 + +| 插件 / action | request type → ArkTS value | response type → ArkTS value | 模式 / context | +|---|---|---|---| +| `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: { visible, leftRect, topRect, rightRect, bottomRect } }` | sync / `window-stage` | +| `ohos.webview` / `create` | `ohos.webview.CreateRequest` → `WebviewCreateRequest` | `ohos.webview.CreateResponse` → `{ id, slotId }` | async / `ui-context` | +| `ohos.webview` / `set-visible`、`set-background-color`、`remove`、`load-url`、`load-html`、`set-zoom`、`reload`、`focus`、`clear-all-browsing-data` | `ohos.webview.ControllerRequest` → `{ id, slotId, visible, color, url, html, headers, zoom }` | `ohos.webview.Acknowledgement` → `{ accepted }` | async / `ui-context` | +| `ohos.webview` / `get-url`、`cookies-with-url` | `ohos.webview.ControllerRequest` → `{ id, slotId, url }` | `ohos.webview.StringResponse` → `{ value }` | async / `ui-context` | +| `ohos.webview` / `evaluate-script` | `ohos.webview.ScriptRequest` → `{ id, slotId, script }` | `ohos.webview.ScriptResponse` → `{ result }` | async / `ui-context` | + +## 代码位置 + +| 插件 | Rust facade | ArkTS 实现 | +|---|---|---| +| permission | `crates/plugin-permission/src/lib.rs` | `plugins/permission/src/main/ets/PermissionPlugin.ets` | +| app-control | `crates/plugin-app-control/src/lib.rs` | `plugins/app-control/src/main/ets/AppControlPlugin.ets` | +| window | `crates/plugin-window/src/lib.rs` | `plugins/window/src/main/ets/WindowPlugin.ets` | +| webview | `crates/plugin-webview/src/lib.rs` | `plugins/webview/src/main/ets/WebviewPlugin.ets` | +| webview 自定义协议 | `crates/plugin-webview/src/protocol.rs` | —(纯 native,ArkTS 只触发 `before-engine-init`/`engine-initialized` 事件) | +| webview JS proxy | `crates/plugin-webview/src/js_proxy.rs` | —(纯 native,依赖 `controller-attached` 事件) | + +## WebView 反向事件(具名 N-API 类型) + +| 事件 | request type → response type | 方向 | +|---|---|---| +| `before-engine-init` / `engine-initialized` | `ohos.webview.EngineLifecycleEvent` → `ohos.webview.EventAcknowledgement` | ArkTS → Rust | +| `controller-attached` / `controller-removed` | `ohos.webview.ControllerEvent` → `ohos.webview.EventAcknowledgement` | ArkTS → Rust | +| `navigation-request` | `ohos.webview.NavigationRequest` → `ohos.webview.NavigationResponse` | ArkTS → Rust | +| `download-start` | `ohos.webview.DownloadStartRequest` → `ohos.webview.DownloadStartResponse` | ArkTS → Rust | +| `download-end` | `ohos.webview.DownloadEndEvent` → `ohos.webview.EventAcknowledgement` | ArkTS → Rust | +| `title-change` | `ohos.webview.TitleChangeEvent` → `ohos.webview.EventAcknowledgement` | ArkTS → Rust | + +## WebView 规则 + +下载、导航与标题回调已经使用上述具名 N-API 契约实现。新增 WebView callback 时必须在 +`docs/plugin-development-standard.md` §7.1 记录 request/response 与失败策略,且不得回退到 JSON。 diff --git a/Cargo.lock b/Cargo.lock index 9ae439d1..8e2956bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cfg-if" @@ -67,13 +67,17 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" name = "demo_native" version = "0.1.0" dependencies = [ + "futures-channel", "futures-executor", + "http", "napi-build-ohos", "napi-derive-ohos", "napi-ohos", "ohos-hilog-binding", "openharmony-ability", "openharmony-ability-derive", + "openharmony-ability-plugin-permission", + "openharmony-ability-plugin-webview", ] [[package]] @@ -181,9 +185,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -315,6 +319,18 @@ version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9e1bba4d94159d0a65f1b4e748c148d4bce7930912c424522693f1837dad77" +[[package]] +name = "ohos-enum-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfaa27b9a5519879fcccdb845993e2d2c28df2f84e60f1016bad4557458a4889" +dependencies = [ + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ohos-hilog-binding" version = "0.1.2" @@ -367,20 +383,20 @@ dependencies = [ [[package]] name = "ohos-web-binding" -version = "0.1.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1ed1f2ba96b92179128f6b18ea5a0dd879210e483f7dcab51200219cbfa4a58" +checksum = "a49cba39c83d93c728e79e5df99d8abe8a00e2485e4d1dd69fc539abb5c5a78f" dependencies = [ "bitflags", + "ohos-enum-derive", "ohos-web-sys", - "ohos_enum_macro 0.0.2", ] [[package]] name = "ohos-web-sys" -version = "0.0.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa646f3223c2bc90a400641c3a5cda4b350387ffda1a904b8bfb3db92b81ecbc" +checksum = "646417b737cf623a0eefa46fa0d981d188ae3b74f05e00db5fe5efe1e49eb5b2" [[package]] name = "ohos-xcomponent-binding" @@ -449,14 +465,12 @@ name = "openharmony-ability" version = "0.3.0" dependencies = [ "futures-channel", - "http", "napi-derive-ohos", "napi-ohos", "ohos-arkui-binding", "ohos-display-binding", "ohos-ime-binding", "ohos-resource-manager-binding", - "ohos-web-binding", "ohos-xcomponent-binding", ] @@ -469,6 +483,44 @@ dependencies = [ "syn", ] +[[package]] +name = "openharmony-ability-plugin-app-control" +version = "0.3.0" +dependencies = [ + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + +[[package]] +name = "openharmony-ability-plugin-permission" +version = "0.3.0" +dependencies = [ + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + +[[package]] +name = "openharmony-ability-plugin-webview" +version = "0.3.0" +dependencies = [ + "http", + "napi-derive-ohos", + "napi-ohos", + "ohos-web-binding", + "openharmony-ability", +] + +[[package]] +name = "openharmony-ability-plugin-window" +version = "0.3.0" +dependencies = [ + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index 3d70163b..793b0cdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ resolver = "2" [workspace.dependencies] openharmony-ability-derive = { version = "0.3.0", path = "crates/derive" } openharmony-ability = { version = "0.3.0", path = "crates/ability" } +openharmony-ability-plugin-app-control = { version = "0.3.0", path = "crates/plugin-app-control" } +openharmony-ability-plugin-permission = { version = "0.3.0", path = "crates/plugin-permission" } +openharmony-ability-plugin-webview = { version = "0.3.0", path = "crates/plugin-webview" } +openharmony-ability-plugin-window = { version = "0.3.0", path = "crates/plugin-window" } napi-sys-ohos = { version = "1.1", features = ["napi8"] } napi-ohos = { version = "1.1" } @@ -18,8 +22,11 @@ proc-macro2 = { version = "1.0" } ohos-arkui-binding = { version = "0.1" } ohos-xcomponent-binding = { version = "0.2" } ohos-ime-binding = { version = "0.1" } -ohos-web-binding = { version = "0.1" } ohos-display-binding = { version = "0.0.1" } ohos-resource-manager-binding = { version = "0.2" } -http = { version = "1.1" } +# Bridge runtime and examples use these without pinning versions in member manifests. +futures-channel = { version = "0.3" } +futures-executor = { version = "0.3" } +http = { version = "1" } +ohos-web-binding = { version = "0.2.1" } diff --git a/crates/ability/Cargo.toml b/crates/ability/Cargo.toml index 9f15e4a0..dfcb0d59 100644 --- a/crates/ability/Cargo.toml +++ b/crates/ability/Cargo.toml @@ -7,12 +7,10 @@ description = "Binding Rust application on Harmony with Ability" [features] default = [] -drag_and_drop = [] -webview = ["dep:ohos-web-binding", "dep:http"] [dependencies] # common dependencies -futures-channel = "0.3" +futures-channel = { workspace = true } # for napi binding napi-ohos = { workspace = true, features = ["napi8"] } @@ -24,7 +22,3 @@ ohos-xcomponent-binding = { workspace = true } ohos-ime-binding = { workspace = true } ohos-display-binding = { workspace = true } ohos-resource-manager-binding = { workspace = true } - -# for webview feature -ohos-web-binding = { workspace = true, optional = true } -http = { workspace = true, optional = true } diff --git a/crates/ability/src/app.rs b/crates/ability/src/app.rs index cebae34c..2665098a 100644 --- a/crates/ability/src/app.rs +++ b/crates/ability/src/app.rs @@ -1,76 +1,35 @@ use std::{ - cell::Cell, cell::RefCell, collections::HashMap, fmt::Debug, - rc::Rc, sync::{ atomic::{AtomicBool, AtomicI64}, Arc, Mutex, RwLock, }, }; -use futures_channel::oneshot; use napi_derive_ohos::napi; -use napi_ohos::{ - bindgen_prelude::{CallbackContext, Function, JsObjectValue, Object, Unknown}, - threadsafe_function::ThreadsafeFunctionCallMode, - Error, Result, -}; +use napi_ohos::{bindgen_prelude::Object, Env, Error, Result}; use ohos_arkui_binding::XComponent; use ohos_display_binding::default_display_scaled_density; use ohos_ime_binding::IME; use ohos_xcomponent_binding::RawWindow; use crate::{ - get_helper, get_main_thread_env, get_permission_request_tsfn, + bridge::MainThreadBridgeEndpoint, resource::{ resource_manager as global_resource_manager, set_resource_manager as set_global_resource_manager, }, - unknown_to_permission_promise, AbilityError, AvoidArea, AvoidAreaType, Configuration, Event, - OpenHarmonyWaker, PermissionRequest, PermissionRequestCode, PermissionRequestOutput, Rect, - ResourceManager, WAKER, + AvoidArea, AvoidAreaType, BridgeMainThread, BridgeMainThreadEvent, BridgePlugin, + BridgePluginRegistry, BridgeRuntime, Configuration, Event, MainThreadScheduler, + OpenHarmonyWaker, PluginLifecycleEvent, Rect, ResourceManager, WAKER, }; static ID: AtomicI64 = AtomicI64::new(0); pub(crate) static HAS_EVENT: AtomicBool = AtomicBool::new(false); -const DEFAULT_AVOID_AREA_TYPES: [AvoidAreaType; 5] = [ - AvoidAreaType::System, - AvoidAreaType::Cutout, - AvoidAreaType::SystemGesture, - AvoidAreaType::Keyboard, - AvoidAreaType::NavigationIndicator, -]; - -fn parse_rect_from_object(rect: Object<'_>) -> Option { - let top = rect.get_named_property::("top").ok()?; - let left = rect.get_named_property::("left").ok()?; - let width = rect.get_named_property::("width").ok()?; - let height = rect.get_named_property::("height").ok()?; - Some(Rect { - top, - left, - width, - height, - }) -} - -fn parse_avoid_area_options(options: Object<'_>) -> Option<(AvoidAreaType, AvoidArea)> { - let area_type = AvoidAreaType::from(options.get_named_property::("type").ok()?); - let area = options.get_named_property::("area").ok()?; - let avoid_area = AvoidArea { - visible: area.get_named_property::("visible").ok()?, - left_rect: parse_rect_from_object(area.get_named_property::("leftRect").ok()?)?, - top_rect: parse_rect_from_object(area.get_named_property::("topRect").ok()?)?, - right_rect: parse_rect_from_object(area.get_named_property::("rightRect").ok()?)?, - bottom_rect: parse_rect_from_object(area.get_named_property::("bottomRect").ok()?)?, - }; - Some((area_type, avoid_area)) -} - #[napi(object)] #[derive(Clone, Debug, Default)] pub struct AbilityInitContext { @@ -238,23 +197,6 @@ impl OpenHarmonyAppInner { pub fn set_resource_manager(&mut self, resource_manager: Option) { set_global_resource_manager(resource_manager); } - - pub fn exit(&self, code: i32) -> Result<()> { - let ret = unsafe { get_helper() }; - if let Some(h) = ret.borrow().as_ref() { - // Try to get main thread env - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let ret = h.get_value(env)?; - let exit_func = ret.get_named_property::>("exit")?; - exit_func.call(code)?; - } else { - return Err(Error::from_reason( - AbilityError::OnlyRunWithMainThread("exit".to_string()).to_string(), - )); - } - } - Ok(()) - } } type EventLoop = Arc>>>; @@ -266,6 +208,9 @@ pub struct OpenHarmonyApp { pub(crate) event_loop: EventLoop, pub(crate) back_press_interceptor: BackPressInterceptor, pub(crate) ime: Arc>>, + bridge_runtime: Arc>>, + bridge_main_thread: Arc>>, + bridge_plugins: Arc, is_keyboard_show: Arc>, } @@ -316,6 +261,9 @@ 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_plugins: Arc::new(BridgePluginRegistry::default()), is_keyboard_show: Arc::new(Mutex::new(false)), } } @@ -364,6 +312,90 @@ impl OpenHarmonyApp { global_resource_manager() } + /// 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. + pub fn bridge(&self) -> Result { + self.bridge_runtime + .read() + .map_err(|_| Error::from_reason("Failed to read bridge runtime"))? + .as_ref() + .cloned() + .ok_or_else(|| { + Error::from_reason( + "Bridge runtime is not ready. Call it after the NativeAbility XComponent is rendered.", + ) + }) + } + + /// Schedules a Rust closure onto the ArkTS/N-API main thread. + /// + /// UI and ArkTS work should normally use [`Self::bridge`]'s typed plugin calls. This helper + /// is for a small Rust-side state transition that must observe main-thread affinity. + pub fn main_thread(&self) -> Result { + Ok(self.bridge()?.main_thread()) + } + + /// Runs a synchronous bridge call while the caller owns the current N-API main-thread `Env`. + /// + /// A `BridgeMainThread` cannot be cloned or sent to a worker. In particular, + /// `MainThreadScheduler::run` does not provide this capability because it does not carry a + /// scoped N-API environment. + pub fn with_main_thread_bridge( + &self, + env: &Env, + operation: impl FnOnce(BridgeMainThread<'_>) -> Result, + ) -> Result { + let bridge = self + .bridge_main_thread + .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.", + ) + })?; + operation(BridgeMainThread::new(env, endpoint)) + } + + /// Registers a Rust facade for ArkTS-originated events and lifecycle notifications. + /// + /// Register during the `#[ability]` initializer, before UI rendering starts. Registration is + /// keyed by `BridgePlugin::ID`, so duplicate contracts fail deterministically. + pub fn register_plugin

(&self, plugin: P) -> Result<()> + where + P: BridgePlugin, + { + self.bridge_plugins.register(plugin) + } + + #[doc(hidden)] + pub fn dispatch_bridge_main_thread_event<'env>( + &self, + event: BridgeMainThreadEvent<'env>, + ) -> Result> { + self.bridge_plugins.dispatch_main_thread_event(event) + } + + #[doc(hidden)] + pub fn dispatch_plugin_lifecycle(&self, event: PluginLifecycleEvent) -> Result<()> { + self.bridge_plugins.dispatch_lifecycle(event) + } + + pub(crate) fn set_bridge_bindings( + &self, + runtime: BridgeRuntime, + main_thread_endpoint: MainThreadBridgeEndpoint, + ) { + if let Ok(mut guard) = self.bridge_runtime.write() { + guard.replace(runtime); + } + if let Ok(mut guard) = self.bridge_main_thread.write() { + guard.replace(main_thread_endpoint); + } + } + #[doc(hidden)] pub fn set_resource_manager(&self, resource_manager: Option) { self.inner @@ -404,53 +436,11 @@ impl OpenHarmonyApp { self.inner.read().unwrap().window_rect() } - fn fetch_avoid_area_from_helper( - &self, - area_type: AvoidAreaType, - ) -> Option<(AvoidAreaType, AvoidArea)> { - let helper = unsafe { get_helper() }; - let helper_borrow = helper.borrow(); - let helper_ref = helper_borrow.as_ref()?; - let env = get_main_thread_env(); - let env_borrow = env.borrow(); - let env_ref = env_borrow.as_ref()?; - let helper_object = helper_ref.get_value(env_ref).ok()?; - let get_window_avoid_area = helper_object - .get_named_property::>>("getWindowAvoidArea") - .ok()?; - let options = get_window_avoid_area.call(i32::from(area_type)).ok()?; - parse_avoid_area_options(options) - } - - fn ensure_avoid_area_cached(&self, area_type: AvoidAreaType) { - if self.inner.read().unwrap().avoid_area(area_type).is_some() { - return; - } - if let Some((fetched_type, area)) = self.fetch_avoid_area_from_helper(area_type) { - self.inner - .write() - .unwrap() - .avoid_areas - .insert(fetched_type, area); - } - } - - fn ensure_avoid_areas_cached(&self) { - if !self.inner.read().unwrap().avoid_areas.is_empty() { - return; - } - for area_type in DEFAULT_AVOID_AREA_TYPES { - self.ensure_avoid_area_cached(area_type); - } - } - pub fn avoid_area(&self, area_type: AvoidAreaType) -> Option { - self.ensure_avoid_area_cached(area_type); self.inner.read().unwrap().avoid_area(area_type) } pub fn avoid_areas(&self) -> HashMap { - self.ensure_avoid_areas_cached(); self.inner.read().unwrap().avoid_areas() } pub fn native_window(&self) -> Option { @@ -462,90 +452,6 @@ impl OpenHarmonyApp { self.inner.read().unwrap().scale() } - /// Exit current app with code - pub fn exit(&self, code: i32) { - self.inner.read().unwrap().exit(code).unwrap(); - } - - /// Request one or more runtime permissions through ArkTS helper. - /// Returns each requested permission and the corresponding request result code. - /// ! Don't call this function from main thread with block_on. - pub async fn request_permission

(&self, permission: P) -> Result> - where - P: Into, - { - let request = permission.into(); - let requested_permissions = request.permissions(); - let input = request.into_input(); - - let permission_tsfn = get_permission_request_tsfn().ok_or_else(|| { - Error::from_reason("requestPermission threadsafe function is not initialized") - })?; - - let (tx, rx) = oneshot::channel::>(); - let status = permission_tsfn.call_with_return_value( - input, - ThreadsafeFunctionCallMode::NonBlocking, - move |result, _| { - match result { - Ok(value) => { - let tx_cell = Rc::new(Cell::new(Some(tx))); - let tx_in_catch = tx_cell.clone(); - let promise = unknown_to_permission_promise(value)?; - promise - .then(move |ctx| { - if let Some(sender) = tx_cell.replace(None) { - let _ = sender.send(Ok(ctx.value)); - } - Ok(()) - })? - .catch(move |ctx: CallbackContext| { - if let Some(sender) = tx_in_catch.replace(None) { - let _ = sender.send(Err(ctx.value.into())); - } - Ok(()) - })?; - } - Err(err) => { - let _ = tx.send(Err(err)); - } - } - - Ok(()) - }, - ); - - if status != napi_ohos::Status::Ok { - return Err(Error::from_reason(format!( - "call requestPermission failed with status: {:?}", - status - ))); - } - - let output = rx - .await - .map_err(|_| Error::from_reason("requestPermission callback receiver dropped"))??; - - let codes = match output { - napi_ohos::Either::A(code) => vec![code], - napi_ohos::Either::B(codes) => codes, - }; - - if requested_permissions.len() != codes.len() { - return Err(Error::from_reason(format!( - "requestPermission result length mismatch: requested {}, got {}", - requested_permissions.len(), - codes.len() - ))); - } - - Ok(requested_permissions - .into_iter() - .zip(codes) - .map(|(permission, code)| PermissionRequestCode { permission, code }) - .collect()) - } - pub fn run_loop<'a, F: FnMut(Event) + 'a>(&self, mut event_handle: F) { if HAS_EVENT.load(std::sync::atomic::Ordering::SeqCst) { return; diff --git a/crates/ability/src/bridge/mod.rs b/crates/ability/src/bridge/mod.rs new file mode 100644 index 00000000..32b0cdf7 --- /dev/null +++ b/crates/ability/src/bridge/mod.rs @@ -0,0 +1,1125 @@ +//! Typed, lifecycle-aware transport between Rust plugins and ArkTS plugins. +//! +//! `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. +//! This split makes it impossible to accidentally invoke a synchronous ArkTS plugin from a Rust +//! worker through the public typed API. + +use std::{ + any::Any, + cell::Cell, + collections::BTreeMap, + marker::PhantomData, + panic::{catch_unwind, AssertUnwindSafe}, + rc::Rc, + sync::{Arc, RwLock}, +}; + +use futures_channel::oneshot; +use napi_ohos::{ + bindgen_prelude::{ + CallbackContext, FnArgs, FromNapiValue, Function, FunctionRef, JsObjectValue, JsValue, + PromiseRaw, ToNapiValue, Uint8Array, Unknown, + }, + sys, + threadsafe_function::{ThreadsafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode}, + Env, Error, Result, Status, ValueType, +}; +const DEFAULT_TIMEOUT_MS: u32 = 15_000; +const MAX_TIMEOUT_MS: u32 = 60_000; + +mod mode { + pub trait Sealed {} +} + +/// Execution mode selected by a bridge plugin at compile time. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BridgeExecution { + /// The call is transported through a `ThreadsafeFunction` and resolves as a Rust future. + Async, + /// The call runs immediately in the active ArkTS N-API environment. + MainThreadSync, +} + +/// A named value that can cross the Rust ↔ ArkTS bridge through N-API. +/// +/// The type name is a compatibility contract, independent from the N-API runtime shape. For +/// example, `String` is named `std.string` and `Vec` is `std.bytes`. Application types +/// normally use `#[napi(object)]` plus [`impl_bridge_napi_type!`], so ArkTS receives a real +/// object with a stable type name. +/// +/// Values are encoded and decoded only while the ArkTS/N-API callback owns its `Env`. The async +/// transport keeps Rust-owned input/output values across threads; it never retains a JS object on +/// a worker. +pub trait BridgeNapiType: Send + Sized + 'static { + const TYPE_NAME: &'static str; + + fn into_bridge_value<'env>(self, env: &'env Env) -> Result>; + fn from_bridge_value(value: Unknown<'_>) -> Result; +} + +/// Implements [`BridgeNapiType`] for a `#[napi(object)]` or another owned N-API-convertible type. +/// +/// ```ignore +/// #[napi(object)] +/// #[derive(Clone)] +/// struct LoginToken { user_id: String, expires_at_ms: i64 } +/// +/// openharmony_ability::impl_bridge_napi_type!(LoginToken, "account.LoginToken"); +/// ``` +#[macro_export] +macro_rules! impl_bridge_napi_type { + ($type:ty, $type_name:literal) => { + impl $crate::BridgeNapiType for $type { + const TYPE_NAME: &'static str = $type_name; + + fn into_bridge_value<'env>( + self, + env: &'env $crate::napi_ohos::Env, + ) -> $crate::napi_ohos::Result<$crate::napi_ohos::bindgen_prelude::Unknown<'env>> { + <$type as $crate::napi_ohos::bindgen_prelude::ToNapiValue>::into_unknown(self, env) + } + + fn from_bridge_value( + value: $crate::napi_ohos::bindgen_prelude::Unknown<'_>, + ) -> $crate::napi_ohos::Result { + <$type as $crate::napi_ohos::bindgen_prelude::FromNapiValue>::from_unknown(value) + } + } + }; +} + +macro_rules! impl_builtin_napi_type { + ($type:ty, $type_name:literal) => { + impl BridgeNapiType for $type { + const TYPE_NAME: &'static str = $type_name; + + fn into_bridge_value<'env>(self, env: &'env Env) -> Result> { + <$type as ToNapiValue>::into_unknown(self, env) + } + + fn from_bridge_value(value: Unknown<'_>) -> Result { + <$type as FromNapiValue>::from_unknown(value) + } + } + }; +} + +impl_builtin_napi_type!(String, "std.string"); +impl_builtin_napi_type!(bool, "std.bool"); +impl_builtin_napi_type!(i32, "std.i32"); +impl_builtin_napi_type!(f64, "std.f64"); + +impl BridgeNapiType for Vec { + const TYPE_NAME: &'static str = "std.bytes"; + + fn into_bridge_value<'env>(self, env: &'env Env) -> Result> { + Uint8Array::from(self).into_unknown(env) + } + + fn from_bridge_value(value: Unknown<'_>) -> Result { + let value = Uint8Array::from_unknown(value)?; + Ok(value.as_ref().to_vec()) + } +} + +/// Marker for plugins callable from any Rust thread through [`BridgeRuntime`]. +pub enum AsyncBridge {} + +/// Marker for plugins callable only through [`BridgeMainThread`]. +pub enum MainThreadSyncBridge {} + +/// Sealed mode trait: framework code owns the only two legal execution modes. +pub trait BridgePluginMode: mode::Sealed + Send + Sync + 'static { + const EXECUTION: BridgeExecution; +} + +impl mode::Sealed for AsyncBridge {} + +impl BridgePluginMode for AsyncBridge { + const EXECUTION: BridgeExecution = BridgeExecution::Async; +} + +impl mode::Sealed for MainThreadSyncBridge {} + +impl BridgePluginMode for MainThreadSyncBridge { + const EXECUTION: BridgeExecution = BridgeExecution::MainThreadSync; +} + +/// ArkTS context that an ArkTS implementation must wait for before it is activated. +/// +/// The associated [`BridgePlugin::REQUIRED_CONTEXTS`] is checked by the Rust event registry. The +/// ArkTS factory declares the same requirement and `BridgeHost` enforces actual activation, +/// because the context objects themselves must never cross the N-API boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BridgeContextRequirement { + Ability, + WindowStage, + UiContext, +} + +/// A synchronous, ArkTS-originated event scoped to the active N-API environment. +/// +/// ArkWeb callbacks such as navigation interception, download admission, title changes, and +/// controller lifecycle signals must finish before ArkTS returns from the active N-API callback. +/// This value is created only by the native N-API export and is deliberately non-Send and +/// non-Sync, so neither the request value nor its response can escape to a Rust worker. A handler +/// may hand decoded, Rust-owned data to a worker, but it must produce this call's response first. +pub struct BridgeMainThreadEvent<'env> { + plugin_id: String, + name: String, + request_type_name: String, + response_type_name: String, + value: Unknown<'env>, + env: &'env Env, + _not_send_or_sync: PhantomData>, +} + +impl<'env> BridgeMainThreadEvent<'env> { + #[doc(hidden)] + pub fn new( + env: &'env Env, + plugin_id: impl Into, + name: impl Into, + request_type_name: impl Into, + response_type_name: impl Into, + value: Unknown<'env>, + ) -> Result { + let plugin_id = plugin_id.into(); + let name = name.into(); + let request_type_name = request_type_name.into(); + let response_type_name = response_type_name.into(); + validate_identifier("plugin id", &plugin_id)?; + validate_identifier("event", &name)?; + validate_identifier("request type", &request_type_name)?; + validate_identifier("response type", &response_type_name)?; + if value.get_type()? == ValueType::Function { + return Err(Error::from_reason( + "Main-thread bridge events cannot carry Function values", + )); + } + Ok(Self { + plugin_id, + name, + request_type_name, + response_type_name, + value, + env, + _not_send_or_sync: PhantomData, + }) + } + + pub fn plugin_id(&self) -> &str { + &self.plugin_id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn request_type_name(&self) -> &str { + &self.request_type_name + } + + pub fn response_type_name(&self) -> &str { + &self.response_type_name + } + + /// Decodes the request after verifying its stable named N-API contract. + pub fn decode(&self) -> Result + where + T: BridgeNapiType, + { + if self.request_type_name != T::TYPE_NAME { + return Err(Error::from_reason(format!( + "Main-thread bridge event '{}.{}' received {}, expected {}", + self.plugin_id, + self.name, + self.request_type_name, + T::TYPE_NAME, + ))); + } + T::from_bridge_value(self.value) + } + + /// Encodes the response while the originating N-API environment is still active. + pub fn respond(&self, response: T) -> Result> + where + T: BridgeNapiType, + { + if self.response_type_name != T::TYPE_NAME { + return Err(Error::from_reason(format!( + "Main-thread bridge event '{}.{}' expects {}, attempted to return {}", + self.plugin_id, + self.name, + self.response_type_name, + T::TYPE_NAME, + ))); + } + response.into_bridge_value(self.env) + } +} + +/// Lifecycle notifications delivered to registered Rust plugins. +/// +/// Native ability lifecycle events originate from `create_lifecycle_handle`. `UiContextReady` +/// originates in `DefaultXComponent`, because only ArkTS owns that object and its lifetime. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PluginLifecycleEvent { + AbilityCreated { restored_state: String }, + AbilityDestroyed, + WindowStageCreated, + WindowStageDestroyed, + ConfigurationUpdated, + MemoryLevel { level: i32 }, + WindowStageEvent { event_type: i32 }, + UiContextReady, + UiContextDestroyed, +} + +impl PluginLifecycleEvent { + /// Parses the small ArkTS-only lifecycle extension that cannot be observed by Rust directly. + pub fn from_arkts(kind: &str) -> Result { + validate_identifier("lifecycle event", kind)?; + match kind { + "ui-context-ready" => Ok(Self::UiContextReady), + "ui-context-destroy" => Ok(Self::UiContextDestroyed), + _ => Err(Error::from_reason(format!( + "Unsupported ArkTS bridge lifecycle event '{kind}'" + ))), + } + } +} + +/// Stable Rust-side contract for a versioned ArkTS plugin. +/// +/// `type Mode` is intentionally not a runtime flag. A facade for `AsyncBridge` cannot be passed +/// to [`BridgeMainThread::call_sync`], and a facade for `MainThreadSyncBridge` cannot be passed +/// to [`BridgeRuntime::call_async`]. +pub trait BridgePlugin: Send + Sync + 'static { + type Mode: BridgePluginMode; + + const ID: &'static str; + const VERSION: u32 = 1; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = &[]; + + /// 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 + /// synchronously influence a platform callback or acknowledge a lifecycle notification. The + /// borrowed event and response are non-Send and non-Sync, so implementations must finish + /// immediately and must not retain N-API values. Plugins that do not own such a contract keep + /// the default error. + fn on_main_thread_event<'env>( + &self, + event: BridgeMainThreadEvent<'env>, + ) -> Result> { + Err(Error::from_reason(format!( + "Bridge plugin '{}' does not support main-thread event '{}'", + event.plugin_id(), + event.name(), + ))) + } + + /// Receives ability and UI-context readiness transitions without exposing ArkTS objects to + /// Rust worker threads. + fn on_lifecycle(&self, _event: &PluginLifecycleEvent) -> Result<()> { + Ok(()) + } +} + +trait RegisteredBridgePlugin: Send + Sync { + fn on_main_thread_event<'env>( + &self, + event: BridgeMainThreadEvent<'env>, + ) -> Result>; + fn on_lifecycle(&self, event: &PluginLifecycleEvent) -> Result<()>; +} + +impl

RegisteredBridgePlugin for P +where + P: BridgePlugin, +{ + fn on_main_thread_event<'env>( + &self, + event: BridgeMainThreadEvent<'env>, + ) -> Result> { + BridgePlugin::on_main_thread_event(self, event) + } + + fn on_lifecycle(&self, event: &PluginLifecycleEvent) -> Result<()> { + BridgePlugin::on_lifecycle(self, event) + } +} + +#[derive(Clone, Copy, Default)] +struct BridgeContextReadiness { + ability: bool, + window_stage: bool, + ui_context: bool, +} + +impl BridgeContextReadiness { + fn supports(self, requirements: &[BridgeContextRequirement]) -> bool { + requirements.iter().all(|requirement| match requirement { + BridgeContextRequirement::Ability => self.ability, + BridgeContextRequirement::WindowStage => self.window_stage, + BridgeContextRequirement::UiContext => self.ui_context, + }) + } + + fn observe(&mut self, event: &PluginLifecycleEvent) { + match event { + PluginLifecycleEvent::AbilityCreated { .. } => self.ability = true, + PluginLifecycleEvent::AbilityDestroyed => *self = Self::default(), + PluginLifecycleEvent::WindowStageCreated => self.window_stage = true, + PluginLifecycleEvent::WindowStageDestroyed => { + self.window_stage = false; + self.ui_context = false; + } + PluginLifecycleEvent::UiContextReady => self.ui_context = true, + PluginLifecycleEvent::UiContextDestroyed => self.ui_context = false, + PluginLifecycleEvent::ConfigurationUpdated + | PluginLifecycleEvent::MemoryLevel { .. } + | PluginLifecycleEvent::WindowStageEvent { .. } => {} + } + } +} + +struct RegisteredPluginEntry { + plugin: Arc, + required_contexts: &'static [BridgeContextRequirement], +} + +#[derive(Default)] +struct BridgePluginRegistryState { + plugins: BTreeMap, + readiness: BridgeContextReadiness, + lifecycle_history: Vec, +} + +/// Registration point for Rust facades that consume ArkTS plugin events and lifecycle changes. +/// +/// The registry owns a Rust-only readiness mirror. A facade that declares `UiContext` does not +/// receive an early lifecycle callback or ArkTS event; when it becomes ready it receives the +/// bounded lifecycle history in order. The lock is always dropped before invoking user code. +#[derive(Default)] +pub struct BridgePluginRegistry { + state: RwLock, +} + +impl BridgePluginRegistry { + pub fn register

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

()?; + let plugin: Arc = Arc::new(plugin); + let replay = { + let mut state = self + .state + .write() + .map_err(|_| Error::from_reason("Failed to register bridge plugin"))?; + if state.plugins.contains_key(P::ID) { + return Err(Error::from_reason(format!( + "Bridge plugin '{}' is already registered", + P::ID + ))); + } + let replay = if state.readiness.supports(P::REQUIRED_CONTEXTS) { + state.lifecycle_history.clone() + } else { + Vec::new() + }; + state.plugins.insert( + P::ID.to_owned(), + RegisteredPluginEntry { + plugin: Arc::clone(&plugin), + required_contexts: P::REQUIRED_CONTEXTS, + }, + ); + replay + }; + + for event in replay { + plugin.on_lifecycle(&event)?; + } + Ok(()) + } + + /// 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>( + &self, + event: BridgeMainThreadEvent<'env>, + ) -> Result> { + let plugin = { + let state = self + .state + .read() + .map_err(|_| Error::from_reason("Failed to read bridge plugin registry"))?; + let entry = state.plugins.get(event.plugin_id()).ok_or_else(|| { + Error::from_reason(format!( + "No Rust bridge plugin registered for '{}'", + 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", + event.plugin_id() + ))); + } + Arc::clone(&entry.plugin) + }; + plugin.on_main_thread_event(event) + } + + pub fn dispatch_lifecycle(&self, event: PluginLifecycleEvent) -> Result<()> { + let deliveries = { + let mut state = self + .state + .write() + .map_err(|_| Error::from_reason("Failed to read bridge plugin registry"))?; + let previous = state.readiness; + 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::>() + }; + + for (plugin, events) in deliveries { + for event in events { + plugin.on_lifecycle(&event)?; + } + } + Ok(()) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.state.read().map_or(0, |state| state.plugins.len()) + } +} + +const MAX_LIFECYCLE_HISTORY: usize = 16; + +/// Per-call policy enforced by the ArkTS host. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BridgeCallOptions { + timeout_ms: u32, +} + +impl BridgeCallOptions { + pub fn with_timeout_ms(mut self, timeout_ms: u32) -> Self { + self.timeout_ms = timeout_ms.clamp(1, MAX_TIMEOUT_MS); + self + } + + pub fn timeout_ms(self) -> u32 { + self.timeout_ms + } +} + +impl Default for BridgeCallOptions { + fn default() -> Self { + Self { + timeout_ms: DEFAULT_TIMEOUT_MS, + } + } +} + +struct BridgeRequest { + plugin_id: String, + plugin_version: u32, + action: String, + request_type_name: String, + response_type_name: String, + value: Box, + timeout_ms: u32, +} + +trait BridgeValueEncoder: Send { + fn encode(self: Box, env: &Env) -> Result; +} + +impl BridgeValueEncoder for T +where + T: BridgeNapiType, +{ + fn encode(self: Box, env: &Env) -> Result { + Ok((*self).into_bridge_value(env)?.raw()) + } +} + +trait BridgeResponseDecoder: Send { + fn decode(self: Box, value: Unknown<'_>) -> Result>; +} + +struct TypedBridgeResponse(PhantomData); + +impl BridgeResponseDecoder for TypedBridgeResponse +where + T: BridgeNapiType, +{ + fn decode(self: Box, value: Unknown<'_>) -> Result> { + T::from_bridge_value(value).map(|value| Box::new(value) as Box) + } +} + +type AsyncBridgeArgs = FnArgs<(String, u32, String, String, String, sys::napi_value, u32)>; +type SyncBridgeArgs = FnArgs<(String, u32, String, String, String, sys::napi_value)>; +type AsyncBridgeFunction<'env> = Function<'env, AsyncBridgeArgs, Unknown<'env>>; +type SyncBridgeFunction<'env> = Function<'env, SyncBridgeArgs, Unknown<'env>>; +type BridgeInvokeTsfn = + ThreadsafeFunction, AsyncBridgeArgs, Status, false>; + +type BridgeWireResult = std::result::Result, String>; +type BridgeSender = oneshot::Sender; + +/// Cloneable client used by asynchronous plugins from the N-API main thread or a Rust worker. +#[derive(Clone)] +pub struct BridgeClient { + invoke: Arc, +} + +impl BridgeClient { + /// Calls an action owned by an asynchronous plugin with named N-API request and response + /// values. `Request` is materialized only on the ArkTS callback thread; `Response` is decoded + /// there before it is sent back to the Rust worker. + pub async fn call_async( + &self, + action: impl AsRef, + request: Request, + options: BridgeCallOptions, + ) -> Result + where + P: BridgePlugin, + Request: BridgeNapiType, + Response: BridgeNapiType, + { + validate_plugin_contract::

()?; + self.call_raw::(P::ID, P::VERSION, action.as_ref(), request, options) + .await + } + + async fn call_raw( + &self, + plugin_id: &str, + plugin_version: u32, + action: &str, + request: Request, + options: BridgeCallOptions, + ) -> Result + where + Request: BridgeNapiType, + Response: BridgeNapiType, + { + validate_wire_call( + plugin_id, + plugin_version, + action, + Request::TYPE_NAME, + Response::TYPE_NAME, + )?; + + let request = BridgeRequest { + plugin_id: plugin_id.to_owned(), + plugin_version, + action: action.to_owned(), + request_type_name: Request::TYPE_NAME.to_owned(), + response_type_name: Response::TYPE_NAME.to_owned(), + value: Box::new(request), + timeout_ms: options.timeout_ms(), + }; + let response_decoder: Box = + Box::new(TypedBridgeResponse::(PhantomData)); + let (sender, receiver) = oneshot::channel::(); + + let status = self.invoke.call_with_return_value( + request, + ThreadsafeFunctionCallMode::NonBlocking, + move |result, _env| { + match result { + Ok(value) => { + attach_promise(value, response_decoder, sender); + } + Err(error) => send_once(sender, Err(error.to_string())), + } + // Deliver ArkTS errors through the Rust future rather than as uncaught N-API + // exceptions on the UI thread. + Ok(()) + }, + ); + + if status != Status::Ok { + return Err(Error::from_reason(format!( + "Bridge TSFN dispatch failed with status: {status:?}" + ))); + } + + let response = receiver + .await + .map_err(|_| Error::from_reason("Bridge response channel was cancelled"))? + .map_err(Error::from_reason)?; + response + .downcast::() + .map(|response| *response) + .map_err(|_| { + Error::from_reason(format!( + "Bridge response type dispatch failed for '{}'", + Response::TYPE_NAME + )) + }) + } +} + +fn attach_promise( + value: Unknown<'static>, + decoder: Box, + sender: BridgeSender, +) { + let sender = Rc::new(Cell::new(Some(sender))); + + let setup_result = (|| -> Result<()> { + if value.get_type()? != ValueType::Object { + send_once_cell( + &sender, + Err("Bridge handler did not return a Promise payload".to_owned()), + ); + return Ok(()); + } + + let promise: PromiseRaw<'static, Unknown<'static>> = unsafe { value.cast()? }; + let resolve_sender = Rc::clone(&sender); + let reject_sender = Rc::clone(&sender); + + promise + .then(move |context| { + let result = decoder + .decode(context.value) + .map_err(|error| error.to_string()); + send_once_cell(&resolve_sender, result); + Ok(()) + })? + .catch(move |context: CallbackContext| { + let message = context + .value + .coerce_to_string() + .and_then(|value| value.into_utf8().and_then(|value| value.into_owned())) + .unwrap_or_else(|_| { + "Bridge handler rejected without a string message".to_owned() + }); + send_once_cell(&reject_sender, Err(message)); + Ok(()) + })?; + + Ok(()) + })(); + + if let Err(error) = setup_result { + send_once_cell( + &sender, + Err(format!("Failed to observe bridge Promise: {error}")), + ); + } +} + +fn send_once(sender: BridgeSender, result: BridgeWireResult) { + let _ = sender.send(result); +} + +fn send_once_cell(sender: &Rc>>, result: BridgeWireResult) { + if let Some(sender) = sender.replace(None) { + let _ = sender.send(result); + } +} + +struct MainThreadTask { + task: Box, +} + +impl MainThreadTask { + fn run(self) { + // Never unwind through the N-API callback. `MainThreadScheduler::run` observes the + // dropped oneshot sender as a cancelled task if the user closure panics. + let _ = catch_unwind(AssertUnwindSafe(self.task)); + } +} + +type MainThreadTsfn = ThreadsafeFunction; + +/// Schedules Rust-only state transitions onto the ArkTS/N-API main thread. +/// +/// This is not a capability for synchronous plugins because a scheduled closure has no scoped +/// `Env`. Use [`crate::OpenHarmonyApp::with_main_thread_bridge`] from a N-API callback instead. +#[derive(Clone)] +pub struct MainThreadScheduler { + dispatch: Arc, +} + +impl MainThreadScheduler { + pub fn dispatch(&self, task: F) -> Result<()> + where + F: FnOnce() + Send + 'static, + { + let status = self.dispatch.call( + MainThreadTask { + task: Box::new(task), + }, + ThreadsafeFunctionCallMode::NonBlocking, + ); + if status == Status::Ok { + Ok(()) + } else { + Err(Error::from_reason(format!( + "Main-thread TSFN dispatch failed with status: {status:?}" + ))) + } + } + + pub async fn run(&self, task: F) -> Result + where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, + { + let (sender, receiver) = oneshot::channel(); + self.dispatch(move || { + let _ = sender.send(task()); + })?; + receiver + .await + .map_err(|_| Error::from_reason("Main-thread task was cancelled or panicked")) + } +} + +type SyncBridgeFunctionRef = FunctionRef>; + +/// A stored reference to the ArkTS synchronous dispatcher. It is intentionally crate-private; +/// callers can only obtain the scoped [`BridgeMainThread`] wrapper from `OpenHarmonyApp`. +pub(crate) struct MainThreadBridgeEndpoint { + owner_env: usize, + invoke_sync: SyncBridgeFunctionRef, +} + +/// Main-thread and `Env`-scoped capability to invoke a synchronous ArkTS plugin. +/// +/// The `Rc` marker makes this type `!Send` and `!Sync`; it cannot be captured by a worker or put +/// in a static. Its lifetime is tied to the N-API callback that supplied `Env`. +pub struct BridgeMainThread<'env> { + env: &'env Env, + endpoint: &'env MainThreadBridgeEndpoint, + _not_send_or_sync: PhantomData>, +} + +impl<'env> BridgeMainThread<'env> { + pub(crate) fn new(env: &'env Env, endpoint: &'env MainThreadBridgeEndpoint) -> Self { + Self { + env, + endpoint, + _not_send_or_sync: PhantomData, + } + } + + /// Calls a synchronous, main-thread-only ArkTS plugin with named N-API request and response + /// values. The `Env` identity check permits N-API callbacks that share the active ArkTS + /// environment even when the runtime uses a different OS thread for dispatch. + pub fn call_sync( + &self, + action: impl AsRef, + request: Request, + ) -> Result + where + P: BridgePlugin, + Request: BridgeNapiType, + Response: BridgeNapiType, + { + validate_plugin_contract::

()?; + let action = action.as_ref(); + validate_wire_call( + P::ID, + P::VERSION, + action, + Request::TYPE_NAME, + Response::TYPE_NAME, + )?; + if self.env.raw() as usize != self.endpoint.owner_env { + return Err(Error::from_reason( + "Synchronous bridge plugins may only run from the active ArkTS N-API environment", + )); + } + + let request = request.into_bridge_value(self.env)?.raw(); + let invoke = self.endpoint.invoke_sync.borrow_back(self.env)?; + let response = invoke.call(FnArgs { + data: ( + P::ID.to_owned(), + P::VERSION, + action.to_owned(), + Request::TYPE_NAME.to_owned(), + Response::TYPE_NAME.to_owned(), + request, + ), + })?; + Response::from_bridge_value(response) + } +} + +/// Per-module worker-safe runtime. A re-render replaces it on the N-API main thread. +#[derive(Clone)] +pub struct BridgeRuntime { + client: BridgeClient, + main_thread: MainThreadScheduler, +} + +pub(crate) struct BridgeBindings { + pub(crate) runtime: BridgeRuntime, + pub(crate) main_thread_endpoint: MainThreadBridgeEndpoint, +} + +impl BridgeRuntime { + pub(crate) fn from_bindings( + env: &Env, + bindings: &napi_ohos::bindgen_prelude::ObjectRef, + ) -> Result { + let bindings = bindings.get_value(env)?; + let invoke: AsyncBridgeFunction<'_> = bindings.get_named_property("bridgeInvoke")?; + let invoke_sync: SyncBridgeFunction<'_> = + bindings.get_named_property("bridgeInvokeSync")?; + let dispatch: Function<'_, (), ()> = bindings.get_named_property("bridgeDispatch")?; + + let invoke = invoke + .build_threadsafe_function::() + .callee_handled::() + .build_callback(|context: ThreadsafeCallContext| { + let request = context.value; + let value = request.value.encode(&context.env)?; + Ok(FnArgs { + data: ( + request.plugin_id, + request.plugin_version, + request.action, + request.request_type_name, + request.response_type_name, + value, + request.timeout_ms, + ), + }) + })?; + + let dispatch = dispatch + .build_threadsafe_function::() + .callee_handled::() + .build_callback(|context: ThreadsafeCallContext| { + context.value.run(); + Ok(()) + })?; + + Ok(BridgeBindings { + runtime: Self { + client: BridgeClient { + invoke: Arc::new(invoke), + }, + main_thread: MainThreadScheduler { + dispatch: Arc::new(dispatch), + }, + }, + main_thread_endpoint: MainThreadBridgeEndpoint { + owner_env: env.raw() as usize, + invoke_sync: invoke_sync.create_ref()?, + }, + }) + } + + pub fn client(&self) -> BridgeClient { + self.client.clone() + } + + pub fn main_thread(&self) -> MainThreadScheduler { + self.main_thread.clone() + } + + pub async fn call_async( + &self, + action: impl AsRef, + request: Request, + options: BridgeCallOptions, + ) -> Result + where + P: BridgePlugin, + Request: BridgeNapiType, + Response: BridgeNapiType, + { + self.client + .call_async::(action, request, options) + .await + } +} + +fn validate_plugin_contract

() -> Result<()> +where + P: BridgePlugin, +{ + validate_identifier("plugin id", P::ID)?; + if P::VERSION == 0 { + return Err(Error::from_reason( + "Bridge plugin version must be greater than zero", + )); + } + for (index, requirement) in P::REQUIRED_CONTEXTS.iter().enumerate() { + if P::REQUIRED_CONTEXTS[..index].contains(requirement) { + return Err(Error::from_reason(format!( + "Bridge plugin '{}' declares a context requirement more than once", + P::ID + ))); + } + } + Ok(()) +} + +fn validate_wire_call( + plugin_id: &str, + plugin_version: u32, + action: &str, + request_type_name: &str, + response_type_name: &str, +) -> Result<()> { + validate_identifier("plugin id", plugin_id)?; + validate_identifier("action", action)?; + validate_identifier("request type", request_type_name)?; + validate_identifier("response type", response_type_name)?; + if plugin_version == 0 { + return Err(Error::from_reason( + "Bridge plugin version must be greater than zero", + )); + } + Ok(()) +} + +fn validate_identifier(label: &str, value: &str) -> Result<()> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(Error::from_reason(format!( + "Bridge {label} must contain only ASCII letters, digits, '.', '_' or '-'" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::{ + validate_identifier, validate_wire_call, AsyncBridge, BridgeCallOptions, + BridgeContextRequirement, BridgeNapiType, BridgePlugin, BridgePluginRegistry, + PluginLifecycleEvent, MAX_TIMEOUT_MS, + }; + + struct TestPlugin; + + impl BridgePlugin for TestPlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "test.plugin"; + } + + static UI_CONTEXT_LIFECYCLES: AtomicUsize = AtomicUsize::new(0); + + struct UiContextPlugin; + + impl BridgePlugin for UiContextPlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "test.ui-context"; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::UiContext]; + + fn on_lifecycle(&self, _event: &PluginLifecycleEvent) -> Result<(), napi_ohos::Error> { + UI_CONTEXT_LIFECYCLES.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[test] + fn accepts_versioned_plugin_identifiers() { + assert!(validate_identifier("plugin id", "auth.login_v2").is_ok()); + assert!(validate_identifier("action", "publish-session").is_ok()); + } + + #[test] + fn rejects_unroutable_identifiers() { + assert!(validate_identifier("plugin id", "").is_err()); + assert!(validate_identifier("plugin id", "auth/login").is_err()); + assert!(validate_identifier("action", "login action").is_err()); + } + + #[test] + fn named_napi_types_share_one_transport_contract() { + assert_eq!(::TYPE_NAME, "std.string"); + assert_eq!( as BridgeNapiType>::TYPE_NAME, "std.bytes"); + assert!(validate_wire_call("test.plugin", 1, "echo", "std.string", "demo.Profile").is_ok()); + } + + #[test] + fn clamps_bridge_timeout() { + assert_eq!( + BridgeCallOptions::default().with_timeout_ms(0).timeout_ms(), + 1 + ); + assert_eq!( + BridgeCallOptions::default() + .with_timeout_ms(MAX_TIMEOUT_MS + 1) + .timeout_ms(), + MAX_TIMEOUT_MS + ); + } + + #[test] + fn registry_rejects_duplicate_contracts() { + let registry = BridgePluginRegistry::default(); + registry.register(TestPlugin).unwrap(); + assert_eq!(registry.len(), 1); + assert!(registry.register(TestPlugin).is_err()); + } + + #[test] + fn registry_replays_lifecycle_only_after_required_context_is_ready() { + UI_CONTEXT_LIFECYCLES.store(0, Ordering::SeqCst); + let registry = BridgePluginRegistry::default(); + registry.register(UiContextPlugin).unwrap(); + registry + .dispatch_lifecycle(PluginLifecycleEvent::AbilityCreated { + restored_state: String::new(), + }) + .unwrap(); + assert_eq!(UI_CONTEXT_LIFECYCLES.load(Ordering::SeqCst), 0); + registry + .dispatch_lifecycle(PluginLifecycleEvent::UiContextReady) + .unwrap(); + assert_eq!(UI_CONTEXT_LIFECYCLES.load(Ordering::SeqCst), 2); + } +} diff --git a/crates/ability/src/helper/mod.rs b/crates/ability/src/helper/mod.rs deleted file mode 100644 index b4475619..00000000 --- a/crates/ability/src/helper/mod.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::{cell::RefCell, rc::Rc}; - -use napi_ohos::{bindgen_prelude::ObjectRef, Env}; - -mod permission; -#[cfg(feature = "webview")] -mod webview; -mod window_info; - -pub use permission::*; -#[cfg(feature = "webview")] -pub use webview::*; - -thread_local! { - static HELPER: Rc>> = Rc::new(RefCell::new(None)); - - static MAIN_THREAD_ENV: Rc>> = Rc::new(RefCell::new(None)); -} - -/// 设置 HELPER 的值 -pub fn set_helper(helper: ObjectRef) { - HELPER.with(|h| { - *h.borrow_mut() = Some(helper); - }); -} - -/// # Safety -pub unsafe fn get_helper() -> Rc>> { - HELPER.with(Rc::clone) -} - -pub fn set_main_thread_env(env: Env) { - MAIN_THREAD_ENV.with(|e| { - *e.borrow_mut() = Some(env); - }); -} - -pub fn get_main_thread_env() -> Rc>> { - MAIN_THREAD_ENV.with(Rc::clone) -} diff --git a/crates/ability/src/helper/permission.rs b/crates/ability/src/helper/permission.rs deleted file mode 100644 index 1f89e6a3..00000000 --- a/crates/ability/src/helper/permission.rs +++ /dev/null @@ -1,133 +0,0 @@ -use std::sync::{Arc, LazyLock, RwLock}; - -use napi_ohos::{ - bindgen_prelude::{Function, JsObjectValue, PromiseRaw, Unknown}, - threadsafe_function::ThreadsafeFunction, - Either, Env, Error, Result, Status, -}; - -use crate::get_main_thread_env; - -pub type PermissionRequestInput = Either>; -pub type PermissionRequestOutput = Either>; - -type PermissionRequestCall<'a> = Function<'a, PermissionRequestInput, Unknown<'a>>; - -type PermissionThreadsafeFunction = ThreadsafeFunction< - PermissionRequestInput, - Unknown<'static>, - PermissionRequestInput, - Status, - false, ->; - -type PermissionRequestTsfn = LazyLock>>>; - -pub(crate) static PERMISSION_REQUEST_TSFN: PermissionRequestTsfn = - LazyLock::new(|| RwLock::new(None)); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PermissionRequest { - Single(String), - Multiple(Vec), -} - -impl PermissionRequest { - pub fn permissions(&self) -> Vec { - match self { - Self::Single(permission) => vec![permission.clone()], - Self::Multiple(permissions) => permissions.clone(), - } - } - - pub fn into_input(self) -> PermissionRequestInput { - match self { - Self::Single(permission) => Either::A(permission), - Self::Multiple(permissions) => Either::B(permissions), - } - } -} - -impl From for PermissionRequest { - fn from(value: String) -> Self { - Self::Single(value) - } -} - -impl From<&str> for PermissionRequest { - fn from(value: &str) -> Self { - Self::Single(value.to_string()) - } -} - -impl From> for PermissionRequest { - fn from(value: Vec) -> Self { - Self::Multiple(value) - } -} - -impl From> for PermissionRequest { - fn from(value: Vec<&str>) -> Self { - Self::Multiple(value.into_iter().map(str::to_string).collect()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PermissionRequestCode { - pub permission: String, - pub code: i32, -} - -/// Create permission request threadsafe function. -/// The callback proxies to ArkTS helper.requestPermission(permission) and returns its Promise object. -pub fn create_permission_request_tsfn(env: &Env) -> Result> { - let permission_request_callback: Function<'_, PermissionRequestInput, Unknown<'_>> = env - .create_function_from_closure("permission_request_callback", move |ctx| { - let permission = ctx.first_arg::()?; - - if let Some(env_ref) = get_main_thread_env().borrow().as_ref() { - let helper = unsafe { crate::get_helper() }; - let helper_borrow = helper.borrow(); - if let Some(helper_ref) = helper_borrow.as_ref() { - let helper_obj = helper_ref.get_value(env_ref)?; - let request_permission_fn = helper_obj - .get_named_property::>("requestPermission")?; - return request_permission_fn.call(permission); - } - } - - Err(Error::from_reason( - "Failed to call helper.requestPermission from main thread", - )) - })?; - - let tsfn = permission_request_callback - .build_threadsafe_function() - .callee_handled::() - .build()?; - - let tsfn_arc = Arc::new(tsfn); - - { - let mut guard = (*PERMISSION_REQUEST_TSFN) - .write() - .map_err(|_| Error::from_reason("Failed to write PERMISSION_REQUEST_TSFN"))?; - guard.replace(tsfn_arc.clone()); - } - - Ok(tsfn_arc) -} - -pub fn get_permission_request_tsfn() -> Option> { - (*PERMISSION_REQUEST_TSFN) - .read() - .ok() - .and_then(|guard| guard.as_ref().map(Arc::clone)) -} - -pub fn unknown_to_permission_promise( - value: Unknown<'static>, -) -> Result> { - // Safety: ArkTS helper.requestPermission always returns a Promise. - unsafe { value.cast::>() } -} diff --git a/crates/ability/src/helper/webview.rs b/crates/ability/src/helper/webview.rs deleted file mode 100644 index 5cff8d27..00000000 --- a/crates/ability/src/helper/webview.rs +++ /dev/null @@ -1,483 +0,0 @@ -use std::{ - borrow::Cow, - collections::HashMap, - rc::Rc, - sync::{Arc, Mutex}, -}; - -use http::{HeaderName, HeaderValue, Request, Response}; -use napi_derive_ohos::napi; -use napi_ohos::{ - bindgen_prelude::{FnArgs, Function, JsObjectValue, ObjectRef}, - Either, Error, Result, -}; -use ohos_web_binding::{ArkWebResponse, CustomProtocolHandler, Web}; - -use crate::get_main_thread_env; - -#[napi(object)] -#[derive(Debug, Clone, Default)] -pub struct WebViewStyle { - pub x: Option>, - pub y: Option>, - pub visible: Option, - pub background_color: Option, -} - -#[napi(object)] -#[derive(Default)] -pub struct DownloadStartResult { - pub allow: bool, - pub temp_path: Option, -} - -type OnDownloadStart<'a> = Option>; -type OnDownloadEnd<'a> = Option, bool), ()>>; - -#[napi(object)] -#[derive(Default)] -pub struct WebViewInitData<'a> { - pub url: Option, - pub id: Option, - pub style: Option, - pub javascript_enabled: Option, - pub devtools: Option, - pub user_agent: Option, - pub autoplay: Option, - pub initialization_scripts: Option>, - pub headers: Option>, - pub html: Option, - pub transparent: Option, - - pub on_drag_and_drop: Option>, - pub on_download_start: OnDownloadStart<'a>, - pub on_download_end: OnDownloadEnd<'a>, - pub on_navigation_request: Option>, - pub on_title_change: Option>, -} - -#[derive(Clone)] -pub struct Webview { - tag: String, - inner: Rc, - web_view_native: Rc, -} - -impl Webview { - pub fn new(tag: String, inner: ObjectRef) -> Result { - let native_instance = Web::new(tag.clone()); - Ok(Self { - inner: Rc::new(inner), - web_view_native: Rc::new(native_instance), - tag, - }) - } - - pub fn inner(&self) -> Rc { - self.inner.clone() - } - - pub fn tag(&self) -> String { - self.tag.clone() - } - - /// Get the current url of the webview - pub fn url(&self) -> Result { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let url_js_function = self - .inner - .get_value(env)? - .get_named_property::>("getUrl")?; - url_js_function.call(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - /// Load a url in the webview - pub fn load_url(&self, url: &str) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let load_url_js_function = self.inner.get_value(env)?.get_named_property::>)>, - (), - >>("loadUrl")?; - - load_url_js_function.call((url.to_string(), None).into())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - /// Load a url with headers in the webview - pub fn load_url_with_headers(&self, url: &str, headers: http::HeaderMap) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let load_url_with_headers_js_function = self - .inner - .get_value(env)? - .get_named_property::)>, ()>>( - "loadUrl", - )?; - - let headers = headers - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default().to_string())) - .collect(); - load_url_with_headers_js_function.call((url.to_string(), headers).into())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - /// Load html in the webview - pub fn load_html(&self, html: &str) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let load_html_js_function = self - .inner - .get_value(env)? - .get_named_property::>("loadHtml")?; - load_html_js_function.call(html.to_string())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - /// Set the zoom level of the webview - pub fn set_zoom(&self, zoom: f64) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let set_zoom_js_function = self - .inner - .get_value(env)? - .get_named_property::>("zoom")?; - set_zoom_js_function.call(zoom)?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - /// Reload the webview - pub fn reload(&self) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let reload_js_function = self - .inner - .get_value(env)? - .get_named_property::>("refresh")?; - reload_js_function.call(())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - /// Focus the webview - pub fn focus(&self) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let focus_js_function = self - .inner - .get_value(env)? - .get_named_property::>("requestFocus")?; - focus_js_function.call(())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - pub fn evaluate_script(&self, js: &str) -> Result<()> { - self.evaluate_script_with_callback(js, None) - } - - pub fn evaluate_script_with_callback( - &self, - js: &str, - callback: Option>, - ) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let evaluate_js_js_function = self - .inner - .get_value(env)? - .get_named_property::)>, ()>>( - "runJavaScript", - )?; - - let cb = env.create_function_from_closure("evaluate_js_callback", move |ctx| { - let ret = ctx.try_get::(1)?; - let ret = match ret { - Either::A(s) => s, - Either::B(_ret) => String::from("undefined"), - }; - if let Some(cb) = callback.as_ref() { - cb(ret); - } - Ok(()) - })?; - - evaluate_js_js_function.call((js.to_string(), cb).into())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - pub fn cookies_with_url(&self, url: &str) -> Result { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let cookies_js_function = self - .inner - .get_value(env)? - .get_named_property::>("getCookies")?; - cookies_js_function.call(url.to_string()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - pub fn set_background_color(&self, color: &str) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let set_background_color_js_function = self - .inner - .get_value(env)? - .get_named_property::>("setBackgroundColor")?; - set_background_color_js_function.call(color.to_string())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - pub fn set_visible(&self, visible: bool) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let set_visible_js_function = self - .inner - .get_value(env)? - .get_named_property::>("setVisible")?; - set_visible_js_function.call(visible)?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - pub fn dispose(&self) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let dispose_js_function = self - .inner - .get_value(env)? - .get_named_property::>("dispose")?; - dispose_js_function.call(())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - pub fn clear_all_browsing_data(&self) -> Result<()> { - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let clear_all_browsing_data_js_function = self - .inner - .get_value(env)? - .get_named_property::>("clearAllBrowsingData")?; - clear_all_browsing_data_js_function.call(())?; - Ok(()) - } else { - Err(Error::from_reason("Failed to get main thread env")) - } - } - - pub fn on_controller_attach(&self, callback: F) -> Result<()> - where - F: FnMut(), - { - self.web_view_native - .on_controller_attach(callback) - .map_err(|e| Error::from_reason(e.to_string()))?; - Ok(()) - } - - pub fn on_page_begin(&self, callback: F) -> Result<()> - where - F: FnMut(), - { - self.web_view_native - .on_page_begin(callback) - .map_err(|e| Error::from_reason(e.to_string()))?; - Ok(()) - } - - pub fn on_page_end(&self, callback: F) -> Result<()> - where - F: FnMut(), - { - self.web_view_native - .on_page_end(callback) - .map_err(|e| Error::from_reason(e.to_string()))?; - Ok(()) - } - - pub fn on_destroy(&self, callback: F) -> Result<()> - where - F: FnMut(), - { - self.web_view_native - .on_destroy(callback) - .map_err(|e| Error::from_reason(e.to_string()))?; - Ok(()) - } - - pub fn custom_protocol(&self, protocol: S, callback: F) -> Result<()> - where - S: Into, - F: Fn(&str, Request>, bool) -> Option>>, - { - self.custom_protocol_async(protocol, move |url, request, is_main_frame, responder| { - let response = callback(url, request, is_main_frame); - if let Some(response) = response { - responder.respond(response); - } - }) - } - - pub fn custom_protocol_async(&self, protocol: S, callback: F) -> Result<()> - where - S: Into, - F: Fn(&str, Request>, bool, CustomProtocolResponder), - { - let handle = CustomProtocolHandler::new(); - let cbs = Box::leak(Box::new(callback)); - let cbs = Arc::new(Mutex::new(cbs)); - - handle.on_request_start(move |req, req_handle| { - let url: String = req.url(); - let header = req.headers(); - let mut iter = header.iter(); - - let request_body = req.http_body_stream(); - - let mut req_handle = Some(req_handle); - - match request_body { - Some(body) => { - let request_body_size = body.size(); - - let cbs = cbs.clone(); - body.read(request_body_size as usize, move |buf| { - let mut request_builder = Request::builder() - .method(req.method().as_str()) - .uri(url.clone()); - for (key, value) in iter.by_ref() { - if let (Ok(header), Ok(value)) = ( - HeaderName::from_bytes(key.as_bytes()), - HeaderValue::from_bytes(value.as_bytes()), - ) { - request_builder = request_builder.header(header, value); - } - } - let request = request_builder - .body(buf) - .expect("Create http:Request failed"); - - let cbs = cbs.clone(); - let req_handle = req_handle.take().unwrap(); - let responder = CustomProtocolResponder { - responder: Box::new(move |response| { - let header = response.headers(); - let body = response.body(); - let status = response.status(); - let body_slice = match body { - Cow::Borrowed(slice) => slice, - Cow::Owned(vec) => vec.as_slice(), - }; - - let resp = ArkWebResponse::new(); - - header.iter().for_each(|(k, v)| { - resp.set_header( - k.as_str(), - v.to_str().unwrap_or_default(), - true, - ); - }); - - resp.set_status(status.as_u16() as _); - - req_handle.receive_response(resp); - req_handle.receive_data(body_slice); - req_handle.finish() - }), - }; - - cbs.lock().unwrap()(&url, request, req.is_main_frame(), responder); - }); - } - None => { - let mut request_builder = Request::builder() - .method(req.method().as_str()) - .uri(url.clone()); - for (key, value) in iter { - if let (Ok(header), Ok(value)) = ( - HeaderName::from_bytes(key.as_bytes()), - HeaderValue::from_bytes(value.as_bytes()), - ) { - request_builder = request_builder.header(header, value); - } - } - let request = request_builder - .body(vec![]) - .expect("Create http:Request failed"); - - let responder = CustomProtocolResponder { - responder: Box::new(move |response| { - let header = response.headers(); - let status = response.status(); - let body = response.body(); - let body_slice = match body { - Cow::Borrowed(slice) => slice, - Cow::Owned(vec) => vec.as_slice(), - }; - - let resp = ArkWebResponse::new(); - - header.iter().for_each(|(k, v)| { - resp.set_header(k.as_str(), v.to_str().unwrap_or_default(), true); - }); - resp.set_status(status.as_u16() as _); - - let req_handle = req_handle.take().unwrap(); - req_handle.receive_response(resp); - req_handle.receive_data(body_slice); - req_handle.finish(); - }), - }; - cbs.lock().unwrap()(&url, request, req.is_main_frame(), responder); - } - } - - true - }); - - self.web_view_native - .custom_protocol(protocol, handle) - .map_err(|e| Error::from_reason(e.to_string()))?; - - Ok(()) - } -} - -type Responder = Box>)>; - -pub struct CustomProtocolResponder { - pub(crate) responder: Responder, -} - -unsafe impl Send for CustomProtocolResponder {} - -impl CustomProtocolResponder { - /// Resolves the request with the given response. - pub fn respond>>(self, response: Response) { - let (parts, body) = response.into_parts(); - (self.responder)(Response::from_parts(parts, body.into())) - } -} diff --git a/crates/ability/src/helper/window_info.rs b/crates/ability/src/helper/window_info.rs deleted file mode 100644 index 578f1346..00000000 --- a/crates/ability/src/helper/window_info.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[allow(dead_code)] -pub(crate) struct WindowInfo { - pub width: i32, - pub height: i32, -} diff --git a/crates/ability/src/lib.rs b/crates/ability/src/lib.rs index 1fbfee6f..82ebc2c8 100644 --- a/crates/ability/src/lib.rs +++ b/crates/ability/src/lib.rs @@ -1,10 +1,10 @@ mod app; mod area; +mod bridge; mod configuration; mod draw; mod error; mod event; -mod helper; mod input; mod lifecycle; mod memory; @@ -13,16 +13,13 @@ mod resource; mod stage; mod waker; -#[cfg(feature = "webview")] -mod webview; - pub use app::*; pub use area::*; +pub use bridge::*; pub use configuration::*; pub use draw::*; pub use error::*; pub use event::*; -pub use helper::*; pub use input::*; pub use lifecycle::*; pub use memory::*; @@ -31,14 +28,13 @@ pub use resource::*; pub use stage::*; pub use waker::*; -#[cfg(feature = "webview")] -pub use webview::*; +/// Re-exported for [`impl_bridge_napi_type!`](crate::impl_bridge_napi_type) expansions in +/// application/plugin crates. +#[doc(hidden)] +pub use napi_ohos; // re-export arkui and avoid the need to import it in the lib.rs pub use ohos_arkui_binding as arkui; pub use ohos_ime_binding as ime; pub use ohos_resource_manager_binding as resource_manager; pub use ohos_xcomponent_binding as xcomponent; - -#[cfg(feature = "webview")] -pub use ohos_web_binding as native_web; diff --git a/crates/ability/src/lifecycle.rs b/crates/ability/src/lifecycle.rs index dde19af0..a37efd1c 100644 --- a/crates/ability/src/lifecycle.rs +++ b/crates/ability/src/lifecycle.rs @@ -7,8 +7,8 @@ use napi_ohos::{ }; use crate::{ - AvoidArea, AvoidAreaInfo, AvoidAreaType, ContentRect, Event, OpenHarmonyApp, Rect, SaveLoader, - SaveSaver, Size, StageEventType, WAKER, + AvoidArea, AvoidAreaInfo, AvoidAreaType, ContentRect, Event, OpenHarmonyApp, + PluginLifecycleEvent, Rect, SaveLoader, SaveSaver, Size, StageEventType, WAKER, }; #[napi(object)] @@ -21,7 +21,7 @@ pub struct EnvironmentCallback<'a> { pub struct WindowStageEventCallback<'a> { pub on_window_stage_create: Function<'a, (), ()>, pub on_window_stage_destroy: Function<'a, (), ()>, - pub on_ability_create: Function<'a, (), ()>, + pub on_ability_create: Function<'a, String, ()>, pub on_ability_destroy: Function<'a, (), ()>, pub on_ability_save_state: Function<'a, (), ()>, pub on_ability_restore_state: Function<'a, (), ()>, @@ -85,7 +85,10 @@ pub fn create_lifecycle_handle<'a>( let on_memory_level_app = app.clone(); let on_memory_level: Function<'_, i32, ()> = - env.create_function_from_closure("memory_level", move |_ctx| { + env.create_function_from_closure("memory_level", move |ctx| { + let level = ctx.first_arg::()?; + let _ = on_memory_level_app + .dispatch_plugin_lifecycle(PluginLifecycleEvent::MemoryLevel { level }); if let Some(ref mut h) = *on_memory_level_app.event_loop.borrow_mut() { h(Event::LowMemory) } @@ -125,6 +128,8 @@ pub fn create_lifecycle_handle<'a>( .write() .unwrap() .configuration = configuration.clone(); + let _ = configuration_updated_app + .dispatch_plugin_lifecycle(PluginLifecycleEvent::ConfigurationUpdated); let conf = configuration.clone(); if let Some(ref mut h) = *configuration_updated_app.event_loop.borrow_mut() { h(Event::ConfigChanged(conf)) @@ -136,6 +141,8 @@ pub fn create_lifecycle_handle<'a>( let window_stage_event = env.create_function_from_closure("window_stage_event", move |ctx| { let event_type = ctx.first_arg::()?; + let _ = window_stage_event_app + .dispatch_plugin_lifecycle(PluginLifecycleEvent::WindowStageEvent { event_type }); if let Some(ref mut h) = *window_stage_event_app.event_loop.borrow_mut() { let state_event = StageEventType::from(event_type); @@ -216,6 +223,8 @@ pub fn create_lifecycle_handle<'a>( let on_window_stage_create_app = app.clone(); let on_window_stage_create = env.create_function_from_closure("on_ability_create", move |_ctx| { + let _ = on_window_stage_create_app + .dispatch_plugin_lifecycle(PluginLifecycleEvent::WindowStageCreated); if let Some(ref mut h) = *on_window_stage_create_app.event_loop.borrow_mut() { h(Event::WindowCreate) } @@ -225,6 +234,8 @@ pub fn create_lifecycle_handle<'a>( let on_window_stage_destroy_app = app.clone(); let on_window_stage_destroy = env.create_function_from_closure("on_window_stage_destroy", move |_ctx| { + let _ = on_window_stage_destroy_app + .dispatch_plugin_lifecycle(PluginLifecycleEvent::WindowStageDestroyed); if let Some(ref mut h) = *on_window_stage_destroy_app.event_loop.borrow_mut() { h(Event::WindowDestroy) } @@ -232,16 +243,22 @@ pub fn create_lifecycle_handle<'a>( })?; let on_ability_create_app = app.clone(); - let on_ability_create = env.create_function_from_closure("on_ability_create", move |_ctx| { - if let Some(ref mut h) = *on_ability_create_app.event_loop.borrow_mut() { - h(Event::Create) - } - Ok(()) - })?; + let on_ability_create: Function<'_, String, ()> = + env.create_function_from_closure("on_ability_create", move |ctx| { + let restored_state = ctx.first_arg::().unwrap_or_default(); + let _ = on_ability_create_app + .dispatch_plugin_lifecycle(PluginLifecycleEvent::AbilityCreated { restored_state }); + if let Some(ref mut h) = *on_ability_create_app.event_loop.borrow_mut() { + h(Event::Create) + } + Ok(()) + })?; let on_ability_destroy_app = app.clone(); let on_ability_destroy = env.create_function_from_closure("on_ability_destroy", move |_ctx| { + let _ = on_ability_destroy_app + .dispatch_plugin_lifecycle(PluginLifecycleEvent::AbilityDestroyed); if let Some(ref mut h) = *on_ability_destroy_app.event_loop.borrow_mut() { h(Event::Destroy) } diff --git a/crates/ability/src/render/xcomponent.rs b/crates/ability/src/render/xcomponent.rs index b548eabb..0e6e328f 100644 --- a/crates/ability/src/render/xcomponent.rs +++ b/crates/ability/src/render/xcomponent.rs @@ -3,23 +3,23 @@ use napi_ohos::{bindgen_prelude::ObjectRef, Env, Error, Result}; use ohos_arkui_binding::{ArkUIHandle, RootNode, XComponent}; use ohos_ime_binding::IME; -use crate::{ - create_permission_request_tsfn, input, set_helper, set_main_thread_env, Event, InputEvent, - IntervalInfo, OpenHarmonyApp, Rect, Size, -}; +use crate::{input, BridgeRuntime, Event, InputEvent, IntervalInfo, OpenHarmonyApp, Rect, Size}; /// create lifecycle object and return to arkts pub fn render( env: &Env, - helper: ObjectRef, + bindings: ObjectRef, slot: ArkUIHandle, app: OpenHarmonyApp, ) -> Result { - set_helper(helper); - set_main_thread_env(*env); - - // Initialize permission request threadsafe function - let _ = create_permission_request_tsfn(env); + // 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 = diff --git a/crates/ability/src/webview/drag.rs b/crates/ability/src/webview/drag.rs deleted file mode 100644 index 83296188..00000000 --- a/crates/ability/src/webview/drag.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! Only for HarmonyPC -// @TODO: Implement this for HarmonyPC -#[allow(dead_code)] -pub enum DragEvent { - Enter {}, -} diff --git a/crates/ability/src/webview/mod.rs b/crates/ability/src/webview/mod.rs deleted file mode 100644 index 6820c34b..00000000 --- a/crates/ability/src/webview/mod.rs +++ /dev/null @@ -1,334 +0,0 @@ -use std::{collections::HashMap, path::PathBuf}; - -use napi_ohos::Either; -use napi_ohos::{ - bindgen_prelude::{Function, JsObjectValue, ObjectRef}, - Error, Result, -}; - -use crate::helper::{DownloadStartResult, WebViewInitData, WebViewStyle, Webview}; - -mod drag; - -type OnDownloadStart = Box bool>; -type OnDownloadEnd = Box, bool)>; - -#[cfg(feature = "webview")] -#[derive(Default)] -pub struct WebViewBuilder { - pub url: Option, - pub style: Option, - pub javascript_enabled: Option, - pub devtools: Option, - pub user_agent: Option, - pub autoplay: Option, - pub initialization_scripts: Option>, - pub headers: Option>, - pub html: Option, - pub transparent: Option, - - id: Option, - #[cfg(feature = "drag_and_drop")] - on_drag_and_drop: Option>, - on_download_start: Option, - on_download_end: Option, - on_navigation_request: Option bool>>, - on_title_change: Option>, -} - -impl WebViewBuilder { - pub fn new() -> Self { - Self::default() - } - - pub fn id>(self, id: S) -> WebViewBuilder { - WebViewBuilder { - id: Some(id.into()), - ..self - } - } - - pub fn url>(self, url: S) -> WebViewBuilder { - WebViewBuilder { - url: Some(url.into()), - ..self - } - } - - pub fn style(self, style: WebViewStyle) -> WebViewBuilder { - WebViewBuilder { - style: Some(style), - ..self - } - } - - pub fn javascript_enabled(self, javascript_enabled: bool) -> WebViewBuilder { - WebViewBuilder { - javascript_enabled: Some(javascript_enabled), - ..self - } - } - - pub fn devtools(self, devtools: bool) -> WebViewBuilder { - WebViewBuilder { - devtools: Some(devtools), - ..self - } - } - - pub fn user_agent>(self, user_agent: S) -> WebViewBuilder { - WebViewBuilder { - user_agent: Some(user_agent.into()), - ..self - } - } - - pub fn autoplay(self, autoplay: bool) -> WebViewBuilder { - WebViewBuilder { - autoplay: Some(autoplay), - ..self - } - } - - pub fn initialization_scripts(self, initialization_scripts: Vec) -> WebViewBuilder { - WebViewBuilder { - initialization_scripts: Some(initialization_scripts), - ..self - } - } - - pub fn headers(self, headers: http::HeaderMap) -> WebViewBuilder { - let convert_header: HashMap = headers - .iter() - .map(|(key, value)| (key.to_string(), value.to_str().unwrap().to_string())) - .collect(); - - WebViewBuilder { - headers: Some(convert_header), - ..self - } - } - - pub fn html>(self, html: S) -> WebViewBuilder { - WebViewBuilder { - html: Some(html.into()), - ..self - } - } - - pub fn transparent(self, transparent: bool) -> WebViewBuilder { - WebViewBuilder { - transparent: Some(transparent), - ..self - } - } - - #[cfg(feature = "drag_and_drop")] - pub fn on_drag_and_drop(self, on_drag_and_drop: F) -> WebViewBuilder { - let static_handler = unsafe { - std::mem::transmute::, Box>(Box::new( - on_drag_and_drop, - )) - }; - WebViewBuilder { - on_drag_and_drop: Some(static_handler), - ..self - } - } - - pub fn on_download_start bool>( - self, - on_download_start: F, - ) -> WebViewBuilder { - let static_handler = unsafe { - std::mem::transmute::< - Box bool>, - Box bool + 'static>, - >(Box::new(on_download_start)) - }; - WebViewBuilder { - on_download_start: Some(static_handler), - ..self - } - } - - pub fn on_download_end, bool)>( - self, - on_download_end: F, - ) -> WebViewBuilder { - let static_handler = unsafe { - std::mem::transmute::< - Box, bool)>, - Box, bool) + 'static>, - >(Box::new(on_download_end)) - }; - WebViewBuilder { - on_download_end: Some(static_handler), - ..self - } - } - - pub fn on_navigation_request bool>( - self, - on_navigation_request: F, - ) -> WebViewBuilder { - let static_handler = unsafe { - std::mem::transmute:: bool>, Box bool + 'static>>( - Box::new(on_navigation_request), - ) - }; - WebViewBuilder { - on_navigation_request: Some(static_handler), - ..self - } - } - - pub fn on_title_change(self, on_title_change: F) -> WebViewBuilder { - let static_handler = unsafe { - std::mem::transmute::, Box>(Box::new( - on_title_change, - )) - }; - WebViewBuilder { - on_title_change: Some(static_handler), - ..self - } - } - - pub fn build(self) -> Result { - let id = self - .id - .ok_or(Error::from_reason("WebTag should be provided"))?; - - let ret = unsafe { - use crate::get_helper; - get_helper() - }; - - if let Some(h) = ret.borrow().as_ref() { - use crate::get_main_thread_env; - - if let Some(env) = get_main_thread_env().borrow().as_ref() { - let ret = h.get_value(env)?; - let create_webview_func = ret - .get_named_property::>( - "createWebview", - )?; - - #[cfg(feature = "drag_and_drop")] - let on_drag_and_drop = self.on_drag_and_drop.and_then(|handler| { - env.create_function_from_closure("on_drag_and_drop", move |ctx| { - let ret = ctx.try_get::(1)?; - let ret = match ret { - Either::A(s) => s, - Either::B(_ret) => String::new(), - }; - handler(ret); - Ok(()) - }) - .ok() - }); - - let on_download_start = self.on_download_start.and_then(|handler| { - env.create_function_from_closure("on_download_start", move |ctx| { - let origin_url = ctx.try_get::(1)?; - let temp_path = ctx.try_get::(2)?; - let origin_url_str = match origin_url { - Either::A(s) => s, - Either::B(_ret) => String::new(), - }; - let temp_path_str = match temp_path { - Either::A(s) => s, - Either::B(_ret) => String::new(), - }; - let mut temp_path = PathBuf::from(temp_path_str); - let ret = handler(origin_url_str, &mut temp_path); - Ok(DownloadStartResult { - allow: ret, - temp_path: Some(temp_path.to_string_lossy().to_string()), - }) - }) - .ok() - }); - - let on_download_end = self.on_download_end.and_then(|handler| { - env.create_function_from_closure("on_download_end", move |ctx| { - let origin_url = ctx.try_get::(1)?; - let temp_path = ctx.try_get::(2)?; - let success = ctx.try_get::(3)?; - let origin_url_str = match origin_url { - Either::A(s) => s, - Either::B(_ret) => String::new(), - }; - let temp_path_str = match temp_path { - Either::A(s) => Some(PathBuf::from(s)), - Either::B(_ret) => None, - }; - let success_bool = match success { - Either::A(ret) => ret, - Either::B(_ret) => false, - }; - handler(origin_url_str, temp_path_str, success_bool); - Ok(()) - }) - .ok() - }); - - let on_navigation_request = self.on_navigation_request.and_then(|handler| { - env.create_function_from_closure("on_navigation_request", move |ctx| { - let ret = ctx.try_get::(1)?; - let ret = match ret { - Either::A(s) => s, - Either::B(_ret) => String::new(), - }; - let ret = handler(ret); - Ok(ret) - }) - .ok() - }); - - let on_title_change = self.on_title_change.and_then(|handler| { - env.create_function_from_closure("on_title_change", move |ctx| { - let ret = ctx.try_get::(1)?; - let ret = match ret { - Either::A(s) => s, - Either::B(_ret) => String::new(), - }; - handler(ret); - Ok(()) - }) - .ok() - }); - - let webview = create_webview_func.call(WebViewInitData { - url: self.url, - id: Some(id.clone()), - style: self.style, - javascript_enabled: self.javascript_enabled, - devtools: self.devtools, - user_agent: self.user_agent, - autoplay: self.autoplay, - initialization_scripts: self.initialization_scripts, - headers: self.headers, - html: self.html, - transparent: self.transparent, - #[cfg(feature = "drag_and_drop")] - on_drag_and_drop, - - #[cfg(not(feature = "drag_and_drop"))] - on_drag_and_drop: None, - on_download_start, - on_download_end, - on_navigation_request, - on_title_change, - })?; - - let web = Webview::new(id.clone(), webview)?; - return Ok(web); - } - - return Err(Error::from_reason("Failed to create webview")); - } - Err(Error::from_reason("Failed to create webview")) - } -} diff --git a/crates/derive/README.md b/crates/derive/README.md index 4bb65d27..ed40c3e7 100644 --- a/crates/derive/README.md +++ b/crates/derive/README.md @@ -1,99 +1,29 @@ # openharmony-ability-derive -## Introduce - -openharmony-ability-derive is a macro crate for the openharmony-ability project. It provides a macro for generating code to accept the OpenHarmony/HarmonyNext ability's lifecycle callbacks. - - -## Install - -```bash -cargo add openharmony-ability-derive -``` - -## Mode - -We support two different mode to render. - -### xcomponent -Using `XComponent` to render code that can use OpenGL or Vulkan. - -**Example** +`#[ability]` generates the N-API exports for one OpenHarmony native Ability module: lifecycle +initialization, XComponent render entry, back-press callback, and the generic bridge event ports. ```rust +use openharmony_ability::{Event, OpenHarmonyApp}; use openharmony_ability_derive::ability; #[ability] fn openharmony_app(app: OpenHarmonyApp) { - app.run_loop(|types| match types { - Event::Input(k) => match k { - InputEvent::TextInputEvent(s) => { - hilog_info!(format!("ohos-rs macro input_text: {:?}", s).as_str()); - } - _ => { - hilog_info!(format!("ohos-rs macro input:").as_str()); - } - }, - Event::WindowRedraw(_) => {} - _ => { - hilog_info!(format!("ohos-rs macro: {:?}", types.as_str()).as_str()); - } - }); -} -``` - -### Init Context - -The generated `init(context)` forwards ArkTS init data into native code. In the Rust runtime, you can read it through `app.init_context()`, `app.module_name()`, `app.base_path()`, `app.pref_path()`, and `app.preferred_locales()`. The Harmony `resourceManager` instance is initialized alongside the other init data, stored globally, and is available through `openharmony_ability::resource_manager()` or `app.resource_manager()`. - -### webview - -Using `ArkWeb` to render everything. - -**Example** - -```rust -use openharmony_ability_derive::ability; - -#[ability(webview)] -fn openharmony_app(app: OpenHarmonyApp) { - app.run_loop(|types| match types { - Event::Input(k) => match k { - InputEvent::TextInputEvent(s) => { - hilog_info!(format!("ohos-rs macro input_text: {:?}", s).as_str()); - } - _ => { - hilog_info!(format!("ohos-rs macro input:").as_str()); - } - }, + app.run_loop(|event| match event { Event::WindowRedraw(_) => {} - _ => { - hilog_info!(format!("ohos-rs macro: {:?}", types.as_str()).as_str()); - } + _ => {} }); } ``` -And we also support custom protocols. Just add them into ability: - -```rust -use openharmony_ability_derive::ability; - -#[ability(webview, protocol = "custom,hello")] -fn openharmony_app(app: OpenHarmonyApp) { - app.run_loop(|types| match types { - Event::Input(k) => match k { - InputEvent::TextInputEvent(s) => { - hilog_info!(format!("ohos-rs macro input_text: {:?}", s).as_str()); - } - _ => { - hilog_info!(format!("ohos-rs macro input:").as_str()); - } - }, - Event::WindowRedraw(_) => {} - _ => { - hilog_info!(format!("ohos-rs macro: {:?}", types.as_str()).as_str()); - } - }); -} -``` +The macro accepts no arguments. In particular, `#[ability(webview)]` and +`#[ability(protocol = ...)]` were removed in the pluginized architecture. Compose WebView and +other platform capabilities through their Rust facade crate and ArkTS HAR factory instead of +making them framework render modes. A business that needs custom protocol interception must ship +it through `openharmony-ability-plugin-webview::WebviewProtocol` and +`WebviewClient::custom_protocol` rather than restoring a macro branch. + +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 available through +`openharmony_ability::resource_manager()` or `app.resource_manager()`. diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs index fd24b223..31bf488a 100644 --- a/crates/derive/src/lib.rs +++ b/crates/derive/src/lib.rs @@ -1,148 +1,53 @@ use proc_macro::TokenStream; -use syn::parse::Parse; -use syn::punctuated::Punctuated; -use syn::Token; -use syn::{ItemFn, Meta, MetaNameValue}; -#[derive(Default, Debug)] -struct AbilityArgs { - webview: bool, - protocol: Option, -} - -struct MetaList { - metas: Punctuated, -} - -impl Parse for MetaList { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - Ok(MetaList { - metas: input.parse_terminated(Meta::parse, Token![,])?, - }) - } -} - -fn parse_ability_args(attr: TokenStream) -> syn::Result { - let mut args = AbilityArgs::default(); - - if attr.is_empty() { - return Ok(args); - } - - let attr_stream = proc_macro2::TokenStream::from(attr); - let meta_list = syn::parse2::(attr_stream)?; - - for meta in meta_list.metas { - match meta { - Meta::Path(path) => { - if path.is_ident("webview") { - args.webview = true; - } - } - Meta::NameValue(MetaNameValue { path, value, .. }) => { - if path.is_ident("protocol") { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(lit_str), - .. - }) = value - { - args.protocol = Some(lit_str.value()); - } else { - return Err(syn::Error::new_spanned( - value, - "protocol must be a string literal", - )); - } - } - } - Meta::List(_) => {} - } - } - - Ok(args) -} +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. #[proc_macro_attribute] pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[ability] no longer accepts arguments; compose capability plugins in ArkTS instead", + ) + .to_compile_error() + .into(); + } + let ast = syn::parse_macro_input!(item as ItemFn); let fn_name = &ast.sig.ident; let block = &ast.block; let arg = &ast.sig.inputs; - let args = match parse_ability_args(attr) { - Ok(args) => args, - Err(e) => { - return TokenStream::from(e.to_compile_error()); - } - }; - - let protocol_registrations = args - .protocol - .as_ref() - .map(|protocols| { - protocols - .split(",") - .map(|protocol| { - let protocol_lit = syn::LitStr::new(protocol, proc_macro2::Span::call_site()); - quote::quote! { - openharmony_ability::native_web::CustomProtocol::add_protocol_with_option(#protocol_lit, - openharmony_ability::native_web::CustomProtocolOption::Standard | - openharmony_ability::native_web::CustomProtocolOption::CorsEnabled | - openharmony_ability::native_web::CustomProtocolOption::CspBypassing | - openharmony_ability::native_web::CustomProtocolOption::FetchEnabled | - openharmony_ability::native_web::CustomProtocolOption::CodeCacheEnabled - ); - } - }) - .collect::>() - }) - .unwrap_or_default(); - let render = quote::quote! { #[napi_derive_ohos::napi] pub fn render<'a>( env: &'a napi_ohos::Env, - helper: napi_ohos::bindgen_prelude::ObjectRef, + bindings: napi_ohos::bindgen_prelude::ObjectRef, #[napi(ts_arg_type = "NodeContent")] slot: openharmony_ability::arkui::ArkUIHandle, ) -> napi_ohos::Result<()> { - let root = openharmony_ability::render(env, helper, slot, (*APP).clone())?; + let root = openharmony_ability::render(env, bindings, slot, (*APP).clone())?; ROOT_NODE.replace(Some(root)); Ok(()) } }; - let protocol_registrations_apply = if args.protocol.is_some() && args.webview { - quote::quote! { - #[napi_derive_ohos::napi] - pub fn register_custom_protocol<'a>( - env: &'a napi_ohos::Env, - ) -> napi_ohos::Result<()> { - #(#protocol_registrations)* - - openharmony_ability::native_web::CustomProtocol::register(); - - Ok(()) - } - } - } else { - quote::quote! {} - }; - - let f = quote::quote! { + let expanded = quote::quote! { pub(crate) fn #fn_name(#arg) #block mod openharmony_ability_mod { use super::*; static APP: std::sync::LazyLock = - std::sync::LazyLock::new(|| openharmony_ability::OpenHarmonyApp::new()); + std::sync::LazyLock::new(openharmony_ability::OpenHarmonyApp::new); thread_local! { pub static ROOT_NODE: std::cell::RefCell> = std::cell::RefCell::new(None); } - #protocol_registrations_apply - #[napi_derive_ohos::napi] pub fn on_back_press_intercept() -> bool { (*APP).get_back_press_interceptor() @@ -163,9 +68,41 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { Ok(lifecycle_handle) } + /// Synchronous ArkTS platform callback -> Rust plugin decision port. + /// + /// The N-API value is scoped to this call and the returned value must be produced + /// before ArkTS resumes the originating platform callback. It is the dedicated + /// typed inbound event port for ArkTS plugins. + #[napi_derive_ohos::napi] + pub fn on_bridge_sync_event<'a>( + env: &'a napi_ohos::Env, + plugin_id: String, + event: String, + request_type_name: String, + response_type_name: String, + value: napi_ohos::bindgen_prelude::Unknown<'a>, + ) -> napi_ohos::Result> { + let event = openharmony_ability::BridgeMainThreadEvent::new( + env, + plugin_id, + event, + request_type_name, + response_type_name, + value, + )?; + (*APP).dispatch_bridge_main_thread_event(event) + } + + /// ArkTS-only lifecycle transitions, currently UI-context readiness transitions. + #[napi_derive_ohos::napi] + pub fn on_bridge_lifecycle(kind: String) -> napi_ohos::Result<()> { + let event = openharmony_ability::PluginLifecycleEvent::from_arkts(&kind)?; + (*APP).dispatch_plugin_lifecycle(event) + } + #render } }; - f.into() + expanded.into() } diff --git a/crates/plugin-app-control/Cargo.toml b/crates/plugin-app-control/Cargo.toml new file mode 100644 index 00000000..2a35d1de --- /dev/null +++ b/crates/plugin-app-control/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openharmony-ability-plugin-app-control" +version = "0.3.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Application control bridge plugin facade for openharmony-ability" + +[dependencies] +openharmony-ability = { workspace = true } +napi-ohos = { workspace = true } +napi-derive-ohos = { workspace = true } diff --git a/crates/plugin-app-control/README.md b/crates/plugin-app-control/README.md new file mode 100644 index 00000000..db9a377f --- /dev/null +++ b/crates/plugin-app-control/README.md @@ -0,0 +1,78 @@ +# openharmony-ability-plugin-app-control + +`openharmony-ability-plugin-app-control` 提供应用进程控制的 Rust facade。目前公开能力为以指定退出码 +结束进程。它与 ArkTS HAR `@ohos-rs/plugin-app-control` 成对使用,并且是严格的主线程同步插件。 + +## 契约 + +| 项目 | 值 | +| --- | --- | +| Rust crate | `openharmony-ability-plugin-app-control` | +| ArkTS HAR | `@ohos-rs/plugin-app-control` | +| 插件 ID / bridge 版本 | `ohos.app-control` / `1` | +| 执行模式 | 主线程同步:`MainThreadSyncBridge` / `invokeSync` | +| 前置 context | `ability` | +| action | `terminate` | +| request → response | `ohos.app_control.TerminateRequest { code }` → `ohos.app_control.TerminateResponse { accepted }` | + +## 接入 + +Rust facade 和 ArkTS factory 都必须在应用启动期装配: + +```rust +use openharmony_ability::OpenHarmonyApp; +use openharmony_ability_derive::ability; +use openharmony_ability_plugin_app_control::AppControlBridgePlugin; + +#[ability] +fn configure_ability(app: OpenHarmonyApp) { + app.register_plugin(AppControlBridgePlugin) + .expect("app-control facade must be registered once"); +} +``` + +```ts +import { NativeAbility } from "@ohos-rs/ability"; +import { createAppControlPlugin } from "@ohos-rs/plugin-app-control"; + +export default class EntryAbility extends NativeAbility { + public bridgePlugins = [createAppControlPlugin()]; +} +``` + +同时在应用 `oh-package.json5` 添加 `@ohos-rs/plugin-app-control`。HAR 的具体依赖和 factory 说明见 +[ArkTS README](../../plugins/app-control/README.md)。 + +## Rust 使用方式 + +`AppControlExt::terminate` 只能在导出的 N-API callback 中调用,并且必须传入该 callback 当前持有的 +`Env`: + +```rust +use napi_ohos::{Env, Result}; +use openharmony_ability_plugin_app_control::AppControlExt; + +#[napi] +pub fn terminate_application(env: Env, code: i32) -> Result<()> { + current_app()?.terminate(&env, code) +} +``` + +`current_app()` 代表应用在 `#[ability]` 初始化时保存并按需读取的 `OpenHarmonyApp`。示例重点是 `Env` +只能来自当前 N-API callback,不能缓存、clone 或跨线程转移。 + +## 线程与生命周期限制 + +- 该插件不是 async API:禁止在 Rust worker、`async` future、`spawn` 任务或 `block_on` 中调用。 +- `BridgeMainThread` 会校验活跃 N-API environment;若 `DefaultXComponent` 尚未 render、Ability context + 未就绪或 `Env` 不匹配,调用会立即报错。 +- ArkTS 调用 `process.ProcessManager.exit(code)`。一旦系统实际结束进程,后续业务逻辑不应依赖继续执行。 +- `accepted = false` 会被 Rust facade 转换为错误,不能静默忽略。 + +## 开发约束 + +`terminate` 的 request/response 都是具名 N-API object,不允许 JSON 或 Promise 包装。若未来新增控制能力, +必须仍使用 `MainThreadSyncBridge`(仅当平台要求立即决定)或单独定义异步插件 action;不能把同步 API +伪装成异步后再阻塞等待。 + +完整规范见 [插件开发规范](../../docs/plugin-development-standard.md)。 diff --git a/crates/plugin-app-control/src/lib.rs b/crates/plugin-app-control/src/lib.rs new file mode 100644 index 00000000..cd274885 --- /dev/null +++ b/crates/plugin-app-control/src/lib.rs @@ -0,0 +1,78 @@ +//! Main-thread-only application-control plugin facade. + +use napi_derive_ohos::napi; +use napi_ohos::{Env, Error, Result}; +use openharmony_ability::{ + impl_bridge_napi_type, BridgeContextRequirement, BridgePlugin, MainThreadSyncBridge, + OpenHarmonyApp, +}; + +pub struct AppControlBridgePlugin; + +impl BridgePlugin for AppControlBridgePlugin { + type Mode = MainThreadSyncBridge; + + const ID: &'static str = "ohos.app-control"; + const VERSION: u32 = 1; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::Ability]; +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct TerminateRequest { + pub code: i32, +} + +impl_bridge_napi_type!(TerminateRequest, "ohos.app_control.TerminateRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct TerminateResponse { + pub accepted: bool, +} + +impl_bridge_napi_type!(TerminateResponse, "ohos.app_control.TerminateResponse"); + +/// A synchronous capability must be invoked in an exported N-API callback that owns `Env`. +pub trait AppControlExt { + fn terminate(&self, env: &Env, code: i32) -> Result<()>; +} + +impl AppControlExt for OpenHarmonyApp { + fn terminate(&self, env: &Env, code: i32) -> Result<()> { + self.with_main_thread_bridge(env, |bridge| { + let response = bridge + .call_sync::( + "terminate", + TerminateRequest { code }, + )?; + if !response.accepted { + return Err(Error::from_reason( + "App-control plugin rejected termination", + )); + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{TerminateRequest, TerminateResponse}; + use openharmony_ability::BridgeNapiType; + + #[test] + fn terminate_uses_a_stable_named_napi_contract() { + assert_eq!( + ::TYPE_NAME, + "ohos.app_control.TerminateRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.app_control.TerminateResponse" + ); + assert_eq!(TerminateRequest { code: -1 }.code, -1); + assert!(TerminateResponse { accepted: true }.accepted); + } +} diff --git a/crates/plugin-permission/Cargo.toml b/crates/plugin-permission/Cargo.toml new file mode 100644 index 00000000..07f9ec8e --- /dev/null +++ b/crates/plugin-permission/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openharmony-ability-plugin-permission" +version = "0.3.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Permission bridge plugin facade for openharmony-ability" + +[dependencies] +openharmony-ability = { workspace = true } +napi-ohos = { workspace = true } +napi-derive-ohos = { workspace = true } diff --git a/crates/plugin-permission/README.md b/crates/plugin-permission/README.md new file mode 100644 index 00000000..1a35e911 --- /dev/null +++ b/crates/plugin-permission/README.md @@ -0,0 +1,101 @@ +# openharmony-ability-plugin-permission + +`openharmony-ability-plugin-permission` 是 OpenHarmony 运行时权限能力的 Rust facade。它与 +ArkTS HAR `@ohos-rs/plugin-permission` 成对使用:Rust 发起强类型异步请求,ArkTS 使用 +`abilityAccessCtrl` 展示系统授权界面并返回结果。 + +## 契约 + +| 项目 | 值 | +| --- | --- | +| Rust crate | `openharmony-ability-plugin-permission` | +| ArkTS HAR | `@ohos-rs/plugin-permission` | +| 插件 ID / bridge 版本 | `ohos.permission` / `1` | +| 执行模式 | 异步:`AsyncBridge` / `invokeAsync` | +| 前置 context | `ability` | +| action | `request` | +| request → response | `ohos.permission.PermissionRequest` → `ohos.permission.PermissionResponse` | + +所有调用使用具名 N-API object,而不是 JSON 字符串。Rust 的 `permissions` 字段在 ArkTS 中对应 +`permissions`,返回的 `codes` 与输入权限列表按相同索引对齐。 + +## 接入 + +1. 在 native Rust 模块中添加该 crate,并在 `#[ability]` 初始化器内注册 Rust facade: + + ```rust + use openharmony_ability::OpenHarmonyApp; + use openharmony_ability_derive::ability; + use openharmony_ability_plugin_permission::PermissionBridgePlugin; + + #[ability] + fn configure_ability(app: OpenHarmonyApp) { + app.register_plugin(PermissionBridgePlugin) + .expect("permission facade must be registered once"); + } + ``` + +2. 在应用的 `oh-package.json5` 中加入 `@ohos-rs/plugin-permission`,并在继承 + `NativeAbility` 的入口显式装配 `createPermissionPlugin()`: + + ```ts + import { NativeAbility } from "@ohos-rs/ability"; + import { createPermissionPlugin } from "@ohos-rs/plugin-permission"; + + export default class EntryAbility extends NativeAbility { + public bridgePlugins = [createPermissionPlugin()]; + } + ``` + +3. 在应用的 `module.json5` 声明需要请求的 OpenHarmony 权限。插件只负责运行时请求,不能绕过 + manifest 声明或系统授权策略。 + +两侧均已装配后才可调用;只添加 Rust crate 或只添加 HAR 都会在桥接边界失败。 + +## Rust 使用方式 + +`PermissionExt` 为 `OpenHarmonyApp` 提供 `request_permission`。它接受单个 `&str` / `String`,也接受 +`Vec` 或 `Vec<&str>`;返回 `Vec`。 + +```rust +use napi_ohos::Result; +use openharmony_ability::OpenHarmonyApp; +use openharmony_ability_plugin_permission::PermissionExt; + +async fn request_camera(app: &OpenHarmonyApp) -> Result<()> { + let results = app + .request_permission(vec![ + "ohos.permission.CAMERA", + "ohos.permission.MICROPHONE", + ]) + .await?; + + for result in results { + // result.permission 是请求名;result.code 是系统返回码。 + tracing::info!(permission = %result.permission, code = result.code); + } + Ok(()) +} +``` + +这是 worker-safe 的异步 API:调用可以来自 Rust worker,桥接层通过 TSFN 回到 ArkTS,等待系统弹窗完成后 +再把 Rust 所有权数据返回给 future。不要在调用侧人为 `block_on` 或占用 ArkTS 主线程。 + +## 行为与错误语义 + +- 空权限列表或只含空白名称会在 Rust/ArkTS 边界前被拒绝。 +- Rust facade 为请求设置 60 秒 bridge deadline;超时或 Ability/session 销毁会取消等待。 +- ArkTS 先以 `-1` 填充每个结果。系统调用失败、返回数组短于请求数组时,对应位置保留 `-1`;正常结果的 + 顺序始终与输入一致。 +- 系统授权弹窗完成后,ArkTS 会检查 `context.isActive()`;如果 Ability 已销毁,调用以错误结束,不返回 + 已失效的结果。 + +## 开发与排查 + +ArkTS 侧必须校验输入 `typeName === "ohos.permission.PermissionRequest"`,并返回 +`"ohos.permission.PermissionResponse"`。新增 action 或修改字段时,Rust 和 ArkTS 的 typeName、插件版本、 +`requires`、执行模式必须同步更新;不得使用 `JSON.stringify` / `JSON.parse` 传输 payload。 + +完整的线程、生命周期、契约升级和验收要求见 +[插件开发规范](../../docs/plugin-development-standard.md)。ArkTS 实现与装配细节见 +[对应 HAR README](../../plugins/permission/README.md)。 diff --git a/crates/plugin-permission/src/lib.rs b/crates/plugin-permission/src/lib.rs new file mode 100644 index 00000000..0318e4cf --- /dev/null +++ b/crates/plugin-permission/src/lib.rs @@ -0,0 +1,194 @@ +//! Typed Rust facade for the ArkTS `@ohos-rs/plugin-permission` HAR. +//! +//! This crate deliberately depends on the bridge core; the core does not depend on this crate. + +use std::{future::Future, pin::Pin}; + +use napi_derive_ohos::napi; +use napi_ohos::{Error, Result}; +use openharmony_ability::{ + impl_bridge_napi_type, AsyncBridge, BridgeCallOptions, BridgeContextRequirement, BridgePlugin, + OpenHarmonyApp, +}; + +pub struct PermissionBridgePlugin; + +impl BridgePlugin for PermissionBridgePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "ohos.permission"; + const VERSION: u32 = 1; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::Ability]; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PermissionRequest { + One(String), + Many(Vec), +} + +impl PermissionRequest { + pub fn permissions(self) -> Vec { + match self { + Self::One(permission) => vec![permission], + Self::Many(permissions) => permissions, + } + } +} + +impl From for PermissionRequest { + fn from(permission: String) -> Self { + Self::One(permission) + } +} + +impl From<&str> for PermissionRequest { + fn from(permission: &str) -> Self { + Self::One(permission.to_owned()) + } +} + +impl From> for PermissionRequest { + fn from(permissions: Vec) -> Self { + Self::Many(permissions) + } +} + +impl<'a> From> for PermissionRequest { + fn from(permissions: Vec<&'a str>) -> Self { + Self::Many(permissions.into_iter().map(str::to_owned).collect()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PermissionRequestCode { + pub permission: String, + pub code: i32, +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct PermissionRequestPayload { + pub permissions: Vec, +} + +impl_bridge_napi_type!( + PermissionRequestPayload, + "ohos.permission.PermissionRequest" +); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct PermissionResponsePayload { + pub codes: Vec, +} + +impl_bridge_napi_type!( + PermissionResponsePayload, + "ohos.permission.PermissionResponse" +); + +fn validate_request(permissions: &[String]) -> Result<()> { + if permissions.is_empty() + || permissions + .iter() + .any(|permission| permission.trim().is_empty()) + { + return Err(Error::from_reason( + "Permission requests must contain one or more non-empty permission names", + )); + } + Ok(()) +} + +/// Extension trait supplied by the capability package, never by `openharmony-ability` core. +pub trait PermissionExt { + fn request_permission

( + &self, + permission: P, + ) -> Pin>> + Send>> + where + P: Into; +} + +impl PermissionExt for OpenHarmonyApp { + fn request_permission

( + &self, + permission: P, + ) -> Pin>> + Send>> + where + P: Into, + { + let permissions = permission.into().permissions(); + let payload = validate_request(&permissions).map(|()| PermissionRequestPayload { + permissions: permissions.clone(), + }); + let bridge = self.bridge(); + + Box::pin(async move { + let response = bridge? + .call_async::< + PermissionBridgePlugin, + PermissionRequestPayload, + PermissionResponsePayload, + >( + "request", + payload?, + BridgeCallOptions::default().with_timeout_ms(60_000), + ) + .await?; + let codes = response.codes; + if codes.len() != permissions.len() { + return Err(Error::from_reason(format!( + "Permission plugin returned {} results for {} requested permissions", + codes.len(), + permissions.len() + ))); + } + + Ok(permissions + .into_iter() + .zip(codes) + .map(|(permission, code)| PermissionRequestCode { permission, code }) + .collect()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{ + validate_request, PermissionRequest, PermissionRequestPayload, PermissionResponsePayload, + }; + use openharmony_ability::BridgeNapiType; + + #[test] + fn permission_request_preserves_order_and_rejects_invalid_names() { + let permissions = + PermissionRequest::from(vec!["ohos.permission.CAMERA", "ohos.permission.MICROPHONE"]) + .permissions(); + assert_eq!( + permissions, + vec![ + "ohos.permission.CAMERA".to_owned(), + "ohos.permission.MICROPHONE".to_owned(), + ] + ); + assert!(validate_request(&permissions).is_ok()); + assert!(validate_request(&[]).is_err()); + assert!(validate_request(&[" ".to_owned()]).is_err()); + } + + #[test] + fn permission_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.permission.PermissionRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.permission.PermissionResponse" + ); + } +} diff --git a/crates/plugin-webview/Cargo.toml b/crates/plugin-webview/Cargo.toml new file mode 100644 index 00000000..f03d8401 --- /dev/null +++ b/crates/plugin-webview/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "openharmony-ability-plugin-webview" +version = "0.3.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "WebView bridge plugin facade for openharmony-ability" + +[dependencies] +openharmony-ability = { workspace = true } +napi-ohos = { workspace = true } +napi-derive-ohos = { workspace = true } +http = { workspace = true } +ohos-web-binding = { workspace = true } diff --git a/crates/plugin-webview/README.md b/crates/plugin-webview/README.md new file mode 100644 index 00000000..6024be42 --- /dev/null +++ b/crates/plugin-webview/README.md @@ -0,0 +1,157 @@ +# openharmony-ability-plugin-webview + +`openharmony-ability-plugin-webview` 是 ArkWeb/WebView 的 Rust facade。它与 +`@ohos-rs/plugin-webview` HAR 成对工作:ArkTS 持有 `WebviewController`、ArkUI `FrameNode` 和 +ArkWeb delegate;Rust 只持有 controller ID、具名 N-API 数据及 Rust-owned callback/protocol closure。 + +插件不把 WebView 写进 framework 的 `DefaultXComponent`。默认情况下它挂载到通用 +`xcomponent-overlay` slot;业务也可用 `BridgeNodeHost` 提供任意命名 slot,从而保留 WebView、 +XComponent 和自定义 ArkUI 节点的混合布局。 + +## 契约 + +| 项目 | 值 | +| --- | --- | +| Rust crate | `openharmony-ability-plugin-webview` | +| ArkTS HAR | `@ohos-rs/plugin-webview` | +| 插件 ID / bridge 版本 | `ohos.webview` / `1` | +| 执行模式 | 异步:`AsyncBridge` / `invokeAsync` | +| 前置 context | `ui-context` | +| 默认 slot | `xcomponent-overlay` | +| 核心 action | `create`、控制器操作、`evaluate-script` | + +所有出站 action 和所有 ArkWeb 反向事件都是具名 N-API 契约,不使用 JSON。`create` 返回 controller +ID 和 slot ID;之后通过 `WebviewHandle` 操作控制器,而不是跨线程保存 ArkTS controller/object。 + +## 接入与注册 + +在 `#[ability]` 初始化期间注册 Rust facade;如需自定义 scheme,必须在 Web engine 初始化之前调用 +`WebviewProtocol::register`: + +```rust +use openharmony_ability::OpenHarmonyApp; +use openharmony_ability_derive::ability; +use openharmony_ability_plugin_webview::{ + WebviewBridgePlugin, WebviewProtocol, WebviewProtocolOptions, +}; + +#[ability] +fn configure_ability(app: OpenHarmonyApp) { + WebviewProtocol::register("asset", WebviewProtocolOptions::Standard) + .expect("scheme must be declared before Web engine initialization"); + app.register_plugin(WebviewBridgePlugin) + .expect("webview facade must be registered once"); +} +``` + +应用侧在 `oh-package.json5` 添加 `@ohos-rs/plugin-webview`,并在 `NativeAbility` 中显式装配 factory: + +```ts +import { NativeAbility } from "@ohos-rs/ability"; +import { createWebviewPlugin } from "@ohos-rs/plugin-webview"; + +export default class EntryAbility extends NativeAbility { + public bridgePlugins = [createWebviewPlugin()]; +} +``` + +HAR 的 ArkTS 运行时、slot 和 delegate 说明见 [ArkTS README](../../plugins/webview/README.md)。 + +## 创建与控制 WebView + +```rust +use napi_ohos::Result; +use openharmony_ability::OpenHarmonyApp; +use openharmony_ability_plugin_webview::{WebviewCreateRequest, WebviewExt}; + +async fn open_article(app: &OpenHarmonyApp) -> Result<()> { + let client = app.webview()?; + let handle = client + .create( + WebviewCreateRequest::new("article") + .slot_id("webview-panel") + .transparent(true) + .url("https://example.com"), + ) + .await?; + + handle.set_visible(true).await?; + let title = handle.evaluate_script("document.title").await?; + tracing::info!(?title, "page title"); + Ok(()) +} +``` + +`WebviewCreateRequest` 支持 URL/HTML、slot、样式、JavaScript 开关、devtools、user agent、autoplay、 +document-start initialization scripts、headers 和 `transparent`。`transparent(true)` 是创建时语义:若 +没有显式 background color,ArkTS 使用透明背景;显式颜色优先。 + +`WebviewHandle` 提供 `set_visible`、`set_background_color`、`load_url`、 +`load_url_with_headers`、`load_html`、`url`、`set_zoom`、`reload`、`focus`、`cookies_with_url`、 +`clear_all_browsing_data`、`remove`/`dispose` 和 `evaluate_script`。这些都是异步控制器 action。 + +## slot 与生命周期 + +- `DefaultXComponent` 已提供默认 slot;需要业务自定义位置时,在页面使用 + `BridgeNodeHost({ moduleName, slotId, underlay, foreground })`,并把同一 `slotId` 写入 create request。 +- 异步 `create` 可以等待 slot attach、controller attach 和首次导航启动;等待由 lifecycle/cancel + 驱动,不使用固定 timer 轮询。 +- UI/Ability 销毁、调用超时或取消时,ArkTS 必须卸载临时节点和 controller 映射;Rust 不保存 + `UIContext`、`FrameNode`、`WebviewController` 或 ArkTS function。 + +## WebView 回调 + +在 `create` 前用 `WebviewCallbacksBuilder` 按 WebView tag 声明回调: + +```rust +use openharmony_ability_plugin_webview::{ + WebviewCallbacksBuilder, WebviewDownloadStartResponse, +}; + +WebviewCallbacksBuilder::new("article") + .on_navigation_request(|request| request.url.starts_with("app://blocked")) + .on_download_start(|request| WebviewDownloadStartResponse::allow(request.temp_path)) + .on_download_end(|event| tracing::info!(?event, "download completed")) + .on_title_change(|event| tracing::info!(?event, "title changed")) + .build()?; +``` + +ArkTS 只接收“是否订阅”的创建快照,实际 closure 始终在 Rust。导航回调未订阅或失败时默认 +`intercept = false`(fail-open);下载开始回调未订阅时默认取消下载(fail-closed);下载结束和标题 +变更是通知型事件。所有 callback 在当前 N-API callback 内运行,应快速返回;耗时工作只能复制数据后 +投递给 worker。 + +## 自定义 protocol 与页面 JavaScript + +自定义 scheme 和页面 JavaScript proxy 是不同机制: + +```rust +use std::borrow::Cow; +use openharmony_ability_plugin_webview::{ + WebviewJavascriptProxyBuilder, WebviewProtocol, WebviewProtocolOptions, +}; + +// 在 #[ability] 初始化器中: +WebviewProtocol::register("asset", WebviewProtocolOptions::Standard)?; + +// 在 create 前: +client.custom_protocol("article", "asset", |_url, _request, _is_main_frame| { + http::Response::builder() + .header("content-type", "text/html") + .body(Cow::Borrowed(&b"

local page

"[..])) + .ok() +})?; + +WebviewJavascriptProxyBuilder::new("article", "native") + .add_method("postMessage", |_tag, arguments| { + tracing::info!(?arguments, "page message"); + }) + .build()?; +``` + +`WebviewProtocol::register` 只能在 engine 初始化前调用。tag handler 和 JS proxy 应优先在 `create` +前声明;controller attach 后插件会先安装 protocol/proxy/delegate,再开始首次导航。需要异步回复 +custom protocol 时使用 `custom_protocol_async` / `WebviewProtocolResponder`。 + +完整的 typeName、反向事件、线程、slot 和验收规则见 +[插件开发规范](../../docs/plugin-development-standard.md)。 diff --git a/crates/plugin-webview/src/callbacks.rs b/crates/plugin-webview/src/callbacks.rs new file mode 100644 index 00000000..0e71c26d --- /dev/null +++ b/crates/plugin-webview/src/callbacks.rs @@ -0,0 +1,202 @@ +//! Rust-owned WebView callback declarations. +//! +//! The registry stores only Rust closures keyed by WebView tag. 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. + +use std::{ + collections::BTreeMap, + sync::{Arc, LazyLock, RwLock}, +}; + +use napi_ohos::{Error, Result}; + +use super::{ + WebviewCallbackOptions, WebviewDownloadEndEvent, WebviewDownloadStartRequest, + WebviewDownloadStartResponse, WebviewNavigationRequest, WebviewNavigationResponse, + WebviewTitleChangeEvent, +}; + +type NavigationCallback = Arc bool + Send + Sync + 'static>; +type DownloadStartCallback = Arc< + dyn Fn(WebviewDownloadStartRequest) -> WebviewDownloadStartResponse + Send + Sync + 'static, +>; +type DownloadEndCallback = Arc; +type TitleChangeCallback = Arc; + +#[derive(Default, Clone)] +struct WebviewCallbacks { + navigation: Option, + download_start: Option, + download_end: Option, + title_change: Option, +} + +impl WebviewCallbacks { + fn options(&self) -> WebviewCallbackOptions { + WebviewCallbackOptions { + navigation_intercept: self.navigation.is_some(), + download_start: self.download_start.is_some(), + download_end: self.download_end.is_some(), + title_change: self.title_change.is_some(), + } + } + + fn is_empty(&self) -> bool { + self.navigation.is_none() + && self.download_start.is_none() + && self.download_end.is_none() + && self.title_change.is_none() + } +} + +static CALLBACKS: LazyLock>> = + LazyLock::new(|| RwLock::new(BTreeMap::new())); + +/// Builder for Rust-owned WebView lifecycle and platform callbacks. +/// +/// Build this before WebviewClient::create. Calling build for the same tag replaces the previous +/// declaration and callback declarations remain valid across a remove and create cycle. +#[derive(Default)] +pub struct WebviewCallbacksBuilder { + webview_id: String, + callbacks: WebviewCallbacks, +} + +impl WebviewCallbacksBuilder { + pub fn new(webview_id: impl Into) -> Self { + Self { + webview_id: webview_id.into(), + callbacks: WebviewCallbacks::default(), + } + } + + /// Decides whether ArkWeb should intercept a navigation. The callback runs synchronously on + /// the active N-API main-thread callback and must return promptly. + pub fn on_navigation_request(mut self, callback: F) -> Self + where + F: Fn(WebviewNavigationRequest) -> bool + Send + Sync + 'static, + { + self.callbacks.navigation = Some(Arc::new(callback)); + self + } + + /// Admits, cancels, or redirects a download before ArkWeb starts it. The callback runs + /// synchronously on the active N-API main-thread callback and must return promptly. + pub fn on_download_start(mut self, callback: F) -> Self + where + F: Fn(WebviewDownloadStartRequest) -> WebviewDownloadStartResponse + Send + Sync + 'static, + { + self.callbacks.download_start = Some(Arc::new(callback)); + self + } + + /// Receives successful and failed download completion notifications on the active N-API + /// callback. It has no platform decision response, but should still return promptly. + pub fn on_download_end(mut self, callback: F) -> Self + where + F: Fn(WebviewDownloadEndEvent) + Send + Sync + 'static, + { + self.callbacks.download_end = Some(Arc::new(callback)); + self + } + + /// Receives page title updates on the active N-API callback and should return promptly. + pub fn on_title_change(mut self, callback: F) -> Self + where + F: Fn(WebviewTitleChangeEvent) + Send + Sync + 'static, + { + self.callbacks.title_change = Some(Arc::new(callback)); + self + } + + pub fn build(self) -> Result<()> { + if self.webview_id.trim().is_empty() { + return Err(Error::from_reason("WebView callback id must not be empty")); + } + if self.callbacks.is_empty() { + return Err(Error::from_reason( + "WebView callbacks must declare at least one callback", + )); + } + CALLBACKS + .write() + .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? + .insert(self.webview_id, self.callbacks); + Ok(()) + } +} + +pub(crate) fn options_for(webview_id: &str) -> Result { + let callbacks = CALLBACKS + .read() + .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))?; + Ok(callbacks + .get(webview_id) + .map(WebviewCallbacks::options) + .unwrap_or_default()) +} + +pub(crate) fn navigation_decision( + request: WebviewNavigationRequest, +) -> Result { + let callback = CALLBACKS + .read() + .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? + .get(&request.id) + .and_then(|callbacks| callbacks.navigation.as_ref()) + .cloned(); + Ok(WebviewNavigationResponse { + intercept: callback.map(|callback| callback(request)).unwrap_or(false), + }) +} + +pub(crate) fn download_start_decision( + request: WebviewDownloadStartRequest, +) -> Result { + let callback = CALLBACKS + .read() + .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? + .get(&request.id) + .and_then(|callbacks| callbacks.download_start.as_ref()) + .cloned(); + Ok(callback + .map(|callback| callback(request)) + .unwrap_or_else(WebviewDownloadStartResponse::cancel)) +} + +pub(crate) fn dispatch_download_end(event: WebviewDownloadEndEvent) -> Result<()> { + let callback = CALLBACKS + .read() + .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? + .get(&event.id) + .and_then(|callbacks| callbacks.download_end.as_ref()) + .cloned(); + if let Some(callback) = callback { + callback(event); + } + Ok(()) +} + +pub(crate) fn dispatch_title_change(event: WebviewTitleChangeEvent) -> Result<()> { + let callback = CALLBACKS + .read() + .map_err(|_| Error::from_reason("Failed to lock WebView callback registry"))? + .get(&event.id) + .and_then(|callbacks| callbacks.title_change.as_ref()) + .cloned(); + if let Some(callback) = callback { + callback(event); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::WebviewCallbacksBuilder; + + #[test] + fn callback_builder_rejects_an_empty_declaration() { + assert!(WebviewCallbacksBuilder::new("webview").build().is_err()); + } +} diff --git a/crates/plugin-webview/src/js_proxy.rs b/crates/plugin-webview/src/js_proxy.rs new file mode 100644 index 00000000..834a8d7f --- /dev/null +++ b/crates/plugin-webview/src/js_proxy.rs @@ -0,0 +1,202 @@ +//! 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 +//! 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}, + sync::{Arc, LazyLock, Mutex}, +}; + +use napi_ohos::{Error, Result}; +use ohos_web_binding::WebProxyBuilder as ArkWebProxyBuilder; + +type ProxyCallback = Arc) + Send + 'static>>>; + +#[derive(Clone)] +struct ProxyMethod { + name: String, + callback: ProxyCallback, +} + +#[derive(Clone)] +struct ProxyDeclaration { + webview_id: String, + object_name: String, + methods: Vec, +} + +#[derive(Default)] +struct ProxyState { + attached_webviews: BTreeSet, + declarations: BTreeMap>, +} + +static PROXY_STATE: LazyLock> = + LazyLock::new(|| Mutex::new(ProxyState::default())); + +/// 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. +pub struct WebviewJavascriptProxyBuilder { + webview_id: String, + object_name: String, + methods: Vec, +} + +impl WebviewJavascriptProxyBuilder { + pub fn new(webview_id: impl Into, object_name: impl Into) -> Self { + Self { + webview_id: webview_id.into(), + object_name: object_name.into(), + methods: Vec::new(), + } + } + + /// Adds a `window..(...args)` callback. + pub fn add_method(mut self, method_name: impl Into, callback: F) -> Self + where + F: FnMut(String, Vec) + Send + 'static, + { + self.methods.push(ProxyMethod { + name: method_name.into(), + callback: Arc::new(Mutex::new(Box::new(callback))), + }); + self + } + + /// 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. + 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); + state + .declarations + .entry(declaration.webview_id.clone()) + .or_default() + .push(declaration.clone()); + attached.then_some(declaration) + }; + if let Some(declaration) = declaration_to_install { + install(declaration, true)?; + } + Ok(()) + } + + fn into_declaration(self) -> Result { + if self.webview_id.trim().is_empty() { + return Err(Error::from_reason( + "WebView JavaScript proxy id must not be empty", + )); + } + if self.object_name.trim().is_empty() { + return Err(Error::from_reason( + "WebView JavaScript proxy object name must not be empty", + )); + } + if self.methods.is_empty() + || self + .methods + .iter() + .any(|method| method.name.trim().is_empty()) + { + return Err(Error::from_reason( + "WebView JavaScript proxy must declare one or more non-empty method names", + )); + } + Ok(ProxyDeclaration { + webview_id: self.webview_id, + object_name: self.object_name, + methods: self.methods, + }) + } +} + +/// Flushes queued page-to-Rust proxies once ArkTS has attached the native controller. +pub(crate) fn on_controller_attached(webview_id: &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()) { + return Ok(()); + } + state + .declarations + .get(webview_id) + .cloned() + .unwrap_or_default() + }; + + for declaration in declarations { + install(declaration, 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<()> { + let mut state = PROXY_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to lock WebView JavaScript proxy state"))?; + 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); + for method in declaration.methods { + let callback = Arc::clone(&method.callback); + builder = builder.add_method(method.name, move |webview_id, arguments| { + if let Ok(mut callback) = callback.lock() { + callback(webview_id, arguments); + } + }); + } + + let proxy = builder.build().map_err(|error| { + Error::from_reason(format!( + "Failed to register WebView JavaScript proxy: {error}" + )) + })?; + if refresh_after_install { + proxy.refresh().map_err(|error| { + Error::from_reason(format!( + "Failed to refresh WebView after JavaScript proxy registration: {error}" + )) + })?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::WebviewJavascriptProxyBuilder; + + #[test] + fn proxy_builder_rejects_invalid_declarations_before_touching_arkweb() { + assert!(WebviewJavascriptProxyBuilder::new("", "native") + .add_method("echo", |_tag, _arguments| {}) + .build() + .is_err()); + assert!(WebviewJavascriptProxyBuilder::new("web", "") + .add_method("echo", |_tag, _arguments| {}) + .build() + .is_err()); + assert!(WebviewJavascriptProxyBuilder::new("web", "native") + .build() + .is_err()); + } +} diff --git a/crates/plugin-webview/src/lib.rs b/crates/plugin-webview/src/lib.rs new file mode 100644 index 00000000..f933d45b --- /dev/null +++ b/crates/plugin-webview/src/lib.rs @@ -0,0 +1,889 @@ +//! WebView plugin facade. +//! +//! The plugin is intentionally named-N-API-value/controller-ID based. Rust never keeps an ArkTS +//! `WebviewController` or `ObjectRef`; the ArkTS HAR mounts its own `FrameNode` into a generic +//! `BridgeNodeSlot`. + +use std::collections::BTreeMap; + +use napi_derive_ohos::napi; +use napi_ohos::{bindgen_prelude::Unknown, Either, Error, Result}; +use ohos_web_binding::Web; +use openharmony_ability::{ + impl_bridge_napi_type, AsyncBridge, BridgeCallOptions, BridgeContextRequirement, + BridgeMainThreadEvent, BridgeNapiType, BridgePlugin, BridgeRuntime, OpenHarmonyApp, +}; + +mod callbacks; +mod js_proxy; +mod protocol; + +pub use callbacks::WebviewCallbacksBuilder; +pub use js_proxy::WebviewJavascriptProxyBuilder; +pub use protocol::{ + bind_custom_protocol, bind_custom_protocol_async, WebviewProtocol, WebviewProtocolOptions, + WebviewProtocolRequest, WebviewProtocolResponder, WebviewProtocolResponse, +}; + +pub const DEFAULT_WEBVIEW_SLOT: &str = "xcomponent-overlay"; + +pub struct WebviewBridgePlugin; + +impl BridgePlugin for WebviewBridgePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "ohos.webview"; + const VERSION: u32 = 1; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::UiContext]; + + 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", + )?; + WebviewProtocol::flush_before_engine_init()?; + event.respond(WebviewEventAcknowledgement { accepted: true }) + } + "engine-initialized" => { + expect_engine_phase( + event.decode::()?, + "engine-initialized", + )?; + WebviewProtocol::mark_engine_initialized()?; + event.respond(WebviewEventAcknowledgement { accepted: true }) + } + "controller-attached" => { + let controller = event.decode::()?; + let webview_id = webview_id_from_controller_event(controller)?; + // 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)?; + 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)?; + event.respond(WebviewEventAcknowledgement { accepted: true }) + } + "navigation-request" => { + let request = event.decode::()?; + event.respond(callbacks::navigation_decision(request)?) + } + "download-start" => { + let request = event.decode::()?; + event.respond(callbacks::download_start_decision(request)?) + } + "download-end" => { + let notification = event.decode::()?; + callbacks::dispatch_download_end(notification)?; + event.respond(WebviewEventAcknowledgement { accepted: true }) + } + "title-change" => { + let notification = event.decode::()?; + callbacks::dispatch_title_change(notification)?; + event.respond(WebviewEventAcknowledgement { accepted: true }) + } + _ => Err(Error::from_reason(format!( + "Unsupported ohos.webview main-thread event '{}'", + event.name() + ))), + } + } +} + +fn expect_engine_phase(event: WebviewEngineLifecycleEvent, expected: &str) -> Result<()> { + if event.phase == expected { + Ok(()) + } else { + Err(Error::from_reason(format!( + "Invalid WebView engine lifecycle phase '{}', expected '{expected}'", + event.phase + ))) + } +} + +fn webview_id_from_controller_event(event: WebviewControllerEvent) -> Result { + if event.id.trim().is_empty() { + return Err(Error::from_reason( + "WebView controller event id must not be empty", + )); + } + Ok(event.id) +} + +#[napi(object)] +#[derive(Clone, Debug, Default)] +pub struct WebviewStyle { + pub x: Option>, + pub y: Option>, + pub visible: Option, + pub background_color: Option, +} + +/// Engine lifecycle signal delivered directly from the ArkTS WebView host. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewEngineLifecycleEvent { + pub phase: String, +} + +impl_bridge_napi_type!( + WebviewEngineLifecycleEvent, + "ohos.webview.EngineLifecycleEvent" +); + +/// Controller lifecycle signal delivered directly from the ArkTS WebView host. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewControllerEvent { + pub id: String, +} + +impl_bridge_napi_type!(WebviewControllerEvent, "ohos.webview.ControllerEvent"); + +/// 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. +#[napi(object)] +#[derive(Clone, Debug, Default)] +pub struct WebviewCallbackOptions { + pub navigation_intercept: bool, + pub download_start: bool, + pub download_end: bool, + pub title_change: bool, +} + +/// A document-start script and the URL rules for pages where ArkWeb may inject it. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewInitializationScript { + pub script: String, + pub script_rules: Vec, +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewCreateRequest { + pub id: String, + pub slot_id: String, + pub url: Option, + pub html: Option, + pub style: WebviewStyle, + pub javascript_enabled: Option, + pub devtools: Option, + pub user_agent: Option, + pub autoplay: Option, + pub initialization_scripts: Option>, + pub headers: Option>, + /// Restores the legacy creation-time transparent-background policy. An explicit style + /// background color takes precedence. + pub transparent: Option, + #[doc(hidden)] + pub event_options: WebviewCallbackOptions, +} + +impl_bridge_napi_type!(WebviewCreateRequest, "ohos.webview.CreateRequest"); + +impl WebviewCreateRequest { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + slot_id: DEFAULT_WEBVIEW_SLOT.to_owned(), + url: None, + html: None, + style: WebviewStyle::default(), + javascript_enabled: None, + devtools: None, + user_agent: None, + autoplay: None, + initialization_scripts: None, + headers: None, + transparent: None, + event_options: WebviewCallbackOptions::default(), + } + } + + pub fn slot_id(mut self, slot_id: impl Into) -> Self { + self.slot_id = slot_id.into(); + self + } + + pub fn url(mut self, url: impl Into) -> Self { + self.url = Some(url.into()); + self + } + + pub fn html(mut self, html: impl Into) -> Self { + self.html = Some(html.into()); + self + } + + pub fn style(mut self, style: WebviewStyle) -> Self { + self.style = style; + self + } + + /// Uses a transparent background when no explicit style background color was supplied. + pub fn transparent(mut self, transparent: bool) -> Self { + self.transparent = Some(transparent); + self + } + + pub fn initialization_scripts( + mut self, + scripts: impl IntoIterator, + ) -> Self { + self.initialization_scripts = Some(scripts.into_iter().collect()); + self + } + + fn validate(&self) -> Result<()> { + if self.id.trim().is_empty() { + return Err(Error::from_reason("WebView id must not be empty")); + } + if self.slot_id.trim().is_empty() { + return Err(Error::from_reason("WebView slotId 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", + )); + } + Ok(()) + } +} + +/// Request delivered synchronously when ArkWeb asks whether a navigation should be intercepted. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewNavigationRequest { + pub id: String, + pub url: String, +} + +impl_bridge_napi_type!(WebviewNavigationRequest, "ohos.webview.NavigationRequest"); + +/// Synchronous navigation decision. A true value retains ArkWeb's existing intercept semantics. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewNavigationResponse { + pub intercept: bool, +} + +impl_bridge_napi_type!(WebviewNavigationResponse, "ohos.webview.NavigationResponse"); + +/// Request delivered synchronously before ArkWeb starts a download. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewDownloadStartRequest { + pub id: String, + pub url: String, + pub temp_path: Option, +} + +impl_bridge_napi_type!( + WebviewDownloadStartRequest, + "ohos.webview.DownloadStartRequest" +); + +/// Immediate download admission and optional replacement destination. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewDownloadStartResponse { + pub allow: bool, + pub temp_path: Option, +} + +impl_bridge_napi_type!( + WebviewDownloadStartResponse, + "ohos.webview.DownloadStartResponse" +); + +impl WebviewDownloadStartResponse { + pub fn allow(temp_path: Option) -> Self { + Self { + allow: true, + temp_path, + } + } + + pub fn cancel() -> Self { + Self { + allow: false, + temp_path: None, + } + } +} + +/// Completion notification delivered directly through a named N-API callback. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewDownloadEndEvent { + pub id: String, + pub url: String, + pub temp_path: Option, + pub success: bool, +} + +/// Title-change notification delivered directly through a named N-API callback. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewTitleChangeEvent { + pub id: String, + pub title: String, +} + +impl_bridge_napi_type!(WebviewDownloadEndEvent, "ohos.webview.DownloadEndEvent"); +impl_bridge_napi_type!(WebviewTitleChangeEvent, "ohos.webview.TitleChangeEvent"); + +/// Response used by one-way named N-API notifications sent directly from ArkTS. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewEventAcknowledgement { + pub accepted: bool, +} + +impl_bridge_napi_type!( + WebviewEventAcknowledgement, + "ohos.webview.EventAcknowledgement" +); + +#[derive(Clone)] +pub struct WebviewClient { + bridge: BridgeRuntime, +} + +impl WebviewClient { + pub fn new(app: &OpenHarmonyApp) -> Result { + Ok(Self { + bridge: app.bridge()?, + }) + } + + pub async fn create(&self, mut request: WebviewCreateRequest) -> Result { + request.validate()?; + // Callback declarations live solely in Rust. Snapshot their event subscriptions into the + // named create request so ArkTS can bind ArkWeb hooks without retaining Rust closures. + request.event_options = callbacks::options_for(&request.id)?; + let request_id = request.id.clone(); + let request_slot_id = request.slot_id.clone(); + let response: WebviewCreateResponse = self.call("create", request).await?; + if response.id != request_id || response.slot_id != request_slot_id { + return Err(Error::from_reason( + "WebView plugin returned a mismatched controller ID", + )); + } + Ok(WebviewHandle { + client: self.clone(), + id: response.id, + slot_id: response.slot_id, + }) + } + + /// Reopens a controller-ID facade for a WebView already created in this module/session. + pub fn handle(&self, id: impl Into, slot_id: impl Into) -> WebviewHandle { + WebviewHandle { + client: self.clone(), + id: id.into(), + slot_id: slot_id.into(), + } + } + + /// Declares a custom-scheme handler by WebView tag 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 + /// scheme. + pub fn custom_protocol( + &self, + webview_id: impl Into, + scheme: S, + callback: F, + ) -> Result<()> + where + S: AsRef, + F: Fn(&str, WebviewProtocolRequest, bool) -> Option + + Send + + Sync + + 'static, + { + bind_custom_protocol(webview_id, scheme, callback) + } + + /// Async custom-scheme handler variant. The responder may be moved to a Rust worker. + pub fn custom_protocol_async( + &self, + webview_id: impl Into, + scheme: S, + callback: F, + ) -> Result<()> + where + S: AsRef, + F: Fn(&str, WebviewProtocolRequest, bool, WebviewProtocolResponder) + Send + Sync + 'static, + { + bind_custom_protocol_async(webview_id, scheme, callback) + } + + async fn call(&self, action: &str, request: Request) -> Result + where + Request: BridgeNapiType, + Response: BridgeNapiType, + { + self.bridge + .call_async::( + action, + request, + BridgeCallOptions::default(), + ) + .await + } +} + +pub trait WebviewExt { + fn webview(&self) -> Result; +} + +impl WebviewExt for OpenHarmonyApp { + fn webview(&self) -> Result { + WebviewClient::new(self) + } +} + +#[derive(Clone)] +pub struct WebviewHandle { + client: WebviewClient, + id: String, + slot_id: String, +} + +impl WebviewHandle { + pub fn id(&self) -> &str { + &self.id + } + + pub fn slot_id(&self) -> &str { + &self.slot_id + } + + fn controller_request(&self) -> WebviewControllerRequest { + WebviewControllerRequest { + id: self.id.clone(), + slot_id: self.slot_id.clone(), + visible: None, + color: None, + url: None, + html: None, + headers: None, + zoom: None, + } + } + + async fn acknowledge(&self, action: &str, request: WebviewControllerRequest) -> Result<()> { + self.client + .call::<_, WebviewAcknowledgement>(action, request) + .await? + .ensure() + } + + async fn string_value( + &self, + action: &str, + request: WebviewControllerRequest, + ) -> Result> { + Ok(self + .client + .call::<_, WebviewStringResponse>(action, request) + .await? + .value) + } + + /// Declares a handler for this controller tag. 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 + S: AsRef, + F: Fn(&str, WebviewProtocolRequest, bool) -> Option + + Send + + Sync + + 'static, + { + self.client.custom_protocol(&self.id, scheme, callback) + } + + pub fn custom_protocol_async(&self, scheme: S, callback: F) -> Result<()> + where + S: AsRef, + F: Fn(&str, WebviewProtocolRequest, bool, WebviewProtocolResponder) + Send + Sync + 'static, + { + self.client + .custom_protocol_async(&self.id, scheme, callback) + } + + /// Registers a native ArkWeb controller-attached callback for this WebView tag. + pub fn on_controller_attach(&self, callback: F) -> Result<()> + where + F: FnMut() + 'static, + { + Web::new(self.id.clone()) + .on_controller_attach(callback) + .map_err(|error| { + Error::from_reason(format!( + "Failed to register WebView controller callback: {error}" + )) + }) + } + + pub fn on_page_begin(&self, callback: F) -> Result<()> + where + F: FnMut() + 'static, + { + Web::new(self.id.clone()) + .on_page_begin(callback) + .map_err(|error| { + Error::from_reason(format!( + "Failed to register WebView page-begin callback: {error}" + )) + }) + } + + pub fn on_page_end(&self, callback: F) -> Result<()> + where + F: FnMut() + 'static, + { + Web::new(self.id.clone()) + .on_page_end(callback) + .map_err(|error| { + Error::from_reason(format!( + "Failed to register WebView page-end callback: {error}" + )) + }) + } + + pub fn on_destroy(&self, callback: F) -> Result<()> + where + F: FnMut() + 'static, + { + Web::new(self.id.clone()) + .on_destroy(callback) + .map_err(|error| { + Error::from_reason(format!( + "Failed to register WebView destroy callback: {error}" + )) + }) + } + + pub async fn set_visible(&self, visible: bool) -> Result<()> { + self.acknowledge( + "set-visible", + WebviewControllerRequest { + visible: Some(visible), + ..self.controller_request() + }, + ) + .await + } + + pub async fn set_background_color(&self, color: impl Into) -> Result<()> { + self.acknowledge( + "set-background-color", + WebviewControllerRequest { + color: Some(color.into()), + ..self.controller_request() + }, + ) + .await + } + + pub async fn remove(&self) -> Result<()> { + self.acknowledge("remove", self.controller_request()).await + } + + /// Returns the URL currently owned by this WebView controller. + pub async fn url(&self) -> Result { + self.string_value("get-url", self.controller_request()) + .await? + .ok_or_else(|| Error::from_reason("WebView controller returned no current URL")) + } + + pub async fn load_url(&self, url: impl Into) -> Result<()> { + self.acknowledge( + "load-url", + WebviewControllerRequest { + url: Some(url.into()), + ..self.controller_request() + }, + ) + .await + } + + pub async fn load_url_with_headers( + &self, + url: impl Into, + headers: BTreeMap, + ) -> Result<()> { + self.acknowledge( + "load-url", + WebviewControllerRequest { + url: Some(url.into()), + headers: Some(headers), + ..self.controller_request() + }, + ) + .await + } + + pub async fn load_html(&self, html: impl Into) -> Result<()> { + self.acknowledge( + "load-html", + WebviewControllerRequest { + html: Some(html.into()), + ..self.controller_request() + }, + ) + .await + } + + pub async fn set_zoom(&self, zoom: f64) -> Result<()> { + if !zoom.is_finite() { + return Err(Error::from_reason("WebView zoom must be finite")); + } + self.acknowledge( + "set-zoom", + WebviewControllerRequest { + zoom: Some(zoom), + ..self.controller_request() + }, + ) + .await + } + + pub async fn reload(&self) -> Result<()> { + self.acknowledge("reload", self.controller_request()).await + } + + pub async fn focus(&self) -> Result<()> { + self.acknowledge("focus", self.controller_request()).await + } + + pub async fn cookies_with_url(&self, url: impl Into) -> Result { + self.string_value( + "cookies-with-url", + WebviewControllerRequest { + url: Some(url.into()), + ..self.controller_request() + }, + ) + .await? + .ok_or_else(|| Error::from_reason("WebView cookie manager returned no cookie string")) + } + + pub async fn clear_all_browsing_data(&self) -> Result<()> { + self.acknowledge("clear-all-browsing-data", self.controller_request()) + .await + } + + /// Named replacement for the old `dispose` controller operation. + pub async fn dispose(&self) -> Result<()> { + self.remove().await + } + + /// Evaluates JavaScript in the currently loaded page and returns its string result. + /// + /// This is asynchronous because ArkWeb runs `runJavaScript` and its completion callback on + /// the UI thread. `None` is the platform representation of a script with no return value. + pub async fn evaluate_script(&self, script: impl Into) -> Result> { + let response = self + .client + .call::<_, WebviewScriptResponse>( + "evaluate-script", + WebviewScriptRequest { + id: self.id.clone(), + slot_id: self.slot_id.clone(), + script: script.into(), + }, + ) + .await?; + Ok(response.result) + } + + /// Callback-shaped convenience for callers migrating from the former controller API. + pub async fn evaluate_script_with_callback( + &self, + script: impl Into, + callback: F, + ) -> Result<()> + where + F: FnOnce(Option) + Send, + { + callback(self.evaluate_script(script).await?); + Ok(()) + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewCreateResponse { + pub id: String, + pub slot_id: String, +} + +impl_bridge_napi_type!(WebviewCreateResponse, "ohos.webview.CreateResponse"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewScriptRequest { + pub id: String, + pub slot_id: String, + pub script: String, +} + +impl_bridge_napi_type!(WebviewScriptRequest, "ohos.webview.ScriptRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewScriptResponse { + pub result: Option, +} + +impl_bridge_napi_type!(WebviewScriptResponse, "ohos.webview.ScriptResponse"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewControllerRequest { + pub id: String, + pub slot_id: String, + pub visible: Option, + pub color: Option, + pub url: Option, + pub html: Option, + pub headers: Option>, + pub zoom: Option, +} + +impl_bridge_napi_type!(WebviewControllerRequest, "ohos.webview.ControllerRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewStringResponse { + pub value: Option, +} + +impl_bridge_napi_type!(WebviewStringResponse, "ohos.webview.StringResponse"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewAcknowledgement { + pub accepted: bool, +} + +impl_bridge_napi_type!(WebviewAcknowledgement, "ohos.webview.Acknowledgement"); + +impl WebviewAcknowledgement { + fn ensure(self) -> Result<()> { + if self.accepted { + Ok(()) + } else { + Err(Error::from_reason( + "WebView plugin rejected the requested operation", + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openharmony_ability::BridgeNapiType; + + #[test] + fn create_request_retains_optional_value_semantics() { + let request = WebviewCreateRequest::new("webview") + .transparent(true) + .url("https://example.test"); + assert_eq!(request.id, "webview"); + assert_eq!(request.slot_id, DEFAULT_WEBVIEW_SLOT); + assert_eq!(request.url.as_deref(), Some("https://example.test")); + assert!(request.html.is_none()); + assert!(request.headers.is_none()); + assert_eq!(request.transparent, Some(true)); + } + + #[test] + fn webview_actions_have_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.webview.CreateRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.CreateResponse" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.ControllerRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.Acknowledgement" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.ScriptRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.ScriptResponse" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.StringResponse" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.EngineLifecycleEvent" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.ControllerEvent" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.NavigationRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.NavigationResponse" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.DownloadStartRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.DownloadStartResponse" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.DownloadEndEvent" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.TitleChangeEvent" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.webview.EventAcknowledgement" + ); + } +} diff --git a/crates/plugin-webview/src/protocol.rs b/crates/plugin-webview/src/protocol.rs new file mode 100644 index 00000000..119ef6c3 --- /dev/null +++ b/crates/plugin-webview/src/protocol.rs @@ -0,0 +1,477 @@ +//! Native custom-protocol support owned by the WebView capability crate. +//! +//! Scheme declarations are collected during the `#[ability]` initializer. `WebviewBridgePlugin` +//! flushes them immediately before ArkTS initializes the Web engine, which keeps the core bridge +//! unaware of WebView-specific global state. + +use std::{ + borrow::Cow, + collections::{BTreeMap, BTreeSet}, + sync::{Arc, LazyLock, Mutex}, +}; + +use http::{HeaderName, HeaderValue, Request, Response}; +use napi_ohos::{Error, Result}; +use ohos_web_binding::{ArkWebResponse, CustomProtocol, CustomProtocolHandler, Web}; + +type NativeResponse = Response>; +type NativeRequest = Request>; +type Responder = Box; +type ProtocolCallback = Arc< + dyn Fn(&str, WebviewProtocolRequest, bool, WebviewProtocolResponder) + Send + Sync + 'static, +>; + +#[derive(Clone)] +struct ProtocolDeclaration { + scheme: String, + callback: ProtocolCallback, +} + +#[derive(Default)] +struct ProtocolState { + schemes: BTreeMap, + flushed: bool, + engine_initialized: bool, + /// Rust-owned declarations survive a controller remove/create cycle for the same WebView tag. + 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. + installing_schemes: BTreeMap>, + installed_schemes: BTreeMap>, +} + +static PROTOCOL_STATE: LazyLock> = + LazyLock::new(|| Mutex::new(ProtocolState::default())); + +/// ArkWeb scheme flags re-exported under the WebView plugin API. +pub use ohos_web_binding::CustomProtocolOption as WebviewProtocolOptions; + +/// Registers a named custom-scheme declaration before ArkTS initializes the Web engine. +/// +/// Call [`Self::register`] in the `#[ability]` initializer, before the `WebviewBridgePlugin` is +/// registered. The plugin flushes declarations at its `before-engine-init` event. Registering a +/// new scheme after the engine has started is rejected deterministically. +pub struct WebviewProtocol; + +impl WebviewProtocol { + pub fn register(scheme: impl AsRef, options: WebviewProtocolOptions) -> Result<()> { + let scheme = scheme.as_ref(); + validate_scheme(scheme)?; + + 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" + ))); + } + return Ok(()); + } + + CustomProtocol::add_protocol_with_option(scheme, options); + state.schemes.insert(scheme.to_owned(), options_bits); + Ok(()) + } + + pub(crate) fn flush_before_engine_init() -> Result<()> { + let mut state = PROTOCOL_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; + if state.engine_initialized || state.flushed { + return Ok(()); + } + CustomProtocol::register(); + state.flushed = true; + Ok(()) + } + + pub(crate) fn mark_engine_initialized() -> 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.engine_initialized = true; + Ok(()) + } + + fn require_declared(scheme: &str) -> Result<()> { + validate_scheme(scheme)?; + let state = PROTOCOL_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; + if !state.schemes.contains_key(scheme) { + return Err(Error::from_reason(format!( + "WebView scheme '{scheme}' was not declared with WebviewProtocol::register" + ))); + } + Ok(()) + } +} + +/// An HTTP-style request delivered for a custom WebView scheme. +pub type WebviewProtocolRequest = NativeRequest; + +/// An HTTP-style response consumed by a custom WebView scheme. +pub type WebviewProtocolResponse = NativeResponse; + +/// One-shot responder for a custom-scheme request. +/// +/// It is `Send` so an async application handler may resolve it on its own worker. ArkWeb owns the +/// underlying handle until the response is delivered. +pub struct WebviewProtocolResponder { + responder: Responder, +} + +unsafe impl Send for WebviewProtocolResponder {} + +impl WebviewProtocolResponder { + pub fn respond>>(self, response: Response) { + let (parts, body) = response.into_parts(); + (self.responder)(Response::from_parts(parts, body.into())); + } +} + +/// Declares a custom-scheme handler for a WebView tag. +/// +/// 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 +/// and before the plugin performs the initial navigation. This keeps the first custom-scheme load +/// race-free without retaining an ArkTS object or calling ArkWeb before it is ready. +pub fn bind_custom_protocol( + webview_id: impl Into, + scheme: S, + callback: F, +) -> Result<()> +where + S: AsRef, + F: Fn(&str, WebviewProtocolRequest, bool) -> Option + + Send + + Sync + + 'static, +{ + bind_custom_protocol_async( + webview_id, + scheme, + move |url, request, is_main_frame, responder| { + if let Some(response) = callback(url, request, is_main_frame) { + responder.respond(response); + } + }, + ) +} + +/// Asynchronous custom-scheme variant. The handler owns the responder and may resolve it later. +pub fn bind_custom_protocol_async( + webview_id: impl Into, + scheme: S, + callback: F, +) -> Result<()> +where + S: AsRef, + F: Fn(&str, WebviewProtocolRequest, bool, WebviewProtocolResponder) + Send + Sync + 'static, +{ + let webview_id = webview_id.into(); + let scheme = scheme.as_ref().to_owned(); + validate_webview_id(&webview_id)?; + WebviewProtocol::require_declared(&scheme)?; + + let declaration = ProtocolDeclaration { + scheme: scheme.clone(), + callback: Arc::new(callback), + }; + let declaration_to_install = { + let mut state = PROTOCOL_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; + let is_new_declaration = { + let declarations = state.declarations.entry(webview_id.clone()).or_default(); + if declarations.contains_key(&scheme) { + false + } else { + declarations.insert(scheme.clone(), declaration.clone()); + 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 + } + }; + + if let Some(declaration) = declaration_to_install { + install_and_record(&webview_id, declaration)?; + } + Ok(()) +} + +/// Flushes queued handlers for a controller whose ArkTS Web component has attached. +/// +/// 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<()> { + validate_webview_id(webview_id)?; + let declarations = { + 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( + "WebView custom protocol handler cannot attach before Web engine initialization", + )); + } + state.attached_webviews.insert(webview_id.to_owned()); + let declared = state + .declarations + .get(webview_id) + .map(|declarations| declarations.values().cloned().collect::>()) + .unwrap_or_default(); + declared + .into_iter() + .filter(|declaration| reserve_installation(&mut state, webview_id, &declaration.scheme)) + .collect::>() + }; + + for declaration in declarations { + install_and_record(webview_id, 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<()> { + let mut state = PROTOCOL_STATE + .lock() + .map_err(|_| Error::from_reason("Failed to lock WebView protocol state"))?; + state.attached_webviews.remove(webview_id); + state.installing_schemes.remove(webview_id); + state.installed_schemes.remove(webview_id); + Ok(()) +} + +fn reserve_installation(state: &mut ProtocolState, webview_id: &str, scheme: &str) -> bool { + if state + .installed_schemes + .get(webview_id) + .is_some_and(|schemes| schemes.contains(scheme)) + || state + .installing_schemes + .get(webview_id) + .is_some_and(|schemes| schemes.contains(scheme)) + { + return false; + } + state + .installing_schemes + .entry(webview_id.to_owned()) + .or_default() + .insert(scheme.to_owned()) +} + +fn install_and_record(webview_id: &str, declaration: ProtocolDeclaration) -> Result<()> { + let scheme = declaration.scheme.clone(); + match install_declaration(webview_id, declaration) { + Ok(()) => finish_installation(webview_id, &scheme, true), + Err(error) => { + let _ = finish_installation(webview_id, &scheme, false); + Err(error) + } + } +} + +fn finish_installation(webview_id: &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) + .map(|schemes| { + schemes.remove(scheme); + schemes.is_empty() + }) + .unwrap_or(false); + if remove_installing_entry { + state.installing_schemes.remove(webview_id); + } + if installed { + state + .installed_schemes + .entry(webview_id.to_owned()) + .or_default() + .insert(scheme.to_owned()); + } + Ok(()) +} + +fn install_declaration(webview_id: &str, declaration: ProtocolDeclaration) -> Result<()> { + let ProtocolDeclaration { scheme, callback } = declaration; + let handler = CustomProtocolHandler::new(); + handler.on_request_start(move |request, request_handle| { + let url = request.url(); + let method = request.method().as_str().to_owned(); + let headers = request.headers(); + let is_main_frame = request.is_main_frame(); + + if let Some(body) = request.http_body_stream() { + let callback = Arc::clone(&callback); + let mut request_handle = Some(request_handle); + body.read(body.size() as usize, move |body| { + let Some(request_handle) = request_handle.take() else { + return; + }; + let responder = responder_for(request_handle); + match build_request(&method, &url, headers.iter(), body) { + Ok(native_request) => callback(&url, native_request, is_main_frame, responder), + Err(_) => responder.respond(invalid_request_response()), + } + }); + } else { + let responder = responder_for(request_handle); + match build_request(&method, &url, headers.iter(), Vec::new()) { + Ok(native_request) => callback(&url, native_request, is_main_frame, responder), + Err(_) => responder.respond(invalid_request_response()), + } + } + true + }); + + let attached = Web::new(webview_id.to_owned()) + .custom_protocol(scheme, handler) + .map_err(|error| { + Error::from_reason(format!("Failed to bind WebView custom protocol: {error}")) + })?; + if attached { + Ok(()) + } else { + Err(Error::from_reason( + "ArkWeb rejected the custom protocol handler for this WebView tag", + )) + } +} + +fn build_request<'a>( + method: &str, + url: &str, + headers: impl Iterator, + body: Vec, +) -> std::result::Result { + let mut builder = Request::builder().method(method).uri(url); + for (key, value) in headers { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(key.as_bytes()), + HeaderValue::from_bytes(value.as_bytes()), + ) { + builder = builder.header(name, value); + } + } + builder.body(body) +} + +fn invalid_request_response() -> WebviewProtocolResponse { + let mut response = Response::new(Cow::Borrowed(&b"Invalid custom-scheme request"[..])); + *response.status_mut() = http::StatusCode::BAD_REQUEST; + response +} + +fn responder_for(request_handle: ohos_web_binding::ResourceHandle) -> WebviewProtocolResponder { + WebviewProtocolResponder { + responder: Box::new(move |response| { + let (parts, body) = response.into_parts(); + let response = ArkWebResponse::new(); + for (name, value) in &parts.headers { + response.set_header(name.as_str(), value.to_str().unwrap_or_default(), true); + } + response.set_status(parts.status.as_u16() as _); + request_handle.receive_response(response); + request_handle.receive_data(body.as_ref()); + request_handle.finish(); + }), + } +} + +fn validate_scheme(scheme: &str) -> Result<()> { + let mut bytes = scheme.bytes(); + let Some(first) = bytes.next() else { + return Err(Error::from_reason("WebView scheme must not be empty")); + }; + if !first.is_ascii_alphabetic() + || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) + { + return Err(Error::from_reason( + "WebView scheme must start with a letter and contain only ASCII letters, digits, '+', '-' or '.'", + )); + } + Ok(()) +} + +fn validate_webview_id(webview_id: &str) -> Result<()> { + if webview_id.trim().is_empty() { + return Err(Error::from_reason( + "WebView custom protocol id must not be empty", + )); + } + if webview_id.contains('\0') { + return Err(Error::from_reason( + "WebView custom protocol id must not contain a NUL byte", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{reserve_installation, validate_scheme, validate_webview_id, ProtocolState}; + + #[test] + fn protocol_declarations_validate_scheme_and_webview_tag_before_arkweb() { + assert!(validate_scheme("asset+v1").is_ok()); + assert!(validate_scheme("1asset").is_err()); + assert!(validate_webview_id("article-view").is_ok()); + assert!(validate_webview_id(" ").is_err()); + assert!(validate_webview_id("article\0view").is_err()); + } + + #[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")); + + state.installing_schemes.clear(); + state + .installed_schemes + .entry("article".to_owned()) + .or_default() + .insert("asset".to_owned()); + assert!(!reserve_installation(&mut state, "article", "asset")); + } +} diff --git a/crates/plugin-window/Cargo.toml b/crates/plugin-window/Cargo.toml new file mode 100644 index 00000000..9b0e9c50 --- /dev/null +++ b/crates/plugin-window/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openharmony-ability-plugin-window" +version = "0.3.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Window bridge plugin facade for openharmony-ability" + +[dependencies] +openharmony-ability = { workspace = true } +napi-ohos = { workspace = true } +napi-derive-ohos = { workspace = true } diff --git a/crates/plugin-window/README.md b/crates/plugin-window/README.md new file mode 100644 index 00000000..a326955f --- /dev/null +++ b/crates/plugin-window/README.md @@ -0,0 +1,76 @@ +# openharmony-ability-plugin-window + +`openharmony-ability-plugin-window` 是窗口能力的 Rust facade。当前提供同步查询窗口避让区 +(`AvoidArea`) 的能力,对应 ArkTS HAR 为 `@ohos-rs/plugin-window`。 + +## 契约 + +| 项目 | 值 | +| --- | --- | +| Rust crate | `openharmony-ability-plugin-window` | +| ArkTS HAR | `@ohos-rs/plugin-window` | +| 插件 ID / bridge 版本 | `ohos.window` / `1` | +| 执行模式 | 主线程同步:`MainThreadSyncBridge` / `invokeSync` | +| 前置 context | `window-stage` | +| action | `get-avoid-area` | +| request → response | `ohos.window.AvoidAreaRequest` → `ohos.window.AvoidAreaResponse` | + +返回的 `AvoidArea` 包含 `visible` 以及 `left_rect`、`top_rect`、`right_rect`、`bottom_rect` 四个矩形; +迁移旧 helper 时不得只保留其中一部分。 + +## 接入 + +```rust +use openharmony_ability::OpenHarmonyApp; +use openharmony_ability_derive::ability; +use openharmony_ability_plugin_window::WindowBridgePlugin; + +#[ability] +fn configure_ability(app: OpenHarmonyApp) { + app.register_plugin(WindowBridgePlugin) + .expect("window facade must be registered once"); +} +``` + +```ts +import { NativeAbility } from "@ohos-rs/ability"; +import { createWindowPlugin } from "@ohos-rs/plugin-window"; + +export default class EntryAbility extends NativeAbility { + public bridgePlugins = [createWindowPlugin()]; +} +``` + +应用还需在 `oh-package.json5` 中添加 `@ohos-rs/plugin-window`。ArkTS factory 的说明见 +[对应 HAR README](../../plugins/window/README.md)。 + +## Rust 使用方式 + +```rust +use napi_ohos::{Env, Result}; +use openharmony_ability::AvoidAreaType; +use openharmony_ability_plugin_window::WindowExt; + +#[napi] +pub fn keyboard_insets(env: Env) -> Result { + let area = current_app()?.query_avoid_area(&env, AvoidAreaType::Keyboard)?; + Ok(area.bottom_rect.height) +} +``` + +`current_app()` 代表应用在 `#[ability]` 初始化时保存并按需读取的 `OpenHarmonyApp`。 + +`AvoidAreaType` 支持 `System`、`Cutout`、`SystemGesture`、`Keyboard`、`NavigationIndicator` 和 +`Unknown(i32)`。实际含义由 HarmonyOS `window.AvoidAreaType` 决定。 + +## 调用限制 + +- 查询是同步主线程调用:`Env` 必须来自当前导出的 N-API callback,不能从 worker 保存后使用。 +- `window-stage` 必须已经建立。应在 `NativeAbility.onWindowStageCreate` 之后、且 native bridge 已 render + 后的 callback 中调用;未就绪时同步失败而不是等待 Promise。 +- ArkTS 使用 `context.getWindowStage().getMainWindowSync()` 与 + `getWindowAvoidArea(areaType)`,平台错误会原样变成 bridge 错误。 +- request/response 为具名 N-API object;Rust `area_type` 会映射为 ArkTS `areaType`,不使用 JSON。 + +完整的线程、生命周期和契约变更要求见 +[插件开发规范](../../docs/plugin-development-standard.md)。 diff --git a/crates/plugin-window/src/lib.rs b/crates/plugin-window/src/lib.rs new file mode 100644 index 00000000..0f6e629b --- /dev/null +++ b/crates/plugin-window/src/lib.rs @@ -0,0 +1,185 @@ +//! Main-thread window plugin facade. + +use napi_derive_ohos::napi; +use napi_ohos::{Env, Result}; +use openharmony_ability::{ + impl_bridge_napi_type, AvoidArea, AvoidAreaType, BridgeContextRequirement, BridgePlugin, + MainThreadSyncBridge, OpenHarmonyApp, Rect, +}; + +pub struct WindowBridgePlugin; + +impl BridgePlugin for WindowBridgePlugin { + type Mode = MainThreadSyncBridge; + + const ID: &'static str = "ohos.window"; + const VERSION: u32 = 1; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::WindowStage]; +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct AvoidAreaRequest { + pub area_type: i32, +} + +impl_bridge_napi_type!(AvoidAreaRequest, "ohos.window.AvoidAreaRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct AvoidAreaResponse { + pub area: RawAvoidArea, +} + +impl_bridge_napi_type!(AvoidAreaResponse, "ohos.window.AvoidAreaResponse"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct RawAvoidArea { + pub visible: bool, + pub left_rect: RawRect, + pub top_rect: RawRect, + pub right_rect: RawRect, + pub bottom_rect: RawRect, +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct RawRect { + pub top: i32, + pub left: i32, + pub width: i32, + pub height: i32, +} + +impl From for Rect { + fn from(rect: RawRect) -> Self { + Self { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + } + } +} + +impl From for AvoidArea { + fn from(area: RawAvoidArea) -> Self { + Self { + visible: area.visible, + left_rect: area.left_rect.into(), + top_rect: area.top_rect.into(), + right_rect: area.right_rect.into(), + bottom_rect: area.bottom_rect.into(), + } + } +} + +/// Queries platform window state synchronously and therefore requires a main-thread N-API `Env`. +pub trait WindowExt { + fn query_avoid_area(&self, env: &Env, area_type: AvoidAreaType) -> Result; +} + +impl WindowExt for OpenHarmonyApp { + fn query_avoid_area(&self, env: &Env, area_type: AvoidAreaType) -> Result { + self.with_main_thread_bridge(env, |bridge| { + let response = bridge + .call_sync::( + "get-avoid-area", + AvoidAreaRequest { + area_type: area_type.into(), + }, + )?; + Ok(response.area.into()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{AvoidAreaRequest, AvoidAreaResponse, RawAvoidArea, RawRect}; + use openharmony_ability::{AvoidArea, BridgeNapiType, Rect}; + + #[test] + fn avoid_area_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.window.AvoidAreaRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.window.AvoidAreaResponse" + ); + } + + #[test] + fn avoid_area_response_keeps_all_rectangles() { + let area: AvoidArea = RawAvoidArea { + visible: true, + left_rect: RawRect { + top: 1, + left: 2, + width: 3, + height: 4, + }, + top_rect: RawRect { + top: 5, + left: 6, + width: 7, + height: 8, + }, + right_rect: RawRect { + top: 9, + left: 10, + width: 11, + height: 12, + }, + bottom_rect: RawRect { + top: 13, + left: 14, + width: 15, + height: 16, + }, + } + .into(); + + assert!(area.visible); + assert_eq!( + area.left_rect, + Rect { + top: 1, + left: 2, + width: 3, + height: 4, + } + ); + assert_eq!( + area.top_rect, + Rect { + top: 5, + left: 6, + width: 7, + height: 8, + } + ); + assert_eq!( + area.right_rect, + Rect { + top: 9, + left: 10, + width: 11, + height: 12, + } + ); + assert_eq!( + area.bottom_rect, + Rect { + top: 13, + left: 14, + width: 15, + height: 16, + } + ); + } +} diff --git a/demo/build-profile.json5 b/demo/build-profile.json5 index 547ba744..df0703f6 100644 --- a/demo/build-profile.json5 +++ b/demo/build-profile.json5 @@ -5,6 +5,7 @@ "name": "default", "signingConfig": "default", "compatibleSdkVersion": "5.0.0(12)", + "targetSdkVersion": "5.0.0(12)", "runtimeOS": "HarmonyOS", "buildOption": { "strictMode": { @@ -22,7 +23,21 @@ "name": "release", }, ], - "signingConfigs": [], + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "/Users/ranger/.ohos/config/default_demo_yyjiCVAYks5mJoCjAMZ-6HF1H5m1uKXWecIn5uDJwdE=.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001B165E7B106C3365DE78329D65754C69AF5D15D34F5AD953726B117AAB736E515277624D3A407C3E", + "profile": "/Users/ranger/.ohos/config/default_demo_yyjiCVAYks5mJoCjAMZ-6HF1H5m1uKXWecIn5uDJwdE=.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "/Users/ranger/.ohos/config/default_demo_yyjiCVAYks5mJoCjAMZ-6HF1H5m1uKXWecIn5uDJwdE=.p12", + "storePassword": "0000001BB2469C4F096DEE4DF743016517039A9F42DAE8A1D49512630DD230FC4820EA60C5D43BC99D3139", + }, + }, + ], }, "modules": [ { @@ -39,5 +54,21 @@ "name": "native_ability", "srcPath": "../native_ability", }, + { + "name": "plugin_app_control", + "srcPath": "../plugins/app-control", + }, + { + "name": "plugin_permission", + "srcPath": "../plugins/permission", + }, + { + "name": "plugin_webview", + "srcPath": "../plugins/webview", + }, + { + "name": "plugin_window", + "srcPath": "../plugins/window", + }, ], } diff --git a/demo/entry/oh-package-lock.json5 b/demo/entry/oh-package-lock.json5 index ab2507ae..0d356e61 100644 --- a/demo/entry/oh-package-lock.json5 +++ b/demo/entry/oh-package-lock.json5 @@ -7,44 +7,77 @@ "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { "@ohos-rs/ability@../../native_ability": "@ohos-rs/ability@../../native_ability", - "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability", - "libentry.so@src/main/cpp/types/libentry": "libentry.so@src/main/cpp/types/libentry", + "@ohos-rs/plugin-app-control@../../plugins/app-control": "@ohos-rs/plugin-app-control@../../plugins/app-control", + "@ohos-rs/plugin-permission@../../plugins/permission": "@ohos-rs/plugin-permission@../../plugins/permission", + "@ohos-rs/plugin-webview@../../plugins/webview": "@ohos-rs/plugin-webview@../../plugins/webview", + "@ohos-rs/plugin-window@../../plugins/window": "@ohos-rs/plugin-window@../../plugins/window", "libdemo_native.so@src/main/cpp/types/libdemo_native": "libdemo_native.so@src/main/cpp/types/libdemo_native", - "libark_web.so@src/main/cpp/types/libark_web": "libark_web.so@src/main/cpp/types/libark_web" + "libentry.so@src/main/cpp/types/libentry": "libentry.so@src/main/cpp/types/libentry", + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability" }, "packages": { "@ohos-rs/ability@../../native_ability": { "name": "@ohos-rs/ability", - "version": "0.0.1", - "resolved": "", + "version": "1.0.0", + "resolved": "../../native_ability", "registryType": "local", "dependencies": { "libnative_ability.so": "file:./src/main/cpp/types/libnative_ability" } }, - "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": { - "name": "libnative_ability.so", + "@ohos-rs/plugin-app-control@../../plugins/app-control": { + "name": "@ohos-rs/plugin-app-control", "version": "1.0.0", - "resolved": "", - "registryType": "local" + "resolved": "../../plugins/app-control", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } }, - "libentry.so@src/main/cpp/types/libentry": { - "name": "libentry.so", + "@ohos-rs/plugin-permission@../../plugins/permission": { + "name": "@ohos-rs/plugin-permission", "version": "1.0.0", - "resolved": "", - "registryType": "local" + "resolved": "../../plugins/permission", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } + }, + "@ohos-rs/plugin-webview@../../plugins/webview": { + "name": "@ohos-rs/plugin-webview", + "version": "1.0.0", + "resolved": "../../plugins/webview", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } + }, + "@ohos-rs/plugin-window@../../plugins/window": { + "name": "@ohos-rs/plugin-window", + "version": "1.0.0", + "resolved": "../../plugins/window", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } }, "libdemo_native.so@src/main/cpp/types/libdemo_native": { "name": "libdemo_native.so", "version": "1.0.0", - "resolved": "", + "resolved": "src/main/cpp/types/libdemo_native", + "registryType": "local" + }, + "libentry.so@src/main/cpp/types/libentry": { + "name": "libentry.so", + "version": "1.0.0", + "resolved": "src/main/cpp/types/libentry", "registryType": "local" }, - "libark_web.so@src/main/cpp/types/libark_web": { - "name": "libark_web.so", + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": { + "name": "libnative_ability.so", "version": "1.0.0", - "resolved": "", + "resolved": "../../native_ability/src/main/cpp/types/libnative_ability", "registryType": "local" } } -} +} \ No newline at end of file diff --git a/demo/entry/oh-package.json5 b/demo/entry/oh-package.json5 index 0459343d..56d75135 100644 --- a/demo/entry/oh-package.json5 +++ b/demo/entry/oh-package.json5 @@ -8,7 +8,10 @@ "dependencies": { "libentry.so": "file:./src/main/cpp/types/libentry", "libdemo_native.so": "file:./src/main/cpp/types/libdemo_native", - "libark_web.so": "file:./src/main/cpp/types/libark_web", "@ohos-rs/ability": "file:../../native_ability", + "@ohos-rs/plugin-app-control": "file:../../plugins/app-control", + "@ohos-rs/plugin-permission": "file:../../plugins/permission", + "@ohos-rs/plugin-webview": "file:../../plugins/webview", + "@ohos-rs/plugin-window": "file:../../plugins/window", }, } diff --git a/demo/entry/src/main/cpp/types/libark_web/Index.d.ts b/demo/entry/src/main/cpp/types/libark_web/Index.d.ts deleted file mode 100644 index ec6b8f90..00000000 --- a/demo/entry/src/main/cpp/types/libark_web/Index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function init(createWeb: (err: Error | null) => string): void; diff --git a/demo/entry/src/main/cpp/types/libark_web/oh-package.json5 b/demo/entry/src/main/cpp/types/libark_web/oh-package.json5 deleted file mode 100644 index 08bf9b35..00000000 --- a/demo/entry/src/main/cpp/types/libark_web/oh-package.json5 +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "libark_web.so", - "types": "./Index.d.ts", - "version": "1.0.0", - "description": "Please describe the basic information.", -} 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 1105ef3f..bb83c4e9 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 @@ -1,20 +1,276 @@ /* auto-generated by OHOS-RS */ /* eslint-disable */ +export interface PermissionRequestPayload { + permissions: Array; +} +export interface PermissionResponsePayload { + codes: Array; +} + +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; +} + +export interface WebviewControllerRequest { + id: string; + slotId: string; + visible?: boolean; + color?: string; + url?: string; + html?: string; + headers?: Record; + zoom?: number; +} + +export interface WebviewCreateRequest { + id: string; + slotId: string; + 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; + slotId: string; +} + +/** Completion notification delivered directly through a named N-API callback. */ +export interface WebviewDownloadEndEvent { + id: string; + url: string; + tempPath?: string; + success: boolean; +} + +/** Request delivered synchronously before ArkWeb starts a download. */ +export interface WebviewDownloadStartRequest { + id: 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; +} + +/** 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; + url: string; +} + +/** Synchronous navigation decision. A true value retains ArkWeb's existing intercept semantics. */ +export interface WebviewNavigationResponse { + intercept: boolean; +} + +export interface WebviewScriptRequest { + id: string; + slotId: string; + script: string; +} + +export interface WebviewScriptResponse { + result?: string; +} + +export interface WebviewStringResponse { + value?: string; +} + +export interface WebviewStyle { + x?: number | string; + y?: number | string; + visible?: boolean; + backgroundColor?: string; +} + +/** Title-change notification delivered directly through a named N-API callback. */ +export interface WebviewTitleChangeEvent { + id: string; + title: string; +} + +/** + * This is passed to ArkTS as a real N-API object, not as JSON. The explicit bridge name makes + * the contract stable even if the Rust struct/module name changes. + */ +export interface DemoProfile { + userId: string; + visitCount: number; +} + +export interface LoginPublishResponse { + published: boolean; + module: string; +} + +export interface LoginRequest { + provider: string; + scopes: Array; +} + +export interface LoginResponse { + userId: string; + displayName: string; + accessToken: string; + expiresAtMs: number; +} + +export interface MainThreadInspectRequest { + requested: boolean; +} + +export interface MainThreadInspectResponse { + module: string; + sessionId: string; + uiContextReady: boolean; + execution: string; +} + +/** + * Creates a WebView through the WebView plugin. The ArkTS plugin mounts only into the named + * business-owned BridgeNodeHost slot; it does not touch DefaultXComponent internals. + */ +export declare function createDemoWebview(): Promise; + +/** Bytes travel through the bridge as a Uint8Array, not a JSON number array or Base64 string. */ +export declare function demoPluginBytes(): Promise>; + +/** Worker -> TSFN -> ArkTS async plugin -> Rust future. */ +export declare function demoPluginLogin(): Promise; + +/** + * A `#[napi(object)]` value crosses the bridge directly and keeps its explicit `demo.Profile` + * type identity at the ArkTS plugin boundary. + */ +export declare function demoPluginProfile(): Promise; + +/** `String` travels as the named `std.string` N-API type, without JSON serialization. */ +export declare function demoPluginString(): Promise; + +/** Synchronous plugins remain scoped to the N-API main-thread `Env`. */ +export declare function demoPluginSyncContext(): string; + export declare function demoRequestPermissionFromMainThread(): Promise>; -export declare function handleChange(): void; +/** + * Proves the Rust → WebView JavaScript path. The bridge waits for `onControllerAttached` before + * calling ArkTS `WebviewController.runJavaScript`. + */ +export declare function evaluateDemoWebviewScript(): Promise; -export declare function init(context?: AbilityInitContext | undefined | null): ApplicationLifecycle; +export declare function init(context?: AbilityInitContext): ApplicationLifecycle; export declare function onBackPressIntercept(): boolean; -export declare function registerCustomProtocol(): void; +/** r" ArkTS-only lifecycle transitions, currently UI-context readiness transitions. */ +export declare function onBridgeLifecycle(kind: string): void; -export declare function render(helper: object, slot: NodeContent): 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 setBackgroundColor(color: string): void; +export declare function render(bindings: object, slot: NodeContent): void; -export declare function setVisible(visible: boolean): void; +export declare function setBackgroundColor(color: string): Promise; + +export declare function setVisible(visible: boolean): Promise; export declare function toggleBackPressIntercept(): boolean; + +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 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; +} diff --git a/demo/entry/src/main/ets/bridge/DemoLoginPlugin.ets b/demo/entry/src/main/ets/bridge/DemoLoginPlugin.ets new file mode 100644 index 00000000..a61d6e8e --- /dev/null +++ b/demo/entry/src/main/ets/bridge/DemoLoginPlugin.ets @@ -0,0 +1,239 @@ +import { + AsyncBridgePlugin, + BridgeCallContext, + BridgeTypedValue, + BridgePluginContext, + BridgePluginFactory, + MainThreadSyncBridgePlugin, +} from "@ohos-rs/ability"; + +const LOGIN_REQUEST_TYPE = "demo.login.LoginRequest"; +const LOGIN_RESPONSE_TYPE = "demo.login.LoginResponse"; +const LOGIN_PUBLISH_RESPONSE_TYPE = "demo.login.LoginPublishResponse"; +const MAIN_THREAD_INSPECT_REQUEST_TYPE = "demo.main-thread.InspectRequest"; +const MAIN_THREAD_INSPECT_RESPONSE_TYPE = "demo.main-thread.InspectResponse"; + +interface LoginRequest { + provider: string; + scopes: string[]; +} + +interface LoginResponse { + userId: string; + displayName: string; + accessToken: string; + expiresAtMs: number; +} + +interface LoginProvider { + authorize(request: LoginRequest, context: BridgeCallContext): Promise; +} + +interface LoginPublishResponse { + published: boolean; + module: string; +} + +interface MainThreadInspectResponse { + module: string; + sessionId: string; + uiContextReady: boolean; + execution: string; +} + +interface MainThreadInspectRequest { + requested: boolean; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function requireObject(payload: BridgeTypedValue, typeName: string, description: string): T { + if ( + payload.typeName !== typeName || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`${description} requires bridge type ${typeName}`); + } + return payload.value as T; +} + +function parseLoginRequest(payload: BridgeTypedValue): LoginRequest { + const request = requireObject(payload, LOGIN_REQUEST_TYPE, "login request"); + if ( + typeof request.provider !== "string" || + !Array.isArray(request.scopes) || + request.scopes.some((scope) => typeof scope !== "string") + ) { + throw new Error("Invalid login request"); + } + return request; +} + +function parseLoginResponse(payload: BridgeTypedValue): LoginResponse { + const response = requireObject(payload, LOGIN_RESPONSE_TYPE, "login response"); + if ( + typeof response.userId !== "string" || + typeof response.displayName !== "string" || + typeof response.accessToken !== "string" || + typeof response.expiresAtMs !== "number" + ) { + throw new Error("Invalid login response"); + } + return response; +} + +/** + * Replace this provider with an AccountKit/OAuth implementation in a product app. Its Promise + * may open system UI or wait for a redirect; BridgeHost will resolve/reject the Rust future and + * discard the result if NativeAbility is destroyed in the meantime. + */ +class DemoIdentityProvider implements LoginProvider { + async authorize(request: LoginRequest, context: BridgeCallContext): Promise { + if (!context.isActive()) { + throw new Error("Ability was destroyed before login started"); + } + if (request.provider !== "demo" || request.scopes.length === 0) { + throw new Error("Unsupported login request"); + } + + // Simulates an asynchronous system-account/OAuth authorization operation on ArkTS. + await delay(80); + if (!context.isActive()) { + throw new Error("Ability was destroyed while login was pending"); + } + return { + userId: "demo-user-1001", + displayName: "Bridge Demo User", + accessToken: "demo-access-token", + expiresAtMs: Date.now() + 60 * 60 * 1000, + }; + } +} + +class DemoLoginPlugin implements AsyncBridgePlugin { + readonly id: string = "demo.login"; + readonly version: number = 1; + readonly execution: "async" = "async"; + /** Login can open system UI, so installation waits for DefaultXComponent's UIContext. */ + readonly requires: ["ui-context"] = ["ui-context"]; + + private readonly pluginContext: BridgePluginContext; + private readonly provider: LoginProvider; + + constructor(pluginContext: BridgePluginContext, provider: LoginProvider) { + this.pluginContext = pluginContext; + this.provider = provider; + } + + async onInstall(context: BridgePluginContext): Promise { + // This would throw before UIContext readiness; BridgeHost guarantees it is ready here. + context.getUIContext(); + AppStorage.setOrCreate("bridgeLoginStatus", "ready"); + } + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): Promise { + if (!context.isActive()) { + throw new Error("Bridge session is no longer active"); + } + + if (action === "authorize") { + const request = parseLoginRequest(payload); + const response = await this.provider.authorize(request, context); + return { typeName: LOGIN_RESPONSE_TYPE, value: response as ESObject }; + } + + if (action === "publish") { + const response = parseLoginResponse(payload); + if (!context.isActive()) { + throw new Error("Bridge session is no longer active"); + } + // This is the app-visible UI state update. A production plugin could instead publish an + // event, persist encrypted credentials, or invoke an application-specific callback. + AppStorage.setOrCreate("bridgeLoginResult", response.userId); + const result: LoginPublishResponse = { + published: true, + module: this.pluginContext.moduleName, + }; + return { typeName: LOGIN_PUBLISH_RESPONSE_TYPE, value: result as ESObject }; + } + + throw new Error(`Unsupported demo.login action '${action}'`); + } +} + +export function createDemoLoginPlugin(): BridgePluginFactory { + return { + id: "demo.login", + version: 1, + execution: "async", + requires: ["ui-context"], + modules: ["demo_native"], + create: (context: BridgePluginContext): AsyncBridgePlugin => { + return new DemoLoginPlugin(context, new DemoIdentityProvider()); + }, + }; +} + +/** + * Synchronous counterpart for the demo. BridgeHost never accepts this through bridgeInvoke; + * Rust can reach it only from an Env-scoped N-API main-thread callback. + */ +class DemoMainThreadPlugin implements MainThreadSyncBridgePlugin { + readonly id: string = "demo.main-thread"; + readonly version: number = 1; + readonly execution: "sync-main-thread" = "sync-main-thread"; + readonly requires: ["ui-context"] = ["ui-context"]; + + onInstall(context: BridgePluginContext): void { + context.getUIContext(); + } + + invokeSync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): BridgeTypedValue { + if (action !== "inspect") { + throw new Error(`Unsupported demo.main-thread action '${action}'`); + } + if (!context.isActive()) { + throw new Error("Bridge session is no longer active"); + } + const request = requireObject( + payload, + MAIN_THREAD_INSPECT_REQUEST_TYPE, + "demo.main-thread.inspect", + ); + if (request.requested !== true) { + throw new Error("Invalid demo.main-thread.inspect request"); + } + context.getUIContext(); + const response: MainThreadInspectResponse = { + module: context.moduleName, + sessionId: context.sessionId, + uiContextReady: true, + execution: "sync-main-thread", + }; + return { typeName: MAIN_THREAD_INSPECT_RESPONSE_TYPE, value: response as ESObject }; + } +} + +export function createDemoMainThreadPlugin(): BridgePluginFactory { + return { + id: "demo.main-thread", + version: 1, + execution: "sync-main-thread", + requires: ["ui-context"], + modules: ["demo_native"], + create: (_context: BridgePluginContext): MainThreadSyncBridgePlugin => { + return new DemoMainThreadPlugin(); + }, + }; +} diff --git a/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets b/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets new file mode 100644 index 00000000..450468d7 --- /dev/null +++ b/demo/entry/src/main/ets/bridge/DemoNodePlugin.ets @@ -0,0 +1,80 @@ +import { BuilderNode, FrameNode } from "@ohos.arkui.node"; +import { + AsyncBridgePlugin, + BridgeCallContext, + BridgeNodeSlot, + BridgeNodeSlotRegistry, + BridgeTypedValue, + BridgePluginContext, + BridgePluginFactory, + DEFAULT_PLUGIN_NODE_SLOT, +} from "@ohos-rs/ability"; + +interface BadgeData { + title: string; +} + +@Builder +function BuildBadge(data: BadgeData) { + Row() { + Text(data.title) + .fontSize(12) + .fontColor("#ffffff") + } + .padding({ left: 8, right: 8, top: 4, bottom: 4 }) + .backgroundColor("#663f51b5") + .borderRadius(10) + .alignSelf(ItemAlign.End); +} + +const badgeBuilder = wrapBuilder<[BadgeData]>(BuildBadge); + +/** Demonstrates that a bridge plugin can mount a normal ArkUI BuilderNode, not only WebView. */ +class DemoNodePlugin implements AsyncBridgePlugin { + readonly id = "demo.node-badge"; + readonly version = 1; + readonly execution: "async" = "async"; + readonly requires: ["ui-context"] = ["ui-context"]; + private slot?: BridgeNodeSlot; + + onInstall(context: BridgePluginContext): void { + const slot = BridgeNodeSlotRegistry.require( + context.sessionId, + context.moduleName, + DEFAULT_PLUGIN_NODE_SLOT, + ); + const node = new BuilderNode<[BadgeData]>(context.getUIContext()); + node.build(badgeBuilder, { title: "ordinary BuilderNode plugin" }); + const frameNode = node.getFrameNode(); + if (frameNode === null) { + throw new Error("BuilderNode did not produce a FrameNode"); + } + const mountedNode: FrameNode = frameNode; + slot.mount("demo.node-badge", mountedNode, () => mountedNode.dispose()); + this.slot = slot; + } + + async invokeAsync( + action: string, + _payload: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + throw new Error(`Unsupported demo.node-badge action '${action}'`); + } + + onDispose(): void { + this.slot?.unmount("demo.node-badge"); + this.slot = undefined; + } +} + +export function createDemoNodePlugin(): BridgePluginFactory { + return { + id: "demo.node-badge", + version: 1, + execution: "async", + requires: ["ui-context"], + modules: ["demo_native"], + create: (_context: BridgePluginContext): AsyncBridgePlugin => new DemoNodePlugin(), + }; +} diff --git a/demo/entry/src/main/ets/bridge/DemoRawPlugin.ets b/demo/entry/src/main/ets/bridge/DemoRawPlugin.ets new file mode 100644 index 00000000..808908ab --- /dev/null +++ b/demo/entry/src/main/ets/bridge/DemoRawPlugin.ets @@ -0,0 +1,79 @@ +import { + AsyncBridgePlugin, + BridgeCallContext, + BridgeTypedValue, + BridgePluginContext, + BridgePluginFactory, +} from "@ohos-rs/ability"; + +interface DemoProfile { + userId: string; + visitCount: number; +} + +class DemoProfileValue { + readonly userId: string; + readonly visitCount: number; + + constructor(userId: string, visitCount: number) { + this.userId = userId; + this.visitCount = visitCount; + } +} + +/** Demonstrates built-in and application-defined named N-API types without a JSON envelope. */ +class DemoTypedPlugin implements AsyncBridgePlugin { + readonly id = "demo.raw"; + readonly version = 1; + readonly execution: "async" = "async"; + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + if (action === "echo-string") { + if (payload.typeName !== "std.string" || typeof payload.value !== "string") { + throw new Error("demo.raw.echo-string requires bridge type std.string"); + } + return { typeName: "std.string", value: `ArkTS received: ${payload.value}` as ESObject }; + } + + if (action === "reverse-bytes") { + if (payload.typeName !== "std.bytes" || !(payload.value instanceof Uint8Array)) { + throw new Error("demo.raw.reverse-bytes requires bridge type std.bytes"); + } + const bytes = payload.value as Uint8Array; + const reversed = new Uint8Array(bytes.length); + for (let index = 0; index < bytes.length; index++) { + reversed[index] = bytes[bytes.length - index - 1]; + } + return { typeName: "std.bytes", value: reversed as ESObject }; + } + + if (action === "bump-profile") { + if (payload.typeName !== "demo.Profile" || typeof payload.value !== "object") { + throw new Error("demo.raw.bump-profile requires bridge type demo.Profile"); + } + const profile = payload.value as DemoProfile; + if (!profile.userId?.trim() || !Number.isInteger(profile.visitCount)) { + throw new Error("demo.Profile must contain userId and integer visitCount"); + } + return { + typeName: "demo.Profile", + value: new DemoProfileValue(profile.userId, profile.visitCount + 1), + }; + } + + throw new Error(`Unsupported demo.raw action '${action}'`); + } +} + +export function createDemoRawPlugin(): BridgePluginFactory { + return { + id: "demo.raw", + version: 1, + execution: "async", + create: (_context: BridgePluginContext): AsyncBridgePlugin => new DemoTypedPlugin(), + }; +} diff --git a/demo/entry/src/main/ets/entryability/EntryAbility.ets b/demo/entry/src/main/ets/entryability/EntryAbility.ets index b68c078b..9abfe187 100644 --- a/demo/entry/src/main/ets/entryability/EntryAbility.ets +++ b/demo/entry/src/main/ets/entryability/EntryAbility.ets @@ -2,20 +2,40 @@ import { NativeAbility } from "@ohos-rs/ability"; import Want from "@ohos.app.ability.Want"; import { AbilityConstant } from "@kit.AbilityKit"; import window from "@ohos.window"; +import { createAppControlPlugin } from "@ohos-rs/plugin-app-control"; +import { createPermissionPlugin } from "@ohos-rs/plugin-permission"; +import { createWebviewPlugin } from "@ohos-rs/plugin-webview"; +import { createWindowPlugin } from "@ohos-rs/plugin-window"; +import { createDemoLoginPlugin, createDemoMainThreadPlugin } from "../bridge/DemoLoginPlugin"; +import { createDemoNodePlugin } from "../bridge/DemoNodePlugin"; +import { createDemoRawPlugin } from "../bridge/DemoRawPlugin"; export default class EntryAbility extends NativeAbility { public moduleName: string = "demo_native"; public defaultPage: boolean = false; + public bridgePlugins = [ + createPermissionPlugin(), + createAppControlPlugin(), + createWindowPlugin(), + createWebviewPlugin(), + createDemoLoginPlugin(), + createDemoMainThreadPlugin(), + createDemoRawPlugin(), + createDemoNodePlugin(), + ]; async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise { super.onCreate(want, launchParam); } async onWindowStageCreate(windowStage: window.WindowStage): Promise { - const window = windowStage.getMainWindowSync(); - await window.setWindowLayoutFullScreen(false); - super.onWindowStageCreate(windowStage); - - await windowStage.loadContent("pages/Index"); + try { + const mainWindow = windowStage.getMainWindowSync(); + await mainWindow.setWindowLayoutFullScreen(false); + super.onWindowStageCreate(windowStage); + await windowStage.loadContent("pages/Index"); + } catch (error) { + throw new Error(`Unable to initialize the demo window: ${String(error)}`); + } } } diff --git a/demo/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/demo/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets index 58ac4237..81357cd6 100644 --- a/demo/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +++ b/demo/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -7,6 +7,6 @@ export default class EntryBackupAbility extends BackupExtensionAbility { } async onRestore(bundleVersion: BundleVersion) { - hilog.info(0x0000, "testTag", "onRestore ok %{public}s", JSON.stringify(bundleVersion)); + hilog.info(0x0000, "testTag", "onRestore ok %{public}s", String(bundleVersion)); } } diff --git a/demo/entry/src/main/ets/pages/Index.ets b/demo/entry/src/main/ets/pages/Index.ets index c771270b..4af63504 100644 --- a/demo/entry/src/main/ets/pages/Index.ets +++ b/demo/entry/src/main/ets/pages/Index.ets @@ -1,13 +1,24 @@ -import { DefaultXComponent } from "@ohos-rs/ability"; import { + BridgeNodeHost, + DefaultXComponent, + invokeNativeBackPressInterceptor, +} from "@ohos-rs/ability"; +import { + createDemoWebview, demoRequestPermissionFromMainThread, - handleChange, + evaluateDemoWebviewScript, + demoPluginLogin, + demoPluginBytes, + demoPluginProfile, + demoPluginSyncContext, + demoPluginString, setBackgroundColor, setVisible, toggleBackPressIntercept, } from "libdemo_native.so"; const MODULE_NAME = "demo_native"; +const WEBVIEW_SLOT = "webview-panel"; @Entry @Component @@ -15,21 +26,98 @@ struct Index { @State currentDemo: number = 0; @State interceptEnabled: boolean = true; @State webviewVisible: boolean = true; + @State loginResult: string = "not signed in"; + @State rawPayloadResult: string = "not called"; + @State profileResult: string = "not called"; + @State mainThreadResult: string = "not inspected"; + @State webviewResult: string = "WebView not created"; + @State webviewScriptResult: string = "JavaScript not called"; async handlePermissionDemo() { const result: number[] = await demoRequestPermissionFromMainThread(); console.log(`permission result: ${result}`); } + async handlePluginLogin() { + this.loginResult = "signing in on a Rust worker…"; + try { + this.loginResult = await demoPluginLogin(); + } catch (error) { + this.loginResult = `login failed: ${String(error)}`; + } + } + + handleSyncPluginContext() { + try { + this.mainThreadResult = demoPluginSyncContext(); + } catch (error) { + this.mainThreadResult = `sync plugin failed: ${String(error)}`; + } + } + + async handleRawPayloadDemo() { + try { + const text = await demoPluginString(); + const bytes = await demoPluginBytes(); + this.rawPayloadResult = `${text}; bytes=${bytes.join(",")}`; + } catch (error) { + this.rawPayloadResult = `raw payload failed: ${String(error)}`; + } + } + + async handleProfileDemo() { + try { + const profile = await demoPluginProfile(); + this.profileResult = `${profile.userId}: visits=${profile.visitCount}`; + } catch (error) { + this.profileResult = `named type failed: ${String(error)}`; + } + } + handleToggleBackIntercept() { this.interceptEnabled = toggleBackPressIntercept(); console.log(`backPressIntercept=${this.interceptEnabled}`); } - handleToggleWebviewVisible() { + onBackPress(): boolean { + const intercepted = invokeNativeBackPressInterceptor(MODULE_NAME); + console.log(`native back press intercepted=${intercepted}`); + return intercepted; + } + + async handleCreateWebview() { + try { + await createDemoWebview(); + this.webviewResult = "WebView mounted in business slot"; + } catch (error) { + this.webviewResult = `create failed: ${String(error)}`; + } + } + + async handleToggleWebviewVisible() { const nextVisible = !this.webviewVisible; - setVisible(nextVisible); - this.webviewVisible = nextVisible; + try { + await setVisible(nextVisible); + this.webviewVisible = nextVisible; + } catch (error) { + this.webviewResult = `visibility failed: ${String(error)}`; + } + } + + async handleWebviewBackground() { + try { + await setBackgroundColor("#ffdddd"); + } catch (error) { + this.webviewResult = `style failed: ${String(error)}`; + } + } + + async handleWebviewScript() { + try { + this.webviewScriptResult = await evaluateDemoWebviewScript(); + } catch (error) { + this.webviewScriptResult = `JavaScript failed: ${String(error)}`; + } } @Builder @@ -41,15 +129,51 @@ struct Index { }); } + @Builder + WebviewUnderlay() { + Column() { + Text("This is business-owned content below the plugin node slot.") + .fontSize(14) + .fontColor("#666") + } + .width("100%") + .height("100%") + .justifyContent(FlexAlign.Center); + } + + @Builder + WebviewOverlay() { + Row() { + Text("Business overlay — the WebView plugin owns only its FrameNode") + .fontSize(12) + .fontColor("#ffffff") + } + .padding(8) + .backgroundColor("#66000000") + .alignSelf(ItemAlign.Start); + } + @Builder XComponentDemo() { Column({ space: 12 }) { Button("request permission demo") .onClick(() => this.handlePermissionDemo()) + Button("async bridge login demo") + .onClick(() => this.handlePluginLogin()) + Text(this.loginResult) + Button("sync main-thread plugin demo") + .onClick(() => this.handleSyncPluginContext()) + Text(this.mainThreadResult) + Button("String + Uint8Array typed plugin demo") + .onClick(() => this.handleRawPayloadDemo()) + Text(this.rawPayloadResult) + Button("named #[napi(object)] type demo") + .onClick(() => this.handleProfileDemo()) + Text(this.profileResult) Button(this.interceptEnabled ? "back intercept: ON" : "back intercept: OFF") .onClick(() => this.handleToggleBackIntercept()) Text("Swipe back to test intercept behavior.") - DefaultXComponent({ moduleName: MODULE_NAME }) + DefaultXComponent({ moduleName: MODULE_NAME }).layoutWeight(1) } .width("100%") .height("100%"); @@ -60,17 +184,27 @@ struct Index { Column({ space: 12 }) { Row({ space: 12 }) { Button("create webview") - .onClick(() => { - handleChange(); - }) + .onClick(() => this.handleCreateWebview()) Button("background red") - .onClick(() => { - setBackgroundColor("red"); - }) + .onClick(() => this.handleWebviewBackground()) + Button("run JavaScript") + .onClick(() => this.handleWebviewScript()) Button(this.webviewVisible ? "hide webview" : "show webview") .onClick(() => this.handleToggleWebviewVisible()) } - DefaultXComponent({ moduleName: MODULE_NAME }) + Text(this.webviewResult) + Text(`document.title: ${this.webviewScriptResult}`) + Stack() { + DefaultXComponent({ moduleName: MODULE_NAME }) + BridgeNodeHost({ + moduleName: MODULE_NAME, + slotId: WEBVIEW_SLOT, + underlay: this.WebviewUnderlay, + foreground: this.WebviewOverlay, + }) + } + .layoutWeight(1) + .width("100%") } .width("100%") .height("100%"); @@ -80,7 +214,7 @@ struct Index { Column({ space: 16 }) { Row({ space: 12 }) { this.DemoSwitcher("XComponent demo", 0) - this.DemoSwitcher("WebView demo", 1) + this.DemoSwitcher("WebView + slot demo", 1) }.width("100%") if (this.currentDemo === 0) { this.XComponentDemo(); diff --git a/demo/oh-package-lock.json5 b/demo/oh-package-lock.json5 index ccb3a72f..b14a30a3 100644 --- a/demo/oh-package-lock.json5 +++ b/demo/oh-package-lock.json5 @@ -11,14 +11,14 @@ }, "packages": { "@ohos/hamock@1.0.0": { - "name": "", + "name": "@ohos/hamock", "version": "1.0.0", "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", "resolved": "https://repo.harmonyos.com/ohpm/@ohos/hamock/-/hamock-1.0.0.har", "registryType": "ohpm" }, "@ohos/hypium@1.0.19": { - "name": "", + "name": "@ohos/hypium", "version": "1.0.19", "integrity": "sha512-cEjDgLFCm3cWZDeRXk7agBUkPqjWxUo6AQeiu0gEkb3J8ESqlduQLSIXeo3cCsm8U/asL7iKjF85ZyOuufAGSQ==", "resolved": "https://repo.harmonyos.com/ohpm/@ohos/hypium/-/hypium-1.0.19.har", diff --git a/docs/plugin-development-standard.md b/docs/plugin-development-standard.md new file mode 100644 index 00000000..66d5a874 --- /dev/null +++ b/docs/plugin-development-standard.md @@ -0,0 +1,544 @@ +# OpenHarmony Ability 插件开发规范 + +> 适用于仓库内置插件和业务插件。本文中的“必须”表示合入条件,“应当”表示没有充分理由不得偏离。 +> 本文是插件开发的唯一规范文档:从建包、契约定义、线程模型、生命周期、节点挂载到 WebView +> 扩展和验收均以本文为准,不依赖其他架构说明文档。 + +## 1. 目标与边界 + +插件是对某一项平台能力的成对封装:Rust 提供业务可调用、强类型的 facade;ArkTS HAR +持有 HarmonyOS 平台对象并实现真正的平台调用。框架 core 只提供通用的桥接 transport、线程 +约束、生命周期和 ArkUI 节点插槽,**不得认识任何具体业务能力**。 + +一个插件从创建到交付必须按以下顺序完成: + +1. 划定能力边界,确定它是异步能力还是必须立即返回的主线程同步能力,并列出它依赖的 context。 +2. 建立 `crates/plugin-` 与 `plugins/` 成对包,先固定插件 ID、版本、action 和 + 具名 request/response ABI。 +3. 在 Rust 实现 `BridgePlugin` marker 与面向业务的扩展 trait/client;在 ArkTS 实现同一契约的 + `BridgePluginFactory`。 +4. 将平台对象、生命周期监听、节点与 delegate 留在 ArkTS;将 worker-safe 数据、业务校验和 + Rust closure 留在 Rust。 +5. 为 context 未就绪、调用取消、页面 detach、Ability 销毁和重复安装写出确定的处理路径。 +6. 在 demo 中分别覆盖异步、主线程同步和(如有)原生节点/平台回调路径,再完成验收检查。 + +每个插件必须满足以下边界: + +- Rust crate 位于 `crates/plugin-`,只能依赖 `openharmony-ability`,不能依赖 ArkTS + 实现或应用页面;对外通过扩展 trait/client 暴露能力。 +- ArkTS HAR 位于 `plugins/`,只能依赖 `@ohos-rs/ability` 和需要的平台 Kit;导出一个 + `BridgePluginFactory`。 +- 应用入口显式同时组合 Rust 插件 facade 与 ArkTS factory;core 不能反向 import 任意 + `plugin-*` crate/HAR。 +- 一个插件只能管理自己的资源、回调和节点。布局、业务页面状态和其他插件资源仍由应用拥有。 + +目录骨架如下: + +```text +crates/plugin-login/ + src/lib.rs # BridgePlugin、具名 N-API 类型、LoginExt facade、测试 +plugins/login/ + src/main/ets/LoginPlugin.ets # BridgePluginFactory、HarmonyOS API、生命周期释放 +demo/ # 显式注册 factory,并覆盖真实调用路径 +``` + +历史 `helper/` 中属于某项平台能力的接口必须迁移到对应的成对插件中,不得在 core 新增 +“万能 helper”。只有不包含平台能力契约的通用运行时设施(例如模块加载、通用调度)才可留在 core。 + +## 2. 插件身份与双端一致性 + +Rust `BridgePlugin` 和 ArkTS `BridgePluginFactory` 是同一个契约的两面。下列字段必须完全一致: + +| 字段 | Rust | ArkTS | 规则 | +| --- | --- | --- | --- | +| 插件 ID | `BridgePlugin::ID` | `factory.id` / `plugin.id` | 使用稳定、小写、点分名称,例如 `ohos.permission` | +| 版本 | `BridgePlugin::VERSION` | `factory.version` / `plugin.version` | 任一 request/response 或 action 语义变化时双端同步升级 | +| 执行模式 | `type Mode` | `execution` | 只能是 async 或 sync-main-thread,不能由运行时布尔值决定 | +| context 前置条件 | `REQUIRED_CONTEXTS` | `requires` | 内容完全相同;不能只在一端声明 | + +ID、action 和 type name 只能使用 `A-Za-z0-9._-`;action 使用 kebab-case 的动词短语,例如 +`request`、`get-avoid-area`、`set-visible`。同一插件内 action 的语义不得因版本不同而悄悄改变。 + +### 2.1 通用桥接边界 + +插件不得自行发明 Rust ↔ ArkTS 通信通道。所有出站 action 都使用 core 提供的统一参数顺序: + +```text +异步:bridgeInvoke(pluginId, version, action, requestType, responseType, value, timeoutMs) +同步:bridgeInvokeSync(pluginId, version, action, requestType, responseType, value) +入站:onBridgeSyncEvent(pluginId, event, requestType, responseType, value) +``` + +其中 `value` 是当前 `napi_env` 中的真实 N-API value;`requestType` 与 `responseType` 是稳定 ABI 名。 +异步出站由 TSFN 将 ArkTS Promise 转成 Rust future:请求只在 ArkTS callback 中编码,响应也在该处 +解码为 Rust 所有权数据后才回到 worker。同步出站和入站事件都只能在当前活跃的 N-API callback 内 +完成。 + +C++ N-API 实现也必须使用上述同一边界,且只能在当前 `napi_env` 内创建、读取或返回 +`napi_value`。禁止把 `napi_env`、`napi_value`、`napi_ref`、ArkTS object 或 function 放入 worker、 +static、跨线程队列或长期缓存。 + +## 3. 请求与响应:具名 N-API 类型,不使用 JSON + +所有插件 action 的 request 和 response 必须通过真实 N-API value 传输,并使用稳定的 +`typeName` 作为 ABI 名称。禁止将对象序列化为 JSON 字符串后穿过桥接层,包括 +`JSON.stringify` / `JSON.parse`、`BridgeJson`、`call_json`、`bridgeJson`、`requireBridgeJson` +及任何等价封装。 + +这条规则同样适用于 WebView 的导航、下载、标题等反向回调。当前插件桥没有面向新插件的 +JSON event port;ArkTS 回调 Rust 应使用 `context.invokeNativeSync` 和具名类型。 + +### 3.1 Rust 侧 + +对象契约必须使用 `#[napi(object)]` 加 `impl_bridge_napi_type!` 固定名字。Rust 字段采用 +snake_case,N-API 对象在 ArkTS 中按 camelCase 使用。 + +```rust +use napi_derive_ohos::napi; +use openharmony_ability::{ + impl_bridge_napi_type, AsyncBridge, BridgeContextRequirement, BridgePlugin, +}; + +pub struct LoginBridgePlugin; + +impl BridgePlugin for LoginBridgePlugin { + type Mode = AsyncBridge; + const ID: &'static str = "account.login"; + const VERSION: u32 = 1; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::Ability]; +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct LoginRequest { + pub account_id: String, +} +impl_bridge_napi_type!(LoginRequest, "account.LoginRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct LoginResponse { + pub token: String, +} +impl_bridge_napi_type!(LoginResponse, "account.LoginResponse"); +``` + +`String`、`Vec` 和基础标量本身也是具名 N-API 类型:`std.string`、`std.bytes`、 +`std.bool`、`std.i32`、`std.f64`。因此“string 也是一种类型”,无需为了统一接口再包一层 JSON。 + +每个公开对象必须有稳定、可读的类型名:内置插件以完整插件 ID 为前缀,例如 +`ohos.permission.PermissionRequest`;业务插件使用 `.`,例如 +`account.LoginRequest`。每个 request/response 至少要有单元测试断言 `BridgeNapiType::TYPE_NAME`。 + +### 3.2 ArkTS 侧 + +ArkTS 必须先校验传入 `typeName` 和对象形状,再调用平台 API;返回时必须填入对应的 response +`typeName`。不得因类型不匹配而静默降级或忽略字段。 + +```ts +const LOGIN_REQUEST_TYPE = "account.LoginRequest"; +const LOGIN_RESPONSE_TYPE = "account.LoginResponse"; + +interface LoginRequest { + accountId: string; +} + +class LoginResponse { + readonly token: string; + + constructor(token: string) { + this.token = token; + } +} + +function parseLoginRequest(payload: BridgeTypedValue): LoginRequest { + if (payload.typeName !== LOGIN_REQUEST_TYPE || typeof payload.value !== "object") { + throw new Error(`login requires bridge type ${LOGIN_REQUEST_TYPE}`); + } + const request = payload.value as LoginRequest; + if (!request?.accountId?.trim()) { + throw new Error("login.accountId must be non-empty"); + } + return request; +} +``` + +完整的 ArkTS 实现还必须把 execution、requires 和 factory 固定下来。下面是异步登录插件的最小 +骨架;真实平台调用完成后要再次检查 `context.isActive()`,防止 Ability 已销毁仍返回过期结果: + +```ts +class LoginPlugin implements AsyncBridgePlugin { + readonly id = "account.login"; + readonly version = 1; + readonly execution: "async" = "async"; + readonly requires: ["ability"] = ["ability"]; + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): Promise { + if (action !== "login") { + throw new Error(`Unsupported account.login action '${action}'`); + } + const request = parseLoginRequest(payload); + const token = await loginWithPlatformApi(context.abilityContext, request.accountId); + if (!context.isActive()) { + throw new Error("Ability was destroyed while login was pending"); + } + return { typeName: LOGIN_RESPONSE_TYPE, value: new LoginResponse(token) }; + } +} + +export function createLoginPlugin(): BridgePluginFactory { + return { + id: "account.login", + version: 1, + execution: "async", + requires: ["ability"], + create: (_context: BridgePluginContext): AsyncBridgePlugin => new LoginPlugin(), + }; +} +``` + +### 3.3 既有内置插件的契约基线 + +迁移 helper 或新增 action 时,应以现有插件的类型名、模式和 context 作为兼容语义基线;只能在 +双端升级版本后改变契约。 + +| 插件 / action | request → response | 模式 / context | +| --- | --- | --- | +| `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.webview` / `create` | `ohos.webview.CreateRequest` → `ohos.webview.CreateResponse { id, slotId }` | async / `ui-context` | +| `ohos.webview` / 控制器 action | `ohos.webview.ControllerRequest` → `ohos.webview.Acknowledgement` / `StringResponse` / `ScriptResponse` | async / `ui-context` | + +`permission` 的结果顺序和失败码 `-1`、`window` 的四个避让区、`app-control` 的主线程同步退出、 +WebView 的 controller ID 与命名 slot 都是既有语义,迁移为插件后不得丢失。 + +## 4. 执行模式由 trait 限制 + +`BridgePlugin::Mode` 是封闭 trait 的关联类型,不是可随意切换的配置。它把不正确的线程调用变成 +编译期不可表达或运行时立即报错的情况。 + +| 模式 | Rust 标记与入口 | ArkTS 实现 | 适用场景 | 禁止事项 | +| --- | --- | --- | --- | --- | +| 异步 | `type Mode = AsyncBridge`;`BridgeClient::call_async` / `BridgeRuntime::call_async` | `AsyncBridgePlugin.invokeAsync` | 登录、权限、网络、WebView 控制器、等待生命周期或节点 | 保存 `Env`、`napi_value`、ArkTS object、`UIContext` 或 `FrameNode` 到 worker | +| 主线程同步 | `type Mode = MainThreadSyncBridge`;仅在导出的 N-API callback 持有 `Env` 时经 `with_main_thread_bridge(...).call_sync` | `MainThreadSyncBridgePlugin.invokeSync` | 需要立即返回的平台决定,例如退出、同步窗口查询、同步拦截决策 | `await`、返回 Promise、阻塞等待、投递后再取结果、从 Rust worker 调用 | + +异步 facade 可被 Rust worker 调用,但它传入和拿回的必须都是 `Send + 'static` 的 Rust 所有权数据: + +```rust +let response = app.bridge()? + .call_async::( + "login", + LoginRequest { account_id }, + BridgeCallOptions::default(), + ) + .await?; +``` + +同步 plugin 的 `BridgeMainThread<'env>` 是 `!Send + !Sync`、不可 clone,生命周期仅限当前活跃 +N-API callback。业务 facade 必须把 `Env` 作为显式参数接收,而不是缓存它: + +```rust +pub trait AppControlExt { + fn terminate(&self, env: &napi_ohos::Env, code: i32) -> napi_ohos::Result<()>; +} + +// 在 #[napi] 导出的回调内调用: +app.with_main_thread_bridge(env, |bridge| { + bridge.call_sync::( + "terminate", + TerminateRequest { code: 0 }, + ) +})?; +``` + +ArkTS 平台回调进入 Rust 的 `on_main_thread_event` 是**入站 scoped callback**,不是第三种 +执行模式。它也必须在当前 N-API callback 内完成;如果需要做耗时工作,只能先复制已解码的 Rust +所有权数据给 worker,并立即返回本次回调的响应。 + +## 5. 生命周期与 context + +平台对象只保留在 ArkTS。Rust 只能收到受控 lifecycle event 和当前 callback 内的具名 N-API value, +绝不能长期保存 `UIAbilityContext`、`WindowStage`、`UIContext`、`WebviewController` 或 ArkUI node。 + +插件必须在 Rust 和 ArkTS 两端声明相同的 `REQUIRED_CONTEXTS` / `requires`: + +| requirement | 就绪时点 | 适合的能力 | +| --- | --- | --- | +| `ability` | `NativeAbility.onCreate` 已建立 Ability context | 权限、应用控制、登录会话 | +| `window-stage` | `NativeAbility.onWindowStageCreate` | 窗口与避让区 | +| `ui-context` | `DefaultXComponent.aboutToAppear` 已建立 UI context;业务 `BridgeNodeHost` 仅负责附加命名 slot | WebView、任意 ArkUI/FrameNode 插件 | + +`BridgeHost` 只会在 requirements 都就绪后调用 `onInstall`,并向延迟激活的插件重放有限的生命周期 +历史。插件如需监听销毁、配置或内存事件,应在 ArkTS `onLifecycle` 或 Rust +`BridgePlugin::on_lifecycle` 中处理,而不是替代或绕开 `NativeAbility` 原有生命周期链。 + +生命周期的调用顺序必须保留为下列链路;插件只能订阅它,不能把原 native module 的 lifecycle +callback 替换掉: + +1. `NativeAbility.onCreate` 打开 module/session 对应的 `BridgeHost`,创建 factory,并发出 + `ability-create`。 +2. `NativeAbility.onWindowStageCreate` 先提供 `WindowStage`,再发出 `window-stage-create`;窗口事件 + 仍要同时转发给原 native module lifecycle。 +3. `DefaultXComponent.aboutToAppear` 先挂接 native event sink 和默认节点 slot,再通知 Rust + `ui-context-ready` 并设置 `UIContext`。这样 plugin `onInstall` 期间已经可以安全发起 scoped + 回调或挂载默认节点。 +4. UI 消失时,先发出 `ui-context-destroy`,再 detach slot 和 event sink;WindowStage 销毁时再发出 + `window-stage-destroy`;Ability 销毁时发出 `ability-destroy` 并 dispose 整个 session。 +5. `configuration-updated`、`memory-level`、window-stage event 等保持由 `NativeAbility` 原有链路 + 分发,同时作为受控 lifecycle event 交给已安装插件。 + +ArkTS context 是 module + session 范围的。插件不得假设多个 module 共用一个 controller、slot 或 +状态表;所有跨页面状态键必须至少包含 `sessionId` 与 `moduleName`。 + +规则如下: + +- 异步调用可以等待 context 就绪;等待必须受 `BridgeCallContext.onCancel` 约束。调用超时或 + Ability/session 销毁时,Host 会取消调用;插件在 `ui-context-destroy` / `window-stage-destroy` + 时必须通过 lifecycle 释放关联资源,并让后续结果或 mount 失败可见。 +- 同步调用不能等待。context 未就绪、插件尚未安装或节点未挂载时必须立即报错,由调用方在后续主线程 + callback 重试。 +- `onDispose` 必须幂等,负责移除平台 delegate、取消订阅、卸载节点、清空 controller/tag 映射。 + 单个插件释放失败不能阻断其余插件释放。 +- 禁止用 `setTimeout`、轮询或固定延迟猜测页面、controller 或 context 是否已经就绪。等待条件必须由 + 生命周期、attach 事件或真正的平台完成事件驱动。 + +下面是取消感知等待的标准形态。`onCancel` 返回的取消订阅必须在 resolve、reject 和 cleanup 任一路径 +解除,避免页面销毁后留下 waiter: + +```ts +const slot = await BridgeNodeSlotRegistry.waitFor( + context.sessionId, + context.moduleName, + slotId, + context.onCancel, +); +if (!context.isActive()) { + throw new Error("Bridge call was cancelled before node mount"); +} +slot.mount("account.login.surface", node, () => node.dispose()); +``` + +## 6. ArkUI 节点与插槽 + +需要渲染原生内容的插件(WebView、地图、相机、视频、渲染根节点等)必须通过通用 +`BridgeNodeSlot` 挂载自己的 `FrameNode`,不能把具体能力写进 `DefaultXComponent`。 + +一个 slot 的唯一键是 `(sessionId, moduleName, slotId)`。它的生命周期只有:`attach` → `active` → +`detach/dispose`。异步 waiter 的终态也只有三种:slot attach 后 resolve、桥接调用取消后 reject、 +Ability/session 销毁后 reject;不存在“页面 3 秒内未渲染即失败”之类的独立计时器。 + +- `DefaultXComponent` 固定提供默认 slot `xcomponent-overlay`,并保留既有 Stack、尺寸和透明 + hit-test 行为。 +- 业务需要决定位置、层级或混合内容时,应在页面显式放置 `BridgeNodeHost`,使用 + `moduleName + slotId` 指定命名 slot;业务拥有 underlay/foreground/layout,插件只拥有中性 + `NodeContainer` 中自己的节点。 +- 同步插件只能使用 `BridgeNodeSlotRegistry.require(...)`,拿不到 slot 立即失败;异步插件使用 + `waitFor(sessionId, moduleName, slotId, context.onCancel)` 等待 attach 或取消。 +- mount key 必须以插件 ID 为前缀且在插件内唯一。插件只能卸载自己的 key,并在 `onDispose`、节点 + detach 或创建中途取消时执行 cleanup。 +- slot 返回后仍可能在 mount 前失效;插件必须让 `mount` 的 active 校验失败可见,不能在已销毁的 + `NodeController` 上重建节点。 + +业务页面应按自己的布局显式放置 `BridgeNodeHost`。下面的 `underlay` 与 `foreground` 始终属于业务, +插件拿到的只是中间中性的 `NodeContainer`: + +```ts +Stack() { + DefaultXComponent({ moduleName: "demo_native" }) + BridgeNodeHost({ + moduleName: "demo_native", + slotId: "webview-panel", + underlay: this.BusinessUnderlay, + foreground: this.BusinessForeground, + }) +} +``` + +这套规则同时保留 WebView 与 XComponent 的混合接入,并允许任意插件接入其他 node 节点;没有 +WebView 专用插槽或对业务布局的隐式所有权。 + +## 7. 平台回调与 WebView 特例 + +平台回调若需要 Rust 立即决策,ArkTS 必须调用: + +```ts +context.invokeNativeSync(event, requestTypeName, responseTypeName, value) +``` + +Rust 在 `BridgePlugin::on_main_thread_event` 中按事件名和具名 request/response 类型处理。回调不能 +持有 ArkTS Function/ObjectRef,也不能异步地再返回本次平台决定。每个可拦截事件必须记录失败语义: +例如导航拦截通常 fail-open,下载准入通常 fail-closed;通知型事件失败只记录并继续平台流程。 + +所有 synchronous platform callback 均应按以下方式实现:ArkTS 立即解码平台参数为具名 request,调用 +`invokeNativeSync`,验证具名 response 后在同一回调栈中返回给平台;Rust 则在 +`on_main_thread_event` 中立即给出响应。事件 handler 可把已解码的 Rust owned data 投递给 worker +做记录或后处理,但不得把本次响应留给 worker 决定。 + +WebView 插件还必须遵守: + +- 自定义 scheme 通过 `WebviewProtocol::register` 在 Web engine 初始化前声明;初始化后不允许 + 再新增 scheme。 +- tag 对应的自定义 protocol、JavaScript proxy 和回调订阅应在 `create` 前声明。controller attach 后, + 必须先安装 delegate/protocol/proxy,再启动首次导航。 +- 自定义 protocol 处理 URL 请求;页面 JS proxy 处理 `window..()` 调用,两者不能 + 混为同一种桥。 +- 创建时的样式语义(包括 transparent)必须在 `create` action 里完整表达,不能要求业务在创建后 + 补一次调用才能得到旧 helper 的效果。 +- WebView 的导航、下载开始/结束、标题、controller attach/remove 都是具名 N-API 的同步入站事件; + 出站的 create、evaluate script、load URL 等仍为异步 action。 + +### 7.1 WebView 回调契约与失败策略 + +WebView 的 callback builder 必须在 `WebviewClient::create` 前按 webview tag 声明。Rust 保存的是 +`Send + Sync + 'static` closure,而不是 ArkTS 函数;ArkTS 在创建时只拿到订阅快照,以决定是否安装 +对应 ArkWeb delegate。 + +| ArkWeb 时点 | Rust 事件/契约 | 默认或错误语义 | +| --- | --- | --- | +| engine 初始化前/后 | `EngineLifecycleEvent` → `EventAcknowledgement` | 初始化前 flush scheme,初始化后封存 scheme 声明 | +| controller attach/remove | `ControllerEvent` → `EventAcknowledgement` | attach 时安装 proxy/protocol,remove 时清理状态 | +| `onLoadIntercept` | `NavigationRequest` → `NavigationResponse` | 未订阅或 handler 失败时 `intercept = false`,fail-open | +| `WebDownloadDelegate.onBeforeDownload` | `DownloadStartRequest` → `DownloadStartResponse` | 失败时取消下载,fail-closed;允许改写临时保存路径 | +| `onDownloadFinish` / `onDownloadFailed` | `DownloadEndEvent` → `EventAcknowledgement` | 含 success 与可选 temp path;通知失败只记录 | +| `onTitleReceive` | `TitleChangeEvent` → `EventAcknowledgement` | 通知失败只记录 | + +`transparent` 是 create 时的行为:当 `.transparent(true)` 且未显式指定 background color 时,ArkTS +必须在创建根节点时使用透明色 `#00000000`;显式 background color 优先。不能把这一语义退化为 +“创建后业务再调用一次 set-background-color”。 + +### 7.2 自定义 protocol 与页面 JavaScript + +自定义 scheme 与页面 JavaScript bridge 是两项不同能力:前者处理 URL 请求,后者处理 +`window..()` 调用,不能混用同一个 handler 或生命周期。 + +1. 应用在 `#[ability]` 初始化期间通过 `WebviewProtocol::register` 声明 scheme 及 option;该步骤 + 必须早于 `WebviewController.initializeWebEngine()`。 +2. ArkTS `WebviewPlugin.onInstall` 发出 `before-engine-init`;Rust flush 全部 scheme 声明后,ArkTS + 才初始化 engine,并以 `engine-initialized` 封存声明集。封存后新增 scheme 必须确定性失败。 +3. 业务在 `WebviewClient::create` 前按 tag 调用 `custom_protocol`、注册 JS proxy 和 callback;Rust + 只保存 tag/scheme/closure 声明,不能保存 ArkTS controller。 +4. controller attach 后先通过 scoped direct event 安装 protocol、proxy 与 delegate,再开始首次 + `loadUrl`。若 handler 在 controller 存在后增量注册,应立即绑定;JS proxy 如需重新生效则刷新页面。 + +主动执行页面脚本使用异步具名 action `ohos.webview.ScriptRequest` → +`ohos.webview.ScriptResponse`,对应 `handle.evaluate_script(...)`。它必须等待 controller attach, +不能用定时器假设 create 返回后 controller 已可执行脚本。 + +## 8. Helper 迁移、对外 API 与应用装配 + +旧 helper 不是兼容层,也不是 framework 的长期扩展点。每项有平台语义的 helper 必须迁移为独立插件, +并把原有行为作为插件 facade 的验收基线: + +| 原能力 | 目标插件 | 调用限制与保留语义 | +| --- | --- | --- | +| `requestPermission` | `plugin-permission` / `ohos.permission` | async + `ability`;结果顺序与失败码保持不变 | +| `exit` | `plugin-app-control` / `ohos.app-control` | sync + 当前主线程 `Env` | +| `getWindowAvoidArea` | `plugin-window` / `ohos.window` | sync + `window-stage`;返回完整避让区 | +| `createWebview`、嵌入式 WebView、custom protocol、导航/下载/标题回调 | `plugin-webview` / `ohos.webview` | 出站 async + `ui-context`;入站为 scoped 主线程具名 N-API;scheme 在 engine 初始化前声明 | +| `Loadable` | `runtime/NativeModuleLoader` | framework 内部 runtime,不是能力 bridge | +| `random`、`objectAssign` 等纯工具函数 | 使用点或插件内部 | 不再作为 framework helper 暴露 | + +迁移时应删除 core 中针对该能力的 helper、类型、factory import、宏参数和页面专用字段;禁止保留 +“旧 helper 调新插件”的双入口,也禁止以 JSON/弱类型 wrapper 伪造兼容。 + +Rust crate 应提供业务语义清晰的扩展 trait 或 client,而非要求业务拼接 plugin ID、action 或 +`BridgeTypedValue`。例如 `PermissionExt::request_permission`、`WebviewExt::webview`。 + +Rust facade 必须在 `#[ability]` 初始化器中、UI render 前注册。该注册用于接收 lifecycle 与 ArkTS +直达事件;它与 ArkTS factory 的应用装配缺一不可: + +```rust +#[ability] +fn configure_ability(app: OpenHarmonyApp) { + app.register_plugin(LoginBridgePlugin) + .expect("login Rust facade must be registered exactly once"); +} +``` + +ArkTS HAR 导出唯一 factory,应用通过 `NativeAbility.bridgePlugins` 显式装配: + +```ts +import { createAppControlPlugin } from "@ohos-rs/plugin-app-control"; +import { createLoginPlugin } from "@ohos-rs/plugin-login"; +import { createPermissionPlugin } from "@ohos-rs/plugin-permission"; +import { createWebviewPlugin } from "@ohos-rs/plugin-webview"; +import { createWindowPlugin } from "@ohos-rs/plugin-window"; + +export default class EntryAbility extends NativeAbility { + bridgePlugins = [ + createPermissionPlugin(), + createAppControlPlugin(), + createWindowPlugin(), + createWebviewPlugin(), + createLoginPlugin(), + ]; +} +``` + +factory 可以用 `modules` 限制适用的 native module。未装配、版本不匹配、模式不匹配和类型不匹配 +都应在桥接边界确定性报错,不得悄悄回退到 helper 或 JSON 兼容路径。 + +## 9. 实现、Demo 与验收 + +每个新插件必须提供一个可运行 demo。demo 不是只验证 HAR 能安装,而是要走完 Rust → bridge → ArkTS +→ platform → Rust 的真实路径。至少覆盖下列与插件相关的分支: + +| 场景 | 必须验证的结果 | +| --- | --- | +| 异步 action | Rust worker 可以发起调用;ArkTS Promise 完成后 Rust 收到具名 response,不存在 JSON encode/decode | +| 主线程同步 action(如有) | 在 `#[napi]` callback 的 `Env` 内成功;从 worker 调用被明确拒绝;没有 Promise 或阻塞等待 | +| context 延迟 | async 调用会等待真正的 context/slot attach;sync 调用在未就绪时立即失败 | +| 生命周期销毁 | timeout、Ability/session destroy 会取消调用;UI/WindowStage detach 会触发生命周期 cleanup,临时节点、delegate、waiter 和映射被释放或失效 | +| 原生节点(如有) | 默认 `xcomponent-overlay` 与业务命名 `BridgeNodeHost` 都能挂载,业务 underlay/foreground 行为不变 | +| 平台回调(如有) | 在当前回调栈完成 Rust 决策,并覆盖明确的 fail-open/fail-closed 语义 | +| WebView(如有) | custom scheme、首次导航前安装、透明背景、导航、下载、标题和 JS script/proxy 均覆盖 | + +建议在 demo 中同时保留三个最小参考能力:异步登录、主线程同步调用、以及 `String`、`Vec`、 +`#[napi(object)]` 三种具名 N-API 值的 raw transport。这样新插件可以直接验证类型边界而非依赖 JSON。 + +新增或迁移插件必须完成以下项目后才可合入: + +- [ ] 建立 `crates/plugin-` 与 `plugins/` 成对包,并完成 workspace/HAR/应用装配登记。 +- [ ] Rust `BridgePlugin` 与 ArkTS factory 的 ID、VERSION、Mode、requires 完全一致。 +- [ ] 每个 request/response 都是具名 N-API 类型;ArkTS 输入校验 `typeName` 和字段,输出填写 + 正确的 `typeName`;不存在 JSON 桥接代码。 +- [ ] 同步 facade 只接受当前回调的 `Env`;异步 facade 和 callback 不保存任何 N-API/ArkTS 对象。 +- [ ] 需要生命周期或 context 的插件声明 requirements,覆盖 delayed activation、destroy、dispose + 和调用取消;没有 timer 轮询。 +- [ ] 需要 UI 的插件通过 `BridgeNodeSlot` 接入,覆盖默认 XComponent slot、业务命名 slot、detach + 和中途取消 cleanup;未改坏默认 XComponent 的既有行为。 +- [ ] 平台回调通过 `invokeNativeSync` + `on_main_thread_event`,明确同步时限和失败策略。 +- [ ] WebView(如涉及)覆盖 engine 初始化、custom protocol、首次导航、delegate 安装、透明背景和 + 所有已支持回调。 +- [ ] Rust 单测至少覆盖 typeName、参数校验、业务结果校验和 mode/context 声明;ArkTS/demo 覆盖 async、 + sync-main-thread、生命周期延迟和销毁取消路径。 +- [ ] 运行仓库规定的 Rust、ArkTS 格式化、编译与测试检查;评审中检索 JSON 桥接关键词,确认没有新增。 + +至少执行以下检查(以仓库实际脚本和目标包名替换尖括号内容): + +```bash +cargo check --workspace +cargo test -p openharmony-ability-plugin- --lib +cargo clippy --workspace --all-targets -- -D warnings +pnpm run format:check +rg -n "BridgeJson|call_json|bridgeJson|requireBridgeJson|JSON\\.stringify|JSON\\.parse" \ + crates/plugin- plugins/ native_ability +``` + +ArkTS/HAP 编译和真机验证也属于合入条件;仅 Rust 单测通过不能证明 N-API typeName、主线程 scope +或 ArkUI 生命周期正确。 + +## 10. 推荐评审问题 + +1. core 是否仍然不知道该插件的具体能力、平台类型和业务语义? +2. Rust 与 ArkTS 是否对 ID、版本、模式、context、action 和 typeName 使用同一份契约? +3. 是否能证明同步路径不会 `await`,异步路径不会保存 Env/ArkTS 对象? +4. 插件依赖的生命周期、slot attach、取消和 dispose 是否都有明确的事件驱动路径? +5. 若插件为 WebView 或其他原生节点,业务是否仍拥有布局和内容插槽? +6. request/response 与反向平台回调是否完全没有 JSON transport? diff --git a/native_ability/.gitignore b/native_ability/.gitignore index e2713a27..bb6f79e9 100644 --- a/native_ability/.gitignore +++ b/native_ability/.gitignore @@ -1,6 +1,7 @@ /node_modules /oh_modules +/.idea /.preview /build /.cxx -/.test \ No newline at end of file +/.test diff --git a/native_ability/BuildProfile.ets b/native_ability/BuildProfile.ets index 885a0e0b..f7ad5d5b 100644 --- a/native_ability/BuildProfile.ets +++ b/native_ability/BuildProfile.ets @@ -1,7 +1,7 @@ /** * Use these variables when you tailor your ArkTS code. They must be of the const type. */ -export const HAR_VERSION = "0.0.1"; +export const HAR_VERSION = "1.0.0"; export const BUILD_MODE_NAME = "debug"; export const DEBUG = true; export const TARGET_NAME = "default"; diff --git a/native_ability/index.ets b/native_ability/index.ets index cecdd4d3..402931df 100644 --- a/native_ability/index.ets +++ b/native_ability/index.ets @@ -1,7 +1,30 @@ export { NativeAbility } from "./src/main/ets/ability/NativeAbility"; +export { + BridgeAbilityCreateLifecyclePayload, + BridgeEmptyLifecyclePayload, + BridgeMemoryLevelLifecyclePayload, + BridgeWindowStageEventLifecyclePayload, +} from "./src/main/ets/ability/type"; +export type { + AsyncBridgePlugin, + BridgeBindings, + BridgeCallContext, + BridgeContextRequirement, + BridgeLifecycleEvent, + BridgeLifecycleKind, + BridgeLifecyclePayload, + BridgeTypedValue, + BridgePlugin, + BridgePluginContext, + BridgePluginExecution, + BridgePluginFactory, + MainThreadSyncBridgePlugin, +} from "./src/main/ets/ability/type"; export { DefaultXComponent } from "./src/main/ets/components/DefaultXComponent"; +export { invokeNativeBackPressInterceptor } from "./src/main/ets/runtime/BackPress"; export { - RustWebviewNodeController, - WebviewStyle, - WebviewInitData, -} from "./src/main/ets/webview/DefaultWebview"; + BridgeNodeHost, + BridgeNodeSlot, + BridgeNodeSlotRegistry, + DEFAULT_PLUGIN_NODE_SLOT, +} from "./src/main/ets/bridge/BridgeNodeSlot"; diff --git a/native_ability/oh-package.json5 b/native_ability/oh-package.json5 index 7cf92dfd..20db42dc 100644 --- a/native_ability/oh-package.json5 +++ b/native_ability/oh-package.json5 @@ -1,6 +1,6 @@ { "name": "@ohos-rs/ability", - "version": "0.0.1", + "version": "1.0.0", "description": "Adaptor for OpenHarmony/HarmonyNext native abilities", "main": "index.ets", "author": "richerfu", diff --git a/native_ability/src/main/ets/ability/NativeAbility.ets b/native_ability/src/main/ets/ability/NativeAbility.ets index f7097e7d..2f6d4084 100644 --- a/native_ability/src/main/ets/ability/NativeAbility.ets +++ b/native_ability/src/main/ets/ability/NativeAbility.ets @@ -1,24 +1,36 @@ import { AbilityConstant, Configuration, UIAbility, Want } from "@kit.AbilityKit"; import common from "@ohos.app.ability.common"; import window from "@ohos.window"; -import webview from "@ohos.web.webview"; import * as Entry from "../components/MainPage"; -import { AbilityInitContext, ApplicationLifecycle, Module } from "./type"; -import { Loadable } from "../helper/loadable"; +import { BridgeHostRegistry } from "../bridge/BridgeHost"; +import { + AbilityInitContext, + ApplicationLifecycle, + BridgeAbilityCreateLifecyclePayload, + BridgeEmptyLifecyclePayload, + BridgeLifecycleEvent, + BridgeMemoryLevelLifecyclePayload, + BridgePluginFactory, + BridgeWindowStageEventLifecyclePayload, + Module, +} from "./type"; +import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; export const STATE_KEY = "ohos.rs.ability.application.state"; export class NativeAbility extends UIAbility { public moduleName: string | string[] = ""; public defaultPage: boolean = true; - public mode: "xcomponent" | "webview" = "xcomponent"; 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 bridgeSessionId: string = ""; protected resolveModuleNames(): string[] { - const moduleNames = Loadable.resolveModuleNames(this.moduleName); + const moduleNames = NativeModuleLoader.resolveModuleNames(this.moduleName); if (moduleNames.length === 0) { throw new Error("moduleName must contain at least one module"); } @@ -29,7 +41,7 @@ export class NativeAbility extends UIAbility { for (const lifecycle of this.lifecycles) { try { handler(lifecycle); - } catch (_) {} + } catch {} } } @@ -39,7 +51,7 @@ export class NativeAbility extends UIAbility { } try { - const parsed = JSON.parse(rawState) as Record; + const parsed = JSON.parse(rawState) as Record; const mapped: Record = {}; const keys = Object.keys(parsed); for (const key of keys) { @@ -47,7 +59,7 @@ export class NativeAbility extends UIAbility { mapped[key] = typeof value === "string" ? value : String(value); } return mapped; - } catch (_) { + } catch { return {}; } } @@ -63,35 +75,62 @@ export class NativeAbility extends UIAbility { }; } - async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise { + protected async notifyBridgeLifecycle(event: BridgeLifecycleEvent): Promise { + if (!this.bridgeSessionId) { + return; + } + for (const moduleName of this.loadedModuleNames) { + await BridgeHostRegistry.emitLifecycle(this.bridgeSessionId, moduleName, event); + } + } + + protected async setBridgeWindowStage(windowStage: window.WindowStage): Promise { + if (!this.bridgeSessionId) { + return; + } + for (const moduleName of this.loadedModuleNames) { + await BridgeHostRegistry.setWindowStage(this.bridgeSessionId, moduleName, windowStage); + } + } + + protected async clearBridgeWindowStage(): Promise { + if (!this.bridgeSessionId) { + return; + } + for (const moduleName of this.loadedModuleNames) { + await BridgeHostRegistry.clearWindowStage(this.bridgeSessionId, moduleName); + } + } + + async onCreate(want: Want, _launchParam: AbilityConstant.LaunchParam): Promise { const isRestore: boolean = (want.parameters?.["ohos.ability.params.abilityRecoveryRestart"] as boolean) ?? false; - const state = isRestore ? ((want.parameters?.[STATE_KEY] as string) ?? "") : ""; + const savedState = want.parameters?.[STATE_KEY]; + const state = isRestore && savedState !== undefined ? savedState.toString() : ""; AppStorage.setOrCreate("moduleName", this.moduleName); AppStorage.setOrCreate("loadMode", this.loadMode); const requestedModules: string[] = this.resolveModuleNames(); const restoredStateMap: Record = this.parseSavedStateMap(state); - let webEngineRequired = false; + if (this.bridgeSessionId) { + await BridgeHostRegistry.dispose(this.bridgeSessionId); + } + this.bridgeSessionId = await BridgeHostRegistry.open( + requestedModules, + this.context as common.UIAbilityContext, + this.bridgePlugins, + ); + AppStorage.setOrCreate("bridgeSessionId", this.bridgeSessionId); this.nativeModules = []; this.lifecycles = []; this.loadedModuleNames = []; for (const moduleName of requestedModules) { - const module: Module = await Loadable.load(moduleName, this.loadMode); + const module: Module = await NativeModuleLoader.load(moduleName, this.loadMode); this.nativeModules.push(module); this.loadedModuleNames.push(moduleName); - - if (typeof module.registerCustomProtocol === "function") { - module.registerCustomProtocol(); - webEngineRequired = true; - } - } - - if (webEngineRequired) { - webview.WebviewController.initializeWebEngine(); } for (let i = 0; i < this.nativeModules.length; i++) { @@ -104,11 +143,19 @@ export class NativeAbility extends UIAbility { const restoredState = restoredStateMap[moduleName] ?? state; try { lifecycle.windowStageEventCallback.onAbilityCreate(restoredState); - } catch (_) {} + } catch {} + } + + for (const moduleName of requestedModules) { + await BridgeHostRegistry.emitLifecycle(this.bridgeSessionId, moduleName, { + kind: "ability-create", + payload: new BridgeAbilityCreateLifecyclePayload(restoredStateMap[moduleName] ?? state), + }); } } async onWindowStageCreate(windowStage: window.WindowStage): Promise { + await this.setBridgeWindowStage(windowStage); this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onWindowStageCreate()); try { @@ -116,13 +163,17 @@ export class NativeAbility extends UIAbility { this.forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onWindowStageEvent(event), ); + this.notifyBridgeLifecycle({ + kind: "window-stage-event", + payload: new BridgeWindowStageEventLifecyclePayload(event), + }).catch(() => {}); }); - } catch (_) {} + } catch {} let win: window.Window | null = null; try { win = await windowStage.getMainWindow(); - } catch (_) { + } catch { win = null; } @@ -148,28 +199,48 @@ export class NativeAbility extends UIAbility { lifecycle.keyboardEventCallback.onKeyboardHeightChange(height), ); }); - } catch (_) {} + } catch {} } if (this.defaultPage) { try { await windowStage.loadContentByName(Entry.RouteName); - } catch (_) {} + } catch {} } } + 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(() => {}); } - onDestroy(): void | Promise { + async onDestroy(): Promise { + await this.notifyBridgeLifecycle({ + kind: "ability-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); 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(() => {}); } onSaveState( @@ -187,17 +258,17 @@ export class NativeAbility extends UIAbility { try { stateMap[moduleName] = lifecycle.windowStageEventCallback.onAbilitySaveState() ?? ""; - } catch (_) {} + } catch {} } const stateValues = Object.values(stateMap); + let stateToSave: string = ""; if (stateValues.length === 1) { - wantParam[STATE_KEY] = stateValues[0]; + stateToSave = stateValues[0]; } else if (stateValues.length > 1) { - wantParam[STATE_KEY] = JSON.stringify(stateMap); - } else { - wantParam[STATE_KEY] = ""; + stateToSave = JSON.stringify(stateMap); } + wantParam[STATE_KEY] = new String(stateToSave); return AbilityConstant.OnSaveResult.RECOVERY_AGREE; } diff --git a/native_ability/src/main/ets/ability/type.ets b/native_ability/src/main/ets/ability/type.ets index 2a7bd670..99f02f92 100644 --- a/native_ability/src/main/ets/ability/type.ets +++ b/native_ability/src/main/ets/ability/type.ets @@ -1,5 +1,8 @@ import { NodeContent } from "@kit.ArkUI"; +import common from "@ohos.app.ability.common"; +import { UIContext } from "@ohos.arkui.UIContext"; import resourceManager from "@ohos.resourceManager"; +import window from "@ohos.window"; export interface ApplicationLifecycle { environmentCallback: EnvironmentCallback; @@ -28,42 +31,6 @@ export interface WindowStageEventCallback { onAvoidAreaChange: (arg: object) => void; } -export interface WebViewComponentEventCallback { - onComponentCreated: () => void; - onComponentDestroyed: () => void; -} - -export interface OnDownloadStartResult { - allow?: boolean; - tempPath?: string; -} - -export interface WebViewInitData { - url?: string; - id?: string; - style?: WebViewStyle; - javascriptEnable?: boolean; - devtools?: boolean; - transparent?: boolean; - autoplay?: boolean; - userAgent?: string; - initializationScripts?: string[]; - headers?: Record; - html?: string; - onDragAndDrop?: (event: string) => void; - onDownloadStart?: (url: string, tempPath: string | undefined) => OnDownloadStartResult; - onDownloadEnd?: (url: string, tempPath: string | undefined, success: boolean) => void; - onNavigationRequest?: (url: string) => boolean; - onTitleChange?: (title: string) => void; -} - -export interface WebViewStyle { - x?: number | string; - y?: number | string; - visible?: boolean; - backgroundColor?: string | Color; -} - export interface AbilityInitContext { basePath?: string; prefPath?: string; @@ -72,21 +39,184 @@ export interface AbilityInitContext { resourceManager?: resourceManager.ResourceManager; } +/** + * A named value exchanged through the N-API bridge. `typeName` is a stable Rust ↔ ArkTS contract + * (for example `std.string`, `std.bytes`, or `account.LoginToken`); `value` remains the actual + * N-API value, not a serialization or Base64 envelope. + */ +export interface BridgeTypedValue { + typeName: string; + value: ESObject; +} + +export interface BridgeBindings { + bridgeInvoke: ( + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + timeoutMs: number, + ) => Promise; + bridgeInvokeSync: ( + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ) => ESObject; + bridgeDispatch: () => void; +} + export interface Module { init: (context?: AbilityInitContext) => ApplicationLifecycle; - render: (helper: ArkHelper, slot: NodeContent) => void; - registerCustomProtocol?: () => void; + render: (bindings: BridgeBindings, slot: NodeContent) => 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. */ + on_back_press_intercept?: () => boolean; + /** + * Synchronous ArkTS platform callback -> Rust plugin decision. The value is a named N-API + * object and must be returned before the platform callback resumes. + */ + onBridgeSyncEvent?: ( + pluginId: string, + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ) => ESObject; + onBridgeLifecycle?: (kind: string) => void; } -export interface ArkHelper { - exit: (code: number) => void; - createWebview: (data: WebViewInitData) => Object; - createEmbeddedWebview: (data: WebViewInitData) => Object; - requestPermission: (permission: string | string[]) => Promise; - getWindowAvoidArea: (type: number) => WindowAvoidAreaInfo | undefined; +export type BridgePluginExecution = "async" | "sync-main-thread"; +export type BridgeContextRequirement = "ability" | "window-stage" | "ui-context"; +export type BridgeLifecycleKind = + | "ability-create" + | "ability-destroy" + | "window-stage-create" + | "window-stage-destroy" + | "window-stage-event" + | "ui-context-ready" + | "ui-context-destroy" + | "configuration-updated" + | "memory-level"; + +export class BridgeEmptyLifecyclePayload {} + +export class BridgeAbilityCreateLifecyclePayload { + readonly restoredState: string; + + constructor(restoredState: string) { + this.restoredState = restoredState; + } +} + +export class BridgeWindowStageEventLifecyclePayload { + readonly event: number; + + constructor(event: number) { + this.event = event; + } } -export interface WindowAvoidAreaInfo { - type: number; - area: Object; +export class BridgeMemoryLevelLifecyclePayload { + readonly level: number; + + constructor(level: number) { + this.level = level; + } +} + +export type BridgeLifecyclePayload = + | BridgeEmptyLifecyclePayload + | BridgeAbilityCreateLifecyclePayload + | BridgeWindowStageEventLifecyclePayload + | BridgeMemoryLevelLifecyclePayload; + +export interface BridgeLifecycleEvent { + kind: BridgeLifecycleKind; + payload: BridgeLifecyclePayload; +} + +/** + * 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. + */ +export interface BridgePluginContext { + pluginId: string; + moduleName: string; + sessionId: string; + abilityContext: common.UIAbilityContext; + isActive: () => boolean; + getWindowStage: () => window.WindowStage; + getUIContext: () => UIContext; + /** + * 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. + */ + invokeNativeSync: ( + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ) => ESObject; +} + +export interface BridgeCallContext extends BridgePluginContext { + action: string; + timeoutMs: number; + isCancelled: () => boolean; + /** + * Registers a listener that is invoked once when this asynchronous call is cancelled (for + * example because its caller timed out or its Ability session was disposed). The returned + * function removes the listener. This is the lifecycle signal for waits on ArkUI state; it is + * deliberately separate from a timer-based readiness heuristic. + */ + onCancel: (listener: () => void) => () => void; +} + +interface BridgePluginBase { + id: string; + version: number; + requires?: BridgeContextRequirement[]; +} + +export interface AsyncBridgePlugin extends BridgePluginBase { + execution: "async"; + onInstall?: (context: BridgePluginContext) => void | Promise; + onLifecycle?: (event: BridgeLifecycleEvent, context: BridgePluginContext) => void | Promise; + invokeAsync: ( + action: string, + request: BridgeTypedValue, + context: BridgeCallContext, + ) => Promise; + onDispose?: (context: BridgePluginContext) => void | Promise; +} + +export interface MainThreadSyncBridgePlugin extends BridgePluginBase { + execution: "sync-main-thread"; + onInstall?: (context: BridgePluginContext) => void; + onLifecycle?: (event: BridgeLifecycleEvent, context: BridgePluginContext) => void; + invokeSync: ( + action: string, + request: BridgeTypedValue, + context: BridgeCallContext, + ) => BridgeTypedValue; + onDispose?: (context: BridgePluginContext) => void; +} + +export type BridgePlugin = AsyncBridgePlugin | MainThreadSyncBridgePlugin; + +/** A factory creates one plugin instance per native module and Ability session. */ +export interface BridgePluginFactory { + id: string; + version: number; + execution: BridgePluginExecution; + requires?: BridgeContextRequirement[]; + modules?: string[]; + create: (context: BridgePluginContext) => BridgePlugin; } diff --git a/native_ability/src/main/ets/bridge/BridgeHost.ets b/native_ability/src/main/ets/bridge/BridgeHost.ets new file mode 100644 index 00000000..a12d4ed0 --- /dev/null +++ b/native_ability/src/main/ets/bridge/BridgeHost.ets @@ -0,0 +1,825 @@ +import common from "@ohos.app.ability.common"; +import { UIContext } from "@ohos.arkui.UIContext"; +import window from "@ohos.window"; +import { + BridgeCallContext, + BridgeContextRequirement, + BridgeEmptyLifecyclePayload, + BridgeLifecycleEvent, + BridgeTypedValue, + AsyncBridgePlugin, + MainThreadSyncBridgePlugin, + BridgePlugin, + BridgePluginContext, + BridgePluginFactory, +} from "../ability/type"; + +const MAX_TIMEOUT_MS = 60_000; +const MAX_PAYLOAD_BYTES = 512 * 1024; +const MAX_LIFECYCLE_HISTORY = 16; + +type BridgeMainThreadEventSink = ( + pluginId: string, + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, +) => ESObject; + +interface HostedPlugin { + factory: BridgePluginFactory; + plugin: BridgePlugin; + installed: boolean; + installing?: Promise; +} + +interface BridgeCallState { + cancelled: boolean; + cancelListeners: Array<() => void>; + rejectCancellation?: (reason: Error) => void; +} + +function isBridgeIdentifier(value: string): boolean { + return value.length > 0 && /^[A-Za-z0-9._-]+$/.test(value); +} + +function clampTimeout(timeoutMs: number): number { + if (!Number.isFinite(timeoutMs)) { + return MAX_TIMEOUT_MS; + } + return Math.max(1, Math.min(Math.floor(timeoutMs), MAX_TIMEOUT_MS)); +} + +function normalizedRequirements(requires?: BridgeContextRequirement[]): BridgeContextRequirement[] { + return requires ? requires.slice() : []; +} + +function sameRequirements( + first?: BridgeContextRequirement[], + second?: BridgeContextRequirement[], +): boolean { + const left = normalizedRequirements(first); + const right = normalizedRequirements(second); + if (left.length !== right.length) { + return false; + } + for (const requirement of left) { + if (right.indexOf(requirement) < 0) { + return false; + } + } + return true; +} + +/** + * One module-scoped host. It owns context readiness, lifecycle replay, timeout/cancellation and + * the direct ArkTS-to-Rust N-API event port; concrete capability implementations are supplied + * as factories. + */ +class BridgeHost { + private readonly plugins: Map = new Map(); + private readonly contextWaiters: Array<() => void> = []; + private readonly lifecycleHistory: BridgeLifecycleEvent[] = []; + // 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 mainThreadEventSink?: BridgeMainThreadEventSink; + private windowStage?: window.WindowStage; + private uiContext?: UIContext; + private disposed: boolean = false; + private readonly sessionId: string; + private readonly moduleName: string; + private readonly abilityContext: common.UIAbilityContext; + + constructor(sessionId: string, moduleName: string, abilityContext: common.UIAbilityContext) { + this.sessionId = sessionId; + this.moduleName = moduleName; + this.abilityContext = abilityContext; + } + + async install(factories: BridgePluginFactory[]): Promise { + for (const factory of factories) { + const modules = factory.modules; + if (modules && modules.length > 0 && modules.indexOf(this.moduleName) < 0) { + continue; + } + this.validateFactory(factory); + + const plugin = factory.create(this.pluginContext(factory.id)); + this.validatePlugin(factory, plugin); + if (this.plugins.has(plugin.id)) { + throw new Error(`Duplicate bridge plugin '${plugin.id}' in module ${this.moduleName}`); + } + this.plugins.set(plugin.id, { factory, plugin, installed: false }); + } + await this.activateReadyPlugins(); + } + + async setWindowStage(windowStage: window.WindowStage): Promise { + this.assertActive(); + this.windowStage = windowStage; + this.notifyContextChanged(); + await this.emitLifecycle({ + kind: "window-stage-create", + payload: new BridgeEmptyLifecyclePayload(), + }); + } + + async clearWindowStage(): Promise { + if (this.disposed) { + return; + } + if (this.uiContext !== undefined) { + await this.emitLifecycle({ + kind: "ui-context-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); + this.uiContext = undefined; + } + await this.emitLifecycle({ + kind: "window-stage-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); + this.windowStage = undefined; + } + + async setUiContext(uiContext: UIContext): Promise { + this.assertActive(); + this.uiContext = uiContext; + this.notifyContextChanged(); + await this.emitLifecycle({ + kind: "ui-context-ready", + payload: new BridgeEmptyLifecyclePayload(), + }); + } + + async clearUiContext(): Promise { + if (this.disposed || this.uiContext === undefined) { + return; + } + await this.emitLifecycle({ + kind: "ui-context-destroy", + payload: new BridgeEmptyLifecyclePayload(), + }); + this.uiContext = undefined; + } + + async emitLifecycle(event: BridgeLifecycleEvent): Promise { + if (this.disposed) { + return; + } + await this.activateReadyPlugins(); + 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)); + } + } + + async invokeAsync( + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + timeoutMs: number, + ): Promise { + const request: BridgeTypedValue = { typeName: requestTypeName, value }; + this.assertWireCall(pluginId, pluginVersion, action, request, responseTypeName); + const entry = this.lookup(pluginId, pluginVersion); + if (entry.plugin.execution !== "async") { + throw new Error( + `Bridge plugin '${pluginId}' is synchronous and must be called through bridgeInvokeSync`, + ); + } + + const asyncPlugin: AsyncBridgePlugin = entry.plugin as AsyncBridgePlugin; + const boundedTimeoutMs = clampTimeout(timeoutMs); + const callState: BridgeCallState = { cancelled: false, cancelListeners: [] }; + const cancellation = new Promise((_resolve, reject) => { + callState.rejectCancellation = reject; + }); + this.activeCalls.push(callState); + const operation = Promise.resolve() + .then(async (): Promise => { + await this.ensureActive(entry, callState); + this.assertCallActive(pluginId, action, callState, "before invocation"); + const result = await asyncPlugin.invokeAsync( + action, + request, + this.callContext(pluginId, action, boundedTimeoutMs, callState), + ); + this.assertCallActive(pluginId, action, callState, "after invocation"); + this.assertResponsePayload(pluginId, action, responseTypeName, result); + return result; + }) + .finally(() => { + this.removeActiveCall(callState); + }); + + const result = await this.withTimeout( + Promise.race([operation, cancellation]), + boundedTimeoutMs, + pluginId, + action, + (reason: Error) => { + this.cancelCall(callState, reason); + }, + ); + return result.value; + } + + invokeSync( + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject { + const request: BridgeTypedValue = { typeName: requestTypeName, value }; + this.assertWireCall(pluginId, pluginVersion, action, request, responseTypeName); + const entry = this.lookup(pluginId, pluginVersion); + if (entry.plugin.execution !== "sync-main-thread") { + throw new Error( + `Bridge plugin '${pluginId}' is asynchronous and must be called through bridgeInvoke`, + ); + } + if (!this.requirementsReady(entry)) { + throw new Error( + `Synchronous bridge plugin '${pluginId}' is not ready: ${this.missingRequirements(entry).join(", ")}`, + ); + } + if (!entry.installed) { + throw new Error( + `Synchronous bridge plugin '${pluginId}' is still installing; invoke it from a later main-thread callback`, + ); + } + + const syncPlugin: MainThreadSyncBridgePlugin = entry.plugin as MainThreadSyncBridgePlugin; + const syncCallState: BridgeCallState = { cancelled: false, cancelListeners: [] }; + const result = syncPlugin.invokeSync( + action, + request, + this.callContext(pluginId, action, 0, syncCallState), + ); + this.assertResponsePayload(pluginId, action, responseTypeName, result); + return result.value; + } + + private invokeNativeSync( + pluginId: string, + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject { + this.assertActive(); + if ( + !isBridgeIdentifier(pluginId) || + !isBridgeIdentifier(event) || + !isBridgeIdentifier(requestTypeName) || + !isBridgeIdentifier(responseTypeName) + ) { + throw new Error("Invalid main-thread bridge plugin id, event or type name"); + } + this.assertTypedValue({ typeName: requestTypeName, value }, "main-thread request"); + const sink = this.mainThreadEventSink; + if (sink === undefined) { + throw new Error( + "Main-thread event sink is unavailable because the native module is not rendered", + ); + } + return sink(pluginId, event, requestTypeName, responseTypeName, value); + } + + attachEventSink(mainThreadSink: BridgeMainThreadEventSink): void { + this.assertActive(); + this.mainThreadEventSink = mainThreadSink; + } + + detachEventSink(): void { + this.mainThreadEventSink = undefined; + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = 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`), + ); + } + 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)); + } + } catch { + // Teardown must not prevent another plugin from releasing its resources. + } + } + this.plugins.clear(); + this.mainThreadEventSink = undefined; + } + + private async ensureActive(entry: HostedPlugin, callState?: BridgeCallState): Promise { + while (!this.requirementsReady(entry)) { + this.assertCallStateActive(callState); + await this.waitForContextChange(callState); + } + this.assertCallStateActive(callState); + await this.activate(entry); + this.assertCallStateActive(callState); + } + + private async activateReadyPlugins(): Promise { + const entries = Array.from(this.plugins.values()); + for (const entry of entries) { + if (this.requirementsReady(entry)) { + await this.activate(entry); + } + } + } + + private async activate(entry: HostedPlugin): Promise { + if (entry.installed) { + return; + } + if (!this.requirementsReady(entry)) { + return; + } + if (!entry.installing) { + entry.installing = Promise.resolve() + .then(async () => { + if (entry.plugin.onInstall) { + await entry.plugin.onInstall(this.pluginContext(entry.plugin.id)); + } + entry.installed = true; + await this.replayLifecycle(entry); + }) + .finally(() => { + entry.installing = undefined; + }); + } + await entry.installing; + } + + private async replayLifecycle(entry: HostedPlugin): Promise { + if (!entry.plugin.onLifecycle) { + return; + } + for (const event of this.lifecycleHistory) { + await entry.plugin.onLifecycle(event, this.pluginContext(entry.plugin.id)); + } + } + + private requirementsReady(entry: HostedPlugin): boolean { + return this.missingRequirements(entry).length === 0; + } + + private missingRequirements(entry: HostedPlugin): BridgeContextRequirement[] { + const missing: BridgeContextRequirement[] = []; + for (const requirement of normalizedRequirements(entry.factory.requires)) { + if (requirement === "window-stage" && this.windowStage === undefined) { + missing.push(requirement); + } + if (requirement === "ui-context" && this.uiContext === undefined) { + missing.push(requirement); + } + } + return missing; + } + + private pluginContext(pluginId: string): BridgePluginContext { + return { + pluginId, + moduleName: this.moduleName, + sessionId: this.sessionId, + abilityContext: this.abilityContext, + isActive: (): boolean => !this.disposed, + getWindowStage: (): window.WindowStage => { + if (this.windowStage === undefined) { + throw new Error(`Bridge plugin '${pluginId}' requires a WindowStage that is not ready`); + } + return this.windowStage; + }, + getUIContext: (): UIContext => { + if (this.uiContext === undefined) { + throw new Error(`Bridge plugin '${pluginId}' requires a UIContext that is not ready`); + } + return this.uiContext; + }, + invokeNativeSync: ( + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject => + this.invokeNativeSync(pluginId, event, requestTypeName, responseTypeName, value), + }; + } + + private callContext( + pluginId: string, + action: string, + timeoutMs: number, + callState: BridgeCallState, + ): BridgeCallContext { + return { + pluginId, + moduleName: this.moduleName, + sessionId: this.sessionId, + abilityContext: this.abilityContext, + action, + timeoutMs, + isActive: (): boolean => !this.disposed && !callState.cancelled, + isCancelled: (): boolean => this.disposed || callState.cancelled, + onCancel: (listener: () => void): (() => void) => + this.subscribeCancellation(callState, listener), + getWindowStage: (): window.WindowStage => this.pluginContext(pluginId).getWindowStage(), + getUIContext: (): UIContext => this.pluginContext(pluginId).getUIContext(), + invokeNativeSync: ( + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject => + this.invokeNativeSync(pluginId, event, requestTypeName, responseTypeName, value), + }; + } + + private recordLifecycle(event: BridgeLifecycleEvent): void { + if (this.lifecycleHistory.length >= MAX_LIFECYCLE_HISTORY) { + this.lifecycleHistory.shift(); + } + this.lifecycleHistory.push(event); + } + + private notifyContextChanged(): void { + const waiters = this.contextWaiters.splice(0, this.contextWaiters.length); + for (const resolve of waiters) { + resolve(); + } + } + + private async waitForContextChange(callState?: BridgeCallState): Promise { + await new Promise((resolve) => { + let settled = false; + let unsubscribe: () => void = (): void => {}; + const wake = (): void => { + if (settled) { + return; + } + settled = true; + const index = this.contextWaiters.indexOf(wake); + if (index >= 0) { + this.contextWaiters.splice(index, 1); + } + unsubscribe(); + resolve(); + }; + this.contextWaiters.push(wake); + if (callState !== undefined) { + unsubscribe = this.subscribeCancellation(callState, wake); + } + }); + } + + private assertCallStateActive(callState?: BridgeCallState): void { + if (this.disposed) { + throw new Error(`Bridge session '${this.sessionId}' is already disposed`); + } + if (callState?.cancelled) { + throw new Error(`Bridge call for session '${this.sessionId}' was cancelled`); + } + } + + private assertCallActive( + pluginId: string, + action: string, + callState: BridgeCallState, + phase: string, + ): void { + if (this.disposed || callState.cancelled) { + throw new Error(`Bridge call '${pluginId}.${action}' was cancelled ${phase}`); + } + } + + private cancelCall(callState: BridgeCallState, reason?: Error): void { + if (callState.cancelled) { + return; + } + callState.cancelled = true; + const listeners = callState.cancelListeners.splice(0, callState.cancelListeners.length); + for (const listener of listeners) { + try { + listener(); + } catch { + // Cancellation is best-effort. One plugin listener must not block the rest. + } + } + const rejectCancellation = callState.rejectCancellation; + callState.rejectCancellation = undefined; + if (rejectCancellation !== undefined) { + rejectCancellation(reason ?? new Error("Bridge call was cancelled")); + } + } + + private subscribeCancellation(callState: BridgeCallState, listener: () => void): () => void { + if (callState.cancelled || this.disposed) { + listener(); + return (): void => {}; + } + callState.cancelListeners.push(listener); + return (): void => { + const index = callState.cancelListeners.indexOf(listener); + if (index >= 0) { + callState.cancelListeners.splice(index, 1); + } + }; + } + + private removeActiveCall(callState: BridgeCallState): void { + const index = this.activeCalls.indexOf(callState); + if (index >= 0) { + this.activeCalls.splice(index, 1); + } + } + + private lookup(pluginId: string, pluginVersion: number): HostedPlugin { + this.assertActive(); + const entry = this.plugins.get(pluginId); + if (!entry) { + throw new Error(`Bridge plugin '${pluginId}' is not installed for '${this.moduleName}'`); + } + if (entry.plugin.version !== pluginVersion) { + throw new Error( + `Bridge plugin '${pluginId}' version mismatch: Rust=${pluginVersion}, ArkTS=${entry.plugin.version}`, + ); + } + return entry; + } + + private assertWireCall( + pluginId: string, + pluginVersion: number, + action: string, + request: BridgeTypedValue, + responseTypeName: string, + ): void { + this.assertActive(); + if (!isBridgeIdentifier(pluginId) || !isBridgeIdentifier(action) || pluginVersion <= 0) { + throw new Error("Invalid bridge plugin id, version or action"); + } + if (!isBridgeIdentifier(responseTypeName)) { + throw new Error("Invalid bridge response type name"); + } + this.assertTypedValue(request, "request"); + } + + private assertTypedValue(payload: BridgeTypedValue, role: string): void { + if (!payload || !isBridgeIdentifier(payload.typeName)) { + throw new Error(`Bridge ${role} must declare a valid type name`); + } + const value: ESObject = payload.value; + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + throw new Error(`Bridge ${role} '${payload.typeName}' is not an N-API data value`); + } + if ( + (typeof value === "string" || value instanceof Uint8Array) && + value.length > MAX_PAYLOAD_BYTES + ) { + throw new Error(`Bridge ${role} exceeds ${MAX_PAYLOAD_BYTES} bytes`); + } + } + + private assertResponsePayload( + pluginId: string, + action: string, + responseTypeName: string, + response: BridgeTypedValue, + ): void { + this.assertTypedValue(response, "response"); + if (response.typeName !== responseTypeName) { + throw new Error( + `Bridge plugin '${pluginId}.${action}' returned ${response.typeName}, expected ${responseTypeName}`, + ); + } + } + + private assertActive(): void { + if (this.disposed) { + throw new Error(`Bridge session '${this.sessionId}' is already disposed`); + } + } + + private validateFactory(factory: BridgePluginFactory): void { + if (!isBridgeIdentifier(factory.id) || factory.version <= 0) { + throw new Error(`Invalid bridge plugin factory for module ${this.moduleName}`); + } + for (const requirement of normalizedRequirements(factory.requires)) { + if ( + requirement !== "ability" && + requirement !== "window-stage" && + requirement !== "ui-context" + ) { + throw new Error(`Invalid context requirement '${requirement}' for '${factory.id}'`); + } + } + } + + private validatePlugin(factory: BridgePluginFactory, plugin: BridgePlugin): void { + if (!isBridgeIdentifier(plugin.id) || plugin.version <= 0) { + throw new Error(`Invalid bridge plugin declaration for module ${this.moduleName}`); + } + if ( + plugin.id !== factory.id || + plugin.version !== factory.version || + plugin.execution !== factory.execution || + !sameRequirements(factory.requires, plugin.requires) + ) { + throw new Error(`Bridge plugin '${factory.id}' does not match its factory contract`); + } + } + + private async withTimeout( + operation: Promise, + timeoutMs: number, + pluginId: string, + action: string, + onTimeout: (reason: Error) => void, + ): Promise { + let timer = 0; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const reason = new Error( + `Bridge call '${pluginId}.${action}' timed out after ${timeoutMs}ms`, + ); + onTimeout(reason); + reject(reason); + }, timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + clearTimeout(timer); + } + } +} + +/** + * Session registry is transport plumbing only. It is keyed by a NativeAbility-created session + * instead of a process-global Helper, so stale calls cannot reach a recreated ability instance. + */ +export class BridgeHostRegistry { + private static readonly sessions: Map> = new Map(); + private static nextSession: number = 1; + + static async open( + moduleNames: string[], + abilityContext: common.UIAbilityContext, + factories: BridgePluginFactory[], + ): Promise { + 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); + } + } catch (error) { + await BridgeHostRegistry.disposeHosts(hosts); + throw new Error(String(error)); + } + BridgeHostRegistry.sessions.set(sessionId, hosts); + return sessionId; + } + + static async invokeAsync( + sessionId: string, + moduleName: string, + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + timeoutMs: number, + ): Promise { + return await BridgeHostRegistry.host(sessionId, moduleName).invokeAsync( + pluginId, + pluginVersion, + action, + requestTypeName, + responseTypeName, + value, + timeoutMs, + ); + } + + static invokeSync( + sessionId: string, + moduleName: string, + pluginId: string, + pluginVersion: number, + action: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject { + return BridgeHostRegistry.host(sessionId, moduleName).invokeSync( + pluginId, + pluginVersion, + action, + requestTypeName, + responseTypeName, + value, + ); + } + + static async setWindowStage( + sessionId: string, + moduleName: string, + windowStage: window.WindowStage, + ): Promise { + await BridgeHostRegistry.host(sessionId, moduleName).setWindowStage(windowStage); + } + + static async clearWindowStage(sessionId: string, moduleName: string): Promise { + await BridgeHostRegistry.host(sessionId, moduleName).clearWindowStage(); + } + + static async setUiContext( + sessionId: string, + moduleName: string, + uiContext: UIContext, + ): Promise { + await BridgeHostRegistry.host(sessionId, moduleName).setUiContext(uiContext); + } + + static async clearUiContext(sessionId: string, moduleName: string): Promise { + await BridgeHostRegistry.host(sessionId, moduleName).clearUiContext(); + } + + static async emitLifecycle( + sessionId: string, + moduleName: string, + event: BridgeLifecycleEvent, + ): Promise { + await BridgeHostRegistry.host(sessionId, moduleName).emitLifecycle(event); + } + + static attachEventSink( + sessionId: string, + moduleName: string, + mainThreadSink: BridgeMainThreadEventSink, + ): void { + BridgeHostRegistry.host(sessionId, moduleName).attachEventSink(mainThreadSink); + } + + static detachEventSink(sessionId: string, moduleName: string): void { + const host = BridgeHostRegistry.sessions.get(sessionId)?.get(moduleName); + host?.detachEventSink(); + } + + static async dispose(sessionId: string): Promise { + const hosts = BridgeHostRegistry.sessions.get(sessionId); + if (!hosts) { + return; + } + BridgeHostRegistry.sessions.delete(sessionId); + await BridgeHostRegistry.disposeHosts(hosts); + } + + private static host(sessionId: string, moduleName: string): BridgeHost { + const host = BridgeHostRegistry.sessions.get(sessionId)?.get(moduleName); + if (!host) { + throw new Error( + `No bridge host for module '${moduleName}'. NativeAbility may have been destroyed or is not hosting this XComponent.`, + ); + } + return host; + } + + private static async disposeHosts(hosts: Map): Promise { + const values = Array.from(hosts.values()); + for (const host of values) { + await host.dispose(); + } + hosts.clear(); + } +} diff --git a/native_ability/src/main/ets/bridge/BridgeNodeSlot.ets b/native_ability/src/main/ets/bridge/BridgeNodeSlot.ets new file mode 100644 index 00000000..311b2a05 --- /dev/null +++ b/native_ability/src/main/ets/bridge/BridgeNodeSlot.ets @@ -0,0 +1,313 @@ +import { UIContext } from "@ohos.arkui.UIContext"; +import { FrameNode, NodeController } from "@ohos.arkui.node"; + +export const DEFAULT_PLUGIN_NODE_SLOT = "xcomponent-overlay"; + +type NodeCleanup = () => void; + +interface MountedNode { + node: FrameNode; + cleanup?: NodeCleanup; +} + +interface SlotWaiter { + resolve: (slot: BridgeNodeSlot) => void; + reject: (reason: Error) => void; +} + +/** + * Owns one ArkUI FrameNode root. Plugins mount child FrameNodes here; the core never needs to + * know whether the child is a WebView, a native node, a BuilderNode, or another plugin surface. + */ +export class BridgeNodeSlotController 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) { + this.root = new FrameNode(uiContext); + } + return this.root; + } + + rootNode(): FrameNode { + return this.makeNode(this.uiContext); + } + + disposeRoot(): void { + this.root?.dispose(); + this.root = null; + } +} + +/** A module/session-scoped slot that a plugin can use to mount arbitrary ArkUI FrameNodes. */ +export class BridgeNodeSlot { + private readonly mountedNodes: Map = new Map(); + private disposed = false; + readonly sessionId: string; + readonly moduleName: string; + readonly slotId: string; + readonly uiContext: UIContext; + private readonly controller: BridgeNodeSlotController; + + constructor( + sessionId: string, + moduleName: string, + slotId: string, + uiContext: UIContext, + controller: BridgeNodeSlotController, + ) { + this.sessionId = sessionId; + this.moduleName = moduleName; + this.slotId = slotId; + this.uiContext = uiContext; + this.controller = controller; + } + + mount(key: string, node: FrameNode, cleanup?: NodeCleanup): void { + this.assertActive(); + if (!key || !/^[A-Za-z0-9._-]+$/.test(key)) { + throw new Error("Bridge node mount key must contain only letters, digits, '.', '_' or '-'"); + } + this.unmount(key); + try { + this.controller.rootNode().appendChild(node); + } catch (error) { + throw new Error(`Unable to mount bridge node '${key}': ${String(error)}`); + } + this.mountedNodes.set(key, { node, cleanup }); + } + + unmount(key: string): void { + const mounted = this.mountedNodes.get(key); + if (!mounted) { + return; + } + this.mountedNodes.delete(key); + try { + this.controller.rootNode().removeChild(mounted.node); + } catch {} + try { + mounted.cleanup?.(); + } catch {} + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + const keys = Array.from(this.mountedNodes.keys()); + for (const key of keys) { + this.unmount(key); + } + this.controller.disposeRoot(); + } + + isActive(): boolean { + return !this.disposed; + } + + private assertActive(): void { + if (this.disposed) { + throw new Error(`Bridge node slot '${this.slotId}' is no longer mounted`); + } + } +} + +function slotKey(sessionId: string, moduleName: string, slotId: string): string { + return `${sessionId}:${moduleName}:${slotId}`; +} + +/** + * ArkTS-only rendezvous point between a plugin factory and an explicitly placed node host. + * Context objects and FrameNodes do not cross the N-API boundary. + */ +export class BridgeNodeSlotRegistry { + private static readonly slots: Map = new Map(); + private static readonly waiters: Map = new Map(); + + static attach( + sessionId: string, + moduleName: string, + slotId: string, + uiContext: UIContext, + controller: BridgeNodeSlotController, + ): BridgeNodeSlot { + if (!slotId || !/^[A-Za-z0-9._-]+$/.test(slotId)) { + throw new Error("Bridge node slotId must contain only letters, digits, '.', '_' or '-'"); + } + const key = slotKey(sessionId, moduleName, slotId); + const existing = BridgeNodeSlotRegistry.slots.get(key); + if (existing?.isActive()) { + throw new Error(`Bridge node slot '${slotId}' is already mounted for ${moduleName}`); + } + if (existing !== undefined) { + BridgeNodeSlotRegistry.slots.delete(key); + } + const slot = new BridgeNodeSlot(sessionId, moduleName, slotId, uiContext, controller); + BridgeNodeSlotRegistry.slots.set(key, slot); + const waiters = BridgeNodeSlotRegistry.waiters.get(key); + if (waiters !== undefined) { + BridgeNodeSlotRegistry.waiters.delete(key); + for (const waiter of waiters) { + waiter.resolve(slot); + } + } + return slot; + } + + static require(sessionId: string, moduleName: string, slotId: string): BridgeNodeSlot { + const key = slotKey(sessionId, moduleName, slotId); + const slot = BridgeNodeSlotRegistry.slots.get(key); + if (!slot?.isActive()) { + if (slot !== undefined) { + BridgeNodeSlotRegistry.slots.delete(key); + } + throw new Error( + `Bridge node slot '${slotId}' is not mounted for ${moduleName}; place DefaultXComponent or BridgeNodeHost before invoking this plugin`, + ); + } + return slot; + } + + /** + * Waits for a business-owned named node layer to mount. This is a lifecycle rendezvous: attach + * resolves it, and the caller's cancellation signal rejects it. It intentionally has no + * readiness timer because page composition time is not an error condition. Async plugins use + * this instead of failing when a page is still composing its BridgeNodeHost; sync plugins must + * keep using `require`, because they cannot suspend a main-thread platform callback. + */ + static async waitFor( + sessionId: string, + moduleName: string, + slotId: string, + onCancel: (listener: () => void) => () => void, + ): Promise { + if (!slotId || !/^[A-Za-z0-9._-]+$/.test(slotId)) { + throw new Error("Bridge node slotId must contain only letters, digits, '.', '_' or '-'"); + } + const key = slotKey(sessionId, moduleName, slotId); + const current = BridgeNodeSlotRegistry.slots.get(key); + if (current?.isActive()) { + return current; + } + if (current !== undefined) { + BridgeNodeSlotRegistry.slots.delete(key); + } + return await new Promise((resolve, reject) => { + let settled = false; + let unsubscribe: () => void = (): void => {}; + const waiter: SlotWaiter = { + resolve: (slot: BridgeNodeSlot): void => { + if (settled) { + return; + } + settled = true; + unsubscribe(); + resolve(slot); + }, + reject: (reason: Error): void => { + if (settled) { + return; + } + settled = true; + unsubscribe(); + reject(reason); + }, + }; + const cancel = (): void => { + BridgeNodeSlotRegistry.removeWaiter(key, waiter); + waiter.reject( + new Error(`Bridge node slot '${slotId}' wait was cancelled for ${moduleName}`), + ); + }; + const waiters = BridgeNodeSlotRegistry.waiters.get(key) ?? []; + waiters.push(waiter); + BridgeNodeSlotRegistry.waiters.set(key, waiters); + // `onCancel` may invoke `cancel` synchronously when the call was already cancelled. The + // waiter is registered first so that path can remove it without leaving a stale entry. + unsubscribe = onCancel(cancel); + }); + } + + static detach(slot: BridgeNodeSlot): void { + const key = slotKey(slot.sessionId, slot.moduleName, slot.slotId); + if (BridgeNodeSlotRegistry.slots.get(key) !== slot) { + return; + } + BridgeNodeSlotRegistry.slots.delete(key); + slot.dispose(); + } + + private static removeWaiter(key: string, waiter: SlotWaiter): void { + const waiters = BridgeNodeSlotRegistry.waiters.get(key); + if (waiters === undefined) { + return; + } + const index = waiters.indexOf(waiter); + if (index >= 0) { + waiters.splice(index, 1); + } + if (waiters.length === 0) { + BridgeNodeSlotRegistry.waiters.delete(key); + } + } +} + +/** + * A business-placeable host for a named plugin node layer. `underlay` and `foreground` keep layout + * ownership in the business page while plugins receive only the neutral FrameNode mount point. + */ +@Component +export struct BridgeNodeHost { + moduleName: string = ""; + slotId: string = "plugin-content"; + @StorageProp("bridgeSessionId") bridgeSessionId: string = ""; + private controller = new BridgeNodeSlotController(this.getUIContext()); + private slot?: BridgeNodeSlot; + + @Builder + private empty(): void {} + + @BuilderParam underlay: () => void = this.empty; + @BuilderParam foreground: () => void = this.empty; + + aboutToAppear(): void { + const moduleName = this.moduleName.trim(); + if (!moduleName || !this.bridgeSessionId) { + throw new Error("BridgeNodeHost requires moduleName and an active bridgeSessionId"); + } + this.slot = BridgeNodeSlotRegistry.attach( + this.bridgeSessionId, + moduleName, + this.slotId, + this.getUIContext(), + this.controller, + ); + } + + aboutToDisappear(): void { + if (this.slot) { + BridgeNodeSlotRegistry.detach(this.slot); + this.slot = undefined; + } + } + + build() { + Stack() { + this.underlay() + NodeContainer(this.controller) + .width("100%") + .height("100%") + this.foreground() + } + .width("100%") + .height("100%"); + } +} diff --git a/native_ability/src/main/ets/components/DefaultXComponent.ets b/native_ability/src/main/ets/components/DefaultXComponent.ets index 50767adc..83fd2984 100644 --- a/native_ability/src/main/ets/components/DefaultXComponent.ets +++ b/native_ability/src/main/ets/components/DefaultXComponent.ets @@ -1,167 +1,156 @@ import { NodeContent } from "@kit.ArkUI"; +import { BridgeBindings, Module } from "../ability/type"; +import { BridgeHostRegistry } from "../bridge/BridgeHost"; import { - ArkHelper, - WebViewInitData as NativeWebViewInitData, - WindowAvoidAreaInfo, -} from "../ability/type"; -import { exit, objectAssign } from "../helper"; -import { Loadable } from "../helper/loadable"; -import { requestPermission } from "../helper/permission"; -import common from "@ohos.app.ability.common"; -import window from "@ohos.window"; -import { - EmbeddedWebviewManager, - RustWebviewNodeController, - WebviewStyle, - WebviewInitData, -} from "../webview/DefaultWebview"; + BridgeNodeSlot, + BridgeNodeSlotController, + BridgeNodeSlotRegistry, + DEFAULT_PLUGIN_NODE_SLOT, +} from "../bridge/BridgeNodeSlot"; +import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; export const RouteName = "NativeAbility"; + +/** + * 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 separate + * HAR host component and the application places that component in its own layout. + */ @Component export struct DefaultXComponent { moduleName: string = ""; private rootSlot = new NodeContent(); - private webviewController = new RustWebviewNodeController(this.getUIContext()); - private embeddedWebviewManager = new EmbeddedWebviewManager(this.getUIContext()); - private nativeModule: ESObject; - private helper: ArkHelper = { - exit, - requestPermission: async (permission: string | string[]): Promise => { - const context = this.getUIContext().getHostContext() as common.UIAbilityContext; - return await requestPermission(context, permission); - }, - getWindowAvoidArea: (type: number): WindowAvoidAreaInfo | undefined => { - try { - const context = this.getUIContext().getHostContext() as common.UIAbilityContext; - const win = context.windowStage.getMainWindowSync(); - const avoidType = type as window.AvoidAreaType; - return { - type: avoidType, - area: win.getWindowAvoidArea(avoidType), - } as WindowAvoidAreaInfo; - } catch (_) { - return undefined; - } - }, - createWebview: (data: NativeWebViewInitData) => { - const initScripts: ScriptItem[] = (data?.initializationScripts || []).map((i) => { - return { - script: i, - scriptRules: ["*"], - } as ScriptItem; - }); - const init: WebviewInitData = { - webTag: data?.id, - url: data?.url, - html: data?.html, - headers: data?.headers, - style: (data.style || {}) as WebviewStyle, - javascriptEnable: data?.javascriptEnable ?? true, - userAgent: data?.userAgent, - devtools: data?.devtools, - autoplay: data?.autoplay, - initializationScripts: initScripts, - onDragAndDrop: data?.onDragAndDrop, - onDownloadStart: data?.onDownloadStart, - onDownloadEnd: data?.onDownloadEnd, - onNavigationRequest: data?.onNavigationRequest, - onTitleChange: data?.onTitleChange, - } as WebviewInitData; - - init.style = init.style || {}; - - if (data?.transparent && !init.style.backgroundColor) { - init.style.backgroundColor = Color.Transparent; - } - const ret = this.webviewController.addWebview(init); - const applyStyle = (style: WebviewStyle) => { - objectAssign(init.style as Record, style); - this.webviewController.updateWebviewStyle(ret.webTag, init.style as WebviewStyle); - }; - - ret.controller.setBackgroundColor = (color: string) => { - applyStyle({ backgroundColor: color }); - }; - - ret.controller.setVisible = (visible: boolean) => { - applyStyle({ visible }); - }; - - ret.controller.dispose = () => { - this.webviewController.removeWebview(ret.webTag); - }; + private pluginNodeController = new BridgeNodeSlotController(this.getUIContext()); + private pluginNodeSlot?: BridgeNodeSlot; + private nativeModule?: Module; + @StorageProp("bridgeSessionId") bridgeSessionId: string = ""; + @StorageProp("loadMode") loadMode: "async" | "sync" = "async"; - return ret.controller; + 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, + ); }, - createEmbeddedWebview: (data: NativeWebViewInitData) => { - const initScripts: ScriptItem[] = (data?.initializationScripts || []).map((i) => { - return { - script: i, - scriptRules: ["*"], - } as ScriptItem; - }); - const init: WebviewInitData = { - webTag: data?.id, - url: data?.url, - html: data?.html, - headers: data?.headers, - style: (data.style || {}) as WebviewStyle, - javascriptEnable: data?.javascriptEnable ?? true, - userAgent: data?.userAgent, - devtools: data?.devtools, - autoplay: data?.autoplay, - initializationScripts: initScripts, - onDragAndDrop: data?.onDragAndDrop, - onDownloadStart: data?.onDownloadStart, - onDownloadEnd: data?.onDownloadEnd, - onNavigationRequest: data?.onNavigationRequest, - onTitleChange: data?.onTitleChange, - } as WebviewInitData; - - init.style = init.style || {}; - - if (data?.transparent && !init.style.backgroundColor) { - init.style.backgroundColor = Color.Transparent; - } - - const ret = this.embeddedWebviewManager.createWebview(init); - const applyStyle = (style: WebviewStyle) => { - objectAssign(init.style as Record, style); - this.embeddedWebviewManager.updateWebviewStyle(ret.webTag, init.style as WebviewStyle); - }; - - ret.controller.setBackgroundColor = (color: string) => { - applyStyle({ backgroundColor: color }); - }; - - ret.controller.setVisible = (visible: boolean) => { - applyStyle({ visible }); - }; - - ret.controller.dispose = () => { - this.embeddedWebviewManager.removeWebview(ret.webTag); - }; - - return ret; + 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 => {}, }; - @StorageProp("loadMode") loadMode: "async" | "sync" = "async"; async aboutToAppear(): Promise { const moduleName = this.moduleName.trim(); if (!moduleName) { throw new Error("DefaultXComponent.moduleName is required"); } - this.nativeModule = await Loadable.load(moduleName, this.loadMode); - this.nativeModule.render(this.helper, this.rootSlot); + + const nativeModule = await NativeModuleLoader.load(moduleName, this.loadMode); + this.nativeModule = nativeModule; + BridgeHostRegistry.attachEventSink( + this.bridgeSessionId, + moduleName, + ( + pluginId: string, + event: string, + requestTypeName: string, + responseTypeName: string, + value: ESObject, + ): ESObject => { + const nativeModuleForSyncEvent = this.nativeModule; + if ( + nativeModuleForSyncEvent === undefined || + typeof nativeModuleForSyncEvent.onBridgeSyncEvent !== "function" + ) { + throw new Error("Native module does not export onBridgeSyncEvent"); + } + return nativeModuleForSyncEvent.onBridgeSyncEvent( + pluginId, + event, + requestTypeName, + responseTypeName, + value, + ); + }, + ); + nativeModule.render(this.bindings, this.rootSlot); + + // Attach the neutral node layer before UI-context activation. A plugin that needs an ArkUI + // surface can therefore mount its own FrameNode during onInstall without DefaultXComponent + // gaining any WebView- or capability-specific code. + this.pluginNodeSlot = BridgeNodeSlotRegistry.attach( + this.bridgeSessionId, + moduleName, + DEFAULT_PLUGIN_NODE_SLOT, + this.getUIContext(), + this.pluginNodeController, + ); + + // Rust must observe readiness before ArkTS plugin installation can emit an event. + nativeModule.onBridgeLifecycle?.("ui-context-ready"); + await BridgeHostRegistry.setUiContext(this.bridgeSessionId, moduleName, this.getUIContext()); + } + + aboutToDisappear(): void { + const moduleName = this.moduleName.trim(); + if (this.bridgeSessionId && moduleName) { + const nativeModule = this.nativeModule; + const pluginNodeSlot = this.pluginNodeSlot; + this.pluginNodeSlot = undefined; + BridgeHostRegistry.clearUiContext(this.bridgeSessionId, moduleName) + .then(() => nativeModule?.onBridgeLifecycle?.("ui-context-destroy")) + .catch(() => {}) + .finally(() => { + if (pluginNodeSlot) { + BridgeNodeSlotRegistry.detach(pluginNodeSlot); + } + BridgeHostRegistry.detachEventSink(this.bridgeSessionId, moduleName); + }); + } } build() { Stack() { ContentSlot(this.rootSlot) + // Keep the legacy overlay tree and its transparent hit-test policy intact. Plugins only + // replace the old WebView-specific controller with a neutral FrameNode mount point, so an + // existing XComponent keeps its sizing, stacking, and input behaviour. Row() { Column() { - NodeContainer(this.webviewController) + NodeContainer(this.pluginNodeController) .height("100%") .width("100%") }.width("100%") diff --git a/native_ability/src/main/ets/components/MainPage.ets b/native_ability/src/main/ets/components/MainPage.ets index 8b16126b..3d9c01b9 100644 --- a/native_ability/src/main/ets/components/MainPage.ets +++ b/native_ability/src/main/ets/components/MainPage.ets @@ -1,5 +1,6 @@ import { DefaultXComponent } from "./DefaultXComponent"; -import { Loadable } from "../helper/loadable"; +import { NativeModuleLoader } from "../runtime/NativeModuleLoader"; +import { invokeNativeBackPressInterceptor } from "../runtime/BackPress"; export const RouteName = "NativeAbility"; @@ -8,27 +9,20 @@ export const RouteName = "NativeAbility"; struct Index { @StorageProp("moduleName") moduleNames: string | string[] = ""; @StorageProp("loadMode") loadMode: "async" | "sync" = "async"; - private nativeModule: ESObject = null; @State private primaryModuleName: string = ""; async aboutToAppear(): Promise { - const moduleName = Loadable.resolvePrimaryModuleName(this.moduleNames); + const moduleName = NativeModuleLoader.resolveModuleNames(this.moduleNames)[0] ?? ""; if (!moduleName) { throw new Error("dynamic moduleName is empty"); } - this.nativeModule = await Loadable.load(moduleName, this.loadMode); + await NativeModuleLoader.load(moduleName, this.loadMode); this.primaryModuleName = moduleName; } onBackPress(): boolean { - if (typeof this.nativeModule?.onBackPressIntercept === "function") { - return this.nativeModule.onBackPressIntercept(); - } - if (typeof this.nativeModule?.on_back_press_intercept === "function") { - return this.nativeModule.on_back_press_intercept(); - } - return false; + return invokeNativeBackPressInterceptor(this.primaryModuleName); } build() { diff --git a/native_ability/src/main/ets/helper/index.ets b/native_ability/src/main/ets/helper/index.ets deleted file mode 100644 index a19ba1ff..00000000 --- a/native_ability/src/main/ets/helper/index.ets +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./os"; - -export * from "./random"; - -export * from "./object"; - -export * from "./permission"; diff --git a/native_ability/src/main/ets/helper/loadable.ets b/native_ability/src/main/ets/helper/loadable.ets deleted file mode 100644 index 8b55fa28..00000000 --- a/native_ability/src/main/ets/helper/loadable.ets +++ /dev/null @@ -1,69 +0,0 @@ -import { Module } from "../ability/type"; - -export class Loadable { - private static modMap: Record = {}; - - 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.startsWith("lib") || moduleName.endsWith(".so")) { - throw new Error(`moduleName must not include lib/.so wrapper: ${moduleName}`); - } - resolved.push(moduleName); - } - - return resolved; - } - - static resolvePrimaryModuleName(name: string | string[]): string { - const names = Loadable.resolveModuleNames(name); - return names.length > 0 ? names[0] : ""; - } - - static toLibraryName(moduleName: string): string { - return `lib${moduleName}.so`; - } - - static async load(moduleName: string, mode: "async" | "sync" = "async"): Promise { - const resolvedModuleName = Loadable.resolvePrimaryModuleName(moduleName); - const cached = Loadable.modMap[resolvedModuleName]; - if (cached) { - return cached; - } - - const libraryName = Loadable.toLibraryName(resolvedModuleName); - let currentMod: ESObject; - if (mode === "async") { - currentMod = await import(libraryName); - } else { - try { - currentMod = loadNativeModule(libraryName); - } catch (error) { - throw new Error(`${libraryName} failed to load: ${String(error)}`); - } - } - - let loadedModule: Module | null = null; - if (typeof currentMod?.render === "function" && typeof currentMod?.init === "function") { - loadedModule = currentMod as Module; - } else if ( - typeof currentMod?.default?.render === "function" && - typeof currentMod?.default?.init === "function" - ) { - loadedModule = currentMod.default as Module; - } - - if (!loadedModule) { - throw new Error(`${libraryName} is not a valid dynamic library`); - } - - Loadable.modMap[resolvedModuleName] = loadedModule; - return loadedModule; - } -} diff --git a/native_ability/src/main/ets/helper/object.ts b/native_ability/src/main/ets/helper/object.ts deleted file mode 100644 index 448e6af2..00000000 --- a/native_ability/src/main/ets/helper/object.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function objectAssign( - target: Record, - ...source: Object[] -): Record { - for (const items of source) { - for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(items))) { - target[key] = Reflect.get(items, key); - } - } - return target; -} diff --git a/native_ability/src/main/ets/helper/os.ets b/native_ability/src/main/ets/helper/os.ets deleted file mode 100644 index 6806fbc3..00000000 --- a/native_ability/src/main/ets/helper/os.ets +++ /dev/null @@ -1,9 +0,0 @@ -import process from "@ohos.process"; - -/** - * exit current application - */ -export const exit = (code: number) => { - const pro = new process.ProcessManager(); - pro.exit(code); -}; diff --git a/native_ability/src/main/ets/helper/permission.ets b/native_ability/src/main/ets/helper/permission.ets deleted file mode 100644 index a7035899..00000000 --- a/native_ability/src/main/ets/helper/permission.ets +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Permission request helper for OpenHarmony. - * Returns Promise for single/multiple permission requests. - */ - -import abilityAccessCtrl, { Permissions } from "@ohos.abilityAccessCtrl"; -import common from "@ohos.app.ability.common"; -import hilog from "@ohos.hilog"; - -const TAG = "PermissionHelper"; -const REQUEST_FAILED: number = -1; - -function normalizePermission(permission: string | string[]): string[] { - return Array.isArray(permission) ? permission : [permission]; -} - -/** - * Request permission(s) and return auth result code(s). - * Single input -> single code; array input -> code array with same order. - */ -export async function requestPermission( - context: common.UIAbilityContext, - permission: string | string[], -): Promise { - const isArrayInput = Array.isArray(permission); - - if (!context) { - hilog.error(0x0000, TAG, "requestPermission: context is null"); - return isArrayInput ? [] : REQUEST_FAILED; - } - - const permissionList = normalizePermission(permission).filter((item: string) => !!item); - if (permissionList.length === 0) { - hilog.error(0x0000, TAG, "requestPermission: permission is empty"); - return isArrayInput ? [] : REQUEST_FAILED; - } - - const requestPermissions: Array = permissionList as Array; - const resultCodes: Array = new Array(permissionList.length).fill(REQUEST_FAILED); - - try { - const atManager = abilityAccessCtrl.createAtManager(); - const result = await atManager.requestPermissionsFromUser(context, requestPermissions); - const authResults: Array = result.authResults || []; - - for (let i = 0; i < resultCodes.length; i++) { - resultCodes[i] = authResults[i] ?? REQUEST_FAILED; - } - } catch (err) { - hilog.error(0x0000, TAG, `requestPermission: failed, error: ${JSON.stringify(err)}`); - } - - return isArrayInput ? resultCodes : resultCodes[0]; -} diff --git a/native_ability/src/main/ets/helper/random.ets b/native_ability/src/main/ets/helper/random.ets deleted file mode 100644 index d329ccdf..00000000 --- a/native_ability/src/main/ets/helper/random.ets +++ /dev/null @@ -1,9 +0,0 @@ -export const randomString = (): string => { - const length = 10; - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let result = ""; - for (let i = 0; i < length; i++) { - result += charset.charAt(Math.floor(Math.random() * charset.length)); - } - return result; -}; diff --git a/native_ability/src/main/ets/runtime/BackPress.ets b/native_ability/src/main/ets/runtime/BackPress.ets new file mode 100644 index 00000000..c216e880 --- /dev/null +++ b/native_ability/src/main/ets/runtime/BackPress.ets @@ -0,0 +1,10 @@ +import { NativeModuleLoader } from "./NativeModuleLoader"; + +/** + * Dispatches an `@Entry` page's synchronous `onBackPress` callback to its already-loaded native + * module. Return this value directly from the page callback: `true` consumes back, `false` lets + * ArkUI perform its default navigation. + */ +export function invokeNativeBackPressInterceptor(moduleName: string): boolean { + return NativeModuleLoader.invokeBackPressInterceptor(moduleName); +} diff --git a/native_ability/src/main/ets/runtime/NativeModuleLoader.ets b/native_ability/src/main/ets/runtime/NativeModuleLoader.ets new file mode 100644 index 00000000..59098146 --- /dev/null +++ b/native_ability/src/main/ets/runtime/NativeModuleLoader.ets @@ -0,0 +1,84 @@ +import { Module } from "../ability/type"; + +/** Module loading is framework runtime infrastructure, not a capability Helper. */ +export class NativeModuleLoader { + private static modules: Record = {}; + + 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.startsWith("lib") || moduleName.endsWith(".so")) { + throw new Error(`moduleName must not include lib/.so wrapper: ${moduleName}`); + } + resolved.push(moduleName); + } + + return resolved; + } + + static async load(moduleName: string, mode: "async" | "sync" = "async"): Promise { + const resolvedModuleName = NativeModuleLoader.resolveModuleNames(moduleName)[0] ?? ""; + const cached = NativeModuleLoader.modules[resolvedModuleName]; + if (cached) { + return cached; + } + + const libraryName = `lib${resolvedModuleName}.so`; + let currentModule: ESObject; + if (mode === "async") { + currentModule = await import(libraryName); + } else { + try { + currentModule = loadNativeModule(libraryName); + } catch (error) { + throw new Error(`${libraryName} failed to load: ${String(error)}`); + } + } + + let module: Module | null = null; + if (typeof currentModule?.render === "function" && typeof currentModule?.init === "function") { + module = currentModule as Module; + } else if ( + typeof currentModule?.default?.render === "function" && + typeof currentModule?.default?.init === "function" + ) { + module = currentModule.default as Module; + } + + if (!module) { + throw new Error(`${libraryName} is not a valid dynamic library`); + } + + NativeModuleLoader.modules[resolvedModuleName] = module; + return module; + } + + /** + * Invokes a loaded module's synchronous back-press interceptor. This is intentionally separate + * from loading: ArkTS requires `onBackPress` to return synchronously, so a page must not trigger + * a dynamic import while deciding whether to consume the system back action. + */ + static invokeBackPressInterceptor(moduleName: string): boolean { + const module = NativeModuleLoader.modules[moduleName.trim()]; + if (module === undefined) { + return false; + } + try { + if (typeof module.onBackPressIntercept === "function") { + return module.onBackPressIntercept(); + } + if (typeof module.on_back_press_intercept === "function") { + return module.on_back_press_intercept(); + } + } catch (error) { + console.error(`Native back-press interceptor failed: ${String(error)}`); + } + return false; + } +} diff --git a/native_ability/src/main/ets/webview/DefaultWebview.ets b/native_ability/src/main/ets/webview/DefaultWebview.ets deleted file mode 100644 index 31a6336f..00000000 --- a/native_ability/src/main/ets/webview/DefaultWebview.ets +++ /dev/null @@ -1,405 +0,0 @@ -import { UIContext } from "@ohos.arkui.UIContext"; -import web_webview from "@ohos.web.webview"; -import { ComponentContent, NodeController, BuilderNode, FrameNode } from "@ohos.arkui.node"; -import { randomString } from "../helper"; -import { OnDownloadStartResult } from "../ability/type"; -import { getCookies, JsHelper } from "./Utils"; -import { WebHeader } from "@kit.ArkUI"; - -export interface WebviewStyle { - x?: number | string; - y?: number | string; - backgroundColor?: string | Color; - visible?: boolean; -} - -export interface WebviewInitData { - webTag?: string; - url: string; - style?: WebviewStyle; - controller?: WebviewController; - javascriptEnable?: boolean; - devtools?: boolean; - autoplay?: boolean; - userAgent?: string; - initializationScripts?: ScriptItem[]; - headers?: Record; - html?: string; - onDragAndDrop?: (event: string) => void; - onDownloadStart?: (url: string, tempPath: string | undefined) => OnDownloadStartResult; - onDownloadEnd?: (url: string, tempPath: string | undefined, success: boolean) => void; - onNavigationRequest?: (url: string) => boolean; - onTitleChange?: (title: string) => void; -} - -interface WebviewNodeData extends WebviewInitData { - controller: WebviewController; - didInitialLoad?: boolean; -} - -@Builder -function WebBuilder(data: WebviewNodeData) { - // init with empty url and reload with loadUrl or loadData with onControllerAttach - Web({ src: "", controller: data.controller as web_webview.WebviewController }) - .width("100%") - .height("100%") - .position({ - x: data.style?.x ?? 0, - y: data.style?.y ?? 0, - }) - .backgroundColor(data?.style?.backgroundColor) - .visibility(data?.style?.visible === false ? Visibility.Hidden : Visibility.Visible) - .javaScriptAccess(data?.javascriptEnable) - .mediaPlayGestureAccess( - typeof data?.autoplay === "boolean" && data.autoplay === true ? false : true, - ) - .javaScriptOnDocumentStart(data?.initializationScripts) - .onControllerAttached(() => { - const ctrl = data.controller; - // load url or html string - if (data?.url) { - const header: WebHeader[] = Object.keys( - (data?.headers || {}) as Record, - ).reduce((t: WebHeader[], i) => { - t.push({ headerKey: i, headerValue: data.headers![i] } as WebHeader); - return t; - }, []); - ctrl.loadUrl(data.url, header); - } else { - ctrl.loadData(data!.html, "text/html", "UTF-8", " ", " "); - } - }) - .onLoadIntercept((event) => { - if (typeof data?.onNavigationRequest === "function") { - const url = event.data.getRequestUrl(); - const ret = data.onNavigationRequest(url); - return ret; - } - return false; - }) - .onTitleReceive((e) => { - if (typeof data?.onTitleChange === "function") { - data.onTitleChange(e.title); - } - }); -} - -@Builder -function EmbeddedWebBuilder(data: WebviewNodeData) { - // The embedded path needs a bounded ArkTS root so hit testing stays clipped - // to the host slot owned by native. A bare `Web` component can render in the - // right place while still participating in input as an unbounded layer. - Stack() { - Web({ src: "", controller: data.controller as web_webview.WebviewController }) - .width("100%") - .height("100%") - .backgroundColor(data?.style?.backgroundColor) - .javaScriptAccess(data?.javascriptEnable) - .mediaPlayGestureAccess( - typeof data?.autoplay === "boolean" && data.autoplay === true ? false : true, - ) - .javaScriptOnDocumentStart(data?.initializationScripts) - .onControllerAttached(() => { - if (data.didInitialLoad === true) { - return; - } - data.didInitialLoad = true; - const ctrl = data.controller; - if (data?.url) { - const header: WebHeader[] = Object.keys( - (data?.headers || {}) as Record, - ).reduce((t: WebHeader[], i) => { - t.push({ headerKey: i, headerValue: data.headers![i] } as WebHeader); - return t; - }, []); - ctrl.loadUrl(data.url, header); - } else { - ctrl.loadData(data!.html, "text/html", "UTF-8", " ", " "); - } - }) - .onLoadIntercept((event) => { - if (typeof data?.onNavigationRequest === "function") { - const url = event.data.getRequestUrl(); - const ret = data.onNavigationRequest(url); - return ret; - } - return false; - }) - .onTitleReceive((e) => { - if (typeof data?.onTitleChange === "function") { - data.onTitleChange(e.title); - } - }) - } - .width("100%") - .height("100%") - .clip(true) - .visibility(data?.style?.visible === false ? Visibility.Hidden : Visibility.Visible) - .hitTestBehavior(HitTestMode.Default); -} - -const webViewWrap = wrapBuilder<[WebviewNodeData]>(WebBuilder); -const embeddedWebViewWrap = wrapBuilder<[WebviewNodeData]>(EmbeddedWebBuilder); - -interface AddWebviewMethod { - webTag: string; - controller: JsHelper; -} - -export interface EmbeddedWebviewHandle extends AddWebviewMethod { - content: ComponentContent; -} - -interface EmbeddedWebviewEntry { - data: WebviewNodeData; - content: ComponentContent; -} - -function ensureWebviewNodeData(data: WebviewInitData): WebviewNodeData { - if (!data.webTag) { - data.webTag = randomString(); - } - if (!data.controller) { - data.controller = new web_webview.WebviewController(data.webTag) as WebviewController; - } - data.style = data.style || {}; - if (typeof data.javascriptEnable !== "boolean") { - data.javascriptEnable = true; - } - const prepared = data as WebviewNodeData; - prepared.didInitialLoad = prepared.didInitialLoad === true; - - if (prepared?.devtools) { - web_webview.WebviewController.setWebDebuggingAccess(true); - } - - return prepared; -} - -function setupDownloadDelegate(data: WebviewNodeData) { - if (typeof data?.onDownloadStart !== "function" && typeof data?.onDownloadEnd !== "function") { - return; - } - - const download = new web_webview.WebDownloadDelegate(); - - if (typeof data?.onDownloadStart === "function") { - download.onBeforeDownload((e) => { - const url = e.getUrl(); - const tempPath = e.getFullPath(); - const ret = data.onDownloadStart!(url, tempPath); - - if (ret.allow) { - e.start(ret.tempPath || tempPath); - } else { - e.cancel(); - } - }); - } - - if (typeof data?.onDownloadEnd === "function") { - download.onDownloadFinish((e) => { - const url = e.getUrl(); - const tempPath = e.getFullPath(); - data.onDownloadEnd!(url, tempPath, true); - }); - download.onDownloadFailed((e) => { - const url = e.getUrl(); - data.onDownloadEnd!(url, undefined, false); - }); - } - - data.controller.setDownloadDelegate(download); -} - -function buildJsHelper(controller: WebviewController): JsHelper { - const getUrl = () => { - return controller.getUrl(); - }; - const getCookiesHelper = (url: string) => { - return getCookies(url) as string; - }; - const loadUrl = (url: string, header?: Record) => { - const headers = Object.keys((header || {}) as Record).reduce((t, i) => { - if (!!header![i]) { - t.push({ headerKey: i, headerValue: header![i] }); - } - return t; - }, [] as Array); - controller.loadUrl(url, headers); - }; - - const loadHtml = (html: string) => { - controller.loadData(html, "text/html", "UTF-8", " ", " "); - }; - - const zoom = (scale: number) => { - controller.zoom(scale); - }; - - const refresh = () => { - controller.refresh(); - }; - - const requestFocus = () => { - controller.requestFocus(); - }; - - const clearAllBrowsingData = () => { - web_webview.WebStorage.deleteAllData(true); - web_webview.WebDataBase.deleteHttpAuthCredentials(); - controller.removeCache(true); - controller.clearHistory(); - }; - - const runJavaScript = (code: string, cb: (result?: string) => void) => { - controller.runJavaScript(code).then((ret) => { - cb(ret); - }); - }; - - return { - getUrl, - getCookies: getCookiesHelper, - loadUrl, - loadHtml, - zoom, - refresh, - requestFocus, - clearAllBrowsingData, - runJavaScript, - } as JsHelper; -} - -export class RustWebviewNodeController extends NodeController { - private rootNode: FrameNode | null = null; - private webviewEntries: Map = new Map(); - private webviewList: Map> = new Map(); - private uiContext: UIContext | null = null; - - constructor(uiContext: UIContext) { - super(); - this.uiContext = uiContext; - } - - updateWebviewStyle(webTag: string, style: WebviewStyle) { - const entry = this.webviewEntries.get(webTag); - const node = this.webviewList.get(webTag); - if (!entry || !node) { - return; - } - - entry.style = style; - node.update(entry); - } - - private buildData(controller: WebviewController) { - return buildJsHelper(controller); - } - - makeNode(uiContext: UIContext): FrameNode { - if (this.rootNode === null) { - this.rootNode = new FrameNode(uiContext); - } - return this.rootNode; - } - - addWebview(data: WebviewInitData): AddWebviewMethod { - const prepared = ensureWebviewNodeData(data); - const webTag = prepared.webTag!; - if (this.rootNode === null) { - this.rootNode = new FrameNode(this.uiContext!); - } - - const node = new BuilderNode<[WebviewNodeData]>(this.uiContext!); - node.build(webViewWrap, prepared); - - const controller = this.buildData(prepared.controller); - setupDownloadDelegate(prepared); - this.webviewEntries.set(webTag, prepared); - this.webviewList.set(webTag, node); - - this.rootNode?.appendChild(node.getFrameNode()); - - return { - webTag, - controller, - }; - } - - removeWebview(webTag: string) { - const node = this.webviewList.get(webTag); - if (!node) { - return; - } - - this.webviewEntries.delete(webTag); - this.webviewList.delete(webTag); - - this.rootNode?.removeChild(node.getFrameNode()); - } -} - -export class EmbeddedWebviewManager { - private uiContext: UIContext; - private entries: Map = new Map(); - - constructor(uiContext: UIContext) { - this.uiContext = uiContext; - } - - createWebview(data: WebviewInitData): EmbeddedWebviewHandle { - const prepared = ensureWebviewNodeData(data); - const webTag = prepared.webTag!; - setupDownloadDelegate(prepared); - - const content = new ComponentContent( - this.uiContext, - embeddedWebViewWrap, - prepared, - ); - - content.update(prepared); - - const controller = buildJsHelper(prepared.controller); - this.entries.set(webTag, { - data: prepared, - content, - }); - - return { - webTag, - controller, - content, - }; - } - - updateWebviewStyle(webTag: string, style: WebviewStyle) { - const entry = this.entries.get(webTag); - if (!entry) { - return; - } - - entry.data.style = style; - entry.content.update(entry.data); - } - - removeWebview(webTag: string) { - const entry = this.entries.get(webTag); - if (!entry) { - return; - } - - this.entries.delete(webTag); - entry.content.dispose(); - } -} - -// extend WebviewController method -declare class WebviewController extends web_webview.WebviewController { - getCookies: (url: string) => string; - setBackgroundColor: (color: string) => void; - setVisible: (visible: boolean) => void; - dispose: () => void; - clearAllBrowsingData: () => void; -} diff --git a/native_ability/src/main/ets/webview/Utils.ets b/native_ability/src/main/ets/webview/Utils.ets deleted file mode 100644 index 892668cd..00000000 --- a/native_ability/src/main/ets/webview/Utils.ets +++ /dev/null @@ -1,27 +0,0 @@ -import webview from "@ohos.web.webview"; - -export const getCookies = (url: string): string => { - return webview.WebCookieManager.fetchCookieSync(url); -}; - -export interface JsWebviewStyle { - x?: number | string; - y?: number | string; - backgroundColor?: string | Color; - visible?: boolean; -} - -export interface JsHelper { - getCookies: (url: string) => string; - getUrl: () => string; - loadUrl: (url: string, header?: Record) => void; - loadHtml: (html: string) => void; - zoom: (scale: number) => void; - refresh: () => void; - requestFocus: () => void; - runJavaScript: (code: string, callback: (result?: string) => void) => void; - setBackgroundColor: (color: string) => void; - setVisible: (visible: boolean) => void; - dispose: () => void; - clearAllBrowsingData: () => void; -} diff --git a/package/README.md b/package/README.md index 47d44b1d..321af4ba 100644 --- a/package/README.md +++ b/package/README.md @@ -1,6 +1,9 @@ # @ohos-rs/ability -`@ohos-rs/ability` provides ArkTS-side helpers for loading native modules and forwarding OpenHarmony lifecycle events into native code. The same `NativeAbility` entry can be reused by Rust modules and C/SDL-style native modules. +`@ohos-rs/ability` provides the ArkTS-side runtime for loading native modules, forwarding +OpenHarmony lifecycle events, and hosting generic bridge plugins. Concrete capabilities live in +separate HAR packages; the core package does not contain permission, window, exit, or WebView +helpers. ## Install @@ -43,7 +46,8 @@ When using `sync`, add the corresponding library to `build-profile.json5` runtim ### `DefaultXComponent` -`DefaultXComponent` loads the native module and binds the default native rendering surface. +`DefaultXComponent` loads the native module and binds the default native rendering surface. It +also exposes the generic `xcomponent-overlay` node slot for capability plugins. ```ts import { DefaultXComponent } from "@ohos-rs/ability"; @@ -63,6 +67,68 @@ struct Index { } ``` +### Plugins and node slots + +Compose ArkTS plugin factories explicitly in `NativeAbility.bridgePlugins`. A capability that +needs UI nodes mounts a `FrameNode` into a generic slot; the framework never embeds a WebView +special case. + +```ts +import { BridgeNodeHost, DefaultXComponent, NativeAbility } from "@ohos-rs/ability"; +import { createWebviewPlugin } from "@ohos-rs/plugin-webview"; + +export default class EntryAbility extends NativeAbility { + public moduleName = "demo_native"; + public bridgePlugins = [createWebviewPlugin()]; +} + +@Entry +@Component +struct Page { + @Builder BusinessOverlay() { + Text("business overlay") + } + + build() { + Stack() { + DefaultXComponent({ moduleName: "demo_native" }) + BridgeNodeHost({ + moduleName: "demo_native", + slotId: "webview-panel", + foreground: this.BusinessOverlay, + }) + } + } +} +``` + +### Typed bridge values + +ArkTS plugins receive a real N-API value with a stable type name, rather than a mandatory JSON +envelope. Check the name at the capability boundary and return the declared response name. + +```ts +import type { AsyncBridgePlugin, BridgeTypedValue } from "@ohos-rs/ability"; + +class ProfilePlugin implements AsyncBridgePlugin { + // id/version/execution omitted + async invokeAsync(_action: string, request: BridgeTypedValue): Promise { + if (request.typeName !== "account.Profile") { + throw new Error("unexpected bridge type"); + } + const profile = request.value as { userId: string; visits: number }; + return { + typeName: "account.Profile", + value: { userId: profile.userId, visits: profile.visits + 1 } as ESObject, + }; + } +} +``` + +`std.string` and `std.bytes` are built-in names; application-owned `#[napi(object)]` structs use +an explicit Rust `impl_bridge_napi_type!(Type, "name")` contract. The bridge deliberately has no +JSON transport type. + ### Custom Page Example ```ts diff --git a/package/index.ets b/package/index.ets index 36b67fc5..ad22dbac 100644 --- a/package/index.ets +++ b/package/index.ets @@ -1,3 +1,29 @@ export { NativeAbility } from "./src/main/ets/ability/NativeAbility"; +export { + BridgeAbilityCreateLifecyclePayload, + BridgeEmptyLifecyclePayload, + BridgeMemoryLevelLifecyclePayload, + BridgeWindowStageEventLifecyclePayload, +} from "./src/main/ets/ability/type"; +export type { + AsyncBridgePlugin, + BridgeBindings, + BridgeCallContext, + BridgeContextRequirement, + BridgeLifecycleEvent, + BridgeLifecycleKind, + BridgeLifecyclePayload, + BridgeTypedValue, + BridgePlugin, + BridgePluginContext, + BridgePluginExecution, + BridgePluginFactory, + MainThreadSyncBridgePlugin, +} from "./src/main/ets/ability/type"; export { DefaultXComponent } from "./src/main/ets/components/DefaultXComponent"; -export * from "./src/main/ets/webview/DefaultWebview"; +export { + BridgeNodeHost, + BridgeNodeSlot, + BridgeNodeSlotRegistry, + DEFAULT_PLUGIN_NODE_SLOT, +} from "./src/main/ets/bridge/BridgeNodeSlot"; diff --git a/plugins/app-control/.gitignore b/plugins/app-control/.gitignore new file mode 100644 index 00000000..baf64219 --- /dev/null +++ b/plugins/app-control/.gitignore @@ -0,0 +1,3 @@ +/oh_modules +/.idea +/build diff --git a/plugins/app-control/BuildProfile.ets b/plugins/app-control/BuildProfile.ets new file mode 100644 index 00000000..f7ad5d5b --- /dev/null +++ b/plugins/app-control/BuildProfile.ets @@ -0,0 +1,17 @@ +/** + * Use these variables when you tailor your ArkTS code. They must be of the const type. + */ +export const HAR_VERSION = "1.0.0"; +export const BUILD_MODE_NAME = "debug"; +export const DEBUG = true; +export const TARGET_NAME = "default"; + +/** + * BuildProfile Class is used only for compatibility purposes. + */ +export default class BuildProfile { + static readonly HAR_VERSION = HAR_VERSION; + static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; + static readonly DEBUG = DEBUG; + static readonly TARGET_NAME = TARGET_NAME; +} diff --git a/plugins/app-control/README.md b/plugins/app-control/README.md new file mode 100644 index 00000000..e0d90e78 --- /dev/null +++ b/plugins/app-control/README.md @@ -0,0 +1,52 @@ +# @ohos-rs/plugin-app-control + +这是应用进程控制的 ArkTS HAR,对应 Rust crate +`openharmony-ability-plugin-app-control`。它实现主线程同步的 `terminate` action,并通过 +`@ohos.process` 的 `ProcessManager.exit(code)` 结束应用进程。 + +## 装配 + +```json5 +{ + "dependencies": { + "@ohos-rs/ability": "file:", + "@ohos-rs/plugin-app-control": "file:" + } +} +``` + +```ts +import { NativeAbility } from "@ohos-rs/ability"; +import { createAppControlPlugin } from "@ohos-rs/plugin-app-control"; + +export default class EntryAbility extends NativeAbility { + public bridgePlugins = [createAppControlPlugin()]; +} +``` + +Rust 必须同时注册 `AppControlBridgePlugin`,并只在当前 N-API callback 的 `Env` 中调用 +`AppControlExt::terminate`。完整 Rust 用法见 +[Rust facade README](../../crates/plugin-app-control/README.md)。 + +## Factory 契约 + +| 字段 | 值 | +| --- | --- | +| `id` / `version` | `ohos.app-control` / `1` | +| `execution` | `sync-main-thread` | +| `requires` | `["ability"]` | +| 支持 action | `terminate` | +| request → response | `ohos.app_control.TerminateRequest { code }` → `ohos.app_control.TerminateResponse { accepted }` | + +该 plugin 的 `invokeSync` 不能返回 Promise、不能 `await`,也不能等待 context。Host 在 Ability 未就绪、 +plugin 未安装或调用方走错异步入口时立即报错。 + +## 行为与安全边界 + +- `code` 必须是整数;类型或 action 不匹配直接抛错。 +- 成功调用 `ProcessManager.exit(code)` 后,调用方不得假设后续 UI 或 Rust 逻辑仍会运行。 +- 该 HAR 不提供业务层的“确认退出” UI;确认、保存状态和风险提示必须由业务在调用 Rust facade 前完成。 +- request/response 是具名 N-API object,禁止 JSON transport。 + +更多主线程同步约束与验收标准见 +[插件开发规范](../../docs/plugin-development-standard.md)。 diff --git a/plugins/app-control/build-profile.json5 b/plugins/app-control/build-profile.json5 new file mode 100644 index 00000000..a1f6c185 --- /dev/null +++ b/plugins/app-control/build-profile.json5 @@ -0,0 +1,9 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + }, + ], +} diff --git a/plugins/app-control/hvigorfile.ts b/plugins/app-control/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/app-control/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/app-control/index.ets b/plugins/app-control/index.ets new file mode 100644 index 00000000..b38e2a8c --- /dev/null +++ b/plugins/app-control/index.ets @@ -0,0 +1 @@ +export { createAppControlPlugin } from "./src/main/ets/AppControlPlugin"; diff --git a/plugins/app-control/oh-package-lock.json5 b/plugins/app-control/oh-package-lock.json5 new file mode 100644 index 00000000..9c5216ba --- /dev/null +++ b/plugins/app-control/oh-package-lock.json5 @@ -0,0 +1,29 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos-rs/ability@../../native_ability": "@ohos-rs/ability@../../native_ability", + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0", + "resolved": "../../native_ability", + "registryType": "local", + "dependencies": { + "libnative_ability.so": "file:./src/main/cpp/types/libnative_ability" + } + }, + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": { + "name": "libnative_ability.so", + "version": "1.0.0", + "resolved": "../../native_ability/src/main/cpp/types/libnative_ability", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/plugins/app-control/oh-package.json5 b/plugins/app-control/oh-package.json5 new file mode 100644 index 00000000..16e10a64 --- /dev/null +++ b/plugins/app-control/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "name": "@ohos-rs/plugin-app-control", + "version": "1.0.0", + "description": "Application-control capability plugin for @ohos-rs/ability", + "main": "index.ets", + "license": "Apache-2.0", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, +} diff --git a/plugins/app-control/src/main/ets/AppControlPlugin.ets b/plugins/app-control/src/main/ets/AppControlPlugin.ets new file mode 100644 index 00000000..82bb3f4c --- /dev/null +++ b/plugins/app-control/src/main/ets/AppControlPlugin.ets @@ -0,0 +1,65 @@ +import process from "@ohos.process"; +import { + BridgeCallContext, + BridgeTypedValue, + BridgePluginContext, + BridgePluginFactory, + MainThreadSyncBridgePlugin, +} from "@ohos-rs/ability"; + +const TERMINATE_REQUEST_TYPE = "ohos.app_control.TerminateRequest"; +const TERMINATE_RESPONSE_TYPE = "ohos.app_control.TerminateResponse"; + +interface TerminateRequest { + code: number; +} + +class TerminateResponse { + readonly accepted: boolean; + + constructor(accepted: boolean) { + this.accepted = accepted; + } +} + +function parseTerminateRequest(payload: BridgeTypedValue): TerminateRequest { + if (payload.typeName !== TERMINATE_REQUEST_TYPE || typeof payload.value !== "object") { + throw new Error(`ohos.app-control.terminate requires bridge type ${TERMINATE_REQUEST_TYPE}`); + } + const request = payload.value as TerminateRequest; + if (!Number.isInteger(request?.code)) { + throw new Error("terminate.code must be an integer"); + } + return request; +} + +class AppControlPlugin implements MainThreadSyncBridgePlugin { + readonly id = "ohos.app-control"; + readonly version = 1; + readonly execution: "sync-main-thread" = "sync-main-thread"; + readonly requires: ["ability"] = ["ability"]; + + invokeSync( + action: string, + payload: BridgeTypedValue, + _context: BridgeCallContext, + ): BridgeTypedValue { + if (action !== "terminate") { + throw new Error(`Unsupported ohos.app-control action '${action}'`); + } + const request = parseTerminateRequest(payload); + const manager = new process.ProcessManager(); + manager.exit(request.code); + return { typeName: TERMINATE_RESPONSE_TYPE, value: new TerminateResponse(true) }; + } +} + +export function createAppControlPlugin(): BridgePluginFactory { + return { + id: "ohos.app-control", + version: 1, + execution: "sync-main-thread", + requires: ["ability"], + create: (_context: BridgePluginContext): MainThreadSyncBridgePlugin => new AppControlPlugin(), + }; +} diff --git a/plugins/app-control/src/main/module.json5 b/plugins/app-control/src/main/module.json5 new file mode 100644 index 00000000..127236a0 --- /dev/null +++ b/plugins/app-control/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_app_control", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/permission/.gitignore b/plugins/permission/.gitignore new file mode 100644 index 00000000..baf64219 --- /dev/null +++ b/plugins/permission/.gitignore @@ -0,0 +1,3 @@ +/oh_modules +/.idea +/build diff --git a/plugins/permission/BuildProfile.ets b/plugins/permission/BuildProfile.ets new file mode 100644 index 00000000..f7ad5d5b --- /dev/null +++ b/plugins/permission/BuildProfile.ets @@ -0,0 +1,17 @@ +/** + * Use these variables when you tailor your ArkTS code. They must be of the const type. + */ +export const HAR_VERSION = "1.0.0"; +export const BUILD_MODE_NAME = "debug"; +export const DEBUG = true; +export const TARGET_NAME = "default"; + +/** + * BuildProfile Class is used only for compatibility purposes. + */ +export default class BuildProfile { + static readonly HAR_VERSION = HAR_VERSION; + static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; + static readonly DEBUG = DEBUG; + static readonly TARGET_NAME = TARGET_NAME; +} diff --git a/plugins/permission/README.md b/plugins/permission/README.md new file mode 100644 index 00000000..d733150d --- /dev/null +++ b/plugins/permission/README.md @@ -0,0 +1,59 @@ +# @ohos-rs/plugin-permission + +这是 `openharmony-ability-plugin-permission` 的 ArkTS 实现包。它在 `NativeAbility` 所属的 +Ability context 中调用 `abilityAccessCtrl.requestPermissionsFromUser`;业务 Rust 代码通过配对的 +Rust facade 发起请求,不直接调用本 HAR 的内部 action。 + +## 装配 + +在应用 `oh-package.json5` 中添加该 HAR(本仓库 demo 使用本地路径,发布场景替换为实际版本): + +```json5 +{ + "dependencies": { + "@ohos-rs/ability": "file:", + "@ohos-rs/plugin-permission": "file:" + } +} +``` + +在入口 Ability 显式注册 factory,并继续调用 `NativeAbility` 的生命周期实现: + +```ts +import { NativeAbility } from "@ohos-rs/ability"; +import { createPermissionPlugin } from "@ohos-rs/plugin-permission"; + +export default class EntryAbility extends NativeAbility { + public bridgePlugins = [createPermissionPlugin()]; +} +``` + +Rust 侧也必须在 `#[ability]` 初始化器注册 `PermissionBridgePlugin`。两端装配方式见 +[Rust facade README](../../crates/plugin-permission/README.md)。 + +## Factory 契约 + +| 字段 | 值 | +| --- | --- | +| `id` / `version` | `ohos.permission` / `1` | +| `execution` | `async` | +| `requires` | `["ability"]` | +| 支持 action | `request` | +| request → response | `ohos.permission.PermissionRequest` → `ohos.permission.PermissionResponse` | + +`PermissionPlugin` 只在 Ability context 可用后激活。它校验 `payload.typeName`、非空权限数组和每个 +权限名称,再调用系统授权 API;不接受 JSON payload 或未声明的 action。 + +## 行为 + +- `permissions` 以输入顺序传入,`codes` 以相同顺序返回。 +- 系统调用失败、缺失结果或未授权的回退码为 `-1`;这让 Rust facade 能保持每项权限一一对应。 +- 请求完成后会检查 `context.isActive()`。若 Ability 已销毁,Promise 以错误结束而不是返回过期结果。 +- 调用方仍必须在应用 manifest 中声明权限;运行时插件不能替代该配置。 + +## 维护要求 + +修改 action、字段或 typeName 时,必须同步修改 Rust crate、factory 版本、测试和 demo。请求/响应使用 +真实 N-API object,不得引入 `JSON.stringify` / `JSON.parse`。 + +完整线程、生命周期与验收规则见 [插件开发规范](../../docs/plugin-development-standard.md)。 diff --git a/plugins/permission/build-profile.json5 b/plugins/permission/build-profile.json5 new file mode 100644 index 00000000..a1f6c185 --- /dev/null +++ b/plugins/permission/build-profile.json5 @@ -0,0 +1,9 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + }, + ], +} diff --git a/plugins/permission/hvigorfile.ts b/plugins/permission/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/permission/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/permission/index.ets b/plugins/permission/index.ets new file mode 100644 index 00000000..fec94dc3 --- /dev/null +++ b/plugins/permission/index.ets @@ -0,0 +1 @@ +export { createPermissionPlugin } from "./src/main/ets/PermissionPlugin"; diff --git a/plugins/permission/oh-package-lock.json5 b/plugins/permission/oh-package-lock.json5 new file mode 100644 index 00000000..9c5216ba --- /dev/null +++ b/plugins/permission/oh-package-lock.json5 @@ -0,0 +1,29 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos-rs/ability@../../native_ability": "@ohos-rs/ability@../../native_ability", + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0", + "resolved": "../../native_ability", + "registryType": "local", + "dependencies": { + "libnative_ability.so": "file:./src/main/cpp/types/libnative_ability" + } + }, + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": { + "name": "libnative_ability.so", + "version": "1.0.0", + "resolved": "../../native_ability/src/main/cpp/types/libnative_ability", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/plugins/permission/oh-package.json5 b/plugins/permission/oh-package.json5 new file mode 100644 index 00000000..371683a5 --- /dev/null +++ b/plugins/permission/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "name": "@ohos-rs/plugin-permission", + "version": "1.0.0", + "description": "Permission capability plugin for @ohos-rs/ability", + "main": "index.ets", + "license": "Apache-2.0", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, +} diff --git a/plugins/permission/src/main/ets/PermissionPlugin.ets b/plugins/permission/src/main/ets/PermissionPlugin.ets new file mode 100644 index 00000000..c8477e0c --- /dev/null +++ b/plugins/permission/src/main/ets/PermissionPlugin.ets @@ -0,0 +1,93 @@ +import abilityAccessCtrl, { Permissions } from "@ohos.abilityAccessCtrl"; +import hilog from "@ohos.hilog"; +import { + AsyncBridgePlugin, + BridgeCallContext, + BridgeTypedValue, + BridgePluginContext, + BridgePluginFactory, +} from "@ohos-rs/ability"; + +const TAG = "PermissionBridgePlugin"; +const REQUEST_FAILED = -1; +const PERMISSION_REQUEST_TYPE = "ohos.permission.PermissionRequest"; +const PERMISSION_RESPONSE_TYPE = "ohos.permission.PermissionResponse"; + +interface PermissionRequestPayload { + permissions: string[]; +} + +class PermissionResponse { + readonly codes: number[]; + + constructor(codes: number[]) { + this.codes = codes; + } +} + +function parsePermissions(payload: BridgeTypedValue): string[] { + try { + if (payload.typeName !== PERMISSION_REQUEST_TYPE || typeof payload.value !== "object") { + throw new Error(`requires bridge type ${PERMISSION_REQUEST_TYPE}`); + } + const request = payload.value as PermissionRequestPayload; + if (!request || !Array.isArray(request.permissions) || request.permissions.length === 0) { + throw new Error("permissions must be a non-empty array"); + } + if (request.permissions.some((permission) => !permission?.trim())) { + throw new Error("permissions must contain only non-empty strings"); + } + return request.permissions; + } catch (error) { + throw new Error(`Invalid permission request: ${String(error)}`); + } +} + +class PermissionPlugin implements AsyncBridgePlugin { + readonly id = "ohos.permission"; + readonly version = 1; + readonly execution: "async" = "async"; + readonly requires: ["ability"] = ["ability"]; + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): Promise { + if (action !== "request") { + throw new Error(`Unsupported ohos.permission action '${action}'`); + } + const permissions = parsePermissions(payload); + const codes: number[] = []; + for (let index = 0; index < permissions.length; index++) { + codes.push(REQUEST_FAILED); + } + try { + const manager = abilityAccessCtrl.createAtManager(); + const result = await manager.requestPermissionsFromUser( + context.abilityContext, + permissions as Permissions[], + ); + const authResults = result.authResults || []; + for (let index = 0; index < codes.length; index++) { + codes[index] = authResults[index] ?? REQUEST_FAILED; + } + } catch (error) { + hilog.error(0x0000, TAG, `request failed: ${String(error)}`); + } + if (!context.isActive()) { + throw new Error("Ability was destroyed while permission request was pending"); + } + return { typeName: PERMISSION_RESPONSE_TYPE, value: new PermissionResponse(codes) }; + } +} + +export function createPermissionPlugin(): BridgePluginFactory { + return { + id: "ohos.permission", + version: 1, + execution: "async", + requires: ["ability"], + create: (_context: BridgePluginContext): AsyncBridgePlugin => new PermissionPlugin(), + }; +} diff --git a/plugins/permission/src/main/module.json5 b/plugins/permission/src/main/module.json5 new file mode 100644 index 00000000..d4a2de68 --- /dev/null +++ b/plugins/permission/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_permission", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/webview/.gitignore b/plugins/webview/.gitignore new file mode 100644 index 00000000..baf64219 --- /dev/null +++ b/plugins/webview/.gitignore @@ -0,0 +1,3 @@ +/oh_modules +/.idea +/build diff --git a/plugins/webview/BuildProfile.ets b/plugins/webview/BuildProfile.ets new file mode 100644 index 00000000..f7ad5d5b --- /dev/null +++ b/plugins/webview/BuildProfile.ets @@ -0,0 +1,17 @@ +/** + * Use these variables when you tailor your ArkTS code. They must be of the const type. + */ +export const HAR_VERSION = "1.0.0"; +export const BUILD_MODE_NAME = "debug"; +export const DEBUG = true; +export const TARGET_NAME = "default"; + +/** + * BuildProfile Class is used only for compatibility purposes. + */ +export default class BuildProfile { + static readonly HAR_VERSION = HAR_VERSION; + static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; + static readonly DEBUG = DEBUG; + static readonly TARGET_NAME = TARGET_NAME; +} diff --git a/plugins/webview/README.md b/plugins/webview/README.md new file mode 100644 index 00000000..1bb730e4 --- /dev/null +++ b/plugins/webview/README.md @@ -0,0 +1,83 @@ +# @ohos-rs/plugin-webview + +这是 `openharmony-ability-plugin-webview` 的 ArkTS HAR。它创建 ArkWeb `Web` / `WebviewController`、 +把自己的 `FrameNode` 挂到通用 `BridgeNodeSlot`,并把所有 controller 操作和 ArkWeb callback 转换为 +具名 N-API bridge 调用。 + +业务不直接保存 controller,也不应把 WebView 接口重新写回 `DefaultXComponent`。Rust facade 使用 +controller ID 操作 WebView;布局始终由业务页面和 `BridgeNodeHost` 决定。 + +## 装配 + +```json5 +{ + "dependencies": { + "@ohos-rs/ability": "file:", + "@ohos-rs/plugin-webview": "file:" + } +} +``` + +```ts +import { NativeAbility } from "@ohos-rs/ability"; +import { createWebviewPlugin } from "@ohos-rs/plugin-webview"; + +export default class EntryAbility extends NativeAbility { + public bridgePlugins = [createWebviewPlugin()]; +} +``` + +Rust 侧必须在 `#[ability]` 初始化器注册 `WebviewBridgePlugin`;自定义 scheme 也必须在该阶段通过 +`WebviewProtocol::register` 声明。完整 Rust 用法见 +[Rust facade README](../../crates/plugin-webview/README.md)。 + +## Factory 与 action + +| 项目 | 值 | +| --- | --- | +| `id` / `version` | `ohos.webview` / `1` | +| `execution` | `async` | +| `requires` | `["ui-context"]` | +| 默认 slot | `xcomponent-overlay` | +| 创建 | `create`:`CreateRequest` → `CreateResponse { id, slotId }` | +| 控制器操作 | `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` | + +所有 action 都校验 `BridgeTypedValue.typeName`,并只返回对应的具名 response。创建会等待 slot、 +controller attach、delegate/protocol/proxy 安装和首次 load 启动;它不是“提交一个未来渲染任务”后立刻 +成功。 + +## slot 与混合布局 + +无需自定义布局时,WebView 使用 `DefaultXComponent` 的 `xcomponent-overlay`。业务要控制层级时,在页面 +显式放置 `BridgeNodeHost` 并将同一 `slotId` 写进 Rust `WebviewCreateRequest`: + +```ts +Stack() { + DefaultXComponent({ moduleName: "demo_native" }) + BridgeNodeHost({ + moduleName: "demo_native", + slotId: "webview-panel", + underlay: this.BusinessUnderlay, + foreground: this.BusinessForeground, + }) +} +``` + +异步 slot 等待由 attach/cancel/session 生命周期驱动,不能用 `setTimeout` 轮询。slot detach 或 +Ability/session dispose 时,HAR 必须卸载自己创建的节点与 controller 状态,不能影响业务节点或其他插件。 + +## ArkWeb callback、custom protocol 与 JS + +- Rust 在 `create` 前声明 callback 订阅;HAR 依据订阅快照安装 ArkWeb delegate。导航请求、下载开始、 + 下载结束、标题变化、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,再进行首次导航。 +- URL custom protocol 与 `window..()` JavaScript proxy 是不同机制;前者处理资源请求, + 后者处理页面到 Rust 的方法调用。 +- `.transparent(true)` 的创建语义由 `CreateRequest` 保留:没有显式 background color 时使用透明背景。 + +完整的反向 typeName、线程、生命周期、slot cleanup 与验收条件见 +[插件开发规范](../../docs/plugin-development-standard.md)。 diff --git a/plugins/webview/build-profile.json5 b/plugins/webview/build-profile.json5 new file mode 100644 index 00000000..a1f6c185 --- /dev/null +++ b/plugins/webview/build-profile.json5 @@ -0,0 +1,9 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + }, + ], +} diff --git a/plugins/webview/hvigorfile.ts b/plugins/webview/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/webview/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/webview/index.ets b/plugins/webview/index.ets new file mode 100644 index 00000000..ce7d72c2 --- /dev/null +++ b/plugins/webview/index.ets @@ -0,0 +1 @@ +export { createWebviewPlugin } from "./src/main/ets/WebviewPlugin"; diff --git a/plugins/webview/oh-package-lock.json5 b/plugins/webview/oh-package-lock.json5 new file mode 100644 index 00000000..9c5216ba --- /dev/null +++ b/plugins/webview/oh-package-lock.json5 @@ -0,0 +1,29 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos-rs/ability@../../native_ability": "@ohos-rs/ability@../../native_ability", + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0", + "resolved": "../../native_ability", + "registryType": "local", + "dependencies": { + "libnative_ability.so": "file:./src/main/cpp/types/libnative_ability" + } + }, + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": { + "name": "libnative_ability.so", + "version": "1.0.0", + "resolved": "../../native_ability/src/main/cpp/types/libnative_ability", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/plugins/webview/oh-package.json5 b/plugins/webview/oh-package.json5 new file mode 100644 index 00000000..0cbd8a4f --- /dev/null +++ b/plugins/webview/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "name": "@ohos-rs/plugin-webview", + "version": "1.0.0", + "description": "WebView capability plugin for @ohos-rs/ability", + "main": "index.ets", + "license": "Apache-2.0", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, +} diff --git a/plugins/webview/src/main/ets/WebviewPlugin.ets b/plugins/webview/src/main/ets/WebviewPlugin.ets new file mode 100644 index 00000000..c11ceb03 --- /dev/null +++ b/plugins/webview/src/main/ets/WebviewPlugin.ets @@ -0,0 +1,1156 @@ +import { WebHeader } from "@kit.ArkUI"; +import { UIContext } from "@ohos.arkui.UIContext"; +import { BuilderNode, FrameNode } from "@ohos.arkui.node"; +import webview from "@ohos.web.webview"; +import { + AsyncBridgePlugin, + BridgeCallContext, + BridgeNodeSlot, + BridgeNodeSlotRegistry, + BridgeTypedValue, + BridgePluginContext, + BridgePluginFactory, + DEFAULT_PLUGIN_NODE_SLOT, +} from "@ohos-rs/ability"; + +const CREATE_REQUEST_TYPE = "ohos.webview.CreateRequest"; +const CREATE_RESPONSE_TYPE = "ohos.webview.CreateResponse"; +const CONTROLLER_REQUEST_TYPE = "ohos.webview.ControllerRequest"; +const ACKNOWLEDGEMENT_TYPE = "ohos.webview.Acknowledgement"; +const SCRIPT_REQUEST_TYPE = "ohos.webview.ScriptRequest"; +const SCRIPT_RESPONSE_TYPE = "ohos.webview.ScriptResponse"; +const STRING_RESPONSE_TYPE = "ohos.webview.StringResponse"; +const NAVIGATION_REQUEST_TYPE = "ohos.webview.NavigationRequest"; +const NAVIGATION_RESPONSE_TYPE = "ohos.webview.NavigationResponse"; +const DOWNLOAD_START_REQUEST_TYPE = "ohos.webview.DownloadStartRequest"; +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 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. + */ +interface WebviewDebuggingApi { + setWebDebuggingAccess?: (enabled: boolean) => void; +} + +function enableWebDebuggingIfAvailable(): void { + const debuggingApi = webview.WebviewController as unknown as WebviewDebuggingApi; + if (typeof debuggingApi.setWebDebuggingAccess !== "function") { + console.warn("WebView debugging is unavailable on this ArkWeb API level"); + return; + } + debuggingApi.setWebDebuggingAccess(true); +} + +interface WebviewStyle { + x?: number | string | null; + y?: number | string | null; + visible?: boolean | null; + backgroundColor?: string | null; +} + +interface WebviewInitializationScript { + script: string; + scriptRules: string[]; +} + +interface WebviewEventOptions { + navigationIntercept?: boolean | null; + downloadStart?: boolean | null; + downloadEnd?: boolean | null; + titleChange?: boolean | null; +} + +interface WebviewCreatePayload { + id: string; + slotId?: string; + url?: string | null; + html?: string | null; + style?: WebviewStyle | null; + javascriptEnabled?: boolean | null; + devtools?: boolean | null; + userAgent?: string | null; + autoplay?: boolean | null; + initializationScripts?: WebviewInitializationScript[] | null; + headers?: Record | null; + transparent?: boolean | null; + eventOptions?: WebviewEventOptions | null; +} + +interface ControllerPayload { + id: string; + slotId?: string | null; + visible?: boolean | null; + color?: string | null; + url?: string | null; + html?: string | null; + headers?: Record | null; + zoom?: number | null; +} + +interface ScriptPayload { + id: string; + slotId?: string | null; + script: string; +} + +interface ManagedWebview extends WebviewCreatePayload { + controller: WebviewController; + style: WebviewStyle; + eventOptions: WebviewEventOptions; + didInitialLoad: boolean; + beginControllerAttach: () => boolean; + notifyNativeControllerAttached: () => void; + markControllerReady: () => void; + failControllerAttach: (reason: string) => void; + onNavigationRequest: (url: string) => boolean; + onTitleChange: (title: string) => void; + onDownloadStart: (url: string, tempPath: string | null) => WebviewDownloadStartResponse; + onDownloadEnd: (url: string, tempPath: string | null, success: boolean) => void; +} + +interface ControllerWaiter { + resolve: () => void; + reject: (reason: Error) => void; +} + +class WebviewEntry { + readonly data: ManagedWebview; + readonly node: BuilderNode<[ManagedWebview]>; + private attaching = false; + private attached = false; + private controllerFailure: Error | null = null; + private readonly controllerWaiters: ControllerWaiter[] = []; + + constructor(data: ManagedWebview, node: BuilderNode<[ManagedWebview]>) { + this.data = data; + this.node = node; + } + + beginControllerAttach(): boolean { + if (this.attaching || this.attached || this.controllerFailure !== null) { + return false; + } + this.attaching = true; + return true; + } + + markControllerReady(): void { + if (this.attached || this.controllerFailure !== null) { + return; + } + this.attached = true; + const waiters = this.controllerWaiters.splice(0, this.controllerWaiters.length); + for (const waiter of waiters) { + waiter.resolve(); + } + } + + async waitForController(onCancel: (listener: () => void) => () => void): Promise { + if (this.attached) { + return; + } + if (this.controllerFailure !== null) { + throw this.controllerFailure; + } + await new Promise((resolve, reject) => { + let settled = false; + let unsubscribe: () => void = (): void => {}; + const waiter: ControllerWaiter = { + resolve: (): void => { + if (settled) { + return; + } + settled = true; + unsubscribe(); + resolve(); + }, + reject: (reason: Error): void => { + if (settled) { + return; + } + settled = true; + unsubscribe(); + reject(reason); + }, + }; + this.controllerWaiters.push(waiter); + // The waiter is registered before subscription because an already-cancelled bridge call + // invokes its listener synchronously. + unsubscribe = onCancel((): void => { + this.removeControllerWaiter(waiter); + waiter.reject(new Error(`WebView '${this.data.id}' controller wait was cancelled`)); + }); + }); + } + + failController(reason: string): void { + if (this.attached || this.controllerFailure !== null) { + return; + } + const error = new Error(reason); + this.controllerFailure = error; + const waiters = this.controllerWaiters.splice(0, this.controllerWaiters.length); + for (const waiter of waiters) { + waiter.reject(error); + } + } + + private removeControllerWaiter(waiter: ControllerWaiter): void { + const index = this.controllerWaiters.indexOf(waiter); + if (index >= 0) { + this.controllerWaiters.splice(index, 1); + } + } +} + +class WebviewCreatedResponse { + readonly id: string; + readonly slotId: string; + + constructor(id: string, slotId: string) { + this.id = id; + this.slotId = slotId; + } +} + +class WebviewAcceptedResponse { + readonly accepted: boolean; + + constructor(accepted: boolean) { + this.accepted = accepted; + } +} + +class WebviewScriptResponse { + readonly result: string | null; + + constructor(result: string | null) { + this.result = result; + } +} + +class WebviewStringResponse { + readonly value: string | null; + + constructor(value: string | null) { + this.value = value; + } +} + +class WebviewNavigationRequest { + readonly id: string; + readonly url: string; + + constructor(id: string, url: string) { + this.id = id; + this.url = url; + } +} + +interface WebviewNavigationResponse { + intercept: boolean; +} + +class WebviewDownloadStartRequest { + readonly id: string; + readonly url: string; + readonly tempPath: string | null; + + constructor(id: string, url: string, tempPath: string | null) { + this.id = id; + this.url = url; + this.tempPath = tempPath; + } +} + +interface WebviewDownloadStartResponse { + allow: boolean; + tempPath: string | null; +} + +class WebviewDownloadEndEvent { + readonly id: string; + readonly url: string; + readonly tempPath: string | null; + readonly success: boolean; + + constructor(id: string, url: string, tempPath: string | null, success: boolean) { + this.id = id; + this.url = url; + this.tempPath = tempPath; + this.success = success; + } +} + +class WebviewTitleChangeEvent { + readonly id: string; + readonly title: string; + + constructor(id: string, title: string) { + this.id = id; + this.title = title; + } +} + +class WebviewEngineLifecycleEvent { + readonly phase: string; + + constructor(phase: string) { + this.phase = phase; + } +} + +class WebviewControllerEvent { + readonly id: string; + + constructor(id: string) { + this.id = id; + } +} + +interface WebviewEventAcknowledgement { + accepted: boolean; +} + +let webEngineInitialized = false; + +/** + * Custom schemes must be registered by Rust before this call. The two events let the Rust + * WebView facade flush declarations immediately before the engine is initialized and seal the + * 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( + context, + "engine-initialized", + ENGINE_LIFECYCLE_EVENT_TYPE, + new WebviewEngineLifecycleEvent("engine-initialized") as ESObject, + ); +} + +function parseCreatePayload(payload: BridgeTypedValue): WebviewCreatePayload { + if ( + payload.typeName !== CREATE_REQUEST_TYPE || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`ohos.webview.create requires bridge type ${CREATE_REQUEST_TYPE}`); + } + return payload.value as WebviewCreatePayload; +} + +function parseControllerPayload(payload: BridgeTypedValue, action: string): ControllerPayload { + if ( + payload.typeName !== CONTROLLER_REQUEST_TYPE || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`ohos.webview.${action} requires bridge type ${CONTROLLER_REQUEST_TYPE}`); + } + return payload.value as ControllerPayload; +} + +function parseScriptPayload(payload: BridgeTypedValue): ScriptPayload { + if ( + payload.typeName !== SCRIPT_REQUEST_TYPE || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`ohos.webview.evaluate-script requires bridge type ${SCRIPT_REQUEST_TYPE}`); + } + const request = payload.value as ScriptPayload; + if (!request?.id?.trim()) { + throw new Error("WebView script controller id must not be empty"); + } + if (typeof request.script !== "string") { + throw new Error("WebView script must be a string"); + } + return request; +} + +function requireControllerString(value: string | null | undefined, description: string): string { + if (typeof value !== "string") { + throw new Error(`${description} must be a string`); + } + return value; +} + +function ensureCreatePayload(payload: WebviewCreatePayload): WebviewCreatePayload { + if (!payload?.id?.trim()) { + throw new Error("WebView id must not be empty"); + } + if (!!payload.url === !!payload.html) { + throw new Error("WebView requires exactly one source: url or html"); + } + if (payload.headers !== undefined && payload.headers !== null) { + for (const key of Object.keys(payload.headers)) { + const value = payload.headers[key]; + if (!key.trim() || typeof value !== "string") { + throw new Error("WebView headers must contain non-empty string names and string values"); + } + } + } + if ( + payload.transparent !== undefined && + payload.transparent !== null && + typeof payload.transparent !== "boolean" + ) { + throw new Error("WebView transparent must be a boolean"); + } + normalizeEventOptions(payload.eventOptions); + return payload; +} + +function optionalEventFlag(value: boolean | null | undefined, description: string): boolean { + if (value === undefined || value === null) { + return false; + } + if (typeof value !== "boolean") { + throw new Error(description + " must be a boolean"); + } + return value; +} + +function normalizeEventOptions( + options: WebviewEventOptions | null | undefined, +): WebviewEventOptions { + return { + navigationIntercept: optionalEventFlag( + options?.navigationIntercept, + "WebView eventOptions.navigationIntercept", + ), + downloadStart: optionalEventFlag(options?.downloadStart, "WebView eventOptions.downloadStart"), + downloadEnd: optionalEventFlag(options?.downloadEnd, "WebView eventOptions.downloadEnd"), + titleChange: optionalEventFlag(options?.titleChange, "WebView eventOptions.titleChange"), + }; +} + +function normalizeStyle( + style: WebviewStyle | null | undefined, + transparent: boolean | null | undefined, +): WebviewStyle { + const normalized: WebviewStyle = {}; + if (style === undefined || style === null) { + if (transparent === true) { + normalized.backgroundColor = "#00000000"; + } + return normalized; + } + if (style.x !== undefined && style.x !== null) { + normalized.x = style.x; + } + if (style.y !== undefined && style.y !== null) { + normalized.y = style.y; + } + if (style.visible !== undefined && style.visible !== null) { + normalized.visible = style.visible; + } + if (style.backgroundColor !== undefined && style.backgroundColor !== null) { + normalized.backgroundColor = style.backgroundColor; + } + if (transparent === true && !normalized.backgroundColor) { + normalized.backgroundColor = "#00000000"; + } + return normalized; +} + +function headersOf(headers?: Record | null): WebHeader[] { + const result: WebHeader[] = []; + if (headers === undefined || headers === null) { + return result; + } + const keys: string[] = Object.keys(headers); + for (const key of keys) { + const value = headers[key]; + if (!key.trim() || typeof value !== "string") { + throw new Error("WebView headers must contain non-empty string names and string values"); + } + const header: WebHeader = { headerKey: key, headerValue: value }; + result.push(header); + } + return result; +} + +function withSlotId(payload: WebviewCreatePayload, slotId: string): WebviewCreatePayload { + const copied: WebviewCreatePayload = { + id: payload.id, + slotId, + url: payload.url, + html: payload.html, + style: payload.style, + javascriptEnabled: payload.javascriptEnabled, + devtools: payload.devtools, + userAgent: payload.userAgent, + autoplay: payload.autoplay, + initializationScripts: payload.initializationScripts, + headers: payload.headers, + transparent: payload.transparent, + eventOptions: payload.eventOptions, + }; + return copied; +} + +function navigationDecision(context: BridgePluginContext, id: string, url: string): boolean { + try { + const response = context.invokeNativeSync( + "navigation-request", + NAVIGATION_REQUEST_TYPE, + NAVIGATION_RESPONSE_TYPE, + new WebviewNavigationRequest(id, url) as ESObject, + ) as WebviewNavigationResponse; + if ( + response === null || + typeof response !== "object" || + typeof response.intercept !== "boolean" + ) { + throw new Error("Invalid WebView navigation response"); + } + return response.intercept; + } catch (error) { + console.error("WebView navigation decision failed: " + String(error)); + // Navigation interception must fail open so a temporarily unavailable Rust handler cannot + // strand the user on a page. + return false; + } +} + +function downloadStartDecision( + context: BridgePluginContext, + id: string, + url: string, + tempPath: string | null, +): WebviewDownloadStartResponse { + try { + const response = context.invokeNativeSync( + "download-start", + DOWNLOAD_START_REQUEST_TYPE, + DOWNLOAD_START_RESPONSE_TYPE, + new WebviewDownloadStartRequest(id, url, tempPath) as ESObject, + ) as WebviewDownloadStartResponse; + if ( + response === null || + typeof response !== "object" || + typeof response.allow !== "boolean" || + (response.tempPath !== null && typeof response.tempPath !== "string") + ) { + throw new Error("Invalid WebView download-start response"); + } + return response; + } catch (error) { + console.error("WebView download decision failed: " + String(error)); + // A download callback explicitly requested a synchronous admission decision. Failing closed + // avoids writing to an unintended path when native state is no longer available. + return { allow: false, tempPath: null }; + } +} + +function notifyNative( + context: BridgePluginContext, + event: string, + requestTypeName: string, + value: ESObject, +): void { + const response = context.invokeNativeSync( + event, + requestTypeName, + EVENT_ACKNOWLEDGEMENT_TYPE, + value, + ) as WebviewEventAcknowledgement; + if (response === null || typeof response !== "object" || response.accepted !== true) { + throw new Error("WebView native notification was not accepted"); + } +} + +function setupDownloadDelegate(data: ManagedWebview): void { + if (data.eventOptions.downloadStart !== true && data.eventOptions.downloadEnd !== true) { + return; + } + const download = new webview.WebDownloadDelegate(); + if (data.eventOptions.downloadStart === true) { + download.onBeforeDownload((event) => { + const tempPath = event.getFullPath(); + const response = data.onDownloadStart(event.getUrl(), tempPath); + if (response.allow) { + event.start(response.tempPath ?? tempPath); + } else { + event.cancel(); + } + }); + } + if (data.eventOptions.downloadEnd === true) { + download.onDownloadFinish((event) => { + data.onDownloadEnd(event.getUrl(), event.getFullPath(), true); + }); + download.onDownloadFailed((event) => { + data.onDownloadEnd(event.getUrl(), null, false); + }); + } + try { + data.controller.setDownloadDelegate(download); + } catch (error) { + throw new Error("Unable to set WebView download delegate: " + String(error)); + } +} + +@Builder +function BuildWebview(data: ManagedWebview) { + Web({ src: "", controller: data.controller as webview.WebviewController }) + .width("100%") + .height("100%") + .position({ x: data.style.x ?? 0, y: data.style.y ?? 0 }) + .backgroundColor(data.style.backgroundColor) + .visibility(data.style.visible === false ? Visibility.Hidden : Visibility.Visible) + .javaScriptAccess(data.javascriptEnabled ?? true) + .mediaPlayGestureAccess(data.autoplay === true ? false : true) + .javaScriptOnDocumentStart(data.initializationScripts ?? []) + .onControllerAttached(() => { + if (!data.beginControllerAttach()) { + return; + } + try { + if (data.userAgent !== undefined && data.userAgent !== null) { + data.controller.setCustomUserAgent(data.userAgent); + } + // The controller is associated with the Web component at this point. Install delegates, + // then synchronously flush Rust-owned protocol/proxy declarations before the first load. + setupDownloadDelegate(data); + data.notifyNativeControllerAttached(); + data.didInitialLoad = true; + if (data.url) { + data.controller.loadUrl(data.url, headersOf(data.headers)); + } else { + data.controller.loadData(data.html!, "text/html", "UTF-8", " ", " "); + } + data.markControllerReady(); + } catch (error) { + const reason = `WebView '${data.id}' controller initialization failed: ${String(error)}`; + console.error(reason); + data.failControllerAttach(reason); + } + }) + .onLoadIntercept((event) => data.onNavigationRequest(event.data.getRequestUrl())) + .onTitleReceive((event) => data.onTitleChange(event.title)); +} + +const webviewBuilder = wrapBuilder<[ManagedWebview]>(BuildWebview); + +/** One mounted surface per generic BridgeNodeSlot; it owns only ArkTS nodes. */ +class WebviewSurface { + private readonly root: FrameNode; + private readonly entries: Map = new Map(); + private disposed = false; + readonly slot: BridgeNodeSlot; + private readonly uiContext: UIContext; + private readonly pluginContext: BridgePluginContext; + + constructor(slot: BridgeNodeSlot, uiContext: UIContext, pluginContext: BridgePluginContext) { + this.slot = slot; + this.uiContext = uiContext; + this.pluginContext = pluginContext; + this.root = new FrameNode(uiContext); + slot.mount("ohos.webview.surface", this.root, () => this.dispose(false)); + } + + async create(payload: WebviewCreatePayload, callContext: BridgeCallContext): Promise { + this.assertActive(); + this.assertCallActive(callContext); + this.remove(payload.id); + const style = normalizeStyle(payload.style, payload.transparent); + const eventOptions = normalizeEventOptions(payload.eventOptions); + const data: ManagedWebview = { + id: payload.id, + slotId: payload.slotId, + url: payload.url, + html: payload.html, + javascriptEnabled: payload.javascriptEnabled, + devtools: payload.devtools, + userAgent: payload.userAgent, + autoplay: payload.autoplay, + initializationScripts: payload.initializationScripts, + headers: payload.headers, + transparent: payload.transparent, + eventOptions, + controller: new webview.WebviewController(payload.id) as WebviewController, + style, + didInitialLoad: false, + beginControllerAttach: (): boolean => false, + notifyNativeControllerAttached: (): void => {}, + markControllerReady: (): void => {}, + failControllerAttach: (_reason: string): void => {}, + onNavigationRequest: (url: string): boolean => { + if (eventOptions.navigationIntercept !== true) { + return false; + } + return navigationDecision(this.pluginContext, payload.id, url); + }, + onTitleChange: (title: string): void => { + if (eventOptions.titleChange !== true) { + return; + } + try { + notifyNative( + this.pluginContext, + "title-change", + TITLE_CHANGE_EVENT_TYPE, + new WebviewTitleChangeEvent(payload.id, 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); + }, + onDownloadEnd: (url: string, tempPath: string | null, success: boolean): void => { + if (eventOptions.downloadEnd !== true) { + return; + } + try { + notifyNative( + this.pluginContext, + "download-end", + DOWNLOAD_END_EVENT_TYPE, + new WebviewDownloadEndEvent(payload.id, url, tempPath, success) as ESObject, + ); + } catch (error) { + console.error("WebView download-end notification failed: " + String(error)); + } + }, + }; + if (payload.devtools) { + enableWebDebuggingIfAvailable(); + } + const node = new BuilderNode<[ManagedWebview]>(this.uiContext); + const entry = new WebviewEntry(data, node); + data.beginControllerAttach = (): boolean => entry.beginControllerAttach(); + data.notifyNativeControllerAttached = (): void => { + notifyNative( + this.pluginContext, + "controller-attached", + CONTROLLER_EVENT_TYPE, + new WebviewControllerEvent(data.id) as ESObject, + ); + }; + data.markControllerReady = (): void => entry.markControllerReady(); + data.failControllerAttach = (reason: string): void => entry.failController(reason); + let registered = false; + try { + node.build(webviewBuilder, data); + this.entries.set(payload.id, entry); + registered = true; + this.root.appendChild(node.getFrameNode()); + } catch (error) { + if (registered) { + this.remove(payload.id, entry); + } + throw new Error(`Unable to attach WebView '${payload.id}': ${String(error)}`); + } + try { + await entry.waitForController(callContext.onCancel); + this.assertCallActive(callContext); + } catch (error) { + this.remove(payload.id, entry); + throw new Error(`Unable to initialize WebView '${payload.id}': ${String(error)}`); + } + } + + setVisible(id: string, visible: boolean): boolean { + const entry = this.entries.get(id); + if (!entry) { + return false; + } + entry.data.style.visible = visible; + entry.node.update(entry.data); + return true; + } + + setBackgroundColor(id: string, color: string): boolean { + const entry = this.entries.get(id); + if (!entry) { + return false; + } + entry.data.style.backgroundColor = color; + entry.node.update(entry.data); + return true; + } + + async getUrl(id: string, callContext: BridgeCallContext): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + return entry.data.controller.getUrl(); + } catch (error) { + throw new Error(`Unable to get WebView URL: ${String(error)}`); + } + } + + async loadUrl( + id: string, + url: string, + headers: Record | null | undefined, + callContext: BridgeCallContext, + ): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + entry.data.controller.loadUrl(url, headersOf(headers)); + } catch (error) { + throw new Error(`Unable to load WebView URL: ${String(error)}`); + } + } + + async loadHtml(id: string, html: string, callContext: BridgeCallContext): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + entry.data.controller.loadData(html, "text/html", "UTF-8", " ", " "); + } catch (error) { + throw new Error(`Unable to load WebView HTML: ${String(error)}`); + } + } + + async setZoom(id: string, zoom: number, callContext: BridgeCallContext): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + entry.data.controller.zoom(zoom); + } catch (error) { + throw new Error(`Unable to set WebView zoom: ${String(error)}`); + } + } + + async reload(id: string, callContext: BridgeCallContext): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + entry.data.controller.refresh(); + } catch (error) { + throw new Error(`Unable to reload WebView: ${String(error)}`); + } + } + + async focus(id: string, callContext: BridgeCallContext): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + entry.data.controller.requestFocus(); + } catch (error) { + throw new Error(`Unable to focus WebView: ${String(error)}`); + } + } + + async cookiesWithUrl(id: string, url: string, callContext: BridgeCallContext): Promise { + await this.controllerEntry(id, callContext); + try { + return webview.WebCookieManager.fetchCookieSync(url); + } catch (error) { + throw new Error(`Unable to get WebView cookies: ${String(error)}`); + } + } + + async clearAllBrowsingData(id: string, callContext: BridgeCallContext): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + webview.WebStorage.deleteAllData(true); + webview.WebDataBase.deleteHttpAuthCredentials(); + entry.data.controller.removeCache(true); + entry.data.controller.clearHistory(); + } catch (error) { + throw new Error(`Unable to clear WebView browsing data: ${String(error)}`); + } + } + + remove(id: string, expected?: WebviewEntry): 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`); + try { + this.root.removeChild(entry.node.getFrameNode()); + } catch {} + try { + notifyNative( + this.pluginContext, + "controller-removed", + CONTROLLER_EVENT_TYPE, + new WebviewControllerEvent(id) as ESObject, + ); + } catch (error) { + console.error("WebView controller-removed notification failed: " + String(error)); + } + return true; + } + + async evaluateScript( + id: string, + script: string, + callContext: BridgeCallContext, + ): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + return await entry.data.controller.runJavaScript(script); + } catch (error) { + throw new Error(`Unable to evaluate WebView JavaScript: ${String(error)}`); + } + } + + dispose(unmount: boolean): void { + if (this.disposed) { + return; + } + this.disposed = true; + for (const id of Array.from(this.entries.keys())) { + this.remove(id); + } + if (unmount) { + this.slot.unmount("ohos.webview.surface"); + } + this.root.dispose(); + } + + private assertActive(): void { + if (this.disposed) { + throw new Error("WebView surface is already disposed"); + } + } + + private async controllerEntry(id: string, callContext: BridgeCallContext): Promise { + this.assertCallActive(callContext); + const entry = this.entries.get(id); + if (!entry) { + throw new Error(`Unknown WebView controller '${id}'`); + } + await entry.waitForController(callContext.onCancel); + this.assertCallActive(callContext); + return entry; + } + + private assertCallActive(callContext: BridgeCallContext): void { + if (!callContext.isActive()) { + throw new Error("Bridge call is no longer active"); + } + } +} + +function surfaceKey(context: BridgePluginContext, slotId: string): string { + return `${context.sessionId}:${context.moduleName}:${slotId}`; +} + +class WebviewSurfaceRegistry { + private static readonly surfaces: Map = new Map(); + + static get(context: BridgePluginContext, slotId: string): WebviewSurface { + const slot = BridgeNodeSlotRegistry.require(context.sessionId, context.moduleName, slotId); + return WebviewSurfaceRegistry.forSlot(context, slot); + } + + /** + * `create` is asynchronous, so it may wait for a business-owned BridgeNodeHost that is still + * entering the page. Controller actions deliberately remain immediate and use `get` above. + */ + static async getWhenMounted( + surfaceContext: BridgePluginContext, + slotId: string, + callContext: BridgeCallContext, + ): Promise { + const slot = await BridgeNodeSlotRegistry.waitFor( + surfaceContext.sessionId, + surfaceContext.moduleName, + slotId, + callContext.onCancel, + ); + if (!slot.isActive() || !surfaceContext.isActive() || !callContext.isActive()) { + throw new Error("Bridge session is no longer active while waiting for the WebView node slot"); + } + return WebviewSurfaceRegistry.forSlot(surfaceContext, slot); + } + + private static forSlot(context: BridgePluginContext, slot: BridgeNodeSlot): WebviewSurface { + const key = surfaceKey(context, slot.slotId); + const current = WebviewSurfaceRegistry.surfaces.get(key); + if (current && current.slot === slot) { + return current; + } + if (current !== undefined) { + current.dispose(false); + } + const surface = new WebviewSurface(slot, slot.uiContext, context); + WebviewSurfaceRegistry.surfaces.set(key, surface); + return surface; + } + + static disposeSession(context: BridgePluginContext): void { + const prefix = `${context.sessionId}:${context.moduleName}:`; + const keys: string[] = Array.from(WebviewSurfaceRegistry.surfaces.keys()); + for (const key of keys) { + if (!key.startsWith(prefix)) { + continue; + } + const surface = WebviewSurfaceRegistry.surfaces.get(key); + if (surface !== undefined) { + WebviewSurfaceRegistry.surfaces.delete(key); + surface.dispose(true); + } + } + } +} + +class WebviewPlugin implements AsyncBridgePlugin { + readonly id = "ohos.webview"; + readonly version = 1; + readonly execution = "async"; + readonly requires: ["ui-context"] = ["ui-context"]; + private readonly pluginContext: BridgePluginContext; + + constructor(pluginContext: BridgePluginContext) { + this.pluginContext = pluginContext; + } + + onInstall(context: BridgePluginContext): void { + ensureWebEngineInitialized(context); + } + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): Promise { + if (!context.isActive()) { + throw new Error("Bridge session is no longer active"); + } + if (action === "create") { + const createPayload = ensureCreatePayload(parseCreatePayload(payload)); + const slotId = createPayload.slotId || DEFAULT_PLUGIN_NODE_SLOT; + const payloadWithSlot = withSlotId(createPayload, slotId); + const surface = await WebviewSurfaceRegistry.getWhenMounted( + this.pluginContext, + slotId, + context, + ); + await surface.create(payloadWithSlot, context); + if (!context.isActive()) { + throw new Error( + "Bridge session is no longer active after WebView controller initialization", + ); + } + return { + typeName: CREATE_RESPONSE_TYPE, + value: new WebviewCreatedResponse(createPayload.id, slotId), + }; + } + + if (action === "evaluate-script") { + const scriptPayload = parseScriptPayload(payload); + const slotId = scriptPayload.slotId || DEFAULT_PLUGIN_NODE_SLOT; + const result = await WebviewSurfaceRegistry.get(this.pluginContext, slotId).evaluateScript( + scriptPayload.id, + scriptPayload.script, + context, + ); + return { + typeName: SCRIPT_RESPONSE_TYPE, + value: new WebviewScriptResponse(result), + }; + } + + const controllerPayload = parseControllerPayload(payload, action); + if (!controllerPayload?.id?.trim()) { + throw new Error("WebView controller id must not be empty"); + } + const slotId = controllerPayload.slotId || DEFAULT_PLUGIN_NODE_SLOT; + const surface = WebviewSurfaceRegistry.get(this.pluginContext, slotId); + let accepted = false; + if (action === "get-url") { + return { + typeName: STRING_RESPONSE_TYPE, + value: new WebviewStringResponse(await surface.getUrl(controllerPayload.id, context)), + }; + } else if (action === "load-url") { + await surface.loadUrl( + controllerPayload.id, + requireControllerString(controllerPayload.url, "load-url.url"), + controllerPayload.headers, + context, + ); + accepted = true; + } else if (action === "load-html") { + await surface.loadHtml( + controllerPayload.id, + requireControllerString(controllerPayload.html, "load-html.html"), + context, + ); + accepted = true; + } else if (action === "set-zoom") { + if (typeof controllerPayload.zoom !== "number" || !Number.isFinite(controllerPayload.zoom)) { + throw new Error("set-zoom.zoom must be a finite number"); + } + await surface.setZoom(controllerPayload.id, controllerPayload.zoom, context); + accepted = true; + } else if (action === "reload") { + await surface.reload(controllerPayload.id, context); + accepted = true; + } else if (action === "focus") { + await surface.focus(controllerPayload.id, context); + accepted = true; + } else if (action === "cookies-with-url") { + return { + typeName: STRING_RESPONSE_TYPE, + value: new WebviewStringResponse( + await surface.cookiesWithUrl( + controllerPayload.id, + requireControllerString(controllerPayload.url, "cookies-with-url.url"), + context, + ), + ), + }; + } else if (action === "clear-all-browsing-data") { + await surface.clearAllBrowsingData(controllerPayload.id, context); + accepted = true; + } else if (action === "set-visible") { + if (typeof controllerPayload.visible !== "boolean") { + throw new Error("set-visible.visible must be a boolean"); + } + accepted = surface.setVisible(controllerPayload.id, controllerPayload.visible); + } else if (action === "set-background-color") { + if (!controllerPayload.color) { + throw new Error("set-background-color.color must not be empty"); + } + accepted = surface.setBackgroundColor(controllerPayload.id, controllerPayload.color); + } else if (action === "remove") { + accepted = surface.remove(controllerPayload.id); + } else { + throw new Error(`Unsupported ohos.webview action '${action}'`); + } + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new WebviewAcceptedResponse(accepted) }; + } + + onDispose(context: BridgePluginContext): void { + WebviewSurfaceRegistry.disposeSession(context); + } +} + +export function createWebviewPlugin(): BridgePluginFactory { + return { + id: "ohos.webview", + version: 1, + execution: "async", + requires: ["ui-context"], + create: (context: BridgePluginContext): AsyncBridgePlugin => new WebviewPlugin(context), + }; +} + +declare class WebviewController extends webview.WebviewController {} diff --git a/plugins/webview/src/main/module.json5 b/plugins/webview/src/main/module.json5 new file mode 100644 index 00000000..7cc8d87b --- /dev/null +++ b/plugins/webview/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_webview", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/window/.gitignore b/plugins/window/.gitignore new file mode 100644 index 00000000..baf64219 --- /dev/null +++ b/plugins/window/.gitignore @@ -0,0 +1,3 @@ +/oh_modules +/.idea +/build diff --git a/plugins/window/BuildProfile.ets b/plugins/window/BuildProfile.ets new file mode 100644 index 00000000..f7ad5d5b --- /dev/null +++ b/plugins/window/BuildProfile.ets @@ -0,0 +1,17 @@ +/** + * Use these variables when you tailor your ArkTS code. They must be of the const type. + */ +export const HAR_VERSION = "1.0.0"; +export const BUILD_MODE_NAME = "debug"; +export const DEBUG = true; +export const TARGET_NAME = "default"; + +/** + * BuildProfile Class is used only for compatibility purposes. + */ +export default class BuildProfile { + static readonly HAR_VERSION = HAR_VERSION; + static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; + static readonly DEBUG = DEBUG; + static readonly TARGET_NAME = TARGET_NAME; +} diff --git a/plugins/window/README.md b/plugins/window/README.md new file mode 100644 index 00000000..6cd53d00 --- /dev/null +++ b/plugins/window/README.md @@ -0,0 +1,53 @@ +# @ohos-rs/plugin-window + +这是窗口避让区能力的 ArkTS HAR,对应 Rust crate `openharmony-ability-plugin-window`。它在当前 +`WindowStage` 的主窗口上同步调用 `getWindowAvoidArea`,并将完整结果返回 Rust。 + +## 装配 + +```json5 +{ + "dependencies": { + "@ohos-rs/ability": "file:", + "@ohos-rs/plugin-window": "file:" + } +} +``` + +```ts +import { NativeAbility } from "@ohos-rs/ability"; +import { createWindowPlugin } from "@ohos-rs/plugin-window"; + +export default class EntryAbility extends NativeAbility { + public bridgePlugins = [createWindowPlugin()]; +} +``` + +Rust 侧还需注册 `WindowBridgePlugin`,并通过当前 N-API callback 的 `Env` 调用 +`WindowExt::query_avoid_area`。使用示例见 +[Rust facade README](../../crates/plugin-window/README.md)。 + +## Factory 契约 + +| 字段 | 值 | +| --- | --- | +| `id` / `version` | `ohos.window` / `1` | +| `execution` | `sync-main-thread` | +| `requires` | `["window-stage"]` | +| 支持 action | `get-avoid-area` | +| request → response | `ohos.window.AvoidAreaRequest` → `ohos.window.AvoidAreaResponse` | + +ArkTS request 是 `{ areaType: number }`,response 是 +`{ area: { visible, leftRect, topRect, rightRect, bottomRect } }`;每个 rect 都包含 +`top`、`left`、`width`、`height`。不能省略任一边的避让区。 + +## 运行限制 + +- 必须在 `NativeAbility.onWindowStageCreate` 后才会激活;同步调用不等待 `WindowStage`。 +- `invokeSync` 通过 `context.getWindowStage().getMainWindowSync()` 获取主窗口。取主窗口或查询平台 API + 失败时,抛出明确错误给 Rust。 +- `areaType` 必须是整数,且 request/response typeName 必须精确匹配;不支持 JSON 或动态对象兼容层。 +- 该插件不保存 `WindowStage`、`UIContext` 或 N-API object 到 worker。 + +完整线程、生命周期和契约变更规则见 +[插件开发规范](../../docs/plugin-development-standard.md)。 diff --git a/plugins/window/build-profile.json5 b/plugins/window/build-profile.json5 new file mode 100644 index 00000000..a1f6c185 --- /dev/null +++ b/plugins/window/build-profile.json5 @@ -0,0 +1,9 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + }, + ], +} diff --git a/plugins/window/hvigorfile.ts b/plugins/window/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/window/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/window/index.ets b/plugins/window/index.ets new file mode 100644 index 00000000..27a2be7b --- /dev/null +++ b/plugins/window/index.ets @@ -0,0 +1 @@ +export { createWindowPlugin } from "./src/main/ets/WindowPlugin"; diff --git a/plugins/window/oh-package-lock.json5 b/plugins/window/oh-package-lock.json5 new file mode 100644 index 00000000..9c5216ba --- /dev/null +++ b/plugins/window/oh-package-lock.json5 @@ -0,0 +1,29 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos-rs/ability@../../native_ability": "@ohos-rs/ability@../../native_ability", + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0", + "resolved": "../../native_ability", + "registryType": "local", + "dependencies": { + "libnative_ability.so": "file:./src/main/cpp/types/libnative_ability" + } + }, + "libnative_ability.so@../../native_ability/src/main/cpp/types/libnative_ability": { + "name": "libnative_ability.so", + "version": "1.0.0", + "resolved": "../../native_ability/src/main/cpp/types/libnative_ability", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/plugins/window/oh-package.json5 b/plugins/window/oh-package.json5 new file mode 100644 index 00000000..d2b2994b --- /dev/null +++ b/plugins/window/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "name": "@ohos-rs/plugin-window", + "version": "1.0.0", + "description": "Window capability plugin for @ohos-rs/ability", + "main": "index.ets", + "license": "Apache-2.0", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, +} diff --git a/plugins/window/src/main/ets/WindowPlugin.ets b/plugins/window/src/main/ets/WindowPlugin.ets new file mode 100644 index 00000000..dae787fd --- /dev/null +++ b/plugins/window/src/main/ets/WindowPlugin.ets @@ -0,0 +1,100 @@ +import window from "@ohos.window"; +import { + BridgeCallContext, + BridgeTypedValue, + BridgePluginContext, + BridgePluginFactory, + MainThreadSyncBridgePlugin, +} from "@ohos-rs/ability"; + +const AVOID_AREA_REQUEST_TYPE = "ohos.window.AvoidAreaRequest"; +const AVOID_AREA_RESPONSE_TYPE = "ohos.window.AvoidAreaResponse"; + +interface AvoidAreaRequest { + areaType: number; +} + +class AvoidAreaRectResponse { + readonly top: number; + readonly left: number; + readonly width: number; + readonly height: number; + + constructor(rect: window.Rect) { + this.top = rect.top; + this.left = rect.left; + this.width = rect.width; + this.height = rect.height; + } +} + +class AvoidAreaValueResponse { + readonly visible: boolean; + readonly leftRect: AvoidAreaRectResponse; + readonly topRect: AvoidAreaRectResponse; + readonly rightRect: AvoidAreaRectResponse; + readonly bottomRect: AvoidAreaRectResponse; + + constructor(area: window.AvoidArea) { + this.visible = area.visible; + this.leftRect = new AvoidAreaRectResponse(area.leftRect); + this.topRect = new AvoidAreaRectResponse(area.topRect); + this.rightRect = new AvoidAreaRectResponse(area.rightRect); + this.bottomRect = new AvoidAreaRectResponse(area.bottomRect); + } +} + +class AvoidAreaResponse { + readonly area: AvoidAreaValueResponse; + + constructor(area: window.AvoidArea) { + this.area = new AvoidAreaValueResponse(area); + } +} + +function parseAvoidAreaRequest(payload: BridgeTypedValue): AvoidAreaRequest { + if (payload.typeName !== AVOID_AREA_REQUEST_TYPE || typeof payload.value !== "object") { + throw new Error(`ohos.window.get-avoid-area requires bridge type ${AVOID_AREA_REQUEST_TYPE}`); + } + const request = payload.value as AvoidAreaRequest; + if (!Number.isInteger(request?.areaType)) { + throw new Error("get-avoid-area.areaType must be an integer"); + } + return request; +} + +class WindowPlugin implements MainThreadSyncBridgePlugin { + readonly id = "ohos.window"; + readonly version = 1; + readonly execution: "sync-main-thread" = "sync-main-thread"; + readonly requires: ["window-stage"] = ["window-stage"]; + + invokeSync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): BridgeTypedValue { + if (action !== "get-avoid-area") { + throw new Error(`Unsupported ohos.window action '${action}'`); + } + const request = parseAvoidAreaRequest(payload); + try { + const mainWindow = context.getWindowStage().getMainWindowSync(); + const areaType = request.areaType as window.AvoidAreaType; + const response = new AvoidAreaResponse(mainWindow.getWindowAvoidArea(areaType)); + return { typeName: AVOID_AREA_RESPONSE_TYPE, value: response }; + } catch (error) { + throw new Error(`Unable to get window avoid area: ${String(error)}`); + } + } +} + +export function createWindowPlugin(): BridgePluginFactory { + return { + id: "ohos.window", + version: 1, + execution: "sync-main-thread", + requires: ["window-stage"], + create: (_context: BridgePluginContext): MainThreadSyncBridgePlugin => new WindowPlugin(), + }; +} diff --git a/plugins/window/src/main/module.json5 b/plugins/window/src/main/module.json5 new file mode 100644 index 00000000..07c20711 --- /dev/null +++ b/plugins/window/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_window", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/prek.toml b/prek.toml index f12e2ef1..52a91adb 100644 --- a/prek.toml +++ b/prek.toml @@ -3,7 +3,7 @@ [[repos]] repo = "local" hooks = [ - { id = "rustfmt", name = "rustfmt", entry = "rustfmt", language = "system", files = "\\.rs$" }, + { id = "rustfmt", name = "rustfmt", entry = "rustfmt --edition 2021", language = "system", files = "\\.rs$" }, { id = "oxk-format", name = "oxk format", entry = "oxk format -t=10", language = "system", files = "\\.(ets|js|ts|json5)$" }, { id = "oxk-lint", name = "oxk lint", entry = "oxk lint --config .oxlintrc.jsonc --threads=10", language = "system", files = "\\.(ets|js|ts)$" }, ] diff --git a/rust_example/demo_native/Cargo.toml b/rust_example/demo_native/Cargo.toml index 6feb89c4..e27dc0d2 100755 --- a/rust_example/demo_native/Cargo.toml +++ b/rust_example/demo_native/Cargo.toml @@ -11,10 +11,14 @@ crate-type = ["cdylib"] napi-ohos = { workspace = true, features = ["tokio_rt"] } napi-derive-ohos = { workspace = true } -openharmony-ability = { workspace = true, features = ["webview"] } +openharmony-ability = { workspace = true } openharmony-ability-derive = { workspace = true } +openharmony-ability-plugin-permission = { workspace = true } +openharmony-ability-plugin-webview = { workspace = true } ohos-hilog-binding = { version = "*" } -futures-executor = "0.3" +futures-channel = { workspace = true } +futures-executor = { workspace = true } +http = { workspace = true } [build-dependencies] napi-build-ohos = { workspace = true } diff --git a/rust_example/demo_native/src/index.html b/rust_example/demo_native/src/index.html index aaa670a3..513cecf9 100644 --- a/rust_example/demo_native/src/index.html +++ b/rust_example/demo_native/src/index.html @@ -19,8 +19,7 @@

run JavaScript Ext demo



- - intercept test! + reload through the custom protocol