diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 38bb045244..6839317c4d 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -61,3 +61,5 @@ pub mod workspace_activation; pub mod worktree_api; pub use app_state::{AppState, AppStatistics, HealthStatus, RemoteWorkspace}; + +pub mod remote_miniapp_host; diff --git a/src/apps/desktop/src/api/remote_miniapp_host.rs b/src/apps/desktop/src/api/remote_miniapp_host.rs new file mode 100644 index 0000000000..ee3d7c1fc7 --- /dev/null +++ b/src/apps/desktop/src/api/remote_miniapp_host.rs @@ -0,0 +1,162 @@ +//! Native-mobile H5 adapter. Calls the existing MiniApp owner and permission paths. +use super::{app_state::AppState, miniapp_api}; +use openbitfun_core::miniapp::is_host_primitive; +use openbitfun_services_integrations::remote_connect::miniapp::{ + MiniAppRequest, RemoteMiniAppHost, +}; +use serde_json::{json, Value}; +use tauri::Manager; + +pub struct DesktopRemoteMiniAppHost(pub tauri::AppHandle); + +#[async_trait::async_trait] +impl RemoteMiniAppHost for DesktopRemoteMiniAppHost { + async fn execute(&self, request: &MiniAppRequest) -> Result { + let state = self.0.state::(); + // These runtimes currently use host-local IO. Never accidentally read the + // controller's files while its active workspace/runtime belongs elsewhere. + if state.remote_workspace.read().await.is_some() + || super::peer_host_invoke::is_peer_controller_active() + { + return Err( + "Mobile MiniApps are not supported for SSH workspaces or Peer Device Mode yet." + .into(), + ); + } + match request { + MiniAppRequest::List => { + let apps = state + .miniapp_manager + .list() + .await + .map_err(|e| e.to_string())?; + Ok(json!({"apps": apps.into_iter().map(|app| json!({ + "id": app.id, "name": app.name, "description": app.description, + })).collect::>()})) + } + MiniAppRequest::Open { app_id } => { + let app = state + .miniapp_manager + .get(app_id) + .await + .map_err(|e| e.to_string())?; + // Compile without a workspace binding: mobile cannot silently + // inherit a changing desktop selection or grant access to it. + let html = if state + .miniapp_manager + .uses_market_strict_runtime(app_id) + .await + { + state.miniapp_manager.compile_market_source( + app_id, + &app.source, + &app.permissions, + "light", + None, + ) + } else { + state.miniapp_manager.compile_source( + app_id, + &app.source, + &app.permissions, + "light", + None, + ) + } + .map_err(|e| e.to_string())?; + if html.len() > 2 * 1024 * 1024 { + return Err( + "This MiniApp page exceeds the mobile transfer limit (2 MiB).".into(), + ); + } + Ok(json!({"id": app.id, "name": app.name, "version": app.version, "html": html})) + } + MiniAppRequest::Call { + app_id, + version, + method, + params, + } => { + let app = state + .miniapp_manager + .get(app_id) + .await + .map_err(|e| e.to_string())?; + if app.version != *version { + return Err("This MiniApp has changed. Reopen it before continuing.".into()); + } + if method.starts_with("os.") + && state + .miniapp_manager + .uses_market_strict_runtime(app_id) + .await + && !app + .permissions + .host + .as_ref() + .is_some_and(|host| host.system_info) + { + return Err("This MiniApp does not have host.system_info permission.".into()); + } + if method.starts_with("storage.") { + let key = params + .get("key") + .and_then(Value::as_str) + .ok_or("Storage key must be a string")?; + return match method.as_str() { + "storage.get" => state + .miniapp_manager + .get_storage(app_id, key) + .await + .map_err(|e| e.to_string()), + "storage.set" => state + .miniapp_manager + .set_storage( + app_id, + key, + params.get("value").cloned().unwrap_or(Value::Null), + ) + .await + .map(|_| Value::Null) + .map_err(|e| e.to_string()), + _ => Err("Unsupported mobile storage method".into()), + }; + } + if app + .permissions + .node + .as_ref() + .is_some_and(|node| !node.enabled) + { + if !is_host_primitive(method) { + return Err( + "This MiniApp has no Worker; the requested method is unsupported." + .into(), + ); + } + miniapp_api::miniapp_host_call( + state, + miniapp_api::MiniAppHostCallRequest { + app_id: app_id.clone(), + method: method.clone(), + params: params.clone(), + workspace_path: None, + }, + ) + .await + } else { + miniapp_api::miniapp_worker_call( + state, + miniapp_api::MiniAppWorkerCallRequest { + app_id: app_id.clone(), + method: method.clone(), + params: params.clone(), + workspace_path: None, + }, + ) + .await + } + } + } + } +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d113f3e0d9..716351ae58 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -910,6 +910,9 @@ pub async fn run() { } }) .setup(move |app| { + openbitfun_services_integrations::remote_connect::miniapp::register_host( + Arc::new(api::remote_miniapp_host::DesktopRemoteMiniAppHost(app.handle().clone())) + ).map_err(std::io::Error::other)?; let setup_started = Instant::now(); startup_trace.record_phase("tauri_setup_start", "native_setup"); #[cfg(target_os = "macos")] diff --git a/src/apps/mobile/harmonyos/README.md b/src/apps/mobile/harmonyos/README.md index bc042c8290..89e0a0a513 100644 --- a/src/apps/mobile/harmonyos/README.md +++ b/src/apps/mobile/harmonyos/README.md @@ -30,3 +30,51 @@ a local signing identity in DevEco Studio when installing the app on a device. The current project targets HarmonyOS `6.1.1(24)` and supports `6.0.1(21)` or newer on phone and tablet devices. + +## MiniApp H5 preview + +Connect to an updated desktop through account-device connection or QR pairing, +then choose **MiniApps** in the sidebar or remote home. Select an installed app. +Signing into an account alone is not a desktop connection: open a remote +conversation on the desired desktop first if no control target is connected. +Its compiled HTML/CSS/JavaScript runs in ArkWeb on the phone; storage and Worker +calls use the existing encrypted connection to the desktop. No market upload, +public hosting port, or mobile-web page is involved. Try the built-in Gomoku app +first: board interactions run locally and its saved statistics live on desktop. + +This first version supports `app.call`, `app.storage.get/set`, and the existing +permission-checked host primitives for apps with Node disabled. The phone never +grants filesystem permissions or inherits the desktop's current workspace; +workspace-dependent calls may report missing access. AI/Agent, desktop dialogs, +clipboard, notifications, deck export and chat integration are unsupported and +return errors. MiniApp pages use the default light appearance and must adapt +their own content to narrow screens. The compiled page transfer limit is 2 MiB. + +The desktop advertises `miniapp_h5_v1` only when its adapter is registered. +Each catalog load refreshes the advertisement so a desktop restart or upgrade +does not leave the phone using capability information from an older handshake. +Older hosts keep their existing chat behavior and show an unsupported state for +MiniApps. SSH workspaces and Peer Device Mode are explicitly rejected; CLI and +Detached Dispatch hosts have no mobile MiniApp adapter. Switching connections +invalidates an open page's ability to issue calls; reopen the app after reconnect. +App version changes also require reopening. Calls are not automatically retried, +and a timed-out Worker operation may still finish on desktop. Temporary page +state is separate from any desktop window and is discarded when the page closes. + +The ArkWeb wrapper isolates H5 in a sandboxed iframe. The native bridge treats +all frame input as untrusted and binds calls to the selected app/version and +connection generation; it does not expose a generic desktop invoke API. + +Focused coverage lives in `entry/src/test/MiniAppUnit.test.ets` (device switching, +late responses after close, unsupported calls and markup isolation). Run the +local test and HAP build commands in `AGENTS.md`; for desktop protocol changes, +also run: + +```bash +cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib remote_connect::miniapp::tests:: +cargo check -p openbitfun-desktop --lib --no-default-features +``` + +Before treating this preview as device-verified, exercise QR and account-device +connections with a real desktop, compact and wide layouts, light/dark app chrome, +keyboard input, and a live folded/unfolded transition on supported hardware. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets index 68e4ed9754..ce24a47411 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/EnUsMessages.ets @@ -1,4 +1,13 @@ export const EN_US_MESSAGES: [string, string][] = [ + ['miniapp.notConnected', 'Connect to a desktop first: open one of its remote conversations in the sidebar, then open MiniApps.'], + ['miniapp.connectionFailed', 'Unable to confirm the desktop connection. Check that it is online and retry.'], + ['miniapp.title', 'MiniApps'], + ['miniapp.refresh', 'Refresh'], + ['miniapp.empty', 'This desktop has no MiniApps yet.'], + ['miniapp.unsupported', 'Mobile MiniApps are unavailable on this connection. Connect to a supported desktop version.'], + ['miniapp.invalidPage', 'The MiniApp page is incomplete. Reopen it.'], + ['miniapp.closed', 'The MiniApp was closed or changed.'], + ['miniapp.previewHint', 'Preview · Page runs on phone; data and host calls run on desktop. AI, Agent and file pickers are not supported yet.'], ['app.title', 'OpenBitFun'], ['common.cancel', 'Cancel'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets index 7026d6da2e..cf2c94ac9e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/ZhCnMessages.ets @@ -1,4 +1,13 @@ export const ZH_CN_MESSAGES: [string, string][] = [ + ['miniapp.notConnected', '请先连接电脑:在侧边栏打开该电脑的一个远程会话,然后再打开小应用。'], + ['miniapp.connectionFailed', '暂时无法确认电脑连接,请检查电脑是否在线后重试。'], + ['miniapp.title', '小应用'], + ['miniapp.refresh', '刷新'], + ['miniapp.empty', '这台电脑还没有小应用。'], + ['miniapp.unsupported', '当前连接不支持手机小应用,请连接支持此功能的新版电脑端。'], + ['miniapp.invalidPage', '小应用页面数据不完整,请重新打开。'], + ['miniapp.closed', '小应用已关闭或切换。'], + ['miniapp.previewHint', '预览版 · 页面在手机运行,数据与宿主调用由电脑处理;暂不支持 AI、Agent 和文件选择。'], ['app.title', 'OpenBitFun'], ['common.cancel', '取消'], diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteMiniAppModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteMiniAppModels.ets new file mode 100644 index 0000000000..3bac211842 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteMiniAppModels.ets @@ -0,0 +1,68 @@ + +export const MINIAPP_H5_CAPABILITY: string = 'miniapp_h5_v1'; + +export interface RemoteMiniAppSummary { + id: string; + name: string; + description: string; +} + +export interface RemoteMiniAppRequest { + action: string; + app_id?: string; + version?: number; + method?: string; + params?: Object; +} + +export interface RemoteMiniAppPayload { + apps?: RemoteMiniAppSummary[]; + id?: string; + name?: string; + version?: number; + html?: string; +} + +export interface RemoteMiniAppResponse { + resp?: string; + message?: string; + value?: Object; +} + +export interface MiniAppBridgeRequest { + id: string; + method: string; + params?: MiniAppWorkerParams; +} + +export interface MiniAppWorkerParams { + method: string; + params?: Object; +} + +export interface MiniAppBridgeError { message: string; } +export interface MiniAppBridgeReply { + jsonrpc: string; + id: string; + result?: Object | null; + error?: MiniAppBridgeError; +} + +export interface MiniAppActions { + open: () => void; + close: () => void; + back: () => void; + refresh: () => void; + select: (id: string) => void; + request: (raw: string) => Promise; +} + +export function emptyMiniAppActions(): MiniAppActions { + return { open: () => {}, close: () => {}, back: () => {}, refresh: () => {}, select: () => {}, + request: async (_raw: string): Promise => '' }; +} + +export interface RemoteMiniAppClient { + miniAppConnection(): number; + miniApp(request: RemoteMiniAppRequest, connection: number): Promise; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 253d2ffee2..6c5f06a832 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -1,3 +1,4 @@ +import { RemoteMiniAppRequest } from './RemoteMiniAppModels'; export interface RemoteDescriptor { relayUrl: string; roomId: string; @@ -109,6 +110,7 @@ export interface ChallengeCommand { } export interface RemoteCommand { + request?: RemoteMiniAppRequest; cmd: string; _request_id?: string; session_id?: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets index bdf2ce7e99..0626dc3a9b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets @@ -1,3 +1,4 @@ +import { MiniAppSurface } from './components/MiniAppSurface'; import { AppRootPresentation } from './components/AppRootPresentation'; import { WatchProvisionCard } from './components/WatchProvisionCard'; import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter'; @@ -31,6 +32,11 @@ struct AppRoot { } onBackPress(): boolean { + if (this.runtime.miniAppViewModel.state.visible) { + if (this.runtime.miniAppViewModel.state.appId) this.runtime.miniAppViewModel.actions.back(); + else this.runtime.miniAppViewModel.close(); + return true; + } return this.runtime.handleRootBack(); } @@ -47,6 +53,10 @@ struct AppRoot { actions: this.runtime.presentationActions }) + if (this.runtime.miniAppViewModel.state.visible) { + MiniAppSurface({ state: this.runtime.miniAppViewModel.state, actions: this.runtime.miniAppViewModel.actions }) + } + // Sits above every route on purpose: a watch waiting for approval must // not be hidden behind whatever screen the phone happens to be on. WatchProvisionCard({ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index 68ee424994..6dcddad076 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -26,6 +26,7 @@ export interface FilePreviewPresentationActions { } export interface RemoteHomePresentationActions { + readonly openMiniApps: () => void; readonly openSidebar: () => void; readonly connectWorkspace: () => void; readonly addConnection: () => void; @@ -116,6 +117,7 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { onCompactLayoutEntered: () => {}, onLayoutModeChanged: () => {}, onRemoteHome: { + openMiniApps: () => {}, openSidebar: () => {}, connectWorkspace: () => {}, addConnection: () => {}, openSettings: () => {}, refresh: () => {}, showWorkspaces: () => {}, showAssistants: () => {}, selectWorkspace: () => {}, selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {}, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index 8be421a15f..17a0f4064c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -51,6 +51,7 @@ export struct AppSidebarSurface { onClose: this.actions.onSidebar.close, onNewChat: this.actions.onSidebar.newChat, onEnterCode: this.actions.onSidebar.enterCode, + onOpenMiniApps: this.actions.onRemoteHome.openMiniApps, onScanDesktop: this.actions.onSidebar.scanDesktop, onOpenViewSettings: this.onOpenRemoteViewSettings, onSearchQueryChange: (query: string) => { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index f5bdbb777b..d8b27c2a08 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -53,6 +53,7 @@ export struct AppSidebar { @Param showConversationSection: boolean = true; @Event onClose: () => void = () => {}; @Event onNewChat: () => void = () => {}; + @Event onOpenMiniApps: () => void = () => {}; @Event onEnterCode: () => void = () => {}; @Event onScanDesktop: () => void = () => {}; @Event onCollapse: () => void = () => {}; @@ -100,6 +101,14 @@ export struct AppSidebar { // the same scroll instead of pushing the current working context down. Scroll() { Column() { + if (this.shouldShowPrimaryNavigation()) { + Button(RemoteI18n.t('miniapp.title')) + .fontSize(MobileDesignTypography.titleSmall.size) + .fontColor(INK).backgroundColor(CARD) + .width('100%').height(MobileDesignGeometry.controlTouchSize) + .margin({ top: 8, bottom: 12 }) + .onClick(() => this.onOpenMiniApps()) + } if (this.showWorkspaceSection) { this.contentSlot() } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets new file mode 100644 index 0000000000..f48bafde85 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets @@ -0,0 +1,51 @@ +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { MiniAppActions, RemoteMiniAppSummary, emptyMiniAppActions } from '../../model/RemoteMiniAppModels'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MiniAppState } from '../state/MiniAppState'; +import { MiniAppWebView } from './MiniAppWebView'; +import { PAGE_BG, INK, MUTED, LINE } from './Theme'; + +@ComponentV2 +export struct MiniAppSurface { + @Param state: MiniAppState = new MiniAppState(); + @Param actions: MiniAppActions = emptyMiniAppActions(); + + build() { + Column() { + Row({ space: 16 }) { + Button(RemoteI18n.t(this.state.appId ? 'common.back' : 'common.close')) + .onClick(() => this.state.appId ? this.actions.back() : this.actions.close()) + Text(this.state.name || RemoteI18n.t('miniapp.title')) + .fontSize(MobileDesignTypography.titleMedium.size).fontColor(INK).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1) + if (!this.state.appId) { + Button(RemoteI18n.t('miniapp.refresh')).onClick(() => this.actions.refresh()).enabled(!this.state.loading) + } + }.padding(16).width('100%') + Text(RemoteI18n.t('miniapp.previewHint')).fontSize(MobileDesignTypography.bodySmall.size).fontColor(MUTED).padding({ left: 16, right: 16, bottom: 10 }) + if (this.state.loading) { + LoadingProgress().width(32).height(32).margin(24) + } else if (this.state.error) { + Text(this.state.error).fontColor(INK).padding(24).copyOption(CopyOptions.InApp) + } else if (this.state.document) { + Column() { + MiniAppWebView({ document: this.state.document, revision: this.state.revision, request: this.actions.request }) + }.width('100%').layoutWeight(1) + } else if (this.state.apps.length === 0) { + Text(RemoteI18n.t('miniapp.empty')).fontColor(MUTED).padding(24) + } else { + List({ space: 8 }) { + ForEach(this.state.apps, (app: RemoteMiniAppSummary) => { + ListItem() { + Column({ space: 6 }) { + Text(app.name).fontSize(MobileDesignTypography.titleMedium.size).fontColor(INK) + Text(app.description).fontSize(MobileDesignTypography.bodySmall.size).fontColor(MUTED).maxLines(2) + }.alignItems(HorizontalAlign.Start).padding(16).width('100%') + .border({ width: 1, color: LINE }).borderRadius(12) + .onClick(() => this.actions.select(app.id)) + } + }, (app: RemoteMiniAppSummary) => app.id) + }.padding({ left: 16, right: 16 }).layoutWeight(1).width('100%') + } + }.width('100%').height('100%').backgroundColor(PAGE_BG) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppWebView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppWebView.ets new file mode 100644 index 0000000000..2468ca9734 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppWebView.ets @@ -0,0 +1,58 @@ +import { webview } from '@kit.ArkWeb'; + +// A synthetic origin for in-memory HTML; no document is fetched from this URL. +const MINIAPP_DOCUMENT_URL: string = 'https://miniapp.invalid/'; + +/** Platform leaf. ArkWeb exposes proxies to frames; the ViewModel treats every request as untrusted. */ +class MiniAppNativeBridge { + private readonly dispatch: (raw: string) => void; + constructor(dispatch: (raw: string) => void) { this.dispatch = dispatch; } + request(raw: string): void { this.dispatch(raw); } +} + +@ComponentV2 +export struct MiniAppWebView { + @Param document: string = ''; + @Param revision: number = 0; + @Param request: (raw: string) => Promise = async (_raw: string): Promise => ''; + private controller: webview.WebviewController = new webview.WebviewController(); + private active: boolean = true; + private bridge: MiniAppNativeBridge = new MiniAppNativeBridge((raw: string): void => { + void this.reply(raw); + }); + + private async reply(raw: string): Promise { + const revision = this.revision; + const response = await this.request(raw); + if (!this.active || revision !== this.revision || !response) return; + try { + await this.controller.runJavaScript(`window.__miniappReply(JSON.parse(${JSON.stringify(response)}))`); + } catch (_error) { + // Navigation/close invalidates replies; never replay a host mutation. + } + } + + aboutToDisappear(): void { this.active = false; } + + build() { + // A nonempty initial src can overwrite loadData after controller attachment. + Web({ src: '', controller: this.controller }) + .javaScriptAccess(true) + .domStorageAccess(false) + .fileAccess(false) + .javaScriptProxy({ object: this.bridge, name: 'miniappNative', methodList: ['request'], controller: this.controller }) + .onControllerAttached(() => { + this.active = true; + // Without an HTTP(S) base, ArkWeb interprets raw HTML as a data URL: + // CSS hashes become URL fragments and truncate the page before its script. + this.controller.loadData(this.document, 'text/html', 'UTF-8', MINIAPP_DOCUMENT_URL); + }) + .onOverrideUrlLoading((request: WebResourceRequest) => { + // Only the wrapper and its sandboxed blob child may navigate here. + const url = request.getRequestUrl(); + return request.isMainFrame() ? url !== 'about:blank' && url !== MINIAPP_DOCUMENT_URL : !url.startsWith('blob:'); + }) + .width('100%') + .height('100%') + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index 0fe3683599..08dbc1c2ca 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -161,6 +161,7 @@ export struct WideConversationHost { onClose: this.actions.onSidebar.close, onNewChat: this.actions.onSidebar.newChat, onEnterCode: this.actions.onSidebar.enterCode, + onOpenMiniApps: this.actions.onRemoteHome.openMiniApps, onScanDesktop: this.actions.onSidebar.scanDesktop, onCollapse: this.onCollapseMasterPane, onOpenViewSettings: this.onOpenRemoteViewSettings, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index 042f30d732..cd2220d430 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -88,10 +88,18 @@ export struct RemoteSurfaceHost { } } + @Builder + private MiniAppEntry() { + Button(RemoteI18n.t('miniapp.title')) + .onClick(() => this.actions.onRemoteHome.openMiniApps()) + .margin({ top: 8, bottom: 8 }) + } + @Builder private MasterContent() { Column() { this.StatusRow() + this.MiniAppEntry() if (this.isInitialLoading()) { RemoteSessionLoadingView() } else if (this.canShowSessionList()) { @@ -218,6 +226,7 @@ export struct RemoteSurfaceHost { showSidebarButton: true, onOpenSidebar: this.onOpenSidebar }) + this.MiniAppEntry() if (RemoteCompactHomePolicy.shouldShowConnectHome(this.remotePageState.connectionState)) { this.DisconnectedState() } else { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets index 37ceee34b1..0ac5859dd4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntime.ets @@ -102,6 +102,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } aboutToDisappear(): void { + this.miniAppViewModel.close(); this.settingsController.stopPresencePolling(); this.watchProvisionController.stop(); this.taskCompletionNotificationController.stop(); @@ -257,6 +258,7 @@ export class AppRootRuntime extends AppRootRuntimeComposition { } async disconnect(clearPairing: boolean): Promise { + this.miniAppViewModel.close(); this.filePreviewController.invalidate(); await this.remoteConnectionController.disconnect(clearPairing); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 7867df838f..f97198b77a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -1,3 +1,4 @@ +import { MiniAppViewModel } from '../viewmodel/MiniAppViewModel'; import { ChatMessage, RemoteModelCatalog, @@ -155,6 +156,7 @@ export abstract class AppRootRuntimeComposition { abstract toggleVoiceInput(): Promise; readonly sessionManager: RemoteSessionManager = new RemoteSessionManager(); + readonly miniAppViewModel: MiniAppViewModel = new MiniAppViewModel(this.sessionManager); readonly taskCompletionNotificationController: TaskCompletionNotificationController = new TaskCompletionNotificationController( new HarmonyTaskCompletionNotificationPort((): Context => this.host.context()), @@ -926,6 +928,7 @@ export abstract class AppRootRuntimeComposition { onCompactLayoutEntered: (): void => this.enterCompactLayout(), onLayoutModeChanged: (wideLayout: boolean): void => this.appShellState.setWideLayout(wideLayout), onRemoteHome: { + openMiniApps: (): void => { this.closeAppSidebar(); this.miniAppViewModel.actions.open(); }, openSidebar: (): void => this.openAppSidebar(), connectWorkspace: (): void => this.openConnectSheet(), addConnection: (): void => this.openConnectSheet(), diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/MiniAppState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/MiniAppState.ets new file mode 100644 index 0000000000..a03a213797 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/MiniAppState.ets @@ -0,0 +1,13 @@ +import { RemoteMiniAppSummary } from '../../model/RemoteMiniAppModels'; + +@ObservedV2 +export class MiniAppState { + @Trace visible: boolean = false; + @Trace loading: boolean = false; + @Trace error: string = ''; + @Trace apps: RemoteMiniAppSummary[] = []; + @Trace appId: string = ''; + @Trace name: string = ''; + @Trace document: string = ''; + @Trace revision: number = 0; +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/MiniAppViewModel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/MiniAppViewModel.ets new file mode 100644 index 0000000000..eab628777e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/MiniAppViewModel.ets @@ -0,0 +1,90 @@ +import { MiniAppActions, MiniAppBridgeReply, MiniAppBridgeRequest, RemoteMiniAppPayload, RemoteMiniAppClient } from '../../model/RemoteMiniAppModels'; +import { MiniAppDocument } from '../../services/MiniAppDocument'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MiniAppState } from '../state/MiniAppState'; + +export class MiniAppViewModel { + readonly state: MiniAppState = new MiniAppState(); + private generation: number = 0; + private connection: number = -1; + private version: number = 0; + private readonly client: RemoteMiniAppClient; + readonly actions: MiniAppActions = { + open: (): void => { void this.open(); }, + close: (): void => this.close(), + back: (): void => { void this.open(); }, + refresh: (): void => { void this.open(); }, + select: (id: string): void => { void this.select(id); }, + request: (raw: string): Promise => this.request(raw) + }; + + constructor(client: RemoteMiniAppClient) { this.client = client; } + + close(): void { + this.generation += 1; + this.state.visible = false; + this.state.document = ''; + this.state.appId = ''; + } + + async open(): Promise { + const generation = ++this.generation; + this.connection = this.client.miniAppConnection(); + this.state.visible = true; + this.state.document = ''; + this.state.appId = ''; + this.state.name = ''; + this.state.apps = []; + this.state.error = ''; + this.state.loading = true; + try { + const value = await this.client.miniApp({ action: 'list' }, this.connection) as RemoteMiniAppPayload; + if (generation === this.generation) this.state.apps = value.apps || []; + } catch (error) { + if (generation === this.generation) this.state.error = String(error); + } finally { + if (generation === this.generation) this.state.loading = false; + } + } + + async select(id: string): Promise { + const generation = ++this.generation; + this.state.loading = true; + this.state.error = ''; + try { + const value = await this.client.miniApp({ action: 'open', app_id: id }, this.connection) as RemoteMiniAppPayload; + if (generation !== this.generation) return; + if (!value.html || value.id !== id || value.version === undefined) throw new Error(RemoteI18n.t('miniapp.invalidPage')); + this.version = value.version; + this.state.appId = id; + this.state.name = value.name || id; + this.state.document = MiniAppDocument.build(value.html, RemoteI18n.language()); + this.state.revision += 1; + } catch (error) { + if (generation === this.generation) this.state.error = String(error); + } finally { + if (generation === this.generation) this.state.loading = false; + } + } + + async request(raw: string): Promise { + const generation = this.generation; + let id: string = ''; + try { + const rpc = JSON.parse(raw) as MiniAppBridgeRequest; + id = rpc.id; + if (typeof id !== 'string' || rpc.method !== 'worker.call' || !rpc.params || + typeof rpc.params.method !== 'string' || !this.state.visible || !this.state.appId) { + throw new Error(RemoteI18n.t('miniapp.unsupported')); + } + const result = await this.client.miniApp({ action: 'call', app_id: this.state.appId, + version: this.version, method: rpc.params.method, params: rpc.params.params }, this.connection); + if (generation !== this.generation) throw new Error(RemoteI18n.t('miniapp.closed')); + const reply: MiniAppBridgeReply = { jsonrpc: '2.0', id, result: result === undefined ? null : result }; + return JSON.stringify(reply); + } catch (error) { + const reply: MiniAppBridgeReply = { jsonrpc: '2.0', id, error: { message: String(error) } }; + return JSON.stringify(reply); + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MiniAppDocument.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MiniAppDocument.ets new file mode 100644 index 0000000000..94b5b82e9f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MiniAppDocument.ets @@ -0,0 +1,35 @@ +/** Trusted wrapper and untrusted H5 are separate browsing contexts. */ +export class MiniAppDocument { + static build(html: string, locale: string): string { + const source = JSON.stringify(html).replace(/ + + +`; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 3b2b91f596..b5b4fcaca4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -1,3 +1,4 @@ +import { MINIAPP_H5_CAPABILITY, RemoteMiniAppClient, RemoteMiniAppRequest, RemoteMiniAppResponse } from '../model/RemoteMiniAppModels'; import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDescriptor, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { PairIdentity, PeerDeviceProvisionOutcome, RelayHttpClient } from './RelayHttpClient'; @@ -48,10 +49,11 @@ export interface ProvisionedPeerDevice { */ const ACCOUNT_HANDSHAKE_TIMEOUT_MS: number = 15000; -export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { +export class RemoteSessionManager implements RemoteMiniAppClient, RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { private roomClient?: RelayHttpClient; private transport?: RemoteCommandTransport; private transportGeneration: number = 0; + private miniAppSupported: boolean = false; private workspace?: WorkspaceInfo; private roomRelayUrl: string = ''; private readonly roomClientFactory: () => RelayHttpClient; @@ -67,6 +69,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile reset(): void { this.transportGeneration += 1; + this.miniAppSupported = false; this.workspace = undefined; this.roomRelayUrl = ''; const hadTransport = this.transport !== undefined; @@ -98,6 +101,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile } const workspace = RemoteResponseMapper.workspaceFromInitialSync(initialSync); + this.miniAppSupported = (initialSync.capabilities || []).includes(MINIAPP_H5_CAPABILITY); this.roomClient = roomClient; this.transport = transport; this.roomRelayUrl = descriptor.relayUrl; @@ -113,6 +117,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile private beginTransportReplacement(): number { this.transportGeneration += 1; + this.miniAppSupported = false; const hadTransport = this.transport !== undefined; this.transport?.reset(); this.transport = undefined; @@ -210,6 +215,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile this.requireCurrentGeneration(generation); this.transport = transport; this.workspace = workspace; + this.miniAppSupported = (workspaceResponse.capabilities || []).includes(MINIAPP_H5_CAPABILITY); return { workspace, sessions, @@ -225,6 +231,7 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile async getWorkspaceInfo(): Promise { const response = await this.send(RemoteCommandFactory.getWorkspaceInfo()); + this.miniAppSupported = (response.capabilities || []).includes(MINIAPP_H5_CAPABILITY); return RemoteResponseMapper.workspaceFromResponse(response); } @@ -571,6 +578,30 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile }; } + miniAppConnection(): number { return this.transportGeneration; } + + async miniApp(request: RemoteMiniAppRequest, connection: number): Promise { + this.requireCurrentGeneration(connection); + if (!this.transport) throw new Error(RemoteI18n.t('miniapp.notConnected')); + // A desktop may restart or upgrade while the relay connection survives. + // Refresh its advertised contract before each catalog load, never probe an + // unsupported command or replay a mutation to discover capabilities. + if (request.action === 'list') { + const info = await this.send(RemoteCommandFactory.getWorkspaceInfo(), 15000); + this.requireCurrentGeneration(connection); + if (info.resp !== 'workspace_info') { + throw new Error(info.message || RemoteI18n.t('miniapp.connectionFailed')); + } + this.miniAppSupported = (info.capabilities || []).includes(MINIAPP_H5_CAPABILITY); + } + if (!this.miniAppSupported) { + throw new Error(RemoteI18n.t('miniapp.unsupported')); + } + const response = await this.send({ cmd: 'miniapp', request }, 60000); + if (response.resp !== 'miniapp_result') throw new Error(response.message || RemoteI18n.t('miniapp.unsupported')); + return response.value as Object; + } + async ping(): Promise { await this.send(RemoteCommandFactory.ping()); return true; diff --git a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets index 609b4f895f..88aad63924 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/LocalUnit.test.ets @@ -1,3 +1,4 @@ +import miniAppUnitTest from './MiniAppUnit.test'; import transportAndGeneralChatUnitTest from './TransportAndGeneralChatUnit.test'; import conversationStateUnitTest from './ConversationStateUnit.test'; import conversationPresentationUnitTest from './ConversationPresentationUnit.test'; @@ -11,6 +12,7 @@ import i18nUnitTest from './I18nUnit.test'; import watchProvisionUnitTest from './WatchProvisionUnit.test'; export default function localUnitTest() { + miniAppUnitTest(); i18nUnitTest(); watchProvisionUnitTest(); architectureUnitTest(); diff --git a/src/apps/mobile/harmonyos/entry/src/test/MiniAppUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/MiniAppUnit.test.ets new file mode 100644 index 0000000000..819df7d81d --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/MiniAppUnit.test.ets @@ -0,0 +1,77 @@ +import { describe, expect, it } from '@ohos/hypium'; +import { MiniAppDocument } from '../main/ets/services/MiniAppDocument'; +import { MiniAppViewModel } from '../main/ets/pages/viewmodel/MiniAppViewModel'; +import { RemoteMiniAppClient, RemoteMiniAppPayload, RemoteMiniAppRequest } from '../main/ets/model/RemoteMiniAppModels'; + +class FakeMiniAppClient implements RemoteMiniAppClient { + generation: number = 1; + calls: RemoteMiniAppRequest[] = []; + pendingOpen?: (payload: Object) => void; + holdOpen: boolean = false; + + miniAppConnection(): number { return this.generation; } + async miniApp(request: RemoteMiniAppRequest, connection: number): Promise { + if (connection !== this.generation) throw new Error('Target changed'); + this.calls.push(request); + if (request.action === 'list') { + const value: RemoteMiniAppPayload = { apps: [{ id: 'game', name: 'Game', description: '' }] }; + return value; + } + if (request.action === 'open') { + if (this.holdOpen) return new Promise((resolve) => { this.pendingOpen = resolve; }); + const value: RemoteMiniAppPayload = { id: 'game', name: 'Game', version: 3, html: '

Game

' }; + return value; + } + return 'saved'; + } +} + +export default function miniAppUnitTest() { + describe('MobileMiniApp', () => { + it('binds_calls_to_the_open_app_and_refuses_a_replaced_device', 0, async () => { + const client = new FakeMiniAppClient(); + const model = new MiniAppViewModel(client); + await model.open(); + await model.select('game'); + const raw = '{"id":"rpc-1","method":"worker.call","params":{"method":"storage.get","params":{"key":"stats"}},"app_id":"attacker"}'; + const response = await model.request(raw); + expect(response.includes('saved')).assertTrue(); + expect(client.calls[2].app_id).assertEqual('game'); + expect(client.calls[2].version).assertEqual(3); + client.generation += 1; + expect((await model.request(raw)).includes('Target changed')).assertTrue(); + expect(client.calls.length).assertEqual(3); + }); + + it('does_not_resurrect_a_closed_page_after_a_slow_open', 0, async () => { + const client = new FakeMiniAppClient(); + const model = new MiniAppViewModel(client); + await model.open(); + client.holdOpen = true; + const loading = model.select('game'); + model.close(); + const payload: RemoteMiniAppPayload = { id: 'game', version: 3, html: '

Late

' }; + client.pendingOpen?.(payload); + await loading; + expect(model.state.visible).assertFalse(); + expect(model.state.document).assertEqual(''); + }); + + it('rejects_unsupported_bridge_methods_before_transport', 0, async () => { + const client = new FakeMiniAppClient(); + const model = new MiniAppViewModel(client); + await model.open(); + await model.select('game'); + const result = await model.request('{"id":"bad","method":"host_invoke"}'); + expect(result.includes('error')).assertTrue(); + expect(client.calls.length).assertEqual(2); + }); + + it('keeps_app_markup_outside_the_trusted_wrapper_script', 0, () => { + const html = MiniAppDocument.build('', 'en-US'); + expect(html.includes('