diff --git a/crates/ability/src/app.rs b/crates/ability/src/app.rs index d33a30d..cebae34 100644 --- a/crates/ability/src/app.rs +++ b/crates/ability/src/app.rs @@ -541,7 +541,7 @@ impl OpenHarmonyApp { Ok(requested_permissions .into_iter() - .zip(codes.into_iter()) + .zip(codes) .map(|(permission, code)| PermissionRequestCode { permission, code }) .collect()) } diff --git a/crates/ability/src/helper/webview.rs b/crates/ability/src/helper/webview.rs index 3272246..5cff8d2 100644 --- a/crates/ability/src/helper/webview.rs +++ b/crates/ability/src/helper/webview.rs @@ -261,6 +261,19 @@ impl Webview { } } + pub fn dispose(&self) -> Result<()> { + if let Some(env) = get_main_thread_env().borrow().as_ref() { + let dispose_js_function = self + .inner + .get_value(env)? + .get_named_property::>("dispose")?; + dispose_js_function.call(())?; + Ok(()) + } else { + Err(Error::from_reason("Failed to get main thread env")) + } + } + pub fn clear_all_browsing_data(&self) -> Result<()> { if let Some(env) = get_main_thread_env().borrow().as_ref() { let clear_all_browsing_data_js_function = self diff --git a/native_ability/src/main/ets/ability/type.ets b/native_ability/src/main/ets/ability/type.ets index 7e82db0..2a7bd67 100644 --- a/native_ability/src/main/ets/ability/type.ets +++ b/native_ability/src/main/ets/ability/type.ets @@ -60,6 +60,8 @@ export interface WebViewInitData { export interface WebViewStyle { x?: number | string; y?: number | string; + visible?: boolean; + backgroundColor?: string | Color; } export interface AbilityInitContext { @@ -79,6 +81,7 @@ export interface Module { export interface ArkHelper { exit: (code: number) => void; createWebview: (data: WebViewInitData) => Object; + createEmbeddedWebview: (data: WebViewInitData) => Object; requestPermission: (permission: string | string[]) => Promise; getWindowAvoidArea: (type: number) => WindowAvoidAreaInfo | undefined; } diff --git a/native_ability/src/main/ets/components/DefaultXComponent.ets b/native_ability/src/main/ets/components/DefaultXComponent.ets index 9fd810e..50767ad 100644 --- a/native_ability/src/main/ets/components/DefaultXComponent.ets +++ b/native_ability/src/main/ets/components/DefaultXComponent.ets @@ -4,12 +4,13 @@ import { WebViewInitData as NativeWebViewInitData, WindowAvoidAreaInfo, } from "../ability/type"; -import { exit } from "../helper"; +import { exit, objectAssign } from "../helper"; import { Loadable } from "../helper/loadable"; import { requestPermission } from "../helper/permission"; import common from "@ohos.app.ability.common"; import window from "@ohos.window"; import { + EmbeddedWebviewManager, RustWebviewNodeController, WebviewStyle, WebviewInitData, @@ -21,6 +22,7 @@ export struct DefaultXComponent { moduleName: string = ""; private rootSlot = new NodeContent(); private webviewController = new RustWebviewNodeController(this.getUIContext()); + private embeddedWebviewManager = new EmbeddedWebviewManager(this.getUIContext()); private nativeModule: ESObject; private helper: ArkHelper = { exit, @@ -66,25 +68,82 @@ export struct DefaultXComponent { onTitleChange: data?.onTitleChange, } as WebviewInitData; - if (data?.transparent && !init.style?.backgroundColor) { - init.style!.backgroundColor = Color.Transparent; + init.style = init.style || {}; + + if (data?.transparent && !init.style.backgroundColor) { + init.style.backgroundColor = Color.Transparent; } const ret = this.webviewController.addWebview(init); + const applyStyle = (style: WebviewStyle) => { + objectAssign(init.style as Record, style); + this.webviewController.updateWebviewStyle(ret.webTag, init.style as WebviewStyle); + }; ret.controller.setBackgroundColor = (color: string) => { - init.style!.backgroundColor = color; - const node = this.webviewController.getWebviewNode(ret.webTag); - node?.update(init); + applyStyle({ backgroundColor: color }); }; ret.controller.setVisible = (visible: boolean) => { - init.style!.visible = visible ? "visible" : "hidden"; - const node = this.webviewController.getWebviewNode(ret.webTag); - node?.update(init); + applyStyle({ visible }); + }; + + ret.controller.dispose = () => { + this.webviewController.removeWebview(ret.webTag); }; return ret.controller; }, + createEmbeddedWebview: (data: NativeWebViewInitData) => { + const initScripts: ScriptItem[] = (data?.initializationScripts || []).map((i) => { + return { + script: i, + scriptRules: ["*"], + } as ScriptItem; + }); + const init: WebviewInitData = { + webTag: data?.id, + url: data?.url, + html: data?.html, + headers: data?.headers, + style: (data.style || {}) as WebviewStyle, + javascriptEnable: data?.javascriptEnable ?? true, + userAgent: data?.userAgent, + devtools: data?.devtools, + autoplay: data?.autoplay, + initializationScripts: initScripts, + onDragAndDrop: data?.onDragAndDrop, + onDownloadStart: data?.onDownloadStart, + onDownloadEnd: data?.onDownloadEnd, + onNavigationRequest: data?.onNavigationRequest, + onTitleChange: data?.onTitleChange, + } as WebviewInitData; + + init.style = init.style || {}; + + if (data?.transparent && !init.style.backgroundColor) { + init.style.backgroundColor = Color.Transparent; + } + + const ret = this.embeddedWebviewManager.createWebview(init); + const applyStyle = (style: WebviewStyle) => { + objectAssign(init.style as Record, style); + this.embeddedWebviewManager.updateWebviewStyle(ret.webTag, init.style as WebviewStyle); + }; + + ret.controller.setBackgroundColor = (color: string) => { + applyStyle({ backgroundColor: color }); + }; + + ret.controller.setVisible = (visible: boolean) => { + applyStyle({ visible }); + }; + + ret.controller.dispose = () => { + this.embeddedWebviewManager.removeWebview(ret.webTag); + }; + + return ret; + }, }; @StorageProp("loadMode") loadMode: "async" | "sync" = "async"; diff --git a/native_ability/src/main/ets/webview/DefaultWebview.ets b/native_ability/src/main/ets/webview/DefaultWebview.ets index 40f9030..03f06c3 100644 --- a/native_ability/src/main/ets/webview/DefaultWebview.ets +++ b/native_ability/src/main/ets/webview/DefaultWebview.ets @@ -1,6 +1,6 @@ import { UIContext } from "@ohos.arkui.UIContext"; import web_webview from "@ohos.web.webview"; -import { NodeController, BuilderNode, FrameNode } from "@ohos.arkui.node"; +import { ComponentContent, NodeController, BuilderNode, FrameNode } from "@ohos.arkui.node"; import { randomString } from "../helper"; import { OnDownloadStartResult } from "../ability/type"; import { getCookies, JsHelper } from "./Utils"; @@ -10,7 +10,7 @@ export interface WebviewStyle { x?: number | string; y?: number | string; backgroundColor?: string | Color; - visible?: string; + visible?: boolean; } export interface WebviewInitData { @@ -34,6 +34,7 @@ export interface WebviewInitData { interface WebviewNodeData extends WebviewInitData { controller: WebviewController; + didInitialLoad?: boolean; } @Builder @@ -43,11 +44,11 @@ function WebBuilder(data: WebviewNodeData) { .width("100%") .height("100%") .position({ - x: data.style?.x || 0, - y: data.style?.y || 0, + x: data.style?.x ?? 0, + y: data.style?.y ?? 0, }) .backgroundColor(data?.style?.backgroundColor) - .visibility(data?.style?.visible === "hidden" ? Visibility.Hidden : Visibility.Visible) + .visibility(data?.style?.visible === false ? Visibility.Hidden : Visibility.Visible) .javaScriptAccess(data?.javascriptEnable) .mediaPlayGestureAccess( typeof data?.autoplay === "boolean" && data.autoplay === true ? false : true, @@ -83,17 +84,200 @@ function WebBuilder(data: WebviewNodeData) { }); } -const webViewWrap = wrapBuilder(WebBuilder); +@Builder +function EmbeddedWebBuilder(data: WebviewNodeData) { + // The embedded path needs a bounded ArkTS root so hit testing stays clipped + // to the host slot owned by native. A bare `Web` component can render in the + // right place while still participating in input as an unbounded layer. + Stack() { + Web({ src: "", controller: data.controller as web_webview.WebviewController }) + .width("100%") + .height("100%") + .backgroundColor(data?.style?.backgroundColor) + .javaScriptAccess(data?.javascriptEnable) + .mediaPlayGestureAccess( + typeof data?.autoplay === "boolean" && data.autoplay === true ? false : true, + ) + .javaScriptOnDocumentStart(data?.initializationScripts) + .onControllerAttached(() => { + if (data.didInitialLoad === true) { + return; + } + data.didInitialLoad = true; + const ctrl = data.controller; + if (data?.url) { + const header: WebHeader[] = Object.keys( + (data?.headers || {}) as Record, + ).reduce((t: WebHeader[], i) => { + t.push({ headerKey: i, headerValue: data.headers![i] } as WebHeader); + return t; + }, []); + ctrl.loadUrl(data.url, header); + } else { + ctrl.loadData(data!.html, "text/html", "UTF-8", " ", " "); + } + }) + .onLoadIntercept((event) => { + if (typeof data?.onNavigationRequest === "function") { + const url = event.data.getRequestUrl(); + const ret = data.onNavigationRequest(url); + return ret; + } + return false; + }) + .onTitleReceive((e) => { + if (typeof data?.onTitleChange === "function") { + data.onTitleChange(e.title); + } + }) + } + .width("100%") + .height("100%") + .clip(true) + .visibility(data?.style?.visible === false ? Visibility.Hidden : Visibility.Visible) + .hitTestBehavior(HitTestMode.Default); +} + +const webViewWrap = wrapBuilder<[WebviewNodeData]>(WebBuilder); +const embeddedWebViewWrap = wrapBuilder<[WebviewNodeData]>(EmbeddedWebBuilder); interface AddWebviewMethod { webTag: string; controller: JsHelper; } +export interface EmbeddedWebviewHandle extends AddWebviewMethod { + content: ComponentContent; +} + +interface EmbeddedWebviewEntry { + data: WebviewNodeData; + content: ComponentContent; +} + +function ensureWebviewNodeData(data: WebviewInitData): WebviewNodeData { + if (!data.webTag) { + data.webTag = randomString(); + } + if (!data.controller) { + data.controller = new web_webview.WebviewController(data.webTag) as WebviewController; + } + data.style = data.style || {}; + if (typeof data.javascriptEnable !== "boolean") { + data.javascriptEnable = true; + } + const prepared = data as WebviewNodeData; + prepared.didInitialLoad = prepared.didInitialLoad === true; + + if (prepared?.devtools) { + web_webview.WebviewController.setWebDebuggingAccess(true); + } + + return prepared; +} + +function setupDownloadDelegate(data: WebviewNodeData) { + if (typeof data?.onDownloadStart !== "function" && typeof data?.onDownloadEnd !== "function") { + return; + } + + const download = new web_webview.WebDownloadDelegate(); + + if (typeof data?.onDownloadStart === "function") { + download.onBeforeDownload((e) => { + const url = e.getUrl(); + const tempPath = e.getFullPath(); + const ret = data.onDownloadStart!(url, tempPath); + + if (ret.allow) { + e.start(ret.tempPath || tempPath); + } else { + e.cancel(); + } + }); + } + + if (typeof data?.onDownloadEnd === "function") { + download.onDownloadFinish((e) => { + const url = e.getUrl(); + const tempPath = e.getFullPath(); + data.onDownloadEnd!(url, tempPath, true); + }); + download.onDownloadFailed((e) => { + const url = e.getUrl(); + data.onDownloadEnd!(url, undefined, false); + }); + } + + data.controller.setDownloadDelegate(download); +} + +function buildJsHelper(controller: WebviewController): JsHelper { + const getUrl = () => { + return controller.getUrl(); + }; + const getCookiesHelper = (url: string) => { + return getCookies(url) as string; + }; + const loadUrl = (url: string, header?: Record) => { + const headers = Object.keys((header || {}) as Record).reduce( + (t, i) => { + if (!!header![i]) { + t.push({ headerKey: i, headerValue: header![i] }); + } + return t; + }, + [] as Array, + ); + controller.loadUrl(url, headers); + }; + + const loadHtml = (html: string) => { + controller.loadData(html, "text/html", "UTF-8", " ", " "); + }; + + const zoom = (scale: number) => { + controller.zoom(scale); + }; + + const refresh = () => { + controller.refresh(); + }; + + const requestFocus = () => { + controller.requestFocus(); + }; + + const clearAllBrowsingData = () => { + web_webview.WebStorage.deleteAllData(true); + web_webview.WebDataBase.deleteHttpAuthCredentials(); + controller.removeCache(true); + controller.clearHistory(); + }; + + const runJavaScript = (code: string, cb: (result?: string) => void) => { + controller.runJavaScript(code).then((ret) => { + cb(ret); + }); + }; + + return { + getUrl, + getCookies: getCookiesHelper, + loadUrl, + loadHtml, + zoom, + refresh, + requestFocus, + clearAllBrowsingData, + runJavaScript, + } as JsHelper; +} + export class RustWebviewNodeController extends NodeController { private rootNode: FrameNode | null = null; - private webviewList: Map> = new Map(); - private webviewData: Map = new Map(); + private webviewEntries: Map = new Map(); + private webviewList: Map> = new Map(); private uiContext: UIContext | null = null; constructor(uiContext: UIContext) { @@ -101,68 +285,19 @@ export class RustWebviewNodeController extends NodeController { this.uiContext = uiContext; } - private buildData(controller: WebviewController) { - 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(); - }; + updateWebviewStyle(webTag: string, style: WebviewStyle) { + const entry = this.webviewEntries.get(webTag); + const node = this.webviewList.get(webTag); + if (!entry || !node) { + return; + } - const runJavaScript = (code: string, cb: (result?: string) => void) => { - controller.runJavaScript(code).then((ret) => { - cb(ret); - }); - }; + entry.style = style; + node.update(entry); + } - const data: JsHelper = { - getUrl, - getCookies: getCookiesHelper, - loadUrl, - loadHtml, - zoom, - refresh, - requestFocus, - clearAllBrowsingData, - runJavaScript, - } as JsHelper; - return data; + private buildData(controller: WebviewController) { + return buildJsHelper(controller); } makeNode(uiContext: UIContext): FrameNode { @@ -173,68 +308,93 @@ export class RustWebviewNodeController extends NodeController { } addWebview(data: WebviewInitData): AddWebviewMethod { - if (!data.webTag) { - data.webTag = randomString(); - } - if (!data.controller) { - data.controller = new web_webview.WebviewController(data.webTag) as WebviewController; - } - + const prepared = ensureWebviewNodeData(data); + const webTag = prepared.webTag!; 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); - // 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(); - } - }); - } + const node = new BuilderNode<[WebviewNodeData]>(this.uiContext!); + node.build(webViewWrap, prepared); - 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); - }); - } + const controller = this.buildData(prepared.controller); + setupDownloadDelegate(prepared); + this.webviewEntries.set(webTag, prepared); + this.webviewList.set(webTag, node); + + this.rootNode?.appendChild(node.getFrameNode()); + + return { + webTag, + controller, + }; + } - data.controller.setDownloadDelegate(download); + removeWebview(webTag: string) { + const node = this.webviewList.get(webTag); + if (!node) { + return; } - this.webviewData.set(data.webTag, controller); - this.rootNode?.appendChild(node.getFrameNode()); + this.webviewEntries.delete(webTag); + this.webviewList.delete(webTag); + + this.rootNode?.removeChild(node.getFrameNode()); + } +} + +export class EmbeddedWebviewManager { + private uiContext: UIContext; + private entries: Map = new Map(); + + constructor(uiContext: UIContext) { + this.uiContext = uiContext; + } + + createWebview(data: WebviewInitData): EmbeddedWebviewHandle { + const prepared = ensureWebviewNodeData(data); + const webTag = prepared.webTag!; + setupDownloadDelegate(prepared); + + const content = new ComponentContent( + this.uiContext, + embeddedWebViewWrap, + prepared, + ); + + content.update(prepared); + + const controller = buildJsHelper(prepared.controller); + this.entries.set(webTag, { + data: prepared, + content, + }); + return { - webTag: data.webTag, + webTag, controller, + content, }; } - getWebviewNode(webTag: string) { - return this.webviewList.get(webTag); + updateWebviewStyle(webTag: string, style: WebviewStyle) { + const entry = this.entries.get(webTag); + if (!entry) { + return; + } + + entry.data.style = style; + entry.content.update(entry.data); + } + + removeWebview(webTag: string) { + const entry = this.entries.get(webTag); + if (!entry) { + return; + } + + this.entries.delete(webTag); + entry.content.dispose(); } } @@ -243,5 +403,6 @@ declare class WebviewController extends web_webview.WebviewController { getCookies: (url: string) => string; setBackgroundColor: (color: string) => void; setVisible: (visible: boolean) => void; + dispose: () => void; clearAllBrowsingData: () => void; } diff --git a/native_ability/src/main/ets/webview/Utils.ets b/native_ability/src/main/ets/webview/Utils.ets index 2195484..892668c 100644 --- a/native_ability/src/main/ets/webview/Utils.ets +++ b/native_ability/src/main/ets/webview/Utils.ets @@ -4,6 +4,13 @@ export const getCookies = (url: string): string => { return webview.WebCookieManager.fetchCookieSync(url); }; +export interface JsWebviewStyle { + x?: number | string; + y?: number | string; + backgroundColor?: string | Color; + visible?: boolean; +} + export interface JsHelper { getCookies: (url: string) => string; getUrl: () => string; @@ -15,5 +22,6 @@ export interface JsHelper { runJavaScript: (code: string, callback: (result?: string) => void) => void; setBackgroundColor: (color: string) => void; setVisible: (visible: boolean) => void; + dispose: () => void; clearAllBrowsingData: () => void; }