From 1f71e11754710bd667245b8e1644492d75827ef5 Mon Sep 17 00:00:00 2001 From: SWangHash <88996709+SWangHash@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:38:18 +0800 Subject: [PATCH 1/5] feat(browser): enable embedded browser on HarmonyOS PC The OHOS branches of browser_webview_create/eval/navigate/reload/set_bounds were stubs returning Err, and Webview.getByLabel returns null on OHOS, so the built-in BrowserScene could not load any URL. Implement the full embedded path using the ArkUI Web component. - Vendor @ohos-rs/ability@0.4.0-beta.0 into src/apps/ohos/oh-rs-ability/ and switch oh-package.json5 to a file: dependency. Extend WebBuilder with width/height support and onPageBegin/onPageFinish callbacks; expose the private RustWebviewNodeController via AppStorage from DefaultXComponent.aboutToAppear; add removeWebview for clean teardown. - Add BrowserWebviewService.ets singleton owning a Map and implementing create/eval/navigate/reload/setBounds/show/hide/close/ setFocus. Page-load callbacks forward to the web-ui via the new #[napi] emit_browser_page_load (mirrors notify_system_color_mode). - Rewrite the #[cfg(target_env = ohos)] branches of the five existing browser_webview_* commands in browser_api.rs to call ArkTS functions via JS_THREADSAFE_FUNCTION (same pattern as ohos_speech_call). Add four new OHOS-only commands (browser_webview_show/hide/close/set_focus) and register them in lib.rs generate_handler. - Frontend useEmbeddedBrowserWebview.ts falls back to a command-based BrowserWebviewHandle when Webview.getByLabel returns null, so the existing close/hide/show/setFocus handle pattern works on OHOS without changing the desktop path. - Flip SHOW_BROWSER_ENTRY to true in PersistentFooterActions.tsx so the Globe entry button appears in the nav footer. Verified: arkts_check passes on all 4 modified .ets files; hvigor build_project entry@default BUILD SUCCESSFUL; tsc --noEmit 0 errors. Rust cargo check could not run locally (no MSVC linker / OHOS toolchain) but follows existing ohos_speech_call / open_browser patterns exactly. # Conflicts: # src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets # Conflicts: # src/crates/assembly/core/src/util/register_arkts_function.rs --- src/apps/desktop/src/api/browser_api.rs | 253 ++++++++++++-- src/apps/desktop/src/lib.rs | 4 + src/apps/ohos/entry/oh-package-lock.json5 | 9 +- src/apps/ohos/entry/oh-package.json5 | 2 +- .../types/libbitfun_desktop_lib/Index.d.ts | 3 +- .../main/ets/entryability/EntryAbility.ets | 50 +++ .../ets/services/BrowserWebviewService.ets | 263 +++++++++++++++ src/apps/ohos/oh-rs-ability/CHANGELOG.md | 66 ++++ src/apps/ohos/oh-rs-ability/LICENSE | 21 ++ src/apps/ohos/oh-rs-ability/README.md | 137 ++++++++ src/apps/ohos/oh-rs-ability/index.ets | 4 + src/apps/ohos/oh-rs-ability/oh-package.json5 | 18 + .../src/main/ets/ability/RustAbility.ets | 89 +++++ .../src/main/ets/ability/type.ets | 69 ++++ .../main/ets/components/DefaultXComponent.ets | 92 +++++ .../src/main/ets/components/MainPage.ets | 15 + .../src/main/ets/helper/index.ets | 5 + .../src/main/ets/helper/loadable.ets | 28 ++ .../src/main/ets/helper/object.ts | 11 + .../oh-rs-ability/src/main/ets/helper/os.ets | 9 + .../src/main/ets/helper/random.ets | 9 + .../src/main/ets/webview/DefaultWebview.ets | 314 ++++++++++++++++++ .../src/main/ets/webview/Utils.ets | 19 ++ .../ohos/oh-rs-ability/src/main/module.json5 | 11 + .../core/src/util/register_arkts_function.rs | 58 +++- .../components/PersistentFooterActions.tsx | 2 +- .../browser/useEmbeddedBrowserWebview.ts | 40 ++- 27 files changed, 1558 insertions(+), 43 deletions(-) create mode 100644 src/apps/ohos/entry/src/main/ets/services/BrowserWebviewService.ets create mode 100644 src/apps/ohos/oh-rs-ability/CHANGELOG.md create mode 100644 src/apps/ohos/oh-rs-ability/LICENSE create mode 100644 src/apps/ohos/oh-rs-ability/README.md create mode 100644 src/apps/ohos/oh-rs-ability/index.ets create mode 100644 src/apps/ohos/oh-rs-ability/oh-package.json5 create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/ability/RustAbility.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/ability/type.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/components/DefaultXComponent.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/components/MainPage.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/helper/index.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/helper/loadable.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/helper/object.ts create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/helper/os.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/helper/random.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/webview/DefaultWebview.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/ets/webview/Utils.ets create mode 100644 src/apps/ohos/oh-rs-ability/src/main/module.json5 diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index a2b48ff8d2..319404df0d 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -2,6 +2,18 @@ //! //! Browser webviews are created as native child webviews by this desktop //! adapter so stream-specific initialization can run before page scripts. +//! +//! On OHOS the desktop child-webview APIs (`app.get_webview(label)`, +//! `window.add_child(...)`) are unavailable, so the `browser_webview_*` +//! commands route through ArkTS callbacks registered by +//! `EntryAbility.onWindowStageCreate` (see `BrowserWebviewService.ets`). +//! The Rust side serializes the request to JSON, calls the registered +//! `ThreadsafeFunction` via `JS_THREADSAFE_FUNCTION`, awaits the returned +//! `Promise` envelope (`{ok:true}` / `{ok:true,result:"..."}` / +//! `{error:"..."}`), and decodes it uniformly. Page-load lifecycle is +//! forwarded back to the web-ui by the ArkTS service calling the +//! `emit_browser_page_load` `#[napi]` function (mirrors the desktop +//! `.on_page_load` handler in `lib.rs`). use serde::Deserialize; use tauri::Manager; @@ -66,6 +78,7 @@ fn find_browser_webview(app: &tauri::AppHandle, label: &str) -> Result Result<() } } +// #region OHOS ArkTS bridge helpers +// Only compiled on the OHOS target. On desktop the child-webview APIs are used +// directly (no ArkTS bridge involved). + +#[cfg(target_env = "ohos")] +async fn ohos_browser_call(name: &str, json_arg: &str) -> Result { + use bitfun_core::util::JS_THREADSAFE_FUNCTION; + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get(name).cloned() + }; + let Some(function) = function else { + return Err(format!("{name} has not been registered by ArkTS")); + }; + let res = function.call_async(Ok(json_arg.to_string())).await; + match res { + Ok(promise) => match promise.await { + Ok(json) => Ok(json), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +/// Decode the JSON envelope returned by `BrowserWebviewService` methods. +/// - `{ok:true}` / `{ok:true,result:"..."}` → `Ok(())` +/// - `{error:"..."}` → `Err(error)` +/// Anything else surfaces as an unexpected-response error so the frontend's +/// `setError` shows a meaningful message instead of a silent failure. +#[cfg(target_env = "ohos")] +fn decode_ok_envelope(response: &str) -> Result<(), String> { + let value: serde_json::Value = serde_json::from_str(response) + .map_err(|e| format!("invalid json response from ArkTS: {e}: {response}"))?; + if value.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + Ok(()) + } else if let Some(error) = value.get("error").and_then(|v| v.as_str()) { + Err(error.to_owned()) + } else { + Err(format!("unexpected response from ArkTS: {response}")) + } +} + +/// Shared URL-scheme validation for create / navigate. Returns the parsed +/// `tauri::Url` so the caller can re-use it on desktop or discard it on OHOS. +fn parse_browser_url(raw: &str) -> Result { + let url = raw + .parse::() + .map_err(|e| format!("invalid url: {e}"))?; + match url.scheme() { + "http" | "https" => Ok(url), + scheme => Err(format!("unsupported protocol: {scheme}")), + } +} +// #endregion + #[tauri::command] pub async fn browser_webview_create( app: tauri::AppHandle, @@ -139,14 +207,7 @@ pub async fn browser_webview_create( validate_browser_label(&request.label)?; validate_webview_bounds(request.x, request.y, request.width, request.height)?; - let url = request - .url - .parse::() - .map_err(|e| format!("invalid url: {e}"))?; - match url.scheme() { - "http" | "https" => {} - scheme => return Err(format!("unsupported protocol: {scheme}")), - } + let url = parse_browser_url(&request.url)?; let window = app .get_window("main") @@ -163,20 +224,28 @@ pub async fn browser_webview_create( } let webview = window - .add_child( - builder, - tauri::LogicalPosition::new(request.x, request.y), - tauri::LogicalSize::new(request.width, request.height), - ) - .map_err(|e| format!("failed to create browser webview: {e}"))?; + .add_child( + builder, + tauri::LogicalPosition::new(request.x, request.y), + tauri::LogicalSize::new(request.width, request.height), + ) + .map_err(|e| format!("failed to create browser webview: {e}"))?; webview .hide() .map_err(|e| format!("failed to hide browser webview before positioning: {e}")) } + #[cfg(target_env = "ohos")] { - Err("Unable to find browser webview".to_string()) + let _ = app; + validate_browser_label(&request.label)?; + validate_webview_bounds(request.x, request.y, request.width, request.height)?; + let _ = parse_browser_url(&request.url)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_create_ohos", &json).await?; + decode_ok_envelope(&response) } } @@ -194,7 +263,12 @@ pub async fn browser_webview_eval( #[cfg(target_env = "ohos")] { - Err("Unable to find browser webview".to_string()) + let _ = app; + validate_browser_label(&request.label)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_eval_ohos", &json).await?; + decode_ok_envelope(&response) } } @@ -204,19 +278,26 @@ pub async fn browser_webview_navigate( app: tauri::AppHandle, request: WebviewNavigateRequest, ) -> Result<(), String> { - let url = request - .url - .parse::() - .map_err(|e| format!("invalid url: {e}"))?; + let _ = parse_browser_url(&request.url)?; - match url.scheme() { - "http" | "https" => {} - scheme => return Err(format!("unsupported protocol: {scheme}")), + #[cfg(not(target_env = "ohos"))] + { + let url = request.url.parse::() + .map_err(|e| format!("invalid url: {e}"))?; + find_browser_webview(&app, &request.label)? + .navigate(url) + .map_err(|e| format!("navigate failed: {e}")) } - find_browser_webview(&app, &request.label)? - .navigate(url) - .map_err(|e| format!("navigate failed: {e}")) + #[cfg(target_env = "ohos")] + { + let _ = app; + validate_browser_label(&request.label)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_navigate_ohos", &json).await?; + decode_ok_envelope(&response) + } } #[derive(Debug, Deserialize)] @@ -230,9 +311,22 @@ pub async fn browser_webview_reload( app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result<(), String> { - find_browser_webview(&app, &request.label)? - .reload() - .map_err(|e| format!("reload failed: {e}")) + #[cfg(not(target_env = "ohos"))] + { + find_browser_webview(&app, &request.label)? + .reload() + .map_err(|e| format!("reload failed: {e}")) + } + + #[cfg(target_env = "ohos")] + { + let _ = app; + validate_browser_label(&request.label)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_reload_ohos", &json).await?; + decode_ok_envelope(&response) + } } #[tauri::command] @@ -244,9 +338,9 @@ pub async fn browser_webview_set_bounds( { validate_webview_bounds(request.x, request.y, request.width, request.height)?; - let webview = app - .get_webview(&request.label) - .ok_or_else(|| format!("Webview not found: {}", request.label))?; + let webview = app + .get_webview(&request.label) + .ok_or_else(|| format!("Webview not found: {}", request.label))?; webview .set_bounds(tauri::Rect { @@ -258,9 +352,102 @@ pub async fn browser_webview_set_bounds( #[cfg(target_env = "ohos")] { - Err("invalid webview bounds".to_string()) + let _ = app; + validate_browser_label(&request.label)?; + validate_webview_bounds(request.x, request.y, request.width, request.height)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_set_bounds_ohos", &json).await?; + decode_ok_envelope(&response) + } + +} + +// OHOS-only commands for handle operations that desktop resolves via +// `Webview.getByLabel(label)` from `@tauri-apps/api/webview`. The frontend's +// `useEmbeddedBrowserWebview` falls back to these when `getByLabel` returns +// null (the OHOS case — Tauri's webview registry doesn't track ArkUI Web +// components). On desktop these commands are never invoked because +// `getByLabel` returns a real handle. + +#[tauri::command] +pub async fn browser_webview_show( + _app: tauri::AppHandle, + request: WebviewLabelRequest, +) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + Err("browser_webview_show is OHOS-only".to_string()) + } + #[cfg(target_env = "ohos")] + { + validate_browser_label(&request.label)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_show_ohos", &json).await?; + decode_ok_envelope(&response) + } +} + +#[tauri::command] +pub async fn browser_webview_hide( + _app: tauri::AppHandle, + request: WebviewLabelRequest, +) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + Err("browser_webview_hide is OHOS-only".to_string()) + } + #[cfg(target_env = "ohos")] + { + validate_browser_label(&request.label)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_hide_ohos", &json).await?; + decode_ok_envelope(&response) + } +} + +#[tauri::command] +pub async fn browser_webview_close( + _app: tauri::AppHandle, + request: WebviewLabelRequest, +) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + Err("browser_webview_close is OHOS-only".to_string()) + } + #[cfg(target_env = "ohos")] + { + validate_browser_label(&request.label)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_close_ohos", &json).await?; + decode_ok_envelope(&response) } +} +#[tauri::command] +pub async fn browser_webview_set_focus( + _app: tauri::AppHandle, + request: WebviewLabelRequest, +) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + Err("browser_webview_set_focus is OHOS-only".to_string()) + } + #[cfg(target_env = "ohos")] + { + validate_browser_label(&request.label)?; + let json = serde_json::to_string(&request) + .map_err(|e| format!("failed to encode request: {e}"))?; + let response = ohos_browser_call("browser_webview_set_focus_ohos", &json).await?; + decode_ok_envelope(&response) + } } /// Return the current URL of a browser webview. diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 16c5d2777c..ecb7bafa5f 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1571,6 +1571,10 @@ pub async fn _run() { api::browser_api::browser_webview_navigate, api::browser_api::browser_webview_reload, api::browser_api::browser_webview_set_bounds, + api::browser_api::browser_webview_show, + api::browser_api::browser_webview_hide, + api::browser_api::browser_webview_close, + api::browser_api::browser_webview_set_focus, api::browser_api::browser_get_url, // Browser Control API (CDP-based user browser control) api::browser_control_api::browser_control_list_browsers, diff --git a/src/apps/ohos/entry/oh-package-lock.json5 b/src/apps/ohos/entry/oh-package-lock.json5 index d09758325d..89b21b5150 100644 --- a/src/apps/ohos/entry/oh-package-lock.json5 +++ b/src/apps/ohos/entry/oh-package-lock.json5 @@ -6,17 +6,16 @@ "lockfileVersion": 3, "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { - "@ohos-rs/ability@0.4.0-beta.0": "@ohos-rs/ability@0.4.0-beta.0", + "@ohos-rs/ability@../oh-rs-ability": "@ohos-rs/ability@../oh-rs-ability", "libbitfun_desktop_lib.so@src/main/cpp/types/libbitfun_desktop_lib": "libbitfun_desktop_lib.so@src/main/cpp/types/libbitfun_desktop_lib", "libentry.so@src/main/cpp/types/libentry": "libentry.so@src/main/cpp/types/libentry" }, "packages": { - "@ohos-rs/ability@0.4.0-beta.0": { + "@ohos-rs/ability@../oh-rs-ability": { "name": "@ohos-rs/ability", "version": "0.4.0-beta.0", - "integrity": "sha512-3jXF0SzSqdyIEcWZy+2i/LWueVEFuLB9J3hYDiNDrL6guTMDqojMy5o9svD6pHEpfjnU+T7058bRTjGD2+iohA==", - "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos-rs/ability/-/ability-0.4.0-beta.0.har", - "registryType": "ohpm" + "resolved": "../oh-rs-ability", + "registryType": "local" }, "libbitfun_desktop_lib.so@src/main/cpp/types/libbitfun_desktop_lib": { "name": "libbitfun_desktop_lib.so", diff --git a/src/apps/ohos/entry/oh-package.json5 b/src/apps/ohos/entry/oh-package.json5 index 3fe6a279b3..fda7cecab0 100644 --- a/src/apps/ohos/entry/oh-package.json5 +++ b/src/apps/ohos/entry/oh-package.json5 @@ -8,7 +8,7 @@ "dependencies": { "libbitfun_desktop_lib.so": "file:./src/main/cpp/types/libbitfun_desktop_lib", "libentry.so": "file:./src/main/cpp/types/libentry", - "@ohos-rs/ability": "0.4.0-beta.0" + "@ohos-rs/ability": "file:../oh-rs-ability" } } diff --git a/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts b/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts index 047a7eb1ae..b93e9563c4 100644 --- a/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts +++ b/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts @@ -2,4 +2,5 @@ export declare function registerArktsFunction(funcName: string, callback: ((err: export declare function setBuildResult(msg: string): void; export declare function ohosMarkCleanShutdown(): void; export declare function getAppConfigBool(path: string): boolean; -export declare function notifySystemColorMode(mode: string): void; \ No newline at end of file +export declare function notifySystemColorMode(mode: string): void; +export declare function emitBrowserPageLoad(label: string, event: string, url: string): void; \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets index f370d7f64d..d2de519a23 100644 --- a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -20,6 +20,14 @@ import { CommonUtils } from '../utils/CommonUtils'; import { runDeveco } from '../utils/DevecoStart'; import { VoiceInputService } from '../services/VoiceInputService'; import { FeedbackCredentialStore } from '../utils/FeedbackCredentialStore'; +import { + BrowserWebviewService, + CreateRequest, + EvalRequest, + NavigateRequest, + BoundsRequest, + LabelRequest, +} from '../services/BrowserWebviewService'; const DOMAIN = 0x0000; @@ -220,6 +228,48 @@ export default class EntryAbility extends RustAbility { }); return ''; }); + // Embedded browser webview bridge: each ArkTS callback parses a JSON-string + // request from the Rust side (browser_api.rs OHOS branches) and delegates to + // BrowserWebviewService, returning a JSON-string envelope. The service owns + // a Map and the RustWebviewNodeController + // exposed via AppStorage by the vendored DefaultXComponent. + const browserService = BrowserWebviewService.getInstance(); + RustModule.registerArktsFunction('browser_webview_create_ohos', async (err: Error, arg: string): Promise => { + const req: CreateRequest = JSON.parse(arg); + return await browserService.create(req); + }); + RustModule.registerArktsFunction('browser_webview_eval_ohos', async (err: Error, arg: string): Promise => { + const req: EvalRequest = JSON.parse(arg); + return await browserService.eval(req); + }); + RustModule.registerArktsFunction('browser_webview_navigate_ohos', async (err: Error, arg: string): Promise => { + const req: NavigateRequest = JSON.parse(arg); + return await browserService.navigate(req); + }); + RustModule.registerArktsFunction('browser_webview_reload_ohos', async (err: Error, arg: string): Promise => { + const req: LabelRequest = JSON.parse(arg); + return await browserService.reload(req); + }); + RustModule.registerArktsFunction('browser_webview_set_bounds_ohos', async (err: Error, arg: string): Promise => { + const req: BoundsRequest = JSON.parse(arg); + return await browserService.setBounds(req); + }); + RustModule.registerArktsFunction('browser_webview_show_ohos', async (err: Error, arg: string): Promise => { + const req: LabelRequest = JSON.parse(arg); + return await browserService.show(req); + }); + RustModule.registerArktsFunction('browser_webview_hide_ohos', async (err: Error, arg: string): Promise => { + const req: LabelRequest = JSON.parse(arg); + return await browserService.hide(req); + }); + RustModule.registerArktsFunction('browser_webview_close_ohos', async (err: Error, arg: string): Promise => { + const req: LabelRequest = JSON.parse(arg); + return await browserService.close(req); + }); + RustModule.registerArktsFunction('browser_webview_set_focus_ohos', async (err: Error, arg: string): Promise => { + const req: LabelRequest = JSON.parse(arg); + return await browserService.setFocus(req); + }); setTimeout(() => { windowStage.getMainWindow((err, data) => { data.setWindowDecorHeight(44); diff --git a/src/apps/ohos/entry/src/main/ets/services/BrowserWebviewService.ets b/src/apps/ohos/entry/src/main/ets/services/BrowserWebviewService.ets new file mode 100644 index 0000000000..31e7ad9c8f --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/services/BrowserWebviewService.ets @@ -0,0 +1,263 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import RustModule from 'libbitfun_desktop_lib.so'; +import { + RustWebviewNodeController, + WebviewInitData, + WebviewStyle, + JsHelper, +} from '@ohos-rs/ability'; + +const BROWSER_DOMAIN: number = 0x0000; +const BROWSER_TAG: string = 'BitFunBrowserWebview'; + +interface BrowserWebviewEntry { + webTag: string; + controller: JsHelper; + initData: WebviewInitData; +} + +export interface CreateRequest { + label: string; + url: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface EvalRequest { + label: string; + script: string; +} + +export interface NavigateRequest { + label: string; + url: string; +} + +export interface BoundsRequest { + label: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface LabelRequest { + label: string; +} + +interface OkResponse { + ok: boolean; +} + +interface OkResultResponse { + ok: boolean; + result: string; +} + +interface ErrorResponse { + error: string; +} + +/** + * Singleton service that bridges the Rust `browser_webview_*` Tauri commands to + * the ArkUI `RustWebviewNodeController` (vendored `@ohos-rs/ability`). Each + * command registers an ArkTS callback via `RustModule.registerArktsFunction` + * in `EntryAbility.onWindowStageCreate`; the callback parses a JSON-string + * request, delegates to the matching method here, and returns a JSON-string + * envelope (`{ok:true}` / `{ok:true,result:"..."}` / `{error:"..."}`) that the + * Rust side decodes uniformly. + * + * Page-load lifecycle (`started` / `finished`) is forwarded to the web-ui by + * calling `RustModule.emitBrowserPageLoad(label, event, url)` from the + * `onPageBegin` / `onPageEnd` callbacks wired into each created webview. The + * web-ui's `useEmbeddedBrowserWebview` hook listens on the + * `browser-webview-page-load` event (emitted by the Rust `#[napi]` function) + * and updates `isLoading`, the address bar URL, and re-injects the + * `BLANK_TARGET_INTERCEPT_SCRIPT` + `STREAM_RENDER_OPTIMIZATION_SCRIPT`. + */ +export class BrowserWebviewService { + private static instance: BrowserWebviewService | null = null; + private entries: Map = new Map(); + private controller: RustWebviewNodeController | null = null; + + private constructor() {} + + static getInstance(): BrowserWebviewService { + if (BrowserWebviewService.instance === null) { + BrowserWebviewService.instance = new BrowserWebviewService(); + } + return BrowserWebviewService.instance; + } + + private resolveController(): RustWebviewNodeController | null { + if (this.controller !== null) { + return this.controller; + } + const fromStorage = AppStorage.get('rustWebviewController'); + if (fromStorage) { + this.controller = fromStorage; + return this.controller; + } + hilog.warn(BROWSER_DOMAIN, BROWSER_TAG, 'rustWebviewController not yet registered in AppStorage'); + return null; + } + + private errorEnvelope(message: string): string { + const err: ErrorResponse = { error: message }; + return JSON.stringify(err); + } + + private okEnvelope(): string { + const ok: OkResponse = { ok: true }; + return JSON.stringify(ok); + } + + async create(request: CreateRequest): Promise { + const controller = this.resolveController(); + if (controller === null) { + return this.errorEnvelope('controller not ready'); + } + const label = request.label; + // Drop any stale entry sharing this label before re-creating so a + // failed/re-tried create does not leave an orphaned BuilderNode. + await this.closeInternal(label); + const style: WebviewStyle = { + x: request.x, + y: request.y, + width: request.width, + height: request.height, + visible: 'hidden', + }; + // The page-load callbacks close over `label` (stable for the webview's + // lifetime) and forward to the Rust-side emitter so the web-ui's listener + // can react exactly like the desktop `.on_page_load` path. + const initData: WebviewInitData = { + webTag: label, + url: request.url, + style: style, + javascriptEnable: true, + devtools: true, + onNavigationRequest: (url: string): boolean => { + return true; + }, + onPageBegin: (url: string): void => { + RustModule.emitBrowserPageLoad(label, 'started', url); + }, + onPageEnd: (url: string): void => { + RustModule.emitBrowserPageLoad(label, 'finished', url); + }, + }; + const ret = controller.addWebview(initData); + const entry: BrowserWebviewEntry = { + webTag: ret.webTag, + controller: ret.controller, + initData: initData, + }; + this.entries.set(label, entry); + hilog.info(BROWSER_DOMAIN, BROWSER_TAG, `created webview label=${label} webTag=${ret.webTag} url=${request.url}`); + return this.okEnvelope(); + } + + async eval(request: EvalRequest): Promise { + const entry = this.entries.get(request.label); + if (entry === undefined) { + return this.errorEnvelope(`webview not found: ${request.label}`); + } + // The JsHelper callback wrapper fires exactly once when the underlying + // WebviewController.runJavaScript promise resolves; wrap it in a Promise + // so the Rust side can await the script's return value. + const result = await new Promise((resolve: (value: string) => void): void => { + entry.controller.runJavaScript(request.script, (res: string | undefined): void => { + resolve(res ?? ''); + }); + }); + const ok: OkResultResponse = { ok: true, result: result }; + return JSON.stringify(ok); + } + + async navigate(request: NavigateRequest): Promise { + const entry = this.entries.get(request.label); + if (entry === undefined) { + return this.errorEnvelope(`webview not found: ${request.label}`); + } + entry.controller.loadUrl(request.url); + hilog.info(BROWSER_DOMAIN, BROWSER_TAG, `navigated label=${request.label} url=${request.url}`); + return this.okEnvelope(); + } + + async reload(request: LabelRequest): Promise { + const entry = this.entries.get(request.label); + if (entry === undefined) { + return this.errorEnvelope(`webview not found: ${request.label}`); + } + entry.controller.refresh(); + return this.okEnvelope(); + } + + async setBounds(request: BoundsRequest): Promise { + const entry = this.entries.get(request.label); + if (entry === undefined) { + return this.errorEnvelope(`webview not found: ${request.label}`); + } + const style = entry.initData.style; + if (style === undefined) { + return this.errorEnvelope(`webview has no style: ${request.label}`); + } + style.x = request.x; + style.y = request.y; + style.width = request.width; + style.height = request.height; + // Re-build the BuilderNode with the mutated style so the Web component + // re-applies .width/.height/.position. Same pattern the original + // DefaultXComponent used for setVisible/setBackgroundColor. + const controller = this.resolveController(); + const node = controller?.getWebviewNode(entry.webTag); + node?.update(entry.initData); + return this.okEnvelope(); + } + + async show(request: LabelRequest): Promise { + const entry = this.entries.get(request.label); + if (entry === undefined) { + return this.errorEnvelope(`webview not found: ${request.label}`); + } + entry.controller.setVisible(true); + return this.okEnvelope(); + } + + async hide(request: LabelRequest): Promise { + const entry = this.entries.get(request.label); + if (entry === undefined) { + return this.errorEnvelope(`webview not found: ${request.label}`); + } + entry.controller.setVisible(false); + return this.okEnvelope(); + } + + async setFocus(request: LabelRequest): Promise { + const entry = this.entries.get(request.label); + if (entry === undefined) { + return this.errorEnvelope(`webview not found: ${request.label}`); + } + entry.controller.requestFocus(); + return this.okEnvelope(); + } + + async close(request: LabelRequest): Promise { + await this.closeInternal(request.label); + return this.okEnvelope(); + } + + private async closeInternal(label: string): Promise { + const entry = this.entries.get(label); + if (entry === undefined) { + return; + } + const controller = this.resolveController(); + controller?.removeWebview(entry.webTag); + this.entries.delete(label); + hilog.info(BROWSER_DOMAIN, BROWSER_TAG, `closed webview label=${label} webTag=${entry.webTag}`); + } +} diff --git a/src/apps/ohos/oh-rs-ability/CHANGELOG.md b/src/apps/ohos/oh-rs-ability/CHANGELOG.md new file mode 100644 index 0000000000..c1d81c59e3 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/CHANGELOG.md @@ -0,0 +1,66 @@ +# 0.4.0-beta.0 +- Support non-full mode render. +- Add `oxc-ark` to format code. + +--- + +# 0.3.0 + +- Allow render xcomponent and webview at the same time. +- Add sync method to load dynamic library. + +--- + +# 0.2.2 + +- Fix: allow enable devtools + +--- + +# 0.2.1 + +- Allow load page with html string. +- Allow load url with custom headers. + +--- + +# 0.2.0 + +- Support webview render mode. + +--- + +# 0.1.5-beta.0 + +- Add Webview render mode. + +--- + +# 0.1.2 + +- Use XComponent's `on_frame` to replace `onFrame` callback. + +--- + +# 0.1.1 + +- Revert: Use `native soloist` to replace `onFrame` callback. + +--- + +# 0.1.0 + +- Use `native soloist` to replace `onFrame` callback. + +--- + +# 0.0.2 + +- Allow use custom page or route +- Move default xcomponent to a single component + +--- + +# 0.0.1 + +- init package diff --git a/src/apps/ohos/oh-rs-ability/LICENSE b/src/apps/ohos/oh-rs-ability/LICENSE new file mode 100644 index 0000000000..3eae217836 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-present richerfu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/apps/ohos/oh-rs-ability/README.md b/src/apps/ohos/oh-rs-ability/README.md new file mode 100644 index 0000000000..ea59c48dcd --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/README.md @@ -0,0 +1,137 @@ +# @ohos-rs/ability + +This package provides a set of components and APIs for building OpenHarmony activities and it helps us create a application entry for rust application. + +For OpenHarmony/HarmonyNext development, our application entry must be a ArkTS file and we need to forward some lifecycle event to rust code. + +## Install + +``` +ohpm install @ohos-rs/ability +``` + +## API and Components + +### RustAbility + +The Activity component is a wrapper of the OpenHarmony Activity class. It will load the native code and forward the lifecycle event to rust code by default. + +If you want to use rust development OpenHarmony/HarmonyNext application, you must use this component to create your application entry. + +```ts +// ets/entryability/EntryAbility.ets +import { RustAbility } from "@ohos-rs/ability"; + +export default class MyAbility extends RustAbility { + public moduleName: string = "hello"; + + onCreate() { + super.onCreate(); + } +} +``` + +Here are some notes and tips: + +1. For every lifecycle callback, you must call the super method to forward the event to rust code as first and then write your own logic. + +2. `moduleName` is the name of your native module name which file name is `lib${moduleName}.so`. **You must define it in your project**. + +### loadMode + +Allow to define that how to load dynamic library in runtime. + +- async + In `async` mode, we will use `await import(${lib})` to load library. And this is default behavior. +- sync + In `sync` mode, we will use `loadNativeModule` to load library. If you define it, you must add some configuration into your `build-profile.json5`. + + ```json + { + "buildOption": { + "arkOptions": { + "runtimeOnly": { + "packages": ["libentry.so"] + } + } + } + } + ``` + + See more with [loadNativeModule](https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-common-load-native-module#loadnativemodule). + +### DefaultXComponent + +When using rust to develop OpenHarmony/HarmonyNext application, we use `XComponent` to render the UI by default. And `DefaultXComponent` loads the native module and forward the lifecycle event to rust code by default. + +This component is a optional component, you may don't need it. If you don't need to use it, `RustAbility` will use it by default. + +And if you want to add some custom logic, you can use it with the following code: + +```ts +// ets/entryability/EntryAbility.ets +import { RustAbility } from "@ohos-rs/ability"; +import Want from "@ohos.app.ability.Want"; +import { AbilityConstant } from "@kit.AbilityKit"; +import window from "@ohos.window"; + +export default class EntryAbility extends RustAbility { + public moduleName: string = "example"; + + // Must mark it as false to prevent the default page from loading + public defaultPage: boolean = false; + + async onCreate( + want: Want, + launchParam: AbilityConstant.LaunchParam + ): Promise { + super.onCreate(want, launchParam); + } + + async onWindowStageCreate(windowStage: window.WindowStage): Promise { + // Must call super method to forward the event to rust code + super.onWindowStageCreate(windowStage); + // Jump to your custom page + await windowStage.loadContent("pages/Index"); + } +} +``` + +```ts +// ets/pages/Index.ets +import { DefaultXComponent } from '@ohos-rs/ability' +import { ItemRestriction, SegmentButton, SegmentButtonOptions, SegmentButtonTextItem } from '@kit.ArkUI'; +import { changeRender } from "libwgpu_in_app.so" + +@Entry +@Component +struct Index { + // Add some custom logic + @State tabOptions: SegmentButtonOptions = SegmentButtonOptions.capsule({ + buttons: [{ text: 'boids' }, + { text: 'MSAA line' }, + { text: 'cube' }, + { text: "water" }, + { text: "shadow" }] as ItemRestriction, + backgroundBlurStyle: BlurStyle.BACKGROUND_THICK, + }) + @State @Watch("handleChange") tabSelectedIndexes: number[] = [0] + + handleChange() { + console.log(`changeIndex: ${this.tabSelectedIndexes}`) + changeRender(this.tabSelectedIndexes[0]) + } + + build() { + Row() { + Column() { + SegmentButton({ options: this.tabOptions, selectedIndexes: $tabSelectedIndexes }) + // Must use the default component to render the UI + DefaultXComponent() + } + .width('100%') + } + .height('100%') + } +} +``` diff --git a/src/apps/ohos/oh-rs-ability/index.ets b/src/apps/ohos/oh-rs-ability/index.ets new file mode 100644 index 0000000000..00db2ba96b --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/index.ets @@ -0,0 +1,4 @@ +export { RustAbility } from "./src/main/ets/ability/RustAbility"; +export { DefaultXComponent } from "./src/main/ets/components/DefaultXComponent"; +export * from "./src/main/ets/webview/DefaultWebview"; +export { JsHelper } from "./src/main/ets/webview/Utils"; diff --git a/src/apps/ohos/oh-rs-ability/oh-package.json5 b/src/apps/ohos/oh-rs-ability/oh-package.json5 new file mode 100644 index 0000000000..db9868020d --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/oh-package.json5 @@ -0,0 +1,18 @@ +{ + "license": "MIT", + "author": "richerfu", + "name": "@ohos-rs/ability", + "description": "Adaptor for OpenHarmony/HarmonyNext Application with Rust", + "main": "index.ets", + "version": "0.4.0-beta.0", + "repository": "https://github.com/harmony-contrib/openharmony-ability.git", + "dependencies": {}, + "keywords": [ + "ohos-rs", + "NAPI", + "Rust", + "node-addon", + "openharmony", + "harmonynext" + ] +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/ability/RustAbility.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/ability/RustAbility.ets new file mode 100644 index 0000000000..c8f8f5e98b --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/ability/RustAbility.ets @@ -0,0 +1,89 @@ +import { AbilityConstant, Configuration, UIAbility, Want } from "@kit.AbilityKit"; +import window from "@ohos.window"; +import webview from "@ohos.web.webview"; +import * as Entry from "../components/MainPage"; +import { ApplicationLifecycle, Module } from "./type"; +import { Loadable } from "../helper/loadable"; + +export const STATE_KEY = "ohos.rs.ability.application.state"; + +export class RustAbility extends UIAbility { + /** + * load dynamic library + */ + public moduleName: string = ""; + /** + * Jump to defaultPage by default + * @default true + */ + public defaultPage: boolean = true; + /** + * Current page mode,support xcomponent and webview + * @default xcomponent + * @deprecated don't use it. Since 0.3, we can render xcomponent and webview in mixed mode. + */ + public mode: "xcomponent" | "webview" = "xcomponent"; + /** + * Load dynamic library mode. + * + */ + public loadMode: "async" | "sync" = "async"; + private nativeModule: Module | null = null; + private lifecycle: ApplicationLifecycle | null = null; + + async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise { + const isRestore: boolean = + (want.parameters?.["ohos.ability.params.abilityRecoveryRestart"] as boolean) ?? false; + const state = isRestore ? ((want.parameters?.[STATE_KEY] as string) ?? "") : ""; + + AppStorage.setOrCreate("moduleName", this.moduleName); + AppStorage.setOrCreate("loadMode", this.loadMode); + + const packageName = `lib${this.moduleName}.so`; + this.nativeModule = await Loadable.load(packageName, this.loadMode); + // Register custom protocol as first + // You can define it by yourself or use ability to define. + if (typeof this.nativeModule?.registerCustomProtocol === "function") { + this.nativeModule!.registerCustomProtocol(); + } + + // Must call it when custom protocol is enabled. + webview.WebviewController.initializeWebEngine(); + + this.lifecycle = this.nativeModule!.init(); + this.lifecycle?.windowStageEventCallback.onAbilityCreate(state); + } + + async onWindowStageCreate(windowStage: window.WindowStage): Promise { + this.lifecycle?.windowStageEventCallback.onWindowStageCreate(); + + windowStage.on("windowStageEvent", (event: window.WindowStageEventType) => { + this.lifecycle?.windowStageEventCallback.onWindowStageEvent(event); + }); + + if (this.defaultPage) { + await windowStage.loadContentByName(Entry.RouteName); + } + } + + onMemoryLevel(level: AbilityConstant.MemoryLevel): void { + this.lifecycle?.environmentCallback.onMemoryLevel(level); + } + + onDestroy(): void | Promise { + this.lifecycle?.windowStageEventCallback.onAbilityDestroy(); + } + + onConfigurationUpdate(newConfig: Configuration): void { + this.lifecycle?.environmentCallback.onConfigurationUpdated(newConfig); + } + + onSaveState( + reason: AbilityConstant.StateType, + wantParam: Record, + ): AbilityConstant.OnSaveResult { + const ret = this.lifecycle?.windowStageEventCallback.onAbilitySaveState(); + wantParam[STATE_KEY] = ret as string; + return AbilityConstant.OnSaveResult.RECOVERY_AGREE; + } +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/ability/type.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/ability/type.ets new file mode 100644 index 0000000000..0498890197 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/ability/type.ets @@ -0,0 +1,69 @@ +import { NodeContent } from "@kit.ArkUI"; + +export interface ApplicationLifecycle { + environmentCallback: EnvironmentCallback; + windowStageEventCallback: WindowStageEventCallback; +} + +export interface EnvironmentCallback { + onConfigurationUpdated: (arg: object) => void; + onMemoryLevel: (arg: number) => void; +} + +export interface WindowStageEventCallback { + onWindowStageCreate: () => void; + onWindowStageDestroy: () => void; + onAbilityCreate: (arg: string) => void; + onAbilityDestroy: () => void; + onAbilitySaveState: () => string; + onWindowStageEvent: (arg: number) => void; + onWindowSizeChange: (arg: object) => void; + onWindowRectChange: (arg: object) => void; +} + +export interface WebViewComponentEventCallback { + onComponentCreated: () => void; + onComponentDestroyed: () => void; +} + +export interface OnDownloadStartResult { + allow?: boolean; + tempPath?: string; +} + +export interface WebViewInitData { + url?: string; + id?: string; + style?: WebViewStyle; + javascriptEnable?: boolean; + devtools?: boolean; + transparent?: boolean; + autoplay?: boolean; + userAgent?: string; + initializationScripts?: string[]; + headers?: Record; + html?: string; + onDragAndDrop?: (event: string) => void; + onDownloadStart?: (url: string, tempPath: string | undefined) => OnDownloadStartResult; + onDownloadEnd?: (url: string, tempPath: string | undefined, success: boolean) => void; + onNavigationRequest?: (url: string) => boolean; + onTitleChange?: (title: string) => void; +} + +export interface WebViewStyle { + x?: number | string; + y?: number | string; +} + +export interface Module { + init: () => ApplicationLifecycle; + // XComponent mode + render: (helper: ArkHelper, slot: NodeContent) => void; + + registerCustomProtocol: () => void; +} + +export interface ArkHelper { + exit: (code: number) => void; + createWebview: (data: WebViewInitData) => Object; +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/components/DefaultXComponent.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/components/DefaultXComponent.ets new file mode 100644 index 0000000000..5e8e163488 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/components/DefaultXComponent.ets @@ -0,0 +1,92 @@ +import { NodeContent } from "@kit.ArkUI"; +import { ArkHelper, WebViewInitData as NativeWebViewInitData } from "../ability/type"; +import { exit } from "../helper"; +import { Loadable } from "../helper/loadable"; +import { + RustWebviewNodeController, + WebviewStyle, + WebviewInitData, +} from "../webview/DefaultWebview"; + +export const RouteName = "RustAbility"; +@Component +export struct DefaultXComponent { + private rootSlot = new NodeContent(); + private webviewController = new RustWebviewNodeController(this.getUIContext()); + private nativeModule: ESObject; + private helper: ArkHelper = { + exit, + createWebview: (data: NativeWebViewInitData) => { + const initScripts: ScriptItem[] = (data?.initializationScripts || []).map((i) => { + return { + script: i, + scriptRules: ["*"], + } as ScriptItem; + }); + // url set to empty string avoid double load. + const init: WebviewInitData = { + webTag: data?.id, + url: data?.url, + html: data?.html, + headers: data?.headers, + style: (data.style || {}) as WebviewStyle, + javascriptEnable: data?.javascriptEnable ?? true, + userAgent: data?.userAgent, + devtools: data?.devtools, + autoplay: data?.autoplay, + initializationScripts: initScripts, + onDragAndDrop: data?.onDragAndDrop, + onDownloadStart: data?.onDownloadStart, + onDownloadEnd: data?.onDownloadEnd, + onNavigationRequest: data?.onNavigationRequest, + onTitleChange: data?.onTitleChange, + } as WebviewInitData; + + // transparent only be set when backgroundColor is null. + if (data?.transparent && !init.style?.backgroundColor) { + init.style!.backgroundColor = Color.Transparent; + } + const ret = this.webviewController.addWebview(init); + + ret.controller.setBackgroundColor = (color: string) => { + init.style!.backgroundColor = color; + const node = this.webviewController.getWebviewNode(ret.webTag); + node?.update(init); + }; + + ret.controller.setVisible = (visible: boolean) => { + init.style!.visible = visible ? "visible" : "hidden"; + const node = this.webviewController.getWebviewNode(ret.webTag); + node?.update(init); + }; + + // Return controller and use controller to control webview behavior + return ret.controller; + }, + }; + @StorageProp("moduleName") name: string = ""; + @StorageProp("loadMode") loadMode: "async" | "sync" = "async"; + + async aboutToAppear(): Promise { + // Expose the controller so app-level services (e.g. BrowserWebviewService) + // can call addWebview/removeWebview directly to create embedded child + // webviews for the built-in browser scene, bypassing the ArkHelper path. + AppStorage.setOrCreate("rustWebviewController", this.webviewController); + const moduleName = `lib${this.name}.so`; + this.nativeModule = await Loadable.load(moduleName, this.loadMode); + this.nativeModule.render(this.helper, this.rootSlot); + } + + build() { + Stack() { + ContentSlot(this.rootSlot) + Row() { + Column() { + NodeContainer(this.webviewController) + .height("100%") + .width("100%") + }.width("100%") + }.height("100%") + }; + } +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/components/MainPage.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/components/MainPage.ets new file mode 100644 index 0000000000..cb9b691ef5 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/components/MainPage.ets @@ -0,0 +1,15 @@ +import { DefaultXComponent } from "./DefaultXComponent"; + +export const RouteName = "RustAbility"; + +@Entry({ routeName: RouteName }) +@Component +struct Index { + build() { + Row() { + Column() { + DefaultXComponent() + }.width("100%") + }.height("100%"); + } +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/helper/index.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/index.ets new file mode 100644 index 0000000000..941336f2a4 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/index.ets @@ -0,0 +1,5 @@ +export * from "./os"; + +export * from "./random"; + +export * from "./object"; diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/helper/loadable.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/loadable.ets new file mode 100644 index 0000000000..40c3037f54 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/loadable.ets @@ -0,0 +1,28 @@ +import { Module } from "../ability/type"; + +export class Loadable { + static mod: ESObject; + + static async load(libName: string, mode: "async" | "sync" = "async"): Promise { + if (!!Loadable.mod) { + return Loadable.mod; + } + let currentMod: ESObject; + if (mode === "async") { + currentMod = await import(libName); + } else { + currentMod = loadNativeModule(libName); + } + if ( + typeof currentMod?.default?.render === "function" && + typeof currentMod?.default?.init === "function" + ) { + Loadable.mod = currentMod.default; + return Loadable.mod; + } else if (typeof currentMod?.render === "function" && typeof currentMod?.init === "function") { + Loadable.mod = currentMod; + return Loadable.mod; + } + throw new Error(`${libName} is not a valid dynamic library`); + } +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/helper/object.ts b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/object.ts new file mode 100644 index 0000000000..448e6af272 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/object.ts @@ -0,0 +1,11 @@ +export function objectAssign( + target: Record, + ...source: Object[] +): Record { + for (const items of source) { + for (const key of Object.getOwnPropertyNames(Object.getPrototypeOf(items))) { + target[key] = Reflect.get(items, key); + } + } + return target; +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/helper/os.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/os.ets new file mode 100644 index 0000000000..6806fbc3ae --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/os.ets @@ -0,0 +1,9 @@ +import process from "@ohos.process"; + +/** + * exit current application + */ +export const exit = (code: number) => { + const pro = new process.ProcessManager(); + pro.exit(code); +}; diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/helper/random.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/random.ets new file mode 100644 index 0000000000..d329ccdf52 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/helper/random.ets @@ -0,0 +1,9 @@ +export const randomString = (): string => { + const length = 10; + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + result += charset.charAt(Math.floor(Math.random() * charset.length)); + } + return result; +}; diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/webview/DefaultWebview.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/webview/DefaultWebview.ets new file mode 100644 index 0000000000..aa5dd02da4 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/webview/DefaultWebview.ets @@ -0,0 +1,314 @@ +import { UIContext } from "@ohos.arkui.UIContext"; +import web_webview from "@ohos.web.webview"; +import { NodeController, BuilderNode, FrameNode } from "@ohos.arkui.node"; +import { randomString } from "../helper"; +import { OnDownloadStartResult } from "../ability/type"; +import { getCookies, JsHelper } from "./Utils"; +import { WebHeader } from "@kit.ArkUI"; + +export interface WebviewStyle { + x?: number | string; + y?: number | string; + width?: number | string; + height?: number | string; + backgroundColor?: string | Color; + visible?: string; +} + +export interface WebviewInitData { + webTag?: string; + url: string; + style?: WebviewStyle; + controller?: WebviewController; + javascriptEnable?: boolean; + devtools?: boolean; + autoplay?: boolean; + userAgent?: string; + initializationScripts?: ScriptItem[]; + headers?: Record; + html?: string; + onDragAndDrop?: (event: string) => void; + onDownloadStart?: (url: string, tempPath: string | undefined) => OnDownloadStartResult; + onDownloadEnd?: (url: string, tempPath: string | undefined, success: boolean) => void; + onNavigationRequest?: (url: string) => boolean; + onTitleChange?: (title: string) => void; + onPageBegin?: (url: string) => void; + onPageEnd?: (url: string) => void; +} + +interface WebviewNodeData extends WebviewInitData { + controller: WebviewController; +} + +@Builder +function WebBuilder(data: WebviewNodeData) { + // init with empty url and reload with loadUrl or loadData with onControllerAttach + Web({ src: "", controller: data.controller as web_webview.WebviewController }) + .width(data.style?.width ?? "100%") + .height(data.style?.height ?? "100%") + .position({ + x: data.style?.x || 0, + y: data.style?.y || 0, + }) + .backgroundColor(data?.style?.backgroundColor) + .visibility(data?.style?.visible === "hidden" ? Visibility.Hidden : Visibility.Visible) + .javaScriptAccess(data?.javascriptEnable) + .mediaPlayGestureAccess( + typeof data?.autoplay === "boolean" && data.autoplay === true ? false : true, + ) + .javaScriptOnDocumentStart(data?.initializationScripts) + .onControllerAttached(() => { + const ctrl = data.controller; + // load url or html string + if (data?.url) { + const header: WebHeader[] = Object.keys( + (data?.headers || {}) as Record, + ).reduce((t: WebHeader[], i) => { + t.push({ headerKey: i, headerValue: data.headers![i] } as WebHeader); + return t; + }, []); + ctrl.loadUrl(data.url, header); + } else { + ctrl.loadData(data!.html, "text/html", "UTF-8", " ", " "); + } + }) + .onLoadIntercept((event) => { + if (typeof data?.onNavigationRequest === "function") { + const url = event.data.getRequestUrl(); + const ret = data.onNavigationRequest(url); + return ret; + } + return false; + }) + .onPageBegin((event) => { + if (typeof data?.onPageBegin === "function") { + const url = event?.url ?? ""; + data.onPageBegin(url); + } + }) + .onPageEnd((event) => { + if (typeof data?.onPageEnd === "function") { + const url = event?.url ?? ""; + data.onPageEnd(url); + } + }) + .onTitleReceive((e) => { + if (typeof data?.onTitleChange === "function") { + data.onTitleChange(e.title); + } + }); +} + +const webViewWrap = wrapBuilder(WebBuilder); + +interface AddWebviewMethod { + webTag: string; + controller: JsHelper; +} + +export class RustWebviewNodeController extends NodeController { + private rootNode: FrameNode | null = null; + private webviewList: Map> = new Map(); + private webviewData: Map = new Map(); + private uiContext: UIContext | null = null; + + constructor(uiContext: UIContext) { + super(); + this.uiContext = uiContext; + } + + private buildData(controller: WebviewController, initData: WebviewInitData): JsHelper { + const getUrl = () => { + return controller.getUrl(); + }; + const getCookiesHelper = (url: string) => { + return getCookies(url) as string; + }; + const loadUrl = (url: string, header?: Record) => { + const headers = Object.keys((header || {}) as Record).reduce( + (t, i) => { + if (!!header![i]) { + t.push({ headerKey: i, headerValue: header![i] }); + } + return t; + }, + [] as Array, + ); + controller.loadUrl(url, headers); + }; + + const loadHtml = (html: string) => { + controller.loadData(html, "text/html", "UTF-8", " ", " "); + }; + + const zoom = (scale: number) => { + controller.zoom(scale); + }; + + const refresh = () => { + controller.refresh(); + }; + + const requestFocus = () => { + controller.requestFocus(); + }; + + // clear browsing data + const clearAllBrowsingData = () => { + web_webview.WebStorage.deleteAllData(true); + web_webview.WebDataBase.deleteHttpAuthCredentials(); + controller.removeCache(true); + controller.clearHistory(); + }; + + const runJavaScript = (code: string, cb: (result?: string) => void) => { + controller.runJavaScript(code).then((ret) => { + cb(ret); + }); + }; + + // Updates the BuilderNode that backs this webview with a new style object. + // Mirrors the pattern the original DefaultXComponent used to apply + // setVisible/setBackgroundColor: mutate initData.style then re-build. + const syncStyleToNode = () => { + const node = this.webviewList.get(initData.webTag ?? ""); + node?.update(initData); + }; + + const setBackgroundColor = (color: string) => { + if (!initData.style) { + initData.style = {}; + } + initData.style.backgroundColor = color; + syncStyleToNode(); + }; + + const setVisible = (visible: boolean) => { + if (!initData.style) { + initData.style = {}; + } + initData.style.visible = visible ? "visible" : "hidden"; + syncStyleToNode(); + }; + + const data: JsHelper = { + getUrl, + getCookies: getCookiesHelper, + loadUrl, + loadHtml, + zoom, + refresh, + requestFocus, + clearAllBrowsingData, + runJavaScript, + setBackgroundColor, + setVisible, + } as JsHelper; + return data; + } + + makeNode(uiContext: UIContext): FrameNode { + if (this.rootNode === null) { + this.rootNode = new FrameNode(uiContext); + } + return this.rootNode; + } + + addWebview(data: WebviewInitData): AddWebviewMethod { + if (!data.webTag) { + data.webTag = randomString(); + } + if (!data.controller) { + data.controller = new web_webview.WebviewController(data.webTag) as WebviewController; + } + + if (this.rootNode === null) { + this.rootNode = new FrameNode(this.uiContext!); + } + + // Enabled devtools + data?.devtools && web_webview.WebviewController.setWebDebuggingAccess(true); + + const node: BuilderNode = new BuilderNode(this.uiContext!); + node.build(webViewWrap, data); + this.webviewList.set(data.webTag, node); + + const controller = this.buildData(data.controller, data); + // intercept download task + if (typeof data?.onDownloadStart === "function" || typeof data?.onDownloadEnd === "function") { + const download = new web_webview.WebDownloadDelegate(); + + if (typeof data?.onDownloadStart === "function") { + download.onBeforeDownload((e) => { + const url = e.getUrl(); + const tempPath = e.getFullPath(); + const ret = data.onDownloadStart!(url, tempPath); + + if (ret.allow) { + e.start(ret.tempPath || tempPath); + } else { + e.cancel(); + } + }); + } + + if (typeof data?.onDownloadEnd === "function") { + download.onDownloadFinish((e) => { + const url = e.getUrl(); + const tempPath = e.getFullPath(); + data.onDownloadEnd!(url, tempPath, true); + }); + download.onDownloadFailed((e) => { + const url = e.getUrl(); + data.onDownloadEnd!(url, undefined, false); + }); + } + + data.controller.setDownloadDelegate(download); + } + this.webviewData.set(data.webTag, controller); + + this.rootNode?.appendChild(node.getFrameNode()); + return { + webTag: data.webTag, + controller, + }; + } + + getWebviewNode(webTag: string) { + return this.webviewList.get(webTag); + } + + getWebviewData(webTag: string): JsHelper | undefined { + return this.webviewData.get(webTag); + } + + /** + * Remove a previously-added webview from the render tree and dispose of its + * BuilderNode / controller entries. The detached FrameNode is removed from + * the rootNode so the embedded Web component is torn down promptly (releases + * the underlying ArkWeb renderer slot). Safe to call when the webTag is + * unknown (returns false). + */ + removeWebview(webTag: string): boolean { + const node = this.webviewList.get(webTag); + if (!node) { + return false; + } + const frameNode = node.getFrameNode(); + if (this.rootNode && frameNode) { + this.rootNode.removeChild(frameNode); + } + this.webviewList.delete(webTag); + this.webviewData.delete(webTag); + return true; + } +} + +// extend WebviewController method +declare class WebviewController extends web_webview.WebviewController { + getCookies: (url: string) => string; + setBackgroundColor: (color: string) => void; + setVisible: (visible: boolean) => void; + clearAllBrowsingData: () => void; +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/ets/webview/Utils.ets b/src/apps/ohos/oh-rs-ability/src/main/ets/webview/Utils.ets new file mode 100644 index 0000000000..21954842a3 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/ets/webview/Utils.ets @@ -0,0 +1,19 @@ +import webview from "@ohos.web.webview"; + +export const getCookies = (url: string): string => { + return webview.WebCookieManager.fetchCookieSync(url); +}; + +export interface JsHelper { + getCookies: (url: string) => string; + getUrl: () => string; + loadUrl: (url: string, header?: Record) => void; + loadHtml: (html: string) => void; + zoom: (scale: number) => void; + refresh: () => void; + requestFocus: () => void; + runJavaScript: (code: string, callback: (result?: string) => void) => void; + setBackgroundColor: (color: string) => void; + setVisible: (visible: boolean) => void; + clearAllBrowsingData: () => void; +} diff --git a/src/apps/ohos/oh-rs-ability/src/main/module.json5 b/src/apps/ohos/oh-rs-ability/src/main/module.json5 new file mode 100644 index 0000000000..55f9aeb095 --- /dev/null +++ b/src/apps/ohos/oh-rs-ability/src/main/module.json5 @@ -0,0 +1,11 @@ +{ + "module": { + "name": "@ohos-rs/ability", + "type": "har", + "deviceTypes": [ + "default", + "tablet", + "2in1" + ] + } +} diff --git a/src/crates/assembly/core/src/util/register_arkts_function.rs b/src/crates/assembly/core/src/util/register_arkts_function.rs index b67b44db6f..fb3f08bbae 100644 --- a/src/crates/assembly/core/src/util/register_arkts_function.rs +++ b/src/crates/assembly/core/src/util/register_arkts_function.rs @@ -25,6 +25,15 @@ pub fn register_arkts_function( /// mode changes. Defined here so rust and the web-ui reference the same string. pub const SYSTEM_COLOR_SCHEME_CHANGED_EVENT: &str = "bitfun:system-color-scheme-changed"; +/// Event name the embedded browser webview emits to signal page-load lifecycle +/// (started/finished) so the web-ui's `useEmbeddedBrowserWebview` hook can +/// update `isLoading`, the address bar URL, and re-inject the +/// `BLANK_TARGET_INTERCEPT_SCRIPT` + `STREAM_RENDER_OPTIMIZATION_SCRIPT`. +/// Mirrors the desktop `BROWSER_WEBVIEW_PAGE_LOAD_EVENT` constant in +/// `apps/desktop/src/lib.rs` (kept duplicated to avoid a cross-crate visibility +/// tweak for one string). +pub const BROWSER_WEBVIEW_PAGE_LOAD_EVENT: &str = "browser-webview-page-load"; + /// Dedicated single-threaded tokio runtime for `notify_system_color_mode`. The /// `#[napi]` callback runs on a HarmonyOS thread that has no tokio runtime in /// context, so we cannot rely on `Handle::try_current()` captured at host init @@ -51,7 +60,7 @@ fn system_color_mode_runtime() -> &'static tokio::runtime::Runtime { /// Called from ArkTS (`NativeModule.notifySystemColorMode`) when the HarmonyOS /// system color mode changes (via `EntryAbility.onConfigurationUpdate`) or on a /// cold-start best-effort initial report. Forwards the color mode -/// (`"light"` | `"dark"`) to the web-ui through the global event system, which +/// (`"light" | "dark"`) to the web-ui through the global event system, which /// re-resolves the "follow system" theme without polling. Runs the emit to /// completion on the dedicated runtime (blocking the HarmonyOS callback thread /// briefly, same as `get_app_config_bool`); `emit_global_event` is a fast @@ -75,6 +84,53 @@ pub fn notify_system_color_mode(mode: String) { }); } +/// Dedicated single-threaded tokio runtime for `emit_browser_page_load`. Same +/// rationale as `system_color_mode_runtime`: the `#[napi]` callback runs on a +/// HarmonyOS thread with no tokio runtime in context, so we lazily build a +/// persistent runtime here to drive `emit_global_event` (a fast channel send, +/// so blocking is negligible) without re-creating the reactor per page-load +/// event. +static BROWSER_PAGE_LOAD_RUNTIME: OnceLock = OnceLock::new(); + +fn browser_page_load_runtime() -> &'static tokio::runtime::Runtime { + BROWSER_PAGE_LOAD_RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build browser page load runtime") + }) +} + +/// Called from ArkTS (`NativeModule.emitBrowserPageLoad`) by the embedded +/// browser service when an ArkUI `Web` component fires `onPageBegin` / +/// `onPageEnd`. Forwards the page-load lifecycle event +/// `{ label, event: "started" | "finished", url }` to the web-ui through the +/// global event system; the web-ui's `useEmbeddedBrowserWebview` hook listens +/// on `BROWSER_WEBVIEW_PAGE_LOAD_EVENT` and updates `isLoading`, the address +/// bar URL, and re-injects the page-init scripts. Mirrors the desktop path in +/// `apps/desktop/src/lib.rs` `.on_page_load` handler that emits the same event +/// to the `"main"` webview via `webview.emit_to(...)`. +#[napi] +pub fn emit_browser_page_load(label: String, event: String, url: String) { + browser_page_load_runtime().block_on(async move { + let payload = serde_json::json!({ + "label": label, + "event": event, + "url": url, + }); + if let Err(error) = emit_global_event(BackendEvent::Custom { + event_name: BROWSER_WEBVIEW_PAGE_LOAD_EVENT.to_string(), + payload, + }) + .await + { + log::warn!("Failed to emit browser page load event: {error}"); + } + }); +} + }); +} + pub async fn call_arkts_string_function( function_name: &str, input: String, diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index f74f3799c6..0750dc7de2 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -220,7 +220,7 @@ const PersistentFooterActions: React.FC = () => { const isBrowserActive = activeTabId === 'browser' || (activeTabId === 'session' && isBrowserPanelActiveInCanvas); - const SHOW_BROWSER_ENTRY = false; + const SHOW_BROWSER_ENTRY = true; return ( <> diff --git a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts index 57698ca972..c17298cb1e 100644 --- a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts @@ -140,6 +140,39 @@ async function setWebviewBounds(label: string, bounds: WebviewBounds): Promise | unknown[]) => Promise; + +/** + * Build a `BrowserWebviewHandle` whose `close / hide / show / setFocus` ops + * route through OHOS-only Tauri commands that bridge to the ArkTS + * `BrowserWebviewService`. Used when `Webview.getByLabel(label)` returns null + * — which is always the case on OHOS, because Tauri's webview registry + * doesn't track ArkUI `Web` components created via the vendored + * `RustWebviewNodeController`. On desktop `getByLabel` returns a real handle + * and this fallback is never reached. + */ +function createCommandBasedBrowserWebviewHandle( + label: string, + invoke: TauriInvoke, +): BrowserWebviewHandle { + const close = async (): Promise => { + await invoke('browser_webview_close', { request: { label } }); + }; + const hide = async (): Promise => { + await invoke('browser_webview_hide', { request: { label } }); + }; + const show = async (): Promise => { + await invoke('browser_webview_show', { request: { label } }); + }; + const setFocus = async (): Promise => { + await invoke('browser_webview_set_focus', { request: { label } }); + }; + return { close, hide, label, setFocus, show }; +} + async function createBrowserWebview(label: string, url: string, bounds: WebviewBounds): Promise { const [{ invoke }, { Webview }] = await Promise.all([ import('@tauri-apps/api/core'), @@ -157,7 +190,12 @@ async function createBrowserWebview(label: string, url: string, bounds: WebviewB }); const handle = await Webview.getByLabel(label) as unknown as BrowserWebviewHandle | null; if (!handle) { - throw new Error(`Webview not found after creation: ${label}`); + // OHOS: Tauri's webview registry doesn't track ArkUI Web components, so + // getByLabel returns null even though browser_webview_create succeeded on + // the Rust side (it routed to the ArkTS BrowserWebviewService which + // created an embedded ArkUI Web node). Fall back to a handle whose + // close/hide/show/setFocus ops route through OHOS-only Tauri commands. + return createCommandBasedBrowserWebviewHandle(label, invoke as TauriInvoke); } return handle; } From 0232865399093af735052279b549f7da6dab8369 Mon Sep 17 00:00:00 2001 From: SWangHash <88996709+SWangHash@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:27:08 +0800 Subject: [PATCH 2/5] feat(web-ui): restore terminal environment variables settings section Reintroduce BasicsEnvVarsSection (Key/Value table with .env import) dropped during the BasicsConfig refactor. Reads/writes terminal.env_vars; backend, types, i18n (en-US/zh-CN/zh-TW) and scss were already present. Placed after BasicsTerminalSection. --- .../config/components/BasicsConfig.tsx | 272 +++++++++++++++++- 1 file changed, 271 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx index f13f47ab89..280898162e 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Archive, FolderOpen } from 'lucide-react'; +import { Archive, FolderOpen, Plus, Trash2, Upload } from 'lucide-react'; import { Alert, Button, + Input, Select, Switch, Tooltip, @@ -961,6 +962,274 @@ function BasicsNotificationsSection() { const { t } = useTranslation('settings/ ); } +interface EnvVarRow { + id: string; + key: string; + value: string; +} + +let envVarRowSeq = 0; +const newEnvVarRowId = (): string => `envvar-${Date.now()}-${envVarRowSeq++}`; + +/** + * Parse environment-variable text (e.g. a `.env` file) into a key/value map. + * Supports `KEY=VALUE`, `export KEY=VALUE`, and `KEY: VALUE` lines. Skips blank + * and `#`-comment lines, strips surrounding quotes, and reports unparseable lines. + */ +function parseEnvText(text: string): { parsed: Record; skipped: number } { + const parsed: Record = {}; + let skipped = 0; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === '' || line.startsWith('#')) continue; + let work = line; + if (/^export\s+/.test(work)) { + work = work.replace(/^export\s+/, ''); + } + const match = work.match(/^([^=:]+)[=:](.*)$/); + if (!match) { + skipped += 1; + continue; + } + const key = match[1].trim(); + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (key === '') { + skipped += 1; + continue; + } + if (key.toUpperCase() === 'PATH') { + skipped += 1; + continue; + } + parsed[key] = value; + } + return { parsed, skipped }; +} + +function BasicsEnvVarsSection() { + const { t } = useTranslation('settings/basics'); + const isTauri = typeof window !== 'undefined' && '__TAURI__' in window; + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + + const showMessage = useCallback((type: 'success' | 'error' | 'info', text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 3000); + }, []); + + const loadData = useCallback(async () => { + try { + setLoading(true); + const terminalConfig = await configManager.getConfig('terminal'); + const envVars = terminalConfig?.env_vars ?? {}; + const next: EnvVarRow[] = Object.keys(envVars) + .sort((a, b) => a.localeCompare(b)) + .map((key) => ({ id: newEnvVarRowId(), key, value: envVars[key] ?? '' })); + setRows(next); + } catch (error) { + log.error('Failed to load terminal env vars', error); + showMessage('error', t('terminal.envVars.messages.loadFailed')); + } finally { + setLoading(false); + } + }, [showMessage, t]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + const handleAddRow = useCallback(() => { + setRows((prev) => [...prev, { id: newEnvVarRowId(), key: '', value: '' }]); + }, []); + + const handleRemoveRow = useCallback((id: string) => { + setRows((prev) => prev.filter((r) => r.id !== id)); + }, []); + + const handleRowChange = useCallback((id: string, field: 'key' | 'value', val: string) => { + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: val } : r))); + }, []); + + const buildRecord = useCallback((source: EnvVarRow[]): Record => { + const record: Record = {}; + for (const row of source) { + const key = row.key.trim(); + if (key === '') continue; + record[key] = row.value; + } + return record; + }, []); + + const persist = useCallback( + async (source: EnvVarRow[]): Promise => { + const record = buildRecord(source); + await configManager.setConfig>('terminal.env_vars', record); + configManager.clearCache(); + }, + [buildRecord] + ); + + const handleSave = useCallback(async () => { + try { + setSaving(true); + await persist(rows); + showMessage('success', t('terminal.envVars.messages.saved')); + } catch (error) { + log.error('Failed to save terminal env vars', error); + showMessage('error', t('terminal.envVars.messages.saveFailed')); + } finally { + setSaving(false); + } + }, [persist, rows, showMessage, t]); + + const handleImportFile = useCallback(async () => { + try { + const filePath = await workspaceAPI.open_oh_file_dialog({ directory: false }); + if (typeof filePath !== 'string') return; + const text = await workspaceAPI.readFileContent(filePath); + const { parsed, skipped } = parseEnvText(text); + const parsedCount = Object.keys(parsed).length; + if (parsedCount === 0) { + showMessage('error', t('terminal.envVars.messages.importFailed')); + return; + } + const merged = (() => { + const byKey = new Map(); + for (const r of rows) byKey.set(r.key.trim(), r); + for (const [k, v] of Object.entries(parsed)) { + const existing = byKey.get(k); + if (existing) { + existing.value = v; + } else { + byKey.set(k, { id: newEnvVarRowId(), key: k, value: v }); + } + } + return Array.from(byKey.values()).sort((a, b) => a.key.localeCompare(b.key)); + })(); + setRows(merged); + await persist(merged); + showMessage( + 'success', + skipped > 0 + ? t('terminal.envVars.messages.importedWithSkipped', { count: parsedCount, skipped }) + : t('terminal.envVars.messages.imported', { count: parsedCount }) + ); + } catch (error) { + log.error('Failed to import env vars from file', error); + showMessage('error', t('terminal.envVars.messages.importFailed')); + } + }, [persist, rows, showMessage, t]); + + if (!isTauri) return null; + + if (loading) { + return ; + } + + return ( +
+
+ + + + +
+ } + > + {rows.length === 0 ? ( +
{t('terminal.envVars.empty')}
+ ) : ( +
+
+
+ {t('terminal.envVars.columns.key')} +
+
+ {t('terminal.envVars.columns.value')} +
+
+
+ {rows.map((row) => ( +
+
+ handleRowChange(row.id, 'key', e.target.value)} + placeholder={t('terminal.envVars.columns.keyPlaceholder')} + disabled={saving} + inputSize="small" + /> +
+
+ handleRowChange(row.id, 'value', e.target.value)} + placeholder={t('terminal.envVars.columns.valuePlaceholder')} + disabled={saving} + inputSize="small" + /> +
+
+ + + +
+
+ ))} +
+ )} +
+ +
+ +
+
+ ); +} + const BasicsConfig: React.FC = () => { const { t } = useTranslation('settings/basics'); @@ -974,6 +1243,7 @@ const BasicsConfig: React.FC = () => { + From e221a30c4ac0283de6469b56c68926aaa198256e Mon Sep 17 00:00:00 2001 From: SWangHash <88996709+SWangHash@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:31:08 +0800 Subject: [PATCH 3/5] fix(browser): avoid @tauri-apps/api/webview entirely, use command-based handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's fallback (try getByLabel, fall back to command-based handle on null) still imported @tauri-apps/api/webview and called Webview.getByLabel(), which invokes plugin:webview|get_all_webviews. On OHOS that call throws ("Webview API not available on mobile") because ArkUI Web components created by RustWebviewNodeController.addWebview are invisible to Tauri's webview registry. Even on desktop the import is unnecessary now that all handle ops route through Tauri commands. - useEmbeddedBrowserWebview.ts: remove import('@tauri-apps/api/webview') and Webview.getByLabel() from both createBrowserWebview and createWebview. The handle is ALWAYS command-based (createCommandBasedBrowserWebviewHandle). Stale-handle cleanup in the retry loop now calls invoke('browser_webview_close') instead of Webview.getByLabel().close(). - browser_api.rs: add desktop implementations of browser_webview_show/hide/close/set_focus (previously OHOS-only stubs returning Err). Desktop uses find_browser_webview(app, label) then .show()/.hide()/.close()/.set_focus() — same Tauri child-webview API the old Webview.getByLabel handle used internally. OHOS branches unchanged. Verified: tsc --noEmit 0 errors; arkts_check passes; hvigor build SUCCESSFUL. --- src/apps/desktop/src/api/browser_api.rs | 47 ++++++++++++------- .../browser/useEmbeddedBrowserWebview.ts | 43 ++++++++--------- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index 319404df0d..a5ee5a6a2b 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -363,25 +363,30 @@ pub async fn browser_webview_set_bounds( } -// OHOS-only commands for handle operations that desktop resolves via -// `Webview.getByLabel(label)` from `@tauri-apps/api/webview`. The frontend's -// `useEmbeddedBrowserWebview` falls back to these when `getByLabel` returns -// null (the OHOS case — Tauri's webview registry doesn't track ArkUI Web -// components). On desktop these commands are never invoked because -// `getByLabel` returns a real handle. +// Handle operations that the frontend drives via the command-based +// `BrowserWebviewHandle` (createCommandBasedBrowserWebviewHandle). On desktop +// these resolve the Tauri child webview by label and call its native method; +// on OHOS they route through the ArkTS `BrowserWebviewService` via the +// `ohos_browser_call` bridge. Using commands instead of `Webview.getByLabel` +// from `@tauri-apps/api/webview` avoids the Tauri webview registry entirely +// — ArkUI Web components created by `RustWebviewNodeController.addWebview` +// are invisible to that registry, so `getByLabel` returns null / throws on +// OHOS. The command path works uniformly on both platforms. #[tauri::command] pub async fn browser_webview_show( - _app: tauri::AppHandle, + app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result<(), String> { #[cfg(not(target_env = "ohos"))] { - let _ = request; - Err("browser_webview_show is OHOS-only".to_string()) + find_browser_webview(&app, &request.label)? + .show() + .map_err(|e| format!("show failed: {e}")) } #[cfg(target_env = "ohos")] { + let _ = app; validate_browser_label(&request.label)?; let json = serde_json::to_string(&request) .map_err(|e| format!("failed to encode request: {e}"))?; @@ -392,16 +397,18 @@ pub async fn browser_webview_show( #[tauri::command] pub async fn browser_webview_hide( - _app: tauri::AppHandle, + app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result<(), String> { #[cfg(not(target_env = "ohos"))] { - let _ = request; - Err("browser_webview_hide is OHOS-only".to_string()) + find_browser_webview(&app, &request.label)? + .hide() + .map_err(|e| format!("hide failed: {e}")) } #[cfg(target_env = "ohos")] { + let _ = app; validate_browser_label(&request.label)?; let json = serde_json::to_string(&request) .map_err(|e| format!("failed to encode request: {e}"))?; @@ -412,16 +419,18 @@ pub async fn browser_webview_hide( #[tauri::command] pub async fn browser_webview_close( - _app: tauri::AppHandle, + app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result<(), String> { #[cfg(not(target_env = "ohos"))] { - let _ = request; - Err("browser_webview_close is OHOS-only".to_string()) + find_browser_webview(&app, &request.label)? + .close() + .map_err(|e| format!("close failed: {e}")) } #[cfg(target_env = "ohos")] { + let _ = app; validate_browser_label(&request.label)?; let json = serde_json::to_string(&request) .map_err(|e| format!("failed to encode request: {e}"))?; @@ -432,16 +441,18 @@ pub async fn browser_webview_close( #[tauri::command] pub async fn browser_webview_set_focus( - _app: tauri::AppHandle, + app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result<(), String> { #[cfg(not(target_env = "ohos"))] { - let _ = request; - Err("browser_webview_set_focus is OHOS-only".to_string()) + find_browser_webview(&app, &request.label)? + .set_focus() + .map_err(|e| format!("set_focus failed: {e}")) } #[cfg(target_env = "ohos")] { + let _ = app; validate_browser_label(&request.label)?; let json = serde_json::to_string(&request) .map_err(|e| format!("failed to encode request: {e}"))?; diff --git a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts index c17298cb1e..09551d40a5 100644 --- a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts @@ -147,12 +147,13 @@ type TauriInvoke = (cmd: string, args?: Record | unknown[]) => /** * Build a `BrowserWebviewHandle` whose `close / hide / show / setFocus` ops - * route through OHOS-only Tauri commands that bridge to the ArkTS - * `BrowserWebviewService`. Used when `Webview.getByLabel(label)` returns null - * — which is always the case on OHOS, because Tauri's webview registry - * doesn't track ArkUI `Web` components created via the vendored - * `RustWebviewNodeController`. On desktop `getByLabel` returns a real handle - * and this fallback is never reached. + * route through Tauri commands (`browser_webview_close/hide/show/set_focus`) + * that the Rust side resolves to the platform's native webview handle — + * `app.get_webview(label)` on desktop (Tauri child webview), or the ArkTS + * `BrowserWebviewService` on OHOS (ArkUI Web component). This avoids the + * `@tauri-apps/api/webview` `Webview.getByLabel()` call entirely, which + * queries Tauri's own webview registry and returns null/throws for webviews + * created outside that registry (notably ArkUI Web components on OHOS). */ function createCommandBasedBrowserWebviewHandle( label: string, @@ -173,11 +174,14 @@ function createCommandBasedBrowserWebviewHandle( return { close, hide, label, setFocus, show }; } +/** + * Create a browser webview via the `browser_webview_create` Tauri command + * and return a command-based handle. The handle's show/hide/close/setFocus + * ops route through Tauri commands (not `Webview.getByLabel`) so they work + * uniformly on desktop (Tauri child webview) and OHOS (ArkUI Web component). + */ async function createBrowserWebview(label: string, url: string, bounds: WebviewBounds): Promise { - const [{ invoke }, { Webview }] = await Promise.all([ - import('@tauri-apps/api/core'), - import('@tauri-apps/api/webview'), - ]); + const { invoke } = await import('@tauri-apps/api/core'); await invoke('browser_webview_create', { request: { label, @@ -188,16 +192,7 @@ async function createBrowserWebview(label: string, url: string, bounds: WebviewB height: bounds.height, }, }); - const handle = await Webview.getByLabel(label) as unknown as BrowserWebviewHandle | null; - if (!handle) { - // OHOS: Tauri's webview registry doesn't track ArkUI Web components, so - // getByLabel returns null even though browser_webview_create succeeded on - // the Rust side (it routed to the ArkTS BrowserWebviewService which - // created an embedded ArkUI Web node). Fall back to a handle whose - // close/hide/show/setFocus ops route through OHOS-only Tauri commands. - return createCommandBasedBrowserWebviewHandle(label, invoke as TauriInvoke); - } - return handle; + return createCommandBasedBrowserWebviewHandle(label, invoke as TauriInvoke); } export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOptions) { @@ -367,7 +362,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti const previous = webviewRef.current; if (previous) await closeWebview(previous); - const { Webview } = await import('@tauri-apps/api/webview'); + const { invoke } = await import('@tauri-apps/api/core'); const initialBounds = await waitForViewportBounds(); let lastError: unknown = null; @@ -389,8 +384,10 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti return handle; } catch (creationError) { lastError = creationError; - const staleHandle = await Webview.getByLabel(label).catch(() => null); - await staleHandle?.close().catch(() => {}); + // Clean up any partially-created webview via the command path — works + // on both desktop (Tauri child webview) and OHOS (ArkUI Web node). + // Swallow errors since the webview may not exist at all. + await invoke('browser_webview_close', { request: { label } }).catch(() => {}); if (!isTransientWebviewCreationError(creationError) || attempt === WEBVIEW_CREATE_RETRY_DELAYS_MS.length - 1) { throw creationError; From c9c2aae903e70eb4adf487048686fddf3e1d3945 Mon Sep 17 00:00:00 2001 From: SWangHash <88996709+SWangHash@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:43:51 +0800 Subject: [PATCH 4/5] fix(browser): check __TAURI_INTERNALS__ not __TAURI__ for isTauriEnvironment The browser hook checked `'__TAURI__' in window` (Tauri's global API namespace, only set when app.withGlobalTauri is true AND the webview was created via Tauri's WebviewWindowBuilder). On OHOS the ArkUI Web component is created by @ohos-rs/ability's RustWebviewNodeController, not Tauri's builder, so __TAURI__ may be absent even though __TAURI_INTERNALS__ (and thus `invoke`) is available. This caused isTauri to be false on OHOS, making loadUrl return early ("nothing happens" when entering a URL) and BrowserScene to render an