From 20dcbf50e999dd019345d1069aaf1e5b756f5da0 Mon Sep 17 00:00:00 2001 From: bowen628 Date: Sun, 1 Mar 2026 18:32:47 +0800 Subject: [PATCH 01/17] init remote control --- Cargo.toml | 19 + package-lock.json | 10 + package.json | 1 + src/apps/desktop/src/api/app_state.rs | 1 + src/apps/desktop/src/api/mod.rs | 1 + .../desktop/src/api/remote_connect_api.rs | 248 ++ src/apps/desktop/src/lib.rs | 8 + src/apps/relay-server/Caddyfile | 17 + src/apps/relay-server/Cargo.toml | 37 + src/apps/relay-server/Dockerfile | 54 + src/apps/relay-server/README.md | 103 + src/apps/relay-server/deploy.sh | 50 + src/apps/relay-server/deploy/Cargo.toml | 32 + src/apps/relay-server/deploy/Dockerfile | 29 + .../relay-server/deploy/docker-compose.yml | 15 + src/apps/relay-server/docker-compose.yml | 37 + src/apps/relay-server/src/config.rs | 47 + src/apps/relay-server/src/main.rs | 70 + src/apps/relay-server/src/relay/mod.rs | 5 + src/apps/relay-server/src/relay/room.rs | 456 ++ src/apps/relay-server/src/routes/api.rs | 100 + src/apps/relay-server/src/routes/mod.rs | 4 + src/apps/relay-server/src/routes/websocket.rs | 213 + src/crates/core/Cargo.toml | 19 + .../src/agentic/coordination/coordinator.rs | 24 +- src/crates/core/src/service/mod.rs | 1 + .../src/service/remote_connect/bot/feishu.rs | 159 + .../src/service/remote_connect/bot/mod.rs | 30 + .../service/remote_connect/bot/telegram.rs | 159 + .../core/src/service/remote_connect/device.rs | 74 + .../service/remote_connect/embedded_relay.rs | 297 ++ .../src/service/remote_connect/encryption.rs | 189 + .../core/src/service/remote_connect/lan.rs | 34 + .../core/src/service/remote_connect/mod.rs | 410 ++ .../core/src/service/remote_connect/ngrok.rs | 137 + .../src/service/remote_connect/pairing.rs | 267 ++ .../service/remote_connect/qr_generator.rs | 60 + .../service/remote_connect/relay_client.rs | 280 ++ .../service/remote_connect/session_bridge.rs | 693 +++ src/crates/core/src/service/workspace/mod.rs | 6 +- .../core/src/service/workspace/service.rs | 16 + src/crates/transport/src/adapters/tauri.rs | 12 + src/mobile-web/index.html | 17 + src/mobile-web/package-lock.json | 3724 +++++++++++++++++ src/mobile-web/package.json | 30 + src/mobile-web/src/App.tsx | 69 + src/mobile-web/src/main.tsx | 9 + src/mobile-web/src/pages/ChatPage.tsx | 197 + src/mobile-web/src/pages/PairingPage.tsx | 88 + src/mobile-web/src/pages/SessionListPage.tsx | 146 + src/mobile-web/src/pages/WorkspacePage.tsx | 178 + src/mobile-web/src/services/E2EEncryption.ts | 78 + .../src/services/RelayConnection.ts | 297 ++ .../src/services/RemoteSessionManager.ts | 180 + src/mobile-web/src/services/store.ts | 70 + src/mobile-web/src/styles/mobile.scss | 607 +++ src/mobile-web/tsconfig.json | 24 + src/mobile-web/vite.config.ts | 17 + .../RemoteConnectDialog.scss | 228 +- .../RemoteConnectDialog.tsx | 440 +- .../services/AgenticEventListener.ts | 18 + .../flow-chat-manager/EventHandlerModule.ts | 41 + .../api/service-api/AgentAPI.ts | 8 + .../api/service-api/RemoteConnectAPI.ts | 121 + src/web-ui/src/locales/en-US/common.json | 37 +- src/web-ui/src/locales/zh-CN/common.json | 37 +- .../src/shared/crypto/e2e-encryption.ts | 184 + src/web-ui/src/shared/crypto/index.ts | 9 + 68 files changed, 11155 insertions(+), 123 deletions(-) create mode 100644 src/apps/desktop/src/api/remote_connect_api.rs create mode 100644 src/apps/relay-server/Caddyfile create mode 100644 src/apps/relay-server/Cargo.toml create mode 100644 src/apps/relay-server/Dockerfile create mode 100644 src/apps/relay-server/README.md create mode 100755 src/apps/relay-server/deploy.sh create mode 100644 src/apps/relay-server/deploy/Cargo.toml create mode 100644 src/apps/relay-server/deploy/Dockerfile create mode 100644 src/apps/relay-server/deploy/docker-compose.yml create mode 100644 src/apps/relay-server/docker-compose.yml create mode 100644 src/apps/relay-server/src/config.rs create mode 100644 src/apps/relay-server/src/main.rs create mode 100644 src/apps/relay-server/src/relay/mod.rs create mode 100644 src/apps/relay-server/src/relay/room.rs create mode 100644 src/apps/relay-server/src/routes/api.rs create mode 100644 src/apps/relay-server/src/routes/mod.rs create mode 100644 src/apps/relay-server/src/routes/websocket.rs create mode 100644 src/crates/core/src/service/remote_connect/bot/feishu.rs create mode 100644 src/crates/core/src/service/remote_connect/bot/mod.rs create mode 100644 src/crates/core/src/service/remote_connect/bot/telegram.rs create mode 100644 src/crates/core/src/service/remote_connect/device.rs create mode 100644 src/crates/core/src/service/remote_connect/embedded_relay.rs create mode 100644 src/crates/core/src/service/remote_connect/encryption.rs create mode 100644 src/crates/core/src/service/remote_connect/lan.rs create mode 100644 src/crates/core/src/service/remote_connect/mod.rs create mode 100644 src/crates/core/src/service/remote_connect/ngrok.rs create mode 100644 src/crates/core/src/service/remote_connect/pairing.rs create mode 100644 src/crates/core/src/service/remote_connect/qr_generator.rs create mode 100644 src/crates/core/src/service/remote_connect/relay_client.rs create mode 100644 src/crates/core/src/service/remote_connect/session_bridge.rs create mode 100644 src/mobile-web/index.html create mode 100644 src/mobile-web/package-lock.json create mode 100644 src/mobile-web/package.json create mode 100644 src/mobile-web/src/App.tsx create mode 100644 src/mobile-web/src/main.tsx create mode 100644 src/mobile-web/src/pages/ChatPage.tsx create mode 100644 src/mobile-web/src/pages/PairingPage.tsx create mode 100644 src/mobile-web/src/pages/SessionListPage.tsx create mode 100644 src/mobile-web/src/pages/WorkspacePage.tsx create mode 100644 src/mobile-web/src/services/E2EEncryption.ts create mode 100644 src/mobile-web/src/services/RelayConnection.ts create mode 100644 src/mobile-web/src/services/RemoteSessionManager.ts create mode 100644 src/mobile-web/src/services/store.ts create mode 100644 src/mobile-web/src/styles/mobile.scss create mode 100644 src/mobile-web/tsconfig.json create mode 100644 src/mobile-web/vite.config.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts create mode 100644 src/web-ui/src/shared/crypto/e2e-encryption.ts create mode 100644 src/web-ui/src/shared/crypto/index.ts diff --git a/Cargo.toml b/Cargo.toml index 3eba81e9..8891410c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "src/apps/cli", "src/apps/desktop", "src/apps/server", + "src/apps/relay-server", ] exclude = [ @@ -111,6 +112,24 @@ win32job = "2.0" fluent-bundle = "0.15" unic-langid = "0.9" +# Encryption (Remote Connect E2E) +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +aes-gcm = "0.10" +sha2 = "0.10" +rand = "0.8" + +# Device/Network info (Remote Connect) +mac_address = "1.1" +local-ip-address = "0.6" +hostname = "0.4" + +# QR code generation +qrcode = "0.14" +image = { version = "0.25", default-features = false, features = ["png"] } + +# WebSocket client +tokio-tungstenite = { version = "0.21", features = ["native-tls"] } + [profile.dev] incremental = true diff --git a/package-lock.json b/package-lock.json index 0879afde..53d7c869 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "partial-json": "^0.1.7", "path-browserify": "^1.0.1", "prismjs": "^1.30.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.3", @@ -6552,6 +6553,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", diff --git a/package.json b/package.json index 369d2b36..a8a62c75 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "partial-json": "^0.1.7", "path-browserify": "^1.0.1", "prismjs": "^1.30.0", + "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.3", diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 860c5855..34b08ae7 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -61,6 +61,7 @@ impl AppState { }; let workspace_service = Arc::new(workspace::WorkspaceService::new().await?); + workspace::set_global_workspace_service(workspace_service.clone()); let filesystem_service = Arc::new(filesystem::FileSystemServiceFactory::create_default()); ai_rules::initialize_global_ai_rules_service() diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 14e384eb..5bfbf6b1 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -29,5 +29,6 @@ pub mod subagent_api; pub mod system_api; pub mod terminal_api; pub mod tool_api; +pub mod remote_connect_api; pub use app_state::{AppState, AppStatistics, HealthStatus}; diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs new file mode 100644 index 00000000..ab0dddd9 --- /dev/null +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -0,0 +1,248 @@ +//! Tauri commands for Remote Connect. + +use bitfun_core::service::remote_connect::{ + bot::BotConfig, ConnectionMethod, ConnectionResult, PairingState, RemoteConnectConfig, + RemoteConnectService, +}; +use once_cell::sync::OnceCell; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +static REMOTE_CONNECT_SERVICE: OnceCell>>> = + OnceCell::new(); + +fn get_service_holder() -> &'static Arc>> { + REMOTE_CONNECT_SERVICE.get_or_init(|| Arc::new(RwLock::new(None))) +} + +async fn ensure_service() -> Result<(), String> { + let holder = get_service_holder(); + let guard = holder.read().await; + if guard.is_some() { + return Ok(()); + } + drop(guard); + + let config = RemoteConnectConfig::default(); + let service = + RemoteConnectService::new(config).map_err(|e| format!("init remote connect: {e}"))?; + *holder.write().await = Some(service); + Ok(()) +} + +// ── Request / Response DTOs ──────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct StartRemoteConnectRequest { + pub method: String, + pub custom_server_url: Option, +} + +#[derive(Debug, Serialize)] +pub struct RemoteConnectStatusResponse { + pub is_connected: bool, + pub pairing_state: PairingState, + pub active_method: Option, + pub peer_device_name: Option, +} + +#[derive(Debug, Serialize)] +pub struct ConnectionMethodInfo { + pub id: String, + pub name: String, + pub available: bool, + pub description: String, +} + +#[derive(Debug, Serialize)] +pub struct DeviceInfo { + pub device_id: String, + pub device_name: String, + pub mac_address: String, +} + +// ── Tauri Commands ───────────────────────────────────────────────── + +#[tauri::command] +pub async fn remote_connect_get_device_info() -> Result { + ensure_service().await?; + let holder = get_service_holder(); + let guard = holder.read().await; + let service = guard.as_ref().ok_or("service not initialized")?; + let id = service.device_identity(); + Ok(DeviceInfo { + device_id: id.device_id.clone(), + device_name: id.device_name.clone(), + mac_address: id.mac_address.clone(), + }) +} + +#[tauri::command] +pub async fn remote_connect_get_methods() -> Result, String> { + ensure_service().await?; + let holder = get_service_holder(); + let guard = holder.read().await; + let service = guard.as_ref().ok_or("service not initialized")?; + let methods = service.available_methods().await; + + let infos = methods + .into_iter() + .map(|m| match m { + ConnectionMethod::Lan => ConnectionMethodInfo { + id: "lan".into(), + name: "LAN".into(), + available: true, + description: "Same local network".into(), + }, + ConnectionMethod::Ngrok => ConnectionMethodInfo { + id: "ngrok".into(), + name: "ngrok".into(), + available: true, + description: "Internet via ngrok tunnel".into(), + }, + ConnectionMethod::BitfunServer => ConnectionMethodInfo { + id: "bitfun_server".into(), + name: "BitFun Server".into(), + available: true, + description: "Official BitFun relay".into(), + }, + ConnectionMethod::CustomServer { url } => ConnectionMethodInfo { + id: "custom_server".into(), + name: "Custom Server".into(), + available: true, + description: format!("Self-hosted: {url}"), + }, + ConnectionMethod::BotFeishu => ConnectionMethodInfo { + id: "bot_feishu".into(), + name: "Feishu Bot".into(), + available: true, + description: "Via Feishu messenger".into(), + }, + ConnectionMethod::BotTelegram => ConnectionMethodInfo { + id: "bot_telegram".into(), + name: "Telegram Bot".into(), + available: true, + description: "Via Telegram".into(), + }, + }) + .collect(); + + Ok(infos) +} + +fn parse_connection_method( + method: &str, + custom_url: Option, +) -> Result { + match method { + "lan" => Ok(ConnectionMethod::Lan), + "ngrok" => Ok(ConnectionMethod::Ngrok), + "bitfun_server" => Ok(ConnectionMethod::BitfunServer), + "custom_server" => Ok(ConnectionMethod::CustomServer { + url: custom_url.unwrap_or_default(), + }), + "bot_feishu" => Ok(ConnectionMethod::BotFeishu), + "bot_telegram" => Ok(ConnectionMethod::BotTelegram), + _ => Err(format!("unknown connection method: {method}")), + } +} + +#[tauri::command] +pub async fn remote_connect_start( + request: StartRemoteConnectRequest, +) -> Result { + ensure_service().await?; + let method = parse_connection_method(&request.method, request.custom_server_url)?; + + let holder = get_service_holder(); + let guard = holder.read().await; + let service = guard.as_ref().ok_or("service not initialized")?; + service + .start(method) + .await + .map_err(|e| format!("start remote connect: {e}")) +} + +#[tauri::command] +pub async fn remote_connect_stop() -> Result<(), String> { + let holder = get_service_holder(); + let guard = holder.read().await; + if let Some(service) = guard.as_ref() { + service.stop().await; + } + Ok(()) +} + +#[tauri::command] +pub async fn remote_connect_status() -> Result { + ensure_service().await?; + let holder = get_service_holder(); + let guard = holder.read().await; + let service = guard.as_ref().ok_or("service not initialized")?; + + let state = service.pairing_state().await; + let method = service.active_method().await; + let peer = service.peer_device_name().await; + + Ok(RemoteConnectStatusResponse { + is_connected: state == PairingState::Connected, + pairing_state: state, + active_method: method.map(|m| format!("{m:?}")), + peer_device_name: peer, + }) +} + +#[tauri::command] +pub async fn remote_connect_configure_custom_server(url: String) -> Result<(), String> { + let holder = get_service_holder(); + let mut guard = holder.write().await; + if guard.is_none() { + let mut config = RemoteConnectConfig::default(); + config.custom_server_url = Some(url); + let service = + RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; + *guard = Some(service); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +pub struct ConfigureBotRequest { + pub bot_type: String, + pub app_id: Option, + pub app_secret: Option, + pub bot_token: Option, +} + +#[tauri::command] +pub async fn remote_connect_configure_bot( + request: ConfigureBotRequest, +) -> Result<(), String> { + let holder = get_service_holder(); + let mut guard = holder.write().await; + + let bot_config = match request.bot_type.as_str() { + "feishu" => BotConfig::Feishu { + app_id: request.app_id.unwrap_or_default(), + app_secret: request.app_secret.unwrap_or_default(), + }, + "telegram" => BotConfig::Telegram { + bot_token: request.bot_token.unwrap_or_default(), + }, + _ => return Err(format!("unknown bot type: {}", request.bot_type)), + }; + + if guard.is_none() { + let mut config = RemoteConnectConfig::default(); + match &bot_config { + BotConfig::Feishu { .. } => config.bot_feishu = Some(bot_config), + BotConfig::Telegram { .. } => config.bot_telegram = Some(bot_config), + } + let service = + RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; + *guard = Some(service); + } + + Ok(()) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6c714a07..8d071619 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -514,6 +514,14 @@ pub async fn run() { i18n_get_supported_languages, i18n_get_config, i18n_set_config, + // Remote Connect + api::remote_connect_api::remote_connect_get_device_info, + api::remote_connect_api::remote_connect_get_methods, + api::remote_connect_api::remote_connect_start, + api::remote_connect_api::remote_connect_stop, + api::remote_connect_api::remote_connect_status, + api::remote_connect_api::remote_connect_configure_custom_server, + api::remote_connect_api::remote_connect_configure_bot, ]) .run(tauri::generate_context!()); if let Err(e) = run_result { diff --git a/src/apps/relay-server/Caddyfile b/src/apps/relay-server/Caddyfile new file mode 100644 index 00000000..39b1fd44 --- /dev/null +++ b/src/apps/relay-server/Caddyfile @@ -0,0 +1,17 @@ +# Caddyfile for BitFun Relay Server +# Replace YOUR_DOMAIN with your actual domain, or use :443 with self-signed cert. +# +# Option A: With a domain (Let's Encrypt auto-HTTPS) +# relay.yourdomain.com { +# reverse_proxy relay-server:9700 +# } +# +# Option B: IP-only with self-signed cert +:443 { + tls internal + reverse_proxy relay-server:9700 +} + +:80 { + reverse_proxy relay-server:9700 +} diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml new file mode 100644 index 00000000..20ff6247 --- /dev/null +++ b/src/apps/relay-server/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "bitfun-relay-server" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "BitFun Relay Server - WebSocket relay for Remote Connect" + +[[bin]] +name = "bitfun-relay-server" +path = "src/main.rs" + +[dependencies] +# Web framework +axum = { workspace = true } +tower-http = { workspace = true, features = ["cors", "fs"] } + +# Async runtime +tokio = { workspace = true, features = ["full"] } +futures-util = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } + +# Error handling +anyhow = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# Utilities +uuid = { workspace = true } +chrono = { workspace = true } +dashmap = { workspace = true } +rand = { workspace = true } +base64 = { workspace = true } diff --git a/src/apps/relay-server/Dockerfile b/src/apps/relay-server/Dockerfile new file mode 100644 index 00000000..c91ac4cc --- /dev/null +++ b/src/apps/relay-server/Dockerfile @@ -0,0 +1,54 @@ +# Multi-stage build for BitFun Relay Server +FROM rust:1.82-slim AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY src/crates/events/Cargo.toml src/crates/events/Cargo.toml +COPY src/crates/core/Cargo.toml src/crates/core/Cargo.toml +COPY src/crates/transport/Cargo.toml src/crates/transport/Cargo.toml +COPY src/crates/api-layer/Cargo.toml src/crates/api-layer/Cargo.toml +COPY src/apps/cli/Cargo.toml src/apps/cli/Cargo.toml +COPY src/apps/desktop/Cargo.toml src/apps/desktop/Cargo.toml +COPY src/apps/server/Cargo.toml src/apps/server/Cargo.toml +COPY src/apps/relay-server/Cargo.toml src/apps/relay-server/Cargo.toml + +# Create dummy source files for dependency caching +RUN mkdir -p src/crates/events/src && echo "pub fn dummy() {}" > src/crates/events/src/lib.rs && \ + mkdir -p src/crates/core/src && echo "pub fn dummy() {}" > src/crates/core/src/lib.rs && \ + mkdir -p src/crates/transport/src && echo "pub fn dummy() {}" > src/crates/transport/src/lib.rs && \ + mkdir -p src/crates/api-layer/src && echo "pub fn dummy() {}" > src/crates/api-layer/src/lib.rs && \ + mkdir -p src/apps/cli/src && echo "fn main() {}" > src/apps/cli/src/main.rs && \ + mkdir -p src/apps/desktop/src && echo "fn main() {}" > src/apps/desktop/src/main.rs && \ + mkdir -p src/apps/server/src && echo "fn main() {}" > src/apps/server/src/main.rs && \ + mkdir -p src/apps/relay-server/src && echo "fn main() {}" > src/apps/relay-server/src/main.rs + +# Build dependencies only (cached layer) +RUN cargo build --release -p bitfun-relay-server 2>/dev/null || true + +# Copy actual source code +COPY src/apps/relay-server/src src/apps/relay-server/src + +# Build the relay server +RUN cargo build --release -p bitfun-relay-server + +# ── Runtime stage ───────────────────────────────────────── +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=builder /build/target/release/bitfun-relay-server /app/bitfun-relay-server + +# Optional: copy mobile-web static files +# COPY src/mobile-web/dist /app/static + +ENV RELAY_PORT=9700 +ENV RELAY_STATIC_DIR=/app/static +EXPOSE 9700 + +CMD ["/app/bitfun-relay-server"] diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md new file mode 100644 index 00000000..612bbeea --- /dev/null +++ b/src/apps/relay-server/README.md @@ -0,0 +1,103 @@ +# BitFun Relay Server + +WebSocket relay server for BitFun Remote Connect. Provides room-based message relaying between desktop and mobile clients with E2E encryption support. + +## Features + +- Room-based WebSocket relay +- End-to-end encrypted message passthrough (server cannot decrypt) +- Heartbeat-based connection management +- Static file serving for mobile web client +- Docker deployment ready + +## Quick Start + +### Docker (Recommended) + +```bash +# One-click deploy +bash deploy.sh + +# With mobile web client +bash deploy.sh --build-mobile +``` + +### Manual + +```bash +# From project root +cargo build --release -p bitfun-relay-server + +# Run +RELAY_PORT=9700 ./target/release/bitfun-relay-server +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `RELAY_PORT` | `9700` | Server listen port | +| `RELAY_STATIC_DIR` | `./static` | Path to mobile web static files | +| `RELAY_ROOM_TTL` | `3600` | Room TTL in seconds (0 = no expiry) | + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check | +| `/api/info` | GET | Server info | +| `/ws` | WebSocket | Main relay endpoint | + +## WebSocket Protocol + +### Client → Server + +```json +// Create a room (desktop) +{ "type": "create_room", "room_id": "...", "device_id": "...", "device_type": "desktop", "public_key": "base64..." } + +// Join a room (mobile) +{ "type": "join_room", "room_id": "...", "device_id": "...", "device_type": "mobile", "public_key": "base64..." } + +// Relay an encrypted message +{ "type": "relay", "room_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } + +// Heartbeat +{ "type": "heartbeat" } +``` + +### Server → Client + +```json +// Peer joined notification +{ "type": "peer_joined", "device_id": "...", "device_type": "...", "public_key": "base64..." } + +// Relayed message +{ "type": "relay", "from_device_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } + +// Peer disconnected +{ "type": "peer_disconnected", "device_id": "..." } + +// Heartbeat acknowledgment +{ "type": "heartbeat_ack" } +``` + +## Self-Hosted Deployment + +1. Clone the repository +2. Navigate to `src/apps/relay-server/` +3. Run `bash deploy.sh --build-mobile` +4. Configure DNS/firewall as needed +5. In BitFun desktop, select "Custom Server" and enter your server URL + +## Architecture + +``` +Mobile Phone ──WSS──► Relay Server ◄──WSS── Desktop Client + │ + E2E Encrypted + (server cannot + read messages) +``` + +The relay server only manages rooms and forwards opaque encrypted payloads. All encryption/decryption happens on the client side. diff --git a/src/apps/relay-server/deploy.sh b/src/apps/relay-server/deploy.sh new file mode 100755 index 00000000..45bfbe13 --- /dev/null +++ b/src/apps/relay-server/deploy.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# BitFun Relay Server — one-click deploy script. +# Usage: bash deploy.sh [--build-mobile] +# +# Prerequisites: Docker, Docker Compose + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +BUILD_MOBILE=false +for arg in "$@"; do + case "$arg" in + --build-mobile) BUILD_MOBILE=true ;; + esac +done + +echo "=== BitFun Relay Server Deploy ===" + +# Build mobile web static files if requested +if [ "$BUILD_MOBILE" = true ] && [ -d "$PROJECT_ROOT/src/mobile-web" ]; then + echo "[1/3] Building mobile web client..." + cd "$PROJECT_ROOT/src/mobile-web" + npm ci + npm run build + mkdir -p "$SCRIPT_DIR/static" + cp -r dist/* "$SCRIPT_DIR/static/" + cd "$SCRIPT_DIR" + echo " Mobile web built → $SCRIPT_DIR/static/" +else + echo "[1/3] Skipping mobile web build (use --build-mobile to include)" +fi + +# Build and start containers +echo "[2/3] Building Docker images..." +cd "$SCRIPT_DIR" +docker compose build + +echo "[3/3] Starting services..." +docker compose up -d + +echo "" +echo "=== Deploy complete ===" +echo "Relay server running on port 9700" +echo "Caddy proxy on ports 80/443" +echo "" +echo "Check status: docker compose ps" +echo "View logs: docker compose logs -f relay-server" +echo "Stop: docker compose down" diff --git a/src/apps/relay-server/deploy/Cargo.toml b/src/apps/relay-server/deploy/Cargo.toml new file mode 100644 index 00000000..5341c8ee --- /dev/null +++ b/src/apps/relay-server/deploy/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "bitfun-relay-server" +version = "0.1.1" +authors = ["BitFun Team"] +edition = "2021" +description = "BitFun Relay Server - WebSocket relay for Remote Connect" + +[[bin]] +name = "bitfun-relay-server" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["json", "ws"] } +tower-http = { version = "0.6", features = ["cors", "fs"] } +tokio = { version = "1.0", features = ["full"] } +futures-util = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde", "clock"] } +dashmap = "5.5" +rand = "0.8" +base64 = "0.21" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/src/apps/relay-server/deploy/Dockerfile b/src/apps/relay-server/deploy/Dockerfile new file mode 100644 index 00000000..b8d743c7 --- /dev/null +++ b/src/apps/relay-server/deploy/Dockerfile @@ -0,0 +1,29 @@ +FROM rust:1.85-slim AS builder + +WORKDIR /build + +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml ./ +RUN mkdir -p src && echo 'fn main() { println!("placeholder"); }' > src/main.rs +RUN cargo build --release 2>/dev/null || true + +RUN rm -rf src target/release/bitfun-relay-server target/release/deps/bitfun* + +COPY src/ src/ + +RUN cargo build --release + +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=builder /build/target/release/bitfun-relay-server /app/bitfun-relay-server +RUN mkdir -p /app/static + +ENV RELAY_PORT=9700 +ENV RELAY_STATIC_DIR=/app/static +EXPOSE 9700 + +CMD ["/app/bitfun-relay-server"] diff --git a/src/apps/relay-server/deploy/docker-compose.yml b/src/apps/relay-server/deploy/docker-compose.yml new file mode 100644 index 00000000..1975774c --- /dev/null +++ b/src/apps/relay-server/deploy/docker-compose.yml @@ -0,0 +1,15 @@ +services: + relay-server: + build: + context: . + dockerfile: Dockerfile + container_name: bitfun-relay + restart: unless-stopped + ports: + - "9700:9700" + environment: + - RELAY_PORT=9700 + - RELAY_STATIC_DIR=/app/static + - RELAY_ROOM_TTL=3600 + volumes: + - ./static:/app/static:ro diff --git a/src/apps/relay-server/docker-compose.yml b/src/apps/relay-server/docker-compose.yml new file mode 100644 index 00000000..d8ed1f72 --- /dev/null +++ b/src/apps/relay-server/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + relay-server: + build: + context: ../../.. + dockerfile: src/apps/relay-server/Dockerfile + container_name: bitfun-relay + restart: unless-stopped + ports: + - "9700:9700" + environment: + - RELAY_PORT=9700 + - RELAY_STATIC_DIR=/app/static + - RELAY_ROOM_TTL=3600 + volumes: + - relay-data:/app/data + + # Caddy reverse proxy for automatic HTTPS + caddy: + image: caddy:2-alpine + container_name: bitfun-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy-data:/data + - caddy-config:/config + depends_on: + - relay-server + +volumes: + relay-data: + caddy-data: + caddy-config: diff --git a/src/apps/relay-server/src/config.rs b/src/apps/relay-server/src/config.rs new file mode 100644 index 00000000..2f4b2680 --- /dev/null +++ b/src/apps/relay-server/src/config.rs @@ -0,0 +1,47 @@ +//! Relay server configuration. + +use std::net::SocketAddr; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct RelayConfig { + pub listen_addr: SocketAddr, + pub room_ttl_secs: u64, + pub heartbeat_interval_secs: u64, + pub heartbeat_timeout_secs: u64, + pub static_dir: Option, + pub cors_allow_origins: Vec, +} + +impl Default for RelayConfig { + fn default() -> Self { + Self { + listen_addr: ([0, 0, 0, 0], 9700).into(), + room_ttl_secs: 3600, + heartbeat_interval_secs: 30, + heartbeat_timeout_secs: 90, + static_dir: None, + cors_allow_origins: vec!["*".to_string()], + } + } +} + +impl RelayConfig { + pub fn from_env() -> Self { + let mut cfg = Self::default(); + if let Ok(port) = std::env::var("RELAY_PORT") { + if let Ok(p) = port.parse::() { + cfg.listen_addr = ([0, 0, 0, 0], p).into(); + } + } + if let Ok(dir) = std::env::var("RELAY_STATIC_DIR") { + cfg.static_dir = Some(dir); + } + if let Ok(ttl) = std::env::var("RELAY_ROOM_TTL") { + if let Ok(t) = ttl.parse() { + cfg.room_ttl_secs = t; + } + } + cfg + } +} diff --git a/src/apps/relay-server/src/main.rs b/src/apps/relay-server/src/main.rs new file mode 100644 index 00000000..5e815bc8 --- /dev/null +++ b/src/apps/relay-server/src/main.rs @@ -0,0 +1,70 @@ +//! BitFun Relay Server +//! +//! WebSocket relay for Remote Connect. Manages rooms and forwards E2E encrypted +//! messages between desktop and mobile clients. Also serves mobile web static files. + +use axum::routing::{get, post}; +use axum::Router; +use tower_http::cors::CorsLayer; +use tracing::info; + +mod config; +mod relay; +mod routes; + +use config::RelayConfig; +use relay::RoomManager; +use routes::api::{self, AppState}; +use routes::websocket; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .init(); + + let cfg = RelayConfig::from_env(); + info!("BitFun Relay Server v{}", env!("CARGO_PKG_VERSION")); + + let room_manager = RoomManager::new(); + + let cleanup_rm = room_manager.clone(); + let cleanup_ttl = cfg.room_ttl_secs; + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + cleanup_rm.cleanup_stale_rooms(cleanup_ttl); + } + }); + + let state = AppState { + room_manager, + start_time: std::time::Instant::now(), + }; + + let mut app = Router::new() + .route("/health", get(api::health_check)) + .route("/api/info", get(api::server_info)) + .route("/api/rooms/{room_id}/poll", get(api::poll_messages)) + .route("/api/rooms/{room_id}/ack", post(api::ack_messages)) + .route("/ws", get(websocket::websocket_handler)) + .layer(CorsLayer::permissive()) + .with_state(state); + + // Serve mobile web static files if configured + if let Some(static_dir) = &cfg.static_dir { + info!("Serving static files from: {static_dir}"); + app = app.nest_service( + "/", + tower_http::services::ServeDir::new(static_dir) + .append_index_html_on_directories(true), + ); + } + + let listener = tokio::net::TcpListener::bind(cfg.listen_addr).await?; + info!("Relay server listening on {}", cfg.listen_addr); + info!("WebSocket endpoint: ws://{}/ws", cfg.listen_addr); + + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/src/apps/relay-server/src/relay/mod.rs b/src/apps/relay-server/src/relay/mod.rs new file mode 100644 index 00000000..c24d4265 --- /dev/null +++ b/src/apps/relay-server/src/relay/mod.rs @@ -0,0 +1,5 @@ +//! Core relay logic: room management and message routing. + +pub mod room; + +pub use room::RoomManager; diff --git a/src/apps/relay-server/src/relay/room.rs b/src/apps/relay-server/src/relay/room.rs new file mode 100644 index 00000000..1d2e860e --- /dev/null +++ b/src/apps/relay-server/src/relay/room.rs @@ -0,0 +1,456 @@ +//! Room management for the relay server. +//! +//! Each room holds at most 2 participants (desktop + mobile). +//! Messages are relayed without decryption (E2E encrypted between clients). +//! Desktop→mobile messages are buffered so that the mobile client can poll +//! for missed messages via the HTTP API. + +use chrono::Utc; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tracing::{debug, info, warn}; + +pub type ConnId = u64; + +#[derive(Debug, Clone)] +pub struct OutboundMessage { + pub text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessageDirection { + ToMobile, + ToDesktop, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BufferedMessage { + pub seq: u64, + pub timestamp: i64, + pub direction: MessageDirection, + pub encrypted_data: String, + pub nonce: String, +} + +#[derive(Debug)] +pub struct Participant { + pub conn_id: ConnId, + pub device_id: String, + pub device_type: String, + pub public_key: String, + pub tx: mpsc::UnboundedSender, + #[allow(dead_code)] + pub joined_at: i64, + pub last_heartbeat: i64, +} + +#[derive(Debug)] +pub struct RelayRoom { + pub room_id: String, + #[allow(dead_code)] + pub created_at: i64, + pub last_activity: i64, + pub participants: Vec, + pub message_store: Vec, + pub next_seq: u64, +} + +impl RelayRoom { + pub fn new(room_id: String) -> Self { + let now = Utc::now().timestamp(); + Self { + room_id, + created_at: now, + last_activity: now, + participants: Vec::with_capacity(2), + message_store: Vec::new(), + next_seq: 1, + } + } + + pub fn add_participant(&mut self, participant: Participant) -> bool { + if self.participants.len() >= 2 { + return false; + } + self.participants.push(participant); + self.touch(); + true + } + + pub fn remove_participant(&mut self, conn_id: ConnId) -> Option { + if let Some(idx) = self.participants.iter().position(|p| p.conn_id == conn_id) { + Some(self.participants.remove(idx)) + } else { + None + } + } + + pub fn relay_to_peer(&self, sender_conn_id: ConnId, message: &str) -> bool { + for p in &self.participants { + if p.conn_id != sender_conn_id { + let _ = p.tx.send(OutboundMessage { + text: message.to_string(), + }); + return true; + } + } + false + } + + #[allow(dead_code)] + pub fn send_to(&self, conn_id: ConnId, message: &str) { + for p in &self.participants { + if p.conn_id == conn_id { + let _ = p.tx.send(OutboundMessage { + text: message.to_string(), + }); + return; + } + } + } + + pub fn broadcast(&self, message: &str) { + for p in &self.participants { + let _ = p.tx.send(OutboundMessage { + text: message.to_string(), + }); + } + } + + pub fn is_empty(&self) -> bool { + self.participants.is_empty() + } + + #[allow(dead_code)] + pub fn participant_count(&self) -> usize { + self.participants.len() + } + + pub fn update_heartbeat(&mut self, conn_id: ConnId) { + let now = Utc::now().timestamp(); + for p in &mut self.participants { + if p.conn_id == conn_id { + p.last_heartbeat = now; + break; + } + } + self.last_activity = now; + } + + fn touch(&mut self) { + self.last_activity = Utc::now().timestamp(); + } + + /// Buffer an encrypted message for later polling by the target device. + pub fn buffer_message( + &mut self, + direction: MessageDirection, + encrypted_data: String, + nonce: String, + ) -> u64 { + let seq = self.next_seq; + self.next_seq += 1; + self.message_store.push(BufferedMessage { + seq, + timestamp: Utc::now().timestamp(), + direction, + encrypted_data, + nonce, + }); + self.touch(); + seq + } + + /// Return buffered messages for a given direction with seq > since_seq. + pub fn poll_messages( + &self, + direction: MessageDirection, + since_seq: u64, + ) -> Vec { + self.message_store + .iter() + .filter(|m| m.direction == direction && m.seq > since_seq) + .cloned() + .collect() + } + + /// Remove buffered messages with seq <= ack_seq for a given direction. + pub fn ack_messages(&mut self, direction: MessageDirection, ack_seq: u64) { + self.message_store + .retain(|m| !(m.direction == direction && m.seq <= ack_seq)); + } + + /// Get the device_type of the sender identified by conn_id. + pub fn sender_device_type(&self, conn_id: ConnId) -> Option<&str> { + self.participants + .iter() + .find(|p| p.conn_id == conn_id) + .map(|p| p.device_type.as_str()) + } +} + +pub struct RoomManager { + rooms: DashMap, + conn_to_room: DashMap, + next_conn_id: std::sync::atomic::AtomicU64, +} + +impl RoomManager { + pub fn new() -> Arc { + Arc::new(Self { + rooms: DashMap::new(), + conn_to_room: DashMap::new(), + next_conn_id: std::sync::atomic::AtomicU64::new(1), + }) + } + + pub fn next_conn_id(&self) -> ConnId { + self.next_conn_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } + + /// If conn_id is already in a room, remove it from that room first. + fn leave_current_room(&self, conn_id: ConnId) { + if let Some((_, old_room_id)) = self.conn_to_room.remove(&conn_id) { + let mut should_remove = false; + if let Some(mut room) = self.rooms.get_mut(&old_room_id) { + room.remove_participant(conn_id); + should_remove = room.is_empty(); + } + if should_remove { + self.rooms.remove(&old_room_id); + debug!("Cleaned up old room {old_room_id} after conn moved"); + } + } + } + + pub fn create_room( + &self, + room_id: &str, + conn_id: ConnId, + device_id: &str, + device_type: &str, + public_key: &str, + tx: mpsc::UnboundedSender, + ) -> bool { + if self.rooms.contains_key(room_id) { + warn!("Room {room_id} already exists"); + return false; + } + + self.leave_current_room(conn_id); + + let now = Utc::now().timestamp(); + let mut room = RelayRoom::new(room_id.to_string()); + room.add_participant(Participant { + conn_id, + device_id: device_id.to_string(), + device_type: device_type.to_string(), + public_key: public_key.to_string(), + tx, + joined_at: now, + last_heartbeat: now, + }); + + self.rooms.insert(room_id.to_string(), room); + self.conn_to_room.insert(conn_id, room_id.to_string()); + + info!("Room {room_id} created by {device_id} ({device_type})"); + true + } + + pub fn join_room( + &self, + room_id: &str, + conn_id: ConnId, + device_id: &str, + device_type: &str, + public_key: &str, + tx: mpsc::UnboundedSender, + ) -> bool { + self.leave_current_room(conn_id); + + let mut room_ref = match self.rooms.get_mut(room_id) { + Some(r) => r, + None => { + warn!("Room {room_id} not found"); + return false; + } + }; + + let now = Utc::now().timestamp(); + let ok = room_ref.add_participant(Participant { + conn_id, + device_id: device_id.to_string(), + device_type: device_type.to_string(), + public_key: public_key.to_string(), + tx, + joined_at: now, + last_heartbeat: now, + }); + + if ok { + drop(room_ref); + self.conn_to_room.insert(conn_id, room_id.to_string()); + info!("Device {device_id} ({device_type}) joined room {room_id}"); + } else { + warn!("Room {room_id} is full"); + } + + ok + } + + /// Relay a message to the peer. If the sender is desktop, also buffer for mobile polling. + pub fn relay_message(&self, conn_id: ConnId, encrypted_data: &str, nonce: &str) -> bool { + if let Some(room_id) = self.conn_to_room.get(&conn_id) { + if let Some(mut room) = self.rooms.get_mut(room_id.value()) { + let sender_type = room + .sender_device_type(conn_id) + .unwrap_or("unknown") + .to_string(); + + let direction = if sender_type == "desktop" { + MessageDirection::ToMobile + } else { + MessageDirection::ToDesktop + }; + room.buffer_message( + direction, + encrypted_data.to_string(), + nonce.to_string(), + ); + + let relay_json = serde_json::json!({ + "type": "relay", + "room_id": room_id.value(), + "encrypted_data": encrypted_data, + "nonce": nonce, + }) + .to_string(); + + return room.relay_to_peer(conn_id, &relay_json); + } + } + false + } + + pub fn on_disconnect(&self, conn_id: ConnId) { + if let Some((_, room_id)) = self.conn_to_room.remove(&conn_id) { + let mut should_remove = false; + + if let Some(mut room) = self.rooms.get_mut(&room_id) { + if let Some(removed) = room.remove_participant(conn_id) { + info!( + "Device {} disconnected from room {}", + removed.device_id, room_id + ); + + let notification = serde_json::json!({ + "type": "peer_disconnected", + "device_id": removed.device_id, + }) + .to_string(); + room.broadcast(¬ification); + } + should_remove = room.is_empty(); + } + + if should_remove { + self.rooms.remove(&room_id); + debug!("Empty room {room_id} removed"); + } + } + } + + pub fn heartbeat(&self, conn_id: ConnId) { + if let Some(room_id) = self.conn_to_room.get(&conn_id) { + if let Some(mut room) = self.rooms.get_mut(room_id.value()) { + room.update_heartbeat(conn_id); + } + } + } + + /// Returns (device_id, device_type, public_key) of the peer. + pub fn get_peer_info( + &self, + room_id: &str, + conn_id: ConnId, + ) -> Option<(String, String, String)> { + if let Some(room) = self.rooms.get(room_id) { + for p in &room.participants { + if p.conn_id != conn_id { + return Some(( + p.device_id.clone(), + p.device_type.clone(), + p.public_key.clone(), + )); + } + } + } + None + } + + /// Clean up stale rooms based on last_activity rather than created_at. + pub fn cleanup_stale_rooms(&self, ttl_secs: u64) { + let now = Utc::now().timestamp(); + let stale_ids: Vec = self + .rooms + .iter() + .filter(|r| (now - r.last_activity) as u64 > ttl_secs) + .map(|r| r.room_id.clone()) + .collect(); + + for room_id in &stale_ids { + if let Some((_, room)) = self.rooms.remove(room_id) { + for p in &room.participants { + self.conn_to_room.remove(&p.conn_id); + } + info!("Stale room {room_id} cleaned up"); + } + } + } + + pub fn send_to_others_in_room(&self, room_id: &str, exclude_conn_id: ConnId, message: &str) { + if let Some(room) = self.rooms.get(room_id) { + for p in &room.participants { + if p.conn_id != exclude_conn_id { + let _ = p.tx.send(OutboundMessage { + text: message.to_string(), + }); + } + } + } + } + + /// Poll buffered messages for a specific room and direction. + pub fn poll_messages( + &self, + room_id: &str, + direction: MessageDirection, + since_seq: u64, + ) -> Vec { + if let Some(room) = self.rooms.get(room_id) { + room.poll_messages(direction, since_seq) + } else { + Vec::new() + } + } + + /// Acknowledge receipt of messages up to ack_seq. + pub fn ack_messages(&self, room_id: &str, direction: MessageDirection, ack_seq: u64) { + if let Some(mut room) = self.rooms.get_mut(room_id) { + room.ack_messages(direction, ack_seq); + } + } + + pub fn room_count(&self) -> usize { + self.rooms.len() + } + + pub fn connection_count(&self) -> usize { + self.conn_to_room.len() + } +} diff --git a/src/apps/relay-server/src/routes/api.rs b/src/apps/relay-server/src/routes/api.rs new file mode 100644 index 00000000..f77e6f1a --- /dev/null +++ b/src/apps/relay-server/src/routes/api.rs @@ -0,0 +1,100 @@ +//! REST API routes for the relay server. + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +use crate::relay::room::{BufferedMessage, MessageDirection}; +use crate::relay::RoomManager; + +#[derive(Clone)] +pub struct AppState { + pub room_manager: Arc, + pub start_time: std::time::Instant, +} + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: String, + pub version: String, + pub uptime_seconds: u64, + pub rooms: usize, + pub connections: usize, +} + +pub async fn health_check(State(state): State) -> Json { + Json(HealthResponse { + status: "healthy".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_seconds: state.start_time.elapsed().as_secs(), + rooms: state.room_manager.room_count(), + connections: state.room_manager.connection_count(), + }) +} + +#[derive(Serialize)] +pub struct ServerInfo { + pub name: String, + pub version: String, + pub protocol_version: u8, +} + +pub async fn server_info() -> Json { + Json(ServerInfo { + name: "BitFun Relay Server".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + protocol_version: 1, + }) +} + +// ── Polling API ─────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct PollQuery { + pub since_seq: Option, + pub device_type: Option, +} + +#[derive(Serialize)] +pub struct PollResponse { + pub messages: Vec, +} + +/// `GET /api/rooms/:room_id/poll?since_seq=0&device_type=mobile` +pub async fn poll_messages( + State(state): State, + Path(room_id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let since = query.since_seq.unwrap_or(0); + let direction = match query.device_type.as_deref() { + Some("desktop") => MessageDirection::ToDesktop, + _ => MessageDirection::ToMobile, + }; + let messages = state.room_manager.poll_messages(&room_id, direction, since); + Ok(Json(PollResponse { messages })) +} + +#[derive(Deserialize)] +pub struct AckRequest { + pub ack_seq: u64, + pub device_type: Option, +} + +/// `POST /api/rooms/:room_id/ack` +pub async fn ack_messages( + State(state): State, + Path(room_id): Path, + Json(body): Json, +) -> StatusCode { + let direction = match body.device_type.as_deref() { + Some("desktop") => MessageDirection::ToDesktop, + _ => MessageDirection::ToMobile, + }; + state + .room_manager + .ack_messages(&room_id, direction, body.ack_seq); + StatusCode::OK +} diff --git a/src/apps/relay-server/src/routes/mod.rs b/src/apps/relay-server/src/routes/mod.rs new file mode 100644 index 00000000..ae5fb74d --- /dev/null +++ b/src/apps/relay-server/src/routes/mod.rs @@ -0,0 +1,4 @@ +//! HTTP and WebSocket routes for the relay server. + +pub mod api; +pub mod websocket; diff --git a/src/apps/relay-server/src/routes/websocket.rs b/src/apps/relay-server/src/routes/websocket.rs new file mode 100644 index 00000000..f638ba2a --- /dev/null +++ b/src/apps/relay-server/src/routes/websocket.rs @@ -0,0 +1,213 @@ +//! WebSocket handler for the relay server. +//! +//! Each connected client sends/receives JSON messages following the relay protocol. +//! The server never decrypts application data — it only handles room management +//! and forwards encrypted payloads between paired devices. +//! Desktop→mobile messages are also buffered for later polling. + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::Response, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +use crate::relay::room::{ConnId, OutboundMessage, RoomManager}; +use crate::routes::api::AppState; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(dead_code)] +pub enum InboundMessage { + CreateRoom { + room_id: Option, + device_id: String, + device_type: String, + public_key: String, + }, + JoinRoom { + room_id: String, + device_id: String, + device_type: String, + public_key: String, + }, + Relay { + room_id: String, + encrypted_data: String, + nonce: String, + }, + Heartbeat, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(dead_code)] +pub enum OutboundProtocol { + RoomCreated { room_id: String }, + PeerJoined { device_id: String, device_type: String, public_key: String }, + Relay { room_id: String, encrypted_data: String, nonce: String }, + HeartbeatAck, + PeerDisconnected { device_id: String }, + Error { message: String }, +} + +pub async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> Response { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: AppState) { + let (mut ws_sender, mut ws_receiver) = socket.split(); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + let conn_id = state.room_manager.next_conn_id(); + info!("WebSocket connected: conn_id={conn_id}"); + + let write_task = tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + if !msg.text.is_empty() { + if ws_sender.send(Message::Text(msg.text)).await.is_err() { + break; + } + } + } + }); + + while let Some(msg_result) = ws_receiver.next().await { + match msg_result { + Ok(Message::Text(text)) => { + handle_text_message(&text, conn_id, &state.room_manager, &out_tx); + } + Ok(Message::Ping(_)) => { + // Axum auto-replies Pong for Ping frames + } + Ok(Message::Close(_)) => { + info!("WebSocket close from conn_id={conn_id}"); + break; + } + Err(e) => { + error!("WebSocket error conn_id={conn_id}: {e}"); + break; + } + _ => {} + } + } + + state.room_manager.on_disconnect(conn_id); + drop(out_tx); + let _ = write_task.await; + info!("WebSocket disconnected: conn_id={conn_id}"); +} + +fn handle_text_message( + text: &str, + conn_id: ConnId, + room_manager: &Arc, + out_tx: &mpsc::UnboundedSender, +) { + debug!("Received from conn_id={conn_id}: {}", &text[..text.len().min(200)]); + let msg: InboundMessage = match serde_json::from_str(text) { + Ok(m) => m, + Err(e) => { + warn!("Invalid message from conn_id={conn_id}: {e}"); + send_json(out_tx, &OutboundProtocol::Error { + message: format!("invalid message format: {e}"), + }); + return; + } + }; + + match msg { + InboundMessage::CreateRoom { + room_id, + device_id, + device_type, + public_key, + } => { + let room_id = room_id.unwrap_or_else(generate_room_id); + let ok = room_manager.create_room( + &room_id, conn_id, &device_id, &device_type, &public_key, out_tx.clone(), + ); + if ok { + send_json(out_tx, &OutboundProtocol::RoomCreated { room_id }); + } else { + send_json(out_tx, &OutboundProtocol::Error { + message: "failed to create room".into(), + }); + } + } + + InboundMessage::JoinRoom { + room_id, + device_id, + device_type, + public_key, + } => { + let existing_peer = room_manager.get_peer_info(&room_id, conn_id); + + let ok = room_manager.join_room( + &room_id, conn_id, &device_id, &device_type, &public_key, out_tx.clone(), + ); + + if ok { + let joiner_notification = serde_json::to_string(&OutboundProtocol::PeerJoined { + device_id: device_id.clone(), + device_type: device_type.clone(), + public_key: public_key.clone(), + }).unwrap_or_default(); + room_manager.send_to_others_in_room(&room_id, conn_id, &joiner_notification); + + if let Some((peer_did, peer_dt, peer_pk)) = existing_peer { + send_json(out_tx, &OutboundProtocol::PeerJoined { + device_id: peer_did, + device_type: peer_dt, + public_key: peer_pk, + }); + } else { + warn!("No existing peer found for room {room_id} to send back to joiner"); + } + } else { + send_json(out_tx, &OutboundProtocol::Error { + message: format!("failed to join room {room_id}"), + }); + } + } + + InboundMessage::Relay { + room_id: _, + encrypted_data, + nonce, + } => { + debug!("Relay message from conn_id={conn_id} data_len={}", encrypted_data.len()); + if room_manager.relay_message(conn_id, &encrypted_data, &nonce) { + debug!("Relay message forwarded from conn_id={conn_id}"); + } else { + warn!("Relay failed for conn_id={conn_id}: no peer found"); + } + } + + InboundMessage::Heartbeat => { + room_manager.heartbeat(conn_id); + send_json(out_tx, &OutboundProtocol::HeartbeatAck); + } + } +} + +fn send_json(tx: &mpsc::UnboundedSender, msg: &T) { + if let Ok(json) = serde_json::to_string(msg) { + let _ = tx.send(OutboundMessage { text: json }); + } +} + +fn generate_room_id() -> String { + let bytes: [u8; 6] = rand::random(); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index 5ff8230b..86e44465 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -60,6 +60,7 @@ portable-pty = { workspace = true } # Command detection (cross-platform) which = { workspace = true } similar = { workspace = true } +urlencoding = { workspace = true } grep-searcher = { workspace = true } grep-regex = { workspace = true } @@ -91,6 +92,24 @@ terminal-core = { path = "src/service/terminal" } fluent-bundle = { workspace = true } unic-langid = { workspace = true } +# Encryption (Remote Connect E2E) +x25519-dalek = { workspace = true } +aes-gcm = { workspace = true } +sha2 = { workspace = true } +rand = { workspace = true } + +# Device/Network info (Remote Connect) +mac_address = { workspace = true } +local-ip-address = { workspace = true } +hostname = { workspace = true } + +# QR code generation +qrcode = { workspace = true } +image = { workspace = true } + +# WebSocket client +tokio-tungstenite = { workspace = true } + # Event layer dependency (lowest layer) bitfun-events = { path = "../events" } diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index a8604dde..27e23f4f 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -82,9 +82,17 @@ impl ConversationCoordinator { agent_type: String, config: SessionConfig, ) -> BitFunResult { - self.session_manager + let session = self + .session_manager .create_session(session_name, agent_type, config) - .await + .await?; + self.emit_event(AgenticEvent::SessionCreated { + session_id: session.session_id.clone(), + session_name: session.session_name.clone(), + agent_type: session.agent_type.clone(), + }) + .await; + Ok(session) } /// Create a new session with optional session ID @@ -95,9 +103,17 @@ impl ConversationCoordinator { agent_type: String, config: SessionConfig, ) -> BitFunResult { - self.session_manager + let session = self + .session_manager .create_session_with_id(session_id, session_name, agent_type, config) - .await + .await?; + self.emit_event(AgenticEvent::SessionCreated { + session_id: session.session_id.clone(), + session_name: session.session_name.clone(), + agent_type: session.agent_type.clone(), + }) + .await; + Ok(session) } async fn wrap_user_input(&self, agent_type: &str, user_input: String) -> BitFunResult { diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index c6521d00..f7807208 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -16,6 +16,7 @@ pub mod project_context; // Project context management pub mod runtime; // Managed runtime and capability management pub mod snapshot; // Snapshot-based change tracking pub mod system; // System command detection and execution +pub mod remote_connect; // Remote Connect (phone → desktop) pub mod workspace; // Workspace management // Diff calculation and merge service // Terminal is a standalone crate; re-export it here. diff --git a/src/crates/core/src/service/remote_connect/bot/feishu.rs b/src/crates/core/src/service/remote_connect/bot/feishu.rs new file mode 100644 index 00000000..3f0e5272 --- /dev/null +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -0,0 +1,159 @@ +//! Feishu (Lark) bot integration for Remote Connect. +//! +//! Users create their own Feishu bot on the Feishu Open Platform and provide +//! App ID + App Secret. Desktop listens for messages via the event subscription API. + +use anyhow::{anyhow, Result}; +use log::{debug, info}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeishuConfig { + pub app_id: String, + pub app_secret: String, +} + +#[derive(Debug, Clone)] +struct FeishuToken { + access_token: String, + expires_at: i64, +} + +pub struct FeishuBot { + config: FeishuConfig, + token: Arc>>, + pending_pairings: Arc>>, +} + +#[derive(Debug, Clone)] +struct PendingPairing { + created_at: i64, +} + +impl FeishuBot { + pub fn new(config: FeishuConfig) -> Self { + Self { + config, + token: Arc::new(RwLock::new(None)), + pending_pairings: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Get or refresh the tenant access token. + async fn get_access_token(&self) -> Result { + { + let guard = self.token.read().await; + if let Some(t) = guard.as_ref() { + if t.expires_at > chrono::Utc::now().timestamp() + 60 { + return Ok(t.access_token.clone()); + } + } + } + + let client = reqwest::Client::new(); + let resp = client + .post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal") + .json(&serde_json::json!({ + "app_id": self.config.app_id, + "app_secret": self.config.app_secret, + })) + .send() + .await + .map_err(|e| anyhow!("feishu token request: {e}"))?; + + let body: serde_json::Value = resp.json().await?; + let access_token = body["tenant_access_token"] + .as_str() + .ok_or_else(|| anyhow!("missing tenant_access_token in response"))? + .to_string(); + let expire = body["expire"].as_i64().unwrap_or(7200); + + *self.token.write().await = Some(FeishuToken { + access_token: access_token.clone(), + expires_at: chrono::Utc::now().timestamp() + expire, + }); + + info!("Feishu access token refreshed"); + Ok(access_token) + } + + /// Send a text message to a Feishu chat. + pub async fn send_message(&self, chat_id: &str, content: &str) -> Result<()> { + let token = self.get_access_token().await?; + let client = reqwest::Client::new(); + let resp = client + .post("https://open.feishu.cn/open-apis/im/v1/messages") + .query(&[("receive_id_type", "chat_id")]) + .bearer_auth(&token) + .json(&serde_json::json!({ + "receive_id": chat_id, + "msg_type": "text", + "content": serde_json::to_string(&serde_json::json!({"text": content}))?, + })) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("feishu send_message failed: {body}")); + } + + debug!("Feishu message sent to {chat_id}"); + Ok(()) + } + + /// Register a pairing code and wait for the user to send it via bot. + pub async fn register_pairing(&self, pairing_code: &str) -> Result<()> { + self.pending_pairings.write().await.insert( + pairing_code.to_string(), + PendingPairing { + created_at: chrono::Utc::now().timestamp(), + }, + ); + Ok(()) + } + + /// Verify a pairing code received from a Feishu message. + pub async fn verify_pairing_code(&self, code: &str) -> bool { + let mut pairings = self.pending_pairings.write().await; + if let Some(p) = pairings.remove(code) { + let age = chrono::Utc::now().timestamp() - p.created_at; + return age < 300; + } + false + } + + /// Process an incoming Feishu event (webhook callback). + pub async fn handle_event(&self, event: &serde_json::Value) -> Result> { + let msg_type = event + .pointer("/event/message/message_type") + .and_then(|v| v.as_str()); + + if msg_type != Some("text") { + return Ok(None); + } + + let content_str = event + .pointer("/event/message/content") + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + + let content: serde_json::Value = serde_json::from_str(content_str).unwrap_or_default(); + let text = content["text"].as_str().unwrap_or("").trim().to_string(); + + if text.len() == 6 && text.chars().all(|c| c.is_ascii_digit()) { + if self.verify_pairing_code(&text).await { + let chat_id = event + .pointer("/event/message/chat_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + return Ok(Some(chat_id.to_string())); + } + } + + Ok(None) + } +} diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs new file mode 100644 index 00000000..111d2ead --- /dev/null +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -0,0 +1,30 @@ +//! Bot integration for Remote Connect. +//! +//! Supports Feishu and Telegram bots as relay channels. + +pub mod feishu; +pub mod telegram; + +use serde::{Deserialize, Serialize}; + +/// Configuration for a bot-based connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "bot_type", rename_all = "snake_case")] +pub enum BotConfig { + Feishu { + app_id: String, + app_secret: String, + }, + Telegram { + bot_token: String, + }, +} + +/// Pairing state for bot-based connections. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotPairingInfo { + pub pairing_code: String, + pub bot_type: String, + pub bot_link: String, + pub expires_at: i64, +} diff --git a/src/crates/core/src/service/remote_connect/bot/telegram.rs b/src/crates/core/src/service/remote_connect/bot/telegram.rs new file mode 100644 index 00000000..8532f97a --- /dev/null +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -0,0 +1,159 @@ +//! Telegram bot integration for Remote Connect. +//! +//! Users create their own bot via @BotFather, obtain a token, and enter it in BitFun settings. +//! Desktop polls for updates via the Telegram Bot API (long polling). + +use anyhow::{anyhow, Result}; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramConfig { + pub bot_token: String, +} + +pub struct TelegramBot { + config: TelegramConfig, + pending_pairings: Arc>>, + last_update_id: Arc>, +} + +#[derive(Debug, Clone)] +struct PendingPairing { + created_at: i64, +} + +impl TelegramBot { + pub fn new(config: TelegramConfig) -> Self { + Self { + config, + pending_pairings: Arc::new(RwLock::new(HashMap::new())), + last_update_id: Arc::new(RwLock::new(0)), + } + } + + fn api_url(&self, method: &str) -> String { + format!( + "https://api.telegram.org/bot{}/{}", + self.config.bot_token, method + ) + } + + /// Send a text message to a Telegram chat. + pub async fn send_message(&self, chat_id: i64, text: &str) -> Result<()> { + let client = reqwest::Client::new(); + let resp = client + .post(&self.api_url("sendMessage")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown", + })) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("telegram sendMessage failed: {body}")); + } + + debug!("Telegram message sent to chat {chat_id}"); + Ok(()) + } + + /// Register a pairing code for verification. + pub async fn register_pairing(&self, pairing_code: &str) -> Result<()> { + self.pending_pairings.write().await.insert( + pairing_code.to_string(), + PendingPairing { + created_at: chrono::Utc::now().timestamp(), + }, + ); + Ok(()) + } + + /// Verify a pairing code. Returns true and removes it if valid and not expired. + pub async fn verify_pairing_code(&self, code: &str) -> bool { + let mut pairings = self.pending_pairings.write().await; + if let Some(p) = pairings.remove(code) { + let age = chrono::Utc::now().timestamp() - p.created_at; + return age < 300; + } + false + } + + /// Long-poll for new messages. Returns (chat_id, text) pairs. + pub async fn poll_updates(&self) -> Result> { + let offset = *self.last_update_id.read().await; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(35)) + .build()?; + + let resp = client + .get(&self.api_url("getUpdates")) + .query(&[ + ("offset", (offset + 1).to_string()), + ("timeout", "30".to_string()), + ]) + .send() + .await?; + + let body: serde_json::Value = resp.json().await?; + let results = body["result"].as_array().cloned().unwrap_or_default(); + + let mut messages = Vec::new(); + for update in results { + if let Some(update_id) = update["update_id"].as_i64() { + let mut last = self.last_update_id.write().await; + if update_id > *last { + *last = update_id; + } + } + + if let (Some(chat_id), Some(text)) = ( + update.pointer("/message/chat/id").and_then(|v| v.as_i64()), + update + .pointer("/message/text") + .and_then(|v| v.as_str()), + ) { + messages.push((chat_id, text.trim().to_string())); + } + } + + Ok(messages) + } + + /// Start a polling loop that checks for pairing codes. + /// Returns the chat_id when a valid pairing code is received. + pub async fn wait_for_pairing(&self) -> Result { + info!("Telegram bot waiting for pairing code..."); + loop { + match self.poll_updates().await { + Ok(messages) => { + for (chat_id, text) in messages { + if text.len() == 6 && text.chars().all(|c| c.is_ascii_digit()) { + if self.verify_pairing_code(&text).await { + info!("Telegram pairing successful, chat_id={chat_id}"); + self.send_message(chat_id, "Pairing successful! BitFun is now connected.") + .await + .ok(); + return Ok(chat_id); + } else { + self.send_message(chat_id, "Invalid or expired pairing code. Please try again.") + .await + .ok(); + } + } + } + } + Err(e) => { + error!("Telegram poll error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + } + } + } +} diff --git a/src/crates/core/src/service/remote_connect/device.rs b/src/crates/core/src/service/remote_connect/device.rs new file mode 100644 index 00000000..264d16ce --- /dev/null +++ b/src/crates/core/src/service/remote_connect/device.rs @@ -0,0 +1,74 @@ +//! Device identity for Remote Connect pairing. +//! +//! Generates a stable `device_id` from `SHA-256(hostname + MAC address)`. +//! Falls back gracefully when MAC or hostname are unavailable. + +use anyhow::Result; +use sha2::{Digest, Sha256}; + +/// Represents a device's identity used for pairing. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DeviceIdentity { + pub device_id: String, + pub device_name: String, + pub mac_address: String, +} + +impl DeviceIdentity { + /// Build the device identity from the current machine. + pub fn from_current_machine() -> Result { + let device_name = get_hostname(); + let mac_address = get_mac_address(); + + let mut hasher = Sha256::new(); + hasher.update(device_name.as_bytes()); + hasher.update(b":"); + hasher.update(mac_address.as_bytes()); + let hash = hasher.finalize(); + let device_id = hash[..16] + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + + Ok(Self { + device_id, + device_name, + mac_address, + }) + } +} + +fn get_hostname() -> String { + hostname::get() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "unknown-host".to_string()) +} + +fn get_mac_address() -> String { + mac_address::get_mac_address() + .ok() + .flatten() + .map(|ma| ma.to_string()) + .unwrap_or_else(|| "00:00:00:00:00:00".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_device_identity_creation() { + let identity = DeviceIdentity::from_current_machine().unwrap(); + assert!(!identity.device_id.is_empty()); + assert!(!identity.device_name.is_empty()); + assert_eq!(identity.device_id.len(), 32); // 16 bytes hex = 32 chars + } + + #[test] + fn test_device_identity_stable() { + let id1 = DeviceIdentity::from_current_machine().unwrap(); + let id2 = DeviceIdentity::from_current_machine().unwrap(); + assert_eq!(id1.device_id, id2.device_id); + } +} diff --git a/src/crates/core/src/service/remote_connect/embedded_relay.rs b/src/crates/core/src/service/remote_connect/embedded_relay.rs new file mode 100644 index 00000000..e8007c2a --- /dev/null +++ b/src/crates/core/src/service/remote_connect/embedded_relay.rs @@ -0,0 +1,297 @@ +//! Embedded mini relay server for LAN / ngrok modes. +//! +//! Runs inside the desktop process using axum + WebSocket. +//! Supports the same protocol as the standalone relay-server. + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use dashmap::DashMap; +use futures_util::{SinkExt, StreamExt}; +use log::{debug, error, info, warn}; +use serde::{Deserialize, Serialize}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; +use tokio::sync::mpsc; + +type ConnId = u64; + +struct Participant { + conn_id: ConnId, + device_id: String, + device_type: String, + public_key: String, + tx: mpsc::UnboundedSender, +} + +struct Room { + participants: Vec, +} + +struct RelayState { + rooms: DashMap, + conn_to_room: DashMap, + next_id: AtomicU64, +} + +impl RelayState { + fn new() -> Arc { + Arc::new(Self { + rooms: DashMap::new(), + conn_to_room: DashMap::new(), + next_id: AtomicU64::new(1), + }) + } +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum Inbound { + CreateRoom { + room_id: Option, + device_id: String, + device_type: String, + public_key: String, + }, + JoinRoom { + room_id: String, + device_id: String, + device_type: String, + public_key: String, + }, + Relay { + #[allow(dead_code)] + room_id: String, + encrypted_data: String, + nonce: String, + }, + Heartbeat, +} + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum Outbound { + RoomCreated { room_id: String }, + PeerJoined { device_id: String, device_type: String, public_key: String }, + Relay { room_id: String, encrypted_data: String, nonce: String }, + PeerDisconnected { device_id: String }, + HeartbeatAck, + Error { message: String }, +} + +/// Start the embedded relay and return a shutdown handle. +/// The server listens on `0.0.0.0:{port}`. +pub async fn start_embedded_relay(port: u16) -> anyhow::Result { + let state = RelayState::new(); + let app_state = state.clone(); + + let app = Router::new() + .route("/ws", get(ws_handler)) + .route("/health", get(health)) + .with_state(app_state); + + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) + .await + .map_err(|e| anyhow::anyhow!("failed to bind embedded relay on port {port}: {e}"))?; + + info!("Embedded relay started on 0.0.0.0:{port}"); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { let _ = shutdown_rx.await; }) + .await + .ok(); + }); + + // Brief wait for server to be ready + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + Ok(EmbeddedRelayHandle { _shutdown: Some(shutdown_tx) }) +} + +pub struct EmbeddedRelayHandle { + _shutdown: Option>, +} + +impl EmbeddedRelayHandle { + pub fn stop(&mut self) { + if let Some(tx) = self._shutdown.take() { + let _ = tx.send(()); + info!("Embedded relay stopped"); + } + } +} + +impl Drop for EmbeddedRelayHandle { + fn drop(&mut self) { + self.stop(); + } +} + +async fn health() -> impl IntoResponse { + Json(serde_json::json!({"status": "healthy"})) +} + +async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> Response { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: Arc) { + let (mut ws_tx, mut ws_rx) = socket.split(); + let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + + let conn_id = state.next_id.fetch_add(1, Ordering::Relaxed); + + let write_task = tokio::spawn(async move { + while let Some(text) = out_rx.recv().await { + if ws_tx.send(Message::Text(text)).await.is_err() { + break; + } + } + }); + + while let Some(Ok(msg)) = ws_rx.next().await { + if let Message::Text(text) = msg { + handle_msg(&text, conn_id, &state, &out_tx); + } + } + + on_disconnect(conn_id, &state); + drop(out_tx); + let _ = write_task.await; + debug!("Embedded relay: conn {conn_id} closed"); +} + +fn handle_msg( + text: &str, + conn_id: ConnId, + state: &Arc, + out_tx: &mpsc::UnboundedSender, +) { + let msg: Inbound = match serde_json::from_str(text) { + Ok(m) => m, + Err(e) => { + send(out_tx, &Outbound::Error { message: format!("bad message: {e}") }); + return; + } + }; + + match msg { + Inbound::CreateRoom { room_id, device_id, device_type, public_key } => { + let room_id = room_id.unwrap_or_else(gen_room_id); + let mut room = Room { participants: Vec::with_capacity(2) }; + room.participants.push(Participant { + conn_id, device_id, device_type, public_key, tx: out_tx.clone(), + }); + state.rooms.insert(room_id.clone(), room); + state.conn_to_room.insert(conn_id, room_id.clone()); + send(out_tx, &Outbound::RoomCreated { room_id }); + } + + Inbound::JoinRoom { room_id, device_id, device_type, public_key } => { + let existing_peer = state.rooms.get(&room_id).and_then(|r| { + r.participants.first().map(|p| (p.device_id.clone(), p.device_type.clone(), p.public_key.clone())) + }); + + let ok = if let Some(mut room) = state.rooms.get_mut(&room_id) { + if room.participants.len() < 2 { + room.participants.push(Participant { + conn_id, + device_id: device_id.clone(), + device_type: device_type.clone(), + public_key: public_key.clone(), + tx: out_tx.clone(), + }); + state.conn_to_room.insert(conn_id, room_id.clone()); + true + } else { + false + } + } else { + false + }; + + if ok { + if let Some(room) = state.rooms.get(&room_id) { + for p in &room.participants { + if p.conn_id != conn_id { + send(&p.tx, &Outbound::PeerJoined { + device_id: device_id.clone(), + device_type: device_type.clone(), + public_key: public_key.clone(), + }); + } + } + } + if let Some((pdid, pdt, ppk)) = existing_peer { + send(out_tx, &Outbound::PeerJoined { + device_id: pdid, device_type: pdt, public_key: ppk, + }); + } + } else { + send(out_tx, &Outbound::Error { message: format!("cannot join room {room_id}") }); + } + } + + Inbound::Relay { room_id, encrypted_data, nonce } => { + if let Some(rid) = state.conn_to_room.get(&conn_id) { + if let Some(room) = state.rooms.get(rid.value()) { + let relay_json = serde_json::to_string(&Outbound::Relay { + room_id: room_id.clone(), encrypted_data, nonce, + }).unwrap_or_default(); + for p in &room.participants { + if p.conn_id != conn_id { + let _ = p.tx.send(relay_json.clone()); + } + } + } + } + } + + Inbound::Heartbeat => { + send(out_tx, &Outbound::HeartbeatAck); + } + } +} + +fn on_disconnect(conn_id: ConnId, state: &Arc) { + if let Some((_, room_id)) = state.conn_to_room.remove(&conn_id) { + let mut should_remove = false; + if let Some(mut room) = state.rooms.get_mut(&room_id) { + let removed = room.participants.iter().position(|p| p.conn_id == conn_id); + if let Some(idx) = removed { + let p = room.participants.remove(idx); + let notif = serde_json::to_string(&Outbound::PeerDisconnected { + device_id: p.device_id, + }).unwrap_or_default(); + for other in &room.participants { + let _ = other.tx.send(notif.clone()); + } + } + should_remove = room.participants.is_empty(); + } + if should_remove { + state.rooms.remove(&room_id); + } + } +} + +fn send(tx: &mpsc::UnboundedSender, msg: &Outbound) { + if let Ok(json) = serde_json::to_string(msg) { + let _ = tx.send(json); + } +} + +fn gen_room_id() -> String { + let bytes: [u8; 6] = rand::random(); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/crates/core/src/service/remote_connect/encryption.rs b/src/crates/core/src/service/remote_connect/encryption.rs new file mode 100644 index 00000000..a4f10839 --- /dev/null +++ b/src/crates/core/src/service/remote_connect/encryption.rs @@ -0,0 +1,189 @@ +//! End-to-end encryption for Remote Connect. +//! +//! Uses X25519 ECDH for key exchange and AES-256-GCM for authenticated encryption. +//! Both sides generate ephemeral keypairs; the shared secret is derived via ECDH +//! and used directly as the AES-256-GCM key (X25519 output is already 32 bytes). + +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use rand::RngCore; +use x25519_dalek::{PublicKey, StaticSecret}; + +const NONCE_SIZE: usize = 12; + +/// Holds a keypair for X25519 ECDH key exchange. +pub struct KeyPair { + secret: StaticSecret, + public: PublicKey, +} + +impl KeyPair { + pub fn generate() -> Self { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + Self { secret, public } + } + + pub fn public_key_bytes(&self) -> [u8; 32] { + self.public.to_bytes() + } + + pub fn public_key_base64(&self) -> String { + BASE64.encode(self.public.to_bytes()) + } + + /// Derive a shared secret from our secret key and the peer's public key. + pub fn derive_shared_secret(&self, peer_public_bytes: &[u8; 32]) -> [u8; 32] { + let peer_public = PublicKey::from(*peer_public_bytes); + let shared = self.secret.diffie_hellman(&peer_public); + *shared.as_bytes() + } +} + +/// Encrypts plaintext using AES-256-GCM with a random nonce. +/// Returns `(ciphertext, nonce)` both as raw bytes. +pub fn encrypt(shared_secret: &[u8; 32], plaintext: &[u8]) -> Result<(Vec, [u8; NONCE_SIZE])> { + let cipher = + Aes256Gcm::new_from_slice(shared_secret).map_err(|e| anyhow!("cipher init: {e}"))?; + + let mut nonce_bytes = [0u8; NONCE_SIZE]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| anyhow!("encrypt: {e}"))?; + + Ok((ciphertext, nonce_bytes)) +} + +/// Decrypts ciphertext using AES-256-GCM. +pub fn decrypt( + shared_secret: &[u8; 32], + ciphertext: &[u8], + nonce_bytes: &[u8; NONCE_SIZE], +) -> Result> { + let cipher = + Aes256Gcm::new_from_slice(shared_secret).map_err(|e| anyhow!("cipher init: {e}"))?; + let nonce = Nonce::from_slice(nonce_bytes); + + cipher + .decrypt(nonce, ciphertext) + .map_err(|e| anyhow!("decrypt: {e}")) +} + +/// Convenience: encrypt a string and return base64-encoded `(data, nonce)`. +pub fn encrypt_to_base64(shared_secret: &[u8; 32], plaintext: &str) -> Result<(String, String)> { + let (ct, nonce) = encrypt(shared_secret, plaintext.as_bytes())?; + Ok((BASE64.encode(ct), BASE64.encode(nonce))) +} + +/// Convenience: decrypt from base64-encoded `(data, nonce)`. +pub fn decrypt_from_base64( + shared_secret: &[u8; 32], + ciphertext_b64: &str, + nonce_b64: &str, +) -> Result { + let ct = BASE64 + .decode(ciphertext_b64) + .map_err(|e| anyhow!("base64 decode ciphertext: {e}"))?; + let nonce_vec = BASE64 + .decode(nonce_b64) + .map_err(|e| anyhow!("base64 decode nonce: {e}"))?; + + if nonce_vec.len() != NONCE_SIZE { + return Err(anyhow!( + "invalid nonce length: expected {NONCE_SIZE}, got {}", + nonce_vec.len() + )); + } + let mut nonce = [0u8; NONCE_SIZE]; + nonce.copy_from_slice(&nonce_vec); + + let plaintext = decrypt(shared_secret, &ct, &nonce)?; + String::from_utf8(plaintext).map_err(|e| anyhow!("utf8 decode: {e}")) +} + +/// Parse a base64-encoded public key into 32-byte array. +pub fn parse_public_key(b64: &str) -> Result<[u8; 32]> { + let bytes = BASE64 + .decode(b64) + .map_err(|e| anyhow!("base64 decode public key: {e}"))?; + if bytes.len() != 32 { + return Err(anyhow!( + "invalid public key length: expected 32, got {}", + bytes.len() + )); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_key_exchange_and_encrypt_decrypt() { + let alice = KeyPair::generate(); + let bob = KeyPair::generate(); + + let alice_shared = alice.derive_shared_secret(&bob.public_key_bytes()); + let bob_shared = bob.derive_shared_secret(&alice.public_key_bytes()); + assert_eq!(alice_shared, bob_shared); + + let message = "Hello, Remote Connect!"; + let (ct, nonce) = encrypt(&alice_shared, message.as_bytes()).unwrap(); + let decrypted = decrypt(&bob_shared, &ct, &nonce).unwrap(); + assert_eq!(decrypted, message.as_bytes()); + } + + #[test] + fn test_base64_round_trip() { + let alice = KeyPair::generate(); + let bob = KeyPair::generate(); + + let shared = alice.derive_shared_secret(&bob.public_key_bytes()); + let message = "加密测试消息 with unicode 🔒"; + let (ct_b64, nonce_b64) = encrypt_to_base64(&shared, message).unwrap(); + let decrypted = decrypt_from_base64(&shared, &ct_b64, &nonce_b64).unwrap(); + assert_eq!(decrypted, message); + } + + #[test] + fn test_wrong_key_fails() { + let alice = KeyPair::generate(); + let bob = KeyPair::generate(); + let eve = KeyPair::generate(); + + let alice_shared = alice.derive_shared_secret(&bob.public_key_bytes()); + let eve_shared = eve.derive_shared_secret(&bob.public_key_bytes()); + + let (ct, nonce) = encrypt(&alice_shared, b"secret").unwrap(); + assert!(decrypt(&eve_shared, &ct, &nonce).is_err()); + } + + #[test] + fn test_parse_public_key() { + let kp = KeyPair::generate(); + let b64 = kp.public_key_base64(); + let parsed = parse_public_key(&b64).unwrap(); + assert_eq!(parsed, kp.public_key_bytes()); + } + + #[test] + fn test_tampered_ciphertext_fails() { + let alice = KeyPair::generate(); + let bob = KeyPair::generate(); + let shared = alice.derive_shared_secret(&bob.public_key_bytes()); + + let (mut ct, nonce) = encrypt(&shared, b"secret data").unwrap(); + if let Some(byte) = ct.last_mut() { + *byte ^= 0xff; + } + assert!(decrypt(&shared, &ct, &nonce).is_err()); + } +} diff --git a/src/crates/core/src/service/remote_connect/lan.rs b/src/crates/core/src/service/remote_connect/lan.rs new file mode 100644 index 00000000..2df68eda --- /dev/null +++ b/src/crates/core/src/service/remote_connect/lan.rs @@ -0,0 +1,34 @@ +//! LAN mode: starts an embedded relay server on the local network. +//! +//! The desktop runs a mini relay server, and the QR code points to the local IP. + +use anyhow::{anyhow, Result}; +use log::info; + +/// Detect the local LAN IP address. +pub fn get_local_ip() -> Result { + let ip = local_ip_address::local_ip().map_err(|e| anyhow!("failed to detect LAN IP: {e}"))?; + Ok(ip.to_string()) +} + +/// Build the relay URL for LAN mode. +pub fn build_lan_relay_url(port: u16) -> Result { + let ip = get_local_ip()?; + let url = format!("http://{ip}:{port}"); + info!("LAN relay URL: {url}"); + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_local_ip() { + let ip = get_local_ip(); + // May fail in CI environments without network, so just check it doesn't panic + if let Ok(ip) = ip { + assert!(!ip.is_empty()); + } + } +} diff --git a/src/crates/core/src/service/remote_connect/mod.rs b/src/crates/core/src/service/remote_connect/mod.rs new file mode 100644 index 00000000..99461b30 --- /dev/null +++ b/src/crates/core/src/service/remote_connect/mod.rs @@ -0,0 +1,410 @@ +//! Remote Connect service module. +//! +//! Provides phone-to-desktop remote connection capabilities with E2E encryption. +//! Supports multiple connection methods: LAN, ngrok, relay server, and bots. + +pub mod bot; +pub mod device; +pub mod embedded_relay; +pub mod encryption; +pub mod lan; +pub mod ngrok; +pub mod pairing; +pub mod qr_generator; +pub mod relay_client; +pub mod session_bridge; + +pub use device::DeviceIdentity; +pub use encryption::{decrypt_from_base64, encrypt_to_base64, KeyPair}; +pub use pairing::{PairingProtocol, PairingState}; +pub use qr_generator::QrGenerator; +pub use relay_client::RelayClient; +pub use session_bridge::SessionBridge; + +use anyhow::Result; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Supported connection methods. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionMethod { + Lan, + Ngrok, + BitfunServer, + CustomServer { url: String }, + BotFeishu, + BotTelegram, +} + +/// Configuration for Remote Connect. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteConnectConfig { + pub lan_port: u16, + pub bitfun_server_url: String, + pub web_app_url: String, + pub custom_server_url: Option, + pub bot_feishu: Option, + pub bot_telegram: Option, +} + +impl Default for RemoteConnectConfig { + fn default() -> Self { + Self { + lan_port: 9700, + bitfun_server_url: "http://116.204.120.240/relay".to_string(), + web_app_url: "http://116.204.120.240/relay".to_string(), + custom_server_url: None, + bot_feishu: None, + bot_telegram: None, + } + } +} + +/// Result of starting a remote connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionResult { + pub method: ConnectionMethod, + pub qr_data: Option, + pub qr_svg: Option, + pub qr_url: Option, + pub bot_pairing_code: Option, + pub bot_link: Option, + pub pairing_state: PairingState, +} + +/// Unified Remote Connect service that orchestrates all connection methods. +pub struct RemoteConnectService { + config: RemoteConnectConfig, + device_identity: DeviceIdentity, + pairing: Arc>, + relay_client: Arc>>, + session_bridge: Arc>>, + active_method: Arc>>, + ngrok_tunnel: Arc>>, + embedded_relay: Arc>>, +} + +impl RemoteConnectService { + pub fn new(config: RemoteConnectConfig) -> Result { + let device_identity = DeviceIdentity::from_current_machine()?; + let pairing = PairingProtocol::new(device_identity.clone()); + + Ok(Self { + config, + device_identity, + pairing: Arc::new(RwLock::new(pairing)), + relay_client: Arc::new(RwLock::new(None)), + session_bridge: Arc::new(RwLock::new(None)), + active_method: Arc::new(RwLock::new(None)), + ngrok_tunnel: Arc::new(RwLock::new(None)), + embedded_relay: Arc::new(RwLock::new(None)), + }) + } + + pub fn device_identity(&self) -> &DeviceIdentity { + &self.device_identity + } + + /// All connection methods are always available in the UI (ngrok shows warning if missing). + pub async fn available_methods(&self) -> Vec { + vec![ + ConnectionMethod::Lan, + ConnectionMethod::Ngrok, + ConnectionMethod::BitfunServer, + ConnectionMethod::CustomServer { + url: self.config.custom_server_url.clone().unwrap_or_default(), + }, + ConnectionMethod::BotFeishu, + ConnectionMethod::BotTelegram, + ] + } + + /// Start a remote connection with the given method. + pub async fn start(&self, method: ConnectionMethod) -> Result { + info!("Starting remote connect: {method:?}"); + + let relay_url = match &method { + ConnectionMethod::Lan => { + let handle = + embedded_relay::start_embedded_relay(self.config.lan_port).await?; + *self.embedded_relay.write().await = Some(handle); + lan::build_lan_relay_url(self.config.lan_port)? + } + ConnectionMethod::Ngrok => { + let handle = + embedded_relay::start_embedded_relay(self.config.lan_port).await?; + *self.embedded_relay.write().await = Some(handle); + + let tunnel = ngrok::start_ngrok_tunnel(self.config.lan_port).await?; + let url = tunnel.public_url.clone(); + *self.ngrok_tunnel.write().await = Some(tunnel); + url + } + ConnectionMethod::BitfunServer => self.config.bitfun_server_url.clone(), + ConnectionMethod::CustomServer { url } => url.clone(), + ConnectionMethod::BotFeishu | ConnectionMethod::BotTelegram => { + return self.start_bot_connection(&method).await; + } + }; + + let mut pairing = self.pairing.write().await; + pairing.reset().await; + let qr_payload = pairing.initiate(&relay_url).await?; + + // QR URL = web app hosted on BitFun server + relay WS address as param + let qr_url = QrGenerator::build_url(&qr_payload, &self.config.web_app_url); + let qr_svg = QrGenerator::generate_svg_from_url(&qr_url)?; + let qr_data = QrGenerator::generate_png_base64_from_url(&qr_url)?; + + *self.active_method.write().await = Some(method.clone()); + + // Connect desktop relay client to the relay server + let ws_url = format!( + "{}/ws", + relay_url + .replace("https://", "wss://") + .replace("http://", "ws://") + ); + + let (client, mut event_rx) = RelayClient::new(); + client.connect(&ws_url).await?; + client + .create_room( + &self.device_identity.device_id, + &qr_payload.public_key, + Some(&qr_payload.room_id), + ) + .await?; + + *self.relay_client.write().await = Some(client); + + let pairing_arc = self.pairing.clone(); + let relay_arc = self.relay_client.clone(); + let bridge_arc = self.session_bridge.clone(); + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + match event { + relay_client::RelayEvent::PeerJoined { + public_key, + device_id, + } => { + info!("Peer joined: {device_id}"); + let mut p = pairing_arc.write().await; + match p.on_peer_joined(&public_key).await { + Ok(challenge) => { + if let Some(secret) = p.shared_secret() { + let challenge_json = + serde_json::to_string(&challenge).unwrap_or_default(); + if let Ok((enc, nonce)) = + encryption::encrypt_to_base64(secret, &challenge_json) + { + if let Some(ref client) = *relay_arc.read().await { + if let Some(room) = p.room_id() { + let _ = client + .send_encrypted(room, &enc, &nonce) + .await; + } + } + } + } + } + Err(e) => { + error!("Pairing error on peer_joined: {e}"); + } + } + } + relay_client::RelayEvent::MessageReceived { + encrypted_data, + nonce, + } => { + let is_paired = bridge_arc.read().await.is_some(); + + if is_paired { + let bridge_guard = bridge_arc.read().await; + if let Some(ref bridge) = *bridge_guard { + match bridge.decrypt_command(&encrypted_data, &nonce) { + Ok((cmd, request_id)) => { + info!("Remote command: {cmd:?}"); + + // For SendMessage, register stream forwarder before dispatch + let stream_sub_id = if let session_bridge::RemoteCommand::SendMessage { session_id, .. } = &cmd { + let secret = *bridge.shared_secret(); + if let Some((sub_id, mut stream_rx)) = session_bridge::register_stream_forwarder(session_id, secret) { + let relay_for_stream = relay_arc.clone(); + let pairing_for_stream = pairing_arc.clone(); + let sub_id_clone = sub_id.clone(); + tokio::spawn(async move { + while let Some((enc, nonce)) = stream_rx.recv().await { + if let Some(ref client) = *relay_for_stream.read().await { + let p = pairing_for_stream.read().await; + if let Some(room) = p.room_id() { + let _ = client.send_encrypted(room, &enc, &nonce).await; + } + } + } + debug!("Stream forwarder channel closed: {sub_id_clone}"); + }); + Some(sub_id) + } else { + None + } + } else { + None + }; + + let response = bridge.dispatch(&cmd).await; + match bridge.encrypt_response(&response, request_id.as_deref()) { + Ok((enc, resp_nonce)) => { + if let Some(ref client) = *relay_arc.read().await { + let p = pairing_arc.read().await; + if let Some(room) = p.room_id() { + let _ = client.send_encrypted(room, &enc, &resp_nonce).await; + } + } + } + Err(e) => { + error!("Failed to encrypt response: {e}"); + // Clean up forwarder on error + if let Some(sub_id) = &stream_sub_id { + session_bridge::unregister_stream_forwarder(sub_id); + } + } + } + // Note: stream forwarder is NOT unregistered here. + // It auto-cleans when the channel sender is dropped + // (which happens when the coordinator unsubscribes or + // the forwarder struct is dropped). + // The DialogTurnCompleted/Failed/Cancelled events are + // the last events for a turn; after that no more events + // flow and the forwarder becomes idle until next turn. + } + Err(e) => error!("Failed to decrypt command: {e}"), + } + } + } else { + // Not yet paired — try to verify pairing response + let p = pairing_arc.read().await; + if let Some(secret) = p.shared_secret() { + if let Ok(json) = + encryption::decrypt_from_base64(secret, &encrypted_data, &nonce) + { + if let Ok(response) = + serde_json::from_str::(&json) + { + drop(p); + let mut pw = pairing_arc.write().await; + match pw.verify_response(&response).await { + Ok(true) => { + info!("Pairing verified successfully"); + if let Some(s) = pw.shared_secret() { + *bridge_arc.write().await = + Some(SessionBridge::new(*s)); + } + } + Ok(false) => { + error!("Pairing verification failed"); + } + Err(e) => { + error!("Pairing verification error: {e}"); + } + } + } + } + } + } + } + relay_client::RelayEvent::PeerDisconnected { device_id } => { + info!("Peer disconnected: {device_id}"); + pairing_arc.write().await.disconnect().await; + *bridge_arc.write().await = None; + } + relay_client::RelayEvent::Disconnected => { + info!("Relay disconnected"); + } + relay_client::RelayEvent::Error { message } => { + error!("Relay error: {message}"); + } + _ => {} + } + } + }); + + let state = pairing.state().await; + Ok(ConnectionResult { + method, + qr_data: Some(qr_data), + qr_svg: Some(qr_svg), + qr_url: Some(qr_url), + bot_pairing_code: None, + bot_link: None, + pairing_state: state, + }) + } + + async fn start_bot_connection(&self, method: &ConnectionMethod) -> Result { + let pairing_code = PairingProtocol::generate_bot_pairing_code(); + + let bot_link = match method { + ConnectionMethod::BotFeishu => { + "https://open.feishu.cn/open-apis/bot".to_string() + } + ConnectionMethod::BotTelegram => { + "https://t.me/your_bitfun_bot".to_string() + } + _ => String::new(), + }; + + *self.active_method.write().await = Some(method.clone()); + + Ok(ConnectionResult { + method: method.clone(), + qr_data: None, + qr_svg: None, + qr_url: None, + bot_pairing_code: Some(pairing_code), + bot_link: Some(bot_link), + pairing_state: PairingState::WaitingForScan, + }) + } + + pub async fn pairing_state(&self) -> PairingState { + self.pairing.read().await.state().await + } + + pub async fn stop(&self) { + if let Some(ref client) = *self.relay_client.read().await { + client.disconnect().await; + } + *self.relay_client.write().await = None; + *self.session_bridge.write().await = None; + *self.active_method.write().await = None; + + if let Some(ref mut tunnel) = *self.ngrok_tunnel.write().await { + tunnel.stop().await; + } + *self.ngrok_tunnel.write().await = None; + + if let Some(ref mut relay) = *self.embedded_relay.write().await { + relay.stop(); + } + *self.embedded_relay.write().await = None; + + self.pairing.write().await.reset().await; + info!("Remote connect stopped"); + } + + pub async fn is_connected(&self) -> bool { + self.pairing.read().await.state().await == PairingState::Connected + } + + pub async fn active_method(&self) -> Option { + self.active_method.read().await.clone() + } + + pub async fn peer_device_name(&self) -> Option { + self.pairing.read().await.peer_device_name().map(String::from) + } +} diff --git a/src/crates/core/src/service/remote_connect/ngrok.rs b/src/crates/core/src/service/remote_connect/ngrok.rs new file mode 100644 index 00000000..267bfb6c --- /dev/null +++ b/src/crates/core/src/service/remote_connect/ngrok.rs @@ -0,0 +1,137 @@ +//! ngrok tunnel mode for Remote Connect. + +use anyhow::{anyhow, Result}; +use log::info; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::process::Command; + +/// Find the ngrok binary, checking common locations beyond just PATH. +fn find_ngrok() -> Option { + let candidates: Vec = vec![ + PathBuf::from("/usr/local/bin/ngrok"), + PathBuf::from("/opt/homebrew/bin/ngrok"), + dirs::home_dir() + .map(|h| h.join("ngrok")) + .unwrap_or_default(), + dirs::home_dir() + .map(|h| h.join(".ngrok/ngrok")) + .unwrap_or_default(), + dirs::home_dir() + .map(|h| h.join("bin/ngrok")) + .unwrap_or_default(), + #[cfg(target_os = "windows")] + { + let appdata = std::env::var("LOCALAPPDATA").unwrap_or_default(); + PathBuf::from(format!("{appdata}\\ngrok\\ngrok.exe")) + }, + #[cfg(target_os = "windows")] + PathBuf::from("C:\\ngrok\\ngrok.exe"), + ]; + + // Try which crate first (uses PATH) + if let Ok(path) = which::which("ngrok") { + return Some(path); + } + + // Check known locations + for path in candidates { + if path.exists() && path.is_file() { + return Some(path); + } + } + + None +} + +/// Check if ngrok is installed and available. +pub async fn is_ngrok_available() -> bool { + find_ngrok().is_some() +} + +/// Start an ngrok HTTP tunnel and return the public URL. +pub async fn start_ngrok_tunnel(local_port: u16) -> Result { + let ngrok_path = find_ngrok() + .ok_or_else(|| anyhow!("ngrok is not installed. Please install it from https://ngrok.com/download"))?; + + info!("Using ngrok at: {}", ngrok_path.display()); + + let child = Command::new(&ngrok_path) + .args(["http", &local_port.to_string(), "--log", "stdout", "--log-format", "json"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| anyhow!("failed to start ngrok: {e}"))?; + + let pid = child.id().unwrap_or(0); + info!("ngrok process started, pid={pid}"); + + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let public_url = query_ngrok_api().await?; + info!("ngrok tunnel established: {public_url}"); + + Ok(NgrokTunnel { + public_url, + local_port, + process: Some(child), + }) +} + +async fn query_ngrok_api() -> Result { + let client = reqwest::Client::new(); + let resp = client + .get("http://127.0.0.1:4040/api/tunnels") + .send() + .await + .map_err(|e| anyhow!("ngrok API query failed: {e}"))?; + + let body: serde_json::Value = resp.json().await?; + let tunnels = body["tunnels"] + .as_array() + .ok_or_else(|| anyhow!("no tunnels in ngrok API response"))?; + + for tunnel in tunnels { + if let Some(url) = tunnel["public_url"].as_str() { + if url.starts_with("https://") { + return Ok(url.to_string()); + } + } + } + + tunnels + .first() + .and_then(|t| t["public_url"].as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("no public URL found in ngrok tunnels")) +} + +pub struct NgrokTunnel { + pub public_url: String, + pub local_port: u16, + process: Option, +} + +impl NgrokTunnel { + pub fn ws_url(&self) -> String { + self.public_url + .replace("https://", "wss://") + .replace("http://", "ws://") + } + + pub async fn stop(&mut self) { + if let Some(ref mut child) = self.process { + let _ = child.kill().await; + info!("ngrok tunnel stopped"); + } + self.process = None; + } +} + +impl Drop for NgrokTunnel { + fn drop(&mut self) { + if let Some(ref mut child) = self.process { + let _ = child.start_kill(); + } + } +} diff --git a/src/crates/core/src/service/remote_connect/pairing.rs b/src/crates/core/src/service/remote_connect/pairing.rs new file mode 100644 index 00000000..5f404398 --- /dev/null +++ b/src/crates/core/src/service/remote_connect/pairing.rs @@ -0,0 +1,267 @@ +//! Pairing protocol for establishing E2E encrypted connections. +//! +//! Desktop generates a keypair + room, encodes it in a QR code. +//! Mobile scans QR, joins room, sends its public key. +//! Both sides derive a shared secret via ECDH and verify with a challenge-response. + +use anyhow::{anyhow, Result}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; + +use super::device::DeviceIdentity; +use super::encryption::{self, KeyPair}; + +/// Current state of the pairing process. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PairingState { + Idle, + WaitingForScan, + Handshaking, + Verifying, + Connected, + Failed { reason: String }, + Disconnected, +} + +/// Information encoded in the QR code. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QrPayload { + pub url: String, + pub room_id: String, + pub device_id: String, + pub device_name: String, + pub public_key: String, + pub version: u8, +} + +/// Challenge sent from desktop to mobile during pairing verification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingChallenge { + pub challenge: String, + pub timestamp: i64, +} + +/// Response from mobile to desktop. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PairingResponse { + pub challenge_echo: String, + pub device_id: String, + pub device_name: String, +} + +/// Manages the pairing state machine. +pub struct PairingProtocol { + state: Arc>, + keypair: Option, + shared_secret: Option<[u8; 32]>, + room_id: Option, + device_identity: DeviceIdentity, + challenge: Option, + peer_device_id: Option, + peer_device_name: Option, +} + +impl PairingProtocol { + pub fn new(device_identity: DeviceIdentity) -> Self { + Self { + state: Arc::new(RwLock::new(PairingState::Idle)), + keypair: None, + shared_secret: None, + room_id: None, + device_identity, + challenge: None, + peer_device_id: None, + peer_device_name: None, + } + } + + pub async fn state(&self) -> PairingState { + self.state.read().await.clone() + } + + pub fn shared_secret(&self) -> Option<&[u8; 32]> { + self.shared_secret.as_ref() + } + + pub fn room_id(&self) -> Option<&str> { + self.room_id.as_deref() + } + + pub fn peer_device_name(&self) -> Option<&str> { + self.peer_device_name.as_deref() + } + + /// Step 1 (Desktop): Generate keypair and prepare QR payload. + pub async fn initiate(&mut self, relay_url: &str) -> Result { + let keypair = KeyPair::generate(); + let room_id = generate_room_id(); + + let payload = QrPayload { + url: relay_url.to_string(), + room_id: room_id.clone(), + device_id: self.device_identity.device_id.clone(), + device_name: self.device_identity.device_name.clone(), + public_key: keypair.public_key_base64(), + version: 1, + }; + + self.keypair = Some(keypair); + self.room_id = Some(room_id); + *self.state.write().await = PairingState::WaitingForScan; + + Ok(payload) + } + + /// Step 2 (Desktop): Peer joined with their public key — derive shared secret. + pub async fn on_peer_joined(&mut self, peer_public_key_b64: &str) -> Result { + let keypair = self + .keypair + .as_ref() + .ok_or_else(|| anyhow!("no keypair — call initiate() first"))?; + + let peer_pub = encryption::parse_public_key(peer_public_key_b64)?; + let shared = keypair.derive_shared_secret(&peer_pub); + self.shared_secret = Some(shared); + + let challenge = generate_challenge(); + self.challenge = Some(challenge.clone()); + + let challenge_payload = PairingChallenge { + challenge, + timestamp: chrono::Utc::now().timestamp(), + }; + + *self.state.write().await = PairingState::Verifying; + Ok(challenge_payload) + } + + /// Step 3 (Desktop): Verify the peer's challenge response. + pub async fn verify_response(&mut self, response: &PairingResponse) -> Result { + let expected = self + .challenge + .as_ref() + .ok_or_else(|| anyhow!("no challenge issued"))?; + + if response.challenge_echo != *expected { + *self.state.write().await = PairingState::Failed { + reason: "challenge mismatch".to_string(), + }; + return Ok(false); + } + + self.peer_device_id = Some(response.device_id.clone()); + self.peer_device_name = Some(response.device_name.clone()); + *self.state.write().await = PairingState::Connected; + Ok(true) + } + + /// Mobile side: process a received challenge and produce a response. + pub fn answer_challenge( + challenge: &PairingChallenge, + device_identity: &DeviceIdentity, + ) -> PairingResponse { + PairingResponse { + challenge_echo: challenge.challenge.clone(), + device_id: device_identity.device_id.clone(), + device_name: device_identity.device_name.clone(), + } + } + + pub async fn disconnect(&mut self) { + *self.state.write().await = PairingState::Disconnected; + self.shared_secret = None; + self.challenge = None; + self.peer_device_id = None; + self.peer_device_name = None; + } + + pub async fn reset(&mut self) { + *self.state.write().await = PairingState::Idle; + self.keypair = None; + self.shared_secret = None; + self.room_id = None; + self.challenge = None; + self.peer_device_id = None; + self.peer_device_name = None; + } + + /// Generate a 6-digit pairing code for bot connections. + pub fn generate_bot_pairing_code() -> String { + let code: u32 = rand::thread_rng().gen_range(100_000..1_000_000); + format!("{code:06}") + } +} + +fn generate_room_id() -> String { + let mut rng = rand::thread_rng(); + let bytes: [u8; 8] = rng.gen(); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn generate_challenge() -> String { + let mut rng = rand::thread_rng(); + let bytes: [u8; 16] = rng.gen(); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_pairing_flow() { + let device = DeviceIdentity { + device_id: "test-desktop-id".into(), + device_name: "TestDesktop".into(), + mac_address: "AA:BB:CC:DD:EE:FF".into(), + }; + + let mobile_device = DeviceIdentity { + device_id: "test-mobile-id".into(), + device_name: "TestMobile".into(), + mac_address: "11:22:33:44:55:66".into(), + }; + + let mut protocol = PairingProtocol::new(device); + + // Step 1: Desktop initiates + let qr = protocol + .initiate("wss://relay.example.com") + .await + .unwrap(); + assert_eq!(protocol.state().await, PairingState::WaitingForScan); + assert!(!qr.room_id.is_empty()); + + // Simulate mobile generating a keypair and joining + let mobile_keypair = KeyPair::generate(); + let mobile_pub_b64 = mobile_keypair.public_key_base64(); + + // Step 2: Desktop receives mobile's public key + let challenge = protocol.on_peer_joined(&mobile_pub_b64).await.unwrap(); + assert_eq!(protocol.state().await, PairingState::Verifying); + + // Mobile answers the challenge + let response = PairingProtocol::answer_challenge(&challenge, &mobile_device); + + // Step 3: Desktop verifies + let ok = protocol.verify_response(&response).await.unwrap(); + assert!(ok); + assert_eq!(protocol.state().await, PairingState::Connected); + + // Both sides should have matching shared secrets + let desktop_secret = protocol.shared_secret().unwrap(); + let desktop_pub = encryption::parse_public_key(&qr.public_key).unwrap(); + let mobile_shared = mobile_keypair.derive_shared_secret(&desktop_pub); + assert_eq!(*desktop_secret, mobile_shared); + } + + #[test] + fn test_bot_pairing_code() { + let code = PairingProtocol::generate_bot_pairing_code(); + assert_eq!(code.len(), 6); + assert!(code.chars().all(|c| c.is_ascii_digit())); + } +} diff --git a/src/crates/core/src/service/remote_connect/qr_generator.rs b/src/crates/core/src/service/remote_connect/qr_generator.rs new file mode 100644 index 00000000..0a792173 --- /dev/null +++ b/src/crates/core/src/service/remote_connect/qr_generator.rs @@ -0,0 +1,60 @@ +//! QR code generation for Remote Connect pairing. + +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use qrcode::QrCode; + +use super::pairing::QrPayload; + +pub struct QrGenerator; + +impl QrGenerator { + /// Build the URL that the QR code points to. + /// `web_app_url` = where the mobile web app is hosted. + /// `payload.url` = the relay server that the mobile WebSocket should connect to. + pub fn build_url(payload: &QrPayload, web_app_url: &str) -> String { + let relay_ws = payload + .url + .replace("https://", "wss://") + .replace("http://", "ws://"); + format!( + "{web_app}/#/pair?room={room}&did={did}&pk={pk}&dn={dn}&relay={relay}&v={v}", + web_app = web_app_url.trim_end_matches('/'), + room = urlencoding::encode(&payload.room_id), + did = urlencoding::encode(&payload.device_id), + pk = urlencoding::encode(&payload.public_key), + dn = urlencoding::encode(&payload.device_name), + relay = urlencoding::encode(&relay_ws), + v = payload.version, + ) + } + + /// Generate a QR code as a base64-encoded PNG from a pre-built URL. + pub fn generate_png_base64_from_url(url: &str) -> Result { + let code = + QrCode::new(url.as_bytes()).map_err(|e| anyhow!("QR code generation failed: {e}"))?; + let img = code.render::>().quiet_zone(true).build(); + let mut buf = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut buf); + image::ImageEncoder::write_image( + encoder, + img.as_raw(), + img.width(), + img.height(), + image::ExtendedColorType::L8, + ) + .map_err(|e| anyhow!("PNG encoding failed: {e}"))?; + Ok(BASE64.encode(&buf)) + } + + /// Generate the QR code as an SVG string from a pre-built URL. + pub fn generate_svg_from_url(url: &str) -> Result { + let code = + QrCode::new(url.as_bytes()).map_err(|e| anyhow!("QR code generation failed: {e}"))?; + let svg = code + .render::() + .quiet_zone(true) + .build(); + Ok(svg) + } +} diff --git a/src/crates/core/src/service/remote_connect/relay_client.rs b/src/crates/core/src/service/remote_connect/relay_client.rs new file mode 100644 index 00000000..325a06a4 --- /dev/null +++ b/src/crates/core/src/service/remote_connect/relay_client.rs @@ -0,0 +1,280 @@ +//! WebSocket client for connecting to the Relay Server. +//! +//! Manages the desktop-side WebSocket connection, sends/receives relay protocol messages, +//! and dispatches events to the pairing and session bridge layers. + +use anyhow::{anyhow, Result}; +use futures_util::{SinkExt, StreamExt}; +use log::{debug, error, info, warn}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::{mpsc, RwLock}; +use tokio_tungstenite::tungstenite::Message; + +/// Messages in the relay protocol (both directions). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RelayMessage { + CreateRoom { + room_id: Option, + device_id: String, + device_type: String, + public_key: String, + }, + RoomCreated { + room_id: String, + }, + JoinRoom { + room_id: String, + device_id: String, + device_type: String, + public_key: String, + }, + PeerJoined { + device_id: String, + device_type: String, + public_key: String, + }, + Relay { + room_id: String, + encrypted_data: String, + nonce: String, + }, + Heartbeat, + HeartbeatAck, + PeerDisconnected { + device_id: String, + }, + Error { + message: String, + }, +} + +/// Events emitted by the relay client to the upper layers. +#[derive(Debug, Clone)] +pub enum RelayEvent { + Connected, + RoomCreated { room_id: String }, + PeerJoined { public_key: String, device_id: String }, + MessageReceived { encrypted_data: String, nonce: String }, + PeerDisconnected { device_id: String }, + Disconnected, + Error { message: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionState { + Disconnected, + Connecting, + Connected, + Reconnecting, +} + +pub struct RelayClient { + state: Arc>, + event_tx: mpsc::UnboundedSender, + cmd_tx: Arc>>>, + room_id: Arc>>, +} + +impl RelayClient { + pub fn new() -> (Self, mpsc::UnboundedReceiver) { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let client = Self { + state: Arc::new(RwLock::new(ConnectionState::Disconnected)), + event_tx, + cmd_tx: Arc::new(RwLock::new(None)), + room_id: Arc::new(RwLock::new(None)), + }; + (client, event_rx) + } + + pub async fn connection_state(&self) -> ConnectionState { + self.state.read().await.clone() + } + + /// Connect to the relay server WebSocket endpoint. + pub async fn connect(&self, ws_url: &str) -> Result<()> { + *self.state.write().await = ConnectionState::Connecting; + + let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url) + .await + .map_err(|e| anyhow!("WebSocket connect failed: {e}"))?; + + info!("Connected to relay server at {ws_url}"); + *self.state.write().await = ConnectionState::Connected; + let _ = self.event_tx.send(RelayEvent::Connected); + + let (mut write, mut read) = ws_stream.split(); + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); + *self.cmd_tx.write().await = Some(cmd_tx); + + let event_tx = self.event_tx.clone(); + let state = self.state.clone(); + let room_id_store = self.room_id.clone(); + + // Read task + let read_state = state.clone(); + let read_event_tx = event_tx.clone(); + let read_room_id = room_id_store.clone(); + tokio::spawn(async move { + while let Some(msg_result) = read.next().await { + match msg_result { + Ok(Message::Text(text)) => { + match serde_json::from_str::(&text) { + Ok(relay_msg) => { + Self::handle_message( + relay_msg, + &read_event_tx, + &read_room_id, + ) + .await; + } + Err(e) => { + warn!("Failed to parse relay message: {e}"); + } + } + } + Ok(Message::Ping(_)) => {} + Ok(Message::Close(_)) => { + info!("Relay server closed connection"); + break; + } + Err(e) => { + error!("WebSocket read error: {e}"); + break; + } + _ => {} + } + } + *read_state.write().await = ConnectionState::Disconnected; + let _ = read_event_tx.send(RelayEvent::Disconnected); + }); + + // Write task + tokio::spawn(async move { + while let Some(msg) = cmd_rx.recv().await { + match serde_json::to_string(&msg) { + Ok(json) => { + if let Err(e) = write.send(Message::Text(json)).await { + error!("WebSocket write error: {e}"); + break; + } + } + Err(e) => { + error!("Failed to serialize relay message: {e}"); + } + } + } + }); + + // Heartbeat task + let hb_cmd_tx = self.cmd_tx.clone(); + let hb_state = self.state.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + if *hb_state.read().await != ConnectionState::Connected { + break; + } + if let Some(tx) = hb_cmd_tx.read().await.as_ref() { + let _ = tx.send(RelayMessage::Heartbeat); + } + } + }); + + Ok(()) + } + + async fn handle_message( + msg: RelayMessage, + event_tx: &mpsc::UnboundedSender, + room_id_store: &Arc>>, + ) { + match msg { + RelayMessage::RoomCreated { room_id } => { + debug!("Room created: {room_id}"); + *room_id_store.write().await = Some(room_id.clone()); + let _ = event_tx.send(RelayEvent::RoomCreated { room_id }); + } + RelayMessage::PeerJoined { + device_id, + public_key, + .. + } => { + info!("Peer joined: {device_id}"); + let _ = event_tx.send(RelayEvent::PeerJoined { + public_key, + device_id, + }); + } + RelayMessage::Relay { + encrypted_data, + nonce, + .. + } => { + let _ = event_tx.send(RelayEvent::MessageReceived { + encrypted_data, + nonce, + }); + } + RelayMessage::PeerDisconnected { device_id } => { + info!("Peer disconnected: {device_id}"); + let _ = event_tx.send(RelayEvent::PeerDisconnected { device_id }); + } + RelayMessage::HeartbeatAck => { + debug!("Heartbeat acknowledged"); + } + RelayMessage::Error { message } => { + error!("Relay error: {message}"); + let _ = event_tx.send(RelayEvent::Error { message }); + } + _ => {} + } + } + + /// Send a protocol message to the relay server. + pub async fn send(&self, msg: RelayMessage) -> Result<()> { + let guard = self.cmd_tx.read().await; + let tx = guard + .as_ref() + .ok_or_else(|| anyhow!("not connected"))?; + tx.send(msg) + .map_err(|e| anyhow!("send failed: {e}"))?; + Ok(()) + } + + /// Create a room on the relay server, optionally with a client-specified room ID. + pub async fn create_room(&self, device_id: &str, public_key: &str, room_id: Option<&str>) -> Result<()> { + self.send(RelayMessage::CreateRoom { + room_id: room_id.map(|s| s.to_string()), + device_id: device_id.to_string(), + device_type: "desktop".to_string(), + public_key: public_key.to_string(), + }) + .await + } + + /// Send an E2E encrypted message through the relay. + pub async fn send_encrypted( + &self, + room_id: &str, + encrypted_data: &str, + nonce: &str, + ) -> Result<()> { + self.send(RelayMessage::Relay { + room_id: room_id.to_string(), + encrypted_data: encrypted_data.to_string(), + nonce: nonce.to_string(), + }) + .await + } + + pub async fn disconnect(&self) { + // Drop the command sender to make the write task exit, + // which in turn closes the underlying WebSocket. + *self.cmd_tx.write().await = None; + *self.state.write().await = ConnectionState::Disconnected; + info!("Relay client disconnected (cmd channel dropped)"); + } +} diff --git a/src/crates/core/src/service/remote_connect/session_bridge.rs b/src/crates/core/src/service/remote_connect/session_bridge.rs new file mode 100644 index 00000000..0e5c600b --- /dev/null +++ b/src/crates/core/src/service/remote_connect/session_bridge.rs @@ -0,0 +1,693 @@ +//! Session bridge: translates remote commands into local session operations. +//! +//! The mobile client sends encrypted commands (list sessions, send message, etc.) +//! which are decrypted and dispatched to the local SessionManager via the global +//! ConversationCoordinator. +//! +//! After a SendMessage command, a `RemoteEventForwarder` is registered as an +//! internal event subscriber so that streaming progress (text chunks, tool events, +//! turn completion, etc.) is encrypted and relayed back to the mobile client. + +use anyhow::{anyhow, Result}; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::mpsc; + +use super::encryption; + +/// Commands that the mobile client can send to the desktop. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum RemoteCommand { + GetWorkspaceInfo, + ListRecentWorkspaces, + SetWorkspace { + path: String, + }, + ListSessions, + CreateSession { + agent_type: Option, + session_name: Option, + }, + GetSessionMessages { + session_id: String, + }, + SendMessage { + session_id: String, + content: String, + }, + CancelTask { + session_id: String, + }, + DeleteSession { + session_id: String, + }, + Ping, +} + +/// Responses sent from desktop back to mobile. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "resp", rename_all = "snake_case")] +pub enum RemoteResponse { + WorkspaceInfo { + has_workspace: bool, + path: Option, + project_name: Option, + git_branch: Option, + }, + RecentWorkspaces { + workspaces: Vec, + }, + WorkspaceUpdated { + success: bool, + path: Option, + project_name: Option, + error: Option, + }, + SessionList { + sessions: Vec, + }, + SessionCreated { + session_id: String, + }, + Messages { + session_id: String, + messages: Vec, + }, + MessageSent { + session_id: String, + turn_id: String, + }, + StreamEvent { + session_id: String, + event_type: String, + payload: serde_json::Value, + }, + TaskCancelled { + session_id: String, + }, + SessionDeleted { + session_id: String, + }, + Pong, + Error { + message: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub session_id: String, + pub name: String, + pub agent_type: String, + pub created_at: String, + pub updated_at: String, + pub message_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub id: String, + pub role: String, + pub content: String, + pub timestamp: String, + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentWorkspaceEntry { + pub path: String, + pub name: String, + pub last_opened: String, +} + +/// An encrypted (data, nonce) pair ready to be sent over the relay. +pub type EncryptedPayload = (String, String); + +/// Map mobile-friendly agent type names to the actual agent registry IDs. +fn resolve_agent_type(mobile_type: Option<&str>) -> &'static str { + match mobile_type { + Some("code") | Some("agentic") => "agentic", + Some("cowork") | Some("Cowork") => "Cowork", + _ => "agentic", + } +} + +/// Bridges remote commands to local session operations. +pub struct SessionBridge { + shared_secret: [u8; 32], +} + +impl SessionBridge { + pub fn new(shared_secret: [u8; 32]) -> Self { + Self { shared_secret } + } + + pub fn shared_secret(&self) -> &[u8; 32] { + &self.shared_secret + } + + pub fn decrypt_command( + &self, + encrypted_data: &str, + nonce: &str, + ) -> Result<(RemoteCommand, Option)> { + let json = encryption::decrypt_from_base64(&self.shared_secret, encrypted_data, nonce)?; + let value: Value = serde_json::from_str(&json).map_err(|e| anyhow!("parse json: {e}"))?; + let request_id = value + .get("_request_id") + .and_then(|v| v.as_str()) + .map(String::from); + let cmd: RemoteCommand = + serde_json::from_value(value).map_err(|e| anyhow!("parse command: {e}"))?; + Ok((cmd, request_id)) + } + + pub fn encrypt_response( + &self, + response: &RemoteResponse, + request_id: Option<&str>, + ) -> Result { + let mut value = + serde_json::to_value(response).map_err(|e| anyhow!("serialize response: {e}"))?; + if let (Some(id), Some(obj)) = (request_id, value.as_object_mut()) { + obj.insert("_request_id".to_string(), Value::String(id.to_string())); + } + let json = serde_json::to_string(&value).map_err(|e| anyhow!("to_string: {e}"))?; + encryption::encrypt_to_base64(&self.shared_secret, &json) + } + + pub async fn dispatch(&self, cmd: &RemoteCommand) -> RemoteResponse { + use crate::agentic::{coordination::get_global_coordinator, core::SessionConfig}; + use crate::infrastructure::get_workspace_path; + use crate::service::workspace::get_global_workspace_service; + + match cmd { + RemoteCommand::Ping => return RemoteResponse::Pong, + + RemoteCommand::GetWorkspaceInfo => { + let ws_path = get_workspace_path(); + let (project_name, git_branch) = if let Some(ref p) = ws_path { + let name = p + .file_name() + .map(|n| n.to_string_lossy().to_string()); + let branch = git2::Repository::open(p) + .ok() + .and_then(|repo| { + repo.head() + .ok() + .and_then(|h| h.shorthand().map(String::from)) + }); + (name, branch) + } else { + (None, None) + }; + return RemoteResponse::WorkspaceInfo { + has_workspace: ws_path.is_some(), + path: ws_path.map(|p| p.to_string_lossy().to_string()), + project_name, + git_branch, + }; + } + + RemoteCommand::ListRecentWorkspaces => { + let ws_service = match get_global_workspace_service() { + Some(s) => s, + None => { + return RemoteResponse::RecentWorkspaces { + workspaces: vec![], + }; + } + }; + let recent = ws_service.get_recent_workspaces().await; + let entries = recent + .into_iter() + .map(|w| RecentWorkspaceEntry { + path: w.root_path.to_string_lossy().to_string(), + name: w.name.clone(), + last_opened: w.last_accessed.to_rfc3339(), + }) + .collect(); + return RemoteResponse::RecentWorkspaces { + workspaces: entries, + }; + } + + RemoteCommand::SetWorkspace { path } => { + let ws_service = match get_global_workspace_service() { + Some(s) => s, + None => { + return RemoteResponse::WorkspaceUpdated { + success: false, + path: None, + project_name: None, + error: Some("Workspace service not available".into()), + }; + } + }; + let path_buf = std::path::PathBuf::from(path); + match ws_service.open_workspace(path_buf).await { + Ok(info) => { + if let Err(e) = + crate::service::snapshot::initialize_global_snapshot_manager( + info.root_path.clone(), + None, + ) + .await + { + error!("Failed to initialize snapshot after remote workspace set: {e}"); + } + return RemoteResponse::WorkspaceUpdated { + success: true, + path: Some(info.root_path.to_string_lossy().to_string()), + project_name: Some(info.name.clone()), + error: None, + }; + } + Err(e) => { + return RemoteResponse::WorkspaceUpdated { + success: false, + path: None, + project_name: None, + error: Some(e.to_string()), + }; + } + } + } + + _ => {} + } + + let coordinator = match get_global_coordinator() { + Some(c) => c, + None => { + return RemoteResponse::Error { + message: "Desktop session system not ready".into(), + }; + } + }; + + match cmd { + RemoteCommand::ListSessions => match coordinator.list_sessions().await { + Ok(summaries) => { + let sessions = summaries + .into_iter() + .map(|s| { + let created = s + .created_at + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + let updated = s + .last_activity_at + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + SessionInfo { + session_id: s.session_id, + name: s.session_name, + agent_type: s.agent_type, + created_at: created, + updated_at: updated, + message_count: s.turn_count, + } + }) + .collect(); + RemoteResponse::SessionList { sessions } + } + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + }, + + RemoteCommand::CreateSession { + agent_type, + session_name: custom_name, + } => { + let agent = resolve_agent_type(agent_type.as_deref()); + let session_name = custom_name + .as_deref() + .filter(|n| !n.is_empty()) + .unwrap_or(match agent { + "Cowork" => "Remote Cowork Session", + _ => "Remote Code Session", + }); + match coordinator + .create_session( + session_name.to_string(), + agent.to_string(), + SessionConfig::default(), + ) + .await + { + Ok(session) => RemoteResponse::SessionCreated { + session_id: session.session_id, + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + } + } + + RemoteCommand::GetSessionMessages { session_id } => { + match coordinator.get_messages(session_id).await { + Ok(messages) => { + let chat_msgs = messages + .into_iter() + .map(|m| { + use crate::agentic::core::MessageRole; + let role = match m.role { + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::Tool => "tool", + MessageRole::System => "system", + }; + let content = match &m.content { + crate::agentic::core::MessageContent::Text(t) => t.clone(), + crate::agentic::core::MessageContent::Mixed { + text, .. + } => text.clone(), + crate::agentic::core::MessageContent::ToolResult { + result_for_assistant, + result, + .. + } => result_for_assistant + .clone() + .unwrap_or_else(|| result.to_string()), + }; + let ts = m + .timestamp + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + ChatMessage { + id: m.id.clone(), + role: role.to_string(), + content, + timestamp: ts, + metadata: None, + } + }) + .collect(); + RemoteResponse::Messages { + session_id: session_id.clone(), + messages: chat_msgs, + } + } + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + } + } + + RemoteCommand::SendMessage { + session_id, + content, + } => { + let session_mgr = coordinator.get_session_manager(); + let agent_type = session_mgr + .get_session(session_id) + .map(|s| s.agent_type.clone()) + .unwrap_or_else(|| "default".to_string()); + + info!("Remote send_message: session={session_id}"); + let turn_id = format!("turn_{}", chrono::Utc::now().timestamp_millis()); + match coordinator + .start_dialog_turn( + session_id.clone(), + content.clone(), + Some(turn_id.clone()), + agent_type, + ) + .await + { + Ok(()) => RemoteResponse::MessageSent { + session_id: session_id.clone(), + turn_id, + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + } + } + + RemoteCommand::CancelTask { session_id } => { + let session_mgr = coordinator.get_session_manager(); + if let Some(session) = session_mgr.get_session(session_id) { + use crate::agentic::core::SessionState; + let _ = session_mgr + .update_session_state(session_id, SessionState::Idle) + .await; + if let Some(last_turn_id) = session.dialog_turn_ids.last() { + let _ = coordinator.cancel_dialog_turn(session_id, last_turn_id).await; + } + } + RemoteResponse::TaskCancelled { + session_id: session_id.clone(), + } + } + + RemoteCommand::DeleteSession { session_id } => { + match coordinator.delete_session(session_id).await { + Ok(_) => RemoteResponse::SessionDeleted { + session_id: session_id.clone(), + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + } + } + + _ => RemoteResponse::Error { + message: "Unknown command".into(), + }, + } + } +} + +// ── Stream event forwarding ────────────────────────────────────── + +/// Converts `AgenticEvent`s for a specific session into encrypted relay +/// payloads and sends them through a channel. +pub struct RemoteEventForwarder { + target_session_id: String, + shared_secret: [u8; 32], + payload_tx: mpsc::UnboundedSender, +} + +impl RemoteEventForwarder { + pub fn new( + target_session_id: String, + shared_secret: [u8; 32], + payload_tx: mpsc::UnboundedSender, + ) -> Self { + Self { + target_session_id, + shared_secret, + payload_tx, + } + } + + fn try_forward(&self, event: &crate::agentic::events::AgenticEvent) { + use bitfun_events::AgenticEvent as AE; + + let session_id = match event.session_id() { + Some(id) if id == self.target_session_id => id.to_string(), + _ => return, + }; + + let (event_type, payload) = match event { + AE::TextChunk { text, turn_id, .. } => ( + "text_chunk", + serde_json::json!({ "text": text, "turn_id": turn_id }), + ), + AE::ThinkingChunk { + content, turn_id, .. + } => ( + "thinking_chunk", + serde_json::json!({ "content": content, "turn_id": turn_id }), + ), + AE::ToolEvent { + tool_event, + turn_id, + .. + } => ( + "tool_event", + serde_json::json!({ + "turn_id": turn_id, + "tool_event": serde_json::to_value(tool_event).unwrap_or_default(), + }), + ), + AE::DialogTurnStarted { + turn_id, + user_input, + .. + } => ( + "stream_start", + serde_json::json!({ "turn_id": turn_id, "user_input": user_input }), + ), + AE::DialogTurnCompleted { + turn_id, + total_rounds, + duration_ms, + .. + } => ( + "stream_end", + serde_json::json!({ + "turn_id": turn_id, + "total_rounds": total_rounds, + "duration_ms": duration_ms, + }), + ), + AE::DialogTurnFailed { + turn_id, error, .. + } => ( + "stream_error", + serde_json::json!({ "turn_id": turn_id, "error": error }), + ), + AE::DialogTurnCancelled { turn_id, .. } => ( + "stream_cancelled", + serde_json::json!({ "turn_id": turn_id }), + ), + AE::ModelRoundStarted { + turn_id, + round_index, + .. + } => ( + "round_started", + serde_json::json!({ "turn_id": turn_id, "round_index": round_index }), + ), + AE::ModelRoundCompleted { + turn_id, + has_tool_calls, + .. + } => ( + "round_completed", + serde_json::json!({ "turn_id": turn_id, "has_tool_calls": has_tool_calls }), + ), + AE::SessionStateChanged { new_state, .. } => ( + "session_state_changed", + serde_json::json!({ "new_state": new_state }), + ), + AE::SessionTitleGenerated { title, .. } => ( + "session_title", + serde_json::json!({ "title": title }), + ), + _ => return, + }; + + let resp = RemoteResponse::StreamEvent { + session_id, + event_type: event_type.to_string(), + payload, + }; + + match encryption::encrypt_to_base64( + &self.shared_secret, + &serde_json::to_string(&resp).unwrap_or_default(), + ) { + Ok(encrypted) => { + let _ = self.payload_tx.send(encrypted); + } + Err(e) => { + error!("Failed to encrypt stream event: {e}"); + } + } + } +} + +#[async_trait::async_trait] +impl crate::agentic::events::EventSubscriber for RemoteEventForwarder { + async fn on_event( + &self, + event: &crate::agentic::events::AgenticEvent, + ) -> crate::util::errors::BitFunResult<()> { + self.try_forward(event); + Ok(()) + } +} + +/// Register a forwarder for a session. Returns the subscriber_id (for later unsubscription) +/// and the receiving end of the encrypted payload channel. +pub fn register_stream_forwarder( + session_id: &str, + shared_secret: [u8; 32], +) -> Option<(String, mpsc::UnboundedReceiver)> { + use crate::agentic::coordination::get_global_coordinator; + + let coordinator = get_global_coordinator()?; + let (tx, rx) = mpsc::unbounded_channel(); + let subscriber_id = format!("remote_stream_{}", session_id); + + let forwarder = RemoteEventForwarder::new(session_id.to_string(), shared_secret, tx); + + coordinator.subscribe_internal(subscriber_id.clone(), forwarder); + info!("Registered remote stream forwarder: {subscriber_id}"); + Some((subscriber_id, rx)) +} + +/// Unregister a previously registered forwarder. +pub fn unregister_stream_forwarder(subscriber_id: &str) { + use crate::agentic::coordination::get_global_coordinator; + + if let Some(coordinator) = get_global_coordinator() { + coordinator.unsubscribe_internal(subscriber_id); + info!("Unregistered remote stream forwarder: {subscriber_id}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::remote_connect::encryption::KeyPair; + + #[test] + fn test_command_round_trip() { + let alice = KeyPair::generate(); + let bob = KeyPair::generate(); + let shared = alice.derive_shared_secret(&bob.public_key_bytes()); + + let bridge = SessionBridge::new(shared); + + let cmd_json = serde_json::json!({ + "cmd": "send_message", + "session_id": "sess-123", + "content": "Hello from mobile!", + "_request_id": "req_abc" + }); + let json = cmd_json.to_string(); + let (enc, nonce) = encryption::encrypt_to_base64(&shared, &json).unwrap(); + let (decoded, req_id) = bridge.decrypt_command(&enc, &nonce).unwrap(); + + assert_eq!(req_id.as_deref(), Some("req_abc")); + if let RemoteCommand::SendMessage { + session_id, + content, + } = decoded + { + assert_eq!(session_id, "sess-123"); + assert_eq!(content, "Hello from mobile!"); + } else { + panic!("unexpected command variant"); + } + } + + #[test] + fn test_response_with_request_id() { + let alice = KeyPair::generate(); + let shared = alice.derive_shared_secret(&alice.public_key_bytes()); + let bridge = SessionBridge::new(shared); + + let resp = RemoteResponse::Pong; + let (enc, nonce) = bridge.encrypt_response(&resp, Some("req_xyz")).unwrap(); + + let json = encryption::decrypt_from_base64(&shared, &enc, &nonce).unwrap(); + let value: Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["resp"], "pong"); + assert_eq!(value["_request_id"], "req_xyz"); + } +} diff --git a/src/crates/core/src/service/workspace/mod.rs b/src/crates/core/src/service/workspace/mod.rs index f01c0e55..59898366 100644 --- a/src/crates/core/src/service/workspace/mod.rs +++ b/src/crates/core/src/service/workspace/mod.rs @@ -22,7 +22,7 @@ pub use manager::{ }; pub use provider::{WorkspaceCleanupResult, WorkspaceProvider, WorkspaceSystemSummary}; pub use service::{ - BatchImportResult, BatchRemoveResult, WorkspaceCreateOptions, WorkspaceExport, - WorkspaceHealthStatus, WorkspaceImportResult, WorkspaceInfoUpdates, WorkspaceQuickSummary, - WorkspaceService, + get_global_workspace_service, set_global_workspace_service, BatchImportResult, + BatchRemoveResult, WorkspaceCreateOptions, WorkspaceExport, WorkspaceHealthStatus, + WorkspaceImportResult, WorkspaceInfoUpdates, WorkspaceQuickSummary, WorkspaceService, }; diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index d64ea4a2..be5d45f5 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -759,3 +759,19 @@ struct WorkspacePersistenceData { pub recent_workspaces: Vec, pub saved_at: chrono::DateTime, } + +// ── Global workspace service singleton ────────────────────────────── + +static GLOBAL_WORKSPACE_SERVICE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +pub fn set_global_workspace_service(service: Arc) { + match GLOBAL_WORKSPACE_SERVICE.set(service) { + Ok(_) => info!("Global workspace service set"), + Err(_) => info!("Global workspace service already exists, skipping set"), + } +} + +pub fn get_global_workspace_service() -> Option> { + GLOBAL_WORKSPACE_SERVICE.get().cloned() +} diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/transport/src/adapters/tauri.rs index 893fdfcf..b7ef9899 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/transport/src/adapters/tauri.rs @@ -41,6 +41,18 @@ impl fmt::Debug for TauriTransportAdapter { impl TransportAdapter for TauriTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { match event { + AgenticEvent::SessionCreated { session_id, session_name, agent_type } => { + self.app_handle.emit("agentic://session-created", json!({ + "sessionId": session_id, + "sessionName": session_name, + "agentType": agent_type, + }))?; + } + AgenticEvent::SessionDeleted { session_id } => { + self.app_handle.emit("agentic://session-deleted", json!({ + "sessionId": session_id, + }))?; + } AgenticEvent::DialogTurnStarted { session_id, turn_id, subagent_parent_info, .. } => { self.app_handle.emit("agentic://dialog-turn-started", json!({ "sessionId": session_id, diff --git a/src/mobile-web/index.html b/src/mobile-web/index.html new file mode 100644 index 00000000..e5bccbd0 --- /dev/null +++ b/src/mobile-web/index.html @@ -0,0 +1,17 @@ + + + + + + + BitFun Remote + + + +
+ + + diff --git a/src/mobile-web/package-lock.json b/src/mobile-web/package-lock.json new file mode 100644 index 00000000..c787f14b --- /dev/null +++ b/src/mobile-web/package-lock.json @@ -0,0 +1,3724 @@ +{ + "name": "bitfun-mobile-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bitfun-mobile-web", + "version": "0.1.0", + "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.0", + "react-syntax-highlighter": "^15.5.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/react-syntax-highlighter": "^15.5.0", + "@vitejs/plugin-react": "^4.2.0", + "sass": "^1.69.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "ws": "^8.19.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/hastscript/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/hastscript/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hastscript/node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hastscript/node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-syntax-highlighter": { + "version": "15.6.6", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", + "integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^3.6.0" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/refractor": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", + "license": "MIT", + "dependencies": { + "hastscript": "^6.0.0", + "parse-entities": "^2.0.0", + "prismjs": "~1.27.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/prismjs": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", + "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json new file mode 100644 index 00000000..7e3a36c7 --- /dev/null +++ b/src/mobile-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "bitfun-mobile-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^9.0.0", + "react-syntax-highlighter": "^15.5.0", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/react-syntax-highlighter": "^15.5.0", + "@vitejs/plugin-react": "^4.2.0", + "sass": "^1.69.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "ws": "^8.19.0" + } +} diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx new file mode 100644 index 00000000..c3f4c0be --- /dev/null +++ b/src/mobile-web/src/App.tsx @@ -0,0 +1,69 @@ +import React, { useState, useCallback, useRef } from 'react'; +import PairingPage from './pages/PairingPage'; +import WorkspacePage from './pages/WorkspacePage'; +import SessionListPage from './pages/SessionListPage'; +import ChatPage from './pages/ChatPage'; +import { RelayConnection } from './services/RelayConnection'; +import { RemoteSessionManager } from './services/RemoteSessionManager'; +import './styles/mobile.scss'; + +type Page = 'pairing' | 'workspace' | 'sessions' | 'chat'; + +const App: React.FC = () => { + const [page, setPage] = useState('pairing'); + const [activeSessionId, setActiveSessionId] = useState(null); + const relayRef = useRef(null); + const sessionMgrRef = useRef(null); + + const handlePaired = useCallback((relay: RelayConnection, sessionMgr: RemoteSessionManager) => { + relayRef.current = relay; + sessionMgrRef.current = sessionMgr; + + relay.setMessageHandler((json: string) => { + sessionMgr.handleMessage(json); + }); + + setPage('workspace'); + }, []); + + const handleWorkspaceReady = useCallback(() => { + setPage('sessions'); + }, []); + + const handleSelectSession = useCallback((sessionId: string) => { + setActiveSessionId(sessionId); + setPage('chat'); + }, []); + + const handleBackToSessions = useCallback(() => { + setActiveSessionId(null); + setPage('sessions'); + }, []); + + return ( +
+ {page === 'pairing' && } + {page === 'workspace' && sessionMgrRef.current && ( + + )} + {page === 'sessions' && sessionMgrRef.current && ( + + )} + {page === 'chat' && sessionMgrRef.current && activeSessionId && ( + + )} +
+ ); +}; + +export default App; diff --git a/src/mobile-web/src/main.tsx b/src/mobile-web/src/main.tsx new file mode 100644 index 00000000..c018515c --- /dev/null +++ b/src/mobile-web/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx new file mode 100644 index 00000000..dcab490f --- /dev/null +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { RemoteSessionManager } from '../services/RemoteSessionManager'; +import { useMobileStore } from '../services/store'; + +interface ChatPageProps { + sessionMgr: RemoteSessionManager; + sessionId: string; + onBack: () => void; +} + +const ChatPage: React.FC = ({ sessionMgr, sessionId, onBack }) => { + const { + getMessages, + setMessages, + appendMessage, + updateLastMessage, + isStreaming, + setIsStreaming, + setError, + } = useMobileStore(); + + const messages = getMessages(sessionId); + const [input, setInput] = useState(''); + const bottomRef = useRef(null); + const inputRef = useRef(null); + // Track accumulated text for the current streaming assistant message + const accumulatedTextRef = useRef(''); + + const loadMessages = useCallback(async () => { + try { + const msgs = await sessionMgr.getSessionMessages(sessionId); + setMessages(sessionId, msgs); + } catch (e: any) { + setError(e.message); + } + }, [sessionMgr, sessionId, setMessages, setError]); + + useEffect(() => { + loadMessages(); + + const unsub = sessionMgr.onStreamEvent((event) => { + if (event.session_id !== sessionId) return; + + const eventType = event.event_type; + + if (eventType === 'stream_start') { + setIsStreaming(true); + accumulatedTextRef.current = ''; + appendMessage(sessionId, { + id: `stream-${Date.now()}`, + role: 'assistant', + content: '', + timestamp: new Date().toISOString(), + }); + } else if (eventType === 'text_chunk') { + const chunk = event.payload?.text || ''; + accumulatedTextRef.current += chunk; + updateLastMessage(sessionId, accumulatedTextRef.current); + } else if (eventType === 'thinking_chunk') { + // Optionally show thinking content + const chunk = event.payload?.content || ''; + accumulatedTextRef.current += chunk; + updateLastMessage(sessionId, accumulatedTextRef.current); + } else if (eventType === 'stream_end') { + setIsStreaming(false); + accumulatedTextRef.current = ''; + } else if (eventType === 'stream_error') { + setIsStreaming(false); + setError(event.payload?.error || 'Stream error'); + accumulatedTextRef.current = ''; + } else if (eventType === 'stream_cancelled') { + setIsStreaming(false); + accumulatedTextRef.current = ''; + } else if (eventType === 'tool_event') { + // Append tool activity as a brief system message + const toolEvt = event.payload?.tool_event; + if (toolEvt?.event_type === 'Started') { + const toolInfo = `[Tool: ${toolEvt.tool_name}]`; + accumulatedTextRef.current += `\n\n${toolInfo}\n`; + updateLastMessage(sessionId, accumulatedTextRef.current); + } + } else if (eventType === 'session_title') { + // Title was generated; could update session list + } + }); + + return unsub; + }, [sessionId, sessionMgr, setIsStreaming, appendMessage, updateLastMessage, setError, loadMessages]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const handleSend = useCallback(async () => { + const text = input.trim(); + if (!text || isStreaming) return; + + setInput(''); + appendMessage(sessionId, { + id: `user-${Date.now()}`, + role: 'user', + content: text, + timestamp: new Date().toISOString(), + }); + + try { + await sessionMgr.sendMessage(sessionId, text); + } catch (e: any) { + setError(e.message); + } + }, [input, isStreaming, sessionId, sessionMgr, appendMessage, setError]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleCancel = async () => { + try { + await sessionMgr.cancelTask(sessionId); + } catch { + // best effort + } + }; + + return ( +
+
+ + Session + {isStreaming && ( + + )} +
+ +
+ {messages.map((m) => ( +
+
{m.role === 'user' ? 'You' : 'BitFun'}
+
+ + {codeStr} + + ) : ( + + {children} + + ); + }, + }} + > + {m.content} + +
+
+ ))} +
+
+ +
+