Skip to content
Merged
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
67 changes: 61 additions & 6 deletions MiniApp/Skills/miniapp-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ src/crates/core/src/miniapp/
├── storage.rs # ui.js, worker.js, package.json, esm_dependencies.json
├── compiler.rs # Import Map + Runtime Adapter 注入 + ESM
├── bridge_builder.rs # window.app 生成 + build_import_map()
├── permission_policy.rs # resolve_policy() → JSON 策略供 Worker 启动
├── permission_policy.rs # resolve_policy() → JSON 策略供 Worker 启动 / host_dispatch 复用
├── host_dispatch.rs # 宿主直连分发 fs/shell/os/net(无需 Bun/Node Worker)
├── runtime_detect.rs # detect_runtime() Bun/Node
├── js_worker.rs # 单进程 stdin/stderr JSON-RPC
├── js_worker_pool.rs # 池管理 + install_deps
Expand All @@ -37,7 +38,7 @@ src/apps/desktop/src/api/miniapp_api.rs
- 应用管理: `list_miniapps`, `get_miniapp`, `create_miniapp`, `update_miniapp`, `delete_miniapp`
- 存储/授权: `get/set_miniapp_storage`, `grant_miniapp_workspace`, `grant_miniapp_path`
- 版本: `get_miniapp_versions`, `rollback_miniapp`
- Worker/Runtime: `miniapp_runtime_status`, `miniapp_worker_call`, `miniapp_worker_stop`, `miniapp_install_deps`, `miniapp_recompile`
- Worker/Runtime: `miniapp_runtime_status`, `miniapp_worker_call`, `miniapp_host_call`, `miniapp_worker_stop`, `miniapp_install_deps`, `miniapp_recompile`
- 对话框由前端 Bridge 用 Tauri dialog 插件处理,无单独后端命令

### Agent 工具
Expand Down Expand Up @@ -100,15 +101,69 @@ MiniAppPermissions { fs?, shell?, net?, node? } // node 替代 env/compute
iframe 内 window.app.call(method, params)
→ postMessage({ method: 'worker.call', params: { method, params } })
→ useMiniAppBridge 监听
→ miniAppAPI.workerCall(appId, method, params)
→ Tauri invoke('miniapp_worker_call')
→ JsWorkerPool → Worker 进程 stdin → stderr 响应
→ 结果回 iframe
├─ 框架原语 (fs.* / shell.* / os.* / net.*):
│ ├─ node.enabled = false → miniAppAPI.hostCall → Tauri invoke('miniapp_host_call')
│ │ → bitfun_core::miniapp::host_dispatch(纯 Rust,无需 Bun/Node)
│ └─ node.enabled = true → miniAppAPI.workerCall → Tauri invoke('miniapp_worker_call')
│ → JsWorkerPool(保留旧路径,允许 worker.js 覆写 fs/shell 等)
├─ 自定义方法:始终走 worker.call → JsWorkerPool(要求 node.enabled = true 且 worker.js 导出)
└─ storage.* (node.enabled = false 时):直接走 get/set_miniapp_storage 命令

dialog.open / dialog.save / dialog.message
→ postMessage → useMiniAppBridge 直接调 @tauri-apps/plugin-dialog
```

### 何时使用「无 Node 模式」(推荐)

只要小应用的后端能力可以用 `fs.*` / `shell.*` / `os.*` / `net.*` 完成(例如调用 `git` 拉数据、读写工作区文件、抓取 HTTP API),就把 `permissions.node.enabled` 设为 `false`:

- 不依赖 Bun/Node 安装环境,bundle 后即点即用,避免 "JS Worker pool not initialized" 类问题;
- 安全与性能与 Worker 路径完全等价(同一份 `permission_policy`,Rust 直接执行);
- 仍然可以使用 `app.shell.exec / fs.* / net.fetch / os.info / storage.get|set` 全部框架原语。

什么时候需要 `node.enabled = true`:

- 需要写 `worker.js` 自定义方法(CPU 密集 / 长流程 / 复杂解析等);
- 需要 `npm_dependencies` 安装第三方 npm 包;
- 需要在 worker 内长期持有连接、缓存、状态。

> 走「无 Node 模式」时,**禁止** 调用 `app.call('myCustomMethod', …)`,宿主会显式报错;只能调用框架原语和 `app.storage.*`。

## 能力边界(重要)

MiniApp 框架**只暴露下列能力**,没有任何"通用 BitFun 后端通道"。设计 / 生成新小应用前请先比对,能力不在表内的需求请走相应替代方案,**不要假设有 `app.bitfun.*` / `app.workspace.*` / `app.git.*` / `app.session.*` 之类的接口存在。**

| 能力 | 入口 | 说明 |
|---|---|---|
| 文件系统 | `app.fs.*` | 受 `permissions.fs.read/write` 路径白名单限制 |
| 子进程 / 命令行 | `app.shell.exec` | 受 `permissions.shell.allow` 命令名白名单限制 |
| HTTP | `app.net.fetch` | 受 `permissions.net.allow` 域名白名单限制 |
| 系统信息 | `app.os.info` | 仅 platform / cpus / homedir / tmpdir 等只读字段 |
| KV 存储 | `app.storage.get/set` | 每个小应用独立的 `storage.json`,跨会话保留 |
| AI | `app.ai.complete / chat / cancel / getModels` | 复用宿主 AIClient,受 `permissions.ai`(含 `allowed_models` / 速率限制) |
| 对话框 | `app.dialog.open/save/message` | Tauri dialog 插件 |
| 剪贴板 | `app.clipboard.readText/writeText` | 宿主 navigator.clipboard |
| 自定义后端 | `app.call('xxx', …)` + `worker.js` | 仅 `node.enabled = true` 时可用,自己实现业务逻辑 |
| 主题 / i18n | `app.theme` / `app.locale` / `app.onThemeChange` / `app.onLocaleChange` / `app.t(...)` | 见对应章节 |

### 框架**不**直接暴露的 BitFun 后端能力(截至本文档)

下面这些 BitFun 内部服务,目前**没有**给小应用开放调用通道:

- WorkspaceService(结构化工作区索引、统一搜索)
- GitService(结构化 status / diff / blame,区别于裸 `git` 命令)
- TerminalService(创建/读写交互式终端)
- Session / AgenticSystem(启动 Agent 会话、消费工具调用与流式事件)
- LSP / Snapshot / Mermaid / Skills / Browser API / Computer Use / Config 等

需要这类能力时的合规姿势:

1. **能用裸命令行解决的**(如 git)→ 在 `permissions.shell.allow` 里加命令名,用 `app.shell.exec` 包一层(参考 `builtin-coding-selfie/ui.js` 的 `scanGitWorkspace`);
2. **只是要读 BitFun 工作区内的文件**(如某些项目元数据) → 把 `{workspace}` 加到 `permissions.fs.read`,自己用 `app.fs.*` 读 + 在前端解析;
3. **必须真调用某个内部服务** → 暂不支持,先记录到需求池。**不要**自己起一个 worker 去模拟服务行为,会和真正的 service 行为漂移。

> 维护者:以后若新增 `app.bitfun.*` / `app.workspace.*` 这类宿主直通通道,请同步更新本节,避免"文档说没有、代码偷偷加了"的不一致。

## window.app 运行时 API

MiniApp UI 内通过 **window.app** 访问:
Expand Down
44 changes: 44 additions & 0 deletions MiniApp/Skills/miniapp-dev/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,27 @@

> **实际全局对象为 `window.app`**(非 `window.__BITFUN__`),以下各节均基于 `window.app`。

## 能力边界(先看这一节)

MiniApp **能且只能**用以下 API,没有任何"通用 BitFun 后端通道"。生成代码前请先确认你需要的能力在表内:

- `app.fs.*` —— 文件系统(受 `permissions.fs.read/write` 限制)
- `app.shell.exec` —— 子进程命令行(受 `permissions.shell.allow` 命令名白名单限制)
- `app.net.fetch` —— HTTP 请求(受 `permissions.net.allow` 域名白名单限制)
- `app.os.info` —— 只读系统信息
- `app.storage.get/set` —— 每应用独立 KV 存储
- `app.ai.complete / chat / cancel / getModels` —— 复用宿主 AI(无需 API Key)
- `app.dialog.open/save/message` —— 文件对话框
- `app.clipboard.readText/writeText` —— 剪贴板
- `app.call('xxx', ...)` + `worker.js` —— 自定义 Node 后端(仅 `node.enabled = true` 时)
- `app.theme / locale / on*` —— 主题与 i18n

**框架不暴露**的 BitFun 后端能力(截至当前版本):WorkspaceService(结构化搜索 / 索引)、GitService(结构化 status/diff/blame)、TerminalService、Session/AgenticSystem、LSP / Snapshot / Mermaid / Skills / Browser / Computer Use / Config 等。需要这些能力时:

1. 能用裸命令行解决就用 `app.shell.exec`(如 git → 在 `permissions.shell.allow` 加 `"git"`,参考 `builtin-coding-selfie`);
2. 只是要读 BitFun 工作区里的文件就用 `app.fs.*`(把 `{workspace}` 加到 `permissions.fs.read`);
3. 必须真正调用某个内部服务 → 暂不支持,请先记录到需求池,**不要**自己 hack 一个 worker 去模拟服务行为。

## 标准 Node.js API(通过 require() shim)

### fs/promises
Expand Down Expand Up @@ -160,6 +181,8 @@ const info = await app.os.info(); // { platform, homedir, tmpdir, ... }
const result = await app.call('myWorkerMethod', { key: 'value' });
```

> **要求 `permissions.node.enabled = true`**。`node.enabled = false` 时只能调用框架原语(`app.fs.* / shell.* / net.* / os.* / storage.*`),调用任何自定义方法会得到明确的错误提示。

---

## `app.ai.*` — AI 接口(v2)
Expand Down Expand Up @@ -358,6 +381,27 @@ const savePath = await app.dialog.save({
}
```

### 无 Node 模式:`node.enabled = false`

如果你的小应用只用 `app.fs.* / app.shell.* / app.net.fetch / app.os.info / app.storage.*`(即不需要在 `worker.js` 里自定义任何方法、也不需要安装 npm 依赖),把 `node.enabled` 设为 `false`:

```json
{
"permissions": {
"fs": { "read": ["{workspace}", "{appdata}"], "write": ["{appdata}"] },
"shell": { "allow": ["git"] },
"node": { "enabled": false }
}
}
```

宿主会把这些框架原语直接路由到 Rust 实现(`bitfun_core::miniapp::host_dispatch`),完全不需要 Bun/Node 运行时;权限策略与 Worker 路径共用同一份 `resolve_policy`,行为完全等价。在这种模式下:

- `app.shell.exec` / `app.fs.*` / `app.net.fetch` / `app.os.info` / `app.storage.get|set` —— 全部可用;
- `app.call('myCustomMethod', …)` —— **不可用**(宿主会显式报错),需要走完整的 Worker 路径请把 `node.enabled` 设回 `true` 并提供 `worker.js`。

推荐:所有"只是包一下 git/curl/系统命令"的开发者工具型小应用都使用此模式,避免 bundle 后宿主缺少 Bun/Node 时的运行时报错。

路径变量:
- `{appdata}` — `{user_data_dir}/miniapps/{app_id}/data/`,始终可读写
- `{workspace}` — 当前打开的工作区路径
Expand Down
63 changes: 60 additions & 3 deletions src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,19 @@ impl AppState {
log::warn!("Failed to seed built-in miniapps: {}", e);
}

let worker_host_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("worker_host.js");
let worker_host_path = match resolve_worker_host_path() {
Some(p) => {
log::info!("Resolved worker_host.js at: {}", p.display());
p
}
None => {
log::warn!(
"worker_host.js not found in any candidate location; \
MiniApp Workers will not start"
);
std::path::PathBuf::from("worker_host.js")
}
};
let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path)
.ok()
.map(Arc::new);
Expand Down Expand Up @@ -490,3 +500,50 @@ impl AppState {
self.remote_workspace.read().await.is_some()
}
}

/// Try every layout we know about for `worker_host.js`, dev or bundled:
/// 1. `CARGO_MANIFEST_DIR/resources/worker_host.js` — `cargo run` / `tauri dev`.
/// 2. `<exe_dir>/resources/worker_host.js` — generic side-by-side bundle.
/// 3. `<exe_dir>/../Resources/resources/worker_host.js` — macOS `.app` (Tauri
/// copies bundle.resources into `Contents/Resources/`).
/// 4. `<exe_dir>/../Resources/worker_host.js` — flat macOS layout fallback.
/// 5. `<exe_dir>/../lib/<bin>/resources/worker_host.js` — typical Linux deb/AppImage.
/// 6. `<exe_dir>/../share/<bin>/resources/worker_host.js` — alt Linux layout.
fn resolve_worker_host_path() -> Option<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();

candidates.push(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("worker_host.js"),
);

if let Ok(exe) = std::env::current_exe() {
if let Some(exe_dir) = exe.parent() {
candidates.push(exe_dir.join("resources").join("worker_host.js"));
if let Some(parent) = exe_dir.parent() {
candidates
.push(parent.join("Resources").join("resources").join("worker_host.js"));
candidates.push(parent.join("Resources").join("worker_host.js"));
if let Some(bin_name) = exe.file_name().and_then(|s| s.to_str()) {
candidates.push(
parent
.join("lib")
.join(bin_name)
.join("resources")
.join("worker_host.js"),
);
candidates.push(
parent
.join("share")
.join(bin_name)
.join("resources")
.join("worker_host.js"),
);
}
}
}
}

candidates.into_iter().find(|p| p.exists())
}
60 changes: 58 additions & 2 deletions src/apps/desktop/src/api/miniapp_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
use crate::api::app_state::AppState;
use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent};
use bitfun_core::miniapp::{
InstallResult as CoreInstallResult, MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions,
MiniAppSource,
dispatch_host, is_host_primitive, InstallResult as CoreInstallResult, MiniApp,
MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource,
};
use bitfun_core::service::config::types::GlobalConfig;
use bitfun_core::util::types::Message;
Expand Down Expand Up @@ -127,6 +127,17 @@ pub struct MiniAppWorkerCallRequest {
pub workspace_path: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MiniAppHostCallRequest {
pub app_id: String,
pub method: String,
#[serde(default)]
pub params: Value,
#[serde(default)]
pub workspace_path: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MiniAppRecompileRequest {
Expand Down Expand Up @@ -525,6 +536,51 @@ pub async fn miniapp_worker_call(
Ok(result)
}

/// Host-side framework primitive RPC.
///
/// Routes `fs.*` / `shell.*` / `os.*` / `net.*` calls directly to the Rust
/// implementation in `bitfun_core::miniapp::host_dispatch`, no Bun/Node Worker
/// required. Used for MiniApps with `permissions.node.enabled = false` (and as
/// the future migration target for everyone, since these calls don't actually
/// need a JS sandbox).
#[tauri::command]
pub async fn miniapp_host_call(
state: State<'_, AppState>,
request: MiniAppHostCallRequest,
) -> Result<Value, String> {
if !is_host_primitive(&request.method) {
return Err(format!(
"method '{}' is not a host primitive (only fs.*/shell.*/os.*/net.* are supported)",
request.method
));
}
let app = state
.miniapp_manager
.get(&request.app_id)
.await
.map_err(|e| e.to_string())?;
let workspace_root = workspace_root_from_input(request.workspace_path.as_deref());
let app_data_dir = state
.miniapp_manager
.path_manager()
.miniapp_dir(&request.app_id);
let granted = state
.miniapp_manager
.granted_paths_for_app(&request.app_id)
.await;
dispatch_host(
&app.permissions,
&request.app_id,
&app_data_dir,
workspace_root.as_deref(),
&granted,
&request.method,
request.params,
)
.await
.map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn miniapp_worker_stop(state: State<'_, AppState>, app_id: String) -> Result<(), String> {
if let Some(ref pool) = state.js_worker_pool {
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ pub async fn run() {
api::miniapp_api::grant_miniapp_path,
api::miniapp_api::miniapp_runtime_status,
api::miniapp_api::miniapp_worker_call,
api::miniapp_api::miniapp_host_call,
api::miniapp_api::miniapp_worker_stop,
api::miniapp_api::miniapp_worker_list_running,
api::miniapp_api::miniapp_install_deps,
Expand Down
3 changes: 2 additions & 1 deletion src/apps/desktop/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"icons/icon.png"
],
"resources": {
"../../mobile-web/dist": "mobile-web/dist"
"../../mobile-web/dist": "mobile-web/dist",
"resources/worker_host.js": "resources/worker_host.js"
},
"linux": {
"deb": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"fs": { "read": ["{appdata}", "{workspace}"], "write": ["{appdata}"] },
"shell": { "allow": ["git"] },
"net": { "allow": [] },
"node": { "enabled": true, "max_memory_mb": 256, "timeout_ms": 20000 }
"node": { "enabled": false }
},
"ai_context": null,
"i18n": {
Expand Down
Loading
Loading