diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index a2b48ff8d2..a5ee5a6a2b 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,11 +352,115 @@ 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) } } +// 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, + request: WebviewLabelRequest, +) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + 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}"))?; + 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"))] + { + 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}"))?; + 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"))] + { + 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}"))?; + 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"))] + { + 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}"))?; + let response = ohos_browser_call("browser_webview_set_focus_ohos", &json).await?; + decode_ok_envelope(&response) + } +} + /// Return the current URL of a browser webview. /// /// Uses `catch_unwind` to guard against a known wry bug where 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..6784718e2d --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/services/BrowserWebviewService.ets @@ -0,0 +1,268 @@ +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 => { + // RustWebviewNodeController forwards this value to ArkUI Web's + // `onLoadIntercept`: `true` means the request is intercepted (and the + // page is not loaded), while `false` allows normal navigation. + // The embedded browser must allow both the initial URL and subsequent + // address-bar navigations to reach the Web component. + return false; + }, + 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..90c4246598 100644 --- a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts @@ -66,7 +66,17 @@ export interface UseEmbeddedBrowserWebviewOptions { } function isTauriEnvironment(): boolean { - return typeof window !== 'undefined' && '__TAURI__' in window; + // Check __TAURI_INTERNALS__ (what `invoke` actually needs) rather than + // __TAURI__ (the global API namespace, only set when withGlobalTauri is + // true AND the webview was created via Tauri's WebviewWindowBuilder). On + // OHOS the ArkUI Web component is created by @ohos-rs/ability, not Tauri's + // builder, so __TAURI__ may be absent even though __TAURI_INTERNALS__ + // (and thus `invoke`) is available. Mirrors the pattern in + // src/infrastructure/runtime/environment.ts `isTauriRuntime`. + if (typeof window === 'undefined') return false; + const internals = (window as unknown as { __TAURI_INTERNALS__?: { invoke?: unknown } }) + .__TAURI_INTERNALS__; + return typeof internals?.invoke === 'function'; } function formatUnknownError(error: unknown): string { @@ -140,11 +150,48 @@ async function setWebviewBounds(label: string, bounds: WebviewBounds): Promise | unknown[]) => Promise; + +/** + * Build a `BrowserWebviewHandle` whose `close / hide / show / setFocus` ops + * 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, + 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 }; +} + +/** + * 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, @@ -155,11 +202,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) { - throw new Error(`Webview not found after creation: ${label}`); - } - return handle; + return createCommandBasedBrowserWebviewHandle(label, invoke as TauriInvoke); } export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOptions) { @@ -329,7 +372,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; @@ -351,8 +394,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; 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 = () => { +