Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/apps/desktop/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
162 changes: 162 additions & 0 deletions src/apps/desktop/src/api/remote_miniapp_host.rs
Original file line number Diff line number Diff line change
@@ -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<Value, String> {
let state = self.0.state::<AppState>();
// 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::<Vec<_>>()}))
}
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
}
}
}
}
}
3 changes: 3 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
48 changes: 48 additions & 0 deletions src/apps/mobile/harmonyos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down
Original file line number Diff line number Diff line change
@@ -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', '取消'],
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>;
}

export function emptyMiniAppActions(): MiniAppActions {
return { open: () => {}, close: () => {}, back: () => {}, refresh: () => {}, select: () => {},
request: async (_raw: string): Promise<string> => '' };
}

export interface RemoteMiniAppClient {
miniAppConnection(): number;
miniApp(request: RemoteMiniAppRequest, connection: number): Promise<Object>;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { RemoteMiniAppRequest } from './RemoteMiniAppModels';
export interface RemoteDescriptor {
relayUrl: string;
roomId: string;
Expand Down Expand Up @@ -109,6 +110,7 @@ export interface ChallengeCommand {
}

export interface RemoteCommand {
request?: RemoteMiniAppRequest;
cmd: string;
_request_id?: string;
session_id?: string;
Expand Down
10 changes: 10 additions & 0 deletions src/apps/mobile/harmonyos/entry/src/main/ets/pages/AppRoot.ets
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MiniAppSurface } from './components/MiniAppSurface';
import { AppRootPresentation } from './components/AppRootPresentation';
import { WatchProvisionCard } from './components/WatchProvisionCard';
import { ArkUiAppRootHostAdapter } from './host/AppRootHostAdapter';
Expand Down Expand Up @@ -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();
}

Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface FilePreviewPresentationActions {
}

export interface RemoteHomePresentationActions {
readonly openMiniApps: () => void;
readonly openSidebar: () => void;
readonly connectWorkspace: () => void;
readonly addConnection: () => void;
Expand Down Expand Up @@ -116,6 +117,7 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions {
onCompactLayoutEntered: () => {},
onLayoutModeChanged: () => {},
onRemoteHome: {
openMiniApps: () => {},
openSidebar: () => {}, connectWorkspace: () => {}, addConnection: () => {}, openSettings: () => {},
refresh: () => {}, showWorkspaces: () => {}, showAssistants: () => {}, selectWorkspace: () => {},
selectAssistant: () => {}, cancelWorkspace: () => {}, cancelAssistant: () => {}, queryChanged: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading