diff --git a/.agents/skills/named-napi-contracts/references/contract-table.md b/.agents/skills/named-napi-contracts/references/contract-table.md index 14e91685..38bf17a2 100644 --- a/.agents/skills/named-napi-contracts/references/contract-table.md +++ b/.agents/skills/named-napi-contracts/references/contract-table.md @@ -17,7 +17,7 @@ | 插件 / 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.app-control` / `terminate`、`restart`、`set-color-mode` | `ohos.app_control.TerminateRequest`/`RestartRequest`/`ColorModeRequest` | `ohos.app_control.TerminateResponse`/`RestartResponse`/`ColorModeResponse` → `{ 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 } }` | async / `ui-context`;查询当前 component 所在窗口 | | `ohos.window` / `create-os-window`、`set-decorations`、`set-background-color`、`set-blur`、`focus`、`set-focusable`、`move-to`、`resize`、`minimize`、`maximize`、`restore`、`recover`、`show`、`destroy-window`、`is-maximized`、`is-minimized` | `ohos.window.CreateRequest` / `WindowIdRequest` / `DecorationsRequest` / `ColorRequest` / `BlurRequest` / `MoveRequest` / `ResizeRequest` / `FocusableRequest` | `ohos.window.CreateResponse` → `{ windowId }` / `Acknowledgement` → `{ accepted }` / `StateResponse` → `{ value }` | async / `ui-context` | @@ -26,6 +26,15 @@ | `ohos.webview` / `get-url`、`cookies-with-url` | `ohos.webview.ControllerRequest` → `{ id, url? }` | `ohos.webview.StringResponse` → `{ value }` | async / `ui-context` | | `ohos.webview` / `evaluate-script` | `ohos.webview.ScriptRequest` → `{ id, script }` | `ohos.webview.ScriptResponse` → `{ result }` | async / `ui-context` | | `ohos.resource` / `resource-manager-ready`(入站) | `ohos.resource.ResourceManagerRef`(ArkTS 直接传 `resourceManager` 对象) | `ohos.resource.ResourceManagerReadyResponse` → `{ accepted }` | 入站事件 / `ability` | +| `ohos.webview` / `set-bounds`、`set-cookie`、`snapshot`、`create-pdf`、`set-debugging-access`、`is-debugging-access` | `ohos.webview.BoundsRequest` / `CookieRequest` / `SnapshotRequest` / `PdfRequest`(`PdfConfig`) / `BoolRequest` | `ohos.webview.Acknowledgement` / `SnapshotResponse` → `{ rgba, width, height }` / `BoolResponse` → `{ value }` | async / `ui-context` | +| `ohos.clipboard` / `write-image` | `ohos.clipboard.ImageRequest` → `{ rgba, width, height }` | `ohos.clipboard.Acknowledgement` → `{ accepted }` | async / `[]` | +| `ohos.autostart` / `open-settings`、`is-enabled` | `ohos.autostart.Request` → `{ requested }` | `ohos.autostart.Acknowledgement` → `{ accepted }` / `ohos.autostart.StatusResponse` → `{ enabled, supported }` | async / `ability` | +| `ohos.updater` / `check`、`download-and-install` | `ohos.updater.CheckRequest` | `ohos.updater.CheckResponse` → `{ result }`(`CheckResult`)/ `Acknowledgement` | async / `ability` | +| `ohos.menu` / `set-menubar`、`popup`、`set-menubar-visible` | `ohos.menu.MenuBarRequest` / `PopupRequest` / `VisibilityRequest`(递归 `MenuItemData`) | `ohos.menu.Acknowledgement` → `{ accepted }` | async / `[]` | +| `ohos.menu` / 主线程事件 `menu-click` | `ohos.menu.ClickRequest` → `{ id, windowId }` | `ohos.menu.ClickResponse` → `{ accepted }` | scoped 主线程具名 N-API | +| `ohos.statusbar` / `add`、`remove`、`update-icon`、`update-menu`、`update-tips`、`predefined-action` | `ohos.statusbar.AddRequest`(`ItemData`)/ `IconRequest` / `MenuRequest` / `TipsRequest` / `ActionRequest` | `ohos.statusbar.Acknowledgement` → `{ accepted }` | async / `ability` | +| `ohos.statusbar` / 主线程事件 `icon-click`、`menu-click` | `ohos.statusbar.ClickRequest` → `{ clickType }` / `MenuClickRequest` → `{ menuCode }` | `ohos.statusbar.ClickResponse` → `{ accepted }` | scoped 主线程具名 N-API | +| `ohos.version` / `get-sdk-api-version`、`get-distribution-api-version`、`can-i-use`、`is-desktop-device` | `ohos.version.VersionRequest` → `{ syscap }` | `ohos.version.VersionResponse` → `{ value, result }` | sync / `[]` | ## 代码位置 @@ -35,6 +44,12 @@ | 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` | +| clipboard | `crates/plugin-clipboard/src/lib.rs` | `plugins/clipboard/src/main/ets/ClipboardPlugin.ets` | +| autostart | `crates/plugin-autostart/src/lib.rs` | `plugins/autostart/src/main/ets/AutostartPlugin.ets` | +| updater | `crates/plugin-updater/src/lib.rs` | `plugins/updater/src/main/ets/UpdaterPlugin.ets` | +| menu | `crates/plugin-menu/src/lib.rs` | `plugins/menu/src/main/ets/MenuPlugin.ets` | +| statusbar | `crates/plugin-statusbar/src/lib.rs` | `plugins/statusbar/src/main/ets/StatusBarPlugin.ets` | +| version | `crates/plugin-version/src/lib.rs`(core `crates/ability/src/version.rs` 提供 init/getters) | `plugins/version/src/main/ets/VersionPlugin.ets` | | resource | `crates/plugin-resource/src/lib.rs` | `plugins/resource/src/main/ets/ResourcePlugin.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` 事件) | diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index f77b4942..85cbff73 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -7,7 +7,13 @@ "arkts/system-api-version": [ "error", { - "minApiVersion": 12 + "minApiVersion": 12, + // AutostartPlugin guards this API-21 call with deviceInfo.sdkApiVersion. + "apis": { + "@kit.AbilityKit.autoStartupManager.getAutoStartupStatusForSelf": 12, + // The API 24 SDK declaration retains @since 9 for this overload. + "@ohos.pasteboard.createData": 9 + } } ] } diff --git a/AGENTS.md b/AGENTS.md index e831beaa..8b5f348a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,12 +56,18 @@ Rust plugin facades (BridgePlugin) + application business code (run_loop) | `crates/ability` | Core: lifecycle/`run_loop`, bridge transport (`impl_bridge_napi_type!`, `BridgeNapiType`, `BridgePlugin`, `BridgeRuntime`, `BridgeHost`), ArkUI/xcomponent/ime binding re-exports | | `crates/derive` | `#[ability]` entry macro | | `crates/plugin-permission` | `ohos.permission` — async permission request | -| `crates/plugin-app-control` | `ohos.app-control` — sync main-thread terminate | +| `crates/plugin-app-control` | `ohos.app-control` — sync terminate/restart/color mode | +| `crates/plugin-autostart` | `ohos.autostart` — async autostart status/settings | | `crates/plugin-window` | `ohos.window` — async avoid-area and multi-window operations | | `crates/plugin-webview` | `ohos.webview` — WebView create, controller, custom protocol, JS proxy, callbacks | | `crates/plugin-files` | `ohos.files` — file dialogs (open/save/folder) | | `crates/plugin-url` | `ohos.url` — `context.openLink` | | `crates/plugin-resource` | `ohos.resource` — inbound-only: ArkTS pushes `resourceManager` from Ability-scoped `onInstall`; no outbound actions | +| `crates/plugin-clipboard` | `ohos.clipboard` — async image clipboard write | +| `crates/plugin-updater` | `ohos.updater` — async application update check/install | +| `crates/plugin-menu` | `ohos.menu` — async menu state plus typed click callbacks | +| `crates/plugin-statusbar` | `ohos.statusbar` — async desktop status-bar operations and callbacks | +| `crates/plugin-version` | `ohos.version` — sync platform version/capability queries | Every `crates/plugin-` is paired with an ArkTS HAR in `plugins/` that exports the matching `BridgePluginFactory`; core (`crates/ability`) never imports any `plugin-*` crate. diff --git a/Cargo.lock b/Cargo.lock index b4115dca..87994e73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bytes" @@ -38,11 +38,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "ctor" -version = "1.0.8" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb22e947478ccf9dc44d8922042c677a63fbb88f2cb468521d1145816e5087cb" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] name = "demo_native" @@ -51,6 +66,7 @@ dependencies = [ "futures-channel", "futures-executor", "http", + "log", "napi-build-ohos", "napi-derive-ohos", "napi-ohos", @@ -58,10 +74,16 @@ dependencies = [ "openharmony-ability", "openharmony-ability-derive", "openharmony-ability-plugin-app-control", + "openharmony-ability-plugin-autostart", + "openharmony-ability-plugin-clipboard", "openharmony-ability-plugin-files", + "openharmony-ability-plugin-menu", "openharmony-ability-plugin-permission", "openharmony-ability-plugin-resource", + "openharmony-ability-plugin-statusbar", + "openharmony-ability-plugin-updater", "openharmony-ability-plugin-url", + "openharmony-ability-plugin-version", "openharmony-ability-plugin-webview", "openharmony-ability-plugin-window", ] @@ -70,6 +92,7 @@ dependencies = [ name = "demo_sub_native" version = "0.1.0" dependencies = [ + "log", "napi-build-ohos", "napi-derive-ohos", "napi-ohos", @@ -81,9 +104,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -96,9 +119,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -106,15 +129,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -123,15 +146,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", @@ -140,21 +163,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -169,9 +192,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -193,6 +216,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" @@ -265,18 +294,18 @@ checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] name = "ohos-accessibility-sys" -version = "0.0.2" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "810ce2df7c760cd2da5a72a3fafe860ecd28f557ac50b17433dd3bb059f13c75" +checksum = "328ddd8c050829f583bdcde996e9cc0217d15a744aa57c2a00118c00afff1562" dependencies = [ "ohos-arkui-input-sys", ] [[package]] name = "ohos-arkui-binding" -version = "0.2.1" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e004b3797638bf87f08cb033552a5e3b813edab7b16d831b2761178f27d05083" +checksum = "97c3e8aef542bff511eac2faa06c3d7c0c37fe18e19e866fca337c2e351867e2" dependencies = [ "bitflags", "napi-ohos", @@ -290,9 +319,9 @@ dependencies = [ [[package]] name = "ohos-arkui-input-binding" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54bbeb86f5de53fe4aaaff8070e19900761a82fcdd4103c0a2d799d888c93fc5" +checksum = "99cef7adee31f6fdac89222a69e94df5236cf4ca049a23e380a1065f9141acb6" dependencies = [ "ohos-arkui-input-sys", "ohos-enum-derive", @@ -300,15 +329,15 @@ dependencies = [ [[package]] name = "ohos-arkui-input-sys" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f7a9b47147020e7b166456be8c046bb950259806d6954d2e41d1826abfa9a0" +checksum = "d9befe0e30b764a29e2d43aeeb5fad7c53c2a1aa61763b6533ac3f52799caa38" [[package]] name = "ohos-arkui-sys" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03318dacc6add03bd1f1adf05ec349febaf7e0614090d8021b315a262038b4f" +checksum = "4d32365469264d0e2f6e35d9b8a509f0eeb83eb639208e134d561c591878d387" dependencies = [ "napi-sys-ohos", "ohos-arkui-input-sys", @@ -319,9 +348,9 @@ dependencies = [ [[package]] name = "ohos-display-binding" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12665edc32a1e47d048ff918768ab54b1fa6d225bc0b11f95e4465b596c01dde" +checksum = "c2e0f8b8d610915ec01a7857d27f6d2a0498e3fdf7488f3bbd602db00e929adc" dependencies = [ "ohos-display-sys", "ohos-enum-derive", @@ -329,9 +358,9 @@ dependencies = [ [[package]] name = "ohos-display-sys" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a7f281a4946086162214c948b805673b00f37f5eb9da8198034c356152aac6" +checksum = "1cf471a32cef6236982949b33a3bc01d5a15e30e5b9d267988e92d94cd89ef67" [[package]] name = "ohos-enum-derive" @@ -347,9 +376,9 @@ dependencies = [ [[package]] name = "ohos-hilog-binding" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cb0bf7b7f725f333c745c2d5adf78caf5a5049ac8f2ce25138007a9e95b5f17" +checksum = "53b6acde069ee755d81ec9d30dcd8b65909a7ee941f9fe8893f7f12855848a1f" dependencies = [ "ohos-enum-derive", "ohos-hilogs-sys", @@ -357,15 +386,15 @@ dependencies = [ [[package]] name = "ohos-hilogs-sys" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3878fded9ee08f9217d6ce4f5de4307110ac209652e349724ece29064151bb76" +checksum = "f7142aca637eb32977214bb4ec9e6e9e7653ece93aee372a5905af3d21a3d578" [[package]] name = "ohos-image-native-sys" -version = "0.0.2" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecaf660fe05ccff647ab201307e5d739eb01edc821744f3a9c6840032006d40b" +checksum = "331baf1db1c885dcbcdb81dce0563fa9f9fd3a27802ddcbf1726eb6784bd020e" dependencies = [ "napi-sys-ohos", "ohos-native-buffer-sys", @@ -374,9 +403,9 @@ dependencies = [ [[package]] name = "ohos-image-sys" -version = "0.0.2" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a94ccf32afe0522c34647e17a9ba1ab39b39399207b3b3ffc21d03cd9cb152" +checksum = "e367c84b78af5ae77d5335601396c1d0af89543becca08f75aedf4ba5258d74c" dependencies = [ "napi-sys-ohos", "ohos-resource-manager-sys", @@ -394,21 +423,21 @@ dependencies = [ [[package]] name = "ohos-input-method-sys" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "855c631663b49706aee58d0a446568aefeb85ccf95180a059d020c00b1187ad2" +checksum = "f8686bdd6249bc09b0789e09d35886563811d9a8dce9d05361d33122648eeb3e" [[package]] name = "ohos-native-buffer-sys" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4642c79ad9743a8bc3e0c46d8a8c8c87cc2b4f03b0fc6f2c049606ae47a5ff16" +checksum = "d1b754679463a2af38c3381936eec015bab187963bd97a6802e5a61ce362573f" [[package]] name = "ohos-native-drawing-sys" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45fcbb5139439d4dc7ac630fe3d02a9980687f8f56295799f842ef260e315c12" +checksum = "f8d58d6a1f1d65d0597c56f1920d86028241ac9cc91c56c9dac41a680f68ac6e" dependencies = [ "ohos-image-native-sys", "ohos-image-sys", @@ -416,9 +445,9 @@ dependencies = [ [[package]] name = "ohos-resource-manager-binding" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da00ab3e5b1322647c40b2c49f2fadc42df04b51884f3e5817ee62a235224ce7" +checksum = "5baae91a5379fc2d1ee8f85409c4e0cedaefc7988244efb2dcf17097a8074182" dependencies = [ "napi-ohos", "ohos-resource-manager-sys", @@ -426,27 +455,27 @@ dependencies = [ [[package]] name = "ohos-resource-manager-sys" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0e3a4f14001ff137b01a80d021b436c4777a4b8cd2a318c9575b796ba9c918" +checksum = "86fbffb79d6e26b845e77653590e6d8951baeaa6c1a307417e917f37dcf3843a" dependencies = [ "napi-sys-ohos", ] [[package]] name = "ohos-udmf-sys" -version = "0.0.2" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a0e5ea4015c1c365841419a0aaf1ca266fbfdfc93465e79a4f2ad826072a98f" +checksum = "389517bf06530019a0fb90b34c5a408bda45f6584b3487621afd82d524d873ab" dependencies = [ "ohos-image-native-sys", ] [[package]] name = "ohos-web-binding" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6ee03359b2b6daca3a068c0c9342250ba82631a2ba21d1095735ee1184d8439" +checksum = "a49cba39c83d93c728e79e5df99d8abe8a00e2485e4d1dd69fc539abb5c5a78f" dependencies = [ "bitflags", "ohos-enum-derive", @@ -455,15 +484,15 @@ dependencies = [ [[package]] name = "ohos-web-sys" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02c4cd2a25905ce1612ef7fd8f159fde19835e27767457dfdac08d3e2c9fc99" +checksum = "646417b737cf623a0eefa46fa0d981d188ae3b74f05e00db5fe5efe1e49eb5b2" [[package]] name = "ohos-xcomponent-binding" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f46c3397d83759326d0af79aaed3880d20ddaa3229b8364c01271c12e4cbd89" +checksum = "90274819d67adc39bde757ddee3ad0b6d3e68efd2ebe9cbc258b56e6146e1ab3" dependencies = [ "napi-derive-ohos", "napi-ohos", @@ -476,9 +505,9 @@ dependencies = [ [[package]] name = "ohos-xcomponent-sys" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b29161503ac5c408d770764b2032e2e16ae3853c16f26717d807c3da9de69" +checksum = "77edaa30b47dd58f2285dda8cef56a6817cf76fa193f537c10bb8ebc6a4f88c7" dependencies = [ "ohos-accessibility-sys", "ohos-arkui-input-sys", @@ -516,6 +545,24 @@ dependencies = [ "openharmony-ability", ] +[[package]] +name = "openharmony-ability-plugin-autostart" +version = "1.0.0-beta.0" +dependencies = [ + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + +[[package]] +name = "openharmony-ability-plugin-clipboard" +version = "1.0.0-beta.0" +dependencies = [ + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + [[package]] name = "openharmony-ability-plugin-files" version = "1.0.0-beta.0" @@ -525,6 +572,16 @@ dependencies = [ "openharmony-ability", ] +[[package]] +name = "openharmony-ability-plugin-menu" +version = "1.0.0-beta.0" +dependencies = [ + "crossbeam-channel", + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + [[package]] name = "openharmony-ability-plugin-permission" version = "1.0.0-beta.0" @@ -545,6 +602,25 @@ dependencies = [ "openharmony-ability", ] +[[package]] +name = "openharmony-ability-plugin-statusbar" +version = "1.0.0-beta.0" +dependencies = [ + "crossbeam-channel", + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + +[[package]] +name = "openharmony-ability-plugin-updater" +version = "1.0.0-beta.0" +dependencies = [ + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + [[package]] name = "openharmony-ability-plugin-url" version = "1.0.0-beta.0" @@ -554,6 +630,15 @@ dependencies = [ "openharmony-ability", ] +[[package]] +name = "openharmony-ability-plugin-version" +version = "1.0.0-beta.0" +dependencies = [ + "napi-derive-ohos", + "napi-ohos", + "openharmony-ability", +] + [[package]] name = "openharmony-ability-plugin-webview" version = "1.0.0-beta.0" @@ -582,18 +667,18 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -624,9 +709,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -635,9 +720,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "pin-project-lite", ] diff --git a/Cargo.toml b/Cargo.toml index 6f88e5c8..0c04e9ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,16 @@ resolver = "2" openharmony-ability-derive = { version = "1.0.0-beta.0", path = "crates/derive" } openharmony-ability = { version = "1.0.0-beta.0", path = "crates/ability" } openharmony-ability-plugin-app-control = { version = "1.0.0-beta.0", path = "crates/plugin-app-control" } +openharmony-ability-plugin-autostart = { version = "1.0.0-beta.0", path = "crates/plugin-autostart" } +openharmony-ability-plugin-clipboard = { version = "1.0.0-beta.0", path = "crates/plugin-clipboard" } openharmony-ability-plugin-files = { version = "1.0.0-beta.0", path = "crates/plugin-files" } +openharmony-ability-plugin-menu = { version = "1.0.0-beta.0", path = "crates/plugin-menu" } openharmony-ability-plugin-permission = { version = "1.0.0-beta.0", path = "crates/plugin-permission" } openharmony-ability-plugin-resource = { version = "1.0.0-beta.0", path = "crates/plugin-resource" } +openharmony-ability-plugin-statusbar = { version = "1.0.0-beta.0", path = "crates/plugin-statusbar" } +openharmony-ability-plugin-updater = { version = "1.0.0-beta.0", path = "crates/plugin-updater" } openharmony-ability-plugin-url = { version = "1.0.0-beta.0", path = "crates/plugin-url" } +openharmony-ability-plugin-version = { version = "1.0.0-beta.0", path = "crates/plugin-version" } openharmony-ability-plugin-webview = { version = "1.0.0-beta.0", path = "crates/plugin-webview" } openharmony-ability-plugin-window = { version = "1.0.0-beta.0", path = "crates/plugin-window" } @@ -32,6 +38,8 @@ ohos-resource-manager-sys = { version = "0.3" } ohos-hilog-binding = { version = "0.2" } # Bridge runtime and examples use these without pinning versions in member manifests. +crossbeam-channel = { version = "0.5" } futures-channel = { version = "0.3" } futures-executor = { version = "0.3" } http = { version = "1" } +log = { version = "0.4" } diff --git a/crates/ability/src/app.rs b/crates/ability/src/app.rs index 76d655f4..84c642f0 100644 --- a/crates/ability/src/app.rs +++ b/crates/ability/src/app.rs @@ -33,6 +33,11 @@ pub struct AbilityInitContext { pub pref_path: Option, pub preferred_locales: Option, pub module_name: Option, + /// OpenHarmony base API Level provided by ArkTS `deviceInfo.sdkApiVersion`. + pub sdk_api_version: Option, + /// HarmonyOS distribution version provided by ArkTS `deviceInfo.distributionOSApiVersion`. + #[napi(js_name = "distributionOSApiVersion")] + pub distribution_api_version: Option, } impl AbilityInitContext { @@ -46,6 +51,8 @@ impl AbilityInitContext { pref_path: context.get("prefPath")?, preferred_locales: context.get("preferredLocales")?, module_name: context.get("moduleName")?, + sdk_api_version: context.get("sdkApiVersion")?, + distribution_api_version: context.get("distributionOSApiVersion")?, }) } } @@ -703,6 +710,40 @@ impl<'a> SaveLoader<'a> { } } +/// Latest `want.parameters` JSON from `onNewWant` (deep-link / URL scheme). +static WANT_PARAMETERS: Mutex = Mutex::new(String::new()); + +/// Initial `want.uri` from `onCreate` (cold start). +static INITIAL_WANT_URI: Mutex = Mutex::new(String::new()); + +pub(crate) fn store_want_parameters(json: &str) { + if let Ok(mut params) = WANT_PARAMETERS.lock() { + *params = json.to_string(); + } +} + +/// Returns the latest `want.parameters` JSON string from `onNewWant`, then clears it. +pub fn take_want_parameters() -> String { + WANT_PARAMETERS + .lock() + .map(|mut p| std::mem::take(&mut *p)) + .unwrap_or_default() +} + +pub(crate) fn store_initial_want_uri(uri: &str) { + if let Ok(mut u) = INITIAL_WANT_URI.lock() { + *u = uri.to_string(); + } +} + +/// Returns the initial `want.uri` from `onCreate` (cold start), then clears it. +pub fn take_initial_want_uri() -> String { + INITIAL_WANT_URI + .lock() + .map(|mut u| std::mem::take(&mut *u)) + .unwrap_or_default() +} + #[cfg(test)] mod tests { use super::OpenHarmonyAppInner; diff --git a/crates/ability/src/event.rs b/crates/ability/src/event.rs index 8948ff2d..914428f8 100644 --- a/crates/ability/src/event.rs +++ b/crates/ability/src/event.rs @@ -90,6 +90,13 @@ pub enum Event<'a> { /// https://developer.huawei.com/consumer/cn/doc/harmonyos-references/arkts-apis-window-window#onkeyboardheightchange7 KeyboardEvent(i32), + /// ability new want event (deep link / URL scheme) + /// alias onNewWant + /// https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/js-apis-app-ability-uiAbility-V5#uiabilityonnewwant + NewWant { + uri: String, + }, + UserEvent, } @@ -118,6 +125,7 @@ impl<'a> Event<'a> { Event::Input(_) => "Input", Event::UserEvent => "UserEvent", Event::KeyboardEvent(_) => "KeyboardEvent", + Event::NewWant { .. } => "NewWant", } } } diff --git a/crates/ability/src/lib.rs b/crates/ability/src/lib.rs index 28d86231..d8509b4a 100644 --- a/crates/ability/src/lib.rs +++ b/crates/ability/src/lib.rs @@ -11,6 +11,7 @@ mod memory; mod node; mod render; mod stage; +pub mod version; mod waker; pub use app::*; @@ -26,6 +27,7 @@ pub use memory::*; pub use node::*; pub use render::*; pub use stage::*; +pub use version::*; pub use waker::*; /// Re-exported for [`impl_bridge_napi_type!`](crate::impl_bridge_napi_type) expansions in diff --git a/crates/ability/src/lifecycle.rs b/crates/ability/src/lifecycle.rs index 6c5c5cc0..ebba5837 100644 --- a/crates/ability/src/lifecycle.rs +++ b/crates/ability/src/lifecycle.rs @@ -29,6 +29,8 @@ pub struct WindowStageEventCallback<'a> { pub on_window_size_change: Function<'a, Object<'a>, ()>, pub on_window_rect_change: Function<'a, Object<'a>, ()>, pub on_avoid_area_change: Function<'a, Object<'a>, ()>, + pub on_new_want: Function<'a, Object<'a>, ()>, + pub on_ability_create_with_want: Function<'a, Object<'a>, ()>, } #[napi(object)] @@ -304,6 +306,26 @@ pub fn create_lifecycle_handle<'a>( Ok(()) })?; + let on_new_want_app = app.clone(); + let on_new_want = env.create_function_from_closure("on_new_want", move |ctx| { + let data = ctx.first_arg::()?; + let uri = data.get_named_property::("uri")?; + let parameters_json = data.get_named_property::("parametersJson")?; + crate::app::store_want_parameters(¶meters_json); + if let Some(ref mut h) = *on_new_want_app.event_loop.borrow_mut() { + h(Event::NewWant { uri }) + } + Ok(()) + })?; + + let on_ability_create_with_want = + env.create_function_from_closure("on_ability_create_with_want", move |ctx| { + let data = ctx.first_arg::()?; + let uri = data.get_named_property::("uri")?; + crate::app::store_initial_want_uri(&uri); + Ok(()) + })?; + Ok(ApplicationLifecycle { bridge_plugins, environment_callback: EnvironmentCallback { @@ -321,6 +343,8 @@ pub fn create_lifecycle_handle<'a>( on_window_size_change: window_resize, on_avoid_area_change: avoid_area_change, on_window_stage_event: window_stage_event, + on_new_want, + on_ability_create_with_want, }, keyboard_event_callback: KeyboardCallback { on_keyboard_height_change: keyboard_event_callback, diff --git a/crates/ability/src/version.rs b/crates/ability/src/version.rs new file mode 100644 index 00000000..ef970869 --- /dev/null +++ b/crates/ability/src/version.rs @@ -0,0 +1,55 @@ +//! Framework-level device version information. +//! +//! The `#[ability]` macro initializes these values from the ArkTS `AbilityInitContext` +//! (`deviceInfo.sdkApiVersion` / `deviceInfo.distributionOSApiVersion`) during `init`. +//! The values are process-wide and read from any Rust thread. +//! +//! Interactive capability queries (`canIUse`, desktop detection) live in the +//! `plugin-version` capability package, never in the core. + +use std::sync::OnceLock; + +static SDK_API_VERSION: OnceLock = OnceLock::new(); +static DISTRIBUTION_API_VERSION: OnceLock = OnceLock::new(); + +/// Initializes version information from the ArkTS side. Called by the `#[ability]` macro +/// during `init`; subsequent calls are no-ops (first call wins). +pub fn init(sdk_version: i32, dist_version: i32) { + let _ = SDK_API_VERSION.set(sdk_version); + let _ = DISTRIBUTION_API_VERSION.set(dist_version); +} + +/// OpenHarmony base API Level (e.g., 12, 14, 20) or 0 when not initialized. +/// Corresponds to `since N` annotations in OpenHarmony API documentation. +pub fn sdk_api_version() -> i32 { + SDK_API_VERSION.get().copied().unwrap_or(0) +} + +/// HarmonyOS distribution API version as `M × 10000 + S × 100 + F` +/// (e.g. HarmonyOS 5.0.1 → 50001) or 0 when not initialized. +pub fn distribution_api_version() -> i32 { + DISTRIBUTION_API_VERSION.get().copied().unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{distribution_api_version, init, sdk_api_version}; + + #[test] + fn version_init_is_idempotent() { + init(20, 50001); + let sdk = sdk_api_version(); + let dist = distribution_api_version(); + init(99, 99999); + assert_eq!(sdk_api_version(), sdk, "first init wins"); + assert_eq!(distribution_api_version(), dist, "first init wins"); + } + + #[test] + fn version_returns_zero_before_init() { + let sdk = sdk_api_version(); + let dist = distribution_api_version(); + assert!(sdk >= 0); + assert!(dist >= 0); + } +} diff --git a/crates/derive/src/lib.rs b/crates/derive/src/lib.rs index 3b64a1e3..afb503f1 100644 --- a/crates/derive/src/lib.rs +++ b/crates/derive/src/lib.rs @@ -126,6 +126,16 @@ pub fn ability(attr: TokenStream, item: TokenStream) -> TokenStream { context: Option>, ) -> napi_ohos::Result> { let init_context = openharmony_ability::AbilityInitContext::from_object(context.as_ref())?; + // Initialize version information from ArkTS side + openharmony_ability::version::init( + init_context.sdk_api_version.unwrap_or(0), + init_context.distribution_api_version.unwrap_or(0), + ); + log::info!( + "OHOS version: sdk_api={}, distribution_api={}", + openharmony_ability::version::sdk_api_version(), + openharmony_ability::version::distribution_api_version(), + ); openharmony_ability::attach_bridge_session(env, bindings, &bridge_owner, &APP)?; let mut bridge_guard = BridgeSessionInitGuard::new(bridge_owner); (*APP).set_init_context(init_context); diff --git a/crates/plugin-app-control/src/lib.rs b/crates/plugin-app-control/src/lib.rs index cb1faad7..91c13f59 100644 --- a/crates/plugin-app-control/src/lib.rs +++ b/crates/plugin-app-control/src/lib.rs @@ -1,4 +1,7 @@ //! Main-thread-only application-control plugin facade. +//! +//! Capabilities (all synchronous, ability context): `terminate`, `restart` (hard process +//! restart via `appRecovery.restartApp()`), `set-color-mode` (dark/light/system theme). use napi_derive_ohos::napi; use napi_ohos::{Env, Error, Result}; @@ -17,6 +20,7 @@ impl BridgePlugin for AppControlBridgePlugin { &[BridgeContextRequirement::Ability]; } +/// Marker retained for API symmetry; all actions are synchronous. #[napi(object)] #[derive(Clone, Debug)] pub struct TerminateRequest { @@ -33,9 +37,72 @@ pub struct TerminateResponse { impl_bridge_napi_type!(TerminateResponse, "ohos.app_control.TerminateResponse"); +#[napi(object)] +#[derive(Clone, Debug)] +pub struct RestartRequest { + pub requested: bool, +} + +impl_bridge_napi_type!(RestartRequest, "ohos.app_control.RestartRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct RestartResponse { + pub accepted: bool, +} + +impl_bridge_napi_type!(RestartResponse, "ohos.app_control.RestartResponse"); + +impl RestartResponse { + fn ensure(self) -> Result<()> { + if self.accepted { + Ok(()) + } else { + Err(Error::from_reason( + "App-control plugin rejected the restart request", + )) + } + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct ColorModeRequest { + /// 0 = dark, 1 = light, 2 = follow system (`ConfigurationConstant.ColorMode`). + pub mode: i32, +} + +impl_bridge_napi_type!(ColorModeRequest, "ohos.app_control.ColorModeRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct ColorModeResponse { + pub accepted: bool, +} + +impl_bridge_napi_type!(ColorModeResponse, "ohos.app_control.ColorModeResponse"); + +impl ColorModeResponse { + fn ensure(self) -> Result<()> { + if self.accepted { + Ok(()) + } else { + Err(Error::from_reason( + "App-control plugin rejected the color mode change", + )) + } + } +} + /// 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<()>; + + /// Hard process restart via `appRecovery.restartApp()`. + fn restart(&self, env: &Env) -> Result<()>; + + /// Switches the app color mode: 0 = dark, 1 = light, 2 = follow system. + fn set_color_mode(&self, env: &Env, mode: i32) -> Result<()>; } impl AppControlExt for OpenHarmonyApp { @@ -54,15 +121,42 @@ impl AppControlExt for OpenHarmonyApp { Ok(()) }) } + + fn restart(&self, env: &Env) -> Result<()> { + self.with_main_thread_bridge(env, |bridge| { + let response = bridge + .call_sync::( + "restart", + RestartRequest { requested: true }, + )?; + response.ensure() + }) + } + + fn set_color_mode(&self, env: &Env, mode: i32) -> Result<()> { + if !(0..=2).contains(&mode) { + return Err(Error::from_reason( + "color mode must be 0 (dark), 1 (light) or 2 (follow system)", + )); + } + self.with_main_thread_bridge(env, |bridge| { + let response = bridge + .call_sync::( + "set-color-mode", + ColorModeRequest { mode }, + )?; + response.ensure() + }) + } } #[cfg(test)] mod tests { - use super::{TerminateRequest, TerminateResponse}; + use super::{ColorModeRequest, RestartRequest, TerminateRequest, TerminateResponse}; use openharmony_ability::BridgeNapiType; #[test] - fn terminate_uses_a_stable_named_napi_contract() { + fn app_control_uses_stable_named_napi_contracts() { assert_eq!( ::TYPE_NAME, "ohos.app_control.TerminateRequest" @@ -71,6 +165,18 @@ mod tests { ::TYPE_NAME, "ohos.app_control.TerminateResponse" ); + assert_eq!( + ::TYPE_NAME, + "ohos.app_control.RestartRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.app_control.ColorModeRequest" + ); + } + + #[test] + fn terminate_roundtrip_shape() { assert_eq!(TerminateRequest { code: -1 }.code, -1); assert!(TerminateResponse { accepted: true }.accepted); } diff --git a/crates/plugin-autostart/Cargo.toml b/crates/plugin-autostart/Cargo.toml new file mode 100644 index 00000000..d21b40e8 --- /dev/null +++ b/crates/plugin-autostart/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openharmony-ability-plugin-autostart" +version = "1.0.0-beta.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "autostart settings 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-autostart/src/lib.rs b/crates/plugin-autostart/src/lib.rs new file mode 100644 index 00000000..01f69127 --- /dev/null +++ b/crates/plugin-autostart/src/lib.rs @@ -0,0 +1,176 @@ +//! Autostart settings capability plugin facade. +//! +//! Ordinary applications cannot toggle autostart directly. `open-settings` navigates to the +//! platform app-launch settings page, while `is-enabled` queries the API 21+ status surface. + +use napi_derive_ohos::napi; +use napi_ohos::{Error, Result}; +use openharmony_ability::{ + impl_bridge_napi_type, sdk_api_version, AsyncBridge, BridgeCallOptions, + BridgeContextRequirement, BridgePlugin, BridgeRuntime, OpenHarmonyApp, +}; + +pub struct AutostartBridgePlugin; + +impl BridgePlugin for AutostartBridgePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "ohos.autostart"; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::Ability]; +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct AutostartRequest { + pub requested: bool, +} + +impl_bridge_napi_type!(AutostartRequest, "ohos.autostart.Request"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct AutostartAcknowledgement { + pub accepted: bool, +} + +impl_bridge_napi_type!(AutostartAcknowledgement, "ohos.autostart.Acknowledgement"); + +impl AutostartAcknowledgement { + fn ensure(self) -> Result<()> { + if self.accepted { + Ok(()) + } else { + Err(Error::from_reason( + "Autostart plugin rejected the requested operation", + )) + } + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct AutostartStatusResponse { + pub enabled: bool, + pub supported: bool, +} + +impl_bridge_napi_type!(AutostartStatusResponse, "ohos.autostart.StatusResponse"); + +/// Public status result that distinguishes an unavailable platform API from a disabled switch. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AutostartStatus { + pub enabled: bool, + pub supported: bool, +} + +/// Worker-safe autostart client scoped to the current `OpenHarmonyApp` bridge registry. +#[derive(Clone)] +pub struct AutostartManager { + bridge: BridgeRuntime, +} + +impl AutostartManager { + fn new(app: &OpenHarmonyApp) -> Result { + Ok(Self { + bridge: app.bridge()?, + }) + } + + /// Opens the system app-launch management page. The user performs the actual toggle there. + pub async fn open_settings(&self) -> Result<()> { + self.bridge + .call_async::( + "open-settings", + AutostartRequest { requested: true }, + BridgeCallOptions::default(), + ) + .await? + .ensure() + } + + /// Compatibility alias: OpenHarmony requires the user to enable autostart in settings. + pub async fn enable(&self) -> Result<()> { + self.open_settings().await + } + + /// Compatibility alias: OpenHarmony requires the user to disable autostart in settings. + pub async fn disable(&self) -> Result<()> { + self.open_settings().await + } + + /// Queries the current autostart status and whether this device exposes the API. + pub async fn status(&self) -> Result { + if sdk_api_version() < 21 { + return Ok(AutostartStatus { + enabled: false, + supported: false, + }); + } + let response = self + .bridge + .call_async::( + "is-enabled", + AutostartRequest { requested: true }, + BridgeCallOptions::default(), + ) + .await?; + Ok(AutostartStatus { + enabled: response.enabled, + supported: response.supported, + }) + } + + /// Compatibility query: unsupported devices are reported as disabled. + pub async fn is_enabled(&self) -> Result { + let status = self.status().await?; + Ok(status.supported && status.enabled) + } +} + +/// Extension trait supplied by the capability package, never by framework core. +pub trait AutostartExt { + fn autostart(&self) -> Result; +} + +impl AutostartExt for OpenHarmonyApp { + fn autostart(&self) -> Result { + AutostartManager::new(self) + } +} + +#[cfg(test)] +mod tests { + use super::{ + AutostartAcknowledgement, AutostartBridgePlugin, AutostartRequest, AutostartStatusResponse, + }; + use openharmony_ability::{BridgeContextRequirement, BridgeNapiType, BridgePlugin}; + + #[test] + fn autostart_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.autostart.Request" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.autostart.Acknowledgement" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.autostart.StatusResponse" + ); + assert_eq!( + AutostartBridgePlugin::REQUIRED_CONTEXTS, + &[BridgeContextRequirement::Ability] + ); + } + + #[test] + fn autostart_rejects_negative_acknowledgement() { + assert!(AutostartAcknowledgement { accepted: true }.ensure().is_ok()); + assert!(AutostartAcknowledgement { accepted: false } + .ensure() + .is_err()); + } +} diff --git a/crates/plugin-clipboard/Cargo.toml b/crates/plugin-clipboard/Cargo.toml new file mode 100644 index 00000000..0dbe81d8 --- /dev/null +++ b/crates/plugin-clipboard/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openharmony-ability-plugin-clipboard" +version = "1.0.0-beta.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "clipboard 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-clipboard/src/lib.rs b/crates/plugin-clipboard/src/lib.rs new file mode 100644 index 00000000..362788b4 --- /dev/null +++ b/crates/plugin-clipboard/src/lib.rs @@ -0,0 +1,140 @@ +//! Clipboard capability plugin facade. +//! +//! Ports the former `crates/ability/src/clipboard/mod.rs` TSFN capability into the +//! pluginized bridge model: Rust workers call `write_image`, the async plugin marshals the +//! RGBA buffer to ArkTS through the bridge, and ArkTS writes a PixelMap to the system +//! pasteboard. + +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 ClipboardBridgePlugin; + +impl BridgePlugin for ClipboardBridgePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "ohos.clipboard"; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = &[]; +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct ClipboardImageRequest { + /// RGBA-8888 pixel data, length must equal `width * height * 4`. + pub rgba: Vec, + pub width: u32, + pub height: u32, +} + +impl_bridge_napi_type!(ClipboardImageRequest, "ohos.clipboard.ImageRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct ClipboardAcknowledgement { + pub accepted: bool, +} + +impl_bridge_napi_type!(ClipboardAcknowledgement, "ohos.clipboard.Acknowledgement"); + +impl ClipboardAcknowledgement { + fn ensure(self) -> Result<()> { + if self.accepted { + Ok(()) + } else { + Err(Error::from_reason( + "Clipboard plugin rejected the requested operation", + )) + } + } +} + +fn validate_dimensions(rgba_len: usize, width: u32, height: u32) -> Result<()> { + if width == 0 || height == 0 { + return Err(Error::from_reason( + "clipboard image width and height must be positive", + )); + } + let expected = (width as usize) + .checked_mul(height as usize) + .and_then(|v| v.checked_mul(4)) + .ok_or_else(|| Error::from_reason("clipboard image dimensions overflow"))?; + if rgba_len != expected { + return Err(Error::from_reason(format!( + "clipboard rgba len {rgba_len} != expected {expected} ({width}x{height}x4)" + ))); + } + Ok(()) +} + +/// Extension trait supplied by the capability package, never by `openharmony-ability` core. +pub trait ClipboardExt { + /// Writes RGBA-8888 image data to the system clipboard. + fn write_image( + &self, + rgba: Vec, + width: u32, + height: u32, + ) -> Pin> + Send>>; +} + +impl ClipboardExt for OpenHarmonyApp { + fn write_image( + &self, + rgba: Vec, + width: u32, + height: u32, + ) -> Pin> + Send>> { + let validated = + validate_dimensions(rgba.len(), width, height).map(|()| ClipboardImageRequest { + rgba, + width, + height, + }); + let bridge = self.bridge(); + + Box::pin(async move { + let response = bridge? + .call_async::( + "write-image", + validated?, + BridgeCallOptions::default().with_timeout_ms(10_000), + ) + .await?; + response.ensure() + }) + } +} + +#[cfg(test)] +mod tests { + use super::{ + validate_dimensions, ClipboardAcknowledgement, ClipboardBridgePlugin, ClipboardImageRequest, + }; + use openharmony_ability::{BridgeNapiType, BridgePlugin}; + + #[test] + fn clipboard_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.clipboard.ImageRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.clipboard.Acknowledgement" + ); + } + + #[test] + fn clipboard_validates_rgba_dimensions() { + assert!(validate_dimensions(4 * 2 * 2, 2, 2).is_ok()); + assert!(validate_dimensions(3, 2, 2).is_err()); + assert!(validate_dimensions(0, 0, 0).is_err()); + assert!(ClipboardBridgePlugin::REQUIRED_CONTEXTS.is_empty()); + } +} diff --git a/crates/plugin-menu/Cargo.toml b/crates/plugin-menu/Cargo.toml new file mode 100644 index 00000000..8b0a3fd7 --- /dev/null +++ b/crates/plugin-menu/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "openharmony-ability-plugin-menu" +version = "1.0.0-beta.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "menu bridge plugin facade for openharmony-ability" + +[dependencies] +openharmony-ability = { workspace = true } +napi-ohos = { workspace = true } +napi-derive-ohos = { workspace = true } +crossbeam-channel = { workspace = true } diff --git a/crates/plugin-menu/src/lib.rs b/crates/plugin-menu/src/lib.rs new file mode 100644 index 00000000..35d41cf2 --- /dev/null +++ b/crates/plugin-menu/src/lib.rs @@ -0,0 +1,563 @@ +//! Menu capability plugin facade (menubar + context popup). +//! +//! Ports the former `crates/ability/src/menu/` capability into the pluginized bridge model. +//! +//! Direction of data flow: +//! - Rust (muda) builds a menu tree and calls `set_menubar` / `popup`; the async plugin +//! delivers it to ArkTS, which renders the `MenuBarComponent` / popup UI. +//! - ArkTS reports clicks through the named main-thread event (`menu-click`); the Rust facade +//! forwards them into [`MenuExt::menu_event_receiver`] for muda-style listeners. +//! +//! Menu items are structured, named N-API values (`ohos.menu.MenuItemData`), never JSON. + +use std::{collections::BTreeSet, future::Future, pin::Pin}; + +use crossbeam_channel::{unbounded, Receiver, Sender}; +use napi_derive_ohos::napi; +use napi_ohos::{bindgen_prelude::Unknown, Error, Result}; +use openharmony_ability::{ + impl_bridge_napi_type, AsyncBridge, BridgeCallOptions, BridgeContextRequirement, + BridgeMainThreadEvent, BridgePlugin, OpenHarmonyApp, +}; + +pub struct MenuBridgePlugin; + +impl BridgePlugin for MenuBridgePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "ohos.menu"; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = &[]; + + fn on_main_thread_event<'env>( + &self, + event: BridgeMainThreadEvent<'env>, + ) -> Result> { + match event.name() { + "menu-click" => { + let request = event.decode::()?; + if request.id.trim().is_empty() { + return Err(Error::from_reason( + "menu-click event requires a non-empty menu id", + )); + } + if request + .window_id + .as_ref() + .is_some_and(|window_id| window_id.trim().is_empty()) + { + return Err(Error::from_reason( + "menu-click event window id must not be empty when provided", + )); + } + let _ = MENU_EVENT_TX.send(MenuEvent { + id: request.id, + window_id: request.window_id, + }); + event.respond(MenuClickResponse { accepted: true }) + } + _ => Err(Error::from_reason(format!( + "Menu plugin does not support main-thread event '{}'", + event.name() + ))), + } + } +} + +/// ArkTS → Rust menu click request (named N-API main-thread event). +#[napi(object)] +#[derive(Clone, Debug)] +pub struct MenuClickRequest { + pub id: String, + pub window_id: Option, +} + +impl_bridge_napi_type!(MenuClickRequest, "ohos.menu.ClickRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct MenuClickResponse { + pub accepted: bool, +} + +impl_bridge_napi_type!(MenuClickResponse, "ohos.menu.ClickResponse"); + +/// Rust-owned menu event. `window_id` preserves the originating business window for multi-window +/// plugin instances; it is not a native module routing key. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MenuEvent { + pub id: String, + pub window_id: Option, +} + +static MENU_EVENT_CHANNEL: std::sync::LazyLock<(Sender, Receiver)> = + std::sync::LazyLock::new(unbounded); + +static MENU_EVENT_TX: std::sync::LazyLock> = + std::sync::LazyLock::new(|| MENU_EVENT_CHANNEL.0.clone()); + +/// Menu item kinds, mirroring muda/tauri menu semantics. +pub mod item_kind { + pub const NORMAL: &str = "item"; + pub const SUBMENU: &str = "submenu"; + pub const SEPARATOR: &str = "separator"; + pub const PREDEFINED: &str = "predefined"; + pub const CHECK: &str = "check"; + pub const ICON: &str = "icon"; + pub const ABOUT: &str = "about"; +} + +const MAX_MENU_ITEMS: usize = 512; +const MAX_MENU_DEPTH: usize = 16; + +/// Optional application metadata for the "about" menu item. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct AboutMetadataData { + pub name: Option, + pub version: Option, + pub short_version: Option, + pub authors: Option>, + pub comments: Option, + pub copyright: Option, + pub license: Option, + pub website: Option, +} + +impl_bridge_napi_type!(AboutMetadataData, "ohos.menu.AboutMetadata"); + +/// One recursive menu entry. `submenu_items` nests child entries. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct MenuItemData { + pub id: String, + /// One of [`item_kind`] constants. + pub item_type: String, + pub text: Option, + pub enabled: Option, + pub accelerator: Option, + /// One of the predefined action names, e.g. `copy`, `paste`, `quit`, `minimize`. + pub predefined_type: Option, + pub checked: Option, + pub icon: Option, + pub native_icon: Option, + pub submenu_items: Option>, + pub about_metadata: Option, +} + +impl_bridge_napi_type!(MenuItemData, "ohos.menu.MenuItemData"); + +impl MenuItemData { + pub fn new(id: impl Into, item_type: impl Into) -> Self { + Self { + id: id.into(), + item_type: item_type.into(), + text: None, + enabled: None, + accelerator: None, + predefined_type: None, + checked: None, + icon: None, + native_icon: None, + submenu_items: None, + about_metadata: None, + } + } + + pub fn text(mut self, text: impl Into) -> Self { + self.text = Some(text.into()); + self + } + + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = Some(enabled); + self + } + + pub fn accelerator(mut self, accelerator: impl Into) -> Self { + self.accelerator = Some(accelerator.into()); + self + } + + pub fn predefined(mut self, predefined_type: impl Into) -> Self { + self.predefined_type = Some(predefined_type.into()); + self + } + + pub fn checked(mut self, checked: bool) -> Self { + self.checked = Some(checked); + self + } + + pub fn submenu(mut self, items: Vec) -> Self { + self.submenu_items = Some(items); + self + } + + fn validate( + &self, + ids: &mut BTreeSet, + item_count: &mut usize, + depth: usize, + ) -> Result<()> { + if depth > MAX_MENU_DEPTH { + return Err(Error::from_reason(format!( + "menu tree exceeds the {MAX_MENU_DEPTH} level depth limit" + ))); + } + *item_count = item_count + .checked_add(1) + .ok_or_else(|| Error::from_reason("menu item count overflow"))?; + if *item_count > MAX_MENU_ITEMS { + return Err(Error::from_reason(format!( + "menu tree exceeds the {MAX_MENU_ITEMS} item limit" + ))); + } + if self.id.trim().is_empty() { + return Err(Error::from_reason("menu item id must not be empty")); + } + if !matches!( + self.item_type.as_str(), + item_kind::NORMAL + | item_kind::SUBMENU + | item_kind::SEPARATOR + | item_kind::PREDEFINED + | item_kind::CHECK + | item_kind::ICON + | item_kind::ABOUT + ) { + return Err(Error::from_reason(format!( + "unsupported menu item type '{}'", + self.item_type + ))); + } + if !ids.insert(self.id.clone()) { + return Err(Error::from_reason(format!( + "menu item id '{}' is duplicated", + self.id + ))); + } + if let Some(items) = self.submenu_items.as_ref() { + for item in items { + item.validate(ids, item_count, depth + 1)?; + } + } + Ok(()) + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct MenuBarRequest { + pub window_id: String, + pub items: Vec, +} + +impl_bridge_napi_type!(MenuBarRequest, "ohos.menu.MenuBarRequest"); + +impl MenuBarRequest { + fn validate(&self) -> Result<()> { + if self.window_id.trim().is_empty() { + return Err(Error::from_reason("menu windowId must not be empty")); + } + let mut ids = BTreeSet::new(); + let mut item_count = 0; + for item in &self.items { + item.validate(&mut ids, &mut item_count, 1)?; + } + Ok(()) + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct MenuPopupRequest { + pub window_id: String, + pub x: Option, + pub y: Option, + pub items: Vec, +} + +impl_bridge_napi_type!(MenuPopupRequest, "ohos.menu.PopupRequest"); + +impl MenuPopupRequest { + fn validate(&self) -> Result<()> { + if self.window_id.trim().is_empty() { + return Err(Error::from_reason("menu windowId must not be empty")); + } + if self.items.is_empty() { + return Err(Error::from_reason("popup menu requires at least one item")); + } + if self.x.is_some_and(|value| !value.is_finite()) + || self.y.is_some_and(|value| !value.is_finite()) + { + return Err(Error::from_reason( + "popup menu coordinates must be finite numbers", + )); + } + let mut ids = BTreeSet::new(); + let mut item_count = 0; + for item in &self.items { + item.validate(&mut ids, &mut item_count, 1)?; + } + Ok(()) + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct MenuVisibilityRequest { + pub window_id: String, + pub visible: bool, +} + +impl_bridge_napi_type!(MenuVisibilityRequest, "ohos.menu.VisibilityRequest"); + +impl MenuVisibilityRequest { + fn validate(&self) -> Result<()> { + if self.window_id.trim().is_empty() { + return Err(Error::from_reason("menu windowId must not be empty")); + } + Ok(()) + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct MenuAcknowledgement { + pub accepted: bool, +} + +impl_bridge_napi_type!(MenuAcknowledgement, "ohos.menu.Acknowledgement"); + +impl MenuAcknowledgement { + fn ensure(self) -> Result<()> { + if self.accepted { + Ok(()) + } else { + Err(Error::from_reason( + "Menu plugin rejected the requested operation", + )) + } + } +} + +/// Extension trait supplied by the capability package, never by `openharmony-ability` core. +pub trait MenuExt { + /// Sets the per-window menubar content. Empty `items` hides the menubar. + fn set_menubar( + &self, + window_id: impl Into, + items: Vec, + ) -> Pin> + Send>>; + + /// Shows a context popup menu at optional screen coordinates. + fn popup( + &self, + window_id: impl Into, + x: Option, + y: Option, + items: Vec, + ) -> Pin> + Send>>; + + /// Sets per-window menubar visibility. + fn set_menubar_visible( + &self, + window_id: impl Into, + visible: bool, + ) -> Pin> + Send>>; + + /// Receives menu ids and their originating business window ids from ArkTS UI. + fn menu_event_receiver() -> &'static Receiver; +} + +impl MenuExt for OpenHarmonyApp { + fn set_menubar( + &self, + window_id: impl Into, + items: Vec, + ) -> Pin> + Send>> { + let request = MenuBarRequest { + window_id: window_id.into(), + items, + }; + if let Err(error) = request.validate() { + return Box::pin(async move { Err(error) }); + } + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "set-menubar", + request, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn popup( + &self, + window_id: impl Into, + x: Option, + y: Option, + items: Vec, + ) -> Pin> + Send>> { + let request = MenuPopupRequest { + window_id: window_id.into(), + x, + y, + items, + }; + if let Err(error) = request.validate() { + return Box::pin(async move { Err(error) }); + } + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "popup", + request, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn set_menubar_visible( + &self, + window_id: impl Into, + visible: bool, + ) -> Pin> + Send>> { + let request = MenuVisibilityRequest { + window_id: window_id.into(), + visible, + }; + if let Err(error) = request.validate() { + return Box::pin(async move { Err(error) }); + } + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "set-menubar-visible", + request, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn menu_event_receiver() -> &'static Receiver { + &MENU_EVENT_CHANNEL.1 + } +} + +#[cfg(test)] +mod tests { + use super::{ + item_kind, MenuBarRequest, MenuBridgePlugin, MenuEvent, MenuItemData, MenuPopupRequest, + MenuVisibilityRequest, MAX_MENU_DEPTH, + }; + use openharmony_ability::{BridgeNapiType, BridgePlugin}; + + #[test] + fn menu_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.menu.MenuItemData" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.menu.MenuBarRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.menu.PopupRequest" + ); + } + + #[test] + fn menu_item_builds_recursive_tree() { + let file = MenuItemData::new("file", item_kind::SUBMENU) + .text("File") + .submenu(vec![ + MenuItemData::new("file.open", item_kind::NORMAL).text("Open"), + MenuItemData::new("file.sep", item_kind::SEPARATOR), + MenuItemData::new("file.quit", item_kind::PREDEFINED) + .predefined("quit") + .accelerator("Ctrl+Q"), + ]); + let request = MenuBarRequest { + window_id: "main".to_owned(), + items: vec![file], + }; + assert!(request.validate().is_ok()); + let empty_window = MenuBarRequest { + window_id: " ".to_owned(), + items: vec![], + }; + assert!(empty_window.validate().is_err()); + assert!(MenuBridgePlugin::REQUIRED_CONTEXTS.is_empty()); + + let duplicated = MenuBarRequest { + window_id: "main".to_owned(), + items: vec![ + MenuItemData::new("same", item_kind::NORMAL), + MenuItemData::new("same", item_kind::NORMAL), + ], + }; + assert!(duplicated.validate().is_err()); + + let unsupported = MenuBarRequest { + window_id: "main".to_owned(), + items: vec![MenuItemData::new("unknown", "custom")], + }; + assert!(unsupported.validate().is_err()); + + let mut nested = MenuItemData::new("leaf", item_kind::NORMAL); + for depth in 0..MAX_MENU_DEPTH { + nested = MenuItemData::new(format!("nested.{depth}"), item_kind::SUBMENU) + .submenu(vec![nested]); + } + assert!(MenuBarRequest { + window_id: "main".to_owned(), + items: vec![nested], + } + .validate() + .is_err()); + + let event = MenuEvent { + id: "open".to_owned(), + window_id: Some("secondary".to_owned()), + }; + assert_eq!(event.window_id.as_deref(), Some("secondary")); + } + + #[test] + fn popup_requires_items() { + let request = MenuPopupRequest { + window_id: "main".to_owned(), + x: Some(10.0), + y: Some(20.0), + items: vec![], + }; + assert!(request.validate().is_err()); + + let non_finite = MenuPopupRequest { + window_id: "main".to_owned(), + x: Some(f64::NAN), + y: None, + items: vec![MenuItemData::new("open", item_kind::NORMAL)], + }; + assert!(non_finite.validate().is_err()); + + let visibility = MenuVisibilityRequest { + window_id: " ".to_owned(), + visible: true, + }; + assert!(visibility.validate().is_err()); + } +} diff --git a/crates/plugin-permission/src/lib.rs b/crates/plugin-permission/src/lib.rs index aad71b9c..5fa9e7b2 100644 --- a/crates/plugin-permission/src/lib.rs +++ b/crates/plugin-permission/src/lib.rs @@ -1,4 +1,4 @@ -//! Typed Rust facade for the ArkTS `@ohos-rs/plugin-permission` HAR. +//! Typed Rust facade for the ArkTS `@ohos-rs/ability-plugin-permission` HAR. //! //! This crate deliberately depends on the bridge core; the core does not depend on this crate. diff --git a/crates/plugin-statusbar/Cargo.toml b/crates/plugin-statusbar/Cargo.toml new file mode 100644 index 00000000..1a6eef28 --- /dev/null +++ b/crates/plugin-statusbar/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "openharmony-ability-plugin-statusbar" +version = "1.0.0-beta.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "statusbar bridge plugin facade for openharmony-ability" + +[dependencies] +openharmony-ability = { workspace = true } +napi-ohos = { workspace = true } +napi-derive-ohos = { workspace = true } +crossbeam-channel = { workspace = true } diff --git a/crates/plugin-statusbar/src/lib.rs b/crates/plugin-statusbar/src/lib.rs new file mode 100644 index 00000000..4bbef67d --- /dev/null +++ b/crates/plugin-statusbar/src/lib.rs @@ -0,0 +1,802 @@ +//! System tray / status bar capability plugin facade. +//! +//! Ports the former `crates/ability/src/statusbar/` capability into the pluginized bridge +//! model. Rust (tray-icon style callers) build a [`StatusBarItemData`] and call the add / +//! update actions; ArkTS renders the item. Clicks flow back through the bounded event port +//! into [`StatusBarExt::icon_click_receiver`] / [`StatusBarExt::menu_click_receiver`]. + +use std::{collections::BTreeSet, future::Future, pin::Pin}; + +use crossbeam_channel::{unbounded, Receiver, Sender}; +use napi_derive_ohos::napi; +use napi_ohos::{bindgen_prelude::Unknown, Error, Result}; +use openharmony_ability::{ + impl_bridge_napi_type, AsyncBridge, BridgeCallOptions, BridgeContextRequirement, + BridgeMainThreadEvent, BridgePlugin, OpenHarmonyApp, +}; + +pub struct StatusBarBridgePlugin; + +impl BridgePlugin for StatusBarBridgePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "ohos.statusbar"; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::Ability]; + + fn on_main_thread_event<'env>( + &self, + event: BridgeMainThreadEvent<'env>, + ) -> Result> { + match event.name() { + "icon-click" => { + let request = event.decode::()?; + if request.click_type.trim().is_empty() { + return Err(Error::from_reason( + "statusbar icon-click event requires a non-empty click type", + )); + } + let _ = ICON_CLICK_TX.send(request.click_type); + event.respond(StatusBarClickResponse { accepted: true }) + } + "menu-click" => { + let request = event.decode::()?; + if request.menu_code.trim().is_empty() { + return Err(Error::from_reason( + "statusbar menu-click event requires a non-empty menu code", + )); + } + let _ = MENU_CLICK_TX.send(request.menu_code); + event.respond(StatusBarClickResponse { accepted: true }) + } + _ => Err(Error::from_reason(format!( + "Status bar plugin does not support main-thread event '{}'", + event.name() + ))), + } + } +} + +/// ArkTS → Rust tray icon click request (named N-API main-thread event). +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarClickRequest { + pub click_type: String, +} + +impl_bridge_napi_type!(StatusBarClickRequest, "ohos.statusbar.ClickRequest"); + +/// ArkTS → Rust tray menu click request (named N-API main-thread event). +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarMenuClickRequest { + pub menu_code: String, +} + +impl_bridge_napi_type!(StatusBarMenuClickRequest, "ohos.statusbar.MenuClickRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarClickResponse { + pub accepted: bool, +} + +impl_bridge_napi_type!(StatusBarClickResponse, "ohos.statusbar.ClickResponse"); + +static ICON_CLICK_CHANNEL: std::sync::LazyLock<(Sender, Receiver)> = + std::sync::LazyLock::new(unbounded); + +static MENU_CLICK_CHANNEL: std::sync::LazyLock<(Sender, Receiver)> = + std::sync::LazyLock::new(unbounded); + +static ICON_CLICK_TX: std::sync::LazyLock> = + std::sync::LazyLock::new(|| ICON_CLICK_CHANNEL.0.clone()); + +static MENU_CLICK_TX: std::sync::LazyLock> = + std::sync::LazyLock::new(|| MENU_CLICK_CHANNEL.0.clone()); + +const MAX_MENU_ITEMS: usize = 20; +const MAX_SUB_MENU_ITEMS: usize = 20; +const MAX_HOVER_TIPS_LEN: usize = 128; +const DEFAULT_MENU_ICON_SIZE: u32 = 24; + +fn expected_rgba_len(width: u32, height: u32, label: &str) -> Result { + if width == 0 || height == 0 { + return Err(Error::from_reason(format!( + "{label} width and height must be positive" + ))); + } + (width as usize) + .checked_mul(height as usize) + .and_then(|value| value.checked_mul(4)) + .ok_or_else(|| Error::from_reason(format!("{label} dimensions overflow"))) +} + +/// Quick-operation card shown when the tray item is hovered. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct QuickOperationData { + pub ability_name: String, + pub title: String, + pub height: u32, + /// ArkTS/HAP module containing the target extension Ability; unrelated to native bridge hosts. + pub module_name: Option, + pub loading_status: Option, +} + +impl_bridge_napi_type!(QuickOperationData, "ohos.statusbar.QuickOperation"); + +impl Default for QuickOperationData { + fn default() -> Self { + Self { + ability_name: "EntryAbility".to_owned(), + title: "App".to_owned(), + height: 200, + module_name: Some("entry".to_owned()), + loading_status: None, + } + } +} + +impl QuickOperationData { + fn validate(&self) -> Result<()> { + if self.ability_name.trim().is_empty() { + return Err(Error::from_reason( + "status bar quick operation ability name must not be empty", + )); + } + if self.title.trim().is_empty() { + return Err(Error::from_reason( + "status bar quick operation title must not be empty", + )); + } + if self.height == 0 { + return Err(Error::from_reason( + "status bar quick operation height must be positive", + )); + } + Ok(()) + } +} + +/// Menu action launched when a tray menu item is activated. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarMenuActionData { + pub ability_name: String, + /// ArkTS/HAP module used in the platform Want; unrelated to native bridge hosts. + pub module_name: Option, + pub menu_code: Option, + pub notify_only: Option, +} + +impl_bridge_napi_type!(StatusBarMenuActionData, "ohos.statusbar.MenuAction"); + +impl StatusBarMenuActionData { + fn validate(&self) -> Result<()> { + if self + .menu_code + .as_ref() + .is_some_and(|code| code.trim().is_empty()) + { + return Err(Error::from_reason( + "status bar menu action code must not be empty when provided", + )); + } + Ok(()) + } + + fn menu_code(&self) -> Option<&str> { + self.menu_code.as_deref() + } +} + +/// One tray sub-menu entry. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarSubMenuItemData { + pub sub_title: String, + pub menu_code: Option, + pub menu_action: StatusBarMenuActionData, + pub selected: Option, +} + +impl_bridge_napi_type!(StatusBarSubMenuItemData, "ohos.statusbar.SubMenuItem"); + +impl StatusBarSubMenuItemData { + fn validate(&self) -> Result<()> { + if self.sub_title.trim().is_empty() { + return Err(Error::from_reason( + "status bar sub menu title must not be empty", + )); + } + if self + .menu_code + .as_ref() + .is_some_and(|code| code.trim().is_empty()) + { + return Err(Error::from_reason( + "status bar sub menu code must not be empty when provided", + )); + } + self.menu_action.validate() + } + + fn effective_menu_code(&self) -> Option<&str> { + self.menu_code + .as_deref() + .or_else(|| self.menu_action.menu_code()) + } +} + +/// One tray menu entry with an optional sub-menu group. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarMenuItemData { + pub title: String, + pub menu_code: Option, + pub sub_menu: Option>, + pub menu_action: Option, + pub selected: Option, + pub icon_rgba: Option>, + pub icon_width: Option, + pub icon_height: Option, +} + +impl_bridge_napi_type!(StatusBarMenuItemData, "ohos.statusbar.MenuItem"); + +impl StatusBarMenuItemData { + fn validate(&self) -> Result<()> { + if self.title.trim().is_empty() { + return Err(Error::from_reason( + "status bar menu item title must not be empty", + )); + } + if let Some(sub_menu) = self.sub_menu.as_ref() { + if sub_menu.len() > MAX_SUB_MENU_ITEMS { + return Err(Error::from_reason(format!( + "status bar sub menu exceeds the {MAX_SUB_MENU_ITEMS} item limit" + ))); + } + for item in sub_menu { + item.validate()?; + } + } + if self.menu_action.is_none() + && self + .sub_menu + .as_ref() + .map(|items| items.is_empty()) + .unwrap_or(true) + { + return Err(Error::from_reason( + "status bar menu item requires a menu action or non-empty sub menu", + )); + } + if let Some(action) = self.menu_action.as_ref() { + action.validate()?; + } + if self + .menu_code + .as_ref() + .is_some_and(|code| code.trim().is_empty()) + { + return Err(Error::from_reason( + "status bar menu code must not be empty when provided", + )); + } + match self.icon_rgba.as_ref() { + Some(rgba) => { + let width = self.icon_width.unwrap_or(DEFAULT_MENU_ICON_SIZE); + let height = self.icon_height.unwrap_or(DEFAULT_MENU_ICON_SIZE); + let expected = expected_rgba_len(width, height, "status bar menu icon")?; + if rgba.len() != expected { + return Err(Error::from_reason(format!( + "status bar menu icon rgba len {} != expected {expected} ({width}x{height}x4)", + rgba.len() + ))); + } + } + None if self.icon_width.is_some() || self.icon_height.is_some() => { + return Err(Error::from_reason( + "status bar menu icon dimensions require icon rgba data", + )); + } + None => {} + } + Ok(()) + } + + fn effective_menu_code(&self) -> Option<&str> { + self.menu_code.as_deref().or_else(|| { + self.menu_action + .as_ref() + .and_then(|action| action.menu_code()) + }) + } +} + +/// One tray icon with optional white/black RGBA pixel variants. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarIconData { + pub white_rgba: Option>, + pub black_rgba: Option>, + pub size: u32, +} + +impl_bridge_napi_type!(StatusBarIconData, "ohos.statusbar.IconData"); + +impl StatusBarIconData { + fn validate(&self) -> Result<()> { + let expected = expected_rgba_len(self.size, self.size, "status bar icon")?; + let mut variants = 0usize; + for (name, rgba) in [ + ("white", self.white_rgba.as_ref()), + ("black", self.black_rgba.as_ref()), + ] { + if let Some(rgba) = rgba { + variants += 1; + if rgba.len() != expected { + return Err(Error::from_reason(format!( + "status bar {name} icon rgba len {} != expected {expected} ({}x{}x4)", + rgba.len(), + self.size, + self.size + ))); + } + } + } + if variants == 0 { + return Err(Error::from_reason( + "status bar requires at least one icon variant", + )); + } + Ok(()) + } +} + +fn validate_menu_groups(groups: &[Vec]) -> Result<()> { + let total = groups.iter().try_fold(0usize, |total, group| { + total + .checked_add(group.len()) + .ok_or_else(|| Error::from_reason("status bar menu item count overflow")) + })?; + if total > MAX_MENU_ITEMS { + return Err(Error::from_reason(format!( + "status bar menu exceeds the {MAX_MENU_ITEMS} item limit" + ))); + } + let mut menu_codes = BTreeSet::new(); + for group in groups { + for item in group { + item.validate()?; + insert_unique_menu_code(&mut menu_codes, item.effective_menu_code())?; + if let Some(sub_menu) = item.sub_menu.as_ref() { + for sub_item in sub_menu { + insert_unique_menu_code(&mut menu_codes, sub_item.effective_menu_code())?; + } + } + } + } + Ok(()) +} + +fn insert_unique_menu_code(codes: &mut BTreeSet, code: Option<&str>) -> Result<()> { + let Some(code) = code else { + return Ok(()); + }; + if !codes.insert(code.to_owned()) { + return Err(Error::from_reason(format!( + "status bar menu code '{code}' is duplicated" + ))); + } + Ok(()) +} + +/// Full tray item description. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarItemData { + pub icons: StatusBarIconData, + pub quick_operation: QuickOperationData, + pub status_bar_group_menu: Option>>, + pub hover_tips: Option, +} + +impl_bridge_napi_type!(StatusBarItemData, "ohos.statusbar.ItemData"); + +impl StatusBarItemData { + fn validate(&self) -> Result<()> { + self.icons.validate()?; + self.quick_operation.validate()?; + if let Some(tips) = self.hover_tips.as_ref() { + let len = tips.trim().len(); + if len == 0 || len > MAX_HOVER_TIPS_LEN { + return Err(Error::from_reason(format!( + "status bar hover tips must be 1..={MAX_HOVER_TIPS_LEN} chars" + ))); + } + } + if let Some(groups) = self.status_bar_group_menu.as_ref() { + validate_menu_groups(groups)?; + } + Ok(()) + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarAddRequest { + pub item: StatusBarItemData, +} + +impl_bridge_napi_type!(StatusBarAddRequest, "ohos.statusbar.AddRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarIconRequest { + pub icon: StatusBarIconData, +} + +impl_bridge_napi_type!(StatusBarIconRequest, "ohos.statusbar.IconRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarMenuRequest { + pub groups: Vec>, +} + +impl_bridge_napi_type!(StatusBarMenuRequest, "ohos.statusbar.MenuRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarTipsRequest { + pub tips: String, +} + +impl_bridge_napi_type!(StatusBarTipsRequest, "ohos.statusbar.TipsRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarActionRequest { + /// One of the predefined action names, e.g. `show` or `quit`. + pub action: String, +} + +impl_bridge_napi_type!(StatusBarActionRequest, "ohos.statusbar.ActionRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct StatusBarAcknowledgement { + pub accepted: bool, +} + +impl_bridge_napi_type!(StatusBarAcknowledgement, "ohos.statusbar.Acknowledgement"); + +impl StatusBarAcknowledgement { + fn ensure(self) -> Result<()> { + if self.accepted { + Ok(()) + } else { + Err(Error::from_reason( + "Status bar plugin rejected the requested operation", + )) + } + } +} + +/// Extension trait supplied by the capability package, never by `openharmony-ability` core. +pub trait StatusBarExt { + /// Adds a tray item. Replaces any existing item. + fn add_to_status_bar( + &self, + item: StatusBarItemData, + ) -> Pin> + Send>>; + + /// Removes the tray item. + fn remove_from_status_bar(&self) -> Pin> + Send>>; + + /// Updates the tray icon. + fn update_status_bar_icon( + &self, + icon: StatusBarIconData, + ) -> Pin> + Send>>; + + /// Updates the tray menu groups. + fn update_status_bar_menu( + &self, + groups: Vec>, + ) -> Pin> + Send>>; + + /// Updates the hover tooltip. + fn update_hover_tips( + &self, + tips: impl Into, + ) -> Pin> + Send>>; + + /// Executes a predefined tray action. + fn execute_predefined_action( + &self, + action: impl Into, + ) -> Pin> + Send>>; + + /// Receives tray icon click types from ArkTS. + fn icon_click_receiver() -> &'static Receiver; + /// Receives tray menu codes from ArkTS. + fn menu_click_receiver() -> &'static Receiver; +} + +impl StatusBarExt for OpenHarmonyApp { + fn add_to_status_bar( + &self, + item: StatusBarItemData, + ) -> Pin> + Send>> { + if let Err(error) = item.validate() { + return Box::pin(async move { Err(error) }); + } + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "add", + StatusBarAddRequest { item }, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn remove_from_status_bar(&self) -> Pin> + Send>> { + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "remove", + StatusBarAddRequest { + item: StatusBarItemData { + icons: StatusBarIconData { + white_rgba: Some(Vec::new()), + black_rgba: None, + size: 1, + }, + quick_operation: QuickOperationData::default(), + status_bar_group_menu: None, + hover_tips: None, + }, + }, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn update_status_bar_icon( + &self, + icon: StatusBarIconData, + ) -> Pin> + Send>> { + if let Err(error) = icon.validate() { + return Box::pin(async move { Err(error) }); + } + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "update-icon", + StatusBarIconRequest { icon }, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn update_status_bar_menu( + &self, + groups: Vec>, + ) -> Pin> + Send>> { + if let Err(error) = validate_menu_groups(&groups) { + return Box::pin(async move { Err(error) }); + } + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "update-menu", + StatusBarMenuRequest { groups }, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn update_hover_tips( + &self, + tips: impl Into, + ) -> Pin> + Send>> { + let tips = tips.into(); + let len = tips.trim().len(); + if len == 0 || len > MAX_HOVER_TIPS_LEN { + return Box::pin(async { + Err(Error::from_reason(format!( + "status bar hover tips must be 1..={MAX_HOVER_TIPS_LEN} chars" + ))) + }); + } + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "update-tips", + StatusBarTipsRequest { tips }, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn execute_predefined_action( + &self, + action: impl Into, + ) -> Pin> + Send>> { + let action = action.into(); + if action.trim().is_empty() { + return Box::pin(async { + Err(Error::from_reason("predefined action must not be empty")) + }); + } + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "predefined-action", + StatusBarActionRequest { action }, + BridgeCallOptions::default(), + ) + .await?; + response.ensure() + }) + } + + fn icon_click_receiver() -> &'static Receiver { + &ICON_CLICK_CHANNEL.1 + } + + fn menu_click_receiver() -> &'static Receiver { + &MENU_CLICK_CHANNEL.1 + } +} + +#[cfg(test)] +mod tests { + use super::{ + validate_menu_groups, QuickOperationData, StatusBarIconData, StatusBarItemData, + StatusBarMenuActionData, StatusBarMenuItemData, + }; + use openharmony_ability::BridgeNapiType; + + #[test] + fn statusbar_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.statusbar.ItemData" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.statusbar.IconData" + ); + } + + #[test] + fn statusbar_validates_item_shape() { + let item = StatusBarItemData { + icons: StatusBarIconData { + white_rgba: Some(vec![0u8; 16 * 16 * 4]), + black_rgba: None, + size: 16, + }, + quick_operation: QuickOperationData::default(), + status_bar_group_menu: Some(vec![vec![StatusBarMenuItemData { + title: "Open".to_owned(), + menu_code: Some("open".to_owned()), + sub_menu: None, + menu_action: Some(StatusBarMenuActionData { + ability_name: "EntryAbility".to_owned(), + module_name: None, + menu_code: None, + notify_only: None, + }), + selected: None, + icon_rgba: None, + icon_width: None, + icon_height: None, + }]]), + hover_tips: Some("hover".to_owned()), + }; + assert!(item.validate().is_ok()); + + let no_icon = StatusBarItemData { + icons: StatusBarIconData { + white_rgba: None, + black_rgba: None, + size: 16, + }, + quick_operation: QuickOperationData::default(), + status_bar_group_menu: None, + hover_tips: None, + }; + assert!(no_icon.validate().is_err()); + + let malformed_icon = StatusBarIconData { + white_rgba: Some(vec![0u8; 15]), + black_rgba: None, + size: 2, + }; + assert!(malformed_icon.validate().is_err()); + + let no_action = StatusBarMenuItemData { + title: "Broken".to_owned(), + menu_code: None, + sub_menu: None, + menu_action: None, + selected: None, + icon_rgba: None, + icon_width: None, + icon_height: None, + }; + assert!(no_action.validate().is_err()); + + let menu_item = StatusBarMenuItemData { + title: "Open".to_owned(), + menu_code: Some("open".to_owned()), + sub_menu: None, + menu_action: Some(StatusBarMenuActionData { + ability_name: "EntryAbility".to_owned(), + module_name: None, + menu_code: None, + notify_only: None, + }), + selected: None, + icon_rgba: None, + icon_width: None, + icon_height: None, + }; + assert!(validate_menu_groups(&[vec![menu_item; 21]]).is_err()); + + let duplicate_code = StatusBarMenuItemData { + title: "Duplicate".to_owned(), + menu_code: None, + sub_menu: None, + menu_action: Some(StatusBarMenuActionData { + ability_name: "EntryAbility".to_owned(), + module_name: None, + menu_code: Some("duplicate".to_owned()), + notify_only: None, + }), + selected: None, + icon_rgba: None, + icon_width: None, + icon_height: None, + }; + assert!(validate_menu_groups(&[vec![duplicate_code.clone(), duplicate_code,]]).is_err()); + + let zero_height = StatusBarItemData { + icons: StatusBarIconData { + white_rgba: Some(vec![0u8; 4]), + black_rgba: None, + size: 1, + }, + quick_operation: QuickOperationData { + height: 0, + ..QuickOperationData::default() + }, + status_bar_group_menu: None, + hover_tips: None, + }; + assert!(zero_height.validate().is_err()); + } +} diff --git a/crates/plugin-updater/Cargo.toml b/crates/plugin-updater/Cargo.toml new file mode 100644 index 00000000..e53309a2 --- /dev/null +++ b/crates/plugin-updater/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openharmony-ability-plugin-updater" +version = "1.0.0-beta.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "updater 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-updater/src/lib.rs b/crates/plugin-updater/src/lib.rs new file mode 100644 index 00000000..c0c5c211 --- /dev/null +++ b/crates/plugin-updater/src/lib.rs @@ -0,0 +1,150 @@ +//! AppGallery updater capability plugin facade. +//! +//! Ports the former `crates/ability/src/updater.rs` + `helper/updater.rs` capability into +//! the pluginized bridge model. ArkTS uses `@kit.AppGalleryKit` `updateManager`. + +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 UpdaterBridgePlugin; + +impl BridgePlugin for UpdaterBridgePlugin { + type Mode = AsyncBridge; + + const ID: &'static str = "ohos.updater"; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = + &[BridgeContextRequirement::Ability]; +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct UpdaterCheckRequest { + pub requested: bool, +} + +impl_bridge_napi_type!(UpdaterCheckRequest, "ohos.updater.CheckRequest"); + +#[napi(object)] +#[derive(Clone, Debug, Default)] +pub struct UpdaterCheckResult { + pub update_available: bool, + pub current_version: String, + pub version: String, + pub body: Option, + pub date: Option, +} + +impl_bridge_napi_type!(UpdaterCheckResult, "ohos.updater.CheckResult"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct UpdaterCheckResponse { + pub result: Option, +} + +impl_bridge_napi_type!(UpdaterCheckResponse, "ohos.updater.CheckResponse"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct UpdaterAcknowledgement { + pub accepted: bool, +} + +impl_bridge_napi_type!(UpdaterAcknowledgement, "ohos.updater.Acknowledgement"); + +impl UpdaterAcknowledgement { + fn ensure(self) -> Result<()> { + if self.accepted { + Ok(()) + } else { + Err(Error::from_reason( + "Updater plugin rejected the requested operation", + )) + } + } +} + +/// Extension trait supplied by the capability package, never by `openharmony-ability` core. +pub trait UpdaterExt { + /// Checks AppGallery for an available update. Pure query, no dialog. + fn check(&self) -> Pin>> + Send>>; + /// Shows the AppGallery update dialog and drives the download + install flow. + fn download_and_install(&self) -> Pin> + Send>>; +} + +impl UpdaterExt for OpenHarmonyApp { + fn check(&self) -> Pin>> + Send>> { + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "check", + UpdaterCheckRequest { requested: true }, + BridgeCallOptions::default().with_timeout_ms(60_000), + ) + .await?; + Ok(response.result) + }) + } + + fn download_and_install(&self) -> Pin> + Send>> { + let bridge = self.bridge(); + Box::pin(async move { + let response = bridge? + .call_async::( + "download-and-install", + UpdaterCheckRequest { requested: true }, + BridgeCallOptions::default().with_timeout_ms(60_000), + ) + .await?; + response.ensure() + }) + } +} + +#[cfg(test)] +mod tests { + use super::{UpdaterCheckRequest, UpdaterCheckResponse, UpdaterCheckResult}; + use openharmony_ability::BridgeNapiType; + + #[test] + fn updater_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.updater.CheckRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.updater.CheckResult" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.updater.CheckResponse" + ); + } + + #[test] + fn updater_check_result_roundtrip() { + let result = UpdaterCheckResult { + update_available: true, + current_version: "1.0.0".to_owned(), + version: "2.0.0".to_owned(), + body: Some("Bug fixes".to_owned()), + date: Some("2025-01-15".to_owned()), + }; + assert!(result.update_available); + assert_eq!(result.version, "2.0.0"); + let response = UpdaterCheckResponse { + result: Some(result), + }; + assert!(response.result.is_some()); + let none = UpdaterCheckResponse { result: None }; + assert!(none.result.is_none()); + } +} diff --git a/crates/plugin-version/Cargo.toml b/crates/plugin-version/Cargo.toml new file mode 100644 index 00000000..59f376c6 --- /dev/null +++ b/crates/plugin-version/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openharmony-ability-plugin-version" +version = "1.0.0-beta.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "version 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-version/src/lib.rs b/crates/plugin-version/src/lib.rs new file mode 100644 index 00000000..0abfffa2 --- /dev/null +++ b/crates/plugin-version/src/lib.rs @@ -0,0 +1,139 @@ +//! Device version and capability detection plugin facade. +//! +//! Ports the former `crates/ability/src/version.rs` capability into the pluginized bridge +//! model. All queries are synchronous (`MainThreadSyncBridge`) and use process-level device +//! information; ArkTS reads `@ohos.deviceInfo` and the global `canIUse()`. + +use napi_derive_ohos::napi; +use napi_ohos::{Env, Error, Result}; +use openharmony_ability::{ + impl_bridge_napi_type, BridgeContextRequirement, BridgePlugin, MainThreadSyncBridge, + OpenHarmonyApp, +}; + +pub struct VersionBridgePlugin; + +impl BridgePlugin for VersionBridgePlugin { + type Mode = MainThreadSyncBridge; + + const ID: &'static str = "ohos.version"; + const REQUIRED_CONTEXTS: &'static [BridgeContextRequirement] = &[]; +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct VersionRequest { + pub syscap: Option, +} + +impl_bridge_napi_type!(VersionRequest, "ohos.version.VersionRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct VersionResponse { + pub value: i32, + pub result: Option, +} + +impl_bridge_napi_type!(VersionResponse, "ohos.version.VersionResponse"); + +fn validate_syscap(syscap: &str) -> Result<()> { + if syscap.trim().is_empty() { + return Err(Error::from_reason("syscap must not be empty")); + } + Ok(()) +} + +/// Extension trait supplied by the capability package, never by `openharmony-ability` core. +pub trait VersionExt { + /// OpenHarmony base API Level (e.g., 12, 14, 20). + fn sdk_api_version(&self, env: &Env) -> Result; + /// HarmonyOS distribution version (`M × 10000 + S × 100 + F`, e.g. 5.0.1 → 50001). + fn distribution_api_version(&self, env: &Env) -> Result; + /// Queries a device system capability through ArkTS `canIUse()`. + fn can_i_use(&self, env: &Env, syscap: &str) -> Result; + /// True when the device is a desktop (2in1) device. + fn is_desktop_device(&self, env: &Env) -> Result; +} + +impl VersionExt for OpenHarmonyApp { + fn sdk_api_version(&self, env: &Env) -> Result { + self.with_main_thread_bridge(env, |bridge| { + let response = bridge + .call_sync::( + "get-sdk-api-version", + VersionRequest { syscap: None }, + )?; + Ok(response.value) + }) + } + + fn distribution_api_version(&self, env: &Env) -> Result { + self.with_main_thread_bridge(env, |bridge| { + let response = bridge + .call_sync::( + "get-distribution-api-version", + VersionRequest { syscap: None }, + )?; + Ok(response.value) + }) + } + + fn can_i_use(&self, env: &Env, syscap: &str) -> Result { + validate_syscap(syscap)?; + self.with_main_thread_bridge(env, |bridge| { + let response = bridge + .call_sync::( + "can-i-use", + VersionRequest { + syscap: Some(syscap.to_owned()), + }, + )?; + Ok(response.result.unwrap_or(false)) + }) + } + + fn is_desktop_device(&self, env: &Env) -> Result { + self.with_main_thread_bridge(env, |bridge| { + let response = bridge + .call_sync::( + "is-desktop-device", + VersionRequest { syscap: None }, + )?; + Ok(response.result.unwrap_or(false)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{VersionBridgePlugin, VersionRequest, VersionResponse}; + use openharmony_ability::{BridgeNapiType, BridgePlugin}; + + #[test] + fn version_uses_stable_named_napi_contracts() { + assert_eq!( + ::TYPE_NAME, + "ohos.version.VersionRequest" + ); + assert_eq!( + ::TYPE_NAME, + "ohos.version.VersionResponse" + ); + } + + #[test] + fn version_response_carries_expected_shapes() { + let sdk = VersionResponse { + value: 20, + result: None, + }; + assert_eq!(sdk.value, 20); + let capability = VersionResponse { + value: 0, + result: Some(true), + }; + assert!(capability.result.unwrap_or(false)); + assert!(VersionBridgePlugin::REQUIRED_CONTEXTS.is_empty()); + } +} diff --git a/crates/plugin-webview/README.md b/crates/plugin-webview/README.md index 0f5f3f21..167e3cd8 100644 --- a/crates/plugin-webview/README.md +++ b/crates/plugin-webview/README.md @@ -5,7 +5,7 @@ ArkWeb delegate;Rust 只持有 controller ID、具名 N-API 数据及 Rust-owned callback/protocol closure。 插件不把 WebView 写进 framework 的 `DefaultXComponent`。默认情况下 WebView 的 `FrameNode` 挂进 -当前 native module 唯一组件的根树(全屏);需要组合时,`parent_node(handle)` 把它挂到 +当前插件 Host 所绑定组件的根树(全屏);需要组合时,`parent_node(handle)` 把它挂到 `ohos.node` 容器句柄之下,从而保留 WebView、XComponent 和自定义 ArkUI 节点的混合布局。 ## 契约 @@ -17,7 +17,7 @@ ArkWeb delegate;Rust 只持有 controller ID、具名 N-API 数据及 Rust-own | 插件 ID | `ohos.webview` | | 执行模式 | 异步:`AsyncBridge` / `invokeAsync` | | 前置 context | `ui-context` | -| 挂载 | 当前 module/component 根树(默认全屏);可选 `parentHandle` 挂到 `ohos.node` 容器 | +| 挂载 | 当前 Host/component 根树(默认全屏);可选 `parentHandle` 挂到 `ohos.node` 容器 | | 核心 action | `create`、控制器操作、`evaluate-script` | 所有出站 action 和所有 ArkWeb 反向事件都是具名 N-API 契约,不使用 JSON。`create` 返回 controller @@ -112,11 +112,11 @@ user agent、autoplay、document-start initialization scripts、headers 和 `tra ## 挂载与生命周期 -- 一个 `DefaultXComponent` 对应一个 native module;跨窗口的第二个组件必须使用另一个 module, - `WebviewCreateRequest` 不接受 window/surface key。 -- 同一个 module/component 可用不同 WebView ID 同时创建多个实例;每个实例拥有独立 mount key 和 - controller,同 ID recreate 才替换旧实例。ID 只在当前 module 内唯一;ArkTS 会生成进程唯一的 - 内部 ArkWeb tag,跨 module 使用相同业务 ID 不会冲突。 +- framework 仍规定一个 `DefaultXComponent` 对应一个 native module;插件契约只接收已经绑定好的 + Host/context,不接受 module、window 或 surface 路由参数。 +- 同一个插件实例/component 可用不同 WebView ID 同时创建多个实例;每个实例拥有独立 mount key 和 + controller,同 ID recreate 才替换旧实例。ID 只在当前插件实例内唯一;ArkTS 会生成进程唯一的 + 内部 ArkWeb tag,不同插件实例使用相同业务 ID 不会冲突。 - 默认全屏挂入当前 component 根树;需要组合时用 `parent_node(container)` 把 WebView 挂到 `ohos.node` 容器之下(容器最终也由 Rust 决定挂不挂根)。 - 异步 `create` 等待 controller attach 和首次导航启动;等待由 lifecycle/cancel 驱动,不使用 @@ -126,7 +126,7 @@ user agent、autoplay、document-start initialization scripts、headers 和 `tra ## WebView 回调 -在 `create` 前用 `WebviewCallbacksBuilder` 按 module-local WebView ID 声明回调: +在 `create` 前用 `WebviewCallbacksBuilder` 按 facade-local WebView ID 声明回调: ```rust use openharmony_ability_plugin_webview::{ @@ -148,7 +148,7 @@ controller;same-ID recreate 前的延迟事件不能命中新实例。所有 c 运行,应快速返回;耗时工作只能复制数据后投递给 worker。 `ui-context-destroy` / `ability-destroy` 会兜底清空 controller attachment/tag 状态,但保留 callback、 -protocol 和 proxy 声明,供同 module 后续 appearance 重建 controller。该兜底不依赖 closing 阶段还能 +protocol 和 proxy 声明,供同 facade 后续 appearance 重建 controller。该兜底不依赖 closing 阶段还能 成功执行 ArkTS → Rust 清理通知。 ## 自定义 protocol 与页面 JavaScript @@ -179,10 +179,10 @@ WebviewJavascriptProxyBuilder::new("article", "native") .build()?; ``` -`WebviewProtocol::register` 的新声明只能在进程级 engine 初始化前调用。不同 native module 拥有 -独立 Rust 状态,但共享 ArkWeb engine;第一个 WebView create 会让所有已激活且装配该插件的 module +`WebviewProtocol::register` 的新声明只能在进程级 engine 初始化前调用。不同插件 facade 拥有 +独立 Rust 状态,但共享 ArkWeb engine;第一个 WebView create 会让所有已激活的插件 facade 先 flush 声明,再初始化 engine。Ability 重建时重复相同 scheme + options 是幂等操作;engine 启动 -后才加载的 module 只能复用进程已注册且 options 相同的 scheme,不能再新增 custom scheme。ID handler 和 JS proxy 应优先在 `create` +后才激活的 facade 只能复用进程已注册且 options 相同的 scheme,不能再新增 custom scheme。ID handler 和 JS proxy 应优先在 `create` 前声明;controller attach 后插件会先安装 protocol/proxy/delegate,再开始首次导航。需要异步回复 custom protocol 时使用 `custom_protocol_async` / `WebviewProtocolResponder`。 diff --git a/crates/plugin-webview/src/callbacks.rs b/crates/plugin-webview/src/callbacks.rs index b52e6c53..777abc58 100644 --- a/crates/plugin-webview/src/callbacks.rs +++ b/crates/plugin-webview/src/callbacks.rs @@ -1,6 +1,6 @@ //! Rust-owned WebView callback declarations. //! -//! The registry stores only Rust closures keyed by module-local WebView ID. ArkTS receives a boolean +//! The registry stores only Rust closures keyed by facade-local WebView ID. ArkTS receives a boolean //! subscription snapshot as part of create, then invokes every ArkWeb callback through a scoped //! named N-API event. No ArkTS Function, ObjectRef, or JSON event payload is kept by Rust. diff --git a/crates/plugin-webview/src/controller.rs b/crates/plugin-webview/src/controller.rs index 64bfd43d..1f8c6455 100644 --- a/crates/plugin-webview/src/controller.rs +++ b/crates/plugin-webview/src/controller.rs @@ -1,4 +1,4 @@ -//! Module-local business WebView ID -> process-unique ArkWeb tag mapping. +//! Facade-local business WebView ID -> process-unique ArkWeb tag mapping. use std::{ collections::BTreeMap, diff --git a/crates/plugin-webview/src/js_proxy.rs b/crates/plugin-webview/src/js_proxy.rs index 8e27af1b..a4660e0e 100644 --- a/crates/plugin-webview/src/js_proxy.rs +++ b/crates/plugin-webview/src/js_proxy.rs @@ -1,7 +1,7 @@ //! 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 module-local WebView ID and +//! public builder therefore queues declarations by facade-local WebView ID and //! `WebviewBridgePlugin` flushes //! them when the ArkTS Web component reports `controller-attached`. This keeps the page callback //! in native ArkWeb while avoiding an ArkTS object or N-API function reference on a Rust worker. @@ -42,7 +42,7 @@ static PROXY_STATE: LazyLock> = /// Builder for a persistent JavaScript object exposed to a WebView page. /// /// The registered object is available as `window.` and each declared method receives -/// the module-local business WebView ID plus stringified page arguments. Calling [`Self::build`] +/// the facade-local business WebView ID plus stringified page arguments. Calling [`Self::build`] /// before `create` is the preferred path: the declaration is installed exactly when the /// process-unique ArkWeb controller attaches, before the initial document is loaded. pub struct WebviewJavascriptProxyBuilder { @@ -166,7 +166,7 @@ pub(crate) fn on_controller_removed(webview_id: &str, native_tag: &str) -> Resul } /// Clears controller-generation state at component/session teardown. Proxy declarations remain -/// available for a later controller created with the same module-local business ID. +/// available for a later controller created with the same facade-local business ID. pub(crate) fn clear_attached() -> Result<()> { PROXY_STATE .lock() diff --git a/crates/plugin-webview/src/lib.rs b/crates/plugin-webview/src/lib.rs index e9ee057d..7e5a7f38 100644 --- a/crates/plugin-webview/src/lib.rs +++ b/crates/plugin-webview/src/lib.rs @@ -212,7 +212,7 @@ pub struct WebviewStyle { #[derive(Clone, Debug)] pub struct WebviewEngineLifecycleEvent { pub phase: String, - /// Process-global scheme set sealed before ArkWeb initialization. A module activated after + /// Process-global scheme set sealed before ArkWeb initialization. A facade activated after /// the engine started may join only when every local declaration already exists in this set /// with identical options. pub schemes: Vec, @@ -250,7 +250,7 @@ impl_bridge_napi_type!( pub struct WebviewControllerEvent { pub id: String, /// Process-unique ArkWeb controller tag generated by the ArkTS host. The public WebView ID - /// remains module-local and is never used as a process-global platform key. + /// remains local to one plugin facade and is never used as a process-global platform key. pub native_tag: String, } @@ -282,8 +282,8 @@ pub struct WebviewInitializationScript { pub struct WebviewCreateRequest { pub id: String, /// Optional opaque container handle issued by the built-in `ohos.node` plugin. When provided, - /// the ArkTS host appends the WebView FrameNode under that container instead of this native - /// module's DefaultXComponent root. + /// the ArkTS host appends the WebView FrameNode under that container instead of the current + /// plugin Host's DefaultXComponent root. pub parent_handle: Option, pub url: Option, pub html: Option, @@ -504,7 +504,7 @@ impl WebviewClient { }) } - /// Reopens a controller-ID facade for a WebView already created in this module/session. + /// Reopens a controller-ID facade for a WebView already created by this plugin facade. pub fn handle(&self, id: impl Into) -> WebviewHandle { WebviewHandle { client: self.clone(), @@ -512,7 +512,7 @@ impl WebviewClient { } } - /// Declares a custom-scheme handler by module-local WebView ID before the ArkTS node is created. + /// Declares a custom-scheme handler by facade-local WebView ID before the ArkTS node is created. /// /// The Rust closure is queued and attached from the controller-attached main-thread event, /// before the initial URL is loaded. This is the preferred route when the first URL uses that @@ -712,6 +712,100 @@ impl WebviewHandle { .await } + /// Repositions and resizes the WebView surface (per-window bounds). + pub async fn set_bounds(&self, x: f64, y: f64, width: f64, height: f64) -> Result<()> { + let request = WebviewBoundsRequest { + id: self.id.clone(), + x, + y, + width, + height, + }; + request.validate()?; + self.client + .call::<_, WebviewAcknowledgement>("set-bounds", request) + .await? + .ensure() + } + + /// Sets a single cookie for the given URL via `WebCookieManager.configCookieSync`. + pub async fn set_cookie(&self, url: impl Into, value: impl Into) -> Result<()> { + let request = WebviewCookieRequest { + id: self.id.clone(), + url: url.into(), + value: value.into(), + }; + request.validate()?; + self.client + .call::<_, WebviewAcknowledgement>("set-cookie", request) + .await? + .ensure() + } + + /// Captures the current page as RGBA pixels. + pub async fn snapshot(&self) -> Result, u32, u32)>> { + let response = self + .client + .call::<_, WebviewSnapshotResponse>( + "snapshot", + WebviewSnapshotRequest { + id: self.id.clone(), + }, + ) + .await?; + Ok(response + .rgba + .map(|rgba| (rgba, response.width, response.height))) + } + + /// Toggles Web debugging at runtime (`setWebDebuggingAccess`). + pub async fn set_web_debugging_access(&self, enabled: bool) -> Result<()> { + self.client + .call::<_, WebviewAcknowledgement>( + "set-debugging-access", + WebviewBoolRequest { + id: self.id.clone(), + enabled: Some(enabled), + }, + ) + .await? + .ensure() + } + + /// Reads the current Web debugging access flag. + pub async fn is_web_debugging_access(&self) -> Result { + let response = self + .client + .call::<_, WebviewBoolResponse>( + "is-debugging-access", + WebviewBoolRequest { + id: self.id.clone(), + enabled: None, + }, + ) + .await?; + Ok(response.value) + } + + /// Generates a PDF of the current page into `path`. Must be called after the page has + /// fully loaded (`on_page_end`). + pub async fn create_pdf( + &self, + path: impl Into, + config: Option, + ) -> Result<()> { + let request = WebviewPdfRequest { + id: self.id.clone(), + path: path.into(), + config, + }; + request.validate()?; + self.client + .call::<_, WebviewAcknowledgement>("create-pdf", request) + .await? + .ensure() + } + pub async fn remove(&self) -> Result<()> { self.acknowledge("remove", self.controller_request()).await } @@ -884,6 +978,162 @@ pub struct WebviewStringResponse { impl_bridge_napi_type!(WebviewStringResponse, "ohos.webview.StringResponse"); +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewBoundsRequest { + pub id: String, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl_bridge_napi_type!(WebviewBoundsRequest, "ohos.webview.BoundsRequest"); + +impl WebviewBoundsRequest { + fn validate(&self) -> Result<()> { + if self.id.trim().is_empty() { + return Err(Error::from_reason("WebView id must not be empty")); + } + if !self.width.is_finite() + || !self.height.is_finite() + || self.width <= 0.0 + || self.height <= 0.0 + { + return Err(Error::from_reason( + "WebView bounds width and height must be positive finite numbers", + )); + } + Ok(()) + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewCookieRequest { + pub id: String, + pub url: String, + /// Set-Cookie format value, e.g. `name=value; Domain=...; Path=...`. + pub value: String, +} + +impl_bridge_napi_type!(WebviewCookieRequest, "ohos.webview.CookieRequest"); + +impl WebviewCookieRequest { + fn validate(&self) -> Result<()> { + if self.id.trim().is_empty() { + return Err(Error::from_reason("WebView id must not be empty")); + } + if self.url.trim().is_empty() { + return Err(Error::from_reason("cookie url must not be empty")); + } + if self.value.trim().is_empty() { + return Err(Error::from_reason("cookie value must not be empty")); + } + Ok(()) + } +} + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewSnapshotRequest { + pub id: String, +} + +impl_bridge_napi_type!(WebviewSnapshotRequest, "ohos.webview.SnapshotRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewSnapshotResponse { + pub rgba: Option>, + pub width: u32, + pub height: u32, +} + +impl_bridge_napi_type!(WebviewSnapshotResponse, "ohos.webview.SnapshotResponse"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewBoolRequest { + pub id: String, + pub enabled: Option, +} + +impl_bridge_napi_type!(WebviewBoolRequest, "ohos.webview.BoolRequest"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewBoolResponse { + pub value: bool, +} + +impl_bridge_napi_type!(WebviewBoolResponse, "ohos.webview.BoolResponse"); + +/// PDF export options. Field names match ArkTS `PdfConfiguration`. +#[napi(object)] +#[derive(Clone, Debug, Default)] +pub struct PdfConfig { + pub width: Option, + pub height: Option, + pub margin_top: Option, + pub margin_bottom: Option, + pub margin_left: Option, + pub margin_right: Option, + pub scale: Option, + pub should_print_background: Option, +} + +impl_bridge_napi_type!(PdfConfig, "ohos.webview.PdfConfig"); + +#[napi(object)] +#[derive(Clone, Debug)] +pub struct WebviewPdfRequest { + pub id: String, + pub path: String, + pub config: Option, +} + +impl_bridge_napi_type!(WebviewPdfRequest, "ohos.webview.PdfRequest"); + +impl WebviewPdfRequest { + fn validate(&self) -> Result<()> { + if self.id.trim().is_empty() { + return Err(Error::from_reason("WebView id must not be empty")); + } + if self.path.trim().is_empty() { + return Err(Error::from_reason("pdf path must not be empty")); + } + if let Some(config) = &self.config { + validate_positive_pdf_value("width", config.width)?; + validate_positive_pdf_value("height", config.height)?; + validate_non_negative_pdf_value("margin_top", config.margin_top)?; + validate_non_negative_pdf_value("margin_bottom", config.margin_bottom)?; + validate_non_negative_pdf_value("margin_left", config.margin_left)?; + validate_non_negative_pdf_value("margin_right", config.margin_right)?; + validate_positive_pdf_value("scale", config.scale)?; + } + Ok(()) + } +} + +fn validate_positive_pdf_value(name: &str, value: Option) -> Result<()> { + if value.is_some_and(|value| !value.is_finite() || value <= 0.0) { + return Err(Error::from_reason(format!( + "pdf {name} must be a finite positive number" + ))); + } + Ok(()) +} + +fn validate_non_negative_pdf_value(name: &str, value: Option) -> Result<()> { + if value.is_some_and(|value| !value.is_finite() || value < 0.0) { + return Err(Error::from_reason(format!( + "pdf {name} must be a finite non-negative number" + ))); + } + Ok(()) +} + #[napi(object)] #[derive(Clone, Debug)] pub struct WebviewAcknowledgement { @@ -1027,6 +1277,29 @@ mod tests { ); } + #[test] + fn pdf_request_rejects_invalid_geometry() { + let request = WebviewPdfRequest { + id: "webview".to_owned(), + path: "/data/storage/el2/base/files/page.pdf".to_owned(), + config: Some(PdfConfig { + width: Some(0.0), + ..PdfConfig::default() + }), + }; + assert!(request.validate().is_err()); + + let request = WebviewPdfRequest { + id: "webview".to_owned(), + path: "/data/storage/el2/base/files/page.pdf".to_owned(), + config: Some(PdfConfig { + margin_top: Some(-1.0), + ..PdfConfig::default() + }), + }; + assert!(request.validate().is_err()); + } + #[test] fn engine_events_are_ability_scoped_but_controller_events_require_ui() { let plugin = WebviewBridgePlugin; diff --git a/crates/plugin-webview/src/protocol.rs b/crates/plugin-webview/src/protocol.rs index c2acbe2f..03ef8966 100644 --- a/crates/plugin-webview/src/protocol.rs +++ b/crates/plugin-webview/src/protocol.rs @@ -117,8 +117,8 @@ impl WebviewProtocol { if state.engine_initialized { return Ok(()); } - // A native module can be activated after another module initialized ArkWeb. Matching - // schemes are already registered process-wide, so this module joins without calling the + // A plugin facade can be activated after another facade initialized ArkWeb. Matching + // schemes are already registered process-wide, so it joins without calling the // pre-init platform API again. New or conflicting schemes were rejected above. state.sealed = true; state.flushed = true; @@ -165,7 +165,7 @@ fn ensure_schemes_registered( continue; } return Err(Error::from_reason(format!( - "WebView scheme '{scheme}' from this native module was not registered with matching options before the process-global engine initialized" + "WebView scheme '{scheme}' from this plugin facade was not registered with matching options before the process-global engine initialized" ))); } Ok(()) @@ -182,7 +182,7 @@ fn scheme_registration_needed( "WebView scheme '{scheme}' was already registered with different options" ))); } - // Native module statics survive Ability recreation. Repeating the same declaration is a + // Facade static state survives Ability recreation. Repeating the same declaration is a // no-op even after the process-global engine has started. return Ok(false); } @@ -222,7 +222,7 @@ impl WebviewProtocolResponder { } } -/// Declares a custom-scheme handler for a module-local WebView ID. +/// Declares a custom-scheme handler for a facade-local WebView ID. /// /// The declaration may be made before the ArkTS node exists. The handler is attached only when /// the Web component reports `controller-attached`, after ArkWeb has created its BrowserContext diff --git a/demo/build-profile.json5 b/demo/build-profile.json5 index 9a9d5459..b39c0d80 100644 --- a/demo/build-profile.json5 +++ b/demo/build-profile.json5 @@ -58,6 +58,10 @@ "name": "plugin_app_control", "srcPath": "../plugins/app-control", }, + { + "name": "plugin_autostart", + "srcPath": "../plugins/autostart", + }, { "name": "plugin_permission", "srcPath": "../plugins/permission", @@ -74,6 +78,26 @@ "name": "plugin_window", "srcPath": "../plugins/window", }, + { + "name": "plugin_clipboard", + "srcPath": "../plugins/clipboard", + }, + { + "name": "plugin_menu", + "srcPath": "../plugins/menu", + }, + { + "name": "plugin_statusbar", + "srcPath": "../plugins/statusbar", + }, + { + "name": "plugin_updater", + "srcPath": "../plugins/updater", + }, + { + "name": "plugin_version", + "srcPath": "../plugins/version", + }, { "name": "plugin_url", "srcPath": "../plugins/url", diff --git a/demo/entry/oh-package-lock.json5 b/demo/entry/oh-package-lock.json5 index e3c86a2e..bfb49142 100644 --- a/demo/entry/oh-package-lock.json5 +++ b/demo/entry/oh-package-lock.json5 @@ -7,10 +7,16 @@ "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { "@ohos-rs/ability-plugin-app-control@../../plugins/app-control": "@ohos-rs/ability-plugin-app-control@../../plugins/app-control", + "@ohos-rs/ability-plugin-autostart@../../plugins/autostart": "@ohos-rs/ability-plugin-autostart@../../plugins/autostart", + "@ohos-rs/ability-plugin-clipboard@../../plugins/clipboard": "@ohos-rs/ability-plugin-clipboard@../../plugins/clipboard", "@ohos-rs/ability-plugin-files@../../plugins/files": "@ohos-rs/ability-plugin-files@../../plugins/files", + "@ohos-rs/ability-plugin-menu@../../plugins/menu": "@ohos-rs/ability-plugin-menu@../../plugins/menu", "@ohos-rs/ability-plugin-permission@../../plugins/permission": "@ohos-rs/ability-plugin-permission@../../plugins/permission", "@ohos-rs/ability-plugin-resource@../../plugins/resource": "@ohos-rs/ability-plugin-resource@../../plugins/resource", + "@ohos-rs/ability-plugin-statusbar@../../plugins/statusbar": "@ohos-rs/ability-plugin-statusbar@../../plugins/statusbar", + "@ohos-rs/ability-plugin-updater@../../plugins/updater": "@ohos-rs/ability-plugin-updater@../../plugins/updater", "@ohos-rs/ability-plugin-url@../../plugins/url": "@ohos-rs/ability-plugin-url@../../plugins/url", + "@ohos-rs/ability-plugin-version@../../plugins/version": "@ohos-rs/ability-plugin-version@../../plugins/version", "@ohos-rs/ability-plugin-webview@../../plugins/webview": "@ohos-rs/ability-plugin-webview@../../plugins/webview", "@ohos-rs/ability-plugin-window@../../plugins/window": "@ohos-rs/ability-plugin-window@../../plugins/window", "@ohos-rs/ability@../../native_ability": "@ohos-rs/ability@../../native_ability", @@ -28,6 +34,24 @@ "@ohos-rs/ability": "file:../../native_ability" } }, + "@ohos-rs/ability-plugin-autostart@../../plugins/autostart": { + "name": "@ohos-rs/ability-plugin-autostart", + "version": "1.0.0-beta.0", + "resolved": "../../plugins/autostart", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } + }, + "@ohos-rs/ability-plugin-clipboard@../../plugins/clipboard": { + "name": "@ohos-rs/ability-plugin-clipboard", + "version": "1.0.0-beta.0", + "resolved": "../../plugins/clipboard", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } + }, "@ohos-rs/ability-plugin-files@../../plugins/files": { "name": "@ohos-rs/ability-plugin-files", "version": "1.0.0-beta.0", @@ -37,6 +61,15 @@ "@ohos-rs/ability": "file:../../native_ability" } }, + "@ohos-rs/ability-plugin-menu@../../plugins/menu": { + "name": "@ohos-rs/ability-plugin-menu", + "version": "1.0.0-beta.0", + "resolved": "../../plugins/menu", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } + }, "@ohos-rs/ability-plugin-permission@../../plugins/permission": { "name": "@ohos-rs/ability-plugin-permission", "version": "1.0.0-beta.0", @@ -55,6 +88,24 @@ "@ohos-rs/ability": "file:../../native_ability" } }, + "@ohos-rs/ability-plugin-statusbar@../../plugins/statusbar": { + "name": "@ohos-rs/ability-plugin-statusbar", + "version": "1.0.0-beta.0", + "resolved": "../../plugins/statusbar", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } + }, + "@ohos-rs/ability-plugin-updater@../../plugins/updater": { + "name": "@ohos-rs/ability-plugin-updater", + "version": "1.0.0-beta.0", + "resolved": "../../plugins/updater", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } + }, "@ohos-rs/ability-plugin-url@../../plugins/url": { "name": "@ohos-rs/ability-plugin-url", "version": "1.0.0-beta.0", @@ -64,6 +115,15 @@ "@ohos-rs/ability": "file:../../native_ability" } }, + "@ohos-rs/ability-plugin-version@../../plugins/version": { + "name": "@ohos-rs/ability-plugin-version", + "version": "1.0.0-beta.0", + "resolved": "../../plugins/version", + "registryType": "local", + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability" + } + }, "@ohos-rs/ability-plugin-webview@../../plugins/webview": { "name": "@ohos-rs/ability-plugin-webview", "version": "1.0.0-beta.1", @@ -107,4 +167,4 @@ "registryType": "local" } } -} \ No newline at end of file +} diff --git a/demo/entry/oh-package.json5 b/demo/entry/oh-package.json5 index 3387d532..677392ed 100644 --- a/demo/entry/oh-package.json5 +++ b/demo/entry/oh-package.json5 @@ -11,11 +11,17 @@ "libdemo_sub_native.so": "file:./src/main/cpp/types/libdemo_sub_native", "@ohos-rs/ability": "file:../../native_ability", "@ohos-rs/ability-plugin-app-control": "file:../../plugins/app-control", + "@ohos-rs/ability-plugin-autostart": "file:../../plugins/autostart", "@ohos-rs/ability-plugin-permission": "file:../../plugins/permission", "@ohos-rs/ability-plugin-resource": "file:../../plugins/resource", "@ohos-rs/ability-plugin-webview": "file:../../plugins/webview", "@ohos-rs/ability-plugin-window": "file:../../plugins/window", "@ohos-rs/ability-plugin-url": "file:../../plugins/url", "@ohos-rs/ability-plugin-files": "file:../../plugins/files", + "@ohos-rs/ability-plugin-version": "file:../../plugins/version", + "@ohos-rs/ability-plugin-updater": "file:../../plugins/updater", + "@ohos-rs/ability-plugin-statusbar": "file:../../plugins/statusbar", + "@ohos-rs/ability-plugin-menu": "file:../../plugins/menu", + "@ohos-rs/ability-plugin-clipboard": "file:../../plugins/clipboard", }, } 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 2538dbfc..ac413dad 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 @@ -9,10 +9,59 @@ export interface PermissionResponsePayload { codes: Array; } +export interface UpdaterAcknowledgement { + accepted: boolean; +} + +export interface UpdaterCheckRequest { + requested: boolean; +} + +export interface UpdaterCheckResponse { + result?: UpdaterCheckResult; +} + +export interface UpdaterCheckResult { + updateAvailable: boolean; + currentVersion: string; + version: string; + body?: string; + date?: string; +} + +/** PDF export options. Field names match ArkTS `PdfConfiguration`. */ +export interface PdfConfig { + width?: number; + height?: number; + marginTop?: number; + marginBottom?: number; + marginLeft?: number; + marginRight?: number; + scale?: number; + shouldPrintBackground?: boolean; +} + export interface WebviewAcknowledgement { accepted: boolean; } +export interface WebviewBoolRequest { + id: string; + enabled?: boolean; +} + +export interface WebviewBoolResponse { + value: boolean; +} + +export interface WebviewBoundsRequest { + id: string; + x: number; + y: number; + width: number; + height: number; +} + /** * Event subscriptions derived from Rust callback declarations before a WebView is created. * @@ -31,7 +80,7 @@ export interface WebviewControllerEvent { id: string; /** * Process-unique ArkWeb controller tag generated by the ArkTS host. The public WebView ID - * remains module-local and is never used as a process-global platform key. + * remains local to one plugin facade and is never used as a process-global platform key. */ nativeTag: string; } @@ -46,12 +95,19 @@ export interface WebviewControllerRequest { zoom?: number; } +export interface WebviewCookieRequest { + id: string; + url: string; + /** Set-Cookie format value, e.g. `name=value; Domain=...; Path=...`. */ + value: string; +} + export interface WebviewCreateRequest { id: string; /** * Optional opaque container handle issued by the built-in `ohos.node` plugin. When provided, - * the ArkTS host appends the WebView FrameNode under that container instead of this native - * module's DefaultXComponent root. + * the ArkTS host appends the WebView FrameNode under that container instead of the current + * plugin Host's DefaultXComponent root. */ parentHandle?: number; url?: string; @@ -104,7 +160,7 @@ export interface WebviewDownloadStartResponse { export interface WebviewEngineLifecycleEvent { phase: string; /** - * Process-global scheme set sealed before ArkWeb initialization. A module activated after + * Process-global scheme set sealed before ArkWeb initialization. A facade activated after * the engine started may join only when every local declaration already exists in this set * with identical options. */ @@ -140,6 +196,12 @@ export interface WebviewNavigationResponse { intercept: boolean; } +export interface WebviewPdfRequest { + id: string; + path: string; + config?: PdfConfig; +} + export interface WebviewSchemeDeclaration { scheme: string; options: number; @@ -154,6 +216,16 @@ export interface WebviewScriptResponse { result?: string; } +export interface WebviewSnapshotRequest { + id: string; +} + +export interface WebviewSnapshotResponse { + rgba?: Array; + width: number; + height: number; +} + export interface WebviewStringResponse { value?: string; } @@ -240,12 +312,33 @@ export declare function createComposedDemoWebview(): Promise; */ export declare function createDemoWebview(): Promise; +/** PR #63 capability demo: query the API 21+ autostart state. */ +export declare function demoAutostartIsEnabled(): Promise; + +/** PR #63 capability demo: open the system app-launch management page. */ +export declare function demoAutostartOpenSettings(): Promise; + +/** PR #63 capability demo: clipboard image write. */ +export declare function demoClipboardWriteImage(): Promise; + +/** PR #63 capability demo: create an OS sub-window through `ohos.window`. */ +export declare function demoCreateOsWindow(): Promise; + +/** PR #63 capability demo: destroy one OS sub-window created by `demo_create_os_window`. */ +export declare function demoDestroyOsWindow(windowId: number): Promise; + /** PR #65 capability demo: open-file dialog through `ohos.files`. */ export declare function demoFileDialogOpen(): Promise>; /** PR #65 capability demo: save-file dialog through `ohos.files`. */ export declare function demoFileDialogSave(): Promise>; +/** PR #63 capability demo: context popup menu at given coordinates. */ +export declare function demoMenuPopup(x: number, y: number): Promise; + +/** PR #63 capability demo: menu system (menubar + popup) through `ohos.menu`. */ +export declare function demoMenuSetMenubar(): Promise; + /** PR #65 capability demo: open an external URL through `ohos.url`. */ export declare function demoOpenUrl(): Promise; @@ -288,6 +381,24 @@ export declare function demoResourceManagerReady(): boolean; */ export declare function demoResourceRawDirCount(): number; +/** PR #63 capability demo: restart the app (cooldown 3s). */ +export declare function demoRestart(): void; + +/** PR #63 capability demo: switch color mode (0 dark / 1 light / 2 system). */ +export declare function demoSetColorMode(mode: number): void; + +/** PR #63 capability demo: system tray item with menu groups. */ +export declare function demoStatusbarAdd(): Promise; + +/** PR #63 capability demo: remove the process-level status bar item. */ +export declare function demoStatusbarRemove(): Promise; + +/** PR #63 capability demo: AppGallery update check. */ +export declare function demoUpdaterCheck(): Promise; + +/** PR #63 capability demo: device version + capability queries. */ +export declare function demoVersionInfo(): string; + export declare function disposeAllRenders(): void; /** @@ -346,6 +457,17 @@ export interface UrlOpenResponse { accepted: boolean; } +export interface ClipboardAcknowledgement { + accepted: boolean; +} + +export interface ClipboardImageRequest { + /** RGBA-8888 pixel data, length must equal `width * height * 4`. */ + rgba: Array; + width: number; + height: number; +} + /** * One suffix filter group: a display name plus `;`-separated suffixes (e.g. `"md"`). * The pattern stays structured; the ArkTS plugin converts it to the picker grammar. @@ -379,6 +501,10 @@ export interface AbilityInitContext { prefPath?: string; preferredLocales?: string; moduleName?: string; + /** OpenHarmony base API Level provided by ArkTS `deviceInfo.sdkApiVersion`. */ + sdkApiVersion?: number; + /** HarmonyOS distribution version provided by ArkTS `deviceInfo.distributionOSApiVersion`. */ + distributionOSApiVersion?: number; } export interface ApplicationLifecycle { @@ -448,6 +574,81 @@ export interface WindowStageEventCallback { onWindowSizeChange: (arg: object) => void; onWindowRectChange: (arg: object) => void; onAvoidAreaChange: (arg: object) => void; + onNewWant: (arg: object) => void; + onAbilityCreateWithWant: (arg: object) => void; +} + +export interface AutostartAcknowledgement { + accepted: boolean; +} + +export interface AutostartRequest { + requested: boolean; +} + +export interface AutostartStatusResponse { + enabled: boolean; + supported: boolean; +} + +/** Optional application metadata for the "about" menu item. */ +export interface AboutMetadataData { + name?: string; + version?: string; + shortVersion?: string; + authors?: Array; + comments?: string; + copyright?: string; + license?: string; + website?: string; +} + +export interface MenuAcknowledgement { + accepted: boolean; +} + +export interface MenuBarRequest { + windowId: string; + items: Array; +} + +/** ArkTS → Rust menu click request (named N-API main-thread event). */ +export interface MenuClickRequest { + id: string; + windowId?: string; +} + +export interface MenuClickResponse { + accepted: boolean; +} + +/** One recursive menu entry. `submenu_items` nests child entries. */ +export interface MenuItemData { + id: string; + /** One of [`item_kind`] constants. */ + itemType: string; + text?: string; + enabled?: boolean; + accelerator?: string; + /** One of the predefined action names, e.g. `copy`, `paste`, `quit`, `minimize`. */ + predefinedType?: string; + checked?: boolean; + icon?: string; + nativeIcon?: string; + submenuItems?: Array; + aboutMetadata?: AboutMetadataData; +} + +export interface MenuPopupRequest { + windowId: string; + x?: number; + y?: number; + items: Array; +} + +export interface MenuVisibilityRequest { + windowId: string; + visible: boolean; } export interface AvoidAreaRequest { @@ -473,6 +674,179 @@ export interface RawRect { height: number; } +export interface WindowAcknowledgement { + accepted: boolean; +} + +export interface WindowBlurRequest { + windowId: number; + radius: number; +} + +export interface WindowColorRequest { + windowId: number; + color: number; +} + +export interface WindowCreateRequest { + name: string; + width: number; + height: number; + x: number; + y: number; + /** Whether to show window decorations (title bar, drag area, close button). */ + decorations: boolean; + /** Fully transparent window background. */ + transparent: boolean; + /** Window background color in 0xAARRGGBB format; ignored when `transparent` is true. */ + backgroundColor?: number; +} + +export interface WindowCreateResponse { + windowId: number; +} + +export interface WindowDecorationsRequest { + windowId: number; + decorations: boolean; +} + +export interface WindowFocusableRequest { + windowId: number; + focusable: boolean; +} + +export interface WindowIdRequest { + windowId: number; +} + +export interface WindowMoveRequest { + windowId: number; + x: number; + y: number; +} + +export interface WindowResizeRequest { + windowId: number; + width: number; + height: number; +} + +export interface WindowStateResponse { + value: boolean; +} + +/** Quick-operation card shown when the tray item is hovered. */ +export interface QuickOperationData { + abilityName: string; + title: string; + height: number; + /** ArkTS/HAP module containing the target extension Ability; unrelated to native bridge hosts. */ + moduleName?: string; + loadingStatus?: boolean; +} + +export interface StatusBarAcknowledgement { + accepted: boolean; +} + +export interface StatusBarActionRequest { + /** One of the predefined action names, e.g. `show` or `quit`. */ + action: string; +} + +export interface StatusBarAddRequest { + item: StatusBarItemData; +} + +/** ArkTS → Rust tray icon click request (named N-API main-thread event). */ +export interface StatusBarClickRequest { + clickType: string; +} + +export interface StatusBarClickResponse { + accepted: boolean; +} + +/** One tray icon with optional white/black RGBA pixel variants. */ +export interface StatusBarIconData { + whiteRgba?: Array; + blackRgba?: Array; + size: number; +} + +export interface StatusBarIconRequest { + icon: StatusBarIconData; +} + +/** Full tray item description. */ +export interface StatusBarItemData { + icons: StatusBarIconData; + quickOperation: QuickOperationData; + statusBarGroupMenu?: Array>; + hoverTips?: string; +} + +/** Menu action launched when a tray menu item is activated. */ +export interface StatusBarMenuActionData { + abilityName: string; + /** ArkTS/HAP module used in the platform Want; unrelated to native bridge hosts. */ + moduleName?: string; + menuCode?: string; + notifyOnly?: boolean; +} + +/** ArkTS → Rust tray menu click request (named N-API main-thread event). */ +export interface StatusBarMenuClickRequest { + menuCode: string; +} + +/** One tray menu entry with an optional sub-menu group. */ +export interface StatusBarMenuItemData { + title: string; + menuCode?: string; + subMenu?: Array; + menuAction?: StatusBarMenuActionData; + selected?: boolean; + iconRgba?: Array; + iconWidth?: number; + iconHeight?: number; +} + +export interface StatusBarMenuRequest { + groups: Array>; +} + +/** One tray sub-menu entry. */ +export interface StatusBarSubMenuItemData { + subTitle: string; + menuCode?: string; + menuAction: StatusBarMenuActionData; + selected?: boolean; +} + +export interface StatusBarTipsRequest { + tips: string; +} + +export interface ColorModeRequest { + /** 0 = dark, 1 = light, 2 = follow system (`ConfigurationConstant.ColorMode`). */ + mode: number; +} + +export interface ColorModeResponse { + accepted: boolean; +} + +export interface RestartRequest { + requested: boolean; +} + +export interface RestartResponse { + accepted: boolean; +} + +/** Marker retained for API symmetry; all actions are synchronous. */ export interface TerminateRequest { code: number; } @@ -480,3 +854,12 @@ export interface TerminateRequest { export interface TerminateResponse { accepted: boolean; } + +export interface VersionRequest { + syscap?: string; +} + +export interface VersionResponse { + value: number; + result?: boolean; +} diff --git a/demo/entry/src/main/cpp/types/libdemo_sub_native/Index.d.ts b/demo/entry/src/main/cpp/types/libdemo_sub_native/Index.d.ts index 8b09cce0..41066d32 100644 --- a/demo/entry/src/main/cpp/types/libdemo_sub_native/Index.d.ts +++ b/demo/entry/src/main/cpp/types/libdemo_sub_native/Index.d.ts @@ -1,10 +1,39 @@ /* auto-generated by OHOS-RS */ /* eslint-disable */ +/** PDF export options. Field names match ArkTS `PdfConfiguration`. */ +export interface PdfConfig { + width?: number; + height?: number; + marginTop?: number; + marginBottom?: number; + marginLeft?: number; + marginRight?: number; + scale?: number; + shouldPrintBackground?: boolean; +} + export interface WebviewAcknowledgement { accepted: boolean; } +export interface WebviewBoolRequest { + id: string; + enabled?: boolean; +} + +export interface WebviewBoolResponse { + value: boolean; +} + +export interface WebviewBoundsRequest { + id: string; + x: number; + y: number; + width: number; + height: number; +} + /** * Event subscriptions derived from Rust callback declarations before a WebView is created. * @@ -38,6 +67,13 @@ export interface WebviewControllerRequest { zoom?: number; } +export interface WebviewCookieRequest { + id: string; + url: string; + /** Set-Cookie format value, e.g. `name=value; Domain=...; Path=...`. */ + value: string; +} + export interface WebviewCreateRequest { id: string; /** @@ -132,6 +168,12 @@ export interface WebviewNavigationResponse { intercept: boolean; } +export interface WebviewPdfRequest { + id: string; + path: string; + config?: PdfConfig; +} + export interface WebviewSchemeDeclaration { scheme: string; options: number; @@ -146,6 +188,16 @@ export interface WebviewScriptResponse { result?: string; } +export interface WebviewSnapshotRequest { + id: string; +} + +export interface WebviewSnapshotResponse { + rgba?: Array; + width: number; + height: number; +} + export interface WebviewStringResponse { value?: string; } @@ -181,6 +233,10 @@ export interface AbilityInitContext { prefPath?: string; preferredLocales?: string; moduleName?: string; + /** OpenHarmony base API Level provided by ArkTS `deviceInfo.sdkApiVersion`. */ + sdkApiVersion?: number; + /** HarmonyOS distribution version provided by ArkTS `deviceInfo.distributionOSApiVersion`. */ + distributionOSApiVersion?: number; } export interface ApplicationLifecycle { @@ -250,6 +306,8 @@ export interface WindowStageEventCallback { onWindowSizeChange: (arg: object) => void; onWindowRectChange: (arg: object) => void; onAvoidAreaChange: (arg: object) => void; + onNewWant: (arg: object) => void; + onAbilityCreateWithWant: (arg: object) => void; } export interface AvoidAreaRequest { diff --git a/demo/entry/src/main/ets/entryability/EntryAbility.ets b/demo/entry/src/main/ets/entryability/EntryAbility.ets index de425449..58f53893 100644 --- a/demo/entry/src/main/ets/entryability/EntryAbility.ets +++ b/demo/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,10 +1,16 @@ import { LazyPlugin, NativeAbility } from "@ohos-rs/ability"; import window from "@ohos.window"; import { AppControlPlugin } from "@ohos-rs/ability-plugin-app-control"; +import { AutostartPlugin } from "@ohos-rs/ability-plugin-autostart"; +import { ClipboardPlugin } from "@ohos-rs/ability-plugin-clipboard"; import { FilesPlugin } from "@ohos-rs/ability-plugin-files"; +import { MenuPlugin } from "@ohos-rs/ability-plugin-menu"; import { PermissionPlugin } from "@ohos-rs/ability-plugin-permission"; import { ResourcePlugin } from "@ohos-rs/ability-plugin-resource"; +import { StatusBarPlugin } from "@ohos-rs/ability-plugin-statusbar"; +import { UpdaterPlugin } from "@ohos-rs/ability-plugin-updater"; import { UrlPlugin } from "@ohos-rs/ability-plugin-url"; +import { VersionPlugin } from "@ohos-rs/ability-plugin-version"; import { WebviewPlugin } from "@ohos-rs/ability-plugin-webview"; import { WindowPlugin } from "@ohos-rs/ability-plugin-window"; import { @@ -28,11 +34,17 @@ export default class EntryAbility extends NativeAbility { public bridgePlugins = [ new LazyPlugin(() => new PermissionPlugin()), new LazyPlugin(() => new AppControlPlugin()), + new LazyPlugin(() => new AutostartPlugin()), + new LazyPlugin(() => new ClipboardPlugin()), new LazyPlugin(() => new FilesPlugin()), new LazyPlugin(() => new ResourcePlugin()), new LazyPlugin(() => new WindowPlugin()), new LazyPlugin(() => new WebviewPlugin()), new LazyPlugin(() => new UrlPlugin()), + new LazyPlugin(() => new VersionPlugin()), + new LazyPlugin(() => new UpdaterPlugin()), + new LazyPlugin(() => new MenuPlugin()), + new LazyPlugin(() => new StatusBarPlugin()), new LazyPlugin(() => new DemoLoginPlugin(new DemoIdentityProvider())), new LazyPlugin(() => new DemoMainThreadPlugin()), new LazyPlugin(() => new DemoTypedPlugin()), diff --git a/demo/entry/src/main/ets/pages/Index.ets b/demo/entry/src/main/ets/pages/Index.ets index 1e1a94a7..9c8d31fc 100644 --- a/demo/entry/src/main/ets/pages/Index.ets +++ b/demo/entry/src/main/ets/pages/Index.ets @@ -1,11 +1,25 @@ import { DefaultXComponent, invokeNativeBackPressInterceptor } from "@ohos-rs/ability"; +import { MenuBarComponent, MenuPopupComponent } from "@ohos-rs/ability-plugin-menu"; import { demoWindowStage, SUB_WINDOW_NATIVE_MODULE } from "../entryability/EntryAbility"; import { SUB_WINDOW_NAME } from "./SubWindowPage"; import { createBottomDemoWebview, createComposedDemoWebview, createDemoWebview, + demoAutostartIsEnabled, + demoAutostartOpenSettings, + demoClipboardWriteImage, + demoCreateOsWindow, + demoDestroyOsWindow, + demoMenuPopup, + demoMenuSetMenubar, demoRequestPermissionFromMainThread, + demoRestart, + demoSetColorMode, + demoStatusbarAdd, + demoStatusbarRemove, + demoUpdaterCheck, + demoVersionInfo, evaluateDemoWebviewScript, demoPluginLogin, demoPluginBytes, @@ -43,7 +57,16 @@ struct Index { @State webviewStatus: string = ""; @State urlResult: string = ""; @State fileDialogResult: string = ""; + @State clipboardResult: string = ""; + @State updaterResult: string = ""; + @State menuResult: string = ""; + @State statusbarResult: string = ""; + @State versionResult: string = ""; + @State windowResult: string = ""; + @State appControlResult: string = ""; + @State autostartResult: string = ""; @State resourceStatus: string = ""; + private osWindowId: number = -1; private tabs: string[] = ["XComponent", "WebView", "Platform", "Resource"]; @@ -305,6 +328,178 @@ struct Index { } } + async handleClipboardWrite() { + this.busyKey = "clipboard"; + this.clipboardResult = ""; + try { + await demoClipboardWriteImage(); + this.clipboardResult = "✓ wrote a 2×2 RGBA PixelMap to the system clipboard"; + } catch (error) { + this.clipboardResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleAutostartStatus() { + this.busyKey = "autostart"; + this.autostartResult = ""; + try { + const enabled = await demoAutostartIsEnabled(); + this.autostartResult = `✓ autostart enabled=${enabled}`; + } catch (error) { + this.autostartResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleAutostartSettings() { + this.busyKey = "autostart"; + this.autostartResult = ""; + try { + await demoAutostartOpenSettings(); + this.autostartResult = "✓ opened app-launch management settings"; + } catch (error) { + this.autostartResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleUpdaterCheck() { + this.busyKey = "updater"; + this.updaterResult = ""; + try { + const update: string | null = await demoUpdaterCheck(); + this.updaterResult = update === null ? "✓ no later version" : `✓ ${update}`; + } catch (error) { + this.updaterResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleMenuSet() { + this.busyKey = "menu"; + this.menuResult = ""; + try { + await demoMenuSetMenubar(); + this.menuResult = "✓ menubar published for business windowId=main"; + } catch (error) { + this.menuResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleMenuPopup() { + this.busyKey = "menu"; + this.menuResult = ""; + try { + await demoMenuPopup(24, 180); + this.menuResult = "✓ popup published at (24, 180)"; + } catch (error) { + this.menuResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleStatusbarAdd() { + this.busyKey = "statusbar"; + this.statusbarResult = ""; + try { + await demoStatusbarAdd(); + this.statusbarResult = "✓ process status bar owner claimed by this plugin instance"; + } catch (error) { + this.statusbarResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleStatusbarRemove() { + this.busyKey = "statusbar"; + this.statusbarResult = ""; + try { + await demoStatusbarRemove(); + this.statusbarResult = "✓ status bar item removed"; + } catch (error) { + this.statusbarResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + handleVersionInfo() { + this.busyKey = "version"; + this.versionResult = ""; + try { + this.versionResult = `✓ ${demoVersionInfo()}`; + } catch (error) { + this.versionResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleCreateOsWindow() { + this.busyKey = "window"; + this.windowResult = ""; + try { + this.osWindowId = await demoCreateOsWindow(); + this.windowResult = `✓ created platform window id=${this.osWindowId}`; + } catch (error) { + this.windowResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + async handleDestroyOsWindow() { + this.busyKey = "window"; + this.windowResult = ""; + try { + if (this.osWindowId < 0) { + throw new Error("create a platform window first"); + } + await demoDestroyOsWindow(this.osWindowId); + this.windowResult = `✓ destroyed platform window id=${this.osWindowId}`; + this.osWindowId = -1; + } catch (error) { + this.windowResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + handleSetColorMode(mode: number) { + this.busyKey = "app-control"; + this.appControlResult = ""; + try { + demoSetColorMode(mode); + this.appControlResult = `✓ color mode=${mode}`; + } catch (error) { + this.appControlResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + + handleRestart() { + this.busyKey = "app-control"; + this.appControlResult = ""; + try { + demoRestart(); + this.appControlResult = "✓ restart requested"; + } catch (error) { + this.appControlResult = `✗ ${String(error)}`; + } finally { + this.busyKey = ""; + } + } + handleResourceManagerReady() { this.busyKey = "resource"; this.resourceStatus = ""; @@ -538,41 +733,162 @@ struct Index { @Builder PlatformServicesDemo() { - Scroll() { - Column({ space: 12 }) { - DemoCard({ - title: "Open external URL", - hint: "ohos.url / context.openLink", - statusText: this.statusTextOf("url", this.urlResult), - statusColor: this.statusColorOf("url", this.urlResult), - busy: this.busyKey === "url", - buttons: [ - new DemoButton("open external URL", (): void => { - this.handleOpenUrl(); - }), - ], - }) - DemoCard({ - title: "File dialogs", - hint: "structured DialogOptions, not JSON", - statusText: this.statusTextOf("files", this.fileDialogResult), - statusColor: this.statusColorOf("files", this.fileDialogResult), - busy: this.busyKey === "files", - buttons: [ - new DemoButton("open (multi-select)", (): void => { - this.handleFileDialogOpen(); - }), - new DemoButton("save (PDF)", (): void => { - this.handleFileDialogSave(); - }), - ], - }) + Stack() { + Scroll() { + Column({ space: 12 }) { + MenuBarComponent({ windowId: "main" }) + DemoCard({ + title: "Menu", + hint: "context-free plugin state keyed by session + business windowId", + statusText: this.statusTextOf("menu", this.menuResult), + statusColor: this.statusColorOf("menu", this.menuResult), + busy: this.busyKey === "menu", + buttons: [ + new DemoButton("publish menubar", (): void => { + this.handleMenuSet(); + }), + new DemoButton("show popup", (): void => { + this.handleMenuPopup(); + }), + ], + }) + DemoCard({ + title: "System status bar", + hint: "process-level platform owner; latest successful mutation receives callbacks", + statusText: this.statusTextOf("statusbar", this.statusbarResult), + statusColor: this.statusColorOf("statusbar", this.statusbarResult), + busy: this.busyKey === "statusbar", + buttons: [ + new DemoButton("add item", (): void => { + this.handleStatusbarAdd(); + }), + new DemoButton("remove item", (): void => { + this.handleStatusbarRemove(); + }), + ], + }) + DemoCard({ + title: "OS sub-window", + hint: "async create/destroy; platform Window remains inside the plugin instance", + statusText: this.statusTextOf("window", this.windowResult), + statusColor: this.statusColorOf("window", this.windowResult), + busy: this.busyKey === "window", + buttons: [ + new DemoButton("create window", (): void => { + this.handleCreateOsWindow(); + }), + new DemoButton("destroy window", (): void => { + this.handleDestroyOsWindow(); + }), + ], + }) + DemoCard({ + title: "Clipboard", + hint: "requires=[]; system pasteboard does not need an Ability platform object", + statusText: this.statusTextOf("clipboard", this.clipboardResult), + statusColor: this.statusColorOf("clipboard", this.clipboardResult), + busy: this.busyKey === "clipboard", + buttons: [ + new DemoButton("write RGBA image", (): void => { + this.handleClipboardWrite(); + }), + ], + }) + DemoCard({ + title: "Version and syscap", + hint: "sync requires=[]; deviceInfo + global canIUse", + statusText: this.statusTextOf("version", this.versionResult), + statusColor: this.statusColorOf("version", this.versionResult), + busy: this.busyKey === "version", + buttons: [ + new DemoButton("query", (): void => { + this.handleVersionInfo(); + }), + ], + }) + DemoCard({ + title: "AppGallery updater", + hint: "ability-scoped async check", + statusText: this.statusTextOf("updater", this.updaterResult), + statusColor: this.statusColorOf("updater", this.updaterResult), + busy: this.busyKey === "updater", + buttons: [ + new DemoButton("check update", (): void => { + this.handleUpdaterCheck(); + }), + ], + }) + DemoCard({ + title: "Autostart", + hint: "ability-scoped; query API 21+ or open system app-launch settings", + statusText: this.statusTextOf("autostart", this.autostartResult), + statusColor: this.statusColorOf("autostart", this.autostartResult), + busy: this.busyKey === "autostart", + buttons: [ + new DemoButton("query status", (): void => { + this.handleAutostartStatus(); + }), + new DemoButton("open settings", (): void => { + this.handleAutostartSettings(); + }), + ], + }) + DemoCard({ + title: "Application control", + hint: "sync main-thread plugin; restart is process-global with a 3s cooldown", + statusText: this.statusTextOf("app-control", this.appControlResult), + statusColor: this.statusColorOf("app-control", this.appControlResult), + busy: this.busyKey === "app-control", + buttons: [ + new DemoButton("dark", (): void => this.handleSetColorMode(0)), + new DemoButton("light", (): void => this.handleSetColorMode(1)), + new DemoButton("system", (): void => this.handleSetColorMode(2)), + new DemoButton("restart app", (): void => this.handleRestart()), + ], + }) + DemoCard({ + title: "Open external URL", + hint: "ohos.url / context.openLink", + statusText: this.statusTextOf("url", this.urlResult), + statusColor: this.statusColorOf("url", this.urlResult), + busy: this.busyKey === "url", + buttons: [ + new DemoButton("open external URL", (): void => { + this.handleOpenUrl(); + }), + ], + }) + DemoCard({ + title: "File dialogs", + hint: "structured DialogOptions, not JSON", + statusText: this.statusTextOf("files", this.fileDialogResult), + statusColor: this.statusColorOf("files", this.fileDialogResult), + busy: this.busyKey === "files", + buttons: [ + new DemoButton("open (multi-select)", (): void => { + this.handleFileDialogOpen(); + }), + new DemoButton("save (PDF)", (): void => { + this.handleFileDialogSave(); + }), + ], + }) + // This tab exercises the ui-context-gated Window plugin. The component is distinct + // from the components on other tabs because only one tab is mounted at a time. + DefaultXComponent({ moduleName: MODULE_NAME }) + .width(1) + .height(1) + .opacity(0) + } + .width("100%") + .padding({ top: 4, bottom: 16 }) } + .scrollable(ScrollDirection.Vertical) + .scrollBar(BarState.Off) .width("100%") - .padding({ top: 4, bottom: 16 }) + .height("100%") + MenuPopupComponent({ windowId: "main" }) } - .scrollable(ScrollDirection.Vertical) - .scrollBar(BarState.Off) .width("100%") .height("100%"); } diff --git a/docs/plugin-development-standard.md b/docs/plugin-development-standard.md index 6436d317..a103be60 100644 --- a/docs/plugin-development-standard.md +++ b/docs/plugin-development-standard.md @@ -208,6 +208,8 @@ export { LoginPlugin }; | 插件 / action | request → response | 模式 / context | | --- | --- | --- | | `ohos.app-control` / `terminate` | `ohos.app_control.TerminateRequest { code }` → `ohos.app_control.TerminateResponse { accepted }` | sync / `ability` | +| `ohos.app-control` / `restart` | `ohos.app_control.RestartRequest` → `ohos.app_control.RestartResponse { accepted }`(3s 冷却) | sync / `ability` | +| `ohos.app-control` / `set-color-mode` | `ohos.app_control.ColorModeRequest { mode }` → `ohos.app_control.ColorModeResponse { accepted }`(0 暗 / 1 亮 / 2 跟随) | 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 }` | async / `ui-context`;查询当前 component 所在窗口 | | `ohos.window` / `create-os-window` | `ohos.window.CreateRequest { name, width, height, x, y, decorations, transparent, backgroundColor }` → `ohos.window.CreateResponse { windowId }` | async / `ui-context` | @@ -220,6 +222,22 @@ export { LoginPlugin }; | `ohos.node`(内置) / `dispose` | `ohos.node.DisposeRequest { handle }` → `ohos.node.Acknowledgement` | async / `ui-context` | | `ohos.webview` / 控制器 action | `ohos.webview.ControllerRequest` → `ohos.webview.Acknowledgement` / `StringResponse` / `ScriptResponse` | async / `ui-context` | | `ohos.resource` / `resource-manager-ready`(入站) | `ohos.resource.ResourceManagerRef`(ArkTS 直接传 `resourceManager` 对象)→ `ohos.resource.ResourceManagerReadyResponse { accepted }` | 入站事件 / `ability` | +| `ohos.webview` / `set-bounds` | `ohos.webview.BoundsRequest { id, x, y, width, height }` → `ohos.webview.Acknowledgement` | async / `ui-context` | +| `ohos.webview` / `set-cookie` | `ohos.webview.CookieRequest { id, url, value }` → `ohos.webview.Acknowledgement` | async / `ui-context` | +| `ohos.webview` / `snapshot` | `ohos.webview.SnapshotRequest { id }` → `ohos.webview.SnapshotResponse { rgba, width, height }` | async / `ui-context` | +| `ohos.webview` / `create-pdf` | `ohos.webview.PdfRequest { id, path, config }`(`ohos.webview.PdfConfig`) → `ohos.webview.Acknowledgement` | async / `ui-context` | +| `ohos.webview` / `set-debugging-access`、`is-debugging-access` | `ohos.webview.BoolRequest { id, enabled }` → `ohos.webview.Acknowledgement` / `BoolResponse { value }` | async / `ui-context` | +| `ohos.clipboard` / `write-image` | `ohos.clipboard.ImageRequest { rgba, width, height }` → `ohos.clipboard.Acknowledgement { accepted }` | async / `[]` | +| `ohos.autostart` / `open-settings` | `ohos.autostart.Request { requested }` → `ohos.autostart.Acknowledgement { accepted }` | async / `ability`;普通应用只能引导用户前往系统设置 | +| `ohos.autostart` / `is-enabled` | `ohos.autostart.Request { requested }` → `ohos.autostart.StatusResponse { enabled, supported }` | async / `ability`;API < 21 Rust facade 返回 `false` | +| `ohos.updater` / `check` | `ohos.updater.CheckRequest` → `ohos.updater.CheckResponse { result }`(`ohos.updater.CheckResult`) | async / `ability` | +| `ohos.updater` / `download-and-install` | `ohos.updater.CheckRequest` → `ohos.updater.Acknowledgement { accepted }` | async / `ability` | +| `ohos.menu` / `set-menubar`、`popup` | `ohos.menu.MenuBarRequest { windowId, items }` / `ohos.menu.PopupRequest { windowId, x, y, items }`(递归 `ohos.menu.MenuItemData`) → `ohos.menu.Acknowledgement` | async / `[]` | +| `ohos.menu` / `set-menubar-visible` | `ohos.menu.VisibilityRequest { windowId, visible }` → `ohos.menu.Acknowledgement` | async / `[]` | +| `ohos.menu` / 主线程事件 `menu-click` | `ohos.menu.ClickRequest { id, windowId }` → `ohos.menu.ClickResponse { accepted }` | scoped 主线程具名 N-API | +| `ohos.statusbar` / `add`、`remove`、`update-icon`、`update-menu`、`update-tips`、`predefined-action` | `ohos.statusbar.*Request`(`ItemData` / `IconData` / `MenuRequest` / `TipsRequest` / `ActionRequest`) → `ohos.statusbar.Acknowledgement` | async / `ability` | +| `ohos.statusbar` / 主线程事件 `icon-click`、`menu-click` | `ohos.statusbar.ClickRequest { clickType }` / `MenuClickRequest { menuCode }` → `ohos.statusbar.ClickResponse { accepted }` | scoped 主线程具名 N-API | +| `ohos.version` / `get-sdk-api-version`、`get-distribution-api-version`、`can-i-use`、`is-desktop-device` | `ohos.version.VersionRequest { syscap }` → `ohos.version.VersionResponse { value, result }` | sync / `[]` | `resource` 是纯入站插件:ArkTS wrapper 在 `ability-create` 时经 `invokeNativeSync` 推送平台 对象,Rust 在 `on_main_thread_event` 解码的同一 N-API callback 内把它转成 native 指针;对象 @@ -313,6 +331,7 @@ ArkTS 平台回调进入 Rust 的 `on_main_thread_event` 是**入站 scoped call | requirement | 就绪时点 | 适合的能力 | | --- | --- | --- | +| `[]` | Ability session 和 bridge transport 已建立,不等待额外平台对象 | 设备信息、系统剪贴板、仅使用插件 session 状态的菜单 | | `ability` | `NativeAbility.onCreate` 已建立 Ability context | 权限、应用控制、登录会话 | | `window-stage` | `NativeAbility.onWindowStageCreate` | 只依赖 Ability `WindowStage` 的 stage 级能力 | | `ui-context` | 该 native module 唯一的 `DefaultXComponent` 已建立 UI context、实际 Window 并注入根 `FrameNode` | 组件窗口/避让区、WebView、任意 ArkUI/FrameNode 插件 | @@ -553,9 +572,17 @@ WebView 的 callback builder 必须在 `WebviewClient::create` 前按 facade-loc | --- | --- | --- | | `requestPermission` | `plugin-permission` / `ohos.permission` | async + `ability`;结果顺序与失败码保持不变 | | `exit` | `plugin-app-control` / `ohos.app-control` | sync + 当前主线程 `Env` | +| `restart`(appRecovery) | `plugin-app-control` / `ohos.app-control` `restart` | sync + `ability`;3s 冷却,无 `onDestroy` 回调 | +| `set_color_mode` | `plugin-app-control` / `ohos.app-control` `set-color-mode` | sync + `ability`;0 暗 / 1 亮 / 2 跟随 | | `getWindowAvoidArea` | `plugin-window` / `ohos.window` | async + `ui-context`;查询当前 component 所在窗口并返回完整避让区 | | `create_os_window`、多窗口操作及显式销毁 | `plugin-window` / `ohos.window` | async + `ui-context`;窗口句柄属于插件实例,按平台 window id 区分;`onDispose` 兜底销毁未释放窗口 | -| `createWebview`、嵌入式 WebView、custom protocol、导航/下载/标题回调 | `plugin-webview` / `ohos.webview` | 出站 async + `ui-context`;入站为 scoped 主线程具名 N-API;scheme 在 engine 初始化前声明 | +| `createWebview`、嵌入式 WebView、custom protocol、导航/下载/标题回调、`set_bounds`、`set_cookie`、`web_page_snapshot`、`create_pdf`、`setWebDebuggingAccess` | `plugin-webview` / `ohos.webview` | 出站 async + `ui-context`;入站为 scoped 主线程具名 N-API;scheme 在 engine 初始化前声明;默认挂载当前 module root,也可传 `parentHandle` | +| `clipboard_write_image` | `plugin-clipboard` / `ohos.clipboard` | async + `[]`;RGBA 长度校验 `width*height*4`,10s 超时 | +| autostart 状态 / 打开应用启动管理设置 | `plugin-autostart` / `ohos.autostart` | async + `ability`;API 21+ 查询,普通应用不承诺直接切换开关 | +| `Updater::check` / `download_and_install` | `plugin-updater` / `ohos.updater` | async + `ability`;AppGallery `updateManager` | +| 菜单系统(menubar / popup / 可见性 / 点击事件) | `plugin-menu` / `ohos.menu` | 出站 async + `[]`(递归具名 `MenuItemData`);状态按 session + 业务 window id 隔离,点击事件为 scoped 主线程具名 N-API `menu-click`,Rust 经 `MenuExt::menu_event_receiver` 供 muda 监听 | +| 系统托盘(add/remove/icon/menu/tips/预定义动作/点击事件) | `plugin-statusbar` / `ohos.statusbar` | 出站 async + `ability`;点击事件为 scoped 主线程具名 N-API `icon-click` / `menu-click`,Rust 经 `StatusBarExt::*_click_receiver` 供 tray 监听 | +| 版本检测(`sdk_api_version` / `distribution_api_version` / `canIUse` / `is_desktop_device`) | core `version` 模块(init/getters)+ `plugin-version` / `ohos.version` | 版本号由 `#[ability]` 宏从 `AbilityInitContext` 初始化,任意线程可读;`can-i-use` / `is-desktop-device` 为 sync + `[]` | | `Loadable` | `runtime/NativeModuleLoader` | framework 内部 runtime,不是能力 bridge | | `openURL` | `plugin-url` / `ohos.url` | async + `ability`;`context.openLink` | | `showFileDialog`(open/save/folder) | `plugin-files` / `ohos.files` | async + `ability`;结构化 `DialogOptions` 传参,filter 字符串语法仅在 ArkTS 插件内部转换 | diff --git a/native_ability/CHANGELOG.md b/native_ability/CHANGELOG.md index 723cb94b..eb73de41 100644 --- a/native_ability/CHANGELOG.md +++ b/native_ability/CHANGELOG.md @@ -38,7 +38,7 @@ validates the matching ArkTS factory. - Business layering is page `Stack` declaration order; `underlay`/`foreground` hosts are gone. ---- +--- # 1.0.0-beta.0 - Pluginized bridge: typed `BridgePlugin` contract (async / main-thread sync), `BridgeRuntime` / `BridgeMainThread` capabilities, named N-API values only (no JSON transport). diff --git a/plugins/app-control/src/main/ets/AppControlPlugin.ets b/plugins/app-control/src/main/ets/AppControlPlugin.ets index ae3ea697..5d9a41ce 100644 --- a/plugins/app-control/src/main/ets/AppControlPlugin.ets +++ b/plugins/app-control/src/main/ets/AppControlPlugin.ets @@ -1,3 +1,4 @@ +import { appRecovery } from "@kit.AbilityKit"; import process from "@ohos.process"; import { BridgeCallContext, @@ -8,12 +9,24 @@ import { const TERMINATE_REQUEST_TYPE = "ohos.app_control.TerminateRequest"; const TERMINATE_RESPONSE_TYPE = "ohos.app_control.TerminateResponse"; +const RESTART_REQUEST_TYPE = "ohos.app_control.RestartRequest"; +const RESTART_RESPONSE_TYPE = "ohos.app_control.RestartResponse"; +const COLOR_MODE_REQUEST_TYPE = "ohos.app_control.ColorModeRequest"; +const COLOR_MODE_RESPONSE_TYPE = "ohos.app_control.ColorModeResponse"; interface TerminateRequest { code: number; } -class TerminateResponse { +interface RestartRequest { + requested: boolean; +} + +interface ColorModeRequest { + mode: number; +} + +class AcceptedResponse { readonly accepted: boolean; constructor(accepted: boolean) { @@ -32,6 +45,36 @@ function parseTerminateRequest(payload: BridgeTypedValue): TerminateRequest { return request; } +function parseRestartRequest(payload: BridgeTypedValue): RestartRequest { + if ( + payload.typeName !== RESTART_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.app-control.restart requires bridge type ${RESTART_REQUEST_TYPE}`); + } + const request = payload.value as RestartRequest; + if (request.requested !== true) { + throw new Error("restart.requested must be true"); + } + return request; +} + +function parseColorModeRequest(payload: BridgeTypedValue): ColorModeRequest { + if (payload.typeName !== COLOR_MODE_REQUEST_TYPE || typeof payload.value !== "object") { + throw new Error( + `ohos.app-control.set-color-mode requires bridge type ${COLOR_MODE_REQUEST_TYPE}`, + ); + } + const request = payload.value as ColorModeRequest; + if (!Number.isInteger(request?.mode) || request.mode < 0 || request.mode > 2) { + throw new Error("set-color-mode.mode must be 0 (dark), 1 (light) or 2 (follow system)"); + } + return request; +} + +let lastProcessRestartAt: number = 0; + export class AppControlPlugin extends SyncPluginBase { readonly id = "ohos.app-control"; readonly requires: BridgeContextRequirement[] = ["ability"]; @@ -39,14 +82,51 @@ export class AppControlPlugin extends SyncPluginBase { invokeSync( action: string, payload: BridgeTypedValue, - _context: BridgeCallContext, + context: BridgeCallContext, ): BridgeTypedValue { - if (action !== "terminate") { - throw new Error(`Unsupported ohos.app-control action '${action}'`); + if (action === "terminate") { + const request = parseTerminateRequest(payload); + const manager = new process.ProcessManager(); + manager.exit(request.code); + return { typeName: TERMINATE_RESPONSE_TYPE, value: new AcceptedResponse(true) }; } - const request = parseTerminateRequest(payload); - const manager = new process.ProcessManager(); - manager.exit(request.code); - return { typeName: TERMINATE_RESPONSE_TYPE, value: new TerminateResponse(true) }; + + if (action === "restart") { + parseRestartRequest(payload); + // 3-second cooldown: appRecovery.restartApp() has no onDestroy callback and the + // process dies immediately; repeated calls before the first restart completes are + // dropped to avoid a restart loop. + const now = Date.now(); + if (now - lastProcessRestartAt < 3000) { + throw new Error("restart is in cooldown; try again later"); + } + lastProcessRestartAt = now; + try { + appRecovery.enableAppRecovery( + appRecovery.RestartFlag.ALWAYS_RESTART, + appRecovery.SaveOccasionFlag.SAVE_WHEN_ERROR, + appRecovery.SaveModeFlag.SAVE_WITH_FILE, + ); + appRecovery.restartApp(); + } catch (error) { + if (lastProcessRestartAt === now) { + lastProcessRestartAt = 0; + } + throw new Error(`restartApp failed: ${String(error)}`); + } + return { typeName: RESTART_RESPONSE_TYPE, value: new AcceptedResponse(true) }; + } + + if (action === "set-color-mode") { + const request = parseColorModeRequest(payload); + try { + context.abilityContext.getApplicationContext().setColorMode(request.mode); + } catch (error) { + throw new Error(`setColorMode failed: ${String(error)}`); + } + return { typeName: COLOR_MODE_RESPONSE_TYPE, value: new AcceptedResponse(true) }; + } + + throw new Error(`Unsupported ohos.app-control action '${action}'`); } } diff --git a/plugins/autostart/BuildProfile.ets b/plugins/autostart/BuildProfile.ets new file mode 100644 index 00000000..48996480 --- /dev/null +++ b/plugins/autostart/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-beta.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/autostart/build-profile.json5 b/plugins/autostart/build-profile.json5 new file mode 100644 index 00000000..92bcdb3d --- /dev/null +++ b/plugins/autostart/build-profile.json5 @@ -0,0 +1,10 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + "runtimeOS": "HarmonyOS", + }, + ], +} diff --git a/plugins/autostart/hvigorfile.ts b/plugins/autostart/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/autostart/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/autostart/index.ets b/plugins/autostart/index.ets new file mode 100644 index 00000000..d36bb281 --- /dev/null +++ b/plugins/autostart/index.ets @@ -0,0 +1 @@ +export { AutostartPlugin } from "./src/main/ets/AutostartPlugin"; diff --git a/plugins/autostart/oh-package-lock.json5 b/plugins/autostart/oh-package-lock.json5 new file mode 100644 index 00000000..b4adaa30 --- /dev/null +++ b/plugins/autostart/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "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" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0-beta.1", + "resolved": "../../native_ability", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/plugins/autostart/oh-package.json5 b/plugins/autostart/oh-package.json5 new file mode 100644 index 00000000..91869c23 --- /dev/null +++ b/plugins/autostart/oh-package.json5 @@ -0,0 +1,16 @@ +{ + "author": { + "name": "richerfu", + "email": "southorange0929@foxmail.com", + }, + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, + "description": "autostart settings capability plugin for @ohos-rs/ability", + "keywords": ["ohos-rs", "NAPI", "Rust", "node-addon", "openharmony", "harmonynext"], + "license": "MIT OR Apache-2.0", + "main": "index.ets", + "name": "@ohos-rs/ability-plugin-autostart", + "repository": "https://github.com/harmony-contrib/openharmony-ability.git", + "version": "1.0.0-beta.0", +} diff --git a/plugins/autostart/src/main/ets/AutostartPlugin.ets b/plugins/autostart/src/main/ets/AutostartPlugin.ets new file mode 100644 index 00000000..10159bdc --- /dev/null +++ b/plugins/autostart/src/main/ets/AutostartPlugin.ets @@ -0,0 +1,144 @@ +import common from "@ohos.app.ability.common"; +import { autoStartupManager, Want } from "@kit.AbilityKit"; +import { BusinessError, deviceInfo } from "@kit.BasicServicesKit"; +import { + AsyncPluginBase, + BridgeCallContext, + BridgeContextRequirement, + BridgeTypedValue, +} from "@ohos-rs/ability"; + +const REQUEST_TYPE = "ohos.autostart.Request"; +const ACKNOWLEDGEMENT_TYPE = "ohos.autostart.Acknowledgement"; +const STATUS_RESPONSE_TYPE = "ohos.autostart.StatusResponse"; + +interface AutostartRequest { + requested: boolean; +} + +class AutostartAcknowledgement { + readonly accepted: boolean; + + constructor(accepted: boolean) { + this.accepted = accepted; + } +} + +class AutostartStatusResponse { + readonly enabled: boolean; + readonly supported: boolean; + + constructor(enabled: boolean, supported: boolean) { + this.enabled = enabled; + this.supported = supported; + } +} + +function parseRequest(payload: BridgeTypedValue, action: string): AutostartRequest { + if ( + payload.typeName !== REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.autostart.${action} requires bridge type ${REQUEST_TYPE}`); + } + const request = payload.value as AutostartRequest; + if (request.requested !== true) { + throw new Error(`ohos.autostart.${action}.requested must be true`); + } + return request; +} + +async function openSettings(context: common.UIAbilityContext): Promise { + const want: Want = { + bundleName: "com.huawei.hmos.settings", + abilityName: "com.huawei.hmos.settings.MainAbility", + uri: "pc_app_setup_settings", + }; + await context.startAbility(want); +} + +let settingsOpenInFlight: Promise | null = null; +let statusQueryInFlight: Promise | null = null; + +function openSettingsOnce(context: common.UIAbilityContext): Promise { + if (settingsOpenInFlight !== null) { + return settingsOpenInFlight; + } + const operation = openSettings(context).finally((): void => { + if (settingsOpenInFlight === operation) { + settingsOpenInFlight = null; + } + }); + settingsOpenInFlight = operation; + return operation; +} + +async function queryStatus(): Promise { + if (deviceInfo.sdkApiVersion < 21) { + return new AutostartStatusResponse(false, false); + } + try { + const enabled = await autoStartupManager.getAutoStartupStatusForSelf(); + return new AutostartStatusResponse(enabled, true); + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code === 801) { + return new AutostartStatusResponse(false, false); + } + throw new Error( + `getAutoStartupStatusForSelf failed: code=${businessError.code} msg=${businessError.message}`, + ); + } +} + +function queryStatusOnce(): Promise { + if (statusQueryInFlight !== null) { + return statusQueryInFlight; + } + const operation = queryStatus().finally((): void => { + if (statusQueryInFlight === operation) { + statusQueryInFlight = null; + } + }); + statusQueryInFlight = operation; + return operation; +} + +export class AutostartPlugin extends AsyncPluginBase { + readonly id = "ohos.autostart"; + readonly requires: BridgeContextRequirement[] = ["ability"]; + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): Promise { + parseRequest(payload, action); + if (action === "open-settings") { + try { + await openSettingsOnce(context.abilityContext); + } catch (error) { + const businessError = error as BusinessError; + throw new Error( + `open autostart settings failed: code=${businessError.code} msg=${businessError.message}`, + ); + } + if (!context.isActive()) { + throw new Error("Ability was destroyed while autostart settings were opening"); + } + return { + typeName: ACKNOWLEDGEMENT_TYPE, + value: new AutostartAcknowledgement(true), + }; + } + if (action === "is-enabled") { + const response = await queryStatusOnce(); + if (!context.isActive()) { + throw new Error("Ability was destroyed while autostart status was queried"); + } + return { typeName: STATUS_RESPONSE_TYPE, value: response }; + } + throw new Error(`Unsupported ohos.autostart action '${action}'`); + } +} diff --git a/plugins/autostart/src/main/module.json5 b/plugins/autostart/src/main/module.json5 new file mode 100644 index 00000000..f6e7893a --- /dev/null +++ b/plugins/autostart/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_autostart", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/clipboard/BuildProfile.ets b/plugins/clipboard/BuildProfile.ets new file mode 100644 index 00000000..48996480 --- /dev/null +++ b/plugins/clipboard/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-beta.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/clipboard/build-profile.json5 b/plugins/clipboard/build-profile.json5 new file mode 100644 index 00000000..92bcdb3d --- /dev/null +++ b/plugins/clipboard/build-profile.json5 @@ -0,0 +1,10 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + "runtimeOS": "HarmonyOS", + }, + ], +} diff --git a/plugins/clipboard/hvigorfile.ts b/plugins/clipboard/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/clipboard/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/clipboard/index.ets b/plugins/clipboard/index.ets new file mode 100644 index 00000000..3cbcf273 --- /dev/null +++ b/plugins/clipboard/index.ets @@ -0,0 +1 @@ +export { ClipboardPlugin } from "./src/main/ets/ClipboardPlugin"; diff --git a/plugins/clipboard/oh-package-lock.json5 b/plugins/clipboard/oh-package-lock.json5 new file mode 100644 index 00000000..4510149d --- /dev/null +++ b/plugins/clipboard/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "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" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0-beta.1", + "resolved": "../../native_ability", + "registryType": "local" + } + } +} diff --git a/plugins/clipboard/oh-package.json5 b/plugins/clipboard/oh-package.json5 new file mode 100644 index 00000000..17f9cd89 --- /dev/null +++ b/plugins/clipboard/oh-package.json5 @@ -0,0 +1,16 @@ +{ + "author": { + "name": "richerfu", + "email": "southorange0929@foxmail.com", + }, + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, + "description": "clipboard capability plugin for @ohos-rs/ability", + "keywords": ["ohos-rs", "NAPI", "Rust", "node-addon", "openharmony", "harmonynext"], + "license": "MIT OR Apache-2.0", + "main": "index.ets", + "name": "@ohos-rs/ability-plugin-clipboard", + "repository": "https://github.com/harmony-contrib/openharmony-ability.git", + "version": "1.0.0-beta.0", +} diff --git a/plugins/clipboard/src/main/ets/ClipboardPlugin.ets b/plugins/clipboard/src/main/ets/ClipboardPlugin.ets new file mode 100644 index 00000000..7f05a1ed --- /dev/null +++ b/plugins/clipboard/src/main/ets/ClipboardPlugin.ets @@ -0,0 +1,109 @@ +import pasteboard from "@ohos.pasteboard"; +import { image } from "@kit.ImageKit"; +import { BusinessError } from "@kit.BasicServicesKit"; +import { + BridgeCallContext, + BridgeTypedValue, + AsyncPluginBase, + BridgeContextRequirement, +} from "@ohos-rs/ability"; + +const IMAGE_REQUEST_TYPE = "ohos.clipboard.ImageRequest"; +const ACKNOWLEDGEMENT_TYPE = "ohos.clipboard.Acknowledgement"; + +interface ClipboardImageRequest { + rgba: Uint8Array; + width: number; + height: number; +} + +class ClipboardAcknowledgement { + readonly accepted: boolean; + + constructor(accepted: boolean) { + this.accepted = accepted; + } +} + +function parseImageRequest(payload: BridgeTypedValue): ClipboardImageRequest { + if ( + payload.typeName !== IMAGE_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.clipboard.write-image requires bridge type ${IMAGE_REQUEST_TYPE}`); + } + const request = payload.value as ClipboardImageRequest; + if (!(request.rgba instanceof Uint8Array)) { + throw new Error("write-image.rgba must be a Uint8Array"); + } + if ( + !Number.isInteger(request.width) || + !Number.isInteger(request.height) || + request.width <= 0 || + request.height <= 0 + ) { + throw new Error("write-image.width and height must be positive integers"); + } + const expected = request.width * request.height * 4; + if (!Number.isSafeInteger(expected)) { + throw new Error("write-image dimensions overflow"); + } + if (request.rgba.length !== expected) { + throw new Error( + `write-image rgba len ${request.rgba.length} != expected ${expected} (${request.width}x${request.height}x4)`, + ); + } + return request; +} + +async function writeImageToClipboard( + rgba: Uint8Array, + width: number, + height: number, +): Promise { + let pixelMap: image.PixelMap | null = null; + try { + // napi external buffers may not be accessible by ArkTS PixelMap APIs; copy into a JS-managed buffer. + const jsArr = new Uint8Array(rgba.length); + jsArr.set(rgba); + + const opts: image.InitializationOptions = { + editable: true, + pixelFormat: image.PixelMapFormat.RGBA_8888, + size: { width: width, height: height }, + }; + pixelMap = image.createPixelMapSync(opts); + pixelMap.writeBufferToPixelsSync(jsArr.buffer); + + const pasteData = pasteboard.createData(pasteboard.MIMETYPE_PIXELMAP, pixelMap); + const systemPasteboard = pasteboard.getSystemPasteboard(); + await systemPasteboard.setData(pasteData); + } catch (error) { + const err = error as BusinessError; + throw new Error(`writeImageToClipboard failed: code=${err.code} msg=${err.message}`); + } finally { + pixelMap?.release(); + } +} + +export class ClipboardPlugin extends AsyncPluginBase { + readonly id = "ohos.clipboard"; + readonly requires: BridgeContextRequirement[] = []; + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): Promise { + if (action !== "write-image") { + throw new Error(`Unsupported ohos.clipboard action '${action}'`); + } + const request = parseImageRequest(payload); + await writeImageToClipboard(request.rgba, request.width, request.height); + if (!context.isActive()) { + throw new Error("Ability was destroyed while clipboard write was pending"); + } + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new ClipboardAcknowledgement(true) }; + } +} diff --git a/plugins/clipboard/src/main/module.json5 b/plugins/clipboard/src/main/module.json5 new file mode 100644 index 00000000..a8091d66 --- /dev/null +++ b/plugins/clipboard/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_clipboard", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/menu/BuildProfile.ets b/plugins/menu/BuildProfile.ets new file mode 100644 index 00000000..48996480 --- /dev/null +++ b/plugins/menu/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-beta.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/menu/build-profile.json5 b/plugins/menu/build-profile.json5 new file mode 100644 index 00000000..92bcdb3d --- /dev/null +++ b/plugins/menu/build-profile.json5 @@ -0,0 +1,10 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + "runtimeOS": "HarmonyOS", + }, + ], +} diff --git a/plugins/menu/hvigorfile.ts b/plugins/menu/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/menu/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/menu/index.ets b/plugins/menu/index.ets new file mode 100644 index 00000000..4f775b31 --- /dev/null +++ b/plugins/menu/index.ets @@ -0,0 +1 @@ +export { MenuBarComponent, MenuPlugin, MenuPopupComponent } from "./src/main/ets/MenuPlugin"; diff --git a/plugins/menu/oh-package-lock.json5 b/plugins/menu/oh-package-lock.json5 new file mode 100644 index 00000000..4510149d --- /dev/null +++ b/plugins/menu/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "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" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0-beta.1", + "resolved": "../../native_ability", + "registryType": "local" + } + } +} diff --git a/plugins/menu/oh-package.json5 b/plugins/menu/oh-package.json5 new file mode 100644 index 00000000..ec5ba18d --- /dev/null +++ b/plugins/menu/oh-package.json5 @@ -0,0 +1,16 @@ +{ + "author": { + "name": "richerfu", + "email": "southorange0929@foxmail.com", + }, + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, + "description": "menu capability plugin for @ohos-rs/ability", + "keywords": ["ohos-rs", "NAPI", "Rust", "node-addon", "openharmony", "harmonynext"], + "license": "MIT OR Apache-2.0", + "main": "index.ets", + "name": "@ohos-rs/ability-plugin-menu", + "repository": "https://github.com/harmony-contrib/openharmony-ability.git", + "version": "1.0.0-beta.0", +} diff --git a/plugins/menu/src/main/ets/MenuPlugin.ets b/plugins/menu/src/main/ets/MenuPlugin.ets new file mode 100644 index 00000000..6f5295a8 --- /dev/null +++ b/plugins/menu/src/main/ets/MenuPlugin.ets @@ -0,0 +1,629 @@ +import { + BridgeCallContext, + BridgePluginContext, + BridgeTypedValue, + AsyncPluginBase, + BridgeContextRequirement, +} from "@ohos-rs/ability"; + +const MENUBAR_REQUEST_TYPE = "ohos.menu.MenuBarRequest"; +const POPUP_REQUEST_TYPE = "ohos.menu.PopupRequest"; +const VISIBILITY_REQUEST_TYPE = "ohos.menu.VisibilityRequest"; +const ACKNOWLEDGEMENT_TYPE = "ohos.menu.Acknowledgement"; +const CLICK_REQUEST_TYPE = "ohos.menu.ClickRequest"; +const CLICK_RESPONSE_TYPE = "ohos.menu.ClickResponse"; +const MAX_MENU_ITEMS: number = 512; +const MAX_MENU_DEPTH: number = 16; +const MENU_ITEM_TYPES: Set = new Set([ + "item", + "submenu", + "separator", + "predefined", + "check", + "icon", + "about", +]); + +export interface AboutMetadataData { + name?: string | null; + version?: string | null; + shortVersion?: string | null; + authors?: string[] | null; + comments?: string | null; + copyright?: string | null; + license?: string | null; + website?: string | null; +} + +export interface MenuItemData { + id: string; + itemType: string; + text?: string | null; + enabled?: boolean | null; + accelerator?: string | null; + predefinedType?: string | null; + checked?: boolean | null; + icon?: string | null; + nativeIcon?: string | null; + submenuItems?: MenuItemData[] | null; + aboutMetadata?: AboutMetadataData | null; +} + +interface MenuBarRequest { + windowId: string; + items: MenuItemData[]; + visible?: boolean; +} + +interface MenuPopupRequest { + windowId: string; + x?: number | null; + y?: number | null; + items: MenuItemData[]; +} + +interface MenuVisibilityRequest { + windowId: string; + visible: boolean; +} + +interface MenuClickRequest { + id: string; + windowId?: string | null; +} + +interface MenuClickResponse { + accepted: boolean; +} + +class MenuAcknowledgement { + readonly accepted: boolean; + + constructor(accepted: boolean) { + this.accepted = accepted; + } +} + +function parseMenuBarRequest(payload: BridgeTypedValue): MenuBarRequest { + if ( + payload.typeName !== MENUBAR_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.menu.set-menubar requires bridge type ${MENUBAR_REQUEST_TYPE}`); + } + const request = payload.value as MenuBarRequest; + if (!request.windowId?.trim()) { + throw new Error("set-menubar.windowId must not be empty"); + } + validateMenuItems(request.items, "set-menubar", true, new Set(), 1, 0); + return request; +} + +function parsePopupRequest(payload: BridgeTypedValue): MenuPopupRequest { + if ( + payload.typeName !== POPUP_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.menu.popup requires bridge type ${POPUP_REQUEST_TYPE}`); + } + const request = payload.value as MenuPopupRequest; + if (!request.windowId?.trim()) { + throw new Error("popup.windowId must not be empty"); + } + validateMenuItems(request.items, "popup", false, new Set(), 1, 0); + if ( + (request.x !== undefined && request.x !== null && !Number.isFinite(request.x)) || + (request.y !== undefined && request.y !== null && !Number.isFinite(request.y)) + ) { + throw new Error("popup coordinates must be finite numbers"); + } + return request; +} + +function validateMenuItems( + items: MenuItemData[], + action: string, + allowEmpty: boolean, + ids: Set, + depth: number, + initialCount: number, +): number { + if (depth > MAX_MENU_DEPTH) { + throw new Error(`${action} menu tree exceeds the ${MAX_MENU_DEPTH} level depth limit`); + } + if (!Array.isArray(items) || (!allowEmpty && items.length === 0)) { + throw new Error(`${action} requires ${allowEmpty ? "an items array" : "at least one item"}`); + } + let itemCount: number = initialCount; + for (const item of items) { + itemCount += 1; + if (itemCount > MAX_MENU_ITEMS) { + throw new Error(`${action} menu tree exceeds the ${MAX_MENU_ITEMS} item limit`); + } + if (!item?.id?.trim() || !MENU_ITEM_TYPES.has(item.itemType)) { + throw new Error(`${action} menu items require non-empty id and itemType`); + } + if (ids.has(item.id)) { + throw new Error(`${action} menu item id '${item.id}' is duplicated`); + } + ids.add(item.id); + validateOptionalMenuFields(item, action); + if (item.submenuItems !== undefined && item.submenuItems !== null) { + itemCount = validateMenuItems(item.submenuItems, action, true, ids, depth + 1, itemCount); + } + } + return itemCount; +} + +function validateOptionalMenuFields(item: MenuItemData, action: string): void { + const stringFields: Array = [ + item.text, + item.accelerator, + item.predefinedType, + item.icon, + item.nativeIcon, + ]; + for (const value of stringFields) { + if (value !== undefined && value !== null && typeof value !== "string") { + throw new Error(`${action} menu string fields must contain strings`); + } + } + if (item.enabled !== undefined && item.enabled !== null && typeof item.enabled !== "boolean") { + throw new Error(`${action} menu enabled must be a boolean`); + } + if (item.checked !== undefined && item.checked !== null && typeof item.checked !== "boolean") { + throw new Error(`${action} menu checked must be a boolean`); + } + const metadata = item.aboutMetadata; + if (metadata !== undefined && metadata !== null) { + if (typeof metadata !== "object") { + throw new Error(`${action} aboutMetadata must be an object`); + } + const metadataStrings: Array = [ + metadata.name, + metadata.version, + metadata.shortVersion, + metadata.comments, + metadata.copyright, + metadata.license, + metadata.website, + ]; + for (const value of metadataStrings) { + if (value !== undefined && value !== null && typeof value !== "string") { + throw new Error(`${action} aboutMetadata string fields must contain strings`); + } + } + if ( + metadata.authors !== undefined && + metadata.authors !== null && + (!Array.isArray(metadata.authors) || + metadata.authors.some((author: string): boolean => typeof author !== "string")) + ) { + throw new Error(`${action} aboutMetadata.authors must contain strings`); + } + } +} + +function parseVisibilityRequest(payload: BridgeTypedValue): MenuVisibilityRequest { + if ( + payload.typeName !== VISIBILITY_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error( + `ohos.menu.set-menubar-visible requires bridge type ${VISIBILITY_REQUEST_TYPE}`, + ); + } + const request = payload.value as MenuVisibilityRequest; + if (!request.windowId?.trim()) { + throw new Error("set-menubar-visible.windowId must not be empty"); + } + if (typeof request.visible !== "boolean") { + throw new Error("set-menubar-visible.visible must be a boolean"); + } + return request; +} + +/** + * Per-window menubar content store. `MenuBarComponent` subscribes here; the plugin writes + * here on `set-menubar` / `set-menubar-visible`. Click events are forwarded to Rust through + * `invokeNativeSync("menu-click", ...)` while the originating callback is active. + */ +class MenuBarRegistry { + private static readonly windows: Map = new Map(); + private static readonly listeners: Map void>> = new Map(); + + private static key(sessionId: string, windowId: string): string { + return `${sessionId}:${windowId}`; + } + + static setMenubar(context: BridgePluginContext, ownerId: number, request: MenuBarRequest): void { + const key = MenuBarRegistry.key(context.sessionId, request.windowId); + const current = MenuBarRegistry.windows.get(key); + MenuBarRegistry.windows.set(key, { + menubar: request, + popup: current?.popup ?? null, + context, + ownerId, + }); + MenuBarRegistry.notify(key); + } + + static setVisible( + context: BridgePluginContext, + ownerId: number, + request: MenuVisibilityRequest, + ): void { + const key = MenuBarRegistry.key(context.sessionId, request.windowId); + const current = MenuBarRegistry.windows.get(key); + const menubar: MenuBarRequest = current?.menubar ?? { + windowId: request.windowId, + items: [], + }; + MenuBarRegistry.windows.set(key, { + menubar: { + windowId: menubar.windowId, + items: menubar.items, + visible: request.visible, + }, + popup: current?.popup ?? null, + context, + ownerId, + }); + MenuBarRegistry.notify(key); + } + + static setPopup(context: BridgePluginContext, ownerId: number, request: MenuPopupRequest): void { + const key = MenuBarRegistry.key(context.sessionId, request.windowId); + const current = MenuBarRegistry.windows.get(key); + MenuBarRegistry.windows.set(key, { + menubar: current?.menubar ?? { windowId: request.windowId, items: [] }, + popup: request, + context, + ownerId, + }); + MenuBarRegistry.notify(key); + } + + static get(sessionId: string, windowId: string): MenuWindowSnapshot { + const state = MenuBarRegistry.windows.get(MenuBarRegistry.key(sessionId, windowId)); + return { + menubar: state?.menubar ?? { windowId, items: [] }, + popup: state?.popup ?? null, + }; + } + + static subscribe(sessionId: string, windowId: string, listener: () => void): () => void { + const key = MenuBarRegistry.key(sessionId, windowId); + let set = MenuBarRegistry.listeners.get(key); + if (!set) { + set = new Set(); + MenuBarRegistry.listeners.set(key, set); + } + set.add(listener); + return () => { + set?.delete(listener); + if (set?.size === 0) { + MenuBarRegistry.listeners.delete(key); + } + }; + } + + static dispatchClick(sessionId: string, windowId: string, id: string): void { + const key = MenuBarRegistry.key(sessionId, windowId); + const state = MenuBarRegistry.windows.get(key); + if (state === undefined || !state.context.isActive()) { + console.warn("MenuPlugin is not active; menu click dropped"); + return; + } + try { + dispatchMenuClick(state.context, id, windowId); + } catch (error) { + console.error(`MenuPlugin failed to dispatch menu click: ${String(error)}`); + } + if (state.popup !== null) { + state.popup = null; + MenuBarRegistry.notify(key); + } + } + + static clearOwner(ownerId: number): void { + const entries = Array.from(MenuBarRegistry.windows.entries()); + for (const entry of entries) { + const key = entry[0]; + const state = entry[1]; + if (state.ownerId === ownerId) { + MenuBarRegistry.windows.delete(key); + MenuBarRegistry.notify(key); + } + } + } + + private static notify(key: string): void { + const set = MenuBarRegistry.listeners.get(key); + if (set) { + for (const listener of Array.from(set)) { + listener(); + } + } + } +} + +interface MenuWindowState { + menubar: MenuBarRequest; + popup: MenuPopupRequest | null; + context: BridgePluginContext; + ownerId: number; +} + +interface MenuWindowSnapshot { + menubar: MenuBarRequest; + popup: MenuPopupRequest | null; +} + +let nextMenuPluginOwnerId: number = 1; + +export class MenuPlugin extends AsyncPluginBase { + readonly id = "ohos.menu"; + readonly requires: BridgeContextRequirement[] = []; + private readonly ownerId: number = nextMenuPluginOwnerId++; + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + const context = this.getContext(); + if (action === "set-menubar") { + const request = parseMenuBarRequest(payload); + MenuBarRegistry.setMenubar(context, this.ownerId, request); + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new MenuAcknowledgement(true) }; + } + + if (action === "set-menubar-visible") { + const request = parseVisibilityRequest(payload); + MenuBarRegistry.setVisible(context, this.ownerId, request); + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new MenuAcknowledgement(true) }; + } + + if (action === "popup") { + const request = parsePopupRequest(payload); + MenuBarRegistry.setPopup(context, this.ownerId, request); + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new MenuAcknowledgement(true) }; + } + + throw new Error(`Unsupported ohos.menu action '${action}'`); + } + + override onDispose(): void { + MenuBarRegistry.clearOwner(this.ownerId); + } +} + +/** Dispatches a menu click to the Rust facade while the current callback is active. */ +export function dispatchMenuClick( + context: BridgePluginContext, + id: string, + windowId: string, +): void { + const click: MenuClickRequest = { id, windowId }; + const response = context.invokeNativeSync( + "menu-click", + CLICK_REQUEST_TYPE, + CLICK_RESPONSE_TYPE, + click, + ) as MenuClickResponse; + if (response === null || typeof response !== "object" || response.accepted !== true) { + throw new Error("Rust menu-click handler did not accept the event"); + } +} + +interface FlattenedMenuItem { + item: MenuItemData; + depth: number; +} + +function isMenuSeparator(item: MenuItemData): boolean { + return ( + item.itemType === "separator" || + (item.itemType === "predefined" && item.predefinedType === "separator") + ); +} + +function hasSubmenu(item: MenuItemData): boolean { + return ( + item.submenuItems !== undefined && item.submenuItems !== null && item.submenuItems.length > 0 + ); +} + +function menuItemLabel(item: MenuItemData): string { + const text = item.text ?? item.aboutMetadata?.name ?? item.predefinedType ?? ""; + const checked = item.checked === true ? "✓ " : ""; + const accelerator = item.accelerator ? ` ${item.accelerator}` : ""; + return `${checked}${text}${accelerator}`; +} + +function flattenMenuItems(items: MenuItemData[], depth: number = 0): FlattenedMenuItem[] { + const flattened: FlattenedMenuItem[] = []; + for (const item of items) { + flattened.push({ item, depth }); + if (hasSubmenu(item)) { + flattened.push(...flattenMenuItems(item.submenuItems!, depth + 1)); + } + } + return flattened; +} + +/** + * Business-placeable menubar host. Place it at the top of a window layout; it renders the + * per-window menubar published by `ohos.menu` and forwards clicks back to Rust. + */ +@Component +export struct MenuBarComponent { + @StorageProp("bridgeSessionId") bridgeSessionId: string = ""; + windowId: string = "main"; + @State private items: MenuItemData[] = []; + @State private visible: boolean = true; + @State private openMenu: string = ""; + private unsubscribe?: () => void; + + aboutToAppear(): void { + if (!this.bridgeSessionId) { + throw new Error("MenuBarComponent requires an active bridgeSessionId"); + } + const refresh = (): void => { + const data = MenuBarRegistry.get(this.bridgeSessionId, this.windowId).menubar; + this.items = data.items; + this.visible = data.visible ?? true; + }; + refresh(); + this.unsubscribe = MenuBarRegistry.subscribe(this.bridgeSessionId, this.windowId, refresh); + } + + aboutToDisappear(): void { + this.unsubscribe?.(); + this.unsubscribe = undefined; + } + + private handleItemClick(item: MenuItemData, windowId: string, topLevel: boolean): void { + if (item.enabled === false || isMenuSeparator(item)) { + return; + } + if (hasSubmenu(item)) { + if (topLevel) { + this.openMenu = this.openMenu === item.id ? "" : item.id; + } + return; + } + this.openMenu = ""; + MenuBarRegistry.dispatchClick(this.bridgeSessionId, windowId, item.id); + } + + build() { + Row() { + if (this.visible) { + ForEach( + this.items, + (item: MenuItemData) => { + Column() { + Text(item.text ?? "") + .fontSize(14) + .padding({ left: 12, right: 12, top: 6, bottom: 6 }) + .onClick(() => this.handleItemClick(item, this.windowId, true)) + if (this.openMenu === item.id && item.submenuItems) { + Column() { + ForEach( + flattenMenuItems(item.submenuItems), + (entry: FlattenedMenuItem) => { + if (isMenuSeparator(entry.item)) { + Divider().color("#D8D8D8").margin({ top: 3, bottom: 3 }); + } else { + Text(menuItemLabel(entry.item)) + .fontSize(13) + .fontColor(entry.item.enabled === false ? "#999999" : "#222222") + .padding({ + left: 16 + entry.depth * 12, + right: 16, + top: 6, + bottom: 6, + }) + .onClick(() => this.handleItemClick(entry.item, this.windowId, false)); + } + }, + (entry: FlattenedMenuItem) => entry.item.id, + ) + } + .backgroundColor(Color.White) + .border({ width: 1, color: "#E0E0E0" }) + .shadow({ radius: 8, color: "#33000000", offsetY: 2 }); + } + }; + }, + (item: MenuItemData) => item.id, + ); + } + } + .width("100%") + .height(this.visible ? 36 : 0) + .backgroundColor("#F5F5F5") + .alignItems(VerticalAlign.Center); + } +} + +/** + * Business-placeable context-menu overlay. Put it in the same window `Stack` as the content that + * owns `windowId`; `popup` updates its items and optional position without any module routing. + */ +@Component +export struct MenuPopupComponent { + @StorageProp("bridgeSessionId") bridgeSessionId: string = ""; + windowId: string = "main"; + @State private items: MenuItemData[] = []; + @State private x: number = 0; + @State private y: number = 0; + @State private visible: boolean = false; + private unsubscribe?: () => void; + + aboutToAppear(): void { + if (!this.bridgeSessionId) { + throw new Error("MenuPopupComponent requires an active bridgeSessionId"); + } + const refresh = (): void => { + const popup = MenuBarRegistry.get(this.bridgeSessionId, this.windowId).popup; + this.items = popup?.items ?? []; + this.x = popup?.x ?? 0; + this.y = popup?.y ?? 0; + this.visible = popup !== null; + }; + refresh(); + this.unsubscribe = MenuBarRegistry.subscribe(this.bridgeSessionId, this.windowId, refresh); + } + + aboutToDisappear(): void { + this.unsubscribe?.(); + this.unsubscribe = undefined; + } + + private handleItemClick(item: MenuItemData): void { + if (item.enabled === false || isMenuSeparator(item) || hasSubmenu(item)) { + return; + } + MenuBarRegistry.dispatchClick(this.bridgeSessionId, this.windowId, item.id); + } + + build() { + if (this.visible) { + Column() { + ForEach( + flattenMenuItems(this.items), + (entry: FlattenedMenuItem) => { + if (isMenuSeparator(entry.item)) { + Divider().color("#D8D8D8").margin({ top: 3, bottom: 3 }); + } else { + Text(menuItemLabel(entry.item)) + .width("100%") + .fontSize(13) + .fontColor(entry.item.enabled === false ? "#999999" : "#222222") + .padding({ + left: 12 + entry.depth * 12, + right: 12, + top: 7, + bottom: 7, + }) + .onClick(() => this.handleItemClick(entry.item)); + } + }, + (entry: FlattenedMenuItem) => entry.item.id, + ) + } + .width(220) + .position({ x: this.x, y: this.y }) + .backgroundColor(Color.White) + .border({ width: 1, color: "#D8D8D8" }) + .borderRadius(6) + .shadow({ radius: 10, color: "#33000000", offsetY: 3 }); + } + } +} diff --git a/plugins/menu/src/main/module.json5 b/plugins/menu/src/main/module.json5 new file mode 100644 index 00000000..ccc9f2f7 --- /dev/null +++ b/plugins/menu/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_menu", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/statusbar/BuildProfile.ets b/plugins/statusbar/BuildProfile.ets new file mode 100644 index 00000000..48996480 --- /dev/null +++ b/plugins/statusbar/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-beta.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/statusbar/build-profile.json5 b/plugins/statusbar/build-profile.json5 new file mode 100644 index 00000000..92bcdb3d --- /dev/null +++ b/plugins/statusbar/build-profile.json5 @@ -0,0 +1,10 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + "runtimeOS": "HarmonyOS", + }, + ], +} diff --git a/plugins/statusbar/hvigorfile.ts b/plugins/statusbar/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/statusbar/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/statusbar/index.ets b/plugins/statusbar/index.ets new file mode 100644 index 00000000..7672dcd1 --- /dev/null +++ b/plugins/statusbar/index.ets @@ -0,0 +1 @@ +export { StatusBarPlugin } from "./src/main/ets/StatusBarPlugin"; diff --git a/plugins/statusbar/oh-package-lock.json5 b/plugins/statusbar/oh-package-lock.json5 new file mode 100644 index 00000000..4510149d --- /dev/null +++ b/plugins/statusbar/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "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" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0-beta.1", + "resolved": "../../native_ability", + "registryType": "local" + } + } +} diff --git a/plugins/statusbar/oh-package.json5 b/plugins/statusbar/oh-package.json5 new file mode 100644 index 00000000..df39d3cb --- /dev/null +++ b/plugins/statusbar/oh-package.json5 @@ -0,0 +1,16 @@ +{ + "author": { + "name": "richerfu", + "email": "southorange0929@foxmail.com", + }, + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, + "description": "statusbar capability plugin for @ohos-rs/ability", + "keywords": ["ohos-rs", "NAPI", "Rust", "node-addon", "openharmony", "harmonynext"], + "license": "MIT OR Apache-2.0", + "main": "index.ets", + "name": "@ohos-rs/ability-plugin-statusbar", + "repository": "https://github.com/harmony-contrib/openharmony-ability.git", + "version": "1.0.0-beta.0", +} diff --git a/plugins/statusbar/src/main/ets/StatusBarPlugin.ets b/plugins/statusbar/src/main/ets/StatusBarPlugin.ets new file mode 100644 index 00000000..5e6a74a2 --- /dev/null +++ b/plugins/statusbar/src/main/ets/StatusBarPlugin.ets @@ -0,0 +1,808 @@ +import common from "@ohos.app.ability.common"; +import process from "@ohos.process"; +import { Want } from "@kit.AbilityKit"; +import { image } from "@kit.ImageKit"; +import { statusBarManager } from "@kit.DeskTopExtensionKit"; +import { emitter, BusinessError } from "@kit.BasicServicesKit"; +import { hilog } from "@kit.PerformanceAnalysisKit"; +import { + AsyncPluginBase, + BridgeCallContext, + BridgeContextRequirement, + BridgePluginContext, + BridgeTypedValue, +} from "@ohos-rs/ability"; + +const ADD_REQUEST_TYPE = "ohos.statusbar.AddRequest"; +const ICON_REQUEST_TYPE = "ohos.statusbar.IconRequest"; +const MENU_REQUEST_TYPE = "ohos.statusbar.MenuRequest"; +const TIPS_REQUEST_TYPE = "ohos.statusbar.TipsRequest"; +const ACTION_REQUEST_TYPE = "ohos.statusbar.ActionRequest"; +const ACKNOWLEDGEMENT_TYPE = "ohos.statusbar.Acknowledgement"; +const CLICK_REQUEST_TYPE = "ohos.statusbar.ClickRequest"; +const MENU_CLICK_REQUEST_TYPE = "ohos.statusbar.MenuClickRequest"; +const CLICK_RESPONSE_TYPE = "ohos.statusbar.ClickResponse"; +const MAX_MENU_ITEMS: number = 20; +const MAX_SUB_MENU_ITEMS: number = 20; +const MAX_HOVER_TIPS_LENGTH: number = 128; +const DEFAULT_MENU_ICON_SIZE: number = 24; + +interface StatusBarIconData { + whiteRgba?: Uint8Array | null; + blackRgba?: Uint8Array | null; + size: number; +} + +interface QuickOperationData { + abilityName: string; + title: string; + height: number; + /** ArkTS/HAP module for the target extension Ability, not a native bridge module. */ + moduleName?: string | null; + loadingStatus?: boolean | null; +} + +interface StatusBarMenuActionData { + abilityName: string; + /** ArkTS/HAP module used in the platform Want, not a native bridge module. */ + moduleName?: string | null; + menuCode?: string | null; + notifyOnly?: boolean | null; +} + +interface StatusBarSubMenuItemData { + subTitle: string; + menuCode?: string | null; + menuAction: StatusBarMenuActionData; + selected?: boolean | null; +} + +interface StatusBarMenuItemData { + title: string; + menuCode?: string | null; + subMenu?: StatusBarSubMenuItemData[] | null; + menuAction?: StatusBarMenuActionData | null; + selected?: boolean | null; + iconRgba?: Uint8Array | null; + iconWidth?: number | null; + iconHeight?: number | null; +} + +interface ActionRequestPayload { + action: string; +} + +interface StatusBarClickResponse { + accepted: boolean; +} + +interface TipsRequestPayload { + tips: string; +} + +interface StatusBarItemData { + icons: StatusBarIconData; + quickOperation: QuickOperationData; + statusBarGroupMenu?: StatusBarMenuItemData[][] | null; + hoverTips?: string | null; +} + +class StatusBarAcknowledgement { + readonly accepted: boolean; + + constructor(accepted: boolean) { + this.accepted = accepted; + } +} + +interface AddRequestPayload { + item: StatusBarItemData; +} + +function validateRgba(rgba: Uint8Array, width: number, height: number, label: string): void { + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { + throw new Error(`${label} width and height must be positive integers`); + } + const expected = width * height * 4; + if (!Number.isSafeInteger(expected)) { + throw new Error(`${label} dimensions overflow`); + } + if (!(rgba instanceof Uint8Array) || rgba.length !== expected) { + throw new Error(`${label} rgba length must equal ${expected} (${width}x${height}x4)`); + } +} + +function validateIcon(icon: StatusBarIconData, label: string): void { + if (typeof icon !== "object" || icon === null) { + throw new Error(`${label}.icon must be an object`); + } + if (!Number.isInteger(icon.size) || icon.size <= 0) { + throw new Error(`${label}.icon.size must be a positive integer`); + } + let variants: number = 0; + if (icon.whiteRgba !== undefined && icon.whiteRgba !== null) { + validateRgba(icon.whiteRgba, icon.size, icon.size, `${label}.icon.whiteRgba`); + variants += 1; + } + if (icon.blackRgba !== undefined && icon.blackRgba !== null) { + validateRgba(icon.blackRgba, icon.size, icon.size, `${label}.icon.blackRgba`); + variants += 1; + } + if (variants === 0) { + throw new Error(`${label} requires at least one icon variant`); + } +} + +function validateMenuAction(action: StatusBarMenuActionData, label: string): void { + if (typeof action !== "object" || action === null || typeof action.abilityName !== "string") { + throw new Error(`${label}.menuAction requires an abilityName string`); + } + if ( + action.moduleName !== undefined && + action.moduleName !== null && + typeof action.moduleName !== "string" + ) { + throw new Error(`${label}.menuAction.moduleName must be a string`); + } + if ( + action.menuCode !== undefined && + action.menuCode !== null && + (typeof action.menuCode !== "string" || !action.menuCode.trim()) + ) { + throw new Error(`${label}.menuAction.menuCode must be a non-empty string`); + } + if ( + action.notifyOnly !== undefined && + action.notifyOnly !== null && + typeof action.notifyOnly !== "boolean" + ) { + throw new Error(`${label}.menuAction.notifyOnly must be a boolean`); + } +} + +function registerMenuCode( + codes: Set, + code: string | null | undefined, + label: string, +): void { + if (code === undefined || code === null) { + return; + } + if (codes.has(code)) { + throw new Error(`${label}.menuCode '${code}' is duplicated`); + } + codes.add(code); +} + +function validateMenuItem(item: StatusBarMenuItemData, label: string, codes: Set): void { + if (typeof item !== "object" || item === null || typeof item.title !== "string") { + throw new Error(`${label} requires a title string`); + } + if (!item.title.trim()) { + throw new Error(`${label}.title must not be empty`); + } + if ( + item.menuCode !== undefined && + item.menuCode !== null && + (typeof item.menuCode !== "string" || !item.menuCode.trim()) + ) { + throw new Error(`${label}.menuCode must be a non-empty string`); + } + if (item.menuAction !== undefined && item.menuAction !== null) { + validateMenuAction(item.menuAction, label); + } + registerMenuCode(codes, item.menuCode ?? item.menuAction?.menuCode, label); + if (item.subMenu !== undefined && item.subMenu !== null) { + if (!Array.isArray(item.subMenu) || item.subMenu.length > MAX_SUB_MENU_ITEMS) { + throw new Error(`${label}.subMenu must contain at most ${MAX_SUB_MENU_ITEMS} items`); + } + for (const sub of item.subMenu) { + if (typeof sub !== "object" || sub === null || !sub.subTitle?.trim()) { + throw new Error(`${label}.subMenu requires non-empty subTitle values`); + } + if ( + sub.menuCode !== undefined && + sub.menuCode !== null && + (typeof sub.menuCode !== "string" || !sub.menuCode.trim()) + ) { + throw new Error(`${label}.subMenu.menuCode must be a non-empty string`); + } + validateMenuAction(sub.menuAction, `${label}.subMenu`); + registerMenuCode(codes, sub.menuCode ?? sub.menuAction.menuCode, `${label}.subMenu`); + if ( + sub.selected !== undefined && + sub.selected !== null && + typeof sub.selected !== "boolean" + ) { + throw new Error(`${label}.subMenu.selected must be a boolean`); + } + } + } + if ( + (item.menuAction === undefined || item.menuAction === null) && + (item.subMenu === undefined || item.subMenu === null || item.subMenu.length === 0) + ) { + throw new Error(`${label} requires menuAction or a non-empty subMenu`); + } + if (item.selected !== undefined && item.selected !== null && typeof item.selected !== "boolean") { + throw new Error(`${label}.selected must be a boolean`); + } + if (item.iconRgba !== undefined && item.iconRgba !== null) { + validateRgba( + item.iconRgba, + item.iconWidth ?? DEFAULT_MENU_ICON_SIZE, + item.iconHeight ?? DEFAULT_MENU_ICON_SIZE, + `${label}.icon`, + ); + } else if (item.iconWidth !== undefined || item.iconHeight !== undefined) { + throw new Error(`${label}.icon dimensions require iconRgba`); + } +} + +function validateGroups(groups: StatusBarMenuItemData[][], label: string): void { + if (!Array.isArray(groups)) { + throw new Error(`${label}.groups must be an array`); + } + let total: number = 0; + const codes: Set = new Set(); + for (const group of groups) { + if (!Array.isArray(group)) { + throw new Error(`${label}.groups entries must be arrays`); + } + total += group.length; + for (const item of group) { + validateMenuItem(item, label, codes); + } + } + if (total > MAX_MENU_ITEMS) { + throw new Error(`${label} supports at most ${MAX_MENU_ITEMS} menu items`); + } +} + +function parseAddRequest(payload: BridgeTypedValue): StatusBarItemData { + if ( + payload.typeName !== ADD_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.statusbar.add requires bridge type ${ADD_REQUEST_TYPE}`); + } + const item = (payload.value as AddRequestPayload).item; + if (typeof item !== "object" || item === null) { + throw new Error("ohos.statusbar.add.item must be an object"); + } + validateIcon(item.icons, "add"); + if ( + typeof item.quickOperation !== "object" || + item.quickOperation === null || + typeof item.quickOperation.abilityName !== "string" || + !item.quickOperation.abilityName.trim() || + typeof item.quickOperation.title !== "string" || + !item.quickOperation.title.trim() || + !Number.isInteger(item.quickOperation.height) || + item.quickOperation.height <= 0 + ) { + throw new Error("add.quickOperation requires abilityName, title and a positive height"); + } + if ( + item.quickOperation.moduleName !== undefined && + item.quickOperation.moduleName !== null && + typeof item.quickOperation.moduleName !== "string" + ) { + throw new Error("add.quickOperation.moduleName must be a string"); + } + if ( + item.quickOperation.loadingStatus !== undefined && + item.quickOperation.loadingStatus !== null && + typeof item.quickOperation.loadingStatus !== "boolean" + ) { + throw new Error("add.quickOperation.loadingStatus must be a boolean"); + } + if (item.statusBarGroupMenu !== undefined && item.statusBarGroupMenu !== null) { + validateGroups(item.statusBarGroupMenu, "add"); + } + if ( + item.hoverTips !== undefined && + item.hoverTips !== null && + (typeof item.hoverTips !== "string" || + item.hoverTips.trim().length === 0 || + item.hoverTips.trim().length > MAX_HOVER_TIPS_LENGTH) + ) { + throw new Error(`add.hoverTips must be 1..=${MAX_HOVER_TIPS_LENGTH} chars`); + } + return item; +} + +interface IconRequestPayload { + icon: StatusBarIconData; +} + +function parseIconRequest(payload: BridgeTypedValue): StatusBarIconData { + if ( + payload.typeName !== ICON_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.statusbar.update-icon requires bridge type ${ICON_REQUEST_TYPE}`); + } + const icon = (payload.value as IconRequestPayload).icon; + validateIcon(icon, "update-icon"); + return icon; +} + +interface MenuRequestPayload { + groups: StatusBarMenuItemData[][]; +} + +function parseMenuRequest(payload: BridgeTypedValue): StatusBarMenuItemData[][] { + if ( + payload.typeName !== MENU_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.statusbar.update-menu requires bridge type ${MENU_REQUEST_TYPE}`); + } + const groups = (payload.value as MenuRequestPayload).groups; + validateGroups(groups, "update-menu"); + return groups; +} + +function parseTipsRequest(payload: BridgeTypedValue): string { + if ( + payload.typeName !== TIPS_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.statusbar.update-tips requires bridge type ${TIPS_REQUEST_TYPE}`); + } + const tips = (payload.value as TipsRequestPayload).tips; + if ( + typeof tips !== "string" || + tips.trim().length === 0 || + tips.trim().length > MAX_HOVER_TIPS_LENGTH + ) { + throw new Error(`update-tips.tips must be 1..=${MAX_HOVER_TIPS_LENGTH} chars`); + } + return tips.trim(); +} + +function parseActionRequest(payload: BridgeTypedValue): string { + if ( + payload.typeName !== ACTION_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.statusbar.predefined-action requires bridge type ${ACTION_REQUEST_TYPE}`); + } + const action = (payload.value as ActionRequestPayload).action; + if (typeof action !== "string" || !action.trim()) { + throw new Error("predefined-action.action must not be empty"); + } + return action.trim(); +} + +function createPixelMap(rgba: Uint8Array, width: number, height: number): image.PixelMap | null { + try { + validateRgba(rgba, width, height, "status bar pixel map"); + const opts: image.InitializationOptions = { + editable: true, + pixelFormat: image.PixelMapFormat.RGBA_8888, + size: { width, height }, + }; + const pixelMap = image.createPixelMapSync(opts); + const buf = rgba.buffer.slice(rgba.byteOffset, rgba.byteOffset + rgba.byteLength); + pixelMap.writeBufferToPixelsSync(buf); + return pixelMap; + } catch (error) { + hilog.error(0x0000, "StatusBarPlugin", "createPixelMap failed: %{public}s", String(error)); + return null; + } +} + +function buildIcons(icon: StatusBarIconData): ESObject { + let white: image.PixelMap | null = null; + let black: image.PixelMap | null = null; + if (icon.whiteRgba && icon.whiteRgba.length > 0) { + white = createPixelMap(icon.whiteRgba, icon.size, icon.size); + } + if (icon.blackRgba && icon.blackRgba.length > 0) { + black = createPixelMap(icon.blackRgba, icon.size, icon.size); + } + const fallback = white ?? black; + if (fallback === null) { + throw new Error("status bar icon PixelMap creation failed"); + } + return { white: white ?? fallback, black: black ?? fallback }; +} + +function buildMenuAction(menuAction: StatusBarMenuActionData, abilityName: string): ESObject { + const action: ESObject = { + abilityName: menuAction.abilityName.trim() || abilityName, + }; + if (menuAction.moduleName) { + action.moduleName = menuAction.moduleName; + } + if (menuAction.menuCode) { + action.menuCode = menuAction.menuCode; + } + if (menuAction.notifyOnly !== undefined && menuAction.notifyOnly !== null) { + action.notifyOnly = menuAction.notifyOnly; + } + return action; +} + +function buildMenuOptions( + selected: boolean | null | undefined, + rgba?: Uint8Array | null, + width?: number | null, + height?: number | null, +): ESObject | null { + const options: ESObject = {}; + if (selected !== undefined && selected !== null) { + options.selected = selected; + } + if (rgba !== undefined && rgba !== null) { + const pixelMap = createPixelMap( + rgba, + width ?? DEFAULT_MENU_ICON_SIZE, + height ?? DEFAULT_MENU_ICON_SIZE, + ); + if (pixelMap === null) { + throw new Error("status bar menu icon PixelMap creation failed"); + } + options.icon = { white: pixelMap, black: pixelMap }; + } + return Object.keys(options).length === 0 ? null : options; +} + +function buildPlatformGroups(groups: StatusBarMenuItemData[][], abilityName: string): ESObject[][] { + const result: ESObject[][] = []; + for (const group of groups) { + const platformGroup: ESObject[] = []; + for (const item of group) { + const platformItem: ESObject = { title: item.title }; + if (item.menuCode) { + platformItem.menuCode = item.menuCode; + } + if (item.menuAction) { + platformItem.menuAction = buildMenuAction(item.menuAction, abilityName); + } + if (item.subMenu) { + const platformSubMenu: ESObject[] = []; + for (const sub of item.subMenu) { + const platformSub: ESObject = { + subTitle: sub.subTitle, + menuAction: buildMenuAction(sub.menuAction, abilityName), + }; + if (sub.menuCode) { + platformSub.menuCode = sub.menuCode; + } + const subOptions: ESObject | null = buildMenuOptions(sub.selected); + if (subOptions !== null) { + platformSub.options = subOptions; + } + platformSubMenu.push(platformSub); + } + platformItem.subMenu = platformSubMenu; + } + const options: ESObject | null = buildMenuOptions( + item.selected, + item.iconRgba, + item.iconWidth, + item.iconHeight, + ); + if (options !== null) { + platformItem.options = options; + } + platformGroup.push(platformItem); + } + result.push(platformGroup); + } + return result; +} + +const iconClickHandler = (event: emitter.EventData): void => { + try { + const clickType = ((event.data as ESObject).clickType as string) ?? "click"; + const context = StatusBarPluginCoordinator.activeContext(); + if (context) { + const response = context.invokeNativeSync( + "icon-click", + CLICK_REQUEST_TYPE, + CLICK_RESPONSE_TYPE, + { clickType }, + ) as StatusBarClickResponse; + if (response === null || typeof response !== "object" || response.accepted !== true) { + throw new Error("Rust icon-click handler did not accept the event"); + } + } + } catch (error) { + hilog.error(0x0000, "StatusBarPlugin", "icon click dispatch failed: %{public}s", String(error)); + } +}; + +const menuClickHandler = (event: emitter.EventData): void => { + try { + const menuCode = ((event.data as ESObject).menuCode as string) ?? ""; + const context = StatusBarPluginCoordinator.activeContext(); + if (context) { + const response = context.invokeNativeSync( + "menu-click", + MENU_CLICK_REQUEST_TYPE, + CLICK_RESPONSE_TYPE, + { menuCode }, + ) as StatusBarClickResponse; + if (response === null || typeof response !== "object" || response.accepted !== true) { + throw new Error("Rust menu-click handler did not accept the event"); + } + } + } catch (error) { + hilog.error(0x0000, "StatusBarPlugin", "menu click dispatch failed: %{public}s", String(error)); + } +}; + +/** Process-level status bar routing. The platform exposes one item, so the latest successful + * mutating call owns subsequent callbacks; plugin instances never inspect native modules. */ +class StatusBarPluginCoordinator { + private static activeOwner: number | null = null; + private static activePluginContext: BridgePluginContext | null = null; + private static iconHandlerRegistered: boolean = false; + private static menuHandlerRegistered: boolean = false; + + static claim(ownerId: number, context: BridgePluginContext, rebindHandlers: boolean): void { + StatusBarPluginCoordinator.activeOwner = ownerId; + StatusBarPluginCoordinator.activePluginContext = context; + if (rebindHandlers) { + StatusBarPluginCoordinator.rebindHandlers(); + } else { + StatusBarPluginCoordinator.ensureHandlers(); + } + } + + static clearActive(): void { + StatusBarPluginCoordinator.activeOwner = null; + StatusBarPluginCoordinator.activePluginContext = null; + StatusBarPluginCoordinator.removeHandlers(); + } + + static activeContext(): BridgePluginContext | null { + const context = StatusBarPluginCoordinator.activePluginContext; + return context?.isActive() === true ? context : null; + } + + static unregister(ownerId: number): void { + if (StatusBarPluginCoordinator.activeOwner === ownerId) { + const context = StatusBarPluginCoordinator.activePluginContext; + if (context !== null) { + try { + statusBarManager.removeFromStatusBar(context.abilityContext); + } catch (error) { + hilog.warn( + 0x0000, + "StatusBarPlugin", + "status bar cleanup failed: %{public}s", + String(error), + ); + } + } + StatusBarPluginCoordinator.clearActive(); + } + } + + private static rebindHandlers(): void { + // addToStatusBar recreates the platform receiver, invalidating prior registrations. + StatusBarPluginCoordinator.removeHandlers(); + StatusBarPluginCoordinator.ensureHandlers(); + } + + private static ensureHandlers(): void { + if (!StatusBarPluginCoordinator.iconHandlerRegistered) { + try { + statusBarManager.on( + "statusBarIconClick" as "statusBarIconClick", + iconClickHandler as Callback, + ); + StatusBarPluginCoordinator.iconHandlerRegistered = true; + } catch (error) { + hilog.error( + 0x0000, + "StatusBarPlugin", + "icon handler registration failed: %{public}s", + String(error), + ); + } + } + if (!StatusBarPluginCoordinator.menuHandlerRegistered) { + try { + statusBarManager.on( + "rightMenuClick" as "rightMenuClick", + menuClickHandler as Callback, + ); + StatusBarPluginCoordinator.menuHandlerRegistered = true; + } catch (error) { + hilog.error( + 0x0000, + "StatusBarPlugin", + "menu handler registration failed: %{public}s", + String(error), + ); + } + } + } + + private static removeHandlers(): void { + if (StatusBarPluginCoordinator.iconHandlerRegistered) { + try { + statusBarManager.off( + "statusBarIconClick" as "statusBarIconClick", + iconClickHandler as Callback, + ); + } catch (error) { + hilog.warn(0x0000, "StatusBarPlugin", "icon unbind failed: %{public}s", String(error)); + } + StatusBarPluginCoordinator.iconHandlerRegistered = false; + } + if (StatusBarPluginCoordinator.menuHandlerRegistered) { + try { + statusBarManager.off( + "rightMenuClick" as "rightMenuClick", + menuClickHandler as Callback, + ); + } catch (error) { + hilog.warn(0x0000, "StatusBarPlugin", "menu unbind failed: %{public}s", String(error)); + } + StatusBarPluginCoordinator.menuHandlerRegistered = false; + } + } +} + +let nextStatusBarPluginOwnerId: number = 1; + +export class StatusBarPlugin extends AsyncPluginBase { + readonly id = "ohos.statusbar"; + readonly requires: BridgeContextRequirement[] = ["ability"]; + private readonly ownerId: number = nextStatusBarPluginOwnerId++; + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + _context: BridgeCallContext, + ): Promise { + const pluginContext = this.getContext(); + const abilityContext = pluginContext.abilityContext; + const abilityName = abilityContext.abilityInfo.name; + + if (action === "add") { + const item = parseAddRequest(payload); + const icons: ESObject = buildIcons(item.icons); + const quickOperation: ESObject = { + abilityName: item.quickOperation.abilityName, + title: item.quickOperation.title, + height: item.quickOperation.height, + }; + if (item.quickOperation.moduleName) { + quickOperation.moduleName = item.quickOperation.moduleName; + } + if ( + item.quickOperation.loadingStatus !== undefined && + item.quickOperation.loadingStatus !== null + ) { + quickOperation.loadingStatus = item.quickOperation.loadingStatus; + } + const opts: ESObject = { + icons, + quickOperation, + statusBarGroupMenu: buildPlatformGroups(item.statusBarGroupMenu ?? [], abilityName), + }; + if (item.hoverTips && item.hoverTips.trim().length > 0) { + opts.hoverTips = item.hoverTips.trim(); + } + try { + statusBarManager.addToStatusBar(abilityContext, opts); + StatusBarPluginCoordinator.claim(this.ownerId, pluginContext, true); + } catch (error) { + const err = error as BusinessError; + throw new Error(`addToStatusBar failed: code=${err.code} msg=${err.message}`); + } + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new StatusBarAcknowledgement(true) }; + } + + if (action === "remove") { + try { + statusBarManager.removeFromStatusBar(abilityContext); + StatusBarPluginCoordinator.clearActive(); + } catch (error) { + const err = error as BusinessError; + throw new Error(`removeFromStatusBar failed: code=${err.code} msg=${err.message}`); + } + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new StatusBarAcknowledgement(true) }; + } + + if (action === "update-icon") { + const icon = parseIconRequest(payload); + const icons: ESObject = buildIcons(icon); + try { + statusBarManager.updateStatusBarIcon(abilityContext, icons); + StatusBarPluginCoordinator.claim(this.ownerId, pluginContext, false); + } catch (error) { + const err = error as BusinessError; + throw new Error(`updateStatusBarIcon failed: code=${err.code} msg=${err.message}`); + } + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new StatusBarAcknowledgement(true) }; + } + + if (action === "update-menu") { + const groups = parseMenuRequest(payload); + const platformGroups: ESObject[][] = buildPlatformGroups(groups, abilityName); + try { + statusBarManager.updateStatusBarMenu(abilityContext, platformGroups); + StatusBarPluginCoordinator.claim(this.ownerId, pluginContext, false); + } catch (error) { + const err = error as BusinessError; + throw new Error(`updateStatusBarMenu failed: code=${err.code} msg=${err.message}`); + } + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new StatusBarAcknowledgement(true) }; + } + + if (action === "update-tips") { + const tips = parseTipsRequest(payload); + try { + await statusBarManager.updateStatusBarHoverTips(abilityContext, tips); + StatusBarPluginCoordinator.claim(this.ownerId, pluginContext, false); + } catch (error) { + const err = error as BusinessError; + throw new Error(`updateStatusBarHoverTips failed: code=${err.code} msg=${err.message}`); + } + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new StatusBarAcknowledgement(true) }; + } + + if (action === "predefined-action") { + const actionType = parseActionRequest(payload); + await executePredefinedAction(abilityContext, actionType); + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new StatusBarAcknowledgement(true) }; + } + + throw new Error(`Unsupported ohos.statusbar action '${action}'`); + } + + override onDispose(): void { + StatusBarPluginCoordinator.unregister(this.ownerId); + } +} + +async function executePredefinedAction( + context: common.UIAbilityContext, + actionType: string, +): Promise { + if (actionType === "show" || actionType === "show-all") { + const want: Want = { + bundleName: context.abilityInfo.bundleName, + abilityName: context.abilityInfo.name, + }; + await context.startAbility(want); + return; + } + if (actionType === "quit" || actionType === "exit") { + new process.ProcessManager().exit(0); + return; + } + // minimize / maximize / restore act on the main window. + const windowStage = context.windowStage; + if (!windowStage) { + throw new Error(`predefined action '${actionType}' requires a window stage`); + } + const mainWindow = windowStage.getMainWindowSync(); + if (actionType === "minimize") { + await mainWindow.minimize(); + return; + } + if (actionType === "maximize") { + await mainWindow.maximize(); + return; + } + if (actionType === "restore" || actionType === "recover") { + await mainWindow.restore(); + return; + } + throw new Error(`Unsupported predefined action '${actionType}'`); +} diff --git a/plugins/statusbar/src/main/module.json5 b/plugins/statusbar/src/main/module.json5 new file mode 100644 index 00000000..aee6e53a --- /dev/null +++ b/plugins/statusbar/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_statusbar", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/updater/BuildProfile.ets b/plugins/updater/BuildProfile.ets new file mode 100644 index 00000000..48996480 --- /dev/null +++ b/plugins/updater/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-beta.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/updater/build-profile.json5 b/plugins/updater/build-profile.json5 new file mode 100644 index 00000000..92bcdb3d --- /dev/null +++ b/plugins/updater/build-profile.json5 @@ -0,0 +1,10 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + "runtimeOS": "HarmonyOS", + }, + ], +} diff --git a/plugins/updater/hvigorfile.ts b/plugins/updater/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/updater/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/updater/index.ets b/plugins/updater/index.ets new file mode 100644 index 00000000..ad290b84 --- /dev/null +++ b/plugins/updater/index.ets @@ -0,0 +1 @@ +export { UpdaterPlugin } from "./src/main/ets/UpdaterPlugin"; diff --git a/plugins/updater/oh-package-lock.json5 b/plugins/updater/oh-package-lock.json5 new file mode 100644 index 00000000..4510149d --- /dev/null +++ b/plugins/updater/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "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" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0-beta.1", + "resolved": "../../native_ability", + "registryType": "local" + } + } +} diff --git a/plugins/updater/oh-package.json5 b/plugins/updater/oh-package.json5 new file mode 100644 index 00000000..9982a090 --- /dev/null +++ b/plugins/updater/oh-package.json5 @@ -0,0 +1,16 @@ +{ + "author": { + "name": "richerfu", + "email": "southorange0929@foxmail.com", + }, + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, + "description": "updater capability plugin for @ohos-rs/ability", + "keywords": ["ohos-rs", "NAPI", "Rust", "node-addon", "openharmony", "harmonynext"], + "license": "MIT OR Apache-2.0", + "main": "index.ets", + "name": "@ohos-rs/ability-plugin-updater", + "repository": "https://github.com/harmony-contrib/openharmony-ability.git", + "version": "1.0.0-beta.0", +} diff --git a/plugins/updater/src/main/ets/UpdaterPlugin.ets b/plugins/updater/src/main/ets/UpdaterPlugin.ets new file mode 100644 index 00000000..31938701 --- /dev/null +++ b/plugins/updater/src/main/ets/UpdaterPlugin.ets @@ -0,0 +1,166 @@ +import common from "@ohos.app.ability.common"; +import { updateManager } from "@kit.AppGalleryKit"; +import { bundleManager } from "@kit.AbilityKit"; +import { hilog } from "@kit.PerformanceAnalysisKit"; +import { BusinessError } from "@kit.BasicServicesKit"; +import { + BridgeCallContext, + BridgeTypedValue, + AsyncPluginBase, + BridgeContextRequirement, +} from "@ohos-rs/ability"; + +const CHECK_REQUEST_TYPE = "ohos.updater.CheckRequest"; +const CHECK_RESPONSE_TYPE = "ohos.updater.CheckResponse"; +const ACKNOWLEDGEMENT_TYPE = "ohos.updater.Acknowledgement"; + +interface UpdaterCheckRequest { + requested: boolean; +} + +class UpdaterCheckResult { + readonly updateAvailable: boolean; + readonly currentVersion: string; + readonly version: string; + readonly body: string | null; + readonly date: string | null; + + constructor( + updateAvailable: boolean, + currentVersion: string, + version: string, + body: string | null, + date: string | null, + ) { + this.updateAvailable = updateAvailable; + this.currentVersion = currentVersion; + this.version = version; + this.body = body; + this.date = date; + } +} + +class UpdaterCheckResponse { + readonly result: UpdaterCheckResult | null; + + constructor(result: UpdaterCheckResult | null) { + this.result = result; + } +} + +class UpdaterAcknowledgement { + readonly accepted: boolean; + + constructor(accepted: boolean) { + this.accepted = accepted; + } +} + +function parseCheckRequest(payload: BridgeTypedValue): UpdaterCheckRequest { + if ( + payload.typeName !== CHECK_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.updater requires bridge type ${CHECK_REQUEST_TYPE}`); + } + const request = payload.value as UpdaterCheckRequest; + if (request.requested !== true) { + throw new Error("ohos.updater.requested must be true"); + } + return request; +} + +async function checkForUpdate( + context: common.UIAbilityContext, +): Promise { + try { + const checkResult = await updateManager.checkAppUpdate(context); + if (checkResult.updateAvailable !== updateManager.UpdateAvailableCode.LATER_VERSION_EXIST) { + hilog.info(0x0000, "UpdaterPlugin", "No update available"); + return null; + } + + let currentVersion = "unknown"; + try { + const bundleInfo = await bundleManager.getBundleInfoForSelf( + bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT, + ); + currentVersion = bundleInfo.versionName || "unknown"; + } catch (error) { + hilog.error( + 0x0000, + "UpdaterPlugin", + "getBundleInfoForSelf failed: %{public}s", + String(error), + ); + } + + // API 20+ exposes versionName; older API levels fall back to "unknown". + const version = ((checkResult as ESObject).versionName as string) || "unknown"; + + return new UpdaterCheckResult(true, currentVersion, String(version), null, null); + } catch (error) { + const err = error as BusinessError; + hilog.error( + 0x0000, + "UpdaterPlugin", + "checkAppUpdate failed: code=%{public}d msg=%{public}s", + err.code, + err.message, + ); + throw new Error(`checkAppUpdate failed: code=${err.code} msg=${err.message}`); + } +} + +async function showUpdateDialog(context: common.UIAbilityContext): Promise { + const resultCode = await updateManager.showUpdateDialog(context); + hilog.info(0x0000, "UpdaterPlugin", "showUpdateDialog result: %{public}d", resultCode); + return resultCode; +} + +let updateDialogInFlight: Promise | null = null; + +function showUpdateDialogOnce(context: common.UIAbilityContext): Promise { + if (updateDialogInFlight !== null) { + return updateDialogInFlight; + } + const operation = showUpdateDialog(context).finally((): void => { + if (updateDialogInFlight === operation) { + updateDialogInFlight = null; + } + }); + updateDialogInFlight = operation; + return operation; +} + +export class UpdaterPlugin extends AsyncPluginBase { + readonly id = "ohos.updater"; + readonly requires: BridgeContextRequirement[] = ["ability"]; + + async invokeAsync( + action: string, + payload: BridgeTypedValue, + context: BridgeCallContext, + ): Promise { + parseCheckRequest(payload); + if (action === "check") { + const result = await checkForUpdate(context.abilityContext); + if (!context.isActive()) { + throw new Error("Ability was destroyed while update check was pending"); + } + return { typeName: CHECK_RESPONSE_TYPE, value: new UpdaterCheckResponse(result) }; + } + if (action === "download-and-install") { + const resultCode = await showUpdateDialogOnce(context.abilityContext); + if (!context.isActive()) { + throw new Error("Ability was destroyed while update dialog was pending"); + } + if (resultCode !== updateManager.ShowUpdateResultCode.SHOW_DIALOG_SUCCESS) { + throw new Error(`showUpdateDialog returned failure code: ${resultCode}`); + } + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new UpdaterAcknowledgement(true) }; + } + throw new Error(`Unsupported ohos.updater action '${action}'`); + } +} diff --git a/plugins/updater/src/main/module.json5 b/plugins/updater/src/main/module.json5 new file mode 100644 index 00000000..7665fc38 --- /dev/null +++ b/plugins/updater/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_updater", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/version/BuildProfile.ets b/plugins/version/BuildProfile.ets new file mode 100644 index 00000000..48996480 --- /dev/null +++ b/plugins/version/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-beta.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/version/build-profile.json5 b/plugins/version/build-profile.json5 new file mode 100644 index 00000000..92bcdb3d --- /dev/null +++ b/plugins/version/build-profile.json5 @@ -0,0 +1,10 @@ +{ + "apiType": "stageMode", + "buildOption": {}, + "targets": [ + { + "name": "default", + "runtimeOS": "HarmonyOS", + }, + ], +} diff --git a/plugins/version/hvigorfile.ts b/plugins/version/hvigorfile.ts new file mode 100644 index 00000000..0939985f --- /dev/null +++ b/plugins/version/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from "@ohos/hvigor-ohos-plugin"; + +export default { + system: harTasks, + plugins: [], +}; diff --git a/plugins/version/index.ets b/plugins/version/index.ets new file mode 100644 index 00000000..0936f194 --- /dev/null +++ b/plugins/version/index.ets @@ -0,0 +1 @@ +export { VersionPlugin } from "./src/main/ets/VersionPlugin"; diff --git a/plugins/version/oh-package-lock.json5 b/plugins/version/oh-package-lock.json5 new file mode 100644 index 00000000..4510149d --- /dev/null +++ b/plugins/version/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "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" + }, + "packages": { + "@ohos-rs/ability@../../native_ability": { + "name": "@ohos-rs/ability", + "version": "1.0.0-beta.1", + "resolved": "../../native_ability", + "registryType": "local" + } + } +} diff --git a/plugins/version/oh-package.json5 b/plugins/version/oh-package.json5 new file mode 100644 index 00000000..96a9761e --- /dev/null +++ b/plugins/version/oh-package.json5 @@ -0,0 +1,16 @@ +{ + "author": { + "name": "richerfu", + "email": "southorange0929@foxmail.com", + }, + "dependencies": { + "@ohos-rs/ability": "file:../../native_ability", + }, + "description": "version capability plugin for @ohos-rs/ability", + "keywords": ["ohos-rs", "NAPI", "Rust", "node-addon", "openharmony", "harmonynext"], + "license": "MIT OR Apache-2.0", + "main": "index.ets", + "name": "@ohos-rs/ability-plugin-version", + "repository": "https://github.com/harmony-contrib/openharmony-ability.git", + "version": "1.0.0-beta.0", +} diff --git a/plugins/version/src/main/ets/VersionPlugin.ets b/plugins/version/src/main/ets/VersionPlugin.ets new file mode 100644 index 00000000..2ffb9e10 --- /dev/null +++ b/plugins/version/src/main/ets/VersionPlugin.ets @@ -0,0 +1,90 @@ +import { deviceInfo } from "@kit.BasicServicesKit"; +import { + BridgeCallContext, + BridgeTypedValue, + SyncPluginBase, + BridgeContextRequirement, +} from "@ohos-rs/ability"; + +const VERSION_REQUEST_TYPE = "ohos.version.VersionRequest"; +const VERSION_RESPONSE_TYPE = "ohos.version.VersionResponse"; + +interface VersionRequest { + syscap?: string | null; +} + +class VersionResponse { + readonly value: number; + readonly result: boolean | null; + + constructor(value: number, result: boolean | null) { + this.value = value; + this.result = result; + } +} + +function parseVersionRequest(payload: BridgeTypedValue): VersionRequest { + if ( + payload.typeName !== VERSION_REQUEST_TYPE || + typeof payload.value !== "object" || + payload.value === null + ) { + throw new Error(`ohos.version requires bridge type ${VERSION_REQUEST_TYPE}`); + } + const request = payload.value as VersionRequest; + if ( + request.syscap !== undefined && + request.syscap !== null && + typeof request.syscap !== "string" + ) { + throw new Error("ohos.version.syscap must be a string when provided"); + } + return request; +} + +function requireSyscap(request: VersionRequest): string { + if (typeof request.syscap !== "string" || !request.syscap.trim()) { + throw new Error("can-i-use.syscap must be a non-empty string"); + } + return request.syscap.trim(); +} + +export class VersionPlugin extends SyncPluginBase { + readonly id = "ohos.version"; + readonly requires: BridgeContextRequirement[] = []; + + invokeSync( + action: string, + payload: BridgeTypedValue, + _context: BridgeCallContext, + ): BridgeTypedValue { + const request = parseVersionRequest(payload); + if (action === "get-sdk-api-version") { + return { + typeName: VERSION_RESPONSE_TYPE, + value: new VersionResponse(deviceInfo.sdkApiVersion, null), + }; + } + if (action === "get-distribution-api-version") { + return { + typeName: VERSION_RESPONSE_TYPE, + value: new VersionResponse(deviceInfo.distributionOSApiVersion, null), + }; + } + if (action === "can-i-use") { + const syscap = requireSyscap(request); + let result = false; + try { + result = canIUse(syscap); + } catch { + result = false; + } + return { typeName: VERSION_RESPONSE_TYPE, value: new VersionResponse(0, result) }; + } + if (action === "is-desktop-device") { + const result = deviceInfo.deviceType === "2in1"; + return { typeName: VERSION_RESPONSE_TYPE, value: new VersionResponse(0, result) }; + } + throw new Error(`Unsupported ohos.version action '${action}'`); + } +} diff --git a/plugins/version/src/main/module.json5 b/plugins/version/src/main/module.json5 new file mode 100644 index 00000000..4a4fc551 --- /dev/null +++ b/plugins/version/src/main/module.json5 @@ -0,0 +1,7 @@ +{ + "module": { + "name": "plugin_version", + "type": "har", + "deviceTypes": ["default", "tablet", "2in1"], + }, +} diff --git a/plugins/webview/README.md b/plugins/webview/README.md index 82489935..be036d06 100644 --- a/plugins/webview/README.md +++ b/plugins/webview/README.md @@ -1,7 +1,7 @@ # @ohos-rs/ability-plugin-webview 这是 `openharmony-ability-plugin-webview` 的 ArkTS HAR。它创建 ArkWeb `Web` / `WebviewController`、 -把 WebView 的 `FrameNode` 挂进当前 native module 对应组件的根树(或 `ohos.node` 容器),并把所有 +把 WebView 的 `FrameNode` 挂进当前插件 Host 对应组件的根树(或 `ohos.node` 容器),并把所有 controller 操作和 ArkWeb callback 转换为具名 N-API bridge 调用。 业务不直接保存 controller,也不应把 WebView 接口重新写回 `DefaultXComponent`。Rust facade 使用 @@ -44,7 +44,7 @@ Rust 侧必须在 `#[ability]` 初始化器注册 `WebviewBridgePlugin`;自定 | `id` | `ohos.webview` | | `execution` | `async` | | `requires` | `["ui-context"]` | -| 挂载 | 当前 module/component 根树(默认全屏);可选 `parentHandle`(`ohos.node` 容器) | +| 挂载 | 当前 Host/component 根树(默认全屏);可选 `parentHandle`(`ohos.node` 容器) | | 创建 | `create`:`CreateRequest { id, parentHandle? }` → `CreateResponse { id }` | | 控制器操作 | `set-visible`、`set-background-color`、`remove`、`load-url`、`load-html`、`set-zoom`、`reload`、`focus`、`get-url`、`cookies-with-url`、`clear-all-browsing-data`、`evaluate-script` | @@ -54,7 +54,7 @@ controller attach、delegate/protocol/proxy 安装和首次 load 启动;它不 ## 挂载与混合布局 -WebView 的 `FrameNode` 默认以 host 内部唯一 key 挂进当前 module 的组件根树, +WebView 的 `FrameNode` 默认以 host 内部唯一 key 挂进当前 Host 的组件根树, 全屏显示。Rust 需要组合时,先用内置 `ohos.node` 插件创建容器,把容器句柄作为 `parentHandle` 传入 create request,WebView 节点就会挂到该容器下;容器最终由 Rust `mount-into-root` 整体挂载。 @@ -68,12 +68,13 @@ Stack() { } ``` -一个 `DefaultXComponent` 必须使用一个独立 native module;跨窗口组件不能共享 module,也不使用 -`windowKey`/`surfaceKey`。同一 module/component 可按不同 ID 同时持有多个 WebView。WebView ID 是 +framework 仍要求一个 `DefaultXComponent` 使用一个独立 native module;插件本身只接收已绑定的 +Host/context,不读取或配置 module,也不使用 `windowKey`/`surfaceKey`。同一插件实例/component 可按 +不同 ID 同时持有多个 WebView。WebView ID 是 不透明、facade-local 的业务标识,不会直接拼接成节点 key 或进程级 ArkWeb tag;HAR 会用 session + 进程计数器生成内部唯一 native tag,因此同一进程的多个插件实例可安全复用同一业务 ID。 -节点挂载没有计时等待:module 根在 `ui-context-ready` 前已存在,`onInstall` 后即可挂载。 +节点挂载没有计时等待:Host 根在 `ui-context-ready` 前已存在,`onInstall` 后即可挂载。 Ability/session dispose 时,HAR 必须卸载自己创建的节点与 controller 状态(`remove` 走 `context.removeChild` 或从父容器摘除),不能影响业务节点或其他插件。 diff --git a/plugins/webview/src/main/ets/WebviewPlugin.ets b/plugins/webview/src/main/ets/WebviewPlugin.ets index a4294cdc..a455cc59 100644 --- a/plugins/webview/src/main/ets/WebviewPlugin.ets +++ b/plugins/webview/src/main/ets/WebviewPlugin.ets @@ -1,5 +1,6 @@ import { WebHeader } from "@kit.ArkUI"; import { BuilderNode } from "@ohos.arkui.node"; +import fs from "@ohos.file.fs"; import webview from "@ohos.web.webview"; import { BridgeCallContext, @@ -18,6 +19,13 @@ 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 BOUNDS_REQUEST_TYPE = "ohos.webview.BoundsRequest"; +const COOKIE_REQUEST_TYPE = "ohos.webview.CookieRequest"; +const SNAPSHOT_REQUEST_TYPE = "ohos.webview.SnapshotRequest"; +const SNAPSHOT_RESPONSE_TYPE = "ohos.webview.SnapshotResponse"; +const BOOL_REQUEST_TYPE = "ohos.webview.BoolRequest"; +const BOOL_RESPONSE_TYPE = "ohos.webview.BoolResponse"; +const PDF_REQUEST_TYPE = "ohos.webview.PdfRequest"; const NAVIGATION_REQUEST_TYPE = "ohos.webview.NavigationRequest"; const NAVIGATION_RESPONSE_TYPE = "ohos.webview.NavigationResponse"; const DOWNLOAD_START_REQUEST_TYPE = "ohos.webview.DownloadStartRequest"; @@ -32,6 +40,21 @@ const EVENT_ACKNOWLEDGEMENT_TYPE = "ohos.webview.EventAcknowledgement"; /** Optional static ArkWeb API resolved dynamically to preserve the API-12 package baseline. */ interface WebviewDebuggingApi { setWebDebuggingAccess?: (enabled: boolean) => void; + getWebDebuggingAccess?: () => boolean; +} + +/** API-14-only `WebCookieManager` surface, resolved at runtime to keep the API 12 baseline. */ +interface CookieApi { + configCookieSync?: (url: string, value: string) => boolean; +} + +/** API-14-only PDF surfaces, resolved dynamically to keep the API 12 package baseline. */ +interface PdfDataApi { + pdfArrayBuffer?: () => Uint8Array; +} + +interface PdfControllerApi { + createPdf?: (configuration: ESObject) => Promise; } function enableWebDebuggingIfAvailable(): void { @@ -70,7 +93,7 @@ interface WebviewCreatePayload { id: string; /** * Optional opaque container handle issued by the built-in ohos.node plugin. When provided the - * WebView FrameNode is appended under that container instead of this module's component root, + * WebView FrameNode is appended under that container instead of this plugin Host's component root, * so an RS-layer node tree can adopt WebViews as children. */ parentHandle?: number | null; @@ -104,7 +127,7 @@ interface ScriptPayload { interface ManagedWebview extends WebviewCreatePayload { controller: WebviewController; - /** Process-unique ArkWeb controller tag; distinct from the module-local public ID. */ + /** Process-unique ArkWeb controller tag; distinct from the plugin-instance-local public ID. */ nativeTag: string; style: WebviewStyle; eventOptions: WebviewEventOptions; @@ -234,6 +257,144 @@ class WebviewAcceptedResponse { } } +class WebviewSnapshotResponse { + readonly rgba: Uint8Array; + readonly width: number; + readonly height: number; + + constructor(rgba: Uint8Array, width: number, height: number) { + this.rgba = rgba; + this.width = width; + this.height = height; + } +} + +class WebviewBoolResponse { + readonly value: boolean; + + constructor(value: boolean) { + this.value = value; + } +} + +interface WebviewBoundsPayload { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +interface WebviewCookiePayload { + id: string; + url: string; + value: string; +} + +interface WebviewSnapshotPayload { + id: string; +} + +interface WebviewBoolPayload { + id: string; + enabled?: boolean | null; +} + +interface PdfConfig { + width?: number | null; + height?: number | null; + marginTop?: number | null; + marginBottom?: number | null; + marginLeft?: number | null; + marginRight?: number | null; + scale?: number | null; + shouldPrintBackground?: boolean | null; +} + +interface WebviewPdfPayload { + id: string; + path: string; + config?: PdfConfig | null; +} + +function parseBoundsPayload(payload: BridgeTypedValue): WebviewBoundsPayload { + if ( + payload.typeName !== BOUNDS_REQUEST_TYPE || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`ohos.webview.set-bounds requires bridge type ${BOUNDS_REQUEST_TYPE}`); + } + const request = payload.value as WebviewBoundsPayload; + if (!request?.id?.trim()) { + throw new Error("WebView bounds controller id must not be empty"); + } + if (request.width <= 0 || request.height <= 0) { + throw new Error("WebView bounds width and height must be positive"); + } + return request; +} + +function parseCookiePayload(payload: BridgeTypedValue): WebviewCookiePayload { + if ( + payload.typeName !== COOKIE_REQUEST_TYPE || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`ohos.webview.set-cookie requires bridge type ${COOKIE_REQUEST_TYPE}`); + } + const request = payload.value as WebviewCookiePayload; + if (!request?.id?.trim() || !request.url?.trim() || !request.value?.trim()) { + throw new Error("WebView cookie requires non-empty id, url and value"); + } + return request; +} + +function parseSnapshotPayload(payload: BridgeTypedValue): WebviewSnapshotPayload { + if ( + payload.typeName !== SNAPSHOT_REQUEST_TYPE || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`ohos.webview.snapshot requires bridge type ${SNAPSHOT_REQUEST_TYPE}`); + } + const request = payload.value as WebviewSnapshotPayload; + if (!request?.id?.trim()) { + throw new Error("WebView snapshot controller id must not be empty"); + } + return request; +} + +function parseBoolPayload(payload: BridgeTypedValue): WebviewBoolPayload { + if ( + payload.typeName !== BOOL_REQUEST_TYPE || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`ohos.webview debugging requires bridge type ${BOOL_REQUEST_TYPE}`); + } + const request = payload.value as WebviewBoolPayload; + if (!request?.id?.trim()) { + throw new Error("WebView debugging controller id must not be empty"); + } + return request; +} + +function parsePdfPayload(payload: BridgeTypedValue): WebviewPdfPayload { + if ( + payload.typeName !== PDF_REQUEST_TYPE || + payload.value === null || + typeof payload.value !== "object" + ) { + throw new Error(`ohos.webview.create-pdf requires bridge type ${PDF_REQUEST_TYPE}`); + } + const request = payload.value as WebviewPdfPayload; + if (!request?.id?.trim() || !request.path?.trim()) { + throw new Error("WebView PDF requires non-empty id and path"); + } + return request; +} + class WebviewScriptResponse { readonly result: string | null; @@ -364,8 +525,8 @@ let nextWebviewNativeTagId = 1; */ function ensureWebEngineInitialized(context: BridgePluginContext): void { if (!webEngineInitialized) { - // Seal and validate every module's declarations before any module mutates ArkWeb's global - // scheme registry. + // Seal and validate every active facade's declarations before ArkWeb's global scheme + // registry is mutated. const sealedSchemes = notifyWebEngineProcessWide( context, "seal-engine-schemes", @@ -382,8 +543,8 @@ function ensureWebEngineInitialized(context: BridgePluginContext): void { webEngineSchemes = sealedSchemes; webEngineInitialized = true; } - // ArkWeb is process-global, while every native module has independent Rust static state. - // Notify every active module, including those whose DefaultXComponent has not appeared yet. + // ArkWeb is process-global. Notify every active Rust facade, including those whose UI context + // has not appeared yet. notifyWebEngineProcessWide( context, "engine-initialized", @@ -776,8 +937,8 @@ function BuildWebview(data: ManagedWebview) { const webviewBuilder = wrapBuilder<[ManagedWebview]>(BuildWebview); /** - * One surface per WebviewPlugin instance (per session and module). It owns no root node of its - * own: every WebView is mounted either into this module's component root (full-bleed default) or + * One surface per WebviewPlugin instance. It owns no root node of its own: every WebView is + * mounted either into this Host's component root (full-bleed default) or * under a caller-provided container handle from the built-in ohos.node plugin. */ class WebviewSurface { @@ -932,6 +1093,137 @@ class WebviewSurface { return true; } + setBounds(id: string, x: number, y: number, width: number, height: number): boolean { + const entry = this.entries.get(id); + if (!entry) { + return false; + } + entry.data.style.x = x; + entry.data.style.y = y; + entry.data.style.width = width; + entry.data.style.height = height; + entry.node.update(entry.data); + return true; + } + + async setCookie( + id: string, + url: string, + value: string, + callContext: BridgeCallContext, + ): Promise { + await this.controllerEntry(id, callContext); + // `configCookieSync` requires API 14; on older devices the runtime check rejects the call. + // oxlint-disable-next-line arkts/system-api-version + const cookieApi = webview.WebCookieManager as ESObject as CookieApi; + if (typeof cookieApi.configCookieSync !== "function") { + throw new Error("WebCookieManager.configCookieSync is unavailable on this API level"); + } + try { + cookieApi.configCookieSync(url, value); + return true; + } catch (error) { + throw new Error(`Unable to set WebView cookie: ${String(error)}`); + } + } + + async snapshot(id: string, callContext: BridgeCallContext): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + // getWebPageSnapshot is not in the API 12 controller surface; resolved dynamically. + const result: ESObject = (await ( + entry.data.controller as ESObject + ).getWebPageSnapshot()) as ESObject; + return new WebviewSnapshotResponse( + result.rgba as Uint8Array, + result.width as number, + result.height as number, + ); + } catch (error) { + throw new Error(`Unable to capture WebView snapshot: ${String(error)}`); + } + } + + async createPdf( + id: string, + path: string, + config: PdfConfig | null, + callContext: BridgeCallContext, + ): Promise { + const entry = await this.controllerEntry(id, callContext); + try { + // ArkWeb requires all six page geometry fields. Use its documented A4 example as defaults. + const pdfConfig: ESObject = { + width: config?.width ?? 8.27, + height: config?.height ?? 11.69, + marginTop: config?.marginTop ?? 0, + marginBottom: config?.marginBottom ?? 0, + marginLeft: config?.marginLeft ?? 0, + marginRight: config?.marginRight ?? 0, + }; + if (config?.scale !== undefined && config?.scale !== null) { + pdfConfig.scale = config.scale; + } + if (config?.shouldPrintBackground !== undefined && config?.shouldPrintBackground !== null) { + pdfConfig.shouldPrintBackground = config.shouldPrintBackground; + } + const controller = entry.data.controller as ESObject as PdfControllerApi; + if (controller.createPdf === undefined) { + throw new Error("PDF export is unavailable on this ArkWeb API level"); + } + const pdfData = await controller.createPdf(pdfConfig); + if (!callContext.isActive()) { + throw new Error("WebView PDF export was cancelled before file creation"); + } + if (typeof pdfData.pdfArrayBuffer !== "function") { + throw new Error("createPdf returned an invalid PDF data object"); + } + const bytes = pdfData.pdfArrayBuffer(); + if (bytes.byteLength === 0) { + throw new Error("createPdf returned an empty PDF document"); + } + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const file = fs.openSync( + path, + fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC, + ); + try { + const written = await fs.write(file.fd, buffer); + if (written !== bytes.byteLength) { + throw new Error(`PDF file write was incomplete: ${written}/${bytes.byteLength} bytes`); + } + } finally { + fs.closeSync(file); + } + return true; + } catch (error) { + throw new Error(`Unable to create WebView PDF: ${String(error)}`); + } + } + + async setDebuggingAccess( + id: string, + enabled: boolean, + callContext: BridgeCallContext, + ): Promise { + await this.controllerEntry(id, callContext); + const debuggingApi = webview.WebviewController as ESObject as WebviewDebuggingApi; + if (typeof debuggingApi.setWebDebuggingAccess !== "function") { + throw new Error("Web debugging access is unavailable on this ArkWeb API level"); + } + debuggingApi.setWebDebuggingAccess(enabled); + return true; + } + + async isDebuggingAccess(id: string, callContext: BridgeCallContext): Promise { + await this.controllerEntry(id, callContext); + const debuggingApi = webview.WebviewController as ESObject as WebviewDebuggingApi; + if (typeof debuggingApi.getWebDebuggingAccess !== "function") { + return false; + } + return debuggingApi.getWebDebuggingAccess(); + } + async getUrl(id: string, callContext: BridgeCallContext): Promise { const entry = await this.controllerEntry(id, callContext); try { @@ -1078,11 +1370,11 @@ class WebviewSurface { } 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}'`); } + this.assertCallActive(callContext); await entry.waitForController(callContext.onCancel); this.assertCallActive(callContext); return entry; @@ -1106,7 +1398,7 @@ export class WebviewPlugin extends AsyncPluginBase { override onLifecycle(event: BridgeLifecycleEvent, _context: BridgePluginHookContext): void { if (event.kind === "ui-context-destroy") { - // BridgeHost already detached this module's component and owns its node-tree cleanup. + // BridgeHost already detached this Host's component and owns its node-tree cleanup. this.surface?.dispose(false); this.surface = undefined; return; @@ -1148,6 +1440,57 @@ export class WebviewPlugin extends AsyncPluginBase { }; } + if (action === "set-bounds") { + const bounds = parseBoundsPayload(payload); + const accepted = surface.setBounds( + bounds.id, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + ); + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new WebviewAcceptedResponse(accepted) }; + } + if (action === "set-cookie") { + const cookie = parseCookiePayload(payload); + const accepted = await surface.setCookie(cookie.id, cookie.url, cookie.value, context); + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new WebviewAcceptedResponse(accepted) }; + } + if (action === "snapshot") { + const snapshotRequest = parseSnapshotPayload(payload); + const snapshot = await surface.snapshot(snapshotRequest.id, context); + return { typeName: SNAPSHOT_RESPONSE_TYPE, value: snapshot }; + } + if (action === "set-debugging-access") { + const debugRequest = parseBoolPayload(payload); + if (typeof debugRequest.enabled !== "boolean") { + throw new Error("set-debugging-access.enabled must be a boolean"); + } + const accepted = await surface.setDebuggingAccess( + debugRequest.id, + debugRequest.enabled, + context, + ); + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new WebviewAcceptedResponse(accepted) }; + } + if (action === "is-debugging-access") { + const debugRequest = parseBoolPayload(payload); + return { + typeName: BOOL_RESPONSE_TYPE, + value: new WebviewBoolResponse(await surface.isDebuggingAccess(debugRequest.id, context)), + }; + } + if (action === "create-pdf") { + const pdfRequest = parsePdfPayload(payload); + const accepted = await surface.createPdf( + pdfRequest.id, + pdfRequest.path, + pdfRequest.config ?? null, + context, + ); + return { typeName: ACKNOWLEDGEMENT_TYPE, value: new WebviewAcceptedResponse(accepted) }; + } + const controllerPayload = parseControllerPayload(payload, action); if (!controllerPayload?.id?.trim()) { throw new Error("WebView controller id must not be empty"); diff --git a/rust_example/demo_native/Cargo.toml b/rust_example/demo_native/Cargo.toml index e4814700..a907ac10 100755 --- a/rust_example/demo_native/Cargo.toml +++ b/rust_example/demo_native/Cargo.toml @@ -14,16 +14,23 @@ napi-derive-ohos = { workspace = true } openharmony-ability = { workspace = true } openharmony-ability-derive = { workspace = true } openharmony-ability-plugin-app-control = { workspace = true } +openharmony-ability-plugin-autostart = { workspace = true } +openharmony-ability-plugin-clipboard = { workspace = true } openharmony-ability-plugin-files = { workspace = true } +openharmony-ability-plugin-menu = { workspace = true } openharmony-ability-plugin-permission = { workspace = true } openharmony-ability-plugin-resource = { workspace = true } +openharmony-ability-plugin-statusbar = { workspace = true } +openharmony-ability-plugin-updater = { workspace = true } openharmony-ability-plugin-url = { workspace = true } +openharmony-ability-plugin-version = { workspace = true } openharmony-ability-plugin-webview = { workspace = true } openharmony-ability-plugin-window = { workspace = true } ohos-hilog-binding = { workspace = true } futures-channel = { workspace = true } futures-executor = "0.3" http = { workspace = true } +log = { workspace = true } [build-dependencies] napi-build-ohos = { workspace = true } diff --git a/rust_example/demo_native/src/lib.rs b/rust_example/demo_native/src/lib.rs index 55334dcd..ea9a42fb 100755 --- a/rust_example/demo_native/src/lib.rs +++ b/rust_example/demo_native/src/lib.rs @@ -19,19 +19,28 @@ use napi_ohos::{Either, Env, Error, Result}; use ohos_hilog_binding::hilog_info; use openharmony_ability::{Event, InputEvent, NodeExt, OpenHarmonyApp}; use openharmony_ability_derive::ability; -use openharmony_ability_plugin_app_control::AppControlBridgePlugin; +use openharmony_ability_plugin_app_control::{AppControlBridgePlugin, AppControlExt}; +use openharmony_ability_plugin_autostart::{AutostartBridgePlugin, AutostartExt}; +use openharmony_ability_plugin_clipboard::{ClipboardBridgePlugin, ClipboardExt}; use openharmony_ability_plugin_files::{ - dialog_type, FileDialogFilter, FileDialogOptions, FilesExt, + dialog_type, FileDialogFilter, FileDialogOptions, FilesBridgePlugin, FilesExt, }; +use openharmony_ability_plugin_menu::{item_kind, MenuBridgePlugin, MenuExt, MenuItemData}; use openharmony_ability_plugin_permission::{PermissionBridgePlugin, PermissionExt}; use openharmony_ability_plugin_resource::{ResourceBridgePlugin, ResourceExt}; -use openharmony_ability_plugin_url::UrlExt; +use openharmony_ability_plugin_statusbar::{ + QuickOperationData, StatusBarBridgePlugin, StatusBarExt, StatusBarIconData, StatusBarItemData, + StatusBarMenuActionData, StatusBarMenuItemData, +}; +use openharmony_ability_plugin_updater::{UpdaterBridgePlugin, UpdaterExt}; +use openharmony_ability_plugin_url::{UrlBridgePlugin, UrlExt}; +use openharmony_ability_plugin_version::{VersionBridgePlugin, VersionExt}; use openharmony_ability_plugin_webview::{ WebviewBridgePlugin, WebviewCallbacksBuilder, WebviewClient, WebviewCreateRequest, WebviewDownloadStartResponse, WebviewExt, WebviewJavascriptProxyBuilder, WebviewProtocol, WebviewProtocolOptions, WebviewStyle, }; -use openharmony_ability_plugin_window::WindowBridgePlugin; +use openharmony_ability_plugin_window::{WindowBridgePlugin, WindowExt}; static INNER_APP: LazyLock>> = LazyLock::new(|| RwLock::new(None)); static PERMISSION_REQUESTED: AtomicBool = AtomicBool::new(false); @@ -373,19 +382,28 @@ fn openharmony_app(app: OpenHarmonyApp) { .expect("demo permission facade must be registered"); app.register_plugin(AppControlBridgePlugin) .expect("demo app-control facade must be registered"); + app.register_plugin(AutostartBridgePlugin) + .expect("demo autostart facade must be registered"); app.register_plugin(WebviewBridgePlugin) .expect("demo WebView facade must be registered"); app.register_plugin(WindowBridgePlugin) .expect("demo Window facade must be registered"); - if let Err(error) = app.register_plugin(openharmony_ability_plugin_url::UrlBridgePlugin) { - hilog_info!(format!("failed to register url facade: {error}").as_str()); - } - if let Err(error) = app.register_plugin(openharmony_ability_plugin_files::FilesBridgePlugin) { - hilog_info!(format!("failed to register files facade: {error}").as_str()); - } - if let Err(error) = app.register_plugin(ResourceBridgePlugin::new()) { - hilog_info!(format!("failed to register resource facade: {error}").as_str()); - } + app.register_plugin(ClipboardBridgePlugin) + .expect("demo clipboard facade must be registered"); + app.register_plugin(FilesBridgePlugin) + .expect("demo files facade must be registered"); + app.register_plugin(MenuBridgePlugin) + .expect("demo menu facade must be registered"); + app.register_plugin(StatusBarBridgePlugin) + .expect("demo statusbar facade must be registered"); + app.register_plugin(UpdaterBridgePlugin) + .expect("demo updater facade must be registered"); + app.register_plugin(UrlBridgePlugin) + .expect("demo URL facade must be registered"); + app.register_plugin(VersionBridgePlugin) + .expect("demo version facade must be registered"); + app.register_plugin(ResourceBridgePlugin::new()) + .expect("demo resource facade must be registered"); hilog_info!(format!( "init context => module={:?}, base={:?}, pref={:?}, locales={:?}", app.module_name(), @@ -447,6 +465,162 @@ fn openharmony_app(app: OpenHarmonyApp) { }); } +/// PR #63 capability demo: menu system (menubar + popup) through `ohos.menu`. +#[napi] +pub async fn demo_menu_set_menubar() -> Result<()> { + let file_menu = MenuItemData::new("demo.file", item_kind::SUBMENU) + .text("File") + .submenu(vec![ + MenuItemData::new("demo.file.open", item_kind::NORMAL).text("Open"), + MenuItemData::new("demo.file.sep", item_kind::SEPARATOR), + MenuItemData::new("demo.file.quit", item_kind::PREDEFINED) + .predefined("quit") + .accelerator("Ctrl+Q"), + ]); + let edit_menu = MenuItemData::new("demo.edit", item_kind::SUBMENU) + .text("Edit") + .submenu(vec![ + MenuItemData::new("demo.edit.copy", item_kind::PREDEFINED) + .predefined("copy") + .accelerator("Ctrl+C"), + MenuItemData::new("demo.edit.paste", item_kind::PREDEFINED) + .predefined("paste") + .accelerator("Ctrl+V"), + ]); + current_app()? + .set_menubar("main", vec![file_menu, edit_menu]) + .await +} + +/// PR #63 capability demo: context popup menu at given coordinates. +#[napi] +pub async fn demo_menu_popup(x: f64, y: f64) -> Result<()> { + let items = vec![ + MenuItemData::new("demo.popup.refresh", item_kind::NORMAL).text("Refresh"), + MenuItemData::new("demo.popup.sep", item_kind::SEPARATOR), + MenuItemData::new("demo.popup.inspect", item_kind::NORMAL).text("Inspect"), + ]; + current_app()?.popup("main", Some(x), Some(y), items).await +} + +/// PR #63 capability demo: system tray item with menu groups. +#[napi] +pub async fn demo_statusbar_add() -> Result<()> { + // 16x16 RGBA white icon. + let rgba: Vec = vec![255u8; 16 * 16 * 4]; + let item = StatusBarItemData { + icons: StatusBarIconData { + white_rgba: Some(rgba), + black_rgba: None, + size: 16, + }, + quick_operation: QuickOperationData { + ability_name: "EntryAbility".to_owned(), + title: "demo app".to_owned(), + height: 200, + module_name: None, + loading_status: None, + }, + status_bar_group_menu: Some(vec![vec![StatusBarMenuItemData { + title: "Open demo".to_owned(), + menu_code: Some("demo.open".to_owned()), + sub_menu: None, + menu_action: Some(StatusBarMenuActionData { + ability_name: "EntryAbility".to_owned(), + module_name: None, + menu_code: None, + notify_only: Some(true), + }), + selected: None, + icon_rgba: None, + icon_width: None, + icon_height: None, + }]]), + hover_tips: Some("demo tray".to_owned()), + }; + current_app()?.add_to_status_bar(item).await +} + +/// PR #63 capability demo: device version + capability queries. +#[napi] +pub fn demo_version_info(env: &Env) -> Result { + let app = current_app()?; + let sdk = app.sdk_api_version(env)?; + let dist = app.distribution_api_version(env)?; + let desktop = app.is_desktop_device(env)?; + let can_use = app.can_i_use(env, "SystemCapability.Window.SessionManager")?; + Ok(format!( + "sdk={sdk}, dist={dist}, desktop={desktop}, window-session={can_use}" + )) +} + +/// PR #63 capability demo: clipboard image write. +#[napi] +pub async fn demo_clipboard_write_image() -> Result<()> { + // 2x2 opaque white RGBA block. + let rgba = vec![255u8; 2 * 2 * 4]; + current_app()?.write_image(rgba, 2, 2).await +} + +/// PR #63 capability demo: query the API 21+ autostart state. +#[napi] +pub async fn demo_autostart_is_enabled() -> Result { + current_app()?.autostart()?.is_enabled().await +} + +/// PR #63 capability demo: open the system app-launch management page. +#[napi] +pub async fn demo_autostart_open_settings() -> Result<()> { + current_app()?.autostart()?.open_settings().await +} + +/// PR #63 capability demo: AppGallery update check. +#[napi] +pub async fn demo_updater_check() -> Result> { + let result = current_app()?.check().await?; + Ok(result.map(|r| format!("{} -> {}", r.current_version, r.version))) +} + +/// PR #63 capability demo: create an OS sub-window through `ohos.window`. +#[napi] +pub async fn demo_create_os_window() -> Result { + let request = openharmony_ability_plugin_window::WindowCreateRequest { + name: "demo_sub".to_owned(), + width: 480, + height: 320, + x: 200, + y: 200, + decorations: true, + transparent: false, + background_color: None, + }; + current_app()?.window()?.create_os_window(request).await +} + +/// PR #63 capability demo: destroy one OS sub-window created by `demo_create_os_window`. +#[napi] +pub async fn demo_destroy_os_window(window_id: i64) -> Result<()> { + current_app()?.window()?.destroy_window(window_id).await +} + +/// PR #63 capability demo: remove the process-level status bar item. +#[napi] +pub async fn demo_statusbar_remove() -> Result<()> { + current_app()?.remove_from_status_bar().await +} + +/// PR #63 capability demo: restart the app (cooldown 3s). +#[napi] +pub fn demo_restart(env: &Env) -> Result<()> { + current_app()?.restart(env) +} + +/// PR #63 capability demo: switch color mode (0 dark / 1 light / 2 system). +#[napi] +pub fn demo_set_color_mode(env: &Env, mode: i32) -> Result<()> { + current_app()?.set_color_mode(env, mode) +} + /// PR #65 capability demo: open an external URL through `ohos.url`. #[napi] pub async fn demo_open_url() -> Result<()> { diff --git a/rust_example/demo_sub_native/Cargo.toml b/rust_example/demo_sub_native/Cargo.toml index f2591273..32ae2e1b 100644 --- a/rust_example/demo_sub_native/Cargo.toml +++ b/rust_example/demo_sub_native/Cargo.toml @@ -8,6 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] +log = { workspace = true } napi-ohos = { workspace = true, features = ["tokio_rt"] } napi-derive-ohos = { workspace = true } openharmony-ability = { workspace = true }