diff --git a/AGENTS.md b/AGENTS.md index abc15c8340..ff0c13f292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -368,3 +368,50 @@ document a command only when a real workflow needs it. ## Agent-doc priority Prefer the nearest matching `AGENTS.md` / `AGENTS-CN.md` for the directory you are changing. If local guidance conflicts with this file, follow the more specific, nearer document. + +## Cursor Cloud specific instructions + +These notes cover non-obvious gotchas for developing BitFun inside the Cloud +Agent VM. Standard commands live in `README.md`, `CONTRIBUTING.md`, and +`package.json`; only the surprises are recorded here. The base image already +has Node 22, pnpm 10.15 (Corepack), the Rust stable toolchain, and the Tauri +Linux system libraries (webkit2gtk-4.1, gtk-3, libxdo, appindicator, librsvg2, +tesseract/leptonica, xcb) preinstalled. The startup script only runs +`pnpm install` plus the two generated-artifact steps below. + +- **Rust must be >= 1.85.** The committed lockfile pulls crates that require the + `edition2024` Cargo feature, so an older toolchain fails resolution with + "feature `edition2024` is required". The VM uses current stable (1.98+). +- **Linker fix for `rust-lld` (`-lstdc++` not found).** Rust's default + `rust-lld` linker on this image does not search GCC's private lib dir, where + the `libstdc++.so` dev symlink lives, so C++-linking crates + (tesseract/leptonica in `bitfun-desktop`) fail to link. This is resolved + globally in `$CARGO_HOME/config.toml` (`/usr/local/cargo/config.toml`) via + `[env] LIBRARY_PATH = "/usr/lib/gcc/x86_64-linux-gnu/13"`. If a fresh VM ever + hits `unable to find library -lstdc++`, recreate that config entry. +- **Generated TypeScript API barrel is required for the Web UI.** + `src/web-ui/src/generated/api/` is git-ignored and produced by + `pnpm --dir src/web-ui run gen:types` (which runs a `cargo test ... export`, + so it needs the Rust toolchain). Without it, `pnpm run type-check:web`, + `pnpm run lint:web`, `pnpm run dev:web`, and the Vite dev server fail to + resolve `@/generated/api`. `pnpm run build:web` regenerates it automatically; + `type-check:web` / `dev:web` do not. The startup script runs `gen:types` once + so the tree is ready. +- **`bitfun-desktop` Rust builds need `src/mobile-web/dist`.** The Tauri build + script references it as a resource, so bare `cargo check/build -p + bitfun-desktop` (or `--workspace`) fails with "resource path doesn't exist" + until you run `pnpm run prepare:mobile-web`. `pnpm run desktop:dev` builds it + automatically in its prep step; the startup script also pre-builds it. +- **Running the desktop app.** `pnpm run desktop:dev` (the primary loop) works + headlessly on `DISPLAY=:1`; the GUI window opens there and the Vite dev + server serves on `localhost:1422`. The first `tauri dev` run recompiles the + workspace with the `devtools` feature, which is a long cold build; subsequent + runs are incremental. A prebuilt debug binary can be launched faster with + `pnpm run desktop:preview:debug` (frontend HMR only, no Rust auto-rebuild). +- **Exercising the AI agent needs an LLM key.** The agent chat loop is inert + until a provider/model is configured in Settings → Models (see README "First + run"). Model configuration and keys are stored under `~/.config/bitfun` + (outside the repo), so they are never committed. +- **Bun** (for the CLI plugin/extension host, `pnpm run plugin-host:*` and + `cli:*`) is not preinstalled; install `bun@1.3.14` only if you work on the CLI + surface. diff --git a/Cargo.lock b/Cargo.lock index dbc807b104..b516d61d35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,6 +1541,7 @@ dependencies = [ name = "bitfun-services-core" version = "0.2.19" dependencies = [ + "aes-gcm", "anyhow", "async-trait", "base64 0.22.1", @@ -1558,6 +1559,7 @@ dependencies = [ "libc", "log", "notify", + "rand 0.8.7", "regex", "rusqlite", "serde", diff --git a/docs/features/mini-app-system.md b/docs/features/mini-app-system.md new file mode 100644 index 0000000000..7426971947 --- /dev/null +++ b/docs/features/mini-app-system.md @@ -0,0 +1,337 @@ +# Mini App System — Design Specification + +**English** · Status: living specification (implemented incrementally) + +This document is the design specification for BitFun's **Mini App** system. It +defines three things the runtime and UI must agree on: + +1. **Lifecycle events** — `install`, `uninstall`, `start`, `stop`, each of which + may trigger a user-defined script. +2. **View modes** — `background` (collapsed into a panel), `front` (default, in a + tab), and `full` (an independent OS window). +3. **App management** — the standardized on-disk location and directory + structure for an installed app. + +It complements the architecture map in +[`product-architecture.md`](../architecture/product-architecture.md) and the +security boundary in +[`../sdlc-harness/architecture/security-boundary.md`](../sdlc-harness/architecture/security-boundary.md). +It does **not** cover BitFun Pages / Page Functions +(`bitfun-page-function-runtime`), which are a separate relay-hosted system. + +Layer ownership follows the repository boundary rules: + +| Concern | Owner layer | Crate / path | +| --- | --- | --- | +| Data shapes, pure lifecycle/view-mode decisions, path contract | Contracts | `src/crates/contracts/product-domains/src/miniapp` | +| Concrete filesystem IO, worker/script process execution | Services | `src/crates/services/services-integrations/src/miniapp` | +| Manager orchestration, `PathManager` wiring, event emission | Assembly | `src/crates/assembly/core/src/miniapp` | +| Tauri commands, independent windows | Interface (desktop) | `src/apps/desktop/src/api` | +| Gallery, scenes, panels, window hosting | Interface (web-ui) | `src/web-ui/src/app/scenes/miniapps` | + +--- + +## 1. App management: local path and directory structure + +### 1.1 Root location + +Mini App data is **user-scoped**, not workspace-scoped. The root is resolved by +`PathManager`: + +- `miniapps_dir()` → `{user_root}/data/miniapps/` +- `miniapp_dir(app_id)` → `{user_root}/data/miniapps/{app_id}/` + +`{user_root}` is the platform BitFun home: + +| OS | `{user_root}` | +| --- | --- | +| Linux | `~/.config/bitfun` | +| macOS | `~/Library/Application Support/BitFun` | +| Windows | `%APPDATA%/BitFun` | + +The frontend mirrors the segment as `MINIAPP_DATA_PATH_SEGMENT = +'/data/miniapps/'`. Workspace services explicitly exclude these paths from +workspace files. + +`app_id` is a UUID v4 for user-created / imported / draft apps; built-ins use a +stable `builtin-*` id. + +### 1.2 Per-app directory layout + +The canonical layout (owned by `MiniAppStorageLayout` in +`product-domains/.../miniapp/storage.rs`): + +``` +{user_root}/data/miniapps/{app_id}/ +├── meta.json # MiniAppMeta: identity, permissions, runtime state, +│ # view_mode, lifecycle scripts, i18n +├── compiled.html # Generated sandbox document (UI + import map + bridge) +├── package.json # npm deps for the worker +├── storage.json # App key/value storage +├── .customization.json # Origin / market / override metadata +├── .builtin-manifest.json # Built-in seed marker (built-ins only) +├── source/ +│ ├── index.html +│ ├── style.css +│ ├── ui.js # ESM browser module +│ ├── worker.js # Node/Bun worker entry +│ └── esm_dependencies.json +├── hooks/ # (optional) lifecycle scripts — see §2 +│ ├── install.js +│ ├── uninstall.js +│ ├── start.js +│ └── stop.js +├── scripts/ # (optional) named capability scripts — see §2b +│ └── .js +└── versions/ + └── v{N}.json # Full snapshots for rollback +``` + +Drafts live under a sibling sandbox and never touch the active app until +applied: + +``` +{user_root}/data/miniapps/.drafts/{app_id}/{draft_id}/ +``` + +Market install/update/rollback use temporary staging dirs +(`.market-install-*`, `.market-update-*`, `.market-rollback-*`) and commit +atomically so a failed install never leaves a half-written app. + +The `hooks/` directory is a **recommended convention** (constant +`HOOKS_DIR = "hooks"`), not a hard requirement: a lifecycle script path is any +path relative to the app root (see §2.2). When an app is imported from a folder, +the whole `hooks/` directory is copied into the installed app dir so declared +lifecycle scripts exist at runtime. + +--- + +## 2. Lifecycle events and scripts + +### 2.1 Events + +A Mini App has four host-driven lifecycle transitions +(`MiniAppLifecycleEvent`): + +| Event | When it fires | Typical use | +| --- | --- | --- | +| `install` | After the app's files are first committed to disk (create / import / market install) | Fetch assets, initialize `storage.json`, scaffold data | +| `uninstall` | Before the app directory is removed | Clean up external state, revoke tokens | +| `start` | When the app is activated / its worker is brought up | Warm caches, open connections | +| `stop` | When the app is deactivated / its worker is torn down | Flush state, close connections | + +These are distinct from the in-iframe UI hooks (`app.onActivate` / +`app.onDeactivate`) exposed by the bridge, which react to focus changes inside +an already-running app. Lifecycle scripts run in the **host JS runtime** +(Bun/Node), not in the sandboxed iframe. + +### 2.2 Declaring scripts + +Scripts are declared in `meta.json` under `lifecycle` +(`MiniAppLifecycleScripts`): + +```json +{ + "lifecycle": { + "install": "hooks/install.js", + "uninstall": "hooks/uninstall.js", + "start": "hooks/start.js", + "stop": "hooks/stop.js" + } +} +``` + +Rules: + +- Each value is a path **relative to the app root**. `hooks/install.js` and + `worker.js` are both valid; the recommended location is `hooks/`. +- Whitespace-only or absent entries mean "no script for this event". +- The field is omitted from `meta.json` entirely when no scripts are declared + (`skip_serializing_if`), so existing apps are unaffected. + +### 2.3 Resolution and safety + +Path resolution is a **pure decision** in the contracts layer +(`plan_lifecycle_script` → `MiniAppStorageLayout::resolve_contained_relative`): + +- The relative path is resolved against the app directory. +- Absolute paths, `..` parent components, and root/drive prefixes are + **rejected** — a script can never escape its own app directory. +- A path that resolves back to the app root itself is rejected. + +The services layer then confirms the file exists and executes it; the contracts +layer never touches the filesystem. + +### 2.4 Execution semantics + +- Scripts run with the detected runtime (`RuntimeKind::Bun` preferred, else + `Node`), using the same non-interactive process facade as the worker + (`bitfun_services_core::process_manager`), so no console window flashes on + Windows and GUI/headless hosts behave identically. +- The working directory is the app directory; the resolved script path is + passed as the entry. +- Scripts run with the app's resolved permission policy (fs/net/shell scopes), + identical to the worker, so a lifecycle script cannot exceed what the app is + already granted. +- Each run emits a `miniapp-lifecycle` event + (`{ id, event, script, succeeded, exitCode, error }`) for the UI / telemetry, + and the desktop command returns the outcome to the caller. +- Lifecycle scripts are **best-effort**: a failing script (non-zero exit, + missing file, traversal attempt, or no runtime) is surfaced via the event, + the command result, and logs, but it does **not** abort or roll back the + surrounding flow. `install` runs after the app is committed, `uninstall` runs + before removal (an app must always be removable), and `stop` runs as part of + worker teardown. This mirrors how npm lifecycle scripts behave and keeps the + app store consistent even when a hook misbehaves; authors that need hard + guarantees should assert inside the script and react to the reported failure. + +--- + +## 2b. Named scripts (capability extension) + +Beyond the four fixed lifecycle hooks, an app may ship **named scripts** to +extend its capabilities — arbitrary commands the author bundles (recommended +under `scripts/`) and invokes on demand. + +Declared in `meta.json` under `scripts` (`Vec`): + +```json +{ + "scripts": [ + { "name": "build", "path": "scripts/build.js", "description": "Rebuild output" }, + { "name": "sync", "path": "scripts/sync.js" } + ] +} +``` + +- `name` is the stable invocation id; `path` is resolved against the app root + with the same traversal guard as lifecycle scripts (`find_script_path` + + `plan_named_script`), and run with the detected JS runtime. +- Invocation: `MiniAppManager::run_named_script(app_id, name, args)` → + desktop command `miniapp_run_script` (emits a `miniapp-script` event with the + outcome). The gallery detail modal lists declared scripts with a Run button; + `MiniAppAPI.runScript` / `setScripts` back it. +- Execution semantics match §2.4: trusted host code in the app dir, with + `BITFUN_MINIAPP_{ID,DIR,SCRIPT,POLICY}` env and forwarded CLI `args`, + captured stdout/stderr/exit-code, best-effort (failures surfaced, never + auto-rollback). +- Named scripts are part of the content hash and carried on import (the + `scripts/` directory travels with the app). + +## 3. View modes + +### 3.1 Modes + +`MiniAppViewMode` (persisted in `meta.json` as `view_mode`) selects how the host +presents the app: + +| Mode | Wire value | Presentation | +| --- | --- | --- | +| `Background` | `background` | Collapsed into a compact resident panel (dock / side rail). Stays running without occupying the main content area. | +| `Front` (default) | `front` | Opens inside a tab in the main content scene area. This is today's behavior and the default for existing apps. | +| `Full` | `full` | Opens in its own independent OS window, detached from the main shell. | + +`view_mode` defaults to `Front`; unknown wire values fall back to `Front` +(`MiniAppViewMode::from_wire`), so an older client reading a newer mode degrades +to a tab rather than failing. + +### 3.2 Semantics + +- The mode is a **persisted app property** (part of the content hash), editable + by the author and via the view-mode command; it is the app's default + presentation. +- The runtime may still let the user temporarily relocate an open app (e.g. + pop a `front` app out to a window), but the persisted `view_mode` is the + restore default. +- `background` apps keep their worker resident and surface through the nav + running-apps entry and a compact panel; they do not claim a scene tab. +- `full` apps are hosted in an independent desktop window that loads the same + compiled document and bridge as the tab host, so behavior and permissions are + identical across modes. + +### 3.3 Remote / non-desktop surfaces + +View mode is a presentation hint. Surfaces that cannot honor a mode (mobile web, +CLl/TUI, peer host) must degrade explicitly to their supported presentation +(typically `front`-equivalent) rather than silently dropping the app, per the +repository's "degrade loudly" rule. + +--- + +## 4. Data model and upgrade compatibility + +`MiniApp` and `MiniAppMeta` gain two additive, defaulted fields: + +- `view_mode: MiniAppViewMode` — `#[serde(default)]`, defaults to `Front`. +- `lifecycle: MiniAppLifecycleScripts` — `#[serde(default)]`, omitted when empty. + +Both are included in `miniapp_content_hash`, so editing them is tracked like any +other content change. Because both deserialize from absent values, **existing +`meta.json` files load unchanged** and keep their current behavior (in-tab, no +scripts) after an upgrade. No field that old data cannot supply is required, and +no persisted field is repurposed — consistent with the upgrade-compatibility +rules in the root `AGENTS.md`. + +--- + +## 5. Implementation status + +The specification is delivered incrementally. Current state: + +- [x] Contracts: `MiniAppViewMode`, `MiniAppLifecycleEvent`, + `MiniAppLifecycleScripts`, `view_mode` / `lifecycle` fields, `HOOKS_DIR`, + safe path resolver, `plan_lifecycle_script`, and event payload — with unit + and contract tests in `bitfun-product-domains`. +- [x] Services: `run_lifecycle_script` runs a resolved script (Bun/Node) via the + non-interactive process facade, injecting app/event/policy env and + capturing stdout/stderr/exit code, in `bitfun-services-integrations`. +- [x] Assembly: `MiniAppManager::run_lifecycle_event` (traversal-guarded resolve + + runtime detect + run + report), `set_view_mode`, and + `set_lifecycle_scripts`, wired to `PathManager` and the permission policy, + with tests. +- [x] Desktop: Tauri commands `miniapp_set_view_mode`, + `miniapp_set_lifecycle_scripts`, `miniapp_run_lifecycle_event`, and + `open_miniapp_full_window`; automatic `install` (create/import), + `uninstall` (delete), and `stop` (worker stop) dispatch with + `miniapp-lifecycle` events. +- [x] Web UI: `MiniAppAPI` gains `setViewMode` / `setLifecycleScripts` / + `runLifecycleEvent` / `openFullWindow` and `view_mode` / `lifecycle` types; + opening an app branches on view mode — `full` opens an independent OS + window via the `?bitfunWindow=miniapp` standalone render, `background` + stays resident in the collapsed `MiniAppBackgroundDock` panel, `front` + opens a tab — and fires the `start` lifecycle event on activation. The + gallery detail modal exposes a view-mode selector (background / tab / + window). + +### Verified + +- Contracts/services/assembly: `cargo test` for `bitfun-product-domains` + (58 lib + 39 contract), `bitfun-services-integrations` miniapp runtime + import + IO, and `bitfun-core` `miniapp::manager` (incl. a real hook-script run). +- View modes: GUI-verified live — `background` dock, `front` tab, and `full` + window all open, and the full window renders real app content. +- Lifecycle: end-to-end verified — importing an app with an `install` hook runs + the script during the install event (marker file written with `BITFUN_MINIAPP_*` + env context), with `hooks/` carried into the installed app dir. + +- [x] Named scripts: `MiniAppScriptDef` + `scripts` manifest field, + `find_script_path` / `plan_named_script`, args-aware `run_miniapp_script`, + `MiniAppManager::run_named_script` / `set_scripts`, desktop + `miniapp_run_script` / `miniapp_set_scripts`, `MiniAppAPI` + gallery Run + UI, `scripts/` carried on import — with contract + manager tests. + +### Design boundary: scripts and the market + +Lifecycle hooks and named scripts run as **trusted, host-privileged** code +(outside the iframe sandbox). They are therefore supported for **user-created +and folder-imported** apps, where the user is the author/installer of that code. + +Market-distributed packages intentionally do **not** carry `hooks/` or +`scripts/`: the market ZIP is a strict, separately-validated whitelist +(`meta.json` + `source/*`), and allowing a downloaded package to ship +host-privileged scripts that auto-run on install would be a security escalation. +Bringing scripts to market apps is a future item that requires an explicit +review/consent model (and matching client + server validator changes), not a +simple whitelist widening. + +Each subsequent change keeps this table current. diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 16701bec1c..5472c626ae 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -16,6 +16,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "startup_window_control", "toggle_main_window_fullscreen", "set_main_window_transient_geometry", + "open_miniapp_full_window", "get_prevent_sleep_enabled", "set_prevent_sleep_enabled", "restart_app", diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 945fca9010..bca9f4839f 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -164,6 +164,7 @@ impl AppState { let miniapp_manager = Arc::new(MiniAppManager::new(path_manager.clone())); initialize_global_miniapp_manager(miniapp_manager.clone()); + bitfun_core::user_secrets::initialize_global_user_secrets(path_manager.clone()); match miniapp_manager.mark_stale_drafts_for_cleanup().await { Ok(cleanup_targets) if !cleanup_targets.is_empty() => { let cleanup_manager = miniapp_manager.clone(); diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 9bf92d8794..178845eba4 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -16,6 +16,9 @@ use bitfun_core::miniapp::lifecycle::{ workspace_root_from_input, }; use bitfun_core::miniapp::rate_limit::{MiniAppRateLimitState, MiniAppRateLimitSubject}; +use bitfun_core::miniapp::types::{ + MiniAppLifecycleEvent, MiniAppLifecycleScripts, MiniAppScriptDef, MiniAppViewMode, +}; use bitfun_core::miniapp::{ dispatch_host, is_host_primitive, InstallResult as CoreInstallResult, MiniApp, MiniAppAiContext, MiniAppCustomizationMetadata, MiniAppDraft, MiniAppMeta, @@ -280,6 +283,47 @@ async fn emit_miniapp_event(event_name: &str, payload: Value) { .await; } +/// Run a MiniApp lifecycle event's script (if declared) and emit a +/// `miniapp-lifecycle` event describing the outcome. Failures are surfaced to +/// the UI and logged; they are best-effort and never abort the surrounding +/// install/uninstall/stop flow (see docs/features/mini-app-system.md). +async fn run_and_emit_lifecycle( + state: &State<'_, AppState>, + app_id: &str, + event: MiniAppLifecycleEvent, +) -> Option { + match state + .miniapp_manager + .run_lifecycle_event(app_id, event) + .await + { + Ok(Some(report)) => { + if !report.succeeded { + log::warn!( + "MiniApp lifecycle '{}' script failed for {}: exit={:?} error={:?} stderr={}", + event.as_str(), + app_id, + report.exit_code, + report.error, + report.stderr.trim() + ); + } + emit_miniapp_event("miniapp-lifecycle", report.to_event_payload(app_id)).await; + Some(report) + } + Ok(None) => None, + Err(error) => { + log::warn!( + "MiniApp lifecycle '{}' dispatch error for {}: {}", + event.as_str(), + app_id, + error + ); + None + } + } +} + async fn maybe_stop_worker(state: &State<'_, AppState>, app: &MiniApp) { if should_stop_worker_for_runtime_update(app) { if let Some(ref pool) = state.js_worker_pool { @@ -409,6 +453,7 @@ pub async fn create_miniapp( miniapp_runtime_event_payload(&app, "create"), ) .await; + run_and_emit_lifecycle(&state, &app.id, MiniAppLifecycleEvent::Install).await; Ok(app) } @@ -446,6 +491,9 @@ pub async fn update_miniapp( #[tauri::command] pub async fn delete_miniapp(state: State<'_, AppState>, app_id: String) -> Result<(), String> { + // Run the uninstall hook while the app directory (and its script) still + // exist; failures are surfaced but do not block removal. + run_and_emit_lifecycle(&state, &app_id, MiniAppLifecycleEvent::Uninstall).await; if let Some(ref pool) = state.js_worker_pool { pool.stop(app_id.as_str()).await; } @@ -692,6 +740,7 @@ pub async fn miniapp_worker_stop(state: State<'_, AppState>, app_id: String) -> if let Some(ref pool) = state.js_worker_pool { pool.stop(&app_id).await; } + run_and_emit_lifecycle(&state, &app_id, MiniAppLifecycleEvent::Stop).await; emit_miniapp_event( "miniapp-worker-stopped", miniapp_worker_stopped_payload(&app_id, "manual-stop"), @@ -806,9 +855,190 @@ pub async fn miniapp_import_from_path( miniapp_runtime_event_payload(&app, "import"), ) .await; + run_and_emit_lifecycle(&state, &app.id, MiniAppLifecycleEvent::Install).await; + Ok(app) +} + +/// Set a MiniApp's persisted view mode (`background` / `front` / `full`). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetViewModeRequest { + pub app_id: String, + pub view_mode: String, +} + +#[tauri::command] +pub async fn miniapp_set_view_mode( + state: State<'_, AppState>, + request: SetViewModeRequest, +) -> Result { + let mode = MiniAppViewMode::from_wire(&request.view_mode); + let app = state + .miniapp_manager + .set_view_mode(&request.app_id, mode) + .await + .map_err(|e| e.to_string())?; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "view-mode"), + ) + .await; + Ok(app) +} + +/// Replace a MiniApp's lifecycle scripts (install/uninstall/start/stop). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetLifecycleScriptsRequest { + pub app_id: String, + #[serde(default)] + pub lifecycle: MiniAppLifecycleScripts, +} + +#[tauri::command] +pub async fn miniapp_set_lifecycle_scripts( + state: State<'_, AppState>, + request: SetLifecycleScriptsRequest, +) -> Result { + let app = state + .miniapp_manager + .set_lifecycle_scripts(&request.app_id, request.lifecycle) + .await + .map_err(|e| e.to_string())?; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "lifecycle"), + ) + .await; + Ok(app) +} + +/// Explicitly trigger a lifecycle event (used by the UI for start/stop on +/// activation/deactivation; install/uninstall also fire automatically). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunLifecycleEventRequest { + pub app_id: String, + pub event: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifecycleRunResult { + pub ran: bool, + pub succeeded: bool, + pub exit_code: Option, + pub error: Option, +} + +#[tauri::command] +pub async fn miniapp_run_lifecycle_event( + state: State<'_, AppState>, + request: RunLifecycleEventRequest, +) -> Result { + let Some(event) = MiniAppLifecycleEvent::from_wire(&request.event) else { + return Err(format!("Unknown lifecycle event: {}", request.event)); + }; + match run_and_emit_lifecycle(&state, &request.app_id, event).await { + Some(report) => Ok(LifecycleRunResult { + ran: true, + succeeded: report.succeeded, + exit_code: report.exit_code, + error: report.error, + }), + None => Ok(LifecycleRunResult { + ran: false, + succeeded: true, + exit_code: None, + error: None, + }), + } +} + +/// Replace a MiniApp's named scripts (`scripts` in the manifest). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetScriptsRequest { + pub app_id: String, + #[serde(default)] + pub scripts: Vec, +} + +#[tauri::command] +pub async fn miniapp_set_scripts( + state: State<'_, AppState>, + request: SetScriptsRequest, +) -> Result { + let app = state + .miniapp_manager + .set_scripts(&request.app_id, request.scripts) + .await + .map_err(|e| e.to_string())?; + emit_miniapp_event( + "miniapp-updated", + miniapp_runtime_event_payload(&app, "scripts"), + ) + .await; Ok(app) } +/// Run a named script the app declared, forwarding optional args. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunScriptRequest { + pub app_id: String, + pub script: String, + #[serde(default)] + pub args: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptRunResult { + pub ran: bool, + pub succeeded: bool, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub error: Option, +} + +#[tauri::command] +pub async fn miniapp_run_script( + state: State<'_, AppState>, + request: RunScriptRequest, +) -> Result { + let report = state + .miniapp_manager + .run_named_script(&request.app_id, &request.script, request.args) + .await + .map_err(|e| e.to_string())?; + match report { + Some(report) => { + if !report.succeeded { + log::warn!( + "MiniApp script '{}' failed for {}: exit={:?} error={:?} stderr={}", + report.name, + request.app_id, + report.exit_code, + report.error, + report.stderr.trim() + ); + } + emit_miniapp_event("miniapp-script", report.to_event_payload(&request.app_id)).await; + Ok(ScriptRunResult { + ran: true, + succeeded: report.succeeded, + exit_code: report.exit_code, + stdout: report.stdout, + stderr: report.stderr, + error: report.error, + }) + } + None => Err(format!("MiniApp has no script named '{}'", request.script)), + } +} + #[tauri::command] pub async fn miniapp_sync_from_fs( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index f7adf9ce6f..d6a4a1378b 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -57,6 +57,7 @@ pub mod system_api; pub mod terminal_api; pub mod token_usage_api; pub mod tool_api; +pub mod user_secrets_api; pub mod workspace_activation; pub mod worktree_api; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index fcc2800946..419fd6bb21 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -38,6 +38,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "startup_window_control", "toggle_main_window_fullscreen", "set_main_window_transient_geometry", + "open_miniapp_full_window", "get_prevent_sleep_enabled", "set_prevent_sleep_enabled", "restart_app", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index b724fda7e5..269537a57f 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -382,6 +382,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("delete_session", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_skill", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_subagent", RemoteWorkspacePolicy::LegacyUnaudited), + ("delete_user_secret", RemoteWorkspacePolicy::LocalOnly), // Detached dispatch is routed by its own immutable target and observer // index, never by the currently open workspace. ("dispatch_cancel", RemoteWorkspacePolicy::WorkspaceAgnostic), @@ -957,6 +958,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "list_subscription_accounts", RemoteWorkspacePolicy::LocalOnly, ), + ("list_user_secrets", RemoteWorkspacePolicy::LocalOnly), ( "list_visible_subagents", RemoteWorkspacePolicy::RemoteRouted, @@ -1274,6 +1276,11 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "miniapp_render_slide_page", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "miniapp_run_lifecycle_event", + RemoteWorkspacePolicy::LocalOnly, + ), + ("miniapp_run_script", RemoteWorkspacePolicy::LocalOnly), ( "miniapp_runtime_status", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1282,6 +1289,12 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "miniapp_set_draft_permissions", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "miniapp_set_lifecycle_scripts", + RemoteWorkspacePolicy::LocalOnly, + ), + ("miniapp_set_scripts", RemoteWorkspacePolicy::LocalOnly), + ("miniapp_set_view_mode", RemoteWorkspacePolicy::LocalOnly), ( "miniapp_sync_draft_from_fs", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1315,6 +1328,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "open_html_file_in_browser", RemoteWorkspacePolicy::LocalOnly, ), + ("open_miniapp_full_window", RemoteWorkspacePolicy::LocalOnly), ("open_remote_workspace", RemoteWorkspacePolicy::RemoteRouted), ("open_workspace", RemoteWorkspacePolicy::LegacyUnaudited), ( @@ -2034,6 +2048,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "upload_image_contexts", RemoteWorkspacePolicy::LegacyUnaudited, ), + ("upsert_user_secret", RemoteWorkspacePolicy::LocalOnly), ("validate_config", RemoteWorkspacePolicy::LegacyUnaudited), ( "validate_skill_path", diff --git a/src/apps/desktop/src/api/user_secrets_api.rs b/src/apps/desktop/src/api/user_secrets_api.rs new file mode 100644 index 0000000000..266837dad8 --- /dev/null +++ b/src/apps/desktop/src/api/user_secrets_api.rs @@ -0,0 +1,45 @@ +//! User secret variables — settings CRUD (values never returned). + +use bitfun_core::user_secrets::{ + delete_user_secret as delete_user_secret_inner, list_user_secrets as list_user_secrets_inner, + upsert_user_secret as upsert_user_secret_inner, +}; +use bitfun_core_types::{UserSecretSummary, UserSecretUpsert}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserSecretRequest { + pub name: String, + pub value: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteUserSecretRequest { + pub name: String, +} + +#[tauri::command] +pub async fn list_user_secrets() -> Result, String> { + list_user_secrets_inner().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn upsert_user_secret( + request: UpsertUserSecretRequest, +) -> Result { + upsert_user_secret_inner(UserSecretUpsert { + name: request.name, + value: request.value, + }) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn delete_user_secret(request: DeleteUserSecretRequest) -> Result { + delete_user_secret_inner(&request.name) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index a9f7130e99..9794cd382c 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -977,6 +977,51 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( Ok(()) } +/// Open (or focus) an independent OS window hosting a MiniApp in `full` view +/// mode. The window loads the shared Web UI with the `miniapp` window role so it +/// renders the same runner/bridge as the in-tab host. +#[tauri::command] +pub async fn open_miniapp_full_window( + app: tauri::AppHandle, + app_id: String, + title: Option, +) -> Result<(), String> { + // MiniApp ids are UUIDs or `builtin-*`; both are valid Tauri window labels. + if app_id.is_empty() + || !app_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(format!("Invalid MiniApp id for window label: {app_id}")); + } + let label = format!("miniapp-{app_id}"); + + if let Some(window) = app.get_webview_window(&label) { + let _ = window.unminimize(); + window + .show() + .map_err(|e| format!("Failed to show MiniApp window: {e}"))?; + window + .set_focus() + .map_err(|e| format!("Failed to focus MiniApp window: {e}"))?; + return Ok(()); + } + + let url = app_url(&format!("?bitfunWindow=miniapp&miniAppId={app_id}")); + let window_title = title.unwrap_or_else(|| "BitFun Mini App".to_string()); + let builder = tauri::WebviewWindowBuilder::new(&app, &label, url) + .title(window_title) + .inner_size(1024.0, 720.0) + .min_inner_size(480.0, 360.0) + .resizable(true) + .visible(true); + + builder + .build() + .map_err(|e| format!("Failed to create MiniApp window: {e}"))?; + Ok(()) +} + #[tauri::command] pub async fn resize_agent_companion_desktop_pet( app: tauri::AppHandle, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 8b3a70737a..6bfc714bb9 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1884,6 +1884,12 @@ pub async fn run() { api::miniapp_api::miniapp_runtime_status, api::miniapp_api::miniapp_worker_call, api::miniapp_api::miniapp_host_call, + api::miniapp_api::miniapp_set_view_mode, + api::miniapp_api::miniapp_set_lifecycle_scripts, + api::miniapp_api::miniapp_run_lifecycle_event, + api::miniapp_api::miniapp_set_scripts, + api::miniapp_api::miniapp_run_script, + appearance::open_miniapp_full_window, api::canvas_api::load_canvas_artifact, api::canvas_api::load_canvas_state, api::canvas_api::report_canvas_runtime_error, @@ -1966,6 +1972,10 @@ pub async fn run() { api::insights_api::cancel_insights_generation, // Token usage statistics API api::token_usage_api::get_token_usage_statistics, + // User secret variables (settings); values never leave the host vault + api::user_secrets_api::list_user_secrets, + api::user_secrets_api::upsert_user_secret, + api::user_secrets_api::delete_user_secret, // SSH Remote API api::ssh_api::ssh_list_saved_connections, api::ssh_api::ssh_save_connection, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 5ae029aee2..4c9606b919 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -233,6 +233,7 @@ agent-runtime = [ "bitfun-services-core/workspace-text-runtime", "bitfun-services-core/session-git", "bitfun-services-core/token-usage-statistics", + "user-secrets", "filesystem", "local-storage", "process-runtime", @@ -420,6 +421,7 @@ review-platform = ["bitfun-services-integrations/review-platform"] service-integrations = ["announcement", "file-watch", "git", "review-platform"] diagnostics = ["bitfun-services-core/diagnostics"] diff = ["bitfun-services-core/diff"] +user-secrets = ["bitfun-services-core/user-secrets"] dispatch-store = ["dep:base64", "local-storage", "bitfun-services-core/dispatch-workspace"] filesystem = ["bitfun-services-core/filesystem"] local-storage = ["dep:bitfun-agent-tools", "bitfun-services-core/local-storage"] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs index 018d514e27..32a8269d1a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs @@ -566,6 +566,9 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), + scripts: Default::default(), i18n: locale_name.map(|value| MiniAppI18n { locales: HashMap::from([( "en-US".to_string(), diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index b9ce46f837..6e3ca39cda 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -28,9 +28,10 @@ use bitfun_agent_tools::{ build_tool_execution_timeout_presentation, build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, build_write_tail_closure_notice, - render_tool_result_for_assistant, validate_tool_execution_admission, PermissionIntent, - ResolvedToolInvocation, ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, - ToolExecutionErrorPresentation, GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, + render_tool_result_for_assistant, resolve_secret_placeholders_in_value, + validate_tool_execution_admission, PermissionIntent, ResolvedToolInvocation, + ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, ToolExecutionErrorPresentation, + GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, }; use bitfun_runtime_ports::{ PermissionReply, PermissionRequest, PermissionRequestSource, PermissionRequestSourceKind, @@ -2128,7 +2129,11 @@ impl ToolPipeline { let tool_context = self.build_tool_use_context(task, cancellation_token); - let execution_future = tool.call(task.effective_arguments(), &tool_context); + // Resolve {{secret}} placeholders into a local copy used only for + // execution. Wire/history arguments stay unresolved so the model never + // sees plaintext secret values. + let execution_arguments = resolve_tool_execution_arguments(task).await?; + let execution_future = tool.call(&execution_arguments, &tool_context); let timeout_owner = resolve_contextual_tool( Arc::clone(&tool), @@ -2194,7 +2199,28 @@ impl ToolPipeline { cancellation_token, ) } +} +/// Resolve `{{secret}}` placeholders for tool execution only. +/// +/// Persisted / model-visible arguments stay on `task.effective_arguments()`; +/// this returns a separate Value used solely as `tool.call` input. +async fn resolve_tool_execution_arguments(task: &ToolTask) -> BitFunResult { + let wire_args = task.effective_arguments().clone(); + #[cfg(feature = "user-secrets")] + { + let secrets = crate::user_secrets::load_user_secret_values().await?; + resolve_secret_placeholders_in_value(&wire_args, &secrets) + .map_err(|e| BitFunError::Tool(e.to_string())) + } + #[cfg(not(feature = "user-secrets"))] + { + let _ = resolve_secret_placeholders_in_value; + Ok(wire_args) + } +} + +impl ToolPipeline { /// Handle streaming results async fn handle_streaming_results( &self, diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index bbfbbd5e71..4582a48964 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -282,6 +282,11 @@ impl PathManager { self.user_root.join("data") } + /// Directory that holds the encrypted user-secrets vault files. + pub fn user_secrets_dir(&self) -> PathBuf { + self.user_data_dir() + } + /// User-level managed model resources shared across workspaces. pub fn user_models_dir(&self) -> PathBuf { self.user_data_dir().join("models") diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 2f0bc52fe0..0b7a0822f7 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -61,6 +61,8 @@ mod runtime_ownership_tests; pub mod service; // Workspace, Config, FileSystem, Terminal, Git #[cfg(feature = "agent-runtime")] pub(crate) mod service_agent_runtime; +#[cfg(feature = "user-secrets")] +pub mod user_secrets; pub mod util; // General types, errors, helper functions // Re-export debug_log from infrastructure for backward compatibility. diff --git a/src/crates/assembly/core/src/miniapp/manager.rs b/src/crates/assembly/core/src/miniapp/manager.rs index 9f88d0d56c..c9da4d13af 100644 --- a/src/crates/assembly/core/src/miniapp/manager.rs +++ b/src/crates/assembly/core/src/miniapp/manager.rs @@ -12,6 +12,14 @@ use crate::miniapp::types::{ }; use crate::product_domain_runtime::CoreProductDomainRuntime; use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_product_domains::miniapp::runtime::detect_runtime; +use bitfun_product_domains::miniapp::storage::resolve_contained_relative; +use bitfun_product_domains::miniapp::types::{ + find_script_path, MiniAppLifecycleEvent, MiniAppScriptDef, MiniAppViewMode, +}; +use bitfun_services_integrations::miniapp::lifecycle_runner::{ + run_lifecycle_script, run_miniapp_script, DEFAULT_LIFECYCLE_TIMEOUT_MS, +}; use bitfun_product_domains::miniapp::customization::{ MiniAppCustomizationBaseline, MiniAppCustomizationMetadata, MiniAppPermissionDiff, }; @@ -43,6 +51,88 @@ pub fn try_get_global_miniapp_manager() -> Option> { GLOBAL_MINIAPP_MANAGER.get().cloned() } +/// Result of dispatching a MiniApp lifecycle event (install/uninstall/start/stop). +/// +/// Returned by [`MiniAppManager::run_lifecycle_event`] when a script is declared +/// for the event. `succeeded` is false when the script exits non-zero or could +/// not be run (`error` then carries the reason); callers decide how strictly to +/// treat each event (see the design spec: install aborts, uninstall is +/// best-effort, start/stop are reported). +#[derive(Debug, Clone)] +pub struct MiniAppLifecycleReport { + pub event: MiniAppLifecycleEvent, + pub relative_path: String, + pub succeeded: bool, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub error: Option, +} + +impl MiniAppLifecycleReport { + fn failed(event: MiniAppLifecycleEvent, relative_path: String, error: String) -> Self { + Self { + event, + relative_path, + succeeded: false, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(error), + } + } + + /// JSON payload for the `miniapp-lifecycle` frontend/telemetry event. + pub fn to_event_payload(&self, app_id: &str) -> serde_json::Value { + serde_json::json!({ + "id": app_id, + "event": self.event.as_str(), + "script": self.relative_path, + "succeeded": self.succeeded, + "exitCode": self.exit_code, + "error": self.error, + }) + } +} + +/// Result of running a named MiniApp script (`scripts` in the manifest). +#[derive(Debug, Clone)] +pub struct MiniAppScriptRunReport { + pub name: String, + pub relative_path: String, + pub succeeded: bool, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub error: Option, +} + +impl MiniAppScriptRunReport { + fn failed(name: String, relative_path: String, error: String) -> Self { + Self { + name, + relative_path, + succeeded: false, + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(error), + } + } + + /// JSON payload for the `miniapp-script` frontend/telemetry event. + pub fn to_event_payload(&self, app_id: &str) -> serde_json::Value { + serde_json::json!({ + "id": app_id, + "script": self.name, + "path": self.relative_path, + "succeeded": self.succeeded, + "exitCode": self.exit_code, + "error": self.error, + }) + } +} + /// MiniApp manager: create, read, update, delete, list, compile, rollback. pub struct MiniAppManager { storage: MiniAppStorage, @@ -221,6 +311,12 @@ impl MiniAppManager { source, permissions, ai_context, + // View mode, lifecycle hooks, and named scripts are managed through + // dedicated paths (import/meta + view-mode / scripts commands); the + // generic update keeps them unchanged. + view_mode: None, + lifecycle: None, + scripts: None, }; let now = Utc::now().timestamp_millis(); let compiled_html = if self.uses_market_strict_runtime(app_id).await { @@ -258,6 +354,237 @@ impl MiniAppManager { self.storage.delete(app_id).await } + /// Set the app's persisted view mode (background / front / full). + /// + /// View mode does not affect the compiled document, so the existing compiled + /// HTML is preserved rather than recompiled. + pub async fn set_view_mode( + &self, + app_id: &str, + view_mode: MiniAppViewMode, + ) -> BitFunResult { + let previous_app = self.storage.load(app_id).await?; + let now = Utc::now().timestamp_millis(); + let compiled_html = previous_app.compiled_html.clone(); + let patch = MiniAppUpdatePatch { + view_mode: Some(view_mode), + ..Default::default() + }; + self.runtime_facade() + .persist_update_result_for_app(app_id.to_string(), previous_app, patch, compiled_html, now) + .await + .map_err(map_miniapp_port_error) + } + + /// Replace the app's persisted lifecycle scripts + /// (install/uninstall/start/stop). Like [`set_view_mode`], this does not + /// change the compiled document, so the existing compiled HTML is preserved. + /// + /// [`set_view_mode`]: MiniAppManager::set_view_mode + pub async fn set_lifecycle_scripts( + &self, + app_id: &str, + lifecycle: bitfun_product_domains::miniapp::types::MiniAppLifecycleScripts, + ) -> BitFunResult { + let previous_app = self.storage.load(app_id).await?; + let now = Utc::now().timestamp_millis(); + let compiled_html = previous_app.compiled_html.clone(); + let patch = MiniAppUpdatePatch { + lifecycle: Some(lifecycle), + ..Default::default() + }; + self.runtime_facade() + .persist_update_result_for_app(app_id.to_string(), previous_app, patch, compiled_html, now) + .await + .map_err(map_miniapp_port_error) + } + + /// Replace the app's named scripts (`scripts` in the manifest). Like the + /// other metadata setters this preserves the compiled document. + pub async fn set_scripts( + &self, + app_id: &str, + scripts: Vec, + ) -> BitFunResult { + let previous_app = self.storage.load(app_id).await?; + let now = Utc::now().timestamp_millis(); + let compiled_html = previous_app.compiled_html.clone(); + let patch = MiniAppUpdatePatch { + scripts: Some(scripts), + ..Default::default() + }; + self.runtime_facade() + .persist_update_result_for_app(app_id.to_string(), previous_app, patch, compiled_html, now) + .await + .map_err(map_miniapp_port_error) + } + + /// Run a named script declared by the app, forwarding `args` to the process. + /// + /// Returns `Ok(None)` when the app declares no script with `name`. Named + /// scripts are trusted, author-provided host code (like lifecycle hooks): + /// they run in the app directory with the resolved permission policy exposed + /// via environment variables, not inside the iframe sandbox. + pub async fn run_named_script( + &self, + app_id: &str, + name: &str, + args: Vec, + ) -> BitFunResult> { + let meta = self.storage.load_meta(app_id).await?; + let Some(relative) = find_script_path(&meta.scripts, name) else { + return Ok(None); + }; + let relative = relative.to_string(); + let script_name = name.trim().to_string(); + let app_dir = self.path_manager.miniapp_dir(app_id); + + let Some(script_path) = resolve_contained_relative(&app_dir, &relative) else { + return Ok(Some(MiniAppScriptRunReport::failed( + script_name, + relative, + "Script path escapes the app directory".to_string(), + ))); + }; + if tokio::fs::metadata(&script_path).await.is_err() { + return Ok(Some(MiniAppScriptRunReport::failed( + script_name, + relative, + format!("Script not found: {}", script_path.to_string_lossy()), + ))); + } + let Some(runtime) = detect_runtime() else { + return Ok(Some(MiniAppScriptRunReport::failed( + script_name, + relative, + "No JS runtime (Bun/Node) detected for script".to_string(), + ))); + }; + + let policy = self + .resolve_policy_for_app(app_id, &meta.permissions, None) + .await; + let extra_env = vec![ + ("BITFUN_MINIAPP_ID".to_string(), app_id.to_string()), + ( + "BITFUN_MINIAPP_DIR".to_string(), + app_dir.to_string_lossy().to_string(), + ), + ("BITFUN_MINIAPP_SCRIPT".to_string(), script_name.clone()), + ("BITFUN_MINIAPP_POLICY".to_string(), policy.to_string()), + ]; + let timeout_ms = meta + .permissions + .node + .as_ref() + .and_then(|node| node.timeout_ms) + .unwrap_or(DEFAULT_LIFECYCLE_TIMEOUT_MS); + + match run_miniapp_script(&runtime, &script_path, &app_dir, &args, &extra_env, timeout_ms) + .await + { + Ok(outcome) => Ok(Some(MiniAppScriptRunReport { + name: script_name, + relative_path: relative, + succeeded: outcome.succeeded, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stderr: outcome.stderr, + error: None, + })), + Err(error) => Ok(Some(MiniAppScriptRunReport::failed( + script_name, + relative, + error, + ))), + } + } + + /// Dispatch a lifecycle event for an app, running its declared script (if + /// any) with the detected JS runtime. + /// + /// Returns `Ok(None)` when the app declares no script for `event`. When a + /// script is declared, returns a report describing the run (including + /// failures) so the caller can emit a `miniapp-lifecycle` event and decide + /// how strictly to react. Lifecycle scripts are trusted, author-provided + /// host code (analogous to npm lifecycle scripts): they run in the app + /// directory with the resolved permission policy exposed via environment + /// variables, not inside the iframe sandbox. + pub async fn run_lifecycle_event( + &self, + app_id: &str, + event: MiniAppLifecycleEvent, + ) -> BitFunResult> { + let meta = self.storage.load_meta(app_id).await?; + let Some(relative) = meta.lifecycle.script_for(event) else { + return Ok(None); + }; + let relative = relative.to_string(); + let app_dir = self.path_manager.miniapp_dir(app_id); + + let Some(script_path) = resolve_contained_relative(&app_dir, &relative) else { + return Ok(Some(MiniAppLifecycleReport::failed( + event, + relative, + "Lifecycle script path escapes the app directory".to_string(), + ))); + }; + + if tokio::fs::metadata(&script_path).await.is_err() { + return Ok(Some(MiniAppLifecycleReport::failed( + event, + relative, + format!( + "Lifecycle script not found: {}", + script_path.to_string_lossy() + ), + ))); + } + + let Some(runtime) = detect_runtime() else { + return Ok(Some(MiniAppLifecycleReport::failed( + event, + relative, + "No JS runtime (Bun/Node) detected for lifecycle script".to_string(), + ))); + }; + + let policy = self + .resolve_policy_for_app(app_id, &meta.permissions, None) + .await; + let extra_env = vec![ + ("BITFUN_MINIAPP_ID".to_string(), app_id.to_string()), + ( + "BITFUN_MINIAPP_DIR".to_string(), + app_dir.to_string_lossy().to_string(), + ), + ( + "BITFUN_MINIAPP_EVENT".to_string(), + event.as_str().to_string(), + ), + ("BITFUN_MINIAPP_POLICY".to_string(), policy.to_string()), + ]; + let timeout_ms = meta + .permissions + .node + .as_ref() + .and_then(|node| node.timeout_ms) + .unwrap_or(DEFAULT_LIFECYCLE_TIMEOUT_MS); + + match run_lifecycle_script(&runtime, &script_path, &app_dir, &extra_env, timeout_ms).await { + Ok(outcome) => Ok(Some(MiniAppLifecycleReport { + event, + relative_path: relative, + succeeded: outcome.succeeded, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stderr: outcome.stderr, + error: None, + })), + Err(error) => Ok(Some(MiniAppLifecycleReport::failed(event, relative, error))), + } + } + /// Get the path manager (for external callers that need paths like miniapp_dir). pub fn path_manager(&self) -> &Arc { &self.path_manager @@ -890,6 +1217,158 @@ mod tests { .unwrap() } + #[tokio::test] + async fn set_view_mode_persists_mode_without_recompiling() { + use bitfun_product_domains::miniapp::types::MiniAppViewMode; + + let manager = test_manager(); + let app = create_sample_app(&manager).await; + assert_eq!(app.view_mode, MiniAppViewMode::Front); + let original_html = app.compiled_html.clone(); + + let updated = manager + .set_view_mode(&app.id, MiniAppViewMode::Full) + .await + .unwrap(); + assert_eq!(updated.view_mode, MiniAppViewMode::Full); + assert_eq!(updated.compiled_html, original_html); + + let reloaded = manager.get(&app.id).await.unwrap(); + assert_eq!(reloaded.view_mode, MiniAppViewMode::Full); + } + + #[tokio::test] + async fn run_lifecycle_event_is_none_when_no_script_declared() { + use bitfun_product_domains::miniapp::types::MiniAppLifecycleEvent; + + let manager = test_manager(); + let app = create_sample_app(&manager).await; + let report = manager + .run_lifecycle_event(&app.id, MiniAppLifecycleEvent::Install) + .await + .unwrap(); + assert!(report.is_none()); + } + + #[tokio::test] + async fn run_lifecycle_event_runs_declared_script() { + use bitfun_product_domains::miniapp::types::{ + MiniAppLifecycleEvent, MiniAppLifecycleScripts, + }; + + let manager = test_manager(); + let app = create_sample_app(&manager).await; + + // Write a real hook script under the app directory. + let app_dir = manager.path_manager().miniapp_dir(&app.id); + let hooks_dir = app_dir.join("hooks"); + tokio::fs::create_dir_all(&hooks_dir).await.unwrap(); + tokio::fs::write( + hooks_dir.join("install.js"), + "console.log('installed:' + process.env.BITFUN_MINIAPP_EVENT);\n", + ) + .await + .unwrap(); + + manager + .set_lifecycle_scripts( + &app.id, + MiniAppLifecycleScripts { + install: Some("hooks/install.js".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + + let report = manager + .run_lifecycle_event(&app.id, MiniAppLifecycleEvent::Install) + .await + .unwrap() + .expect("a report is produced when a script is declared"); + assert_eq!(report.event, MiniAppLifecycleEvent::Install); + assert_eq!(report.relative_path, "hooks/install.js"); + + // A JS runtime is available in this environment; assert the script ran. + // If none is present the report carries an explanatory error instead. + if report.error.is_none() { + assert!(report.succeeded, "stderr: {}", report.stderr); + assert!(report.stdout.contains("installed:install")); + } else { + assert!(!report.succeeded); + } + + // Traversal is rejected even if declared. + manager + .set_lifecycle_scripts( + &app.id, + MiniAppLifecycleScripts { + start: Some("../escape.js".to_string()), + ..Default::default() + }, + ) + .await + .unwrap(); + let escape = manager + .run_lifecycle_event(&app.id, MiniAppLifecycleEvent::Start) + .await + .unwrap() + .expect("declared script yields a report"); + assert!(!escape.succeeded); + assert!(escape.error.is_some()); + } + + #[tokio::test] + async fn run_named_script_runs_declared_script_with_args() { + use bitfun_product_domains::miniapp::types::MiniAppScriptDef; + + let manager = test_manager(); + let app = create_sample_app(&manager).await; + + let app_dir = manager.path_manager().miniapp_dir(&app.id); + let scripts_dir = app_dir.join("scripts"); + tokio::fs::create_dir_all(&scripts_dir).await.unwrap(); + tokio::fs::write( + scripts_dir.join("echo.js"), + "console.log('script:' + process.env.BITFUN_MINIAPP_SCRIPT + ':' + (process.argv[2] || ''));\n", + ) + .await + .unwrap(); + + // Unknown script -> None. + assert!(manager + .run_named_script(&app.id, "missing", vec![]) + .await + .unwrap() + .is_none()); + + manager + .set_scripts( + &app.id, + vec![MiniAppScriptDef { + name: "echo".to_string(), + path: "scripts/echo.js".to_string(), + description: Some("Echo a value".to_string()), + }], + ) + .await + .unwrap(); + + let report = manager + .run_named_script(&app.id, "echo", vec!["hello".to_string()]) + .await + .unwrap() + .expect("declared script yields a report"); + assert_eq!(report.name, "echo"); + assert_eq!(report.relative_path, "scripts/echo.js"); + if report.error.is_none() { + assert!(report.succeeded, "stderr: {}", report.stderr); + assert!(report.stdout.contains("script:echo:hello")); + } else { + assert!(!report.succeeded); + } + } + #[test] fn miniapp_port_error_mapping_preserves_manager_error_shape() { let not_found = map_miniapp_port_error(MiniAppPortError::new( @@ -940,6 +1419,9 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), + scripts: Default::default(), i18n: None, }; tokio::fs::write( diff --git a/src/crates/assembly/core/src/miniapp/mod.rs b/src/crates/assembly/core/src/miniapp/mod.rs index fc2a7bdc74..7850a8d809 100644 --- a/src/crates/assembly/core/src/miniapp/mod.rs +++ b/src/crates/assembly/core/src/miniapp/mod.rs @@ -25,7 +25,8 @@ pub use exporter::{ExportCheckResult, ExportOptions, ExportResult, ExportTarget, pub use host_dispatch::{dispatch_host, is_host_primitive}; pub use js_worker_pool::{InstallResult, JsWorkerPool}; pub use manager::{ - initialize_global_miniapp_manager, try_get_global_miniapp_manager, MiniAppManager, + initialize_global_miniapp_manager, try_get_global_miniapp_manager, MiniAppLifecycleReport, + MiniAppManager, }; pub use permission_policy::resolve_policy; pub use runtime_detect::{DetectedRuntime, RuntimeKind}; diff --git a/src/crates/assembly/core/src/user_secrets.rs b/src/crates/assembly/core/src/user_secrets.rs new file mode 100644 index 0000000000..8fc423fc7a --- /dev/null +++ b/src/crates/assembly/core/src/user_secrets.rs @@ -0,0 +1,64 @@ +//! User secret variables (`{{name}}`) facade for settings CRUD and tool resolution. +//! +//! Values live in an AES-GCM vault under the user data directory. Chat / model +//! history keep placeholders unchanged; only tool-argument resolution loads +//! plaintext on the executing host. + +use crate::infrastructure::app_paths::path_manager::{get_path_manager_arc, PathManager}; +use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_core_types::{UserSecretSummary, UserSecretUpsert}; +use bitfun_services_core::user_secrets::UserSecretsVault; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +static GLOBAL_USER_SECRETS: OnceLock> = OnceLock::new(); + +fn vault_for(path_manager: &PathManager) -> Arc { + Arc::new(UserSecretsVault::new(path_manager.user_secrets_dir())) +} + +/// Initialize the global vault (called once at desktop/CLI startup). +pub fn initialize_global_user_secrets(path_manager: Arc) { + let _ = GLOBAL_USER_SECRETS.set(vault_for(path_manager.as_ref())); +} + +fn global_vault() -> BitFunResult> { + if let Some(vault) = GLOBAL_USER_SECRETS.get() { + return Ok(vault.clone()); + } + // Fall back to the global PathManager when startup did not initialize yet + // (tests / late callers). + let path_manager = get_path_manager_arc(); + let vault = vault_for(path_manager.as_ref()); + let _ = GLOBAL_USER_SECRETS.set(Arc::clone(&vault)); + Ok(GLOBAL_USER_SECRETS.get().cloned().unwrap_or(vault)) +} + +pub async fn list_user_secrets() -> BitFunResult> { + global_vault()? + .list() + .await + .map_err(|e| BitFunError::service(e.to_string())) +} + +pub async fn upsert_user_secret(request: UserSecretUpsert) -> BitFunResult { + global_vault()? + .upsert(&request.name, &request.value) + .await + .map_err(|e| BitFunError::service(e.to_string())) +} + +pub async fn delete_user_secret(name: &str) -> BitFunResult { + global_vault()? + .delete(name) + .await + .map_err(|e| BitFunError::service(e.to_string())) +} + +/// Load plaintext map for tool-argument resolution only. Do not expose to UI. +pub async fn load_user_secret_values() -> BitFunResult> { + global_vault()? + .load_all_values() + .await + .map_err(|e| BitFunError::service(e.to_string())) +} diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 8766e5b8ea..837da08e39 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -12,6 +12,7 @@ pub mod session_usage; pub mod speech; pub mod surface; pub mod tool_image_attachment; +pub mod user_secret; pub mod worktree; pub use ai::{ @@ -43,6 +44,7 @@ pub use surface::{ RuntimeArtifactKind, RuntimeArtifactRef, SurfaceKind, ThreadEnvironment, ThreadEnvironmentKind, }; pub use tool_image_attachment::ToolImageAttachment; +pub use user_secret::{is_valid_user_secret_name, UserSecretSummary, UserSecretUpsert}; pub use worktree::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSessionSummary, WorktreeSettings, diff --git a/src/crates/contracts/core-types/src/user_secret.rs b/src/crates/contracts/core-types/src/user_secret.rs new file mode 100644 index 0000000000..0bdba43f29 --- /dev/null +++ b/src/crates/contracts/core-types/src/user_secret.rs @@ -0,0 +1,59 @@ +//! User-defined secret variables used as `{{name}}` placeholders in chat. +//! +//! Values never appear in read models. List/summary APIs expose only names and +//! timestamps so settings UIs and peers cannot read plaintext. + +use serde::{Deserialize, Serialize}; + +/// Allowed secret name: `[A-Za-z_][A-Za-z0-9_]*`. +pub fn is_valid_user_secret_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Secret-safe listing row. Never includes the value. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UserSecretSummary { + pub name: String, + /// Unix millis when the secret was last written. + pub updated_at: i64, +} + +/// Write-only upsert. Empty `value` is rejected by the host. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserSecretUpsert { + pub name: String, + pub value: String, +} + +impl std::fmt::Debug for UserSecretUpsert { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UserSecretUpsert") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_secret_names() { + assert!(is_valid_user_secret_name("api_token")); + assert!(is_valid_user_secret_name("_private")); + assert!(is_valid_user_secret_name("A1")); + assert!(!is_valid_user_secret_name("")); + assert!(!is_valid_user_secret_name("1bad")); + assert!(!is_valid_user_secret_name("has-dash")); + assert!(!is_valid_user_secret_name("has space")); + assert!(!is_valid_user_secret_name("中文")); + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs index 434b9b2a2d..4cb417b2bb 100644 --- a/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs +++ b/src/crates/contracts/product-domains/src/miniapp/lifecycle.rs @@ -2,8 +2,11 @@ use std::path::{Path, PathBuf}; +use crate::miniapp::storage::MiniAppStorageLayout; use crate::miniapp::types::{ - MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppRuntimeState, MiniAppSource, + find_script_path, MiniApp, MiniAppAiContext, MiniAppLifecycleEvent, MiniAppLifecycleScripts, + MiniAppMeta, MiniAppPermissions, MiniAppRuntimeState, MiniAppScriptDef, MiniAppSource, + MiniAppViewMode, }; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -30,6 +33,9 @@ pub struct MiniAppUpdatePatch { pub source: Option, pub permissions: Option, pub ai_context: Option, + pub view_mode: Option, + pub lifecycle: Option, + pub scripts: Option>, } impl MiniAppUpdatePatch { @@ -75,6 +81,9 @@ pub fn miniapp_content_hash(app: &MiniApp) -> String { "permissions": &app.permissions, "aiContext": &app.ai_context, "runtimeProfile": app.runtime_profile, + "viewMode": app.view_mode, + "lifecycle": &app.lifecycle, + "scripts": &app.scripts, "i18n": &app.i18n, })); let encoded = serde_json::to_vec(&payload) @@ -144,6 +153,9 @@ pub fn build_created_app( ai_context: input.ai_context, runtime, runtime_profile: Default::default(), + view_mode: MiniAppViewMode::default(), + lifecycle: MiniAppLifecycleScripts::default(), + scripts: Vec::new(), i18n: None, }; refresh_content_hash(&mut app); @@ -184,6 +196,15 @@ pub fn apply_update_patch( if let Some(ai_context) = patch.ai_context { app.ai_context = Some(ai_context); } + if let Some(view_mode) = patch.view_mode { + app.view_mode = view_mode; + } + if let Some(lifecycle) = patch.lifecycle { + app.lifecycle = lifecycle; + } + if let Some(scripts) = patch.scripts { + app.scripts = scripts; + } app.version += 1; app.updated_at = now; @@ -276,6 +297,9 @@ pub fn apply_draft_to_active( app.source = draft.source; app.permissions = draft.permissions; app.ai_context = draft.ai_context; + app.view_mode = draft.view_mode; + app.lifecycle = draft.lifecycle; + app.scripts = draft.scripts; app.i18n = draft.i18n; app.version = current.version + 1; app.updated_at = now; @@ -458,6 +482,97 @@ pub fn miniapp_worker_stopped_payload(app_id: &str, reason: &str) -> Value { json!({ "id": app_id, "reason": reason }) } +/// A resolved lifecycle-script invocation the host runtime should execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppLifecyclePlan { + pub event: MiniAppLifecycleEvent, + /// Absolute path to the script file, guaranteed to be inside the app dir. + pub script_path: PathBuf, + /// The manifest-relative path, retained for logging/telemetry. + pub relative_path: String, +} + +/// Resolve the script (if any) to run for `event`, given the app's on-disk +/// layout and its declared lifecycle scripts. +/// +/// Returns `None` when no script is declared or when the declared path would +/// escape the app directory (see +/// [`MiniAppStorageLayout::resolve_contained_relative`]). This is a pure +/// decision: the services layer confirms the file exists and executes it. +pub fn plan_lifecycle_script( + layout: &MiniAppStorageLayout, + scripts: &MiniAppLifecycleScripts, + event: MiniAppLifecycleEvent, +) -> Option { + let relative = scripts.script_for(event)?; + let script_path = layout.resolve_contained_relative(relative)?; + Some(MiniAppLifecyclePlan { + event, + script_path, + relative_path: relative.to_string(), + }) +} + +/// A resolved named-script invocation the host runtime should execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiniAppScriptPlan { + pub name: String, + /// Absolute path to the script file, guaranteed to be inside the app dir. + pub script_path: PathBuf, + /// The manifest-relative path, retained for logging/telemetry. + pub relative_path: String, +} + +/// Resolve a named script (declared in `meta.json` `scripts`) to an absolute +/// path within the app directory. +/// +/// Returns `None` when no script matches `name` or when the declared path would +/// escape the app directory. Pure decision: the services layer confirms the +/// file exists and executes it. +pub fn plan_named_script( + layout: &MiniAppStorageLayout, + scripts: &[MiniAppScriptDef], + name: &str, +) -> Option { + let relative = find_script_path(scripts, name)?; + let script_path = layout.resolve_contained_relative(relative)?; + Some(MiniAppScriptPlan { + name: name.trim().to_string(), + script_path, + relative_path: relative.to_string(), + }) +} + +/// Frontend/telemetry payload emitted when a named script runs. +pub fn miniapp_script_event_payload( + app_id: &str, + name: &str, + relative_path: &str, + succeeded: bool, +) -> Value { + json!({ + "id": app_id, + "script": name, + "path": relative_path, + "succeeded": succeeded, + }) +} + +/// Frontend/telemetry payload emitted when a lifecycle script runs. +pub fn miniapp_lifecycle_event_payload( + app_id: &str, + event: MiniAppLifecycleEvent, + relative_path: &str, + succeeded: bool, +) -> Value { + json!({ + "id": app_id, + "event": event.as_str(), + "script": relative_path, + "succeeded": succeeded, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -478,6 +593,9 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), + scripts: Default::default(), i18n: None, }; @@ -547,4 +665,201 @@ mod tests { assert_eq!(worker_restart_reason(true), "deps-installed"); assert_eq!(worker_restart_reason(false), "runtime-restart"); } + + fn sample_app() -> MiniApp { + build_created_app( + "app-1".to_string(), + MiniAppCreateInput { + name: "App".to_string(), + description: "Desc".to_string(), + icon: "box".to_string(), + category: "utility".to_string(), + tags: vec![], + source: MiniAppSource::default(), + permissions: MiniAppPermissions::default(), + ai_context: None, + }, + "".to_string(), + 123, + ) + } + + #[test] + fn view_mode_defaults_to_front_and_round_trips_over_wire() { + assert_eq!(MiniAppViewMode::default(), MiniAppViewMode::Front); + for mode in [ + MiniAppViewMode::Background, + MiniAppViewMode::Front, + MiniAppViewMode::Full, + ] { + assert_eq!(MiniAppViewMode::from_wire(mode.as_str()), mode); + } + // Unknown values fall back to Front rather than erroring. + assert_eq!(MiniAppViewMode::from_wire("bogus"), MiniAppViewMode::Front); + } + + #[test] + fn lifecycle_scripts_report_declared_events_and_emptiness() { + let empty = MiniAppLifecycleScripts::default(); + assert!(empty.is_empty()); + for event in MiniAppLifecycleEvent::all() { + assert_eq!(empty.script_for(event), None); + } + + let scripts = MiniAppLifecycleScripts { + install: Some("hooks/install.js".to_string()), + // Whitespace-only entries are treated as absent. + uninstall: Some(" ".to_string()), + start: Some(" worker.js ".to_string()), + stop: None, + }; + assert!(!scripts.is_empty()); + assert_eq!( + scripts.script_for(MiniAppLifecycleEvent::Install), + Some("hooks/install.js") + ); + assert_eq!(scripts.script_for(MiniAppLifecycleEvent::Uninstall), None); + assert_eq!( + scripts.script_for(MiniAppLifecycleEvent::Start), + Some("worker.js") + ); + assert_eq!(scripts.script_for(MiniAppLifecycleEvent::Stop), None); + } + + #[test] + fn plan_lifecycle_script_resolves_declared_scripts_and_rejects_escapes() { + let layout = MiniAppStorageLayout::new("/root/miniapps", "app-1"); + let scripts = MiniAppLifecycleScripts { + install: Some("hooks/install.js".to_string()), + uninstall: Some("../evil.js".to_string()), + start: Some("/etc/passwd".to_string()), + stop: None, + }; + + let install = plan_lifecycle_script(&layout, &scripts, MiniAppLifecycleEvent::Install) + .expect("install script should resolve"); + assert_eq!(install.event, MiniAppLifecycleEvent::Install); + assert_eq!(install.relative_path, "hooks/install.js"); + assert_eq!( + install.script_path, + layout.app_dir().join("hooks").join("install.js") + ); + + // Path traversal and absolute paths are rejected. + assert_eq!( + plan_lifecycle_script(&layout, &scripts, MiniAppLifecycleEvent::Uninstall), + None + ); + assert_eq!( + plan_lifecycle_script(&layout, &scripts, MiniAppLifecycleEvent::Start), + None + ); + // Undeclared events produce no plan. + assert_eq!( + plan_lifecycle_script(&layout, &scripts, MiniAppLifecycleEvent::Stop), + None + ); + } + + #[test] + fn content_hash_tracks_view_mode_and_lifecycle_changes() { + let base = sample_app(); + let base_hash = miniapp_content_hash(&base); + + let mut with_mode = base.clone(); + with_mode.view_mode = MiniAppViewMode::Full; + assert_ne!(miniapp_content_hash(&with_mode), base_hash); + + let mut with_hooks = base.clone(); + with_hooks.lifecycle.install = Some("hooks/install.js".to_string()); + assert_ne!(miniapp_content_hash(&with_hooks), base_hash); + } + + #[test] + fn update_patch_applies_view_mode_and_lifecycle() { + let previous = sample_app(); + let patch = MiniAppUpdatePatch { + view_mode: Some(MiniAppViewMode::Background), + lifecycle: Some(MiniAppLifecycleScripts { + start: Some("hooks/start.js".to_string()), + ..Default::default() + }), + ..Default::default() + }; + + let updated = apply_update_patch(&previous, patch, "".to_string(), 456); + assert_eq!(updated.view_mode, MiniAppViewMode::Background); + assert_eq!( + updated.lifecycle.script_for(MiniAppLifecycleEvent::Start), + Some("hooks/start.js") + ); + assert_eq!(updated.version, previous.version + 1); + } + + #[test] + fn named_scripts_lookup_and_resolution_guard_traversal() { + let scripts = vec![ + MiniAppScriptDef { + name: "build".to_string(), + path: "scripts/build.js".to_string(), + description: Some("Build the project".to_string()), + }, + MiniAppScriptDef { + name: "escape".to_string(), + path: "../evil.js".to_string(), + description: None, + }, + // Blank name/path entries are ignored. + MiniAppScriptDef { + name: " ".to_string(), + path: "scripts/x.js".to_string(), + description: None, + }, + ]; + + assert_eq!(find_script_path(&scripts, "build"), Some("scripts/build.js")); + assert_eq!(find_script_path(&scripts, "missing"), None); + assert_eq!(find_script_path(&scripts, " "), None); + + let layout = MiniAppStorageLayout::new("/root/miniapps", "app-1"); + let plan = plan_named_script(&layout, &scripts, "build").expect("build resolves"); + assert_eq!(plan.name, "build"); + assert_eq!(plan.relative_path, "scripts/build.js"); + assert_eq!( + plan.script_path, + layout.app_dir().join("scripts").join("build.js") + ); + // Traversal is rejected. + assert_eq!(plan_named_script(&layout, &scripts, "escape"), None); + assert_eq!(plan_named_script(&layout, &scripts, "missing"), None); + } + + #[test] + fn content_hash_and_update_patch_track_named_scripts() { + let base = sample_app(); + let base_hash = miniapp_content_hash(&base); + + let mut with_scripts = base.clone(); + with_scripts.scripts.push(MiniAppScriptDef { + name: "sync".to_string(), + path: "scripts/sync.js".to_string(), + description: None, + }); + assert_ne!(miniapp_content_hash(&with_scripts), base_hash); + + let patch = MiniAppUpdatePatch { + scripts: Some(vec![MiniAppScriptDef { + name: "build".to_string(), + path: "scripts/build.js".to_string(), + description: None, + }]), + ..Default::default() + }; + let updated = apply_update_patch(&base, patch, "".to_string(), 789); + assert_eq!(updated.scripts.len(), 1); + assert_eq!( + find_script_path(&updated.scripts, "build"), + Some("scripts/build.js") + ); + } } diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs index 5a9d296410..58820d9355 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs @@ -192,6 +192,9 @@ impl<'a> MiniAppRuntimeFacade<'a> { source: Some(source), permissions: Some(permissions), ai_context: None, + view_mode: None, + lifecycle: None, + scripts: None, }, compiled_html, now, @@ -866,6 +869,9 @@ mod tests { ai_context: None, runtime: MiniAppRuntimeState::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), + scripts: Default::default(), i18n: None, } } diff --git a/src/crates/contracts/product-domains/src/miniapp/storage.rs b/src/crates/contracts/product-domains/src/miniapp/storage.rs index 6eabcc6e69..2035529c06 100644 --- a/src/crates/contracts/product-domains/src/miniapp/storage.rs +++ b/src/crates/contracts/product-domains/src/miniapp/storage.rs @@ -20,6 +20,16 @@ pub const EMPTY_STORAGE_JSON: &str = "{}"; pub const PLACEHOLDER_COMPILED_HTML: &str = "Loading..."; pub const VERSIONS_DIR: &str = "versions"; +/// Conventional directory for lifecycle hook scripts inside an app +/// (`{app_dir}/hooks/`). Lifecycle script paths in `meta.json` are resolved +/// relative to the app root, so hooks may also live at the app root; this +/// constant documents the recommended layout. +pub const HOOKS_DIR: &str = "hooks"; +/// Conventional directory for named capability scripts inside an app +/// (`{app_dir}/scripts/`). Like hooks, a named script's path in `meta.json` is +/// resolved relative to the app root, so scripts may live elsewhere; this +/// constant documents the recommended layout and is carried on import. +pub const SCRIPTS_DIR: &str = "scripts"; pub const DRAFTS_DIR: &str = ".drafts"; pub const DRAFTS_CLEANUP_PREFIX: &str = ".drafts.cleanup-"; pub const DRAFTS_CLEANUP_MARKER: &str = ".cleanup-pending"; @@ -120,6 +130,22 @@ impl MiniAppStorageLayout { self.app_dir().join(VERSIONS_DIR) } + /// Recommended directory for lifecycle hook scripts (`{app_dir}/hooks/`). + pub fn hooks_dir(&self) -> PathBuf { + self.app_dir().join(HOOKS_DIR) + } + + /// Resolve a lifecycle-script path (declared in `meta.json`, relative to the + /// app root) into an absolute path, rejecting anything that would escape the + /// app directory. + /// + /// Returns `None` when the value is empty, absolute, or contains a parent + /// (`..`) component. This is a pure, lexical containment check; the caller + /// (services layer) is still responsible for confirming the file exists. + pub fn resolve_contained_relative(&self, relative: &str) -> Option { + resolve_contained_relative(&self.app_dir(), relative) + } + pub fn version_path(&self, version: u32) -> PathBuf { self.versions_dir().join(format!("v{}.json", version)) } @@ -159,6 +185,42 @@ impl MiniAppStorageLayout { } } +/// Lexically resolve `relative` under `root`, rejecting absolute paths, empty +/// input, and any `..` / root / drive-prefix components that would escape +/// `root`. Returns `None` when the value cannot be safely contained or resolves +/// back to `root` itself. +/// +/// This is a pure containment check: it does not touch the filesystem, so the +/// caller (services layer) must still confirm the resolved file exists. +pub fn resolve_contained_relative(root: &Path, relative: &str) -> Option { + let trimmed = relative.trim(); + if trimmed.is_empty() { + return None; + } + + let candidate = Path::new(trimmed); + if candidate.is_absolute() { + return None; + } + + let mut resolved = root.to_path_buf(); + for component in candidate.components() { + match component { + std::path::Component::Normal(part) => resolved.push(part), + std::path::Component::CurDir => {} + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => return None, + } + } + + if resolved == root { + return None; + } + + Some(resolved) +} + /// Parse package.json dependencies using the legacy MiniApp storage contract. pub fn parse_npm_dependencies(package_json: &str) -> Result, serde_json::Error> { let package: serde_json::Value = serde_json::from_str(package_json)?; diff --git a/src/crates/contracts/product-domains/src/miniapp/types.rs b/src/crates/contracts/product-domains/src/miniapp/types.rs index db0d378e18..26dad16e8a 100644 --- a/src/crates/contracts/product-domains/src/miniapp/types.rs +++ b/src/crates/contracts/product-domains/src/miniapp/types.rs @@ -222,6 +222,173 @@ pub enum MiniAppRuntimeProfile { MarketStrict, } +/// How a MiniApp is presented in the host shell. +/// +/// - `Background`: collapsed into a compact panel (dock/side rail) that keeps the +/// app resident without occupying the main content area. +/// - `Front` (default): opens inside a tab in the main content scene area. +/// - `Full`: opens in its own independent OS window, detached from the main shell. +/// +/// Persisted in `meta.json` as `view_mode`; absent values default to `Front` so +/// existing installs keep their current in-tab behavior after an upgrade. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MiniAppViewMode { + Background, + #[default] + Front, + Full, +} + +impl MiniAppViewMode { + /// Stable wire identifier shared with the frontend and Tauri layer. + pub fn as_str(&self) -> &'static str { + match self { + MiniAppViewMode::Background => "background", + MiniAppViewMode::Front => "front", + MiniAppViewMode::Full => "full", + } + } + + /// Parse a wire identifier; unknown values fall back to the default (`Front`). + pub fn from_wire(value: &str) -> Self { + match value { + "background" => MiniAppViewMode::Background, + "full" => MiniAppViewMode::Full, + _ => MiniAppViewMode::Front, + } + } +} + +/// A MiniApp lifecycle transition that can trigger a user-defined script. +/// +/// The host runs the matching script (see [`MiniAppLifecycleScripts`]) at each +/// transition: +/// - `Install`: after the app's files are committed to disk for the first time. +/// - `Uninstall`: before the app directory is removed. +/// - `Start`: when the app is activated / its worker is brought up. +/// - `Stop`: when the app is deactivated / its worker is torn down. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MiniAppLifecycleEvent { + Install, + Uninstall, + Start, + Stop, +} + +impl MiniAppLifecycleEvent { + /// Stable wire identifier shared with the frontend and Tauri layer. + pub fn as_str(&self) -> &'static str { + match self { + MiniAppLifecycleEvent::Install => "install", + MiniAppLifecycleEvent::Uninstall => "uninstall", + MiniAppLifecycleEvent::Start => "start", + MiniAppLifecycleEvent::Stop => "stop", + } + } + + /// Parse a wire identifier. + pub fn from_wire(value: &str) -> Option { + match value { + "install" => Some(MiniAppLifecycleEvent::Install), + "uninstall" => Some(MiniAppLifecycleEvent::Uninstall), + "start" => Some(MiniAppLifecycleEvent::Start), + "stop" => Some(MiniAppLifecycleEvent::Stop), + _ => None, + } + } + + /// All lifecycle events, in canonical order. + pub fn all() -> [MiniAppLifecycleEvent; 4] { + [ + MiniAppLifecycleEvent::Install, + MiniAppLifecycleEvent::Uninstall, + MiniAppLifecycleEvent::Start, + MiniAppLifecycleEvent::Stop, + ] + } +} + +/// User-declared scripts run at MiniApp lifecycle transitions. +/// +/// Each value is a path relative to the app root (for example `hooks/install.js` +/// or `worker.js`). The host resolves it against the app directory, rejecting any +/// path that escapes the app root, and executes it with the detected JS runtime +/// (Bun/Node). Absent entries mean "no script for this event". +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MiniAppLifecycleScripts { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub install: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub uninstall: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop: Option, +} + +impl MiniAppLifecycleScripts { + /// The declared script for `event`, if any (trimmed, non-empty). + pub fn script_for(&self, event: MiniAppLifecycleEvent) -> Option<&str> { + let raw = match event { + MiniAppLifecycleEvent::Install => self.install.as_deref(), + MiniAppLifecycleEvent::Uninstall => self.uninstall.as_deref(), + MiniAppLifecycleEvent::Start => self.start.as_deref(), + MiniAppLifecycleEvent::Stop => self.stop.as_deref(), + }; + raw.map(str::trim).filter(|value| !value.is_empty()) + } + + /// Whether no lifecycle script is declared. Used by `skip_serializing_if` so + /// apps without hooks keep a clean `meta.json`. + pub fn is_empty(&self) -> bool { + MiniAppLifecycleEvent::all() + .iter() + .all(|event| self.script_for(*event).is_none()) + } +} + +/// A named, user-declared script that extends a MiniApp's capabilities. +/// +/// Unlike the fixed lifecycle hooks, named scripts are arbitrary commands the +/// app author ships (recommended under `scripts/`), invokable on demand by the +/// user, the app UI (via the host bridge), or the agent. `path` is resolved +/// against the app root with the same traversal guard as lifecycle scripts and +/// run with the detected JS runtime (Bun/Node). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MiniAppScriptDef { + /// Stable identifier used to invoke the script (e.g. `build`, `sync`). + pub name: String, + /// Path to the script file, relative to the app root. + pub path: String, + /// Optional human-facing description shown in the UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +impl MiniAppScriptDef { + /// The script path if both `name` and `path` are non-empty after trimming. + pub fn resolved_path(&self) -> Option<&str> { + let path = self.path.trim(); + if self.name.trim().is_empty() || path.is_empty() { + None + } else { + Some(path) + } + } +} + +/// Look up a declared script by name (trimmed, exact match) and return its +/// relative path. +pub fn find_script_path<'a>(scripts: &'a [MiniAppScriptDef], name: &str) -> Option<&'a str> { + let target = name.trim(); + scripts + .iter() + .find(|script| script.name.trim() == target) + .and_then(MiniAppScriptDef::resolved_path) +} + /// Full MiniApp entity (in-memory / API). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MiniApp { @@ -252,6 +419,19 @@ pub struct MiniApp { #[serde(default)] pub runtime_profile: MiniAppRuntimeProfile, + /// How the app is presented in the host shell (background panel / front tab / + /// full window). Defaults to `Front`. + #[serde(default)] + pub view_mode: MiniAppViewMode, + + /// User-declared lifecycle scripts (install / uninstall / start / stop). + #[serde(default, skip_serializing_if = "MiniAppLifecycleScripts::is_empty")] + pub lifecycle: MiniAppLifecycleScripts, + + /// Named scripts the app ships to extend its capabilities. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scripts: Vec, + /// Optional per-locale overrides for `name` / `description` / `tags`. #[serde(default, skip_serializing_if = "Option::is_none")] pub i18n: Option, @@ -278,6 +458,15 @@ pub struct MiniAppMeta { pub runtime: MiniAppRuntimeState, #[serde(default)] pub runtime_profile: MiniAppRuntimeProfile, + /// How the app is presented in the host shell. Defaults to `Front`. + #[serde(default)] + pub view_mode: MiniAppViewMode, + /// User-declared lifecycle scripts (install / uninstall / start / stop). + #[serde(default, skip_serializing_if = "MiniAppLifecycleScripts::is_empty")] + pub lifecycle: MiniAppLifecycleScripts, + /// Named scripts the app ships to extend its capabilities. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub scripts: Vec, /// Optional per-locale overrides for `name` / `description` / `tags`. #[serde(default, skip_serializing_if = "Option::is_none")] pub i18n: Option, @@ -299,6 +488,9 @@ impl From<&MiniApp> for MiniAppMeta { ai_context: app.ai_context.clone(), runtime: app.runtime.clone(), runtime_profile: app.runtime_profile, + view_mode: app.view_mode, + lifecycle: app.lifecycle.clone(), + scripts: app.scripts.clone(), i18n: app.i18n.clone(), } } diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index d60fd0feeb..4f2851a9ce 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -2308,6 +2308,9 @@ fn sample_miniapp_for_lifecycle(source: MiniAppSource) -> MiniApp { ai_context: None, runtime: MiniAppRuntimeState::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), + scripts: Default::default(), i18n: None, } } diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index 4bbdaf97ec..c142c246c4 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -18,6 +18,7 @@ pub mod input_validator; #[cfg(feature = "mcp-bridge")] pub mod mcp_tool_bridge; pub mod permission_intent; +pub mod secret_placeholders; pub mod tool_execution_presentation; pub mod tool_result_storage; pub mod tool_snapshot; @@ -108,6 +109,10 @@ pub use mcp_tool_bridge::{ MCP_TOOL_DELIMITER, MCP_TOOL_PREFIX, }; pub use permission_intent::PermissionIntent; +pub use secret_placeholders::{ + resolve_secret_placeholders_in_text, resolve_secret_placeholders_in_value, + text_contains_secret_placeholder, SecretPlaceholderError, +}; pub use tool_execution_presentation::{ build_invalid_tool_call_error_message, build_normal_tool_json_repair_notice, build_permission_denied_tool_presentation, build_tool_call_truncation_recovery_notice, diff --git a/src/crates/execution/tool-contracts/src/secret_placeholders.rs b/src/crates/execution/tool-contracts/src/secret_placeholders.rs new file mode 100644 index 0000000000..b6761b18b5 --- /dev/null +++ b/src/crates/execution/tool-contracts/src/secret_placeholders.rs @@ -0,0 +1,188 @@ +//! Resolve `{{secret_name}}` placeholders in tool-call JSON arguments. +//! +//! This helper is intentionally pure: callers supply the secret map. Chat +//! messages and model-visible history must keep placeholders unchanged; only +//! the tool-execution argument path should call this. + +use bitfun_core_types::is_valid_user_secret_name; +use serde_json::Value; +use std::collections::HashMap; + +/// Error when a placeholder cannot be resolved for tool execution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecretPlaceholderError { + pub name: String, +} + +impl std::fmt::Display for SecretPlaceholderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Unknown or unset secret variable '{{{{{}}}}}'. Add it in Settings → Secrets.", + self.name + ) + } +} + +impl std::error::Error for SecretPlaceholderError {} + +/// Walk `value` and replace every `{{name}}` in string leaves using `secrets`. +/// +/// - Placeholders whose name is a valid secret id but missing from `secrets` +/// fail closed. +/// - Text that does not match `{{valid_name}}` is left unchanged. +/// - Non-string JSON nodes are walked recursively; structure is preserved. +pub fn resolve_secret_placeholders_in_value( + value: &Value, + secrets: &HashMap, +) -> Result { + match value { + Value::String(text) => Ok(Value::String(resolve_secret_placeholders_in_text( + text, secrets, + )?)), + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(resolve_secret_placeholders_in_value(item, secrets)?); + } + Ok(Value::Array(out)) + } + Value::Object(map) => { + let mut out = serde_json::Map::new(); + for (key, child) in map { + out.insert( + key.clone(), + resolve_secret_placeholders_in_value(child, secrets)?, + ); + } + Ok(Value::Object(out)) + } + other => Ok(other.clone()), + } +} + +/// Replace `{{name}}` occurrences in a single string. +pub fn resolve_secret_placeholders_in_text( + text: &str, + secrets: &HashMap, +) -> Result { + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' { + if let Some((name, end)) = parse_placeholder(text, i) { + let value = secrets.get(name).ok_or_else(|| SecretPlaceholderError { + name: name.to_string(), + })?; + out.push_str(value); + i = end; + continue; + } + } + // Copy one UTF-8 char safely. + let ch = text[i..].chars().next().expect("index inside string"); + out.push(ch); + i += ch.len_utf8(); + } + Ok(out) +} + +/// Returns `(name, end_index_exclusive)` when `text[start..]` begins with +/// `{{valid_name}}`. +fn parse_placeholder(text: &str, start: usize) -> Option<(&str, usize)> { + if !text[start..].starts_with("{{") { + return None; + } + let name_start = start + 2; + let rest = &text[name_start..]; + let name_end_rel = rest.find("}}")?; + let name = &rest[..name_end_rel]; + if !is_valid_user_secret_name(name) { + return None; + } + Some((name, name_start + name_end_rel + 2)) +} + +/// True when `text` contains at least one resolvable-shape placeholder. +pub fn text_contains_secret_placeholder(text: &str) -> bool { + let mut i = 0; + let bytes = text.as_bytes(); + while i < bytes.len() { + if bytes[i] == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' { + if parse_placeholder(text, i).is_some() { + return true; + } + } + let ch = text[i..].chars().next().expect("index inside string"); + i += ch.len_utf8(); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn map(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn resolves_nested_json_strings() { + let secrets = map(&[("token", "s3cret"), ("host", "example.com")]); + let input = json!({ + "url": "https://{{host}}/v1", + "headers": { "Authorization": "Bearer {{token}}" }, + "tags": ["plain", "x-{{token}}"] + }); + let resolved = resolve_secret_placeholders_in_value(&input, &secrets).unwrap(); + assert_eq!( + resolved, + json!({ + "url": "https://example.com/v1", + "headers": { "Authorization": "Bearer s3cret" }, + "tags": ["plain", "x-s3cret"] + }) + ); + } + + #[test] + fn unknown_secret_fails_closed() { + let secrets = map(&[("token", "s3cret")]); + let err = resolve_secret_placeholders_in_text("use {{missing}}", &secrets).unwrap_err(); + assert_eq!(err.name, "missing"); + assert!(err.to_string().contains("{{{{missing}}}}") || err.to_string().contains("missing")); + } + + #[test] + fn invalid_placeholder_shape_is_left_alone() { + let secrets = map(&[]); + let text = "keep {{bad-name}} and {single} and {{}}"; + assert_eq!( + resolve_secret_placeholders_in_text(text, &secrets).unwrap(), + text + ); + } + + #[test] + fn detects_placeholder_presence() { + assert!(text_contains_secret_placeholder("hi {{api_key}}")); + assert!(!text_contains_secret_placeholder("hi {{bad-name}}")); + assert!(!text_contains_secret_placeholder("no placeholders")); + } + + #[test] + fn history_shape_is_unchanged_when_not_resolved() { + // Document the invariant: callers must keep the original Value for + // persistence; this test just shows resolve returns a new tree. + let secrets = map(&[("x", "1")]); + let original = json!({ "cmd": "echo {{x}}" }); + let _resolved = resolve_secret_placeholders_in_value(&original, &secrets).unwrap(); + assert_eq!(original, json!({ "cmd": "echo {{x}}" })); + } +} diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 455119c07e..d829ae68ad 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -23,6 +23,8 @@ serde_yaml = { workspace = true, optional = true } base64 = { workspace = true, optional = true } chrono = { workspace = true, optional = true } chrono-tz = { workspace = true, optional = true } +aes-gcm = { workspace = true, optional = true } +rand = { workspace = true, optional = true } git2 = { workspace = true, optional = true } dunce = { workspace = true, optional = true } zip = { workspace = true, optional = true } @@ -148,6 +150,16 @@ permission = [ dispatch-workspace = ["dep:anyhow", "dep:sha2"] session-git = ["local-storage", "dep:git2"] workspace-text-runtime = ["dep:tokio", "tokio/rt"] +user-secrets = [ + "dep:aes-gcm", + "dep:base64", + "dep:bitfun-core-types", + "dep:rand", + "dep:tokio", + "tokio/fs", + "tokio/rt", + "tokio/sync", +] [dev-dependencies] filetime = { workspace = true } diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index e744185412..820094277f 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -49,6 +49,8 @@ pub mod storage_cleanup; pub mod system; #[cfg(feature = "local-storage")] pub mod token_usage; +#[cfg(feature = "user-secrets")] +pub mod user_secrets; #[cfg(feature = "workspace-runtime")] pub mod workspace; #[cfg(feature = "workspace-identity")] diff --git a/src/crates/services/services-core/src/user_secrets.rs b/src/crates/services/services-core/src/user_secrets.rs new file mode 100644 index 0000000000..45738d66ef --- /dev/null +++ b/src/crates/services/services-core/src/user_secrets.rs @@ -0,0 +1,300 @@ +//! Encrypted file-backed vault for user secret variables (`{{name}}`). +//! +//! Layout (under the product user data directory): +//! - `.user_secrets_vault.key` — 32-byte AES key (0600 on Unix) +//! - `user_secrets_vault.json` — base64 ciphertext map keyed by secret name +//! +//! List APIs expose names and timestamps only. Plaintext is loaded only for +//! tool-argument resolution on the executing host. + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use bitfun_core_types::{is_valid_user_secret_name, UserSecretSummary}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::Mutex; + +const NONCE_LEN: usize = 12; +pub const USER_SECRETS_VAULT_FILE: &str = "user_secrets_vault.json"; +pub const USER_SECRETS_KEY_FILE: &str = ".user_secrets_vault.key"; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum UserSecretsError { + #[error("{0}")] + InvalidName(String), + #[error("{0}")] + InvalidValue(String), + #[error("{0}")] + Io(String), + #[error("{0}")] + Crypto(String), +} + +#[derive(Serialize, Deserialize, Default)] +struct VaultFile { + /// name -> { ciphertext, updated_at } + entries: HashMap, +} + +#[derive(Serialize, Deserialize)] +struct VaultEntry { + ciphertext: String, + updated_at: i64, +} + +/// AES-GCM vault for user-defined secret variables. +pub struct UserSecretsVault { + key_path: PathBuf, + vault_path: PathBuf, + lock: Mutex<()>, +} + +impl UserSecretsVault { + pub fn new(data_dir: impl AsRef) -> Self { + let data_dir = data_dir.as_ref(); + Self { + key_path: data_dir.join(USER_SECRETS_KEY_FILE), + vault_path: data_dir.join(USER_SECRETS_VAULT_FILE), + lock: Mutex::new(()), + } + } + + pub fn vault_path(&self) -> &Path { + &self.vault_path + } + + async fn ensure_key(&self) -> Result<[u8; 32], UserSecretsError> { + if self.key_path.exists() { + let bytes = tokio::fs::read(&self.key_path) + .await + .map_err(|e| UserSecretsError::Io(format!("read user secrets vault key: {e}")))?; + if bytes.len() != 32 { + return Err(UserSecretsError::Crypto( + "invalid user secrets vault key length".into(), + )); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + return Ok(key); + } + if let Some(parent) = self.key_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| UserSecretsError::Io(format!("create secrets vault dir: {e}")))?; + } + let mut key = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut key); + tokio::fs::write(&self.key_path, key.as_slice()) + .await + .map_err(|e| UserSecretsError::Io(format!("write user secrets vault key: {e}")))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + std::fs::set_permissions(&self.key_path, std::fs::Permissions::from_mode(0o600)); + } + Ok(key) + } + + fn encrypt(key: &[u8; 32], plaintext: &str) -> Result { + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| UserSecretsError::Crypto(format!("aes init: {e}")))?; + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let ct = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext.as_bytes()) + .map_err(|e| UserSecretsError::Crypto(format!("encrypt: {e}")))?; + let mut blob = Vec::with_capacity(NONCE_LEN + ct.len()); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ct); + Ok(B64.encode(blob)) + } + + fn decrypt(key: &[u8; 32], blob_b64: &str) -> Result { + let blob = B64 + .decode(blob_b64) + .map_err(|e| UserSecretsError::Crypto(format!("base64 decode: {e}")))?; + if blob.len() <= NONCE_LEN { + return Err(UserSecretsError::Crypto( + "user secrets vault entry too short".into(), + )); + } + let (nonce, ct) = blob.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| UserSecretsError::Crypto(format!("aes init: {e}")))?; + let pt = cipher + .decrypt(Nonce::from_slice(nonce), ct) + .map_err(|e| UserSecretsError::Crypto(format!("decrypt: {e}")))?; + String::from_utf8(pt).map_err(|e| UserSecretsError::Crypto(format!("utf8: {e}"))) + } + + async fn read_file(&self) -> Result { + if !self.vault_path.exists() { + return Ok(VaultFile::default()); + } + let s = tokio::fs::read_to_string(&self.vault_path) + .await + .map_err(|e| UserSecretsError::Io(format!("read user secrets vault: {e}")))?; + Ok(serde_json::from_str(&s).unwrap_or_default()) + } + + async fn write_file(&self, file: &VaultFile) -> Result<(), UserSecretsError> { + if let Some(parent) = self.vault_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| UserSecretsError::Io(format!("create secrets vault dir: {e}")))?; + } + let body = serde_json::to_string_pretty(file) + .map_err(|e| UserSecretsError::Io(format!("serialize secrets vault: {e}")))?; + tokio::fs::write(&self.vault_path, body) + .await + .map_err(|e| UserSecretsError::Io(format!("write user secrets vault: {e}")))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + std::fs::set_permissions(&self.vault_path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) + } + + fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + } + + /// List secret names and timestamps (never values). + pub async fn list(&self) -> Result, UserSecretsError> { + let _g = self.lock.lock().await; + let file = self.read_file().await?; + let mut rows: Vec = file + .entries + .into_iter() + .map(|(name, entry)| UserSecretSummary { + name, + updated_at: entry.updated_at, + }) + .collect(); + rows.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(rows) + } + + /// Upsert a secret value. Rejects invalid names and empty values. + pub async fn upsert( + &self, + name: &str, + value: &str, + ) -> Result { + if !is_valid_user_secret_name(name) { + return Err(UserSecretsError::InvalidName(format!( + "Invalid secret name '{name}'. Use letters, digits, and underscore; must start with a letter or underscore." + ))); + } + if value.is_empty() { + return Err(UserSecretsError::InvalidValue( + "Secret value must not be empty".into(), + )); + } + let _g = self.lock.lock().await; + let key = self.ensure_key().await?; + let mut file = self.read_file().await?; + let updated_at = Self::now_ms(); + let ciphertext = Self::encrypt(&key, value)?; + file.entries.insert( + name.to_string(), + VaultEntry { + ciphertext, + updated_at, + }, + ); + self.write_file(&file).await?; + Ok(UserSecretSummary { + name: name.to_string(), + updated_at, + }) + } + + /// Delete a secret. Returns true when an entry was removed. + pub async fn delete(&self, name: &str) -> Result { + let _g = self.lock.lock().await; + let mut file = self.read_file().await?; + let removed = file.entries.remove(name).is_some(); + if removed { + self.write_file(&file).await?; + } + Ok(removed) + } + + /// Load all secrets as name → plaintext for tool-argument resolution. + pub async fn load_all_values(&self) -> Result, UserSecretsError> { + let _g = self.lock.lock().await; + if !self.vault_path.exists() || !self.key_path.exists() { + return Ok(HashMap::new()); + } + let key = self.ensure_key().await?; + let file = self.read_file().await?; + let mut out = HashMap::new(); + for (name, entry) in file.entries { + match Self::decrypt(&key, &entry.ciphertext) { + Ok(value) => { + out.insert(name, value); + } + Err(error) => { + log::warn!("Failed to decrypt user secret '{}': {}", name, error); + } + } + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn round_trips_secrets_and_lists_without_values() { + let dir = tempfile::tempdir().unwrap(); + let vault = UserSecretsVault::new(dir.path()); + + let summary = vault.upsert("api_token", "s3cret-value").await.unwrap(); + assert_eq!(summary.name, "api_token"); + + let listed = vault.list().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, "api_token"); + + let values = vault.load_all_values().await.unwrap(); + assert_eq!( + values.get("api_token").map(String::as_str), + Some("s3cret-value") + ); + + // Ciphertext file must not contain plaintext. + let raw = tokio::fs::read_to_string(vault.vault_path()).await.unwrap(); + assert!(!raw.contains("s3cret-value")); + + assert!(vault.delete("api_token").await.unwrap()); + assert!(vault.list().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn rejects_invalid_names_and_empty_values() { + let dir = tempfile::tempdir().unwrap(); + let vault = UserSecretsVault::new(dir.path()); + assert!(matches!( + vault.upsert("bad-name", "x").await, + Err(UserSecretsError::InvalidName(_)) + )); + assert!(matches!( + vault.upsert("ok", "").await, + Err(UserSecretsError::InvalidValue(_)) + )); + } +} diff --git a/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs b/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs new file mode 100644 index 0000000000..72faafdb77 --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp/lifecycle_runner.rs @@ -0,0 +1,167 @@ +//! MiniApp lifecycle-script execution. +//! +//! Runs a resolved lifecycle script (see +//! [`bitfun_product_domains::miniapp::lifecycle::plan_lifecycle_script`]) as a +//! one-shot child process using the detected JS runtime (Bun/Node), through the +//! shared non-interactive process facade so no console window flashes on Windows +//! and GUI/headless hosts behave identically. +//! +//! Path containment and event selection are pure decisions owned by +//! `bitfun-product-domains`; this module only performs the concrete process +//! execution and result capture. + +use bitfun_product_domains::miniapp::runtime::DetectedRuntime; +use std::path::Path; +use std::process::Output; +use std::time::Duration; + +/// Default per-script timeout when the caller does not specify one. +pub const DEFAULT_LIFECYCLE_TIMEOUT_MS: u64 = 30_000; + +/// Result of running a lifecycle script. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LifecycleScriptOutcome { + pub succeeded: bool, + pub exit_code: Option, + pub stdout: String, + pub stderr: String, +} + +impl LifecycleScriptOutcome { + /// Build an outcome from a finished process `Output`. Kept pure and separate + /// from spawning so the success/exit-code/stream mapping is unit-testable. + pub fn from_output(output: &Output) -> Self { + Self { + succeeded: output.status.success(), + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } + } +} + +/// Run `script_path` with `runtime`, using `app_dir` as the working directory. +/// +/// The script path must already be validated as contained within the app +/// directory by the caller. `extra_env` is injected into the child environment +/// (app id, app dir, event, resolved permission policy) so a trusted script has +/// its context. Returns an error only when the process cannot be spawned/awaited +/// or exceeds `timeout_ms`; a script that runs but exits non-zero returns `Ok` +/// with `succeeded == false` so the caller can decide how to react per event. +pub async fn run_lifecycle_script( + runtime: &DetectedRuntime, + script_path: &Path, + app_dir: &Path, + extra_env: &[(String, String)], + timeout_ms: u64, +) -> Result { + run_miniapp_script(runtime, script_path, app_dir, &[], extra_env, timeout_ms).await +} + +/// Run a MiniApp script (lifecycle hook or named script) with optional CLI +/// arguments, identical to [`run_lifecycle_script`] but forwarding `args` to the +/// script process. +pub async fn run_miniapp_script( + runtime: &DetectedRuntime, + script_path: &Path, + app_dir: &Path, + args: &[String], + extra_env: &[(String, String)], + timeout_ms: u64, +) -> Result { + let exe = runtime.path.to_string_lossy(); + let script = script_path.to_string_lossy(); + + let mut command = bitfun_services_core::process_manager::create_tokio_command(&*exe); + command + .arg(&*script) + .args(args) + .current_dir(app_dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + for (key, value) in extra_env { + command.env(key, value); + } + + let effective_timeout = if timeout_ms == 0 { + DEFAULT_LIFECYCLE_TIMEOUT_MS + } else { + timeout_ms + }; + + let run = command.output(); + match tokio::time::timeout(Duration::from_millis(effective_timeout), run).await { + Ok(Ok(output)) => Ok(LifecycleScriptOutcome::from_output(&output)), + Ok(Err(error)) => Err(format!("Failed to run lifecycle script: {error}")), + Err(_) => Err(format!( + "Lifecycle script timed out after {effective_timeout}ms" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::miniapp::runtime::{detect_runtime, RuntimeKind}; + + #[cfg(unix)] + fn output_from(code: i32, stdout: &str, stderr: &str) -> Output { + // Constructing an ExitStatus directly is only supported on Unix; the + // stream/exit-code mapping under test is platform-independent. + use std::os::unix::process::ExitStatusExt; + Output { + status: std::process::ExitStatus::from_raw((code & 0xff) << 8), + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + } + } + + #[cfg(unix)] + #[test] + fn from_output_maps_success_and_streams() { + let ok = LifecycleScriptOutcome::from_output(&output_from(0, "hello", "")); + assert!(ok.succeeded); + assert_eq!(ok.exit_code, Some(0)); + assert_eq!(ok.stdout, "hello"); + assert_eq!(ok.stderr, ""); + + let failed = LifecycleScriptOutcome::from_output(&output_from(3, "", "boom")); + assert!(!failed.succeeded); + assert_eq!(failed.exit_code, Some(3)); + assert_eq!(failed.stderr, "boom"); + } + + #[tokio::test] + async fn runs_a_real_script_when_a_js_runtime_is_available() { + // Hermetic-friendly: skip when no Bun/Node is present (e.g. minimal CI). + let Some(runtime) = detect_runtime() else { + eprintln!("no JS runtime detected; skipping live lifecycle-script run"); + return; + }; + + let dir = std::env::temp_dir().join(format!("miniapp-lifecycle-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("hook.js"); + // Print to stdout, then exit non-zero to prove exit-code capture. + std::fs::write( + &script, + "console.log('lifecycle-ok'); process.exit(2);\n", + ) + .unwrap(); + + let env = [("BITFUN_MINIAPP_EVENT".to_string(), "install".to_string())]; + let outcome = run_lifecycle_script(&runtime, &script, &dir, &env, 10_000) + .await + .expect("script should run"); + + assert!(outcome.stdout.contains("lifecycle-ok")); + assert!(!outcome.succeeded); + // Bun and Node both honor process.exit(2). + assert_eq!(outcome.exit_code, Some(2)); + assert!(matches!(runtime.kind, RuntimeKind::Bun | RuntimeKind::Node)); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/crates/services/services-integrations/src/miniapp/mod.rs b/src/crates/services/services-integrations/src/miniapp/mod.rs index 0b72d5f3ce..1c2cad0dea 100644 --- a/src/crates/services/services-integrations/src/miniapp/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp/mod.rs @@ -2,6 +2,7 @@ pub mod builtin_io; pub mod host_dispatch; +pub mod lifecycle_runner; pub mod storage; pub mod worker; pub mod worker_pool; diff --git a/src/crates/services/services-integrations/src/miniapp/storage.rs b/src/crates/services/services-integrations/src/miniapp/storage.rs index c0758f6058..cde2309ba7 100644 --- a/src/crates/services/services-integrations/src/miniapp/storage.rs +++ b/src/crates/services/services-integrations/src/miniapp/storage.rs @@ -7,9 +7,9 @@ use bitfun_product_domains::miniapp::ports::{ use bitfun_product_domains::miniapp::storage::{ build_package_json, parse_npm_dependencies, MiniAppImportBundleWriteRequest, MiniAppImportLayout, MiniAppStorageLayout, COMPILED_HTML, CUSTOMIZATION_JSON, - DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, DRAFT_JSON, ESM_DEPS_JSON, - INDEX_HTML, META_JSON, PACKAGE_JSON, REQUIRED_SOURCE_FILES, STORAGE_JSON, STYLE_CSS, UI_JS, - VERSIONS_DIR, WORKER_JS, + DRAFTS_CLEANUP_MARKER, DRAFTS_CLEANUP_PREFIX, DRAFTS_DIR, DRAFT_JSON, ESM_DEPS_JSON, HOOKS_DIR, + INDEX_HTML, META_JSON, PACKAGE_JSON, REQUIRED_SOURCE_FILES, SCRIPTS_DIR, STORAGE_JSON, + STYLE_CSS, UI_JS, VERSIONS_DIR, WORKER_JS, }; use bitfun_product_domains::miniapp::types::{MiniApp, MiniAppMeta, MiniAppSource, NpmDep}; use serde_json; @@ -277,12 +277,57 @@ impl MiniAppStorage { .map_err(|_| MiniAppStorageError::io("Failed to write storage.json"))?; } + // Carry optional capability directories so declared lifecycle hooks and + // named scripts exist after import. + for extra_dir in [HOOKS_DIR, SCRIPTS_DIR] { + let from = request.source_path.join(extra_dir); + if from.is_dir() { + Self::copy_dir_recursive(&from, &dest_dir.join(extra_dir)).await?; + } + } + tokio::fs::write(dest_dir.join(COMPILED_HTML), request.compiled_html) .await .map_err(|_| MiniAppStorageError::io("Failed to write placeholder compiled.html"))?; Ok(()) } + /// Recursively copy a directory tree (used to carry the `hooks/` directory on + /// import). Iterative to avoid boxing an async recursion. + async fn copy_dir_recursive(from: &Path, to: &Path) -> MiniAppStorageResult<()> { + let mut pending: Vec<(PathBuf, PathBuf)> = vec![(from.to_path_buf(), to.to_path_buf())]; + while let Some((src, dst)) = pending.pop() { + tokio::fs::create_dir_all(&dst).await.map_err(|e| { + MiniAppStorageError::io(format!("Failed to create {}: {}", dst.display(), e)) + })?; + let mut entries = tokio::fs::read_dir(&src).await.map_err(|e| { + MiniAppStorageError::io(format!("Failed to read {}: {}", src.display(), e)) + })?; + while let Some(entry) = entries.next_entry().await.map_err(|e| { + MiniAppStorageError::io(format!("Failed to enumerate {}: {}", src.display(), e)) + })? { + let file_type = entry.file_type().await.map_err(|e| { + MiniAppStorageError::io(format!("Failed to stat entry: {}", e)) + })?; + let child_src = entry.path(); + let child_dst = dst.join(entry.file_name()); + if file_type.is_dir() { + pending.push((child_src, child_dst)); + } else if file_type.is_file() { + tokio::fs::copy(&child_src, &child_dst).await.map_err(|e| { + MiniAppStorageError::io(format!( + "Failed to copy {}: {}", + child_src.display(), + e + )) + })?; + } + // Symlinks and other node types are intentionally skipped. + } + } + Ok(()) + } + /// Ensure app directory and source subdir exist. pub async fn ensure_app_dir(&self, app_id: &str) -> MiniAppStorageResult<()> { let dir = self.app_dir(app_id); @@ -363,6 +408,9 @@ impl MiniAppStorage { ai_context: meta.ai_context, runtime: meta.runtime, runtime_profile: meta.runtime_profile, + view_mode: meta.view_mode, + lifecycle: meta.lifecycle, + scripts: meta.scripts, i18n: meta.i18n, }) } @@ -747,6 +795,9 @@ impl MiniAppStorage { ai_context: meta.ai_context, runtime: meta.runtime, runtime_profile: meta.runtime_profile, + view_mode: meta.view_mode, + lifecycle: meta.lifecycle, + scripts: meta.scripts, i18n: meta.i18n, }) } @@ -1449,6 +1500,14 @@ mod tests { ) .unwrap(); fs::write(import_source_dir.join(WORKER_JS), "").unwrap(); + // Lifecycle hooks directory must travel with the imported app. + let import_hooks_dir = import_root.join(HOOKS_DIR); + fs::create_dir_all(&import_hooks_dir).unwrap(); + fs::write( + import_hooks_dir.join("install.js"), + "console.log('installed');", + ) + .unwrap(); let storage = MiniAppStorage::new(miniapps_dir.clone()); let read_meta = storage.read_import_meta_json(&import_root).await.unwrap(); @@ -1485,6 +1544,11 @@ mod tests { fs::read_to_string(layout.compiled_path()).unwrap(), "placeholder" ); + // The hooks directory and its scripts are carried into the app dir. + assert_eq!( + fs::read_to_string(layout.app_dir().join(HOOKS_DIR).join("install.js")).unwrap(), + "console.log('installed');" + ); } #[tokio::test] @@ -1846,6 +1910,9 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), + scripts: Default::default(), i18n: None, } } diff --git a/src/crates/services/services-integrations/src/miniapp_market/package.rs b/src/crates/services/services-integrations/src/miniapp_market/package.rs index 5d169aa8c6..c6b353d7fa 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/package.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/package.rs @@ -431,6 +431,9 @@ mod tests { ai_context: None, runtime: Default::default(), runtime_profile: Default::default(), + view_mode: Default::default(), + lifecycle: Default::default(), + scripts: Default::default(), i18n: None, }; diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 32d94bd012..0711fbcc7f 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -54,6 +54,9 @@ const ToolbarMode = lazy(() => const FloatingMiniChat = lazy(() => import('./FloatingMiniChat').then(module => ({ default: module.FloatingMiniChat })) ); +const MiniAppBackgroundDock = lazy(() => + import('../scenes/miniapps/components/MiniAppBackgroundDock') +); const AboutDialog = lazy(() => import('../components/AboutDialog').then(module => ({ default: module.AboutDialog })) ); @@ -783,6 +786,11 @@ const AppLayout: React.FC = ({ className = '' }) => { )} + + {/* Resident dock for background view-mode MiniApps */} + + + {/* Dialogs (previously owned by TitleBar) */} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.scss b/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.scss new file mode 100644 index 0000000000..a66f643383 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.scss @@ -0,0 +1,128 @@ +/** + * MiniAppBackgroundDock styles — resident panel for background view-mode apps. + */ +@use '../../../../component-library/styles/tokens' as *; + +.miniapp-bg-dock { + position: fixed; + right: $size-gap-4; + bottom: $size-gap-4; + z-index: 40; + display: flex; + flex-direction: column; + width: 360px; + max-width: calc(100vw - #{$size-gap-6}); + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: $size-radius-lg; + background: var(--bf-appearance-token-color-bg-elevated); + box-shadow: var(--bf-appearance-token-shadow-base); + overflow: hidden; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + } + + &__title { + font-size: var(--bf-appearance-token-font-size-sm); + font-weight: $font-weight-semibold; + color: var(--bf-appearance-token-color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__tabs { + display: flex; + flex-wrap: wrap; + gap: $size-gap-1; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + } + + &__tab { + display: inline-flex; + align-items: center; + gap: $size-gap-1; + padding: 2px $size-gap-2; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: $size-radius-base; + background: var(--bf-appearance-token-color-bg-secondary); + color: var(--bf-appearance-token-color-text-muted); + font-size: var(--bf-appearance-token-font-size-xs); + cursor: pointer; + + &--active { + background: var(--bf-appearance-token-color-bg-primary); + color: var(--bf-appearance-token-color-text-primary); + } + } + + &__tab-label { + max-width: 120px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + + &__body { + display: flex; + flex-direction: column; + height: 420px; + max-height: calc(100vh - #{$size-gap-8}); + } + + &__body-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-1 $size-gap-3; + } + + &__body-title { + font-size: var(--bf-appearance-token-font-size-xs); + color: var(--bf-appearance-token-color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__runner { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + } + + &__loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $size-gap-2; + width: 100%; + color: var(--bf-appearance-token-color-text-muted); + font-size: var(--bf-appearance-token-font-size-sm); + } + + &__spinner { + animation: miniapp-bg-dock-spin 1s linear infinite; + } +} + +@keyframes miniapp-bg-dock-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.tsx new file mode 100644 index 0000000000..dd8b82795b --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppBackgroundDock.tsx @@ -0,0 +1,193 @@ +/** + * MiniAppBackgroundDock — resident panel hosting `background` view-mode apps. + * + * Background apps do not claim a scene tab; they stay collapsed into this dock + * (bottom-right), where the user can expand one to interact with it while it + * keeps running. Closing an entry stops its worker and removes it from the dock. + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ChevronDown, ChevronUp, Loader2, X } from 'lucide-react'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useAppearance } from '@/infrastructure/appearance'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { createLogger } from '@/shared/utils/logger'; +import { IconButton } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import { useMiniAppStore } from '../miniAppStore'; +import { pickLocalizedString } from '../utils/pickLocalizedString'; +import MiniAppRunner from './MiniAppRunner'; +import './MiniAppBackgroundDock.scss'; + +const log = createLogger('MiniAppBackgroundDock'); + +const MiniAppBackgroundDock: React.FC = () => { + const apps = useMiniAppStore((state) => state.apps); + const backgroundAppIds = useMiniAppStore((state) => state.backgroundAppIds); + const closeBackground = useMiniAppStore((state) => state.closeBackground); + const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped); + const { current: appearance } = useAppearance(); + const appearanceMode = appearance?.mode ?? 'dark'; + const { workspacePath } = useCurrentWorkspace(); + const { t, currentLanguage } = useI18n('scenes/miniapp'); + + const [expanded, setExpanded] = useState(true); + const [activeId, setActiveId] = useState(null); + const [app, setApp] = useState(null); + const [loading, setLoading] = useState(false); + const [strictRuntime, setStrictRuntime] = useState(false); + + const backgroundApps = useMemo( + () => backgroundAppIds + .map((id) => apps.find((candidate) => candidate.id === id)) + .filter((value): value is NonNullable => Boolean(value)), + [backgroundAppIds, apps], + ); + + // Keep an active selection that always points at a resident app. + useEffect(() => { + if (backgroundAppIds.length === 0) { + setActiveId(null); + return; + } + setActiveId((current) => + current && backgroundAppIds.includes(current) ? current : backgroundAppIds[0] + ); + }, [backgroundAppIds]); + + const loadApp = useCallback(async (id: string) => { + setLoading(true); + try { + const loaded = await miniAppAPI.getMiniApp(id, appearanceMode, workspacePath || undefined); + setStrictRuntime(loaded.runtime_profile === 'market_strict'); + setApp(loaded); + } catch (error) { + log.error('Failed to load background MiniApp', error); + setApp(null); + } finally { + setLoading(false); + } + }, [appearanceMode, workspacePath]); + + useEffect(() => { + if (expanded && activeId) { + void loadApp(activeId); + } else { + setApp(null); + } + }, [expanded, activeId, loadApp]); + + const handleClose = useCallback(async (id: string) => { + try { + await miniAppAPI.workerStop(id); + } catch (error) { + log.warn('Stop background worker failed', error); + } finally { + markWorkerStopped(id); + closeBackground(id); + } + }, [closeBackground, markWorkerStopped]); + + if (backgroundApps.length === 0) { + return null; + } + + const activeApp = backgroundApps.find((candidate) => candidate.id === activeId) ?? backgroundApps[0]; + const activeName = activeApp ? pickLocalizedString(activeApp, currentLanguage, 'name') : 'Mini App'; + + return ( +
+
+ + {t('dock.title', { count: backgroundApps.length })} + + setExpanded((value) => !value)} + tooltip={expanded ? t('dock.collapse') : t('dock.expand')} + aria-label={expanded ? t('dock.collapse') : t('dock.expand')} + > + {expanded ? : } + +
+ + {backgroundApps.length > 1 && expanded && ( +
+ {backgroundApps.map((candidate) => { + const name = pickLocalizedString(candidate, currentLanguage, 'name'); + const isActive = candidate.id === activeApp?.id; + return ( + + ); + })} +
+ )} + + {expanded && activeApp && ( +
+
+ {activeName} + void handleClose(activeApp.id)} + tooltip={t('dock.close')} + aria-label={t('dock.close')} + > + + +
+
+ {loading && !app && ( +
+ + {t('dock.loading')} +
+ )} + {app && ( + + )} +
+
+ )} +
+ ); +}; + +export default MiniAppBackgroundDock; diff --git a/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts b/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts index 1057a986d4..6e76295324 100644 --- a/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts +++ b/src/web-ui/src/app/scenes/miniapps/miniAppStore.ts @@ -148,6 +148,8 @@ interface MiniAppState { loading: boolean; /** App IDs whose scenes are currently open in the viewport. */ openedAppIds: string[]; + /** App IDs resident in the background dock panel (view_mode = background). */ + backgroundAppIds: string[]; /** App IDs whose JS workers are currently running. */ runningWorkerIds: string[]; /** App IDs with an active customization surface in the MiniApp tab. */ @@ -170,6 +172,10 @@ interface MiniAppState { setMarketOrigin: (appId: string, origin: InstalledMarketOrigin) => void; openApp: (id: string) => void; closeApp: (id: string) => void; + /** Add an app to the resident background dock. */ + openBackground: (id: string) => void; + /** Remove an app from the resident background dock. */ + closeBackground: (id: string) => void; setRunningWorkerIds: (ids: string[]) => void; markWorkerRunning: (id: string) => void; markWorkerStopped: (id: string) => void; @@ -188,6 +194,7 @@ export const useMiniAppStore = create((set) => ({ apps: [], loading: false, openedAppIds: [], + backgroundAppIds: [], runningWorkerIds: [], customizingAppIds: [], composerClaims: {}, @@ -199,6 +206,7 @@ export const useMiniAppStore = create((set) => ({ return { apps, openedAppIds: state.openedAppIds.filter((id) => validIds.has(id)), + backgroundAppIds: state.backgroundAppIds.filter((id) => validIds.has(id)), runningWorkerIds: state.runningWorkerIds.filter((id) => validIds.has(id)), customizingAppIds: state.customizingAppIds.filter((id) => validIds.has(id)), composerClaims: Object.fromEntries( @@ -236,6 +244,16 @@ export const useMiniAppStore = create((set) => ({ composerClaims, }; }), + openBackground: (id) => + set((state) => + state.backgroundAppIds.includes(id) + ? state + : { backgroundAppIds: [...state.backgroundAppIds, id] } + ), + closeBackground: (id) => + set((state) => ({ + backgroundAppIds: state.backgroundAppIds.filter((value) => value !== id), + })), setRunningWorkerIds: (ids) => set({ runningWorkerIds: Array.from(new Set(ids)) }), markWorkerRunning: (id) => set((state) => diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx index 581379e5cd..7fc8663bb8 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx @@ -13,7 +13,7 @@ import { import { open } from '@tauri-apps/plugin-dialog'; import { useSceneManager } from '@/app/hooks/useSceneManager'; import MiniAppCard from '../components/MiniAppCard'; -import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; +import type { MiniAppMeta, MiniAppViewMode } from '@/infrastructure/api/service-api/MiniAppAPI'; import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; import { miniAppMarketAPI, @@ -54,6 +54,8 @@ const MiniAppGalleryView: React.FC = () => { const setMarketOrigins = useMiniAppStore((state) => state.setMarketOrigins); const setRunningWorkerIds = useMiniAppStore((state) => state.setRunningWorkerIds); const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped); + const upsertApp = useMiniAppStore((state) => state.upsertApp); + const openBackground = useMiniAppStore((state) => state.openBackground); const { workspacePath } = useCurrentWorkspace(); const notification = useNotification(); const { openScene, activateScene, closeScene, openTabs } = useSceneManager(); @@ -109,6 +111,29 @@ const MiniAppGalleryView: React.FC = () => { const handleOpenApp = useCallback( (appId: string) => { setSelectedApp(null); + const app = apps.find((candidate) => candidate.id === appId); + const viewMode = app?.view_mode ?? 'front'; + + // Fire the `start` lifecycle hook on activation (host runs it if declared). + void miniAppAPI + .runLifecycleEvent(appId, 'start') + .catch((error) => log.warn('MiniApp start lifecycle failed', error)); + + if (viewMode === 'full') { + // Full mode opens an independent OS window instead of a scene tab. + void miniAppAPI + .openFullWindow(appId, app?.name) + .catch((error) => log.error('Open MiniApp window failed', error)); + return; + } + + if (viewMode === 'background') { + // Background mode stays resident in the collapsed dock panel. + openBackground(appId); + return; + } + + // Front (default): open in a scene tab in the main shell. const tabId: SceneTabId = `miniapp:${appId}`; if (openTabIds.has(tabId)) { activateScene(tabId); @@ -116,7 +141,7 @@ const MiniAppGalleryView: React.FC = () => { openScene(tabId); } }, - [openTabIds, activateScene, openScene] + [apps, openBackground, openTabIds, activateScene, openScene] ); const handleStopRunning = useCallback( @@ -136,6 +161,43 @@ const MiniAppGalleryView: React.FC = () => { [markWorkerStopped, closeScene, openTabIds] ); + const handleSetViewMode = useCallback( + async (appId: string, mode: MiniAppViewMode) => { + try { + const updated = await miniAppAPI.setViewMode(appId, mode); + upsertApp(updated); + setSelectedApp((current) => (current && current.id === appId ? updated : current)); + } catch (error) { + log.error('Set view mode failed', error); + } + }, + [upsertApp] + ); + + const handleRunScript = useCallback( + async (appId: string, scriptName: string) => { + try { + const result = await miniAppAPI.runScript(appId, scriptName); + if (result.succeeded) { + notification.success(t('detail.scripts.ran', { name: scriptName })); + } else { + notification.error( + t('detail.scripts.failed', { + name: scriptName, + error: result.error ?? (result.stderr || '').trim(), + }) + ); + } + } catch (error) { + log.error('Run script failed', error); + notification.error( + t('detail.scripts.failed', { name: scriptName, error: String(error) }) + ); + } + }, + [notification, t] + ); + const handleDeleteRequest = (appId: string) => { setPendingDeleteId(appId); }; @@ -433,16 +495,97 @@ const MiniAppGalleryView: React.FC = () => { > {selectedApp ? (() => { const detailTags = pickLocalizedTags(selectedApp, currentLanguage); - return detailTags.length ? ( -
- {detailTags.map((tag) => ( - - - {tag} + const activeMode: MiniAppViewMode = selectedApp.view_mode ?? 'front'; + const modeVariant = (mode: MiniAppViewMode) => + activeMode === mode ? 'primary' : 'secondary'; + return ( + <> +
+ + {t('detail.viewMode.label')} - ))} -
- ) : null; +
+ + + +
+

+ {t('detail.viewMode.hint')} +

+
+ {selectedApp.scripts && selectedApp.scripts.length > 0 ? ( +
+ + {t('detail.scripts.label')} + +
+ {selectedApp.scripts.map((script) => ( +
+ + {script.description ? ( + + {script.description} + + ) : null} +
+ ))} +
+
+ ) : null} + {detailTags.length ? ( +
+ {detailTags.map((tag) => ( + + + {tag} + + ))} +
+ ) : null} + + ); })() : null} diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index db0f4f7b8d..c76a074a7a 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -28,6 +28,7 @@ import { KeyboardShortcutsTab, McpToolsConfig, MemoriesConfig, + SecretsConfig, QuickActionsConfig, ReviewConfig, SessionPermissionsConfig, @@ -71,6 +72,7 @@ function resolveSettingsContent(tab: ConfigTab): React.ComponentType | null { case 'voice-input': return VoiceInputConfig; case 'review': return ReviewConfig; case 'memories': return MemoriesConfig; + case 'secrets': return SecretsConfig; case 'mcp-tools': return McpToolsConfig; case 'external-sources': return ExternalSourcesConfig; // Hooks are part of the external AI applications surface. diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index d57f18cf19..f6e2707d96 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -18,6 +18,7 @@ export type ConfigTab = | 'voice-input' | 'review' | 'memories' + | 'secrets' | 'mcp-tools' | 'external-sources' | 'hooks' @@ -256,6 +257,22 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'knowledge', ], }, + { + id: 'secrets', + labelKey: 'configCenter.tabs.secrets', + descriptionKey: 'configCenter.tabDescriptions.secrets', + keywords: [ + 'secret', + 'secrets', + 'variable', + 'variables', + 'token', + 'password', + 'credential', + 'api key', + 'placeholder', + ], + }, { id: 'external-sources', labelKey: 'configCenter.tabs.externalSources', diff --git a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts index 3cda2c3427..9ee79a98b1 100644 --- a/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsContentRegistry.ts @@ -11,6 +11,7 @@ const loadBasicsConfig = () => import('../../../infrastructure/config/components const loadAppearanceConfig = () => import('../../../infrastructure/config/components/AppearanceConfig'); const loadReviewConfig = () => import('../../../infrastructure/config/components/ReviewConfig'); const loadMemoriesConfig = () => import('../../../infrastructure/config/components/MemoriesConfig'); +const loadSecretsConfig = () => import('../../../infrastructure/config/components/SecretsConfig'); const loadQuickActionsConfig = () => import('../../../infrastructure/config/components/QuickActionsConfig'); const loadVoiceInputConfig = () => import('../../../infrastructure/config/components/VoiceInputConfig'); const loadArchivedSessionsConfig = () => import('./components/ArchivedSessionsConfig'); @@ -28,6 +29,7 @@ export const BasicsConfig = lazy(loadBasicsConfig); export const AppearanceConfig = lazy(loadAppearanceConfig); export const ReviewConfig = lazy(loadReviewConfig); export const MemoriesConfig = lazy(loadMemoriesConfig); +export const SecretsConfig = lazy(loadSecretsConfig); export const QuickActionsConfig = lazy(loadQuickActionsConfig); export const VoiceInputConfig = lazy(loadVoiceInputConfig); export const ArchivedSessionsConfig = lazy(loadArchivedSessionsConfig); @@ -58,6 +60,7 @@ const SETTINGS_CONTENT_LOADERS: Partial Promise 'voice-input': loadVoiceInputConfig, review: loadReviewConfig, memories: loadMemoriesConfig, + secrets: loadSecretsConfig, 'mcp-tools': loadMcpToolsConfig, 'external-sources': loadExternalSourcesConfig, // Hooks no longer have a standalone GUI page; a stale deep link resolves to diff --git a/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts index 42ef96dcae..4ff602f232 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabI18n.ts @@ -33,6 +33,7 @@ export const SETTINGS_TAB_I18N_NAMESPACES: Record; } +/** + * How a MiniApp is presented in the host shell: + * - `background`: collapsed into a compact resident panel + * - `front` (default): opens inside a tab in the main content area + * - `full`: opens in its own independent OS window + */ +export type MiniAppViewMode = 'background' | 'front' | 'full'; + +/** A MiniApp lifecycle transition that can trigger a user-defined script. */ +export type MiniAppLifecycleEvent = 'install' | 'uninstall' | 'start' | 'stop'; + +/** Per-event lifecycle script paths, relative to the app root. */ +export interface MiniAppLifecycleScripts { + install?: string; + uninstall?: string; + start?: string; + stop?: string; +} + +/** A named script the app ships to extend its capabilities. */ +export interface MiniAppScriptDef { + name: string; + path: string; + description?: string; +} + +/** Result of running a named script via `runScript`. */ +export interface ScriptRunResult { + ran: boolean; + succeeded: boolean; + exitCode?: number | null; + stdout: string; + stderr: string; + error?: string | null; +} + +/** Result of triggering a lifecycle event via `runLifecycleEvent`. */ +export interface LifecycleRunResult { + /** Whether a script was declared and therefore run. */ + ran: boolean; + succeeded: boolean; + exitCode?: number | null; + error?: string | null; +} + export interface MiniAppMeta { id: string; name: string; @@ -189,6 +234,12 @@ export interface MiniAppMeta { permissions: MiniAppPermissions; runtime?: MiniAppRuntimeState; runtime_profile?: MiniAppRuntimeProfile; + /** Presentation mode (background panel / front tab / full window). */ + view_mode?: MiniAppViewMode; + /** User-declared lifecycle scripts (install/uninstall/start/stop). */ + lifecycle?: MiniAppLifecycleScripts; + /** Named scripts the app ships to extend its capabilities. */ + scripts?: MiniAppScriptDef[]; /** Optional per-locale overrides for `name` / `description` / `tags`. */ i18n?: MiniAppI18n; } @@ -345,6 +396,64 @@ export class MiniAppAPI { } } + /** Set the app's persisted view mode (background / front / full). */ + async setViewMode(appId: string, viewMode: MiniAppViewMode): Promise { + try { + return await api.invoke('miniapp_set_view_mode', { request: { appId, viewMode } }); + } catch (error) { + throw createTauriCommandError('miniapp_set_view_mode', error, { appId, viewMode }); + } + } + + /** Replace the app's lifecycle scripts (install/uninstall/start/stop). */ + async setLifecycleScripts(appId: string, lifecycle: MiniAppLifecycleScripts): Promise { + try { + return await api.invoke('miniapp_set_lifecycle_scripts', { request: { appId, lifecycle } }); + } catch (error) { + throw createTauriCommandError('miniapp_set_lifecycle_scripts', error, { appId }); + } + } + + /** + * Explicitly trigger a lifecycle event. Install/uninstall/stop also fire + * automatically host-side; the UI uses this for `start` (and `stop`) on + * activation / deactivation. + */ + async runLifecycleEvent(appId: string, event: MiniAppLifecycleEvent): Promise { + try { + return await api.invoke('miniapp_run_lifecycle_event', { request: { appId, event } }); + } catch (error) { + throw createTauriCommandError('miniapp_run_lifecycle_event', error, { appId, event }); + } + } + + /** Open (or focus) an independent OS window hosting the app in full mode. */ + async openFullWindow(appId: string, title?: string): Promise { + try { + await api.invoke('open_miniapp_full_window', { appId, title }); + } catch (error) { + throw createTauriCommandError('open_miniapp_full_window', error, { appId }); + } + } + + /** Replace the app's named scripts (scripts that extend its capabilities). */ + async setScripts(appId: string, scripts: MiniAppScriptDef[]): Promise { + try { + return await api.invoke('miniapp_set_scripts', { request: { appId, scripts } }); + } catch (error) { + throw createTauriCommandError('miniapp_set_scripts', error, { appId }); + } + } + + /** Run a named script the app declared, forwarding optional args. */ + async runScript(appId: string, script: string, args: string[] = []): Promise { + try { + return await api.invoke('miniapp_run_script', { request: { appId, script, args } }); + } catch (error) { + throw createTauriCommandError('miniapp_run_script', error, { appId, script }); + } + } + async getMiniAppVersions(appId: string): Promise { try { return await api.invoke('get_miniapp_versions', { appId }); diff --git a/src/web-ui/src/infrastructure/api/service-api/UserSecretsAPI.ts b/src/web-ui/src/infrastructure/api/service-api/UserSecretsAPI.ts new file mode 100644 index 0000000000..d87ae4570a --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/UserSecretsAPI.ts @@ -0,0 +1,44 @@ +/** + * UserSecretsAPI — settings CRUD for {{name}} secret variables. + * List responses never include values. + */ + +import { api } from '../index'; +import { createTauriCommandError } from '../errors/TauriCommandError'; + +export interface UserSecretSummary { + name: string; + updatedAt: number; +} + +export class UserSecretsAPI { + async list(): Promise { + try { + return await api.invoke('list_user_secrets'); + } catch (error) { + throw createTauriCommandError('list_user_secrets', error); + } + } + + async upsert(name: string, value: string): Promise { + try { + return await api.invoke('upsert_user_secret', { + request: { name, value }, + }); + } catch (error) { + throw createTauriCommandError('upsert_user_secret', error, { name }); + } + } + + async delete(name: string): Promise { + try { + return await api.invoke('delete_user_secret', { + request: { name }, + }); + } catch (error) { + throw createTauriCommandError('delete_user_secret', error, { name }); + } + } +} + +export const userSecretsAPI = new UserSecretsAPI(); diff --git a/src/web-ui/src/infrastructure/config/components/SecretsConfig.scss b/src/web-ui/src/infrastructure/config/components/SecretsConfig.scss new file mode 100644 index 0000000000..232f1ddee3 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SecretsConfig.scss @@ -0,0 +1,34 @@ +.bitfun-secrets-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2, 8px); +} + +.bitfun-secrets-list__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3, 12px); + padding: var(--space-2, 8px) 0; + border-bottom: 1px solid var(--border-subtle, rgba(0, 0, 0, 0.08)); +} + +.bitfun-secrets-list__meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.bitfun-secrets-list__name { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.9rem; +} + +.bitfun-secrets-list__hint { + color: var(--text-secondary, #666); + font-size: 0.8rem; +} diff --git a/src/web-ui/src/infrastructure/config/components/SecretsConfig.tsx b/src/web-ui/src/infrastructure/config/components/SecretsConfig.tsx new file mode 100644 index 0000000000..f668bba101 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SecretsConfig.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, Trash2 } from 'lucide-react'; +import { Button, IconButton, Input, confirmDanger } from '@/component-library'; +import { useNotification } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { + userSecretsAPI, + type UserSecretSummary, +} from '@/infrastructure/api/service-api/UserSecretsAPI'; +import { + ConfigPageContent, + ConfigPageHeader, + ConfigPageLayout, + ConfigPageLoading, + ConfigPageMessage, + ConfigPageRow, + ConfigPageSection, +} from './common'; +import './SecretsConfig.scss'; + +const log = createLogger('SecretsConfig'); + +const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export const SecretsConfig: React.FC = () => { + const { t } = useTranslation('settings/secrets'); + const { success, error: notifyError } = useNotification(); + const [loading, setLoading] = useState(true); + const [secrets, setSecrets] = useState([]); + const [name, setName] = useState(''); + const [value, setValue] = useState(''); + const [saving, setSaving] = useState(false); + const [nameError, setNameError] = useState(); + + const refresh = useCallback(async () => { + try { + const rows = await userSecretsAPI.list(); + setSecrets(rows); + } catch (err) { + log.error('Failed to list user secrets', err); + notifyError(t('errors.loadFailed')); + } finally { + setLoading(false); + } + }, [notifyError, t]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const validateName = (next: string): boolean => { + if (!next.trim()) { + setNameError(t('errors.nameRequired')); + return false; + } + if (!NAME_PATTERN.test(next.trim())) { + setNameError(t('errors.nameInvalid')); + return false; + } + setNameError(undefined); + return true; + }; + + const handleSave = async () => { + const trimmed = name.trim(); + if (!validateName(trimmed)) return; + if (!value) { + notifyError(t('errors.valueRequired')); + return; + } + setSaving(true); + try { + await userSecretsAPI.upsert(trimmed, value); + setName(''); + setValue(''); + success(t('toast.saved', { name: trimmed })); + await refresh(); + } catch (err) { + log.error('Failed to upsert user secret', err); + notifyError(err instanceof Error ? err.message : t('errors.saveFailed')); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (secretName: string) => { + const confirmed = await confirmDanger( + t('delete.title'), + t('delete.message', { name: secretName }), + { + confirmText: t('delete.confirm'), + cancelText: t('delete.cancel'), + } + ); + if (!confirmed) return; + try { + await userSecretsAPI.delete(secretName); + success(t('toast.deleted', { name: secretName })); + await refresh(); + } catch (err) { + log.error('Failed to delete user secret', err); + notifyError(t('errors.deleteFailed')); + } + }; + + if (loading) { + return ; + } + + return ( + + + + + + { + setName(e.target.value); + if (nameError) validateName(e.target.value); + }} + placeholder={t('fields.name.placeholder')} + error={Boolean(nameError)} + errorMessage={nameError} + autoComplete="off" + spellCheck={false} + /> + + + setValue(e.target.value)} + placeholder={t('fields.value.placeholder')} + autoComplete="new-password" + spellCheck={false} + /> + + + + + + + + {secrets.length === 0 ? ( + + ) : ( +
    + {secrets.map((secret) => ( +
  • +
    + {`{{${secret.name}}}`} + {t('list.valueHidden')} +
    + void handleDelete(secret.name)} + aria-label={t('actions.delete', { name: secret.name })} + > + + +
  • + ))} +
+ )} +
+
+
+ ); +}; + +export default SecretsConfig; diff --git a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts index db9d7a45f3..33cd0958b0 100644 --- a/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts +++ b/src/web-ui/src/infrastructure/i18n/presets/namespaceRegistry.ts @@ -37,6 +37,7 @@ export const ALL_NAMESPACES = [ 'settings/mcp', 'settings/mcp-tools', 'settings/memories', + 'settings/secrets', 'settings/quick-actions', 'settings/review', 'settings/session-config', diff --git a/src/web-ui/src/locales/en-US/scenes/miniapp.json b/src/web-ui/src/locales/en-US/scenes/miniapp.json index 52f488d02e..bd89e02d76 100644 --- a/src/web-ui/src/locales/en-US/scenes/miniapp.json +++ b/src/web-ui/src/locales/en-US/scenes/miniapp.json @@ -21,7 +21,26 @@ "detail": { "stop": "Stop", "delete": "Delete", - "open": "Open" + "open": "Open", + "viewMode": { + "label": "View mode", + "background": "Background", + "front": "Tab", + "full": "Window", + "hint": "Background keeps the app in a panel, Tab opens it in the workspace, Window opens a separate window." + }, + "scripts": { + "label": "Scripts", + "ran": "Script \"{{name}}\" finished", + "failed": "Script \"{{name}}\" failed: {{error}}" + } + }, + "dock": { + "title": "Background apps ({{count}})", + "expand": "Expand", + "collapse": "Collapse", + "close": "Close", + "loading": "Loading…" }, "confirmDelete": { "title": "Delete \"{{name}}\"?", diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 07190c7d76..b96ae08dd0 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -14,6 +14,7 @@ "quickActions": [], "review": [], "memories": [], + "secrets": [], "usageStatistics": [] }, "tabDescriptions": { @@ -28,6 +29,7 @@ "voiceInput": "Local microphone input and speech-to-text model.", "review": "Review strategy, coverage depth, capacity, cost, and latency controls.", "memories": "Automatic memory generation, injection, retention windows, and memory models.", + "secrets": "Secret variables referenced in chat as {{name}}; resolved only when tools run.", "mcpTools": "MCP servers and tool integrations.", "externalSources": "Load compatible commands and extensions from other AI applications.", "hooks": "Run your own commands at Agent lifecycle points. Codex-compatible.", @@ -54,6 +56,7 @@ "voiceInput": "Voice Input", "review": "Review", "memories": "Memory", + "secrets": "Secrets", "skills": "Skills", "mcpTools": "MCP", "externalSources": "External AI Apps", diff --git a/src/web-ui/src/locales/en-US/settings/secrets.json b/src/web-ui/src/locales/en-US/settings/secrets.json new file mode 100644 index 0000000000..c7ea1c3fbf --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/secrets.json @@ -0,0 +1,54 @@ +{ + "title": "Secrets", + "subtitle": "Store secret values and reference them in chat as {{name}}. The agent never sees the value; it is resolved only when a tool runs.", + "sections": { + "add": { + "title": "Add or update", + "description": "Names are case-sensitive. Saving the same name replaces the previous value." + }, + "list": { + "title": "Stored secrets", + "description": "Values stay encrypted on this device and are never shown again after save." + } + }, + "fields": { + "name": { + "label": "Name", + "description": "Letters, digits, and underscore. Must start with a letter or underscore.", + "placeholder": "api_token" + }, + "value": { + "label": "Value", + "description": "Write-only. Paste the secret here; it will not be readable from Settings afterward.", + "placeholder": "••••••••" + }, + "usageHint": "In chat, write {{api_token}}. The message body keeps that placeholder; tools receive the real value." + }, + "actions": { + "save": "Save secret", + "saving": "Saving…", + "delete": "Delete {{name}}" + }, + "list": { + "empty": "No secrets yet.", + "valueHidden": "Value hidden" + }, + "delete": { + "title": "Delete secret", + "message": "Delete {{name}}? Chat placeholders that still use it will fail at tool time until you recreate it.", + "confirm": "Delete", + "cancel": "Cancel" + }, + "toast": { + "saved": "Saved {{name}}", + "deleted": "Deleted {{name}}" + }, + "errors": { + "loadFailed": "Failed to load secrets.", + "saveFailed": "Failed to save secret.", + "deleteFailed": "Failed to delete secret.", + "nameRequired": "Name is required.", + "nameInvalid": "Use letters, digits, and underscore only.", + "valueRequired": "Value is required." + } +} diff --git a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json index 95b3989326..5afcaf7d55 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-CN/scenes/miniapp.json @@ -21,7 +21,26 @@ "detail": { "stop": "停止", "delete": "删除", - "open": "打开" + "open": "打开", + "viewMode": { + "label": "打开方式", + "background": "后台面板", + "front": "标签页", + "full": "独立窗口", + "hint": "后台面板常驻侧栏面板,标签页在工作区中打开,独立窗口在单独的系统窗口中打开。" + }, + "scripts": { + "label": "脚本", + "ran": "脚本“{{name}}”已执行完成", + "failed": "脚本“{{name}}”执行失败:{{error}}" + } + }, + "dock": { + "title": "后台应用({{count}})", + "expand": "展开", + "collapse": "收起", + "close": "关闭", + "loading": "加载中…" }, "confirmDelete": { "title": "删除 \"{{name}}\"?", diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 803074badd..4401e6e7bf 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -35,6 +35,11 @@ "长期记忆", "学习" ], + "secrets": [ + "密钥", + "密钥变量", + "secret" + ], "usageStatistics": [ "调用统计", "用量", @@ -56,6 +61,7 @@ "voiceInput": "本地麦克风输入与语音转文字模型。", "review": "Review 策略、覆盖深度、容量、成本和耗时控制。", "memories": "自动记忆生成、注入、整理窗口与记忆模型。", + "secrets": "在对话中用 {{name}} 引用的密钥变量;仅在工具执行时解析。", "mcpTools": "MCP 服务器与工具集成。", "externalSources": "加载其他 AI 应用中兼容的命令与扩展。", "hooks": "在 Agent 生命周期节点运行你自己的命令,与 Codex Hooks 兼容。", @@ -82,6 +88,7 @@ "voiceInput": "语音输入", "review": "审核", "memories": "记忆", + "secrets": "密钥变量", "skills": "技能", "mcpTools": "MCP", "externalSources": "外部 AI 应用", diff --git a/src/web-ui/src/locales/zh-CN/settings/secrets.json b/src/web-ui/src/locales/zh-CN/settings/secrets.json new file mode 100644 index 0000000000..3a5e4e21cd --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/secrets.json @@ -0,0 +1,54 @@ +{ + "title": "密钥变量", + "subtitle": "在设置中保存密钥,对话里用 {{name}} 引用。Agent 看不到明文,只有工具真正执行时才会解析。", + "sections": { + "add": { + "title": "添加或更新", + "description": "名称区分大小写。使用同名会覆盖旧值。" + }, + "list": { + "title": "已保存的密钥", + "description": "值在本机加密存储,保存后无法在设置中再次查看。" + } + }, + "fields": { + "name": { + "label": "名称", + "description": "仅字母、数字和下划线,且须以字母或下划线开头。", + "placeholder": "api_token" + }, + "value": { + "label": "值", + "description": "只写不读。粘贴密钥后,设置页不会再展示明文。", + "placeholder": "••••••••" + }, + "usageHint": "在对话中写 {{api_token}}。消息正文始终保留占位符;工具调用时才会替换为真实值。" + }, + "actions": { + "save": "保存密钥", + "saving": "保存中…", + "delete": "删除 {{name}}" + }, + "list": { + "empty": "还没有密钥。", + "valueHidden": "值已隐藏" + }, + "delete": { + "title": "删除密钥", + "message": "删除 {{name}}?仍引用它的对话占位符会在工具执行时失败,直到你重新创建。", + "confirm": "删除", + "cancel": "取消" + }, + "toast": { + "saved": "已保存 {{name}}", + "deleted": "已删除 {{name}}" + }, + "errors": { + "loadFailed": "加载密钥失败。", + "saveFailed": "保存密钥失败。", + "deleteFailed": "删除密钥失败。", + "nameRequired": "名称不能为空。", + "nameInvalid": "只能使用字母、数字和下划线。", + "valueRequired": "值不能为空。" + } +} diff --git a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json index bc0fb2d23b..4fd91c0ce7 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/miniapp.json +++ b/src/web-ui/src/locales/zh-TW/scenes/miniapp.json @@ -21,7 +21,26 @@ "detail": { "stop": "停止", "delete": "刪除", - "open": "開啟" + "open": "開啟", + "viewMode": { + "label": "開啟方式", + "background": "背景面板", + "front": "分頁", + "full": "獨立視窗", + "hint": "背景面板常駐側欄面板,分頁在工作區中開啟,獨立視窗在單獨的系統視窗中開啟。" + }, + "scripts": { + "label": "腳本", + "ran": "腳本「{{name}}」已執行完成", + "failed": "腳本「{{name}}」執行失敗:{{error}}" + } + }, + "dock": { + "title": "背景應用({{count}})", + "expand": "展開", + "collapse": "收起", + "close": "關閉", + "loading": "載入中…" }, "confirmDelete": { "title": "刪除 \"{{name}}\"?", diff --git a/src/web-ui/src/locales/zh-TW/settings.json b/src/web-ui/src/locales/zh-TW/settings.json index 2a1ba87f85..1c8993e76a 100644 --- a/src/web-ui/src/locales/zh-TW/settings.json +++ b/src/web-ui/src/locales/zh-TW/settings.json @@ -35,6 +35,11 @@ "長期記憶", "學習" ], + "secrets": [ + "密鑰", + "密鑰變數", + "secret" + ], "usageStatistics": [ "調用統計", "用量", @@ -54,6 +59,7 @@ "sessionPermissions": "工具權限、執行方式,以及桌面和瀏覽器控制。", "review": "Review 策略、覆蓋深度、容量、成本和耗時控制。", "memories": "自動記憶生成、注入、整理窗口與記憶模型。", + "secrets": "在對話中用 {{name}} 引用的密鑰變數;僅在工具執行時解析。", "mcpTools": "MCP 伺服器與工具集成。", "externalSources": "載入其他 AI 應用中相容的命令與擴充。", "hooks": "在 Agent 生命週期節點執行你自己的命令,與 Codex Hooks 相容。", @@ -80,6 +86,7 @@ "sessionPermissions": "權限管理", "review": "審核", "memories": "記憶", + "secrets": "密鑰變數", "skills": "技能", "mcpTools": "MCP", "externalSources": "外部 AI 應用", diff --git a/src/web-ui/src/locales/zh-TW/settings/secrets.json b/src/web-ui/src/locales/zh-TW/settings/secrets.json new file mode 100644 index 0000000000..c9838e13e5 --- /dev/null +++ b/src/web-ui/src/locales/zh-TW/settings/secrets.json @@ -0,0 +1,54 @@ +{ + "title": "密鑰變數", + "subtitle": "在設定中保存密鑰,對話裡用 {{name}} 引用。Agent 看不到明文,只有工具真正執行時才會解析。", + "sections": { + "add": { + "title": "新增或更新", + "description": "名稱區分大小寫。使用同名會覆蓋舊值。" + }, + "list": { + "title": "已保存的密鑰", + "description": "值在本機加密儲存,保存後無法在設定中再次查看。" + } + }, + "fields": { + "name": { + "label": "名稱", + "description": "僅字母、數字和底線,且須以字母或底線開頭。", + "placeholder": "api_token" + }, + "value": { + "label": "值", + "description": "只寫不讀。貼上密鑰後,設定頁不會再顯示明文。", + "placeholder": "••••••••" + }, + "usageHint": "在對話中寫 {{api_token}}。訊息本文始終保留佔位符;工具呼叫時才會替換為真實值。" + }, + "actions": { + "save": "保存密鑰", + "saving": "保存中…", + "delete": "刪除 {{name}}" + }, + "list": { + "empty": "還沒有密鑰。", + "valueHidden": "值已隱藏" + }, + "delete": { + "title": "刪除密鑰", + "message": "刪除 {{name}}?仍引用它的對話佔位符會在工具執行時失敗,直到你重新建立。", + "confirm": "刪除", + "cancel": "取消" + }, + "toast": { + "saved": "已保存 {{name}}", + "deleted": "已刪除 {{name}}" + }, + "errors": { + "loadFailed": "載入密鑰失敗。", + "saveFailed": "保存密鑰失敗。", + "deleteFailed": "刪除密鑰失敗。", + "nameRequired": "名稱不能為空。", + "nameInvalid": "只能使用字母、數字和底線。", + "valueRequired": "值不能為空。" + } +} diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index fe7ce2b532..29a56ff8f9 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -1,8 +1,10 @@ import ReactDOM from "react-dom/client"; import App from "./app/App"; import AgentCompanionDesktopPet from "./app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet"; +import MiniAppScene from "./app/scenes/miniapps/MiniAppScene"; import AppErrorBoundary from "./app/components/AppErrorBoundary"; import { STARTUP_OVERLAY_HIDDEN_EVENT } from "./app/startup/startupSignals"; +import { hideStartupOverlay } from "./app/startup/startupOverlay"; import { WorkspaceProvider } from "./infrastructure/contexts/WorkspaceProvider"; import { PeerDeviceProvider } from "./infrastructure/peer-device/PeerDeviceContext"; import { PeerHostInvokeBridge } from "./infrastructure/peer-device/PeerHostInvokeBridge"; @@ -340,10 +342,37 @@ async function startApplication(): Promise { durationMs: 0, mode: 'static', }); - const isAgentCompanionWindow = new URLSearchParams(window.location.search) - .get('bitfunWindow') === 'agent-companion'; + const windowParams = new URLSearchParams(window.location.search); + const isAgentCompanionWindow = windowParams.get('bitfunWindow') === 'agent-companion'; + const miniAppWindowId = + windowParams.get('bitfunWindow') === 'miniapp' + ? (windowParams.get('miniAppId') ?? '').trim() + : ''; const renderStartedAt = nowMs(); + if (miniAppWindowId) { + // Full view mode: host the MiniApp in its own OS window, reusing the shared + // scene component inside the minimal provider set it needs. + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + ); + // This standalone window does not run the full post-render startup pipeline, + // so dismiss the static startup overlay explicitly or it would cover the app. + void hideStartupOverlay(); + logElapsed(log, 'Startup step completed', renderStartedAt, { + data: { + step: 'scheduleMiniAppWindowRender', + sinceStartupMs: elapsedMs(appStartedAt), + }, + }); + return; + } if (isAgentCompanionWindow) { ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(