chore: remove openclaw integration on desktop app

Retain the cli launch openclaw

# Conflicts:
#	extensions/yarn.lock
#	src-tauri/Cargo.toml
#	src-tauri/src/core/app/commands.rs
#	src-tauri/src/lib.rs
#	web-app/src/components/left-sidebar/NavMain.tsx
#	yarn.lock
This commit is contained in:
Vanalite
2026-03-17 16:16:05 +07:00
parent ca3664de4b
commit 841ab1bb91
70 changed files with 68 additions and 16671 deletions

View File

@@ -342,7 +342,7 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=4d03b5&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6c8a42&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
@@ -354,7 +354,7 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=4d03b5&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6c8a42&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
@@ -366,19 +366,7 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=4d03b5&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
peerDependencies:
react: 19.0.0
checksum: 10c0/c80736877d9b0d9498d76588a1e92664f467a84265aee7cb6be497b89ed4b1e1c9379c12686c4676bb438eb268ba97caae607b01686e3dc0d485933a4ab69af7
languageName: node
linkType: hard
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Ffoundation-models-extension%40workspace%3Afoundation-models-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=4d03b5&locator=%40janhq%2Ffoundation-models-extension%40workspace%3Afoundation-models-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6c8a42&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
@@ -390,7 +378,7 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=4d03b5&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6c8a42&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
@@ -402,7 +390,7 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fmlx-extension%40workspace%3Amlx-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=4d03b5&locator=%40janhq%2Fmlx-extension%40workspace%3Amlx-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6c8a42&locator=%40janhq%2Fmlx-extension%40workspace%3Amlx-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
@@ -414,7 +402,7 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=4d03b5&locator=%40janhq%2Frag-extension%40workspace%3Arag-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6c8a42&locator=%40janhq%2Frag-extension%40workspace%3Arag-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
@@ -426,7 +414,7 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension":
version: 0.1.10
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=4d03b5&locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension"
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6c8a42&locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension"
dependencies:
rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0"
@@ -451,21 +439,6 @@ __metadata:
languageName: unknown
linkType: soft
"@janhq/foundation-models-extension@workspace:foundation-models-extension":
version: 0.0.0-use.local
resolution: "@janhq/foundation-models-extension@workspace:foundation-models-extension"
dependencies:
"@janhq/core": ../../core/package.tgz
"@janhq/tauri-plugin-foundation-models-api": "link:../../src-tauri/plugins/tauri-plugin-foundation-models"
"@tauri-apps/api": "npm:2.8.0"
"@tauri-apps/plugin-log": "npm:^2.6.0"
cpx: "npm:1.5.0"
rimraf: "npm:3.0.2"
rolldown: "npm:1.0.0-beta.1"
typescript: "npm:5.9.2"
languageName: unknown
linkType: soft
"@janhq/llamacpp-extension@workspace:llamacpp-extension":
version: 0.0.0-use.local
resolution: "@janhq/llamacpp-extension@workspace:llamacpp-extension"
@@ -520,12 +493,6 @@ __metadata:
languageName: unknown
linkType: soft
"@janhq/tauri-plugin-foundation-models-api@link:../../src-tauri/plugins/tauri-plugin-foundation-models::locator=%40janhq%2Ffoundation-models-extension%40workspace%3Afoundation-models-extension":
version: 0.0.0-use.local
resolution: "@janhq/tauri-plugin-foundation-models-api@link:../../src-tauri/plugins/tauri-plugin-foundation-models::locator=%40janhq%2Ffoundation-models-extension%40workspace%3Afoundation-models-extension"
languageName: node
linkType: soft
"@janhq/tauri-plugin-hardware-api@link:../../src-tauri/plugins/tauri-plugin-hardware::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension":
version: 0.0.0-use.local
resolution: "@janhq/tauri-plugin-hardware-api@link:../../src-tauri/plugins/tauri-plugin-hardware::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"

116
src-tauri/Cargo.lock generated
View File

@@ -8,7 +8,6 @@ version = "0.6.599"
dependencies = [
"async-trait",
"base64 0.22.1",
"bollard",
"chrono",
"clap",
"console",
@@ -64,7 +63,6 @@ dependencies = [
"tempfile",
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
"tokio-util",
"url",
"uuid",
@@ -549,50 +547,6 @@ dependencies = [
"piper",
]
[[package]]
name = "bollard"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30"
dependencies = [
"base64 0.22.1",
"bollard-stubs",
"bytes",
"futures-core",
"futures-util",
"hex",
"http 1.3.1",
"http-body-util",
"hyper 1.7.0",
"hyper-named-pipe",
"hyper-util",
"hyperlocal",
"log",
"pin-project-lite",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"serde_urlencoded",
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tower-service",
"url",
"winapi",
]
[[package]]
name = "bollard-stubs"
version = "1.47.1-rc.27.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da"
dependencies = [
"serde",
"serde_repr",
"serde_with",
]
[[package]]
name = "borsh"
version = "1.5.7"
@@ -1334,12 +1288,6 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -2700,7 +2648,6 @@ dependencies = [
"http 1.3.1",
"http-body 1.0.1",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -2709,21 +2656,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-named-pipe"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278"
dependencies = [
"hex",
"hyper 1.7.0",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
"winapi",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
@@ -2794,21 +2726,6 @@ dependencies = [
"windows-registry",
]
[[package]]
name = "hyperlocal"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
dependencies = [
"hex",
"http-body-util",
"hyper 1.7.0",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "iana-time-zone"
version = "0.1.64"
@@ -7196,20 +7113,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -7424,25 +7327,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 1.3.1",
"httparse",
"log",
"native-tls",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "type1-encoding-parser"
version = "0.1.0"

View File

@@ -35,13 +35,11 @@ hardware = ["dep:tauri-plugin-hardware"]
deep-link = ["dep:tauri-plugin-deep-link"]
mlx = ["dep:tauri-plugin-mlx"]
foundation-models = ["dep:tauri-plugin-foundation-models"]
docker = ["dep:bollard"]
desktop = [
"deep-link",
"hardware",
"mlx",
"foundation-models",
"docker"
"foundation-models"
]
mobile = [
"tauri/protocol-asset",
@@ -122,14 +120,12 @@ ed25519-dalek = { version = "2.1", features = ["rand_core"] }
futures = "0.3"
hostname = "0.4"
base64 = "0.22"
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
glob = "0.3"
clap = { version = "4", features = ["derive"] }
dialoguer = { version = "0.11", optional = true }
env_logger = { version = "0.11", optional = true }
indicatif = { version = "0.17", optional = true }
console = { version = "0.15", optional = true }
bollard = { version = "0.18", optional = true }
[dependencies.tauri]
version = "2.8.5"

View File

@@ -5,7 +5,6 @@ pub mod downloads;
pub mod extensions;
pub mod filesystem;
pub mod mcp;
pub mod openclaw;
pub mod server;
pub mod setup;
pub mod state;

View File

@@ -1,254 +0,0 @@
use std::process::Stdio;
use tokio::process::Command;
use super::models::OpenClawStatus;
use super::{get_openclaw_config_dir, get_openclaw_config_path, OPENCLAW_PORT};
#[cfg(target_os = "windows")]
fn hide_window(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
#[cfg(not(target_os = "windows"))]
fn hide_window(_cmd: &mut Command) {}
/// Returns the BUN_INSTALL directory (`~/.jan/.bunx`), creating it if needed.
fn get_bunx_dir() -> Option<std::path::PathBuf> {
let dir = dirs::home_dir()?.join(".jan").join(".bunx");
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("Failed to create BUN_INSTALL dir {:?}: {}", dir, e);
}
Some(dir)
}
/// Build a command for openclaw. On Unix, uses bun as explicit interpreter
/// to bypass shebang resolution. On Windows, runs openclaw.exe directly.
fn build_openclaw_command(args: &[&str]) -> tokio::process::Command {
let bunx_dir = get_bunx_dir();
let installed_bin = bunx_dir.as_ref().map(|d| {
if cfg!(target_os = "windows") {
d.join("bin").join("openclaw.exe")
} else {
d.join("bin").join("openclaw")
}
});
let mut cmd = if installed_bin.as_ref().map(|p| p.exists()).unwrap_or(false) {
log::info!("Running openclaw from installed path: {:?}", installed_bin);
tokio::process::Command::new(installed_bin.unwrap())
} else if let Some(bun) = super::resolve_bundled_bun() {
log::info!("openclaw not installed yet, falling back to bun x");
let mut c = tokio::process::Command::new(bun);
c.arg("x");
c.arg("openclaw");
c
} else {
tokio::process::Command::new("openclaw")
};
cmd.args(args)
// .current_dir(config_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = bunx_dir {
cmd.env("BUN_INSTALL", dir);
}
// if let Some(new_path) = super::build_augmented_path() {
// cmd.env("PATH", new_path);
// }
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
cmd
}
async fn check_openclaw_installed() -> Result<Option<String>, String> {
if let Ok(openclaw_path) = super::get_openclaw_bin_path() {
if openclaw_path.exists() {
let bun_path = super::resolve_bundled_bun();
let mut cmd = if !cfg!(target_os = "windows") && bun_path.is_some() {
let mut c = Command::new(bun_path.unwrap());
c.arg(&openclaw_path);
c
} else {
Command::new(&openclaw_path)
};
cmd.arg("--version");
// if let Some(new_path) = super::build_augmented_path() {
// cmd.env("PATH", new_path);
// }
hide_window(&mut cmd);
let output = cmd.output().await;
if let Ok(output) = output {
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Ok(Some(version));
}
}
}
}
let mut cmd = Command::new("openclaw");
cmd.arg("--version");
// if let Some(new_path) = super::build_augmented_path() {
// cmd.env("PATH", new_path);
// }
hide_window(&mut cmd);
let output = cmd.output().await;
match output {
Ok(output) => {
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(Some(version))
} else {
Ok(None)
}
}
Err(_) => Ok(None),
}
}
async fn check_runtime_version() -> Option<String> {
if let Some(bun_path) = super::resolve_bundled_bun() {
let mut cmd = Command::new(&bun_path);
cmd.arg("--version");
hide_window(&mut cmd);
let output = cmd.output().await.ok()?;
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Some(format!("bun {}", version));
}
}
let mut cmd = Command::new("node");
cmd.arg("--version");
hide_window(&mut cmd);
let output = cmd.output().await.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
async fn check_port_available(port: u16) -> bool {
let addr = format!("127.0.0.1:{}", port);
tokio::net::TcpListener::bind(&addr).await.is_ok()
}
async fn check_gateway_running() -> bool {
!check_port_available(OPENCLAW_PORT).await
}
pub async fn get_status() -> Result<OpenClawStatus, String> {
let openclaw_version = check_openclaw_installed().await?;
let runtime_version = check_runtime_version().await;
let port_available = check_port_available(OPENCLAW_PORT).await;
let running = check_gateway_running().await;
Ok(OpenClawStatus {
installed: openclaw_version.is_some(),
running,
runtime_version,
openclaw_version,
port_available,
error: None,
sandbox_type: None,
isolation_tier: None,
})
}
pub async fn start_gateway() -> Result<(), String> {
if check_gateway_running().await {
return Err("OpenClaw gateway is already running".to_string());
}
let config_path = get_openclaw_config_path()?;
if !config_path.exists() {
return Err("OpenClaw is not configured. Run 'jan openclaw configure' first.".to_string());
}
let config_dir = get_openclaw_config_dir()?;
let mut child = build_openclaw_command(&["gateway", "start"])
.current_dir(&config_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start OpenClaw: {}", e))?;
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
return Err("OpenClaw gateway failed to start".to_string());
}
}
Ok(None) => {}
Err(e) => {
return Err(format!("Failed to check gateway status: {}", e));
}
}
Ok(())
}
pub async fn stop_gateway() -> Result<(), String> {
let output = build_openclaw_command(&["gateway", "stop"])
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
return Ok(());
}
}
#[cfg(unix)]
{
let mut cmd = Command::new("pkill");
cmd.args(["-f", "openclaw"]);
let _ = cmd.output().await;
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let kill = |im: &'static str| async move {
let mut cmd = Command::new("taskkill");
cmd.args(["/F", "/IM", im]);
cmd.creation_flags(0x08000000);
let _ = cmd.output().await;
};
kill("bun.exe").await;
kill("node.exe").await;
kill("openclaw.exe").await;
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
if check_gateway_running().await {
return Err("Failed to stop OpenClaw gateway".to_string());
}
Ok(())
}
pub async fn restart_gateway() -> Result<(), String> {
let _ = stop_gateway().await;
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
start_gateway().await
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
/// OpenClaw package name on npm
pub const OPENCLAW_PACKAGE_NAME: &str = "openclaw";
/// Pinned OpenClaw version (npm semver and Docker image tag)
/// This ensures config compatibility across installs.
pub const OPENCLAW_VERSION: &str = "2026.3.8";
/// Default OpenClaw gateway port
pub const DEFAULT_OPENCLAW_PORT: u16 = 18789;
/// Default Jan API base URL (direct process — same host)
pub const DEFAULT_JAN_BASE_URL: &str = "http://localhost:1337/v1";
/// Jan API base URL for Docker sandbox (container → host networking)
pub const DOCKER_JAN_BASE_URL: &str = "http://host.docker.internal:1337/v1";
/// Gateway bind mode — "lan" for all sandbox types.
pub const GATEWAY_BIND_MODE: &str = "lan";
/// Default API type for Jan
pub const DEFAULT_JAN_API_TYPE: &str = "openai-completions";
pub const DEFAULT_JAN_API_KEY: &str = "jan-local";
pub const DEFAULT_MODEL_ID: &str = "Jan-v3-4b-base-instruct-Q4_K_XL";

View File

@@ -1,87 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use tauri::Emitter;
use tokio::sync::Mutex;
use super::lifecycle::is_port_in_use;
use super::sandbox::{Sandbox, SandboxMode};
use super::OpenClawState;
const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(15);
const MAX_RESTART_ATTEMPTS: u32 = 3;
/// Spawn a background task that monitors the sandbox health.
/// Returns a JoinHandle that can be used to cancel the monitor.
pub fn spawn_health_monitor(
sandbox: Arc<Mutex<Option<Box<dyn Sandbox>>>>,
state: Arc<OpenClawState>,
app_handle: tauri::AppHandle,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut was_healthy = true;
let mut restart_count: u32 = 0;
loop {
tokio::time::sleep(HEALTH_CHECK_INTERVAL).await;
let mode = state.sandbox_mode.lock().await;
let is_active = matches!(*mode, SandboxMode::Active { .. });
drop(mode);
if !is_active {
// Sandbox is not active, nothing to monitor
was_healthy = true;
restart_count = 0;
continue;
}
// Check health by probing the port
let is_healthy = is_port_in_use(super::OPENCLAW_PORT).await;
if is_healthy && !was_healthy {
// Recovered
log::info!("OpenClaw health restored");
restart_count = 0;
let _ = app_handle.emit("openclaw-health-changed", "healthy");
} else if !is_healthy && was_healthy {
// Became unhealthy
log::warn!("OpenClaw health check failed — gateway not responding");
let _ = app_handle.emit("openclaw-health-changed", "unhealthy");
}
if !is_healthy && restart_count < MAX_RESTART_ATTEMPTS {
log::info!(
"Attempting auto-restart ({}/{})",
restart_count + 1,
MAX_RESTART_ATTEMPTS
);
// Attempt restart through the sandbox
let sandbox_guard = sandbox.lock().await;
if let Some(ref sandbox_impl) = *sandbox_guard {
let mut mode = state.sandbox_mode.lock().await;
if let SandboxMode::Active { ref mut handle, .. } = *mode {
// Stop, then restart is handled by the caller re-starting.
// For auto-restart, we just stop and let the next cycle detect it.
let _ = sandbox_impl.stop(handle).await;
}
*mode = SandboxMode::Inactive;
}
drop(sandbox_guard);
restart_count += 1;
if restart_count >= MAX_RESTART_ATTEMPTS {
log::error!(
"OpenClaw failed after {} restart attempts",
MAX_RESTART_ATTEMPTS
);
let _ = app_handle.emit("openclaw-failed", "max_restarts_exceeded");
}
}
was_healthy = is_healthy;
}
})
}

View File

@@ -1,270 +0,0 @@
use std::path::Path;
use std::time::Duration;
use super::sandbox::{
IsolationTier, Sandbox, SandboxConfig, SandboxMode, SandboxStatus,
};
use super::security::ensure_secure_config_permissions;
use super::{get_openclaw_config_dir, OpenClawState, OPENCLAW_PORT};
/// Build a SandboxConfig from the current OpenClaw configuration.
pub fn build_sandbox_config(jan_api_url: &str) -> Result<SandboxConfig, String> {
let config_dir = get_openclaw_config_dir()?;
Ok(SandboxConfig {
config_dir: config_dir.clone(),
port: OPENCLAW_PORT,
jan_api_url: jan_api_url.to_string(),
env_vars: vec![(
"OPENCLAW_CONFIG".into(),
config_dir
.join("openclaw.json")
.to_string_lossy()
.into_owned(),
)],
})
}
/// Start OpenClaw using the provided sandbox implementation.
pub async fn start_openclaw(
sandbox: &dyn Sandbox,
config: &SandboxConfig,
state: &OpenClawState,
) -> Result<(), String> {
ensure_secure_config_permissions(&config.config_dir).await?;
let _ = patch_config_for_sandbox(sandbox, &config.config_dir)?;
let handle = sandbox.start(config).await?;
wait_for_port(config.port, Duration::from_secs(30)).await?;
let mut mode = state.sandbox_mode.lock().await;
*mode = SandboxMode::Active {
sandbox_name: sandbox.name().to_string(),
isolation_tier: sandbox.isolation_tier(),
handle,
};
log::info!("OpenClaw started via {} (tier: {})", sandbox.name(), sandbox.isolation_tier());
Ok(())
}
/// Stop OpenClaw regardless of which sandbox type is in use.
pub async fn stop_openclaw(
sandbox: &dyn Sandbox,
state: &OpenClawState,
) -> Result<(), String> {
let mut mode = state.sandbox_mode.lock().await;
match *mode {
SandboxMode::Active {
ref mut handle, ..
} => {
sandbox.stop(handle).await?;
}
SandboxMode::Inactive => {
if is_port_in_use(OPENCLAW_PORT).await {
let mut dummy = super::sandbox::SandboxHandle::Named("stop-fallback".to_string());
sandbox.stop(&mut dummy).await?;
}
}
}
*mode = SandboxMode::Inactive;
for _i in 0..10 {
if !is_port_in_use(OPENCLAW_PORT).await {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
log::warn!("OpenClaw port may still be in use after stop");
Ok(())
}
/// Get OpenClaw status using the sandbox.
pub async fn get_openclaw_status(
sandbox: &dyn Sandbox,
state: &OpenClawState,
) -> Result<(SandboxStatus, Option<String>, Option<IsolationTier>), String> {
let mode = state.sandbox_mode.lock().await;
match &*mode {
SandboxMode::Active {
sandbox_name,
isolation_tier,
handle,
} => {
let status = sandbox.status(handle).await?;
Ok((
status,
Some(sandbox_name.clone()),
Some(isolation_tier.clone()),
))
}
SandboxMode::Inactive => {
if is_port_in_use(OPENCLAW_PORT).await {
Ok((SandboxStatus::Running, None, None))
} else {
Ok((SandboxStatus::Stopped, None, None))
}
}
}
}
/// Poll a port until it responds or timeout.
pub async fn wait_for_port(port: u16, timeout: Duration) -> Result<(), String> {
let start = tokio::time::Instant::now();
let interval = Duration::from_millis(500);
loop {
if is_port_in_use(port).await {
return Ok(());
}
if start.elapsed() >= timeout {
return Err(format!(
"Timed out waiting for port {} to respond ({}s)",
port,
timeout.as_secs()
));
}
tokio::time::sleep(interval).await;
}
}
/// Check if a port has something listening.
pub async fn is_port_in_use(port: u16) -> bool {
tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
.await
.is_ok()
}
/// Patch OpenClaw config for the active sandbox: bind mode, Jan API baseUrl,
/// agent paths, and agent model URLs. Returns `true` if config was modified.
pub fn patch_config_for_sandbox(sandbox: &dyn Sandbox, config_dir: &Path) -> Result<bool, String> {
use super::constants;
let config_path = config_dir.join("openclaw.json");
if !config_path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config: {}", e))?;
let mut config: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse config: {}", e))?;
let mut modified = false;
match sandbox.isolation_tier() {
IsolationTier::FullContainer => {
if let Some(bind) = config.pointer_mut("/gateway/bind") {
if bind.as_str() != Some(constants::GATEWAY_BIND_MODE) {
*bind = serde_json::json!(constants::GATEWAY_BIND_MODE);
modified = true;
}
}
if let Some(base_url) = config.pointer_mut("/models/providers/jan/baseUrl") {
if base_url.as_str() != Some(constants::DOCKER_JAN_BASE_URL) {
*base_url = serde_json::json!(constants::DOCKER_JAN_BASE_URL);
modified = true;
}
}
}
IsolationTier::PlatformSandbox | IsolationTier::None => {
if let Some(base_url) = config.pointer_mut("/models/providers/jan/baseUrl") {
if base_url.as_str() != Some(constants::DEFAULT_JAN_BASE_URL) {
*base_url = serde_json::json!(constants::DEFAULT_JAN_BASE_URL);
modified = true;
}
}
}
}
// Rewrite cross-mode agent paths (Docker ↔ host)
let config_dir_str = config_dir.to_string_lossy();
let docker_prefix = "/home/node/.openclaw/";
let is_docker = matches!(sandbox.isolation_tier(), IsolationTier::FullContainer);
if let Some(agents_list) = config.pointer_mut("/agents/list") {
if let Some(arr) = agents_list.as_array_mut() {
for agent in arr.iter_mut() {
for field in &["workspace", "agentDir"] {
if let Some(val) = agent.get_mut(*field) {
if let Some(s) = val.as_str() {
if is_docker && !s.starts_with(docker_prefix) && s.contains("/.openclaw/") {
if let Some(pos) = s.find("/.openclaw/") {
let suffix = &s[pos + "/.openclaw/".len()..];
let new_path = format!("{}{}", docker_prefix, suffix);
*val = serde_json::json!(new_path);
modified = true;
}
} else if !is_docker && s.starts_with(docker_prefix) {
let suffix = &s[docker_prefix.len()..];
let new_path = format!("{}/{}", config_dir_str, suffix);
*val = serde_json::json!(new_path);
modified = true;
}
}
}
}
}
}
}
if modified {
let updated = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
std::fs::write(&config_path, updated)
.map_err(|e| format!("Failed to write config: {}", e))?;
log::info!("Patched config for {} sandbox", sandbox.name());
}
// Fix agent models.json baseUrl for current sandbox mode
let target_base_url = if is_docker {
constants::DOCKER_JAN_BASE_URL
} else {
constants::DEFAULT_JAN_BASE_URL
};
let wrong_base_url = if is_docker {
constants::DEFAULT_JAN_BASE_URL
} else {
constants::DOCKER_JAN_BASE_URL
};
patch_agent_models_base_url(config_dir, target_base_url, wrong_base_url);
Ok(modified)
}
/// Patch baseUrl in all `agents/*/agent/models.json` files for the current sandbox mode.
fn patch_agent_models_base_url(config_dir: &Path, target_url: &str, wrong_url: &str) {
let agents_dir = config_dir.join("agents");
if !agents_dir.exists() {
return;
}
let entries = match std::fs::read_dir(&agents_dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let models_path = entry.path().join("agent").join("models.json");
if !models_path.exists() {
continue;
}
let content = match std::fs::read_to_string(&models_path) {
Ok(c) => c,
Err(_) => continue,
};
if !content.contains(wrong_url) {
continue;
}
let updated = content.replace(wrong_url, target_url);
if let Err(e) = std::fs::write(&models_path, &updated) {
log::warn!("Failed to patch {}: {}", models_path.display(), e);
}
}
}

View File

@@ -1,158 +0,0 @@
pub mod cli;
pub mod commands;
pub mod constants;
pub mod health;
pub mod lifecycle;
pub mod models;
pub mod sandbox;
pub mod sandbox_direct;
#[cfg(feature = "docker")]
pub mod sandbox_docker;
pub mod security;
pub mod tailscale;
pub mod tunnels;
#[cfg(test)]
mod tests;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
use sandbox::{Sandbox, SandboxMode};
use tunnels::TunnelState;
static DOCKER_MODE_ACTIVE: AtomicBool = AtomicBool::new(false);
pub fn set_docker_mode(active: bool) {
DOCKER_MODE_ACTIVE.store(active, Ordering::SeqCst);
}
pub fn is_docker_mode() -> bool {
DOCKER_MODE_ACTIVE.load(Ordering::SeqCst)
}
/// Base directory for all OpenClaw data, rooted under Jan's data folder.
/// Returns `<jan_data_folder>/openclaw/`.
pub fn get_openclaw_base_dir() -> Result<PathBuf, String> {
let jan_data = crate::core::app::commands::resolve_jan_data_folder();
let base = jan_data.join("openclaw");
if !base.exists() {
std::fs::create_dir_all(&base).map_err(|e| e.to_string())?;
}
Ok(base)
}
/// OpenClaw configuration directory, isolated per sandbox mode.
pub fn get_openclaw_config_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or("Could not find home directory")?;
let config_dir = if is_docker_mode() {
home.join(".openclaw").join("sandbox").join("docker")
} else {
home.join(".openclaw")
};
if !config_dir.exists() {
std::fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
}
Ok(config_dir)
}
/// OpenClaw configuration file path
pub fn get_openclaw_config_path() -> Result<PathBuf, String> {
Ok(get_openclaw_config_dir()?.join("openclaw.json"))
}
/// OpenClaw gateway port
pub const OPENCLAW_PORT: u16 = 18789;
/// Minimum Bun version required
pub const MIN_BUN_VERSION: &str = "1.0";
/// OpenClaw runtime directory (where Bun and OpenClaw are installed)
pub fn get_openclaw_runtime_dir() -> Result<std::path::PathBuf, String> {
let base = get_openclaw_base_dir()?;
let runtime_dir = base.join("bunx");
if !runtime_dir.exists() {
std::fs::create_dir_all(&runtime_dir).map_err(|e| e.to_string())?;
}
Ok(runtime_dir)
}
/// Path to the OpenClaw binary in the runtime directory
pub fn get_openclaw_bin_path() -> Result<std::path::PathBuf, String> {
let runtime_dir = get_openclaw_runtime_dir()?;
let bin_path = if cfg!(target_os = "windows") {
runtime_dir.join("bin").join("openclaw.exe")
} else {
runtime_dir.join("bin").join("openclaw")
};
Ok(bin_path)
}
/// Resolve the bundled Bun path. Returns None if unavailable.
///
/// Looks for bun next to the current executable (same approach as MCP).
pub fn resolve_bundled_bun() -> Option<std::path::PathBuf> {
let bun_name = if cfg!(target_os = "windows") { "bun.exe" } else { "bun" };
if let Ok(exe_path) = std::env::current_exe() {
if let Some(bin_path) = exe_path.parent() {
let candidate = bin_path.join(bun_name);
if candidate.exists() {
let s = candidate.to_string_lossy().to_string();
if jan_utils::system::can_override_npx(s) {
return Some(candidate);
}
}
}
}
None
}
pub const PATH_SEPARATOR: &str = if cfg!(target_os = "windows") { ";" } else { ":" };
/// Prepend bundled Bun dir and openclaw-runtime/bin to PATH.
pub fn build_augmented_path() -> Option<String> {
let mut path_entries = Vec::new();
if let Some(bun_path) = resolve_bundled_bun() {
if let Some(bun_dir) = bun_path.parent() {
path_entries.push(bun_dir.to_string_lossy().to_string());
}
}
if let Ok(runtime_dir) = get_openclaw_runtime_dir() {
path_entries.push(runtime_dir.join("bin").to_string_lossy().to_string());
}
if path_entries.is_empty() {
return None;
}
let current_path = std::env::var("PATH").unwrap_or_default();
Some(format!(
"{}{}{}",
path_entries.join(PATH_SEPARATOR),
PATH_SEPARATOR,
current_path
))
}
/// OpenClaw Manager state
pub struct OpenClawState {
/// Tunnel state for managing tunnel processes
pub tunnel_state: TunnelState,
/// Current sandbox mode (inactive or active with handle)
pub sandbox_mode: Arc<Mutex<SandboxMode>>,
/// Detected sandbox implementation (set once on first use via detect_sandbox())
pub sandbox: Arc<Mutex<Option<Box<dyn Sandbox>>>>,
}
impl Default for OpenClawState {
fn default() -> Self {
Self {
tunnel_state: TunnelState::default(),
sandbox_mode: Arc::new(Mutex::new(SandboxMode::Inactive)),
sandbox: Arc::new(Mutex::new(None)),
}
}
}

View File

@@ -1,639 +0,0 @@
use serde::{Deserialize, Serialize};
use super::constants;
/// OpenClaw configuration structure
/// Only includes keys that OpenClaw actually recognizes.
/// Unknown keys (like agents.defaults.systemPrompt) cause OpenClaw to reject the config.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawConfig {
/// Gateway configuration
pub gateway: GatewayConfig,
/// Model providers configuration
pub models: ModelsConfig,
}
impl Default for OpenClawConfig {
fn default() -> Self {
Self {
gateway: GatewayConfig::default(),
models: ModelsConfig::default(),
}
}
}
/// Gateway configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GatewayConfig {
/// Gateway mode: "local" (run server here) or "remote" (connect to remote)
pub mode: String,
/// Bind address (loopback or 0.0.0.0)
pub bind: String,
/// Port number
pub port: u16,
/// Authentication configuration
pub auth: AuthConfig,
/// Control UI configuration (for WebSocket origin validation)
#[serde(rename = "controlUi", skip_serializing_if = "Option::is_none")]
pub control_ui: Option<ControlUiConfig>,
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
mode: "local".to_string(),
bind: "lan".to_string(),
port: super::OPENCLAW_PORT,
auth: AuthConfig::default(),
control_ui: Some(ControlUiConfig::default()),
}
}
}
/// Control UI configuration for the Gateway
/// This controls WebSocket origin validation for security
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlUiConfig {
/// Allowed origins for WebSocket connections
/// Required for non-loopback connections (e.g., from Tauri apps)
#[serde(rename = "allowedOrigins")]
pub allowed_origins: Vec<String>,
}
impl Default for ControlUiConfig {
fn default() -> Self {
Self {
// Allow connections from Tauri app and local development
allowed_origins: vec![
"tauri://localhost".to_string(),
"http://tauri.localhost".to_string(),
"http://localhost".to_string(),
"http://localhost:1420".to_string(), // Tauri dev server
"http://127.0.0.1".to_string(),
],
}
}
}
/// Authentication configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
/// Authentication mode (token, etc.)
pub mode: String,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
mode: "token".to_string(),
}
}
}
/// Models configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelsConfig {
/// Provider configurations
pub providers: ProvidersConfig,
}
impl Default for ModelsConfig {
fn default() -> Self {
Self {
providers: ProvidersConfig::default(),
}
}
}
/// Providers configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProvidersConfig {
/// Jan provider configuration
#[serde(rename = "jan")]
pub jan: JanProviderConfig,
}
impl Default for ProvidersConfig {
fn default() -> Self {
Self {
jan: JanProviderConfig::default(),
}
}
}
/// A single model definition for OpenClaw's provider config.
/// Only `id` and `name` are required by OpenClaw's Zod schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelDefinition {
/// Model identifier (plain name, no provider prefix)
pub id: String,
/// Human-readable display name
pub name: String,
/// Context window size in tokens
#[serde(rename = "contextWindow", skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
/// Maximum output tokens
#[serde(rename = "maxTokens", skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
}
/// Jan provider configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JanProviderConfig {
/// Base URL for Jan API
#[serde(rename = "baseUrl")]
pub base_url: String,
/// API type (openai-completions, etc.)
pub api: String,
/// API key for authentication.
/// For local models this is a placeholder (Jan doesn't require auth).
/// For remote providers routed through Jan, this carries the real key.
#[serde(rename = "apiKey")]
pub api_key: String,
/// List of model definitions (objects with at least { id, name })
pub models: Vec<ModelDefinition>,
}
impl Default for JanProviderConfig {
fn default() -> Self {
Self {
base_url: constants::DEFAULT_JAN_BASE_URL.to_string(),
api: constants::DEFAULT_JAN_API_TYPE.to_string(),
api_key: constants::DEFAULT_JAN_API_KEY.to_string(),
// Start with the pinned default model; openclaw_sync_all_models
// replaces this list with the full catalog from Jan.
models: vec![ModelDefinition {
id: constants::DEFAULT_MODEL_ID.to_string(),
name: constants::DEFAULT_MODEL_ID.to_string(),
context_window: Some(128000),
max_tokens: Some(4096),
}],
}
}
}
// NOTE: OpenClaw uses strict Zod validation on its config — unknown keys
// cause the gateway to refuse to start. The `agents.defaults` section accepts
// known keys like `model` (object: {primary, fallbacks}), `maxConcurrent`,
// `subagents`, `compaction`, `workspace`, etc. Custom keys like `systemPrompt`
// are NOT valid and will be rejected. See: https://docs.openclaw.ai/gateway/configuration-reference
/// OpenClaw status response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawStatus {
/// Whether OpenClaw is installed
pub installed: bool,
/// Whether the gateway is running
pub running: bool,
/// Runtime version (Bun or Node.js, if installed)
pub runtime_version: Option<String>,
/// Installed OpenClaw version (if installed)
pub openclaw_version: Option<String>,
/// Port status (available or in use)
pub port_available: bool,
/// Error message (if any)
pub error: Option<String>,
/// Active sandbox type name (e.g., "Linux Namespaces", "WSL2", "Docker", "Direct Process")
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_type: Option<String>,
/// Isolation tier ("none", "platform_sandbox", "full_container")
#[serde(skip_serializing_if = "Option::is_none")]
pub isolation_tier: Option<String>,
}
/// Node.js check result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeCheckResult {
/// Whether Node.js is installed
pub installed: bool,
/// Node.js version (if installed)
pub version: Option<String>,
/// Major version number
pub major_version: Option<u32>,
/// Whether the version meets minimum requirements (22+)
pub meets_requirements: bool,
/// Error message (if any)
pub error: Option<String>,
}
/// Installation result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallResult {
/// Whether installation was successful
pub success: bool,
/// Installed version (if successful)
pub version: Option<String>,
/// Error message (if failed)
pub error: Option<String>,
}
/// Port check result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortCheckResult {
/// Whether the port is available
pub available: bool,
/// The port number checked
pub port: u16,
/// Error message (if any)
pub error: Option<String>,
}
/// Custom configuration input for OpenClaw
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawConfigInput {
/// Optional custom Jan base URL
#[serde(rename = "janBaseUrl")]
pub jan_base_url: Option<String>,
/// Optional custom model ID (sets agents.defaults.model.primary)
#[serde(rename = "modelId")]
pub model_id: Option<String>,
/// Optional custom port
pub port: Option<u16>,
/// Optional bind address
pub bind: Option<String>,
/// Optional API key for Jan's local API server
#[serde(rename = "janApiKey")]
pub jan_api_key: Option<String>,
}
// ============================================
// Tailscale Integration Models
// ============================================
/// Tailscale installation and connection status
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TailscaleStatus {
/// Whether Tailscale is installed on the system
pub installed: bool,
/// Whether Tailscale daemon is running
pub running: bool,
/// Whether user is logged in to Tailscale
pub logged_in: bool,
/// Tailscale version (if installed)
pub version: Option<String>,
/// Error message (if any)
pub error: Option<String>,
}
impl Default for TailscaleStatus {
fn default() -> Self {
Self {
installed: false,
running: false,
logged_in: false,
version: None,
error: None,
}
}
}
/// Tailscale network information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TailscaleInfo {
/// Machine hostname in the tailnet
pub hostname: Option<String>,
/// Name of the tailnet
pub tailnet: Option<String>,
/// Tailscale IP addresses assigned to this machine
pub ip_addresses: Vec<String>,
/// DNS name for this machine in the tailnet
pub dns_name: Option<String>,
/// Whether Tailscale Serve is enabled
pub serve_enabled: bool,
/// Whether Tailscale Funnel is enabled
pub funnel_enabled: bool,
/// URL for accessing via Tailscale Serve/Funnel
pub serve_url: Option<String>,
}
impl Default for TailscaleInfo {
fn default() -> Self {
Self {
hostname: None,
tailnet: None,
ip_addresses: Vec::new(),
dns_name: None,
serve_enabled: false,
funnel_enabled: false,
serve_url: None,
}
}
}
// ============================================
// Tunnel Provider Models
// ============================================
/// Available tunnel providers
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum TunnelProvider {
#[default]
None,
Tailscale,
Ngrok,
Cloudflare,
LocalOnly,
}
/// Status of a tunnel provider
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelProviderStatus {
/// The tunnel provider type
pub provider: TunnelProvider,
/// Whether the provider CLI is installed
pub installed: bool,
/// Whether the provider is authenticated
pub authenticated: bool,
/// Version of the installed CLI (if available)
pub version: Option<String>,
/// Error message if detection failed
pub error: Option<String>,
}
impl Default for TunnelProviderStatus {
fn default() -> Self {
Self {
provider: TunnelProvider::None,
installed: false,
authenticated: false,
version: None,
error: None,
}
}
}
/// Status of all tunnel providers
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelProvidersStatus {
/// Tailscale status
pub tailscale: TunnelProviderStatus,
/// ngrok status
pub ngrok: TunnelProviderStatus,
/// Cloudflare (cloudflared) status
pub cloudflare: TunnelProviderStatus,
/// Currently active/preferred provider
pub active_provider: TunnelProvider,
/// Currently active tunnel information (if any)
pub active_tunnel: Option<TunnelInfo>,
}
impl Default for TunnelProvidersStatus {
fn default() -> Self {
Self {
tailscale: TunnelProviderStatus {
provider: TunnelProvider::Tailscale,
..Default::default()
},
ngrok: TunnelProviderStatus {
provider: TunnelProvider::Ngrok,
..Default::default()
},
cloudflare: TunnelProviderStatus {
provider: TunnelProvider::Cloudflare,
..Default::default()
},
active_provider: TunnelProvider::None,
active_tunnel: None,
}
}
}
/// Active tunnel information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TunnelInfo {
/// The tunnel provider being used
pub provider: TunnelProvider,
/// Public URL for the tunnel
pub url: String,
/// ISO 8601 timestamp when tunnel started
pub started_at: String,
/// Local port being tunneled
pub port: u16,
/// Whether the tunnel is publicly accessible
pub is_public: bool,
}
/// Tunnel configuration stored in config file
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TunnelConfig {
/// Preferred tunnel provider
pub preferred_provider: TunnelProvider,
/// ngrok authentication token
pub ngrok_auth_token: Option<String>,
/// Cloudflare tunnel ID
pub cloudflare_tunnel_id: Option<String>,
/// Whether to auto-start tunnel when OpenClaw starts
pub auto_start: bool,
}
// ============================================
// Security Configuration Models
// ============================================
/// Authentication mode for the gateway
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AuthMode {
#[default]
Token,
Password,
None,
}
/// Security configuration for OpenClaw gateway
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
/// Authentication mode (token, password, or none)
pub auth_mode: AuthMode,
/// Hashed access token (if auth_mode is Token)
pub token_hash: Option<String>,
/// Hashed password (if auth_mode is Password)
pub password_hash: Option<String>,
/// Whether device pairing is required
pub require_pairing: bool,
/// List of approved devices
pub approved_devices: Vec<DeviceInfo>,
/// Maximum authentication attempts before rate limiting
pub rate_limit_attempts: u32,
/// Time window for rate limiting in seconds
pub rate_limit_window_secs: u32,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
auth_mode: AuthMode::Token,
token_hash: None,
password_hash: None,
require_pairing: true,
approved_devices: vec![],
rate_limit_attempts: 5,
rate_limit_window_secs: 300,
}
}
}
/// Approved device information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceInfo {
/// Unique device identifier
pub id: String,
/// Human-readable device name
pub name: String,
/// Communication channel (telegram, whatsapp, discord)
pub channel: String,
/// User ID on the channel
pub user_id: String,
/// ISO 8601 timestamp when device was approved
pub approved_at: String,
/// ISO 8601 timestamp of last access (if any)
pub last_access: Option<String>,
}
/// Access log entry for security auditing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessLogEntry {
/// ISO 8601 timestamp of the event
pub timestamp: String,
/// Device ID (if known)
pub device_id: Option<String>,
/// Communication channel
pub channel: String,
/// User ID on the channel
pub user_id: String,
/// Action performed (message, pairing, auth_success, auth_fail)
pub action: String,
/// IP address (if available)
pub ip_address: Option<String>,
/// Whether the action was successful
pub success: bool,
/// Error message (if any)
pub error: Option<String>,
}
/// Entry for bulk model sync from Jan to OpenClaw
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelSyncEntry {
/// Model ID (e.g., "llama-3.2-3b", "gpt-4o")
#[serde(rename = "modelId")]
pub model_id: String,
/// Jan's internal provider name (e.g., "llamacpp", "openai", "anthropic")
pub provider: String,
/// Human-readable display name
#[serde(rename = "displayName")]
pub display_name: String,
/// Context window size from Jan's model settings (ctx_len). None = use default.
#[serde(rename = "contextWindow")]
pub context_window: Option<u32>,
}
/// Result of bulk model sync
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BulkSyncResult {
/// Number of models synced
pub synced_count: u32,
/// Default model that was set (if any)
pub default_model: Option<String>,
}
// ============================================
// 1-Click Enable Models
// ============================================
/// Steps in the 1-click enable flow
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EnableStep {
CheckingDependencies,
CheckingInstallation,
Installing,
Configuring,
Starting,
ValidatingConfig,
SyncingModels,
}
/// Result of the 1-click enable orchestrator
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnableResult {
/// Whether the enable flow completed successfully
pub success: bool,
/// Whether OpenClaw was already installed before this run
pub already_installed: bool,
/// Steps that were completed
pub steps_completed: Vec<EnableStep>,
/// Final OpenClaw status
pub status: OpenClawStatus,
}
/// Progress event emitted during the enable flow
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnableProgressEvent {
/// Current step identifier
pub step: String,
/// Progress percentage (0-100)
pub progress: u32,
/// Human-readable message
pub message: String,
/// Optional sandbox info for display (e.g., "Linux Namespaces", "Docker 24.0.7")
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_info: Option<String>,
}
/// Error with recovery options for the enable flow
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnableError {
/// Error code for programmatic handling
pub code: EnableErrorCode,
/// Human-readable error message
pub message: String,
/// Recovery options the user can take
pub recovery: Vec<RecoveryOption>,
}
/// Error codes for the enable flow
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EnableErrorCode {
RuntimeNotFound,
RuntimeVersionTooLow,
InstallFailed,
PortInUse,
ConfigWriteFailed,
GatewayStartFailed,
ValidationFailed,
}
/// A recovery option the user can take
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryOption {
/// Button label
pub label: String,
/// Recovery action type
pub action: RecoveryAction,
/// Description of what this action does
pub description: String,
}
/// Recovery actions the frontend can execute
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RecoveryAction {
Retry,
UseDifferentPort { port: u16 },
}
/// Security status response for the frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityStatus {
/// Current authentication mode
pub auth_mode: AuthMode,
/// Whether an access token has been configured
pub has_token: bool,
/// Whether a password has been configured
pub has_password: bool,
/// Whether device pairing is required
pub require_pairing: bool,
/// Number of approved devices
pub approved_device_count: u32,
/// Number of recent authentication failures
pub recent_auth_failures: u32,
}

View File

@@ -1,109 +0,0 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Configuration passed to any sandbox implementation.
/// Platform-agnostic — no Docker-specific, WSL-specific, or namespace-specific fields.
pub struct SandboxConfig {
/// Path to the OpenClaw config directory on the host (~/.openclaw/)
pub config_dir: PathBuf,
/// The port OpenClaw should listen on
pub port: u16,
/// The Jan API base URL that OpenClaw should connect to
pub jan_api_url: String,
/// Environment variables to pass to the OpenClaw process
pub env_vars: Vec<(String, String)>,
}
/// Opaque handle returned by `start()`. Each implementation stores what it needs.
pub enum SandboxHandle {
/// PID-based handle (Linux namespaces, macOS)
Pid(u32),
/// Named handle (WSL2 distro name, Docker container name)
Named(String),
/// Process handle for direct process fallback
Process(tokio::process::Child),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SandboxStatus {
Running,
Stopped,
Failed { error: String },
Unknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IsolationTier {
/// Tier 0: No isolation (direct process)
None,
/// Tier 2: Namespace/VM isolation (bubblewrap, WSL2, Apple Containerization)
PlatformSandbox,
/// Tier 3: Full OCI container (Docker/Podman)
FullContainer,
}
impl std::fmt::Display for IsolationTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IsolationTier::None => write!(f, "none"),
IsolationTier::PlatformSandbox => write!(f, "platform_sandbox"),
IsolationTier::FullContainer => write!(f, "full_container"),
}
}
}
#[async_trait::async_trait]
pub trait Sandbox: Send + Sync {
/// Human-readable name for this sandbox type
fn name(&self) -> &str;
/// The isolation tier this sandbox provides
fn isolation_tier(&self) -> IsolationTier;
/// Returns true if this sandbox mechanism is available on the current system
async fn is_available(&self) -> bool;
/// Start OpenClaw in the sandbox. Returns a handle for subsequent operations.
async fn start(&self, config: &SandboxConfig) -> Result<SandboxHandle, String>;
/// Stop the sandboxed OpenClaw process.
async fn stop(&self, handle: &mut SandboxHandle) -> Result<(), String>;
/// Check current status of the sandboxed process.
async fn status(&self, handle: &SandboxHandle) -> Result<SandboxStatus, String>;
/// Retrieve recent log lines from the sandboxed process.
async fn logs(&self, handle: &SandboxHandle, lines: usize) -> Result<Vec<String>, String>;
}
/// Active sandbox mode — stored in OpenClawState.
pub enum SandboxMode {
/// No sandbox active
Inactive,
/// Sandbox is running
Active {
sandbox_name: String,
isolation_tier: IsolationTier,
handle: SandboxHandle,
},
}
impl Default for SandboxMode {
fn default() -> Self {
SandboxMode::Inactive
}
}
/// Auto-detect the best available sandbox: Docker if available, otherwise direct process.
pub async fn detect_sandbox() -> Box<dyn Sandbox> {
#[cfg(feature = "docker")]
{
let docker = super::sandbox_docker::DockerSandbox::new();
if docker.is_available().await {
return Box::new(docker);
}
}
Box::new(super::sandbox_direct::DirectProcessSandbox)
}

View File

@@ -1,275 +0,0 @@
/// Apple Containerization Framework sandbox for macOS 26+ (Tahoe).
///
/// Delegates to a Swift helper binary (`container-helper`) that wraps
/// Apple's Containerization.framework. This follows the same pattern as
/// the `mlx-server` integration: a Swift binary built separately and
/// bundled in `src-tauri/resources/bin/`.
///
/// The helper communicates via a simple JSON protocol over stdout.
///
/// Network model: the helper configures the container with host networking,
/// so `localhost:1337` reaches Jan's API without config patching.
#[cfg(target_os = "macos")]
use std::process::Stdio;
#[cfg(target_os = "macos")]
use super::sandbox::{IsolationTier, Sandbox, SandboxConfig, SandboxHandle, SandboxStatus};
#[cfg(target_os = "macos")]
const HELPER_BINARY: &str = "container-helper";
#[cfg(target_os = "macos")]
pub struct AppleContainerSandbox;
#[cfg(target_os = "macos")]
impl AppleContainerSandbox {
/// Check if the macOS version is 26.0 (Tahoe) or later.
async fn is_macos_26_or_later() -> bool {
match tokio::process::Command::new("sw_vers")
.args(["-productVersion"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
{
Ok(output) => {
let version = String::from_utf8_lossy(&output.stdout);
let trimmed = version.trim();
let parts: Vec<&str> = trimmed.split('.').collect();
if let Some(major) = parts.first() {
if let Ok(major_num) = major.parse::<u32>() {
return major_num >= 26;
}
}
log::info!(
"AppleContainerSandbox: could not parse macOS version: '{}'",
trimmed
);
false
}
Err(e) => {
log::info!("AppleContainerSandbox: sw_vers failed: {}", e);
false
}
}
}
/// Resolve the path to the container-helper binary from Tauri resources.
fn helper_path() -> Option<std::path::PathBuf> {
// Try the standard resource locations
let candidates = [
std::path::PathBuf::from("resources/bin").join(HELPER_BINARY),
// In development, the binary might be at the src-tauri level
std::path::PathBuf::from("src-tauri/resources/bin").join(HELPER_BINARY),
];
for candidate in &candidates {
if candidate.exists() {
return Some(candidate.clone());
}
}
// Try resolving relative to the current executable
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let resource_path = exe_dir.join("resources/bin").join(HELPER_BINARY);
if resource_path.exists() {
return Some(resource_path);
}
// macOS .app bundle: Contents/MacOS/../Resources/
let bundle_path = exe_dir
.join("../Resources/resources/bin")
.join(HELPER_BINARY);
if bundle_path.exists() {
return Some(bundle_path);
}
}
}
None
}
}
#[cfg(target_os = "macos")]
#[async_trait::async_trait]
impl Sandbox for AppleContainerSandbox {
fn name(&self) -> &str {
"Apple Containerization"
}
fn isolation_tier(&self) -> IsolationTier {
IsolationTier::PlatformSandbox
}
async fn is_available(&self) -> bool {
if !Self::is_macos_26_or_later().await {
log::info!("AppleContainerSandbox: macOS version < 26");
return false;
}
if Self::helper_path().is_none() {
log::info!("AppleContainerSandbox: container-helper binary not found");
return false;
}
true
}
async fn start(&self, config: &SandboxConfig) -> Result<SandboxHandle, String> {
let helper = Self::helper_path()
.ok_or("container-helper binary not found in resources")?;
log::info!(
"AppleContainerSandbox: starting via {:?}",
helper
);
let output = tokio::process::Command::new(&helper)
.args([
"start",
"--config-dir",
&config.config_dir.to_string_lossy(),
"--port",
&config.port.to_string(),
"--jan-api-url",
&config.jan_api_url,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to run container-helper: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("container-helper start failed: {}", stderr));
}
// Parse JSON output: {"container_id": "..."}
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|e| format!("Failed to parse container-helper output: {}", e))?;
let container_id = parsed
.get("container_id")
.and_then(|v| v.as_str())
.ok_or("container-helper did not return container_id")?
.to_string();
log::info!(
"AppleContainerSandbox: started container '{}'",
container_id
);
Ok(SandboxHandle::Named(container_id))
}
async fn stop(&self, handle: &mut SandboxHandle) -> Result<(), String> {
let container_id = match handle {
SandboxHandle::Named(id) => id.clone(),
_ => return Err("Invalid handle for AppleContainerSandbox".to_string()),
};
let helper = Self::helper_path()
.ok_or("container-helper binary not found")?;
log::info!(
"AppleContainerSandbox: stopping container '{}'",
container_id
);
let output = tokio::process::Command::new(&helper)
.args(["stop", "--container-id", &container_id])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to run container-helper stop: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("AppleContainerSandbox: stop returned error: {}", stderr);
}
Ok(())
}
async fn status(&self, handle: &SandboxHandle) -> Result<SandboxStatus, String> {
// Primary check: port connectivity
match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", super::OPENCLAW_PORT)).await {
Ok(_) => return Ok(SandboxStatus::Running),
Err(_) => {}
}
// Secondary check: ask the helper
let container_id = match handle {
SandboxHandle::Named(id) => id.clone(),
_ => return Ok(SandboxStatus::Unknown),
};
if let Some(helper) = Self::helper_path() {
let output = tokio::process::Command::new(&helper)
.args(["status", "--container-id", &container_id])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(stdout.trim()) {
if parsed.get("running").and_then(|v| v.as_bool()) == Some(true) {
return Ok(SandboxStatus::Running);
}
}
}
}
}
Ok(SandboxStatus::Stopped)
}
async fn logs(
&self,
handle: &SandboxHandle,
lines: usize,
) -> Result<Vec<String>, String> {
let container_id = match handle {
SandboxHandle::Named(id) => id.clone(),
_ => return Ok(vec!["Invalid handle".to_string()]),
};
let helper = match Self::helper_path() {
Some(p) => p,
None => return Ok(vec!["container-helper not found".to_string()]),
};
let output = tokio::process::Command::new(&helper)
.args([
"logs",
"--container-id",
&container_id,
"--lines",
&lines.to_string(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to read container logs: {}", e))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// Try to parse as JSON array, fall back to raw lines
if let Ok(log_lines) = serde_json::from_str::<Vec<String>>(stdout.trim()) {
Ok(log_lines)
} else {
Ok(stdout.lines().map(String::from).collect())
}
} else {
Ok(vec![
"Failed to retrieve logs from Apple Container sandbox.".to_string(),
])
}
}
}

View File

@@ -1,322 +0,0 @@
use std::process::Stdio;
use super::sandbox::{IsolationTier, Sandbox, SandboxConfig, SandboxHandle, SandboxStatus};
pub struct DirectProcessSandbox;
/// Returns the BUN_INSTALL directory under Jan's data folder, creating it if needed.
fn get_bunx_dir() -> Option<std::path::PathBuf> {
let dir = super::get_openclaw_base_dir().ok()?.join("bunx");
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("Failed to create BUN_INSTALL dir {:?}: {}", dir, e);
}
Some(dir)
}
/// Installs openclaw globally into the BUN_INSTALL dir using `bun add -g openclaw`.
/// After this, the binary lives at `$BUN_INSTALL/bin/openclaw[.exe]`.
async fn install_openclaw_globally() -> Result<(), String> {
let bun_path = super::resolve_bundled_bun().ok_or("Bundled bun not found")?;
let bunx_dir = get_bunx_dir().ok_or("Could not resolve home directory")?;
log::info!("Installing openclaw globally into {:?}", bunx_dir);
let mut cmd = tokio::process::Command::new(&bun_path);
cmd.args(["add", "-g", &format!("openclaw@{}", super::constants::OPENCLAW_VERSION)])
.env("BUN_INSTALL", &bunx_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// if let Some(new_path) = super::build_augmented_path() {
// cmd.env("PATH", new_path);
// }
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
let output = cmd.output().await.map_err(|e| format!("Failed to run bun add -g openclaw: {}", e))?;
log::info!("bun add -g openclaw stdout: {}", String::from_utf8_lossy(&output.stdout).trim());
log::info!("bun add -g openclaw stderr: {}", String::from_utf8_lossy(&output.stderr).trim());
if !output.status.success() {
return Err(format!(
"bun add -g openclaw failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
}
/// Build a command that runs the globally-installed openclaw binary from BUN_INSTALL/bin/.
/// Falls back to `bun x openclaw` if the installed binary is not found.
fn build_openclaw_command(args: &[&str], config_dir: &std::path::Path) -> tokio::process::Command {
let bunx_dir = get_bunx_dir();
let installed_bin = bunx_dir.as_ref().map(|d| {
if cfg!(target_os = "windows") {
d.join("bin").join("openclaw.exe")
} else {
d.join("bin").join("openclaw")
}
});
let mut cmd = if installed_bin.as_ref().map(|p| p.exists()).unwrap_or(false) {
let bin = installed_bin.unwrap();
#[cfg(not(target_os = "windows"))]
let cmd = {
log::info!("Running openclaw via node from installed path: {:?}", bin);
let mut c = tokio::process::Command::new("node");
c.arg(&bin);
c
};
#[cfg(target_os = "windows")]
let cmd = {
log::info!("Running openclaw from installed path: {:?}", bin);
tokio::process::Command::new(&bin)
};
cmd
} else if let Some(bun) = super::resolve_bundled_bun() {
log::info!("openclaw not installed yet, falling back to bun x");
let mut c = tokio::process::Command::new(bun);
c.arg("x");
c.arg("openclaw");
c
} else {
tokio::process::Command::new("openclaw")
};
cmd.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = bunx_dir {
cmd.env("BUN_INSTALL", dir);
}
// if let Some(new_path) = super::build_augmented_path() {
// cmd.env("PATH", new_path);
// }
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
cmd
}
#[async_trait::async_trait]
impl Sandbox for DirectProcessSandbox {
fn name(&self) -> &str {
"Direct Process"
}
fn isolation_tier(&self) -> IsolationTier {
IsolationTier::None
}
async fn is_available(&self) -> bool {
true // Always available as fallback
}
async fn start(&self, config: &SandboxConfig) -> Result<SandboxHandle, String> {
let use_child_process = if cfg!(target_os = "windows") {
true
} else {
if let Err(e) = install_openclaw_globally().await {
log::warn!("openclaw global install failed, will attempt to run anyway: {}", e);
}
let install_args = vec!["gateway", "install"];
let mut install_cmd = build_openclaw_command(&install_args.iter().map(|s| *s).collect::<Vec<_>>(), &config.config_dir);
match install_cmd.output().await {
Ok(output) if output.status.success() => false,
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::error!("gateway install failed: {}", stderr.trim());
true
}
Err(e) => {
log::error!("Failed to run gateway install: {}", e);
true
}
}
};
if !use_child_process {
let mut cmd =
build_openclaw_command(&["gateway", "start"], &config.config_dir);
for (key, value) in &config.env_vars {
cmd.env(key, value);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn openclaw gateway: {}", e))?;
// Drain stdout and stderr in background so the pipes don't block
if let Some(stdout) = child.stdout.take() {
tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, BufReader};
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
log::info!("openclaw stdout: {}", line);
}
});
}
if let Some(stderr) = child.stderr.take() {
tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, BufReader};
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
log::info!("openclaw stderr: {}", line);
}
});
}
// Log when the process exits
let pid = child.id();
tokio::spawn(async move {
// We can't await the child here since we've already moved it into the handle,
// so poll the port instead as a proxy for the process being alive.
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let alive = tokio::net::TcpStream::connect(
format!("127.0.0.1:{}", super::OPENCLAW_PORT)
).await.is_ok();
if !alive {
log::warn!("openclaw gateway process (pid {:?}) appears to have exited", pid);
break;
}
}
});
Ok(SandboxHandle::Process(child))
} else {
log::info!("Starting gateway as child process");
let mut cmd = build_openclaw_command(&["gateway"], &config.config_dir);
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW only — DETACHED_PROCESS conflicts and can
// cause Windows to allocate a visible console for the child.
cmd.creation_flags(0x08000000);
}
for (key, value) in &config.env_vars {
cmd.env(key, value);
}
let child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn openclaw gateway: {}", e))?;
Ok(SandboxHandle::Process(child))
}
}
async fn stop(&self, handle: &mut SandboxHandle) -> Result<(), String> {
if let SandboxHandle::Process(child) = handle {
#[cfg(target_os = "windows")]
{
if let Some(pid) = child.id() {
use std::os::windows::process::CommandExt;
let mut cmd = tokio::process::Command::new("taskkill");
cmd.args(["/F", "/T", "/PID", &pid.to_string()]);
cmd.creation_flags(0x08000000);
let _ = cmd.output().await;
} else {
let _ = child.kill().await;
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = child.kill().await;
}
return Ok(());
}
let mut stopped_via_cli = false;
let config_dir =
super::get_openclaw_config_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut stop_cmd = build_openclaw_command(&["gateway", "stop"], &config_dir);
if let Ok(output) = stop_cmd.output().await {
stopped_via_cli = output.status.success();
}
if !stopped_via_cli {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let _ = tokio::process::Command::new("pkill")
.args(["-f", "openclaw"])
.output()
.await;
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let kill = |im: &'static str| async move {
let mut cmd = tokio::process::Command::new("taskkill");
cmd.args(["/F", "/T", "/IM", im]);
cmd.creation_flags(0x08000000);
let _ = cmd.output().await;
};
kill("bun.exe").await;
kill("node.exe").await;
kill("openclaw.exe").await;
}
}
// Port-based kill fallback
if tokio::net::TcpStream::connect(format!("127.0.0.1:{}", super::OPENCLAW_PORT))
.await
.is_ok()
{
log::warn!(
"Port {} still in use after stop attempts, trying port-based kill",
super::OPENCLAW_PORT
);
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let _ = tokio::process::Command::new("sh")
.args([
"-c",
&format!("lsof -ti :{} | xargs kill -9", super::OPENCLAW_PORT),
])
.output()
.await;
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let mut cmd = tokio::process::Command::new("cmd");
cmd.args(["/C", &format!("for /f \"tokens=5\" %%a in ('netstat -aon ^| findstr :{} ^| findstr LISTENING') do taskkill /F /T /PID %%a", super::OPENCLAW_PORT)]);
cmd.creation_flags(0x08000000);
let _ = cmd.output().await;
}
}
Ok(())
}
async fn status(&self, _handle: &SandboxHandle) -> Result<SandboxStatus, String> {
match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", super::OPENCLAW_PORT)).await {
Ok(_) => Ok(SandboxStatus::Running),
Err(_) => Ok(SandboxStatus::Stopped),
}
}
async fn logs(&self, _handle: &SandboxHandle, _lines: usize) -> Result<Vec<String>, String> {
Ok(vec!["Check /tmp/openclaw/ for logs.".to_string()])
}
}

View File

@@ -1,593 +0,0 @@
/// Docker/Podman sandbox using Bollard (pure Rust Docker API client).
///
/// This is an optional Tier 3 sandbox — only used if Docker is already installed.
/// Jan never prompts users to install Docker. Bollard also works with Podman's
/// Docker-compatible socket, so this implementation covers both.
///
/// Security hardening (Phase 3):
/// - cap_drop: ALL — drop all Linux capabilities
/// - cap_add: NET_BIND_SERVICE only — minimum needed for port binding
/// - readonly_rootfs: true — container filesystem is read-only
/// - tmpfs at /tmp — writable scratch space (noexec, nosuid)
/// - no-new-privileges — prevent privilege escalation via setuid binaries
/// - pids_limit: 256 — prevent fork bombs
/// - memory: 512MB — prevent memory exhaustion
/// - nano_cpus: 1 CPU — prevent CPU starvation of host
/// - seccomp profile — custom syscall allowlist loaded from resources
///
/// Network model: Docker uses network isolation. The config patching in
/// `lifecycle.rs` replaces `localhost:1337` with `host.docker.internal:1337`
/// for `IsolationTier::FullContainer`.
#[cfg(feature = "docker")]
use std::collections::HashMap;
#[cfg(feature = "docker")]
use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions,
};
#[cfg(feature = "docker")]
use bollard::image::CreateImageOptions;
#[cfg(feature = "docker")]
use bollard::models::{HostConfig, PortBinding, PortMap};
#[cfg(feature = "docker")]
use futures::StreamExt;
#[cfg(feature = "docker")]
use super::sandbox::{IsolationTier, Sandbox, SandboxConfig, SandboxHandle, SandboxStatus};
#[cfg(feature = "docker")]
use super::constants::OPENCLAW_VERSION;
#[cfg(feature = "docker")]
const CONTAINER_NAME: &str = "jan-openclaw";
#[cfg(feature = "docker")]
const IMAGE_REPO: &str = "ghcr.io/openclaw/openclaw";
#[cfg(feature = "docker")]
fn image_name() -> String {
format!("{}:{}", IMAGE_REPO, OPENCLAW_VERSION)
}
#[cfg(feature = "docker")]
pub struct DockerSandbox;
#[cfg(feature = "docker")]
impl DockerSandbox {
pub fn new() -> Self {
Self
}
/// Check if Docker is available and the OpenClaw container exists.
/// Returns Some(true) if container exists, Some(false) if Docker available but no container,
/// None if Docker is not available.
pub async fn is_installed() -> Option<bool> {
let client = match Self::get_client().await {
Ok(c) => c,
Err(_) => return None,
};
// Check if Docker is responding
if client.ping().await.is_err() {
return None;
}
// Check if container exists
Some(Self::container_exists(&client).await)
}
async fn get_client() -> Result<bollard::Docker, String> {
// Windows: Docker Desktop uses a named pipe (//./pipe/docker_engine).
// connect_with_local_defaults() handles this and DOCKER_HOST.
#[cfg(target_os = "windows")]
{
return bollard::Docker::connect_with_local_defaults()
.map_err(|e| format!("Failed to connect to Docker: {}", e));
}
// macOS/Linux: try DOCKER_HOST, then probe known socket paths.
#[cfg(not(target_os = "windows"))]
{
if std::env::var("DOCKER_HOST").is_ok() {
return bollard::Docker::connect_with_local_defaults()
.map_err(|e| format!("Failed to connect to Docker: {}", e));
}
let mut candidates: Vec<String> = vec![
"/var/run/docker.sock".to_string(),
];
if let Ok(home) = std::env::var("HOME") {
candidates.push(format!("{}/.docker/run/docker.sock", home));
}
#[cfg(target_os = "linux")]
{
let uid = unsafe { libc::getuid() };
candidates.push(format!("/run/user/{}/docker.sock", uid));
}
for socket_path in &candidates {
if !std::path::Path::new(socket_path).exists() {
continue;
}
let uri = format!("unix://{}", socket_path);
match bollard::Docker::connect_with_socket(
&uri,
120,
&bollard::API_DEFAULT_VERSION,
) {
Ok(client) => {
log::info!("DockerSandbox: connected via {}", socket_path);
return Ok(client);
}
Err(e) => {
log::info!("DockerSandbox: socket {} found but connect failed: {}", socket_path, e);
}
}
}
// Fallback for non-standard socket locations (Colima, Rancher Desktop, etc.)
bollard::Docker::connect_with_local_defaults()
.map_err(|_| "Failed to connect to Docker: no working socket found".to_string())
}
}
/// Check if the jan-openclaw container already exists (running or stopped).
async fn container_exists(client: &bollard::Docker) -> bool {
let mut filters = HashMap::new();
filters.insert("name", vec![CONTAINER_NAME]);
let options = ListContainersOptions {
all: true,
filters,
..Default::default()
};
match client.list_containers(Some(options)).await {
Ok(containers) => !containers.is_empty(),
Err(_) => false,
}
}
/// Check if the image exists locally (internal).
async fn image_exists(client: &bollard::Docker) -> bool {
client.inspect_image(&image_name()).await.is_ok()
}
/// Check if the Docker image is available locally (public).
/// Returns true if Docker is available AND the image exists.
pub async fn is_image_available() -> bool {
let client = match Self::get_client().await {
Ok(c) => c,
Err(_) => return false,
};
Self::image_exists(&client).await
}
/// Load the custom seccomp profile from the resources directory.
/// Returns the JSON string if found, None otherwise.
/// The Docker daemon will use its default seccomp profile as fallback.
fn load_seccomp_profile() -> Option<String> {
// Try resource locations
let candidates = [
std::path::PathBuf::from("resources/openclaw-seccomp.json"),
std::path::PathBuf::from("src-tauri/resources/openclaw-seccomp.json"),
];
for candidate in &candidates {
if let Ok(content) = std::fs::read_to_string(candidate) {
return Some(content);
}
}
// Try relative to current executable (production builds)
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let resource_path = exe_dir.join("resources/openclaw-seccomp.json");
if let Ok(content) = std::fs::read_to_string(&resource_path) {
return Some(content);
}
// macOS .app bundle
let bundle_path = exe_dir.join("../Resources/resources/openclaw-seccomp.json");
if let Ok(content) = std::fs::read_to_string(&bundle_path) {
return Some(content);
}
}
}
log::info!("DockerSandbox: seccomp profile not found, using Docker default");
None
}
/// Pull the OpenClaw image (internal, no progress events).
async fn pull_image(client: &bollard::Docker) -> Result<(), String> {
let img = image_name();
log::info!("DockerSandbox: pulling image {}", img);
let options = CreateImageOptions {
from_image: img.as_str(),
..Default::default()
};
let mut stream = client.create_image(Some(options), None, None);
while let Some(result) = stream.next().await {
match result {
Ok(info) => {
if let Some(status) = info.status {
log::debug!("DockerSandbox: pull: {}", status);
}
}
Err(e) => {
return Err(format!("Failed to pull image {}: {}", img, e));
}
}
}
log::info!("DockerSandbox: image pulled successfully");
Ok(())
}
/// Pull the Docker image if not already present, emitting progress events.
/// Called from `openclaw_enable()` to provide real-time feedback during the
/// potentially long image download.
pub async fn pull_image_if_needed_with_progress(
app: &tauri::AppHandle,
sandbox_info: Option<&str>,
) -> Result<(), String> {
use tauri::Emitter;
use super::models::EnableProgressEvent;
let client = Self::get_client().await?;
let img = image_name();
// Check if image already exists locally
if Self::image_exists(&client).await {
log::info!("DockerSandbox: image {} already exists locally", img);
let _ = app.emit(
"openclaw-enable-progress",
EnableProgressEvent {
step: "installing".to_string(),
progress: 55,
message: "Docker image already available.".to_string(),
sandbox_info: sandbox_info.map(|s| s.to_string()),
},
);
return Ok(());
}
log::info!("DockerSandbox: pulling image {} with progress", img);
let options = CreateImageOptions {
from_image: img.as_str(),
..Default::default()
};
let mut stream = client.create_image(Some(options), None, None);
// Track per-layer progress to compute aggregate percentage.
// Docker reports current/total per layer — we sum across all layers
// so the progress bar only moves forward.
let mut layer_current: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
let mut layer_total: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
let mut last_progress_pct: u32 = 40; // Start at 40% (where installing step begins)
while let Some(result) = stream.next().await {
match result {
Ok(info) => {
// Update per-layer byte tracking
if let Some(ref id) = info.id {
if let Some(ref detail) = info.progress_detail {
if let (Some(current), Some(total)) = (detail.current, detail.total) {
if total > 0 {
layer_current.insert(id.clone(), current);
layer_total.insert(id.clone(), total);
}
}
}
}
// Compute aggregate progress across all layers
let sum_current: i64 = layer_current.values().sum();
let sum_total: i64 = layer_total.values().sum();
if sum_total > 0 {
let pct = (sum_current as f64 / sum_total as f64 * 15.0) as u32 + 40;
// Only move forward — never let the bar go backward
let clamped = pct.min(55);
if clamped > last_progress_pct {
last_progress_pct = clamped;
}
}
if let Some(ref status) = info.status {
if status.contains("Pulling") || status.contains("Downloading")
|| status.contains("Extracting") || status.contains("Pull complete")
|| status.contains("Already exists")
{
let display_msg = if let Some(ref id) = info.id {
format!("{}: {}", status, id)
} else {
status.clone()
};
let _ = app.emit(
"openclaw-enable-progress",
EnableProgressEvent {
step: "installing".to_string(),
progress: last_progress_pct,
message: format!("Pulling image... {}", display_msg),
sandbox_info: sandbox_info.map(|s| s.to_string()),
},
);
}
}
log::debug!("DockerSandbox: pull: {:?}", info.status);
}
Err(e) => {
return Err(format!("Failed to pull image {}: {}", img, e));
}
}
}
log::info!("DockerSandbox: image pulled successfully");
let _ = app.emit(
"openclaw-enable-progress",
EnableProgressEvent {
step: "installing".to_string(),
progress: 58,
message: "Docker image pulled successfully.".to_string(),
sandbox_info: sandbox_info.map(|s| s.to_string()),
},
);
Ok(())
}
}
#[cfg(feature = "docker")]
#[async_trait::async_trait]
impl Sandbox for DockerSandbox {
fn name(&self) -> &str {
"Docker"
}
fn isolation_tier(&self) -> IsolationTier {
IsolationTier::FullContainer
}
async fn is_available(&self) -> bool {
match Self::get_client().await {
Ok(client) => {
match client.ping().await {
Ok(_) => {
log::info!("DockerSandbox: Docker daemon is responsive");
true
}
Err(e) => {
log::info!("DockerSandbox: Docker ping failed: {}", e);
false
}
}
}
Err(e) => {
log::info!("DockerSandbox: {}", e);
false
}
}
}
async fn start(&self, config: &SandboxConfig) -> Result<SandboxHandle, String> {
let client = Self::get_client().await?;
// Pull image if not present
if !Self::image_exists(&client).await {
Self::pull_image(&client).await?;
}
// Remove existing container if it exists (stopped from a previous run)
if Self::container_exists(&client).await {
log::info!("DockerSandbox: removing existing container '{}'", CONTAINER_NAME);
let _ = client
.remove_container(
CONTAINER_NAME,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
}
// Build port bindings: 127.0.0.1:18789 -> 18789/tcp
let port_key = format!("{}/tcp", config.port);
let mut port_bindings: PortMap = HashMap::new();
port_bindings.insert(
port_key.clone(),
Some(vec![PortBinding {
host_ip: Some("127.0.0.1".to_string()),
host_port: Some(config.port.to_string()),
}]),
);
// Build environment variables
// Note: the official OpenClaw image runs as user `node` (home = /home/node)
let mut env_vars: Vec<String> = config
.env_vars
.iter()
.filter(|(k, _)| k != "OPENCLAW_CONFIG") // Remove host path, we set the container path below
.map(|(k, v)| format!("{}={}", k, v))
.collect();
// Override config path for inside the container (node user's home)
env_vars.push("OPENCLAW_CONFIG=/home/node/.openclaw/openclaw.json".to_string());
// V8 default heap limit on arm64 is ~512MB which is too small for OpenClaw.
// Explicitly raise it so the gateway doesn't OOM during startup.
env_vars.push("NODE_OPTIONS=--max-old-space-size=768".to_string());
// Build volume bind: ~/.openclaw -> /home/node/.openclaw
// The official image runs as `node` user, NOT root
let host_config_dir = config.config_dir.to_string_lossy();
let binds = vec![format!("{}:/home/node/.openclaw", host_config_dir)];
// Build extra hosts for Docker-to-host networking
let extra_hosts = vec!["host.docker.internal:host-gateway".to_string()];
// Writable tmpfs mounts on top of the read-only root filesystem.
// /tmp — general scratch space for Node.js and OpenClaw
// /home/node/.npm — npm cache (plugin enable/install writes here)
// /home/node/.cache — generic cache dir used by various Node tools
let mut tmpfs = HashMap::new();
tmpfs.insert(
"/tmp".to_string(),
"rw,noexec,nosuid,size=65536k".to_string(),
);
tmpfs.insert(
"/home/node/.npm".to_string(),
"rw,noexec,nosuid,size=131072k".to_string(),
);
tmpfs.insert(
"/home/node/.cache".to_string(),
"rw,noexec,nosuid,size=131072k".to_string(),
);
// Security: load custom seccomp profile if available
let seccomp_profile = Self::load_seccomp_profile();
let mut security_opt = vec!["no-new-privileges".to_string()];
if let Some(profile_json) = seccomp_profile {
security_opt.push(format!("seccomp={}", profile_json));
log::info!("DockerSandbox: custom seccomp profile loaded");
}
let host_config = HostConfig {
port_bindings: Some(port_bindings),
binds: Some(binds),
extra_hosts: Some(extra_hosts),
// Security hardening
cap_drop: Some(vec!["ALL".to_string()]),
cap_add: Some(vec!["NET_BIND_SERVICE".to_string()]),
readonly_rootfs: Some(true),
tmpfs: Some(tmpfs),
security_opt: Some(security_opt),
pids_limit: Some(256),
memory: Some(1024 * 1024 * 1024), // 1 GB — Node.js V8 heap needs headroom
nano_cpus: Some(1_000_000_000), // 1 CPU
..Default::default()
};
let mut exposed_ports = HashMap::new();
exposed_ports.insert(port_key, HashMap::new());
let container_config = Config {
image: Some(image_name()),
env: Some(env_vars),
exposed_ports: Some(exposed_ports),
host_config: Some(host_config),
..Default::default()
};
let options = CreateContainerOptions {
name: CONTAINER_NAME,
platform: None,
};
log::info!("DockerSandbox: creating container '{}'", CONTAINER_NAME);
client
.create_container(Some(options), container_config)
.await
.map_err(|e| format!("Failed to create Docker container: {}", e))?;
log::info!("DockerSandbox: starting container '{}'", CONTAINER_NAME);
client
.start_container(CONTAINER_NAME, None::<StartContainerOptions<String>>)
.await
.map_err(|e| format!("Failed to start Docker container: {}", e))?;
Ok(SandboxHandle::Named(CONTAINER_NAME.to_string()))
}
async fn stop(&self, _handle: &mut SandboxHandle) -> Result<(), String> {
let client = Self::get_client().await?;
log::info!("DockerSandbox: stopping container '{}'", CONTAINER_NAME);
// Stop with a 10-second grace period
let _ = client
.stop_container(
CONTAINER_NAME,
Some(StopContainerOptions { t: 10 }),
)
.await;
// Remove the container
let _ = client
.remove_container(
CONTAINER_NAME,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
log::info!("DockerSandbox: container '{}' stopped and removed", CONTAINER_NAME);
Ok(())
}
async fn status(&self, _handle: &SandboxHandle) -> Result<SandboxStatus, String> {
let client = match Self::get_client().await {
Ok(c) => c,
Err(_) => return Ok(SandboxStatus::Unknown),
};
match client.inspect_container(CONTAINER_NAME, None).await {
Ok(info) => {
if let Some(state) = info.state {
if state.running == Some(true) {
return Ok(SandboxStatus::Running);
}
if let Some(error) = state.error {
if !error.is_empty() {
return Ok(SandboxStatus::Failed { error });
}
}
}
Ok(SandboxStatus::Stopped)
}
Err(_) => Ok(SandboxStatus::Stopped),
}
}
async fn logs(
&self,
_handle: &SandboxHandle,
lines: usize,
) -> Result<Vec<String>, String> {
let client = Self::get_client().await?;
let options = LogsOptions::<String> {
stdout: true,
stderr: true,
tail: lines.to_string(),
..Default::default()
};
let mut stream = client.logs(CONTAINER_NAME, Some(options));
let mut log_lines = Vec::new();
while let Some(result) = stream.next().await {
match result {
Ok(output) => {
log_lines.push(output.to_string());
}
Err(e) => {
log::warn!("DockerSandbox: error reading logs: {}", e);
break;
}
}
}
Ok(log_lines)
}
}

View File

@@ -1,365 +0,0 @@
/// WSL2-based sandbox for Windows.
///
/// Uses the `wsl` CLI to manage a dedicated "Jan.OpenClaw" WSL2 distribution.
/// The distribution runs a minimal Linux environment with OpenClaw installed.
/// WSL2 provides VM-level isolation (separate Linux kernel in Hyper-V).
///
/// Security hardening (Phase 3):
/// - Non-root user: OpenClaw runs as `openclaw` user (UID 1001) inside the distro
/// - Resource limits: ulimit -v (memory), ulimit -n (files), ulimit -u (procs)
/// - WSL2 already provides VM-level isolation (Hyper-V lightweight VM)
/// - Users can further limit resources via .wslconfig (memory, processors)
///
/// Network model: WSL2 default NAT mode — the guest can reach the Windows host
/// via `localhost`, so Jan's API at `localhost:1337` is accessible without
/// config patching.
#[cfg(target_os = "windows")]
use std::process::Stdio;
#[cfg(target_os = "windows")]
use super::sandbox::{IsolationTier, Sandbox, SandboxConfig, SandboxHandle, SandboxStatus};
#[cfg(target_os = "windows")]
const DISTRO_NAME: &str = "Jan.OpenClaw";
#[cfg(target_os = "windows")]
pub struct Wsl2Sandbox {
distro_name: String,
}
#[cfg(target_os = "windows")]
impl Wsl2Sandbox {
pub fn new() -> Self {
Self {
distro_name: DISTRO_NAME.to_string(),
}
}
/// Check if WSL2 is available by probing wslapi.dll and running wsl --status.
async fn check_wsl2_available() -> bool {
// Step 1: Verify wslapi.dll exists (means WSL feature is installed)
if unsafe { libloading::Library::new("wslapi.dll") }.is_err() {
log::info!("Wsl2Sandbox: wslapi.dll not available");
return false;
}
// Step 2: Verify WSL2 is functional by running wsl --status
match tokio::process::Command::new("wsl")
.args(["--status"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
{
Ok(output) => {
if !output.status.success() {
log::info!("Wsl2Sandbox: wsl --status failed");
return false;
}
true
}
Err(e) => {
log::info!("Wsl2Sandbox: failed to run wsl --status: {}", e);
false
}
}
}
/// Check if the Jan.OpenClaw distribution is registered.
async fn is_distro_registered(&self) -> bool {
match tokio::process::Command::new("wsl")
.args(["--list", "--quiet"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.any(|line| line.trim() == self.distro_name)
}
Err(_) => false,
}
}
/// Get the installation directory for the WSL2 distro.
fn install_dir(&self) -> std::path::PathBuf {
dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("C:\\"))
.join("Jan")
.join("wsl")
.join(&self.distro_name)
}
/// Get the expected rootfs path from Tauri resources.
fn rootfs_path(&self) -> std::path::PathBuf {
// The rootfs tar.gz is expected in the resources/wsl/ directory.
// This is a build-time artifact — if not present, start() fails gracefully.
std::path::PathBuf::from("resources")
.join("wsl")
.join("jan-openclaw-rootfs.tar.gz")
}
/// Register the distro from a rootfs tar.gz.
async fn register_distro(&self, rootfs_path: &std::path::Path) -> Result<(), String> {
let install_dir = self.install_dir();
std::fs::create_dir_all(&install_dir)
.map_err(|e| format!("Failed to create WSL install dir: {}", e))?;
let output = tokio::process::Command::new("wsl")
.args([
"--import",
&self.distro_name,
&install_dir.to_string_lossy(),
&rootfs_path.to_string_lossy(),
"--version",
"2",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to register WSL2 distro: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("WSL2 distro registration failed: {}", stderr));
}
log::info!(
"Wsl2Sandbox: registered distro '{}' from {:?}",
self.distro_name,
rootfs_path
);
Ok(())
}
/// Set up security inside the distro after import.
/// Creates a non-root `openclaw` user (UID 1001) and sets it as the default user.
async fn setup_distro_security(&self) -> Result<(), String> {
// Create the openclaw user if it doesn't exist
let setup_script = concat!(
"id -u openclaw >/dev/null 2>&1 || ",
"(addgroup -g 1001 openclaw 2>/dev/null || true; ",
"adduser -u 1001 -G openclaw -s /bin/sh -D openclaw 2>/dev/null || true; ",
"mkdir -p /home/openclaw/.openclaw; ",
"chown -R openclaw:openclaw /home/openclaw)"
);
let output = tokio::process::Command::new("wsl")
.args([
"-d",
&self.distro_name,
"--user",
"root",
"--",
"sh",
"-c",
setup_script,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to setup distro security: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!(
"Wsl2Sandbox: security setup had warnings (non-fatal): {}",
stderr
);
}
log::info!(
"Wsl2Sandbox: security setup complete for distro '{}'",
self.distro_name
);
Ok(())
}
/// Convert a Windows path to a WSL-compatible path.
/// e.g., C:\Users\foo\.openclaw -> /mnt/c/Users/foo/.openclaw
fn windows_to_wsl_path(win_path: &std::path::Path) -> String {
let path_str = win_path.to_string_lossy();
// Replace backslashes with forward slashes
let unix_path = path_str.replace('\\', "/");
// Convert drive letter: C:/... -> /mnt/c/...
if unix_path.len() >= 2 && unix_path.as_bytes()[1] == b':' {
let drive = unix_path.as_bytes()[0].to_ascii_lowercase() as char;
format!("/mnt/{}/{}", drive, &unix_path[3..])
} else {
unix_path
}
}
}
#[cfg(target_os = "windows")]
#[async_trait::async_trait]
impl Sandbox for Wsl2Sandbox {
fn name(&self) -> &str {
"WSL2"
}
fn isolation_tier(&self) -> IsolationTier {
IsolationTier::PlatformSandbox
}
async fn is_available(&self) -> bool {
Self::check_wsl2_available().await
}
async fn start(&self, config: &SandboxConfig) -> Result<SandboxHandle, String> {
log::info!(
"Wsl2Sandbox: starting OpenClaw in WSL2 distro '{}' (hardened)",
self.distro_name
);
// Register distro if not already present
if !self.is_distro_registered().await {
let rootfs = self.rootfs_path();
if !rootfs.exists() {
return Err(format!(
"WSL2 rootfs not found at {:?}. \
The WSL2 sandbox requires a bundled rootfs which is not yet available. \
Falling back to direct process mode.",
rootfs
));
}
self.register_distro(&rootfs).await?;
}
// Security: set up non-root user inside the distro
if let Err(e) = self.setup_distro_security().await {
log::warn!("Wsl2Sandbox: security setup failed (non-fatal): {}", e);
}
// Convert the host config dir to a WSL-compatible path
let wsl_config_dir = Self::windows_to_wsl_path(&config.config_dir);
// Start OpenClaw inside the WSL2 distro as non-root user with resource limits.
// The sh -c wrapper applies ulimit constraints before launching openclaw:
// ulimit -v 524288 : virtual memory limit (512 MB in KB)
// ulimit -n 4096 : max open file descriptors
// ulimit -u 256 : max user processes
// ulimit -c 0 : disable core dumps
let start_script = format!(
"ulimit -v 524288; ulimit -n 4096; ulimit -u 256; ulimit -c 0; \
export OPENCLAW_CONFIG='{}/openclaw.json'; \
exec openclaw gateway start",
wsl_config_dir
);
let mut cmd = tokio::process::Command::new("wsl");
cmd.args([
"-d",
&self.distro_name,
"--user",
"openclaw",
"--",
"sh",
"-c",
&start_script,
])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Pass environment variables
for (key, value) in &config.env_vars {
cmd.env(key, value);
}
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to start OpenClaw in WSL2: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("OpenClaw start in WSL2 failed: {}", stderr));
}
log::info!(
"Wsl2Sandbox: OpenClaw started in distro '{}' as user 'openclaw'",
self.distro_name
);
Ok(SandboxHandle::Named(self.distro_name.clone()))
}
async fn stop(&self, _handle: &mut SandboxHandle) -> Result<(), String> {
log::info!("Wsl2Sandbox: terminating distro '{}'", self.distro_name);
// First try to gracefully stop OpenClaw inside the distro
let _ = tokio::process::Command::new("wsl")
.args([
"-d",
&self.distro_name,
"--",
"openclaw",
"gateway",
"stop",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
// Then terminate the entire distro
let output = tokio::process::Command::new("wsl")
.args(["--terminate", &self.distro_name])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to terminate WSL2 distro: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Wsl2Sandbox: terminate returned error: {}", stderr);
}
Ok(())
}
async fn status(&self, _handle: &SandboxHandle) -> Result<SandboxStatus, String> {
// Check if the gateway is responding on its port
match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", super::OPENCLAW_PORT)).await {
Ok(_) => Ok(SandboxStatus::Running),
Err(_) => Ok(SandboxStatus::Stopped),
}
}
async fn logs(
&self,
_handle: &SandboxHandle,
lines: usize,
) -> Result<Vec<String>, String> {
let output = tokio::process::Command::new("wsl")
.args([
"-d",
&self.distro_name,
"--",
"tail",
"-n",
&lines.to_string(),
"/tmp/openclaw/gateway.log",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| format!("Failed to read WSL2 logs: {}", e))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(String::from).collect())
} else {
Ok(vec![
"Log retrieval from WSL2 distro failed. Check /tmp/openclaw/ inside the distro."
.to_string(),
])
}
}
}

View File

@@ -1,352 +0,0 @@
//! Security configuration module for OpenClaw gateway
//!
//! Handles authentication tokens, device pairing, and access logging
//! for the OpenClaw remote access feature.
use chrono::Utc;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use super::get_openclaw_config_dir;
use super::models::{AccessLogEntry, DeviceInfo, SecurityConfig};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// Checks and fixes permissions on the OpenClaw config directory.
///
/// Ensures the config directory has restrictive permissions (700) to prevent
/// unauthorized access to sensitive files like security tokens and credentials.
/// This is called before starting any sandbox to ensure the config is secure.
pub async fn ensure_secure_config_permissions(config_dir: &std::path::Path) -> Result<(), String> {
#[cfg(unix)]
{
// Ensure the config directory exists
if !config_dir.exists() {
tokio::fs::create_dir_all(config_dir)
.await
.map_err(|e| format!("Failed to create config dir: {}", e))?;
}
let metadata = tokio::fs::metadata(config_dir)
.await
.map_err(|e| format!("Cannot stat config dir: {}", e))?;
let mode = metadata.permissions().mode() & 0o777;
// Check if group/other have any permissions
if mode & 0o077 != 0 {
let mut perms = metadata.permissions();
perms.set_mode(0o700);
tokio::fs::set_permissions(config_dir, perms)
.await
.map_err(|e| format!("Cannot fix config dir permissions: {}", e))?;
log::info!("Fixed ~/.openclaw permissions to 700");
}
}
#[cfg(not(unix))]
{
// On non-Unix systems (Windows), we don't have the same permission model
// but we still ensure the directory exists
if !config_dir.exists() {
tokio::fs::create_dir_all(config_dir)
.await
.map_err(|e| format!("Failed to create config dir: {}", e))?;
}
}
Ok(())
}
/// Maximum number of access log entries to keep
const MAX_ACCESS_LOG_ENTRIES: usize = 1000;
/// Security configuration file name
const SECURITY_CONFIG_FILE: &str = "security.json";
/// Access logs file name
const ACCESS_LOGS_FILE: &str = "access_logs.json";
/// Generate a new secure access token
///
/// Returns a UUID-based token that can be used for authentication.
pub fn generate_access_token() -> String {
Uuid::new_v4().to_string()
}
/// Hash an access token or password for storage
///
/// Uses SHA-256 to create a secure hash of the input.
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let result = hasher.finalize();
hex::encode(result)
}
/// Verify a token or password against a stored hash
///
/// Returns true if the token matches the stored hash.
pub fn verify_token(token: &str, hash: &str) -> bool {
let token_hash = hash_token(token);
token_hash == hash
}
/// Generate a pairing code for device authentication
///
/// Returns an 8-character alphanumeric code that's easy to type.
pub fn generate_pairing_code() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
let chars: Vec<char> = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
.chars()
.collect();
(0..8)
.map(|_| {
let idx = rng.gen_range(0..chars.len());
chars[idx]
})
.collect()
}
/// Get the path to the security configuration file
fn get_security_config_path() -> Result<std::path::PathBuf, String> {
let config_dir = get_openclaw_config_dir()?;
Ok(config_dir.join(SECURITY_CONFIG_FILE))
}
/// Get the path to the access logs file
fn get_access_logs_path() -> Result<std::path::PathBuf, String> {
let config_dir = get_openclaw_config_dir()?;
Ok(config_dir.join(ACCESS_LOGS_FILE))
}
/// Load the security configuration from disk
pub async fn load_security_config() -> Result<SecurityConfig, String> {
let config_path = get_security_config_path()?;
if !config_path.exists() {
return Ok(SecurityConfig::default());
}
let config_json = tokio::fs::read_to_string(&config_path)
.await
.map_err(|e| format!("Failed to read security config: {}", e))?;
serde_json::from_str(&config_json)
.map_err(|e| format!("Failed to parse security config: {}", e))
}
/// Save the security configuration to disk
pub async fn save_security_config(config: &SecurityConfig) -> Result<(), String> {
let config_path = get_security_config_path()?;
let config_json = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize security config: {}", e))?;
tokio::fs::write(&config_path, config_json)
.await
.map_err(|e| format!("Failed to write security config: {}", e))?;
Ok(())
}
/// Record an access log entry
///
/// Appends the entry to the access logs file, rotating if necessary.
pub async fn log_access(entry: AccessLogEntry) -> Result<(), String> {
let logs_path = get_access_logs_path()?;
// Load existing logs
let mut logs = load_access_logs_internal(&logs_path).await?;
// Add new entry
logs.push(entry);
// Rotate if we exceed the maximum
if logs.len() > MAX_ACCESS_LOG_ENTRIES {
let excess = logs.len() - MAX_ACCESS_LOG_ENTRIES;
logs.drain(0..excess);
}
// Save back to disk
let logs_json = serde_json::to_string_pretty(&logs)
.map_err(|e| format!("Failed to serialize access logs: {}", e))?;
tokio::fs::write(&logs_path, logs_json)
.await
.map_err(|e| format!("Failed to write access logs: {}", e))?;
Ok(())
}
/// Load access logs from the file
async fn load_access_logs_internal(path: &std::path::Path) -> Result<Vec<AccessLogEntry>, String> {
if !path.exists() {
return Ok(Vec::new());
}
let logs_json = tokio::fs::read_to_string(path)
.await
.map_err(|e| format!("Failed to read access logs: {}", e))?;
serde_json::from_str(&logs_json)
.map_err(|e| format!("Failed to parse access logs: {}", e))
}
/// Get recent access logs
///
/// Returns the most recent `limit` entries.
pub async fn get_access_logs(limit: usize) -> Result<Vec<AccessLogEntry>, String> {
let logs_path = get_access_logs_path()?;
let logs = load_access_logs_internal(&logs_path).await?;
// Return the most recent entries
let start = if logs.len() > limit {
logs.len() - limit
} else {
0
};
Ok(logs[start..].to_vec())
}
/// Clear all access logs
pub async fn clear_access_logs() -> Result<(), String> {
let logs_path = get_access_logs_path()?;
if logs_path.exists() {
tokio::fs::write(&logs_path, "[]")
.await
.map_err(|e| format!("Failed to clear access logs: {}", e))?;
}
Ok(())
}
/// Add a device to the approved list
pub async fn approve_device(device: DeviceInfo) -> Result<(), String> {
let mut config = load_security_config().await?;
// Check if device already exists (by ID)
let existing_index = config.approved_devices
.iter()
.position(|d| d.id == device.id);
if let Some(index) = existing_index {
// Update existing device
config.approved_devices[index] = device;
} else {
// Add new device
config.approved_devices.push(device);
}
save_security_config(&config).await
}
/// Remove a device from the approved list
pub async fn revoke_device(device_id: &str) -> Result<(), String> {
let mut config = load_security_config().await?;
let original_len = config.approved_devices.len();
config.approved_devices.retain(|d| d.id != device_id);
if config.approved_devices.len() == original_len {
return Err(format!("Device '{}' not found", device_id));
}
save_security_config(&config).await
}
/// Get list of approved devices
pub async fn get_approved_devices() -> Result<Vec<DeviceInfo>, String> {
let config = load_security_config().await?;
Ok(config.approved_devices)
}
/// Update the last access time for a device
pub async fn update_device_last_access(device_id: &str) -> Result<(), String> {
let mut config = load_security_config().await?;
if let Some(device) = config.approved_devices.iter_mut().find(|d| d.id == device_id) {
device.last_access = Some(Utc::now().to_rfc3339());
save_security_config(&config).await
} else {
Err(format!("Device '{}' not found", device_id))
}
}
/// Count recent authentication failures within the rate limit window
pub async fn count_recent_auth_failures() -> Result<u32, String> {
let config = load_security_config().await?;
let logs = get_access_logs(100).await?;
let window_start = Utc::now()
.checked_sub_signed(chrono::Duration::seconds(config.rate_limit_window_secs as i64))
.map(|t| t.to_rfc3339())
.unwrap_or_default();
let failures = logs
.iter()
.filter(|entry| {
entry.action == "auth_fail"
&& entry.timestamp > window_start
})
.count();
Ok(failures as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_access_token() {
let token = generate_access_token();
assert!(!token.is_empty());
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
assert_eq!(token.len(), 36);
}
#[test]
fn test_hash_token() {
let token = "test-token-12345";
let hash = hash_token(token);
// SHA-256 produces 64 hex characters
assert_eq!(hash.len(), 64);
// Same input should produce same hash
let hash2 = hash_token(token);
assert_eq!(hash, hash2);
// Different input should produce different hash
let hash3 = hash_token("different-token");
assert_ne!(hash, hash3);
}
#[test]
fn test_verify_token() {
let token = "my-secret-token";
let hash = hash_token(token);
assert!(verify_token(token, &hash));
assert!(!verify_token("wrong-token", &hash));
}
#[test]
fn test_generate_pairing_code() {
let code = generate_pairing_code();
assert_eq!(code.len(), 8);
// All characters should be alphanumeric (excluding confusing chars like 0, O, 1, I)
for c in code.chars() {
assert!(c.is_ascii_alphanumeric());
assert!(c != '0' && c != 'O' && c != '1' && c != 'I');
}
}
}

View File

@@ -1,480 +0,0 @@
//! Tailscale integration for OpenClaw remote access
//!
//! This module provides functions to detect, configure, and manage Tailscale
//! for secure remote access to the OpenClaw gateway.
use serde::Deserialize;
use tokio::process::Command;
use super::models::{TailscaleInfo, TailscaleStatus};
#[cfg(target_os = "windows")]
fn hide_window(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
#[cfg(not(target_os = "windows"))]
fn hide_window(_cmd: &mut Command) {}
/// Tailscale status JSON response structure
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct TailscaleStatusJson {
#[serde(default)]
backend_state: String,
#[serde(rename = "Self")]
self_node: Option<TailscaleSelfNode>,
#[serde(default)]
current_tailnet: Option<TailscaleCurrentTailnet>,
}
/// Tailscale self node information
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct TailscaleSelfNode {
#[serde(default)]
host_name: String,
#[serde(rename = "DNSName")]
#[serde(default)]
dns_name: String,
#[serde(rename = "TailscaleIPs")]
#[serde(default)]
tailscale_ips: Vec<String>,
}
/// Tailscale current tailnet information
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
#[allow(dead_code)]
struct TailscaleCurrentTailnet {
#[serde(default)]
name: String,
#[serde(rename = "MagicDNSSuffix")]
#[serde(default)]
magic_dns_suffix: String,
}
/// Get the Tailscale CLI command based on the platform
fn get_tailscale_cmd() -> &'static str {
#[cfg(target_os = "macos")]
{
// On macOS, Tailscale is typically installed via App Store or direct download
// The CLI is usually available at /Applications/Tailscale.app/Contents/MacOS/Tailscale
// or via /usr/local/bin/tailscale if installed via Homebrew
"tailscale"
}
#[cfg(target_os = "windows")]
{
"tailscale"
}
#[cfg(target_os = "linux")]
{
"tailscale"
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
"tailscale"
}
}
/// Check if Tailscale is installed on the system
pub async fn detect_tailscale() -> TailscaleStatus {
log::info!("Detecting Tailscale installation");
// Try to get Tailscale version
let mut cmd = Command::new(get_tailscale_cmd());
cmd.arg("version");
hide_window(&mut cmd);
let version_output = cmd.output().await;
match version_output {
Ok(output) => {
if output.status.success() {
let version_str = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
// Check if Tailscale daemon is running and user is logged in
let mut cmd = Command::new(get_tailscale_cmd());
cmd.args(["status", "--json"]);
hide_window(&mut cmd);
let status_output = cmd.output().await;
match status_output {
Ok(status) => {
if status.status.success() {
let status_json = String::from_utf8_lossy(&status.stdout);
match serde_json::from_str::<TailscaleStatusJson>(&status_json) {
Ok(parsed) => {
let running = !parsed.backend_state.is_empty();
let logged_in = parsed.backend_state == "Running"
&& parsed.self_node.is_some();
TailscaleStatus {
installed: true,
running,
logged_in,
version: Some(version_str),
error: None,
}
}
Err(e) => {
log::warn!("Failed to parse Tailscale status JSON: {}", e);
TailscaleStatus {
installed: true,
running: true,
logged_in: false,
version: Some(version_str),
error: Some(format!(
"Failed to parse status: {}",
e
)),
}
}
}
} else {
let stderr = String::from_utf8_lossy(&status.stderr);
// Check for common error messages
let (running, error) = if stderr.contains("not running") {
(false, Some("Tailscale daemon is not running".to_string()))
} else if stderr.contains("not logged in") {
(true, Some("Not logged in to Tailscale".to_string()))
} else {
(false, Some(stderr.trim().to_string()))
};
TailscaleStatus {
installed: true,
running,
logged_in: false,
version: Some(version_str),
error,
}
}
}
Err(e) => TailscaleStatus {
installed: true,
running: false,
logged_in: false,
version: Some(version_str),
error: Some(format!("Failed to check Tailscale status: {}", e)),
},
}
} else {
TailscaleStatus {
installed: false,
running: false,
logged_in: false,
version: None,
error: Some("Tailscale is not installed".to_string()),
}
}
}
Err(e) => {
log::info!("Tailscale not found: {}", e);
TailscaleStatus {
installed: false,
running: false,
logged_in: false,
version: None,
error: Some("Tailscale is not installed".to_string()),
}
}
}
}
/// Get the current tailnet name and status
pub async fn get_tailscale_status() -> TailscaleInfo {
log::info!("Getting Tailscale status");
let mut cmd = Command::new(get_tailscale_cmd());
cmd.args(["status", "--json"]);
hide_window(&mut cmd);
let output = cmd.output().await;
let mut info = TailscaleInfo::default();
match output {
Ok(output) => {
if output.status.success() {
let status_json = String::from_utf8_lossy(&output.stdout);
if let Ok(parsed) = serde_json::from_str::<TailscaleStatusJson>(&status_json) {
if let Some(self_node) = parsed.self_node {
info.hostname = Some(self_node.host_name);
info.dns_name = if self_node.dns_name.is_empty() {
None
} else {
// Remove trailing dot if present
Some(self_node.dns_name.trim_end_matches('.').to_string())
};
info.ip_addresses = self_node.tailscale_ips;
}
if let Some(tailnet) = parsed.current_tailnet {
info.tailnet = if tailnet.name.is_empty() {
None
} else {
Some(tailnet.name)
};
}
}
}
}
Err(e) => {
log::warn!("Failed to get Tailscale status: {}", e);
}
}
// Check serve status
let serve_status = check_serve_status().await;
info.serve_enabled = serve_status.0;
info.funnel_enabled = serve_status.1;
info.serve_url = serve_status.2;
info
}
/// Check the current Tailscale Serve status
/// Returns (serve_enabled, funnel_enabled, serve_url)
async fn check_serve_status() -> (bool, bool, Option<String>) {
let mut cmd = Command::new(get_tailscale_cmd());
cmd.args(["serve", "status", "--json"]);
hide_window(&mut cmd);
let output = cmd.output().await;
match output {
Ok(output) => {
if output.status.success() {
let status_str = String::from_utf8_lossy(&output.stdout);
// Parse the serve status JSON
// The structure varies but typically contains "Services" or similar
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&status_str) {
// Check if there are any active services
let has_services = json.get("Services").map(|s| !s.is_null()).unwrap_or(false)
|| json.get("TCP").map(|s| !s.is_null()).unwrap_or(false)
|| json.get("Web").map(|s| !s.is_null()).unwrap_or(false);
// Check for funnel
let funnel_enabled = json.get("AllowFunnel")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Try to extract the serve URL
let serve_url = json.get("ServeConfig")
.and_then(|c| c.get("DNS"))
.and_then(|d| d.as_str())
.map(|s| format!("https://{}", s));
return (has_services, funnel_enabled, serve_url);
}
}
}
Err(e) => {
log::debug!("Failed to check serve status: {}", e);
}
}
(false, false, None)
}
/// Configure Tailscale Serve for OpenClaw gateway
///
/// This sets up Tailscale Serve to proxy HTTPS traffic to the local OpenClaw port.
pub async fn configure_tailscale_serve(port: u16) -> Result<String, String> {
log::info!("Configuring Tailscale Serve for port {}", port);
// First, check if Tailscale is ready
let status = detect_tailscale().await;
if !status.installed {
return Err("Tailscale is not installed".to_string());
}
if !status.running {
return Err("Tailscale daemon is not running".to_string());
}
if !status.logged_in {
return Err("Not logged in to Tailscale. Please run 'tailscale login' first.".to_string());
}
// Configure Tailscale Serve to forward HTTPS to localhost:port
// The command is: tailscale serve https / http://localhost:PORT
let mut cmd = Command::new(get_tailscale_cmd());
cmd.args([
"serve",
"https",
"/",
&format!("http://localhost:{}", port),
]);
hide_window(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to run tailscale serve: {}", e))?;
if output.status.success() {
// Get the serve URL
let info = get_tailscale_status().await;
let url = info.serve_url.unwrap_or_else(|| {
// Construct URL from DNS name if available
info.dns_name
.map(|dns| format!("https://{}", dns))
.unwrap_or_else(|| "URL not available".to_string())
});
log::info!("Tailscale Serve configured successfully: {}", url);
Ok(url)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("Failed to configure Tailscale Serve: {}", stderr.trim()))
}
}
/// Remove Tailscale Serve configuration
pub async fn remove_tailscale_serve() -> Result<(), String> {
log::info!("Removing Tailscale Serve configuration");
let mut cmd = Command::new(get_tailscale_cmd());
cmd.args(["serve", "off"]);
hide_window(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to run tailscale serve off: {}", e))?;
if output.status.success() {
log::info!("Tailscale Serve configuration removed");
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
// If already off, that's fine
if stderr.contains("no serve config") || stderr.is_empty() {
Ok(())
} else {
Err(format!("Failed to remove Tailscale Serve: {}", stderr.trim()))
}
}
}
/// Enable Tailscale Funnel for public access
///
/// This allows the OpenClaw gateway to be accessed from the public internet
/// via Tailscale's Funnel feature.
pub async fn enable_tailscale_funnel(port: u16) -> Result<String, String> {
log::info!("Enabling Tailscale Funnel for port {}", port);
// First, check if Tailscale is ready
let status = detect_tailscale().await;
if !status.installed {
return Err("Tailscale is not installed".to_string());
}
if !status.running {
return Err("Tailscale daemon is not running".to_string());
}
if !status.logged_in {
return Err("Not logged in to Tailscale. Please run 'tailscale login' first.".to_string());
}
// Enable Funnel - this also sets up serve if not already configured
// The command is: tailscale funnel PORT
let mut cmd = Command::new(get_tailscale_cmd());
cmd.args(["funnel", &port.to_string()]);
hide_window(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to run tailscale funnel: {}", e))?;
if output.status.success() {
// Get the funnel URL
let info = get_tailscale_status().await;
let url = info.serve_url.unwrap_or_else(|| {
info.dns_name
.map(|dns| format!("https://{}", dns))
.unwrap_or_else(|| "URL not available".to_string())
});
log::info!("Tailscale Funnel enabled: {}", url);
Ok(url)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
// Check for common errors
if stderr.contains("Funnel not available") || stderr.contains("not enabled") {
Err("Tailscale Funnel is not enabled for your account. Please enable it in the Tailscale admin console.".to_string())
} else {
Err(format!("Failed to enable Tailscale Funnel: {}", stderr.trim()))
}
}
}
/// Disable Tailscale Funnel
pub async fn disable_tailscale_funnel() -> Result<(), String> {
log::info!("Disabling Tailscale Funnel");
let mut cmd = Command::new(get_tailscale_cmd());
cmd.args(["funnel", "off"]);
hide_window(&mut cmd);
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to run tailscale funnel off: {}", e))?;
if output.status.success() {
log::info!("Tailscale Funnel disabled");
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
// If already off, that's fine
if stderr.contains("no funnel") || stderr.is_empty() {
Ok(())
} else {
Err(format!("Failed to disable Tailscale Funnel: {}", stderr.trim()))
}
}
}
/// Get the tailnet URL for accessing the gateway
///
/// Returns the HTTPS URL that can be used to access the OpenClaw gateway
/// via Tailscale Serve or Funnel.
pub async fn get_tailscale_url() -> Result<Option<String>, String> {
log::info!("Getting Tailscale URL");
let info = get_tailscale_status().await;
// If serve is enabled and we have a URL, return it
if info.serve_enabled {
if let Some(url) = info.serve_url {
return Ok(Some(url));
}
}
// Otherwise, construct from DNS name if available
if let Some(dns_name) = info.dns_name {
// If serve is enabled, use HTTPS
if info.serve_enabled {
return Ok(Some(format!("https://{}", dns_name)));
}
// Otherwise, return the Tailscale IP-based URL for direct access
// (though this won't work without serve for HTTP services)
return Ok(Some(format!("http://{}", dns_name)));
}
// No URL available
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_detect_tailscale() {
// This test will vary based on whether Tailscale is installed
let status = detect_tailscale().await;
// Just verify we get a valid response
assert!(status.installed || status.error.is_some());
}
}

View File

@@ -1,28 +0,0 @@
use super::commands::needs_upgrade;
#[test]
fn test_needs_upgrade_older_version() {
assert!(needs_upgrade("2026.3.1", "2026.3.2"));
assert!(needs_upgrade("2026.2.26", "2026.3.2"));
assert!(needs_upgrade("2025.1.0", "2026.3.2"));
}
#[test]
fn test_needs_upgrade_same_version() {
assert!(!needs_upgrade("2026.3.2", "2026.3.2"));
}
#[test]
fn test_needs_upgrade_newer_version() {
assert!(!needs_upgrade("2026.4.0", "2026.3.2"));
assert!(!needs_upgrade("2026.3.3", "2026.3.2"));
assert!(!needs_upgrade("2027.0.0", "2026.3.2"));
}
#[test]
fn test_needs_upgrade_unparseable() {
assert!(!needs_upgrade("custom-build", "2026.3.2"));
assert!(!needs_upgrade("", "2026.3.2"));
assert!(!needs_upgrade("2026.3.2", "custom"));
assert!(!needs_upgrade("abc", "def"));
}

View File

@@ -1,884 +0,0 @@
//! Tunnel provider detection and management for OpenClaw remote access.
//!
//! This module provides support for multiple tunnel providers (ngrok, cloudflared, Tailscale)
//! to enable remote access to the Jan OpenClaw gateway.
use std::process::Stdio;
use std::sync::Arc;
use chrono::Utc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::Mutex;
use super::models::{TunnelConfig, TunnelInfo, TunnelProvider, TunnelProviderStatus, TunnelProvidersStatus};
use super::get_openclaw_config_dir;
#[cfg(target_os = "windows")]
fn hide_window(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
#[cfg(not(target_os = "windows"))]
fn hide_window(_cmd: &mut Command) {}
/// Shared tunnel state for managing active tunnel processes
#[derive(Clone)]
pub struct TunnelState {
/// Process handle for the active tunnel
pub process_handle: Arc<Mutex<Option<tokio::process::Child>>>,
/// Information about the active tunnel
pub active_tunnel: Arc<Mutex<Option<TunnelInfo>>>,
/// Current tunnel configuration
pub config: Arc<Mutex<TunnelConfig>>,
}
impl Default for TunnelState {
fn default() -> Self {
Self {
process_handle: Arc::new(Mutex::new(None)),
active_tunnel: Arc::new(Mutex::new(None)),
config: Arc::new(Mutex::new(TunnelConfig::default())),
}
}
}
/// Get the tunnel configuration file path
pub fn get_tunnel_config_path() -> Result<std::path::PathBuf, String> {
Ok(get_openclaw_config_dir()?.join("tunnel.json"))
}
/// Load tunnel configuration from disk
pub async fn load_tunnel_config() -> Result<TunnelConfig, String> {
let config_path = get_tunnel_config_path()?;
if !config_path.exists() {
return Ok(TunnelConfig::default());
}
let config_json = tokio::fs::read_to_string(&config_path)
.await
.map_err(|e| format!("Failed to read tunnel config: {}", e))?;
serde_json::from_str(&config_json)
.map_err(|e| format!("Failed to parse tunnel config: {}", e))
}
/// Save tunnel configuration to disk
pub async fn save_tunnel_config(config: &TunnelConfig) -> Result<(), String> {
let config_path = get_tunnel_config_path()?;
let config_json = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize tunnel config: {}", e))?;
tokio::fs::write(&config_path, config_json)
.await
.map_err(|e| format!("Failed to write tunnel config: {}", e))
}
/// Check if ngrok is installed and get its status
pub async fn detect_ngrok() -> TunnelProviderStatus {
log::debug!("Detecting ngrok installation");
let mut cmd = Command::new("ngrok");
cmd.arg("version");
hide_window(&mut cmd);
let output = cmd.output().await;
match output {
Ok(output) => {
if output.status.success() {
let version_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
// ngrok version outputs like "ngrok version 3.x.x"
let version = version_output
.split_whitespace()
.last()
.map(String::from);
// Check if ngrok is authenticated by trying to get the config
let authenticated = check_ngrok_authenticated().await;
TunnelProviderStatus {
provider: TunnelProvider::Ngrok,
installed: true,
authenticated,
version,
error: None,
}
} else {
TunnelProviderStatus {
provider: TunnelProvider::Ngrok,
installed: false,
authenticated: false,
version: None,
error: Some("ngrok command failed".to_string()),
}
}
}
Err(_) => TunnelProviderStatus {
provider: TunnelProvider::Ngrok,
installed: false,
authenticated: false,
version: None,
error: None,
},
}
}
/// Check if ngrok is authenticated
async fn check_ngrok_authenticated() -> bool {
// Try to check ngrok config for authtoken
let mut cmd = Command::new("ngrok");
cmd.args(["config", "check"]);
hide_window(&mut cmd);
let output = cmd.output().await;
match output {
Ok(output) => output.status.success(),
Err(_) => false,
}
}
/// Check if cloudflared is installed and get its status
pub async fn detect_cloudflared() -> TunnelProviderStatus {
log::debug!("Detecting cloudflared installation");
let mut cmd = Command::new("cloudflared");
cmd.arg("version");
hide_window(&mut cmd);
let output = cmd.output().await;
match output {
Ok(output) => {
if output.status.success() {
let version_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
// cloudflared version outputs like "cloudflared version 2024.x.x (built 2024-xx-xx)"
let version = version_output
.split_whitespace()
.nth(2)
.map(String::from);
// Check if cloudflared has tunnels configured
let authenticated = check_cloudflared_authenticated().await;
TunnelProviderStatus {
provider: TunnelProvider::Cloudflare,
installed: true,
authenticated,
version,
error: None,
}
} else {
TunnelProviderStatus {
provider: TunnelProvider::Cloudflare,
installed: false,
authenticated: false,
version: None,
error: Some("cloudflared command failed".to_string()),
}
}
}
Err(_) => TunnelProviderStatus {
provider: TunnelProvider::Cloudflare,
installed: false,
authenticated: false,
version: None,
error: None,
},
}
}
/// Check if cloudflared is authenticated (has tunnels)
async fn check_cloudflared_authenticated() -> bool {
// Try to list tunnels to see if authenticated
let mut cmd = Command::new("cloudflared");
cmd.args(["tunnel", "list"]);
hide_window(&mut cmd);
let output = cmd.output().await;
match output {
Ok(output) => {
// If the command succeeds and doesn't show an error about authentication
let stderr = String::from_utf8_lossy(&output.stderr);
output.status.success() && !stderr.contains("You did not provide credentials")
}
Err(_) => false,
}
}
/// Check if Tailscale is installed and get its status
pub async fn detect_tailscale() -> TunnelProviderStatus {
log::info!("Detecting Tailscale installation");
let mut cmd = Command::new("tailscale");
cmd.arg("version");
hide_window(&mut cmd);
let output = cmd.output().await;
match output {
Ok(output) => {
if output.status.success() {
let version_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
// tailscale version outputs just the version number
let version = version_output.lines().next().map(String::from);
// Check if Tailscale is running and logged in
let authenticated = check_tailscale_authenticated().await;
TunnelProviderStatus {
provider: TunnelProvider::Tailscale,
installed: true,
authenticated,
version,
error: None,
}
} else {
TunnelProviderStatus {
provider: TunnelProvider::Tailscale,
installed: false,
authenticated: false,
version: None,
error: Some("tailscale command failed".to_string()),
}
}
}
Err(_) => TunnelProviderStatus {
provider: TunnelProvider::Tailscale,
installed: false,
authenticated: false,
version: None,
error: None,
},
}
}
/// Check if Tailscale is authenticated
async fn check_tailscale_authenticated() -> bool {
let mut cmd = Command::new("tailscale");
cmd.args(["status", "--json"]);
hide_window(&mut cmd);
let output = cmd.output().await;
match output {
Ok(output) => {
if output.status.success() {
// Parse JSON to check BackendState
let json_str = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_str) {
// BackendState should be "Running" when logged in
json.get("BackendState")
.and_then(|v| v.as_str())
.map(|s| s == "Running")
.unwrap_or(false)
} else {
false
}
} else {
false
}
}
Err(_) => false,
}
}
/// Get status of all tunnel providers
pub async fn get_tunnel_providers(tunnel_state: &TunnelState) -> TunnelProvidersStatus {
log::info!("Getting status of all tunnel providers");
// Run all detections in parallel
let (tailscale, ngrok, cloudflare) = tokio::join!(
detect_tailscale(),
detect_ngrok(),
detect_cloudflared()
);
// Get current config and active tunnel
let config = tunnel_state.config.lock().await;
let active_tunnel = tunnel_state.active_tunnel.lock().await.clone();
TunnelProvidersStatus {
tailscale,
ngrok,
cloudflare,
active_provider: config.preferred_provider.clone(),
active_tunnel,
}
}
/// Start ngrok tunnel
pub async fn start_ngrok_tunnel(
tunnel_state: &TunnelState,
port: u16,
auth_token: Option<String>,
) -> Result<TunnelInfo, String> {
log::info!("Starting ngrok tunnel on port {}", port);
// Check if already running
{
let handle = tunnel_state.process_handle.lock().await;
if handle.is_some() {
return Err("A tunnel is already running".to_string());
}
}
// If auth token provided, configure ngrok
if let Some(token) = &auth_token {
let mut cmd = Command::new("ngrok");
cmd.args(["config", "add-authtoken", token]);
hide_window(&mut cmd);
let output = cmd.output()
.await
.map_err(|e| format!("Failed to configure ngrok auth token: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to set ngrok auth token: {}", stderr));
}
}
// Start ngrok with JSON log format
let mut cmd = Command::new("ngrok");
cmd.args(["http", &port.to_string(), "--log=stdout", "--log-format=json"])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
hide_window(&mut cmd);
let mut child = cmd.spawn()
.map_err(|e| format!("Failed to start ngrok: {}", e))?;
// Read stdout to get the public URL
let stdout = child.stdout.take()
.ok_or("Failed to capture ngrok stdout")?;
let mut reader = BufReader::new(stdout).lines();
let mut public_url: Option<String> = None;
// Set a timeout for URL detection
let timeout = tokio::time::Duration::from_secs(30);
let start = tokio::time::Instant::now();
while start.elapsed() < timeout {
match tokio::time::timeout(tokio::time::Duration::from_secs(1), reader.next_line()).await {
Ok(Ok(Some(line))) => {
// Parse JSON log line
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
// Look for the URL in the log
if let Some(url) = json.get("url").and_then(|v| v.as_str()) {
if url.starts_with("https://") {
public_url = Some(url.to_string());
break;
}
}
// Also check msg field for URL assignment
if let Some(msg) = json.get("msg").and_then(|v| v.as_str()) {
if msg.contains("started tunnel") {
if let Some(url) = json.get("url").and_then(|v| v.as_str()) {
public_url = Some(url.to_string());
break;
}
}
}
}
}
Ok(Ok(None)) => break, // EOF
Ok(Err(e)) => {
log::warn!("Error reading ngrok output: {}", e);
break;
}
Err(_) => continue, // Timeout, try again
}
}
// If we couldn't get the URL from logs, try the API
if public_url.is_none() {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
public_url = get_ngrok_url_from_api().await;
}
let url = public_url.ok_or("Failed to get ngrok public URL")?;
let tunnel_info = TunnelInfo {
provider: TunnelProvider::Ngrok,
url,
started_at: Utc::now().to_rfc3339(),
port,
is_public: true,
};
// Store the process handle and tunnel info
{
let mut handle = tunnel_state.process_handle.lock().await;
*handle = Some(child);
}
{
let mut active = tunnel_state.active_tunnel.lock().await;
*active = Some(tunnel_info.clone());
}
log::info!("ngrok tunnel started: {}", tunnel_info.url);
Ok(tunnel_info)
}
/// Get ngrok URL from the local API
async fn get_ngrok_url_from_api() -> Option<String> {
// ngrok runs a local API on port 4040
let client = reqwest::Client::new();
let response = client
.get("http://127.0.0.1:4040/api/tunnels")
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let json: serde_json::Value = response.json().await.ok()?;
// Find the https tunnel
json.get("tunnels")?
.as_array()?
.iter()
.find_map(|tunnel| {
let url = tunnel.get("public_url")?.as_str()?;
if url.starts_with("https://") {
Some(url.to_string())
} else {
None
}
})
}
/// Stop ngrok tunnel
pub async fn stop_ngrok_tunnel(tunnel_state: &TunnelState) -> Result<(), String> {
log::info!("Stopping ngrok tunnel");
let mut handle = tunnel_state.process_handle.lock().await;
if let Some(mut child) = handle.take() {
child.kill().await
.map_err(|e| format!("Failed to kill ngrok process: {}", e))?;
// Clear active tunnel
let mut active = tunnel_state.active_tunnel.lock().await;
*active = None;
log::info!("ngrok tunnel stopped");
Ok(())
} else {
// Try to kill any orphaned ngrok processes
#[cfg(unix)]
{
let mut cmd = Command::new("pkill");
cmd.args(["-f", "ngrok"]);
hide_window(&mut cmd);
let _ = cmd.output().await;
}
#[cfg(windows)]
{
let mut cmd = Command::new("taskkill");
cmd.args(["/F", "/IM", "ngrok.exe"]);
hide_window(&mut cmd);
let _ = cmd.output().await;
}
Ok(())
}
}
/// Start cloudflared tunnel
pub async fn start_cloudflared_tunnel(
tunnel_state: &TunnelState,
port: u16,
tunnel_name: Option<String>,
) -> Result<TunnelInfo, String> {
log::info!("Starting cloudflared tunnel on port {}", port);
// Check if already running
{
let handle = tunnel_state.process_handle.lock().await;
if handle.is_some() {
return Err("A tunnel is already running".to_string());
}
}
let url = format!("http://localhost:{}", port);
// Build command args based on whether we have a named tunnel or quick tunnel
let (child, public_url) = if let Some(name) = tunnel_name {
// Named tunnel (requires pre-configuration)
let mut cmd = Command::new("cloudflared");
cmd.args(["tunnel", "run", "--url", &url, &name])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
hide_window(&mut cmd);
let child = cmd.spawn()
.map_err(|e| format!("Failed to start cloudflared: {}", e))?;
// For named tunnels, the URL is typically <tunnel-name>.<domain>
// We need to get this from the tunnel configuration
let tunnel_url = get_cloudflared_tunnel_url(&name).await
.unwrap_or_else(|| format!("https://{}.trycloudflare.com", name));
(child, tunnel_url)
} else {
// Quick tunnel (no account required)
let mut cmd = Command::new("cloudflared");
cmd.args(["tunnel", "--url", &url])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
hide_window(&mut cmd);
let mut child = cmd.spawn()
.map_err(|e| format!("Failed to start cloudflared: {}", e))?;
// Read stderr to get the public URL (cloudflared outputs to stderr)
let stderr = child.stderr.take()
.ok_or("Failed to capture cloudflared stderr")?;
let mut reader = BufReader::new(stderr).lines();
let mut public_url: Option<String> = None;
let timeout = tokio::time::Duration::from_secs(30);
let start = tokio::time::Instant::now();
while start.elapsed() < timeout {
match tokio::time::timeout(tokio::time::Duration::from_secs(1), reader.next_line()).await {
Ok(Ok(Some(line))) => {
// Look for the URL in the output
// cloudflared outputs something like: "Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): https://xxx.trycloudflare.com"
if line.contains("trycloudflare.com") {
// Extract URL from the line
if let Some(start_idx) = line.find("https://") {
let url_part = &line[start_idx..];
let end_idx = url_part.find(|c: char| c.is_whitespace()).unwrap_or(url_part.len());
public_url = Some(url_part[..end_idx].to_string());
break;
}
}
}
Ok(Ok(None)) => break,
Ok(Err(e)) => {
log::warn!("Error reading cloudflared output: {}", e);
break;
}
Err(_) => continue,
}
}
let url = public_url.ok_or("Failed to get cloudflared public URL")?;
(child, url)
};
let tunnel_info = TunnelInfo {
provider: TunnelProvider::Cloudflare,
url: public_url,
started_at: Utc::now().to_rfc3339(),
port,
is_public: true,
};
// Store the process handle and tunnel info
{
let mut handle = tunnel_state.process_handle.lock().await;
*handle = Some(child);
}
{
let mut active = tunnel_state.active_tunnel.lock().await;
*active = Some(tunnel_info.clone());
}
log::info!("cloudflared tunnel started: {}", tunnel_info.url);
Ok(tunnel_info)
}
/// Get cloudflared tunnel URL for a named tunnel
async fn get_cloudflared_tunnel_url(tunnel_name: &str) -> Option<String> {
let mut cmd = Command::new("cloudflared");
cmd.args(["tunnel", "info", tunnel_name, "--output", "json"]);
hide_window(&mut cmd);
let output = cmd.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
// Extract the hostname from tunnel info
json.get("config")?
.get("ingress")?
.as_array()?
.first()?
.get("hostname")
.and_then(|h| h.as_str())
.map(|h| format!("https://{}", h))
}
/// Stop cloudflared tunnel
pub async fn stop_cloudflared_tunnel(tunnel_state: &TunnelState) -> Result<(), String> {
log::info!("Stopping cloudflared tunnel");
let mut handle = tunnel_state.process_handle.lock().await;
if let Some(mut child) = handle.take() {
child.kill().await
.map_err(|e| format!("Failed to kill cloudflared process: {}", e))?;
// Clear active tunnel
let mut active = tunnel_state.active_tunnel.lock().await;
*active = None;
log::info!("cloudflared tunnel stopped");
Ok(())
} else {
// Try to kill any orphaned cloudflared processes
#[cfg(unix)]
{
let mut cmd = Command::new("pkill");
cmd.args(["-f", "cloudflared tunnel"]);
hide_window(&mut cmd);
let _ = cmd.output().await;
}
#[cfg(windows)]
{
let mut cmd = Command::new("taskkill");
cmd.args(["/F", "/IM", "cloudflared.exe"]);
hide_window(&mut cmd);
let _ = cmd.output().await;
}
Ok(())
}
}
/// Start Tailscale Serve/Funnel
pub async fn start_tailscale_tunnel(
tunnel_state: &TunnelState,
port: u16,
use_funnel: bool,
) -> Result<TunnelInfo, String> {
log::info!("Starting Tailscale {} on port {}", if use_funnel { "Funnel" } else { "Serve" }, port);
// Check if already running
{
let active = tunnel_state.active_tunnel.lock().await;
if active.is_some() {
return Err("A tunnel is already running".to_string());
}
}
// First, get the Tailscale hostname
let mut cmd = Command::new("tailscale");
cmd.args(["status", "--json"]);
hide_window(&mut cmd);
let status_output = cmd.output()
.await
.map_err(|e| format!("Failed to get Tailscale status: {}", e))?;
if !status_output.status.success() {
return Err("Tailscale is not running or not logged in".to_string());
}
let status_json: serde_json::Value = serde_json::from_slice(&status_output.stdout)
.map_err(|e| format!("Failed to parse Tailscale status: {}", e))?;
let dns_name = status_json.get("Self")
.and_then(|s| s.get("DNSName"))
.and_then(|d| d.as_str())
.map(|s| s.trim_end_matches('.').to_string())
.ok_or("Failed to get Tailscale DNS name")?;
// Start Tailscale Serve
let _serve_url = format!("http://localhost:{}", port);
// Use funnel if requested (publicly accessible), otherwise serve (tailnet only)
let command = if use_funnel { "funnel" } else { "serve" };
let mut cmd = Command::new("tailscale");
cmd.args([command, &port.to_string()]);
hide_window(&mut cmd);
let output = cmd.output()
.await
.map_err(|e| format!("Failed to start Tailscale {}: {}", command, e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to start Tailscale {}: {}", command, stderr));
}
let public_url = if use_funnel {
format!("https://{}", dns_name)
} else {
format!("https://{}:{}", dns_name, port)
};
let tunnel_info = TunnelInfo {
provider: TunnelProvider::Tailscale,
url: public_url,
started_at: Utc::now().to_rfc3339(),
port,
is_public: use_funnel,
};
// Store tunnel info (Tailscale serve/funnel runs as a daemon, not a child process)
{
let mut active = tunnel_state.active_tunnel.lock().await;
*active = Some(tunnel_info.clone());
}
log::info!("Tailscale {} started: {}", command, tunnel_info.url);
Ok(tunnel_info)
}
/// Stop Tailscale Serve/Funnel
pub async fn stop_tailscale_tunnel(tunnel_state: &TunnelState) -> Result<(), String> {
log::info!("Stopping Tailscale Serve/Funnel");
// Reset serve configuration
let mut cmd = Command::new("tailscale");
cmd.args(["serve", "reset"]);
hide_window(&mut cmd);
let output = cmd.output()
.await
.map_err(|e| format!("Failed to stop Tailscale serve: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Failed to reset Tailscale serve: {}", stderr);
}
// Also reset funnel
let mut cmd = Command::new("tailscale");
cmd.args(["funnel", "reset"]);
hide_window(&mut cmd);
let _ = cmd.output().await;
// Clear active tunnel
let mut active = tunnel_state.active_tunnel.lock().await;
*active = None;
log::info!("Tailscale tunnel stopped");
Ok(())
}
/// Get active tunnel info
pub async fn get_active_tunnel(tunnel_state: &TunnelState) -> Option<TunnelInfo> {
let active = tunnel_state.active_tunnel.lock().await;
active.clone()
}
/// Set preferred tunnel provider
pub async fn set_tunnel_provider(
tunnel_state: &TunnelState,
provider: TunnelProvider,
) -> Result<(), String> {
log::info!("Setting preferred tunnel provider to {:?}", provider);
let mut config = tunnel_state.config.lock().await;
config.preferred_provider = provider;
// Save to disk
save_tunnel_config(&config).await?;
Ok(())
}
/// Start tunnel with preferred provider
pub async fn start_tunnel(
tunnel_state: &TunnelState,
port: u16,
) -> Result<TunnelInfo, String> {
let config = tunnel_state.config.lock().await.clone();
match config.preferred_provider {
TunnelProvider::Ngrok => {
start_ngrok_tunnel(tunnel_state, port, config.ngrok_auth_token).await
}
TunnelProvider::Cloudflare => {
start_cloudflared_tunnel(tunnel_state, port, config.cloudflare_tunnel_id).await
}
TunnelProvider::Tailscale => {
start_tailscale_tunnel(tunnel_state, port, false).await
}
TunnelProvider::None | TunnelProvider::LocalOnly => {
Err("No tunnel provider configured. Please set a preferred provider first.".to_string())
}
}
}
/// Stop active tunnel
pub async fn stop_tunnel(tunnel_state: &TunnelState) -> Result<(), String> {
let active = tunnel_state.active_tunnel.lock().await.clone();
match active.map(|t| t.provider) {
Some(TunnelProvider::Ngrok) => stop_ngrok_tunnel(tunnel_state).await,
Some(TunnelProvider::Cloudflare) => stop_cloudflared_tunnel(tunnel_state).await,
Some(TunnelProvider::Tailscale) => stop_tailscale_tunnel(tunnel_state).await,
_ => {
// No active tunnel, but try to clean up any orphaned processes
let _ = stop_ngrok_tunnel(tunnel_state).await;
let _ = stop_cloudflared_tunnel(tunnel_state).await;
Ok(())
}
}
}
/// Set ngrok auth token
pub async fn set_ngrok_token(
tunnel_state: &TunnelState,
token: String,
) -> Result<(), String> {
log::info!("Setting ngrok auth token");
// Configure ngrok with the token
let mut cmd = Command::new("ngrok");
cmd.args(["config", "add-authtoken", &token]);
hide_window(&mut cmd);
let output = cmd.output()
.await
.map_err(|e| format!("Failed to configure ngrok: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to set ngrok auth token: {}", stderr));
}
// Save to config
let mut config = tunnel_state.config.lock().await;
config.ngrok_auth_token = Some(token);
save_tunnel_config(&config).await?;
Ok(())
}
/// Set cloudflare tunnel ID
pub async fn set_cloudflare_tunnel(
tunnel_state: &TunnelState,
tunnel_id: String,
) -> Result<(), String> {
log::info!("Setting Cloudflare tunnel ID: {}", tunnel_id);
// Verify the tunnel exists
let mut cmd = Command::new("cloudflared");
cmd.args(["tunnel", "info", &tunnel_id]);
hide_window(&mut cmd);
let output = cmd.output()
.await
.map_err(|e| format!("Failed to verify Cloudflare tunnel: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Invalid tunnel ID or not authenticated: {}", stderr));
}
// Save to config
let mut config = tunnel_state.config.lock().await;
config.cloudflare_tunnel_id = Some(tunnel_id);
save_tunnel_config(&config).await?;
Ok(())
}

View File

@@ -1,6 +1,4 @@
pub mod core;
pub mod openclaw_cli;
pub use core::openclaw::OpenClawState;
#[cfg(not(feature = "cli"))]
@@ -148,83 +146,6 @@ pub fn run() {
// Custom updater commands (desktop only)
core::updater::commands::check_for_app_updates,
core::updater::commands::is_update_available,
// OpenClaw commands
core::openclaw::commands::openclaw_check_dependencies,
core::openclaw::commands::openclaw_check_port,
core::openclaw::commands::openclaw_install,
core::openclaw::commands::openclaw_configure,
core::openclaw::commands::openclaw_get_config,
core::openclaw::commands::openclaw_get_auth_token,
core::openclaw::commands::openclaw_ensure_http_api,
core::openclaw::commands::openclaw_sync_model,
core::openclaw::commands::openclaw_sync_all_models,
core::openclaw::commands::openclaw_get_model,
core::openclaw::commands::openclaw_list_channels,
core::openclaw::commands::openclaw_channel_status,
core::openclaw::commands::openclaw_enable,
core::openclaw::commands::openclaw_start,
core::openclaw::commands::openclaw_stop,
core::openclaw::commands::openclaw_status,
core::openclaw::commands::openclaw_check_gateway,
core::openclaw::commands::openclaw_restart,
core::openclaw::commands::openclaw_get_config_dir,
core::openclaw::commands::openclaw_ensure_jan_origin,
core::openclaw::commands::openclaw_setup_for_channels,
// Sandbox commands
core::openclaw::commands::sandbox_get_logs,
core::openclaw::commands::sandbox_restart,
// Telegram commands
core::openclaw::commands::telegram_validate_token,
core::openclaw::commands::telegram_configure,
core::openclaw::commands::telegram_get_config,
core::openclaw::commands::telegram_check_pairing,
core::openclaw::commands::telegram_clear_pending_pairing,
core::openclaw::commands::telegram_get_pending_pairing_codes,
core::openclaw::commands::telegram_approve_pairing,
core::openclaw::commands::telegram_disconnect,
// WhatsApp commands
core::openclaw::commands::whatsapp_validate_connection,
core::openclaw::commands::whatsapp_start_auth,
core::openclaw::commands::whatsapp_get_qr_code,
core::openclaw::commands::whatsapp_check_auth,
core::openclaw::commands::whatsapp_get_config,
core::openclaw::commands::whatsapp_get_contacts,
core::openclaw::commands::whatsapp_disconnect,
// Tailscale commands
core::openclaw::commands::tailscale_detect,
core::openclaw::commands::tailscale_get_status,
core::openclaw::commands::tailscale_configure_serve,
core::openclaw::commands::tailscale_remove_serve,
core::openclaw::commands::tailscale_enable_funnel,
core::openclaw::commands::tailscale_disable_funnel,
core::openclaw::commands::tailscale_get_url,
// Security commands
core::openclaw::commands::security_get_status,
core::openclaw::commands::security_set_auth_mode,
core::openclaw::commands::security_generate_token,
core::openclaw::commands::security_set_password,
core::openclaw::commands::security_verify_token,
core::openclaw::commands::security_set_require_pairing,
core::openclaw::commands::security_get_devices,
core::openclaw::commands::security_approve_device,
core::openclaw::commands::security_revoke_device,
core::openclaw::commands::security_get_logs,
core::openclaw::commands::security_clear_logs,
core::openclaw::commands::security_generate_pairing_code,
// Tunnel commands
core::openclaw::commands::tunnel_get_providers,
core::openclaw::commands::tunnel_detect_all,
core::openclaw::commands::tunnel_set_provider,
core::openclaw::commands::tunnel_start,
core::openclaw::commands::tunnel_stop,
core::openclaw::commands::tunnel_get_active,
core::openclaw::commands::tunnel_set_ngrok_token,
core::openclaw::commands::tunnel_set_cloudflare_tunnel,
core::openclaw::commands::tunnel_get_config,
core::openclaw::commands::tunnel_start_ngrok,
core::openclaw::commands::tunnel_stop_ngrok,
core::openclaw::commands::tunnel_start_cloudflared,
core::openclaw::commands::tunnel_stop_cloudflared,
]);
// Mobile: no updater commands
@@ -323,7 +244,6 @@ pub fn run() {
mcp_server_pids: Arc::new(Mutex::new(HashMap::new())),
provider_configs: Arc::new(Mutex::new(HashMap::new())),
})
.manage(OpenClawState::default())
.setup(|app| {
app.handle().plugin(
tauri_plugin_log::Builder::default()
@@ -462,6 +382,7 @@ pub fn run() {
}
}
#[cfg(feature = "foundation-models")]
{
use tauri_plugin_foundation_models::cleanup_processes;
@@ -469,48 +390,6 @@ pub fn run() {
log::info!("Foundation Models processes cleaned up successfully");
}
// OpenClaw gateway cleanup
{
use crate::core::openclaw::sandbox::Sandbox;
use crate::core::openclaw::OpenClawState;
let openclaw_state = app_handle.state::<OpenClawState>();
let openclaw_future = async {
let sandbox_guard = openclaw_state.sandbox.lock().await;
match sandbox_guard.as_ref() {
Some(sandbox) => {
crate::core::openclaw::lifecycle::stop_openclaw(
sandbox.as_ref(),
&openclaw_state,
)
.await
}
None => {
let direct =
crate::core::openclaw::sandbox_direct::DirectProcessSandbox;
let mut handle =
crate::core::openclaw::sandbox::SandboxHandle::Named(
"exit-cleanup".to_string(),
);
direct.stop(&mut handle).await
}
}
};
match tokio::time::timeout(
tokio::time::Duration::from_secs(10),
openclaw_future,
)
.await
{
Ok(Ok(_)) => {
log::info!("OpenClaw cleanup completed successfully")
}
Ok(Err(e)) => log::warn!("OpenClaw cleanup failed: {}", e),
Err(_) => {
log::warn!("OpenClaw cleanup timed out after 10 seconds")
}
}
}
log::info!("App cleanup completed");
});
});

View File

@@ -1,220 +1,9 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use app_lib::openclaw_cli::{get_openclaw_cli_args, OpenClawCli, OpenClawCommands};
use std::process::exit;
fn main() {
let _ = fix_path_env::fix();
// Check if we're in CLI mode with openclaw subcommand
if let Some(cli) = get_openclaw_cli_args() {
// Run CLI command and exit
run_openclaw_cli(cli);
exit(0);
}
// Normal Tauri app startup
app_lib::run();
}
/// Execute OpenClaw CLI commands
fn run_openclaw_cli(cli: OpenClawCli) {
use tokio::runtime::Runtime;
let rt = Runtime::new().expect("Failed to create runtime");
match cli.command {
OpenClawCommands::Status => {
// Use standalone status check that doesn't require Tauri State
let status = rt.block_on(async {
app_lib::core::openclaw::cli::get_status().await
});
match status {
Ok(status) => {
println!("OpenClaw Status:");
println!(" Installed: {}", if status.installed { "Yes" } else { "No" });
println!(" Running: {}", if status.running { "Yes" } else { "No" });
println!(" OpenClaw Version: {}", status.openclaw_version.clone().unwrap_or_else(|| "N/A".to_string()));
println!(" Runtime Version: {}", status.runtime_version.clone().unwrap_or_else(|| "N/A".to_string()));
println!(" Port (18789): {}", if status.port_available { "Available" } else { "In Use" });
if let Some(err) = status.error {
println!(" Error: {}", err);
}
}
Err(e) => {
eprintln!("Failed to get status: {}", e);
exit(1);
}
}
}
OpenClawCommands::Start => {
let result = rt.block_on(async {
app_lib::core::openclaw::cli::start_gateway().await
});
match result {
Ok(_) => println!("OpenClaw gateway started successfully on port 18789"),
Err(e) => {
eprintln!("Failed to start OpenClaw: {}", e);
exit(1);
}
}
}
OpenClawCommands::Stop => {
let result = rt.block_on(async {
app_lib::core::openclaw::cli::stop_gateway().await
});
match result {
Ok(_) => println!("OpenClaw gateway stopped successfully"),
Err(e) => {
eprintln!("Failed to stop OpenClaw: {}", e);
exit(1);
}
}
}
OpenClawCommands::Logs { lines } => {
// Read logs from the OpenClaw config directory
if let Ok(config_dir) = app_lib::core::openclaw::get_openclaw_config_dir() {
let log_path = config_dir.join("logs");
if log_path.exists() {
if let Ok(entries) = std::fs::read_dir(&log_path) {
let mut log_files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "log"))
.collect();
// Sort by modified time - get the most recent
log_files.sort_by(|a, b| {
let a_time = a.metadata().and_then(|m| m.modified()).ok();
let b_time = b.metadata().and_then(|m| m.modified()).ok();
b_time.cmp(&a_time)
});
if let Some(latest_log) = log_files.first() {
if let Ok(content) = std::fs::read_to_string(latest_log.path()) {
let lines_vec: Vec<&str> = content.lines().collect();
let start = if lines_vec.len() > lines {
lines_vec.len() - lines
} else {
0
};
for line in &lines_vec[start..] {
println!("{}", line);
}
return;
}
}
}
}
// Also try to find logs in data directory
let data_log_path = dirs::data_dir()
.map(|p| p.join("openclaw").join("logs"))
.unwrap_or_default();
if data_log_path.exists() {
if let Ok(entries) = std::fs::read_dir(&data_log_path) {
let mut log_files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "log"))
.collect();
log_files.sort_by(|a, b| {
let a_time = a.metadata().and_then(|m| m.modified()).ok();
let b_time = b.metadata().and_then(|m| m.modified()).ok();
b_time.cmp(&a_time)
});
if let Some(latest_log) = log_files.first() {
if let Ok(content) = std::fs::read_to_string(latest_log.path()) {
let lines_vec: Vec<&str> = content.lines().collect();
let start = if lines_vec.len() > lines {
lines_vec.len() - lines
} else {
0
};
for line in &lines_vec[start..] {
println!("{}", line);
}
return;
}
}
}
}
}
println!("No logs found. Make sure OpenClaw has been started at least once.");
}
OpenClawCommands::Install => {
println!("Installing OpenClaw...");
let result = rt.block_on(async {
app_lib::core::openclaw::commands::openclaw_install().await
});
match result {
Ok(install_result) => {
if install_result.success {
println!("OpenClaw installed successfully!");
if let Some(version) = install_result.version {
println!(" Version: {}", version);
}
} else {
eprintln!("Installation failed: {}", install_result.error.unwrap_or_else(|| "Unknown error".to_string()));
exit(1);
}
}
Err(e) => {
eprintln!("Installation error: {}", e);
exit(1);
}
}
}
OpenClawCommands::Configure { port, bind, jan_base_url, model_id, jan_api_key } => {
let config_input = app_lib::core::openclaw::models::OpenClawConfigInput {
port,
bind,
jan_base_url,
model_id,
jan_api_key,
};
let result = rt.block_on(async {
app_lib::core::openclaw::commands::openclaw_configure(Some(config_input)).await
});
match result {
Ok(config) => {
println!("OpenClaw configured successfully!");
println!(" Gateway Port: {}", config.gateway.port);
println!(" Gateway Bind: {}", config.gateway.bind);
println!(" Jan Base URL: {}", config.models.providers.jan.base_url);
}
Err(e) => {
eprintln!("Configuration failed: {}", e);
exit(1);
}
}
}
OpenClawCommands::Restart => {
let result = rt.block_on(async {
app_lib::core::openclaw::cli::restart_gateway().await
});
match result {
Ok(_) => println!("OpenClaw gateway restarted successfully"),
Err(e) => {
eprintln!("Failed to restart OpenClaw: {}", e);
exit(1);
}
}
}
}
}

View File

@@ -1,68 +0,0 @@
use clap::{Parser, Subcommand};
use std::process::exit;
/// OpenClaw CLI commands
#[derive(Parser)]
#[command(name = "openclaw")]
#[command(about = "Manage OpenClaw gateway", long_about = None)]
pub struct OpenClawCli {
#[command(subcommand)]
pub command: OpenClawCommands,
}
#[derive(Subcommand)]
pub enum OpenClawCommands {
/// Show OpenClaw status (running/stopped, version, port)
Status,
/// Start the OpenClaw gateway
Start,
/// Stop the OpenClaw gateway
Stop,
/// View recent logs from OpenClaw
Logs {
/// Number of lines to show (default: 50)
#[arg(short, long, default_value = "50")]
lines: usize,
},
/// Install OpenClaw (if not installed)
Install,
/// Configure OpenClaw settings
Configure {
/// Port number for the gateway
#[arg(long)]
port: Option<u16>,
/// Bind address
#[arg(long)]
bind: Option<String>,
/// Jan base URL
#[arg(long)]
jan_base_url: Option<String>,
/// Model ID to use (sets agents.defaults.model.primary)
#[arg(long)]
model_id: Option<String>,
/// Jan API key for authentication
#[arg(long)]
jan_api_key: Option<String>,
},
/// Restart the OpenClaw gateway
Restart,
}
/// Check if we're in CLI mode with openclaw subcommand
pub fn get_openclaw_cli_args() -> Option<OpenClawCli> {
let args: Vec<String> = std::env::args().collect();
// Check if we have at least 2 arguments and second is "openclaw"
if args.len() >= 2 && args[1] == "openclaw" {
// Parse just the openclaw subcommand
match OpenClawCli::try_parse_from(&args[1..]) {
Ok(cli) => Some(cli),
Err(e) => {
eprintln!("Error parsing CLI arguments: {}", e);
exit(1);
}
}
} else {
None
}
}

View File

@@ -11,7 +11,7 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
import { Link, useNavigate } from '@tanstack/react-router'
import { PlatformMetaKey } from '@/containers/PlatformMetaKey'
import React, { useEffect, useRef } from 'react'
import React, { useRef } from 'react'
import {
SearchIcon,
type SearchIconHandle,
@@ -40,8 +40,6 @@ import { useSearchDialog } from '@/hooks/useSearchDialog'
import { useProjectDialog } from '@/hooks/useProjectDialog'
import { useAgentMode } from '@/hooks/useAgentMode'
import { TEMPORARY_CHAT_ID } from '@/constants/chat'
import { useAppState } from '@/hooks/useAppState'
import { isOpenClawRunning } from '@/utils/openclaw'
import { PlatformShortcuts, ShortcutAction } from '@/lib/shortcuts'
type AnimatedIconHandle =
@@ -177,13 +175,6 @@ export function NavMain() {
const { open: searchOpen, setOpen: setSearchOpen } = useSearchDialog()
const { open: projectDialogOpen, setOpen: setProjectDialogOpen } =
useProjectDialog()
const openClawAvailable = useAppState((state) => state.openClawRunning)
const setOpenClawRunning = useAppState((state) => state.setOpenClawRunning)
useEffect(() => {
isOpenClawRunning().then(setOpenClawRunning)
}, [setOpenClawRunning])
const navMainItems = getNavMainItems(
() => setProjectDialogOpen(true),
() => setSearchOpen(true),
@@ -195,7 +186,7 @@ export function NavMain() {
useAgentMode.getState().setAgentMode(TEMPORARY_CHAT_ID, true)
navigate({ to: route.home })
}
).filter((item) => item.title !== 'common:newAgentChat' || openClawAvailable)
)
const handleCreateProject = async (name: string, assistantId?: string) => {
const newProject = await addFolder(name, assistantId)

View File

@@ -19,7 +19,6 @@ export const route = {
https_proxy: '/settings/https-proxy',
hardware: '/settings/hardware',
assistant: '/settings/assistant',
remote_access: '/settings/remote-access',
claude_code: '/settings/claude-code',
},
hub: {

View File

@@ -1,63 +0,0 @@
import { memo, useEffect, useState, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { IconRobot } from '@tabler/icons-react'
import { cn } from '@/lib/utils'
import { useAgentMode } from '@/hooks/useAgentMode'
import { isOpenClawRunning } from '@/utils/openclaw'
type AgentModeToggleProps = {
threadId: string
}
const AgentModeToggle = memo(function AgentModeToggle({
threadId,
}: AgentModeToggleProps) {
const [openClawAvailable, setOpenClawAvailable] = useState(false)
const isAgent = useAgentMode((state) => state.isAgentMode(threadId))
const toggleAgentMode = useAgentMode((state) => state.toggleAgentMode)
useEffect(() => {
isOpenClawRunning().then(setOpenClawAvailable)
}, [threadId])
const handleToggle = useCallback(() => {
toggleAgentMode(threadId)
}, [threadId, toggleAgentMode])
if (!openClawAvailable) return null
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'gap-1.5 text-xs font-medium',
isAgent
? 'text-primary bg-primary/10 hover:bg-primary/15'
: 'text-muted-foreground'
)}
onClick={handleToggle}
>
<IconRobot size={16} />
Agent
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{isAgent
? 'Agent mode active — messages route through OpenClaw'
: 'Enable agent mode to use OpenClaw agent'}
</p>
</TooltipContent>
</Tooltip>
)
})
export default AgentModeToggle

View File

@@ -1,136 +0,0 @@
import { useTranslation } from '@/i18n/react-i18next-compat'
import { Button } from '@/components/ui/button'
import {
IconBrandTelegram,
IconBrandWhatsapp,
IconSettings,
IconTrash,
IconLoader2,
} from '@tabler/icons-react'
import { cn } from '@/lib/utils'
import type { ChannelType, ChannelConfig, TelegramConfig, WhatsAppConfig } from '@/types/openclaw'
export type { ChannelType, ChannelConfig, TelegramConfig, WhatsAppConfig }
interface ChannelCardProps {
type: ChannelType
config: ChannelConfig | null
onSettings: () => void
onDisconnect: () => void
OCIsInstalled: boolean
isDisconnecting?: boolean
}
export function ChannelCard({
type,
config,
onSettings,
onDisconnect,
OCIsInstalled,
isDisconnecting = false,
}: ChannelCardProps) {
const { t } = useTranslation()
const isConnected = config?.connected ?? false
const getChannelIcon = () => {
switch (type) {
case 'telegram':
return <IconBrandTelegram size={16} className="text-blue-500" />
case 'whatsapp':
return <IconBrandWhatsapp size={16} className="text-green-500" />
}
}
const getChannelName = (): string => {
switch (type) {
case 'telegram':
return t('settings:remoteAccess.telegram')
case 'whatsapp':
return t('settings:remoteAccess.whatsapp')
}
}
const getChannelDetails = (): string => {
if (!config || !isConnected) return ''
switch (type) {
case 'telegram': {
const tg = config as TelegramConfig
return tg.bot_username ? `@${tg.bot_username}` : ''
}
case 'whatsapp': {
const wa = config as WhatsAppConfig
return wa.phone_number || ''
}
}
}
return (
<div
className={cn("border rounded-lg overflow-hidden transition-all",
isConnected
? 'border-green-500/30 bg-green-500/5'
: 'border-border bg-card'
)}
>
<div className="flex items-center justify-between p-2 px-3">
<div className="flex items-center gap-2">
<div className="shrink-0">{getChannelIcon()}</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">
{getChannelName()}
</span>
<div
className={cn("size-2 rounded-full", {
'bg-green-500': isConnected,
'bg-muted-foreground': !isConnected,
})}
/>
<span
className={cn("text-xs", {
'text-green-500': isConnected,
'text-muted-foreground': !isConnected,
})}
>
{isConnected
? t('settings:remoteAccess.connected')
: t('settings:remoteAccess.notConnected')}
</span>
</div>
{isConnected && getChannelDetails() && (
<p className="text-sm text-muted-foreground">
{getChannelDetails()}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{!isConnected && (
<Button variant="outline" size="sm" onClick={onSettings}>
<IconSettings />
{t('settings:remoteAccess.settings')}
</Button>
)}
{isConnected && (
<Button variant="ghost" size="sm" onClick={onDisconnect} disabled={isDisconnecting}>
{isDisconnecting ? <IconLoader2 className="animate-spin" /> : <IconTrash />}
</Button>
)}
</div>
</div>
{!isConnected && OCIsInstalled && (
<div className="border-t border-border/40 px-4 py-3 bg-muted/30">
<p className="text-xs text-muted-foreground">
{t('settings:remoteAccess.channelNotConnected', {
channel: getChannelName(),
})}
</p>
</div>
)}
</div>
)
}

View File

@@ -88,7 +88,6 @@ import JanBrowserExtensionDialog from '@/containers/dialogs/JanBrowserExtensionD
import { useJanBrowserExtension } from '@/hooks/useJanBrowserExtension'
import { PromptVisionModel } from '@/containers/PromptVisionModel'
import { useAgentMode } from '@/hooks/useAgentMode'
import { isOpenClawRunning } from '@/utils/openclaw'
type ChatInputProps = {
className?: string
@@ -144,10 +143,9 @@ const ChatInput = memo(function ChatInput({
const assistants = useAssistant((state) => state.assistants)
const defaultAssistantId = useAssistant((state) => state.defaultAssistantId)
// Agent mode (OpenClaw)
// Agent mode
// Use TEMPORARY_CHAT_ID as fallback key on the home screen (same pattern as attachments)
const agentModeKey = currentThreadId ?? TEMPORARY_CHAT_ID
const [openClawAvailable, setOpenClawAvailable] = useState(false)
const isAgentMode = useAgentMode((state) =>
state.agentThreads[agentModeKey] === true
)
@@ -155,10 +153,6 @@ const ChatInput = memo(function ChatInput({
const effectiveAgentMode = isAgentMode && !projectId
const toggleAgentMode = useAgentMode((state) => state.toggleAgentMode)
useEffect(() => {
isOpenClawRunning().then(setOpenClawAvailable)
}, [currentThreadId])
const handleAgentToggle = useCallback(() => {
toggleAgentMode(agentModeKey)
}, [agentModeKey, toggleAgentMode])
@@ -1817,7 +1811,7 @@ const ChatInput = memo(function ChatInput({
</Tooltip>
))}
{openClawAvailable && !projectId && isAgentMode && (
{!projectId && isAgentMode && (
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -23,7 +23,6 @@ import { useFavoriteModel } from '@/hooks/useFavoriteModel'
import { predefinedProviders } from '@/constants/providers'
import { useServiceHub } from '@/hooks/useServiceHub'
import { getLastUsedModel } from '@/utils/getModelToStart'
import { syncModelToOpenClaw } from '@/utils/openclaw'
import { ChevronsUpDown } from 'lucide-react'
import { useAgentMode } from '@/hooks/useAgentMode'
import { BotIcon } from 'lucide-react'
@@ -426,18 +425,6 @@ const DropdownModelProvider = memo(function DropdownModelProvider({
searchableModel.model.id
)
// Sync model to OpenClaw (async, don't block UI)
syncModelToOpenClaw(
searchableModel.model.id,
searchableModel.provider.provider,
getModelDisplayName(searchableModel.model)
).catch((error) => {
console.debug(
'Error syncing model to OpenClaw:',
searchableModel.model.id,
error
)
})
// Check mmproj existence for llamacpp models (async, don't block UI)
if (searchableModel.provider.provider === 'llamacpp') {
@@ -491,7 +478,7 @@ const DropdownModelProvider = memo(function DropdownModelProvider({
<div className="border relative z-20 px-4 py-1.5 flex items-center gap-1.5 rounded-full text-muted-foreground">
<BotIcon className="shrink-0 size-4" />
<span className="text-sm font-medium leading-normal">
{t('common:openclawAgent')}
{t('common:newAgentChat')}
</span>
<span className="text-xs ml-1 font-medium px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-600 dark:text-blue-400">
{t('common:experimental')}

View File

@@ -259,12 +259,6 @@ const SettingsMenu = () => {
>
<span>{t('common:claude_code')}</span>
</Link>
<Link
to={route.settings.remote_access}
className="flex items-center gap-2 px-2 py-1 cursor-pointer hover:dark:bg-secondary/60 hover:bg-secondary rounded-sm [&.active]:dark:bg-secondary/80 [&.active]:bg-secondary"
>
<span>{t('common:openclaw')}</span>
</Link>
</div>
</div>

View File

@@ -1,162 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
IconBrandTelegram,
IconBrandWhatsapp,
IconArrowRight,
} from '@tabler/icons-react'
import type { ChannelType } from '@/types/openclaw'
export type { ChannelType }
interface AddChannelDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onSelectChannel: (channel: ChannelType) => void
connectedChannels: ChannelType[]
}
interface ChannelOption {
id: ChannelType
name: string
icon: React.ReactNode
description: string
}
export function AddChannelDialog({
isOpen,
onOpenChange,
onSelectChannel,
connectedChannels,
}: AddChannelDialogProps) {
const { t } = useTranslation()
const [selectedChannel, setSelectedChannel] = useState<ChannelType | null>(null)
useEffect(() => {
if (!isOpen) {
setSelectedChannel(null)
}
}, [isOpen])
const channelOptions: ChannelOption[] = [
{
id: 'telegram',
name: t('settings:remoteAccess.telegram'),
icon: <IconBrandTelegram size={32} className="text-blue-500" />,
description: t('settings:remoteAccess.channelDescriptions.telegram'),
},
{
id: 'whatsapp',
name: t('settings:remoteAccess.whatsapp'),
icon: <IconBrandWhatsapp size={32} className="text-green-500" />,
description: t('settings:remoteAccess.channelDescriptions.whatsapp'),
},
]
const handleChannelSelect = (channel: ChannelType) => {
setSelectedChannel(channel)
}
const handleContinue = () => {
if (selectedChannel) {
onSelectChannel(selectedChannel)
onOpenChange(false)
}
}
const handleOpenWizard = (channel: ChannelType) => {
onSelectChannel(channel)
onOpenChange(false)
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px] max-w-[90vw]">
<DialogHeader>
<DialogTitle>{t('settings:remoteAccess.addChannel.title')}</DialogTitle>
<DialogDescription>
{t('settings:remoteAccess.addChannel.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
{channelOptions.map((channel) => {
const isConnected = connectedChannels.includes(channel.id)
return (
<div
key={channel.id}
className={`relative flex items-center gap-4 p-4 rounded-lg border transition-all cursor-pointer ${
selectedChannel === channel.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-border hover:bg-secondary/50'
}`}
onClick={() => handleChannelSelect(channel.id)}
>
<div className="flex-shrink-0">{channel.icon}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{channel.name}</span>
{isConnected && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-500">
{t('settings:remoteAccess.connected')}
</span>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{channel.description}
</p>
</div>
{isConnected ? (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleOpenWizard(channel.id)
}}
>
{t('settings:remoteAccess.manage')}
</Button>
) : (
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedChannel === channel.id
? 'border-primary bg-primary'
: 'border-muted-foreground'
}`}
>
{selectedChannel === channel.id && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
)}
</div>
)}
</div>
)
})}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('settings:remoteAccess.addChannel.cancel')}
</Button>
<Button
onClick={handleContinue}
disabled={!selectedChannel}
className="gap-2"
>
{t('settings:remoteAccess.addChannel.continue')}
<IconArrowRight size={16} />
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,400 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import {
IconCheck,
IconLoader2,
IconAlertTriangle,
IconRefresh,
} from '@tabler/icons-react'
import { setOpenClawRunningState, syncAllModelsToOpenClaw } from '@/utils/openclaw'
import { ensureModelForServer } from '@/utils/ensureModelForServer'
import { getServiceHub } from '@/hooks/useServiceHub'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
import { useAppState } from '@/hooks/useAppState'
interface EnableProgressEvent {
step: string
progress: number
message: string
sandbox_info?: string
}
interface EnableResult {
success: boolean
already_installed: boolean
steps_completed: string[]
status: {
installed: boolean
running: boolean
runtime_version: string | null
openclaw_version: string | null
port_available: boolean
error: string | null
}
}
interface EnableError {
code: string
message: string
recovery: {
label: string
action: RecoveryAction
description: string
}[]
}
type RecoveryAction =
| 'Retry'
| { UseDifferentPort: { port: number } }
interface EnableProgressDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
const STEP_LABELS: Record<string, string> = {
checking_dependencies: 'Checking runtime',
checking_installation: 'Checking OpenClaw',
installing: 'Installing OpenClaw',
already_installed: 'OpenClaw installed',
configuring: 'Configuring',
starting: 'Starting gateway',
validating: 'Validating configuration',
complete: 'Complete',
}
function parseEnableError(errorStr: string): EnableError | null {
try {
return JSON.parse(errorStr) as EnableError
} catch {
return null
}
}
export function EnableProgressDialog({
isOpen,
onOpenChange,
onSuccess,
}: EnableProgressDialogProps) {
const [progress, setProgress] = useState(0)
const [message, setMessage] = useState('')
const [completedSteps, setCompletedSteps] = useState<string[]>([])
const [currentStep, setCurrentStep] = useState('')
const [sandboxInfo, setSandboxInfo] = useState<string | null>(null)
const [error, setError] = useState<EnableError | null>(null)
const [isRunning, setIsRunning] = useState(false)
const [isDone, setIsDone] = useState(false)
const resetState = useCallback(() => {
setProgress(0)
setMessage('')
setCompletedSteps([])
setCurrentStep('')
setSandboxInfo(null)
setError(null)
setIsRunning(false)
setIsDone(false)
}, [])
const runEnable = useCallback(async () => {
resetState()
setIsRunning(true)
try {
const { apiKey, serverPort, apiPrefix } = useLocalApiServer.getState()
const janBaseUrl = `http://localhost:${serverPort}${apiPrefix}`
const result = await invoke<EnableResult>('openclaw_enable', {
configInput: { janApiKey: apiKey || undefined, janBaseUrl },
})
if (result.success) {
setOpenClawRunningState(true)
setProgress(90)
setMessage('Loading model...')
try {
await ensureModelForServer({
modelsService: getServiceHub().models(),
})
} catch {
// Non-fatal — remote models can still work via OpenClaw
}
setMessage('Syncing models...')
try {
const { providers, selectedModel } = useModelProvider.getState()
await syncAllModelsToOpenClaw(providers, selectedModel?.id)
} catch {
// Non-fatal — models can be synced later
}
// Auto-start local API server on 0.0.0.0 if not already running
const { serverStatus, setServerStatus } = useAppState.getState()
if (serverStatus === 'stopped') {
const { serverPort, apiPrefix, apiKey, trustedHosts, corsEnabled, verboseLogs, proxyTimeout, setServerHost } =
useLocalApiServer.getState()
setServerHost('0.0.0.0')
try {
setServerStatus('pending')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actualPort = await (window as any).core?.api?.startServer({
host: '0.0.0.0',
port: serverPort,
prefix: apiPrefix,
apiKey,
trustedHosts,
isCorsEnabled: corsEnabled,
isVerboseEnabled: verboseLogs,
proxyTimeout,
})
if (actualPort && actualPort !== serverPort) {
useLocalApiServer.getState().setServerPort(actualPort)
// Update OpenClaw's baseUrl to point at the actual port
await invoke('openclaw_configure', {
configInput: { janBaseUrl: `http://localhost:${actualPort}${apiPrefix}` },
}).catch(() => {})
}
setServerStatus('running')
} catch {
setServerStatus('stopped')
// Non-fatal — user can start manually
}
}
setProgress(100)
setMessage('OpenClaw is ready!')
setCurrentStep('complete')
setIsDone(true)
toast.success('Remote access enabled successfully')
onSuccess?.()
}
} catch (err) {
const errorStr = err instanceof Error ? err.message : String(err)
const parsed = parseEnableError(errorStr)
if (parsed) {
setError(parsed)
} else {
setError({
code: 'Unknown',
message: errorStr,
recovery: [
{
label: 'Retry',
action: 'Retry',
description: 'Try again.',
},
],
})
}
} finally {
setIsRunning(false)
}
}, [resetState, onSuccess])
// Listen for progress events
useEffect(() => {
if (!isOpen) return
const unlisten = listen<EnableProgressEvent>(
'openclaw-enable-progress',
(event) => {
const { step, progress: p, message: msg, sandbox_info } = event.payload
setProgress(p)
setMessage(msg)
setCurrentStep(step)
if (sandbox_info) {
setSandboxInfo(sandbox_info)
}
setCompletedSteps((prev) => {
if (step !== prev[prev.length - 1]) {
return [...prev, step]
}
return prev
})
}
)
return () => {
unlisten.then((f) => f())
}
}, [isOpen])
// Start the enable flow when dialog opens
useEffect(() => {
if (isOpen && !isRunning && !isDone && !error) {
runEnable()
}
}, [isOpen, isRunning, isDone, error, runEnable])
// Reset when dialog closes
useEffect(() => {
if (!isOpen) {
resetState()
}
}, [isOpen, resetState])
const handleRecoveryAction = (action: RecoveryAction) => {
if (action === 'Retry') {
setError(null)
// runEnable will be triggered by the useEffect
} else if (
typeof action === 'object' &&
'UseDifferentPort' in action
) {
// For now, retry is the main action - port config can be extended later
setError(null)
}
}
const allSteps = [
'checking_dependencies',
'checking_installation',
'installing',
'configuring',
'starting',
'validating',
'complete',
]
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>
{error
? 'Setup Failed'
: isDone
? 'Setup Complete'
: 'Setting Up Remote Access'}
</DialogTitle>
<DialogDescription>
{error
? 'An error occurred during setup.'
: isDone
? 'OpenClaw is running and ready to use.'
: 'Installing and configuring OpenClaw...'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Progress bar */}
{!error && (
<div className="space-y-2">
<Progress value={progress} />
<p className="text-sm text-muted-foreground text-center">
{message}
</p>
</div>
)}
{/* Step list */}
{!error && (
<div className="space-y-2">
{allSteps.map((step) => {
const isCompleted = completedSteps.includes(step)
const isCurrent = currentStep === step && !isDone
const label = STEP_LABELS[step] || step
// Show sandbox info next to specific steps
const showSandboxInfo = sandboxInfo && (
step === 'checking_dependencies' ||
step === 'starting' ||
step === 'complete'
)
// Skip install step if it didn't happen
if (
step === 'installing' &&
!completedSteps.includes('installing') &&
completedSteps.includes('configuring')
) {
return null
}
return (
<div
key={step}
className="flex items-center gap-2 text-sm"
>
{isCompleted && !isCurrent ? (
<IconCheck className="h-4 w-4 text-green-500 shrink-0" />
) : isCurrent ? (
<IconLoader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
) : (
<div className="h-4 w-4 rounded-full border border-muted-foreground/30 shrink-0" />
)}
<span
className={
isCurrent
? 'text-foreground font-medium'
: isCompleted
? 'text-muted-foreground'
: 'text-muted-foreground/50'
}
>
{label}
</span>
{showSandboxInfo && (
<span className="ml-auto text-xs text-muted-foreground">
{sandboxInfo}
</span>
)}
</div>
)
})}
</div>
)}
{/* Error state */}
{error && (
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<IconAlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<p className="text-sm text-destructive wrap-break-word min-w-0">{error.message}</p>
</div>
<div className="flex flex-col gap-2">
{error.recovery.map((option, i) => (
<Button
key={i}
variant="outline"
size="sm"
className="w-full justify-start h-auto py-2"
onClick={() => handleRecoveryAction(option.action)}
>
<IconRefresh className="mr-2 h-4 w-4" />
{option.label}
<span className="ml-auto text-xs text-muted-foreground">
{option.description}
</span>
</Button>
))}
</div>
</div>
)}
{/* Done state - close button */}
{isDone && (
<div className="flex justify-end">
<Button size="sm" onClick={() => onOpenChange(false)}>
Done
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,206 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
import {
IconSearch,
IconCopy,
IconDownload,
IconRefresh,
IconLoader2,
IconFileText,
} from '@tabler/icons-react'
interface SandboxLogsDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
}
export function SandboxLogsDialog({
isOpen,
onOpenChange,
}: SandboxLogsDialogProps) {
const { t } = useTranslation()
const [logs, setLogs] = useState<string[]>([])
const [filteredLogs, setFilteredLogs] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isRestarting, setIsRestarting] = useState(false)
const logsEndRef = useRef<HTMLDivElement>(null)
const fetchLogs = useCallback(async () => {
setIsLoading(true)
try {
const logLines = await invoke<string[]>('sandbox_get_logs', { lines: 200 })
setLogs(logLines)
setFilteredLogs(logLines)
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
toast.error(`Failed to load logs: ${errorMsg}`)
setLogs([])
setFilteredLogs([])
} finally {
setIsLoading(false)
}
}, [])
const handleRestart = useCallback(async () => {
setIsRestarting(true)
try {
await invoke('sandbox_restart')
toast.success(t('settings:remoteAccess.restarting'))
onOpenChange(false)
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err)
toast.error(`Failed to restart: ${errorMsg}`)
} finally {
setIsRestarting(false)
}
}, [onOpenChange, t])
const handleCopy = useCallback(() => {
const text = filteredLogs.join('\n')
navigator.clipboard.writeText(text)
toast.success(t('settings:remoteAccess.copiedToClipboard'))
}, [filteredLogs, t])
const handleDownload = useCallback(() => {
const text = filteredLogs.join('\n')
const blob = new Blob([text], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `openclaw-logs-${new Date().toISOString().slice(0, 10)}.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [filteredLogs])
// Filter logs when search query changes
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredLogs(logs)
return
}
const query = searchQuery.toLowerCase()
const filtered = logs.filter((log) => log.toLowerCase().includes(query))
setFilteredLogs(filtered)
}, [searchQuery, logs])
// Scroll to bottom when logs change
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [filteredLogs])
// Subscribe to live log events
useEffect(() => {
if (!isOpen) return
const unlisten = listen<string>('openclaw-log-line', (event) => {
const line = event.payload
setLogs((prev) => {
const updated = [...prev, line]
// Keep only last 500 lines
return updated.slice(-500)
})
})
return () => {
unlisten.then((f) => f())
}
}, [isOpen])
// Fetch logs when dialog opens
useEffect(() => {
if (isOpen) {
fetchLogs()
}
}, [isOpen, fetchLogs])
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>{t('settings:remoteAccess.logViewer')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 h-[500px]">
{/* Toolbar */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchLogs}
disabled={isLoading}
>
{isLoading ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconRefresh className="h-4 w-4" />
)}
</Button>
<Button variant="outline" size="sm" onClick={handleCopy}>
<IconCopy className="h-4 w-4 mr-1" />
{t('settings:remoteAccess.copyLogs')}
</Button>
<Button variant="outline" size="sm" onClick={handleDownload}>
<IconDownload className="h-4 w-4 mr-1" />
{t('settings:remoteAccess.downloadLogs')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={isRestarting}
>
{isRestarting ? (
<IconLoader2 className="h-4 w-4 animate-spin mr-1" />
) : (
<IconRefresh className="h-4 w-4 mr-1" />
)}
{t('settings:remoteAccess.restartSandbox')}
</Button>
</div>
{/* Log content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-md border bg-muted/50 p-3 font-mono text-xs">
{filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<IconFileText className="h-8 w-8 mb-2" />
<p>{t('settings:remoteAccess.noLogs')}</p>
</div>
) : (
<div className="space-y-0.5">
{filteredLogs.map((log, index) => (
<div key={index} className="whitespace-pre-wrap break-all overflow-hidden">
{log}
</div>
))}
<div ref={logsEndRef} />
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,747 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { toast } from 'sonner'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
IconNetwork,
IconCheck,
IconX,
IconArrowRight,
IconArrowLeft,
IconLoader2,
IconAlertCircle,
IconExternalLink,
IconWorld,
IconCopy,
IconShieldLock,
} from '@tabler/icons-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
// Types mirroring Rust backend
interface TailscaleStatus {
installed: boolean
running: boolean
logged_in: boolean
version: string | null
error: string | null
}
interface TailscaleInfo {
hostname: string | null
tailnet: string | null
ip_addresses: string[]
dns_name: string | null
serve_enabled: boolean
funnel_enabled: boolean
serve_url: string | null
}
interface TailscaleSetupDialogProps {
isOpen: boolean
onClose: () => void
onSuccess?: () => void
}
type WizardStep = 'detection' | 'configuration' | 'funnel' | 'success'
const TOTAL_STEPS = 4
export function TailscaleSetupDialog({
isOpen,
onClose,
onSuccess,
}: TailscaleSetupDialogProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('detection')
const [isLoading, setIsLoading] = useState(false)
const [status, setStatus] = useState<TailscaleStatus | null>(null)
const [info, setInfo] = useState<TailscaleInfo | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isConfiguringServe, setIsConfiguringServe] = useState(false)
const [isConfiguringFunnel, setIsConfiguringFunnel] = useState(false)
const getStepNumber = (): number => {
const stepMap: Record<WizardStep, number> = {
detection: 1,
configuration: 2,
funnel: 3,
success: 4,
}
return stepMap[step]
}
const fetchTailscaleInfo = useCallback(async () => {
try {
const result = await invoke<TailscaleInfo>('tailscale_get_status')
setInfo(result)
} catch {
// Info fetch may fail if Tailscale is not configured
}
}, [])
const detectTailscale = useCallback(async () => {
setIsLoading(true)
setErrorMessage(null)
try {
const result = await invoke<TailscaleStatus>('tailscale_detect')
setStatus(result)
if (result.error) {
setErrorMessage(result.error)
} else if (result.installed && result.running && result.logged_in) {
// Tailscale is ready, fetch detailed info
await fetchTailscaleInfo()
}
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : 'Failed to detect Tailscale'
)
} finally {
setIsLoading(false)
}
}, [fetchTailscaleInfo])
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setStep('detection')
setIsLoading(false)
setStatus(null)
setInfo(null)
setErrorMessage(null)
setIsConfiguringServe(false)
setIsConfiguringFunnel(false)
}
}, [isOpen])
// Start detection when dialog opens
useEffect(() => {
if (isOpen) {
detectTailscale()
}
}, [isOpen, detectTailscale])
const handleConfigureServe = useCallback(
async (enable: boolean) => {
setIsConfiguringServe(true)
try {
if (enable) {
await invoke('tailscale_configure_serve')
toast.success(t('settings:remoteAccess.tailscaleSettings.serveEnabled'))
} else {
await invoke('tailscale_remove_serve')
toast.success(t('settings:remoteAccess.tailscaleSettings.serveDisabled'))
}
await fetchTailscaleInfo()
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t('settings:remoteAccess.tailscaleSettings.serveError')
)
} finally {
setIsConfiguringServe(false)
}
},
[fetchTailscaleInfo, t]
)
const handleConfigureFunnel = useCallback(
async (enable: boolean) => {
setIsConfiguringFunnel(true)
try {
if (enable) {
await invoke('tailscale_enable_funnel')
toast.success(t('settings:remoteAccess.tailscaleSettings.funnelEnabled'))
} else {
await invoke('tailscale_disable_funnel')
toast.success(t('settings:remoteAccess.tailscaleSettings.funnelDisabled'))
}
await fetchTailscaleInfo()
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t('settings:remoteAccess.tailscaleSettings.funnelError')
)
} finally {
setIsConfiguringFunnel(false)
}
},
[fetchTailscaleInfo, t]
)
const handleCopyUrl = useCallback((url: string) => {
navigator.clipboard.writeText(url)
toast.success(t('settings:remoteAccess.tailscaleSettings.urlCopied'))
}, [t])
const handleOpenUrl = useCallback((url: string) => {
window.open(url, '_blank')
}, [])
const handleClose = useCallback(() => {
onClose()
}, [onClose])
const handleSuccess = useCallback(() => {
if (onSuccess) {
onSuccess()
}
onClose()
}, [onSuccess, onClose])
const renderStepIndicator = () => {
const currentStep = getStepNumber()
return (
<div className="flex items-center justify-center gap-2 mb-6">
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
<div
key={i}
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
i + 1 < currentStep
? 'bg-green-500 text-white'
: i + 1 === currentStep
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground'
}`}
>
{i + 1 < currentStep ? <IconCheck size={16} /> : i + 1}
</div>
))}
</div>
)
}
const renderDetectionStep = () => {
return (
<div className="space-y-4">
<DialogDescription className="text-center">
Checking Tailscale status on your system
</DialogDescription>
{isLoading ? (
<div className="flex flex-col items-center justify-center py-8">
<IconLoader2 className="animate-spin h-12 w-12 text-primary mb-4" />
<p className="text-muted-foreground">Detecting Tailscale...</p>
</div>
) : errorMessage ? (
<div className="flex flex-col items-center py-4">
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center mb-4">
<IconX size={32} className="text-red-500" />
</div>
<p className="text-red-500 text-center mb-4">{errorMessage}</p>
<Button variant="outline" onClick={detectTailscale}>
Try Again
</Button>
</div>
) : !status?.installed ? (
<div className="flex flex-col items-center py-4">
<div className="w-16 h-16 rounded-full bg-yellow-500/10 flex items-center justify-center mb-4">
<IconAlertCircle size={32} className="text-yellow-500" />
</div>
<h3 className="text-lg font-semibold mb-2">
Tailscale Not Installed
</h3>
<p className="text-muted-foreground text-center mb-4 max-w-xs">
Tailscale is required to securely share your OpenClaw endpoint
across your network.
</p>
<div className="bg-secondary/50 rounded-lg p-4 w-full mb-4">
<h4 className="font-medium text-sm mb-2">Installation Steps:</h4>
<ol className="text-muted-foreground text-sm space-y-2">
<li className="flex items-start gap-2">
<span className="font-medium text-foreground">1.</span>
Download Tailscale from the official website
</li>
<li className="flex items-start gap-2">
<span className="font-medium text-foreground">2.</span>
Install and launch Tailscale
</li>
<li className="flex items-start gap-2">
<span className="font-medium text-foreground">3.</span>
Sign in with your account
</li>
</ol>
</div>
<Button
variant="outline"
onClick={() =>
window.open('https://tailscale.com/download', '_blank')
}
className="gap-2"
>
<IconExternalLink size={16} />
Download Tailscale
</Button>
</div>
) : !status?.running ? (
<div className="flex flex-col items-center py-4">
<div className="w-16 h-16 rounded-full bg-yellow-500/10 flex items-center justify-center mb-4">
<IconAlertCircle size={32} className="text-yellow-500" />
</div>
<h3 className="text-lg font-semibold mb-2">Tailscale Not Running</h3>
<p className="text-muted-foreground text-center mb-4 max-w-xs">
Tailscale is installed but not currently running. Please start
Tailscale and try again.
</p>
<Button variant="outline" onClick={detectTailscale} className="gap-2">
<IconLoader2 size={16} />
Check Again
</Button>
</div>
) : !status?.logged_in ? (
<div className="flex flex-col items-center py-4">
<div className="w-16 h-16 rounded-full bg-yellow-500/10 flex items-center justify-center mb-4">
<IconAlertCircle size={32} className="text-yellow-500" />
</div>
<h3 className="text-lg font-semibold mb-2">Not Logged In</h3>
<p className="text-muted-foreground text-center mb-4 max-w-xs">
You need to log in to Tailscale to use this feature.
</p>
<div className="bg-secondary/50 rounded-lg p-4 w-full mb-4">
<p className="text-sm text-muted-foreground mb-2">
Run this command in your terminal:
</p>
<code className="text-sm font-mono bg-background px-3 py-2 rounded block">
tailscale login
</code>
</div>
<Button variant="outline" onClick={detectTailscale} className="gap-2">
<IconLoader2 size={16} />
Check Again
</Button>
</div>
) : (
<div className="flex flex-col items-center py-4">
<div className="w-16 h-16 rounded-full bg-green-500/10 flex items-center justify-center mb-4">
<IconCheck size={32} className="text-green-500" />
</div>
<h3 className="text-lg font-semibold mb-2">Tailscale Ready</h3>
<p className="text-muted-foreground text-center mb-4">
{status.version && `Version ${status.version}`}
</p>
{info && (
<div className="bg-secondary/50 rounded-lg p-4 w-full space-y-2 text-sm">
{info.hostname && (
<div className="flex justify-between">
<span className="text-muted-foreground">Hostname:</span>
<span className="text-foreground font-medium">
{info.hostname}
</span>
</div>
)}
{info.tailnet && (
<div className="flex justify-between">
<span className="text-muted-foreground">Tailnet:</span>
<span className="text-foreground font-medium">
{info.tailnet}
</span>
</div>
)}
{info.dns_name && (
<div className="flex justify-between">
<span className="text-muted-foreground">DNS Name:</span>
<span className="text-foreground font-medium text-xs">
{info.dns_name}
</span>
</div>
)}
{info.ip_addresses.length > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">IP Address:</span>
<span className="text-foreground font-medium">
{info.ip_addresses[0]}
</span>
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
const renderConfigurationStep = () => {
return (
<div className="space-y-4">
<DialogDescription className="text-center">
Configure Tailscale Serve to expose OpenClaw on your tailnet
</DialogDescription>
<div className="flex flex-col items-center py-4">
<div className="w-16 h-16 rounded-full bg-blue-500/10 flex items-center justify-center mb-4">
<IconNetwork size={32} className="text-blue-500" />
</div>
<div className="bg-secondary/50 rounded-lg p-4 w-full mb-4">
<h4 className="font-medium text-sm mb-2">What is Tailscale Serve?</h4>
<p className="text-muted-foreground text-sm">
Tailscale Serve allows you to share your local OpenClaw gateway
with other devices on your tailnet. Only devices connected to your
tailnet can access it.
</p>
</div>
<div className="w-full border border-border rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="font-medium text-foreground">
Enable Tailscale Serve
</h4>
<p className="text-muted-foreground text-sm">
Share OpenClaw on your tailnet
</p>
</div>
<Switch
checked={info?.serve_enabled ?? false}
onCheckedChange={handleConfigureServe}
disabled={isConfiguringServe}
loading={isConfiguringServe}
/>
</div>
{info?.serve_enabled && info?.serve_url && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground mb-2">
Your tailnet URL:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono bg-background px-3 py-2 rounded truncate">
{info.serve_url}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopyUrl(info.serve_url!)}
title="Copy URL"
>
<IconCopy size={16} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenUrl(info.serve_url!)}
title="Open URL"
>
<IconExternalLink size={16} />
</Button>
</div>
</div>
)}
</div>
{info && (
<div className="bg-secondary/50 rounded-lg p-4 w-full mt-4 space-y-2 text-sm">
{info.hostname && (
<div className="flex justify-between">
<span className="text-muted-foreground">Hostname:</span>
<span className="text-foreground font-medium">
{info.hostname}
</span>
</div>
)}
{info.tailnet && (
<div className="flex justify-between">
<span className="text-muted-foreground">Tailnet:</span>
<span className="text-foreground font-medium">
{info.tailnet}
</span>
</div>
)}
{info.ip_addresses.length > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">IP Addresses:</span>
<span className="text-foreground font-medium">
{info.ip_addresses.join(', ')}
</span>
</div>
)}
</div>
)}
</div>
</div>
)
}
const renderFunnelStep = () => {
return (
<div className="space-y-4">
<DialogDescription className="text-center">
Optionally enable Tailscale Funnel for public internet access
</DialogDescription>
<div className="flex flex-col items-center py-4">
<div className="w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center mb-4">
<IconWorld size={32} className="text-orange-500" />
</div>
<div className="bg-secondary/50 rounded-lg p-4 w-full mb-4">
<h4 className="font-medium text-sm mb-2">What is Tailscale Funnel?</h4>
<p className="text-muted-foreground text-sm">
Funnel exposes your OpenClaw gateway to the public internet. Anyone
with the URL can access it, not just devices on your tailnet.
</p>
</div>
{/* Warning banner */}
<div className="w-full bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<IconShieldLock size={20} className="text-yellow-500 shrink-0 mt-0.5" />
<div>
<h4 className="font-medium text-yellow-600 dark:text-yellow-400 text-sm">
Security Warning
</h4>
<p className="text-yellow-600/80 dark:text-yellow-400/80 text-sm mt-1">
Enabling Funnel makes your endpoint publicly accessible on the
internet. Make sure you have appropriate authentication and
access controls in place.
</p>
</div>
</div>
</div>
<div className="w-full border border-border rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h4 className="font-medium text-foreground">
Enable Tailscale Funnel
</h4>
<p className="text-muted-foreground text-sm">
Public internet access
</p>
</div>
<Switch
checked={info?.funnel_enabled ?? false}
onCheckedChange={handleConfigureFunnel}
disabled={isConfiguringFunnel || !info?.serve_enabled}
loading={isConfiguringFunnel}
/>
</div>
{!info?.serve_enabled && (
<p className="text-sm text-muted-foreground mt-2">
Enable Tailscale Serve first to use Funnel
</p>
)}
{info?.funnel_enabled && info?.serve_url && (
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground mb-2">
Your public URL:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono bg-background px-3 py-2 rounded truncate">
{info.serve_url}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopyUrl(info.serve_url!)}
title="Copy URL"
>
<IconCopy size={16} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenUrl(info.serve_url!)}
title="Open URL"
>
<IconExternalLink size={16} />
</Button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
const renderSuccessStep = () => {
return (
<div className="space-y-4">
<div className="flex flex-col items-center justify-center py-4">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mb-4">
<IconCheck size={32} className="text-green-500" />
</div>
<DialogTitle className="text-center">
Tailscale Setup Complete
</DialogTitle>
</div>
<DialogDescription className="text-center">
Your OpenClaw gateway is now accessible via Tailscale
</DialogDescription>
<div className="bg-secondary/50 rounded-lg p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Tailscale Serve:</span>
<span
className={`font-medium ${info?.serve_enabled ? 'text-green-500' : 'text-muted-foreground'}`}
>
{info?.serve_enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Tailscale Funnel:</span>
<span
className={`font-medium ${info?.funnel_enabled ? 'text-orange-500' : 'text-muted-foreground'}`}
>
{info?.funnel_enabled ? 'Enabled (Public)' : 'Disabled'}
</span>
</div>
{info?.serve_url && (
<div className="pt-2 border-t border-border">
<p className="text-sm text-muted-foreground mb-2">Access URL:</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono bg-background px-3 py-2 rounded truncate">
{info.serve_url}
</code>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopyUrl(info.serve_url!)}
title="Copy URL"
>
<IconCopy size={16} />
</Button>
</div>
</div>
)}
</div>
</div>
)
}
const renderStepContent = () => {
switch (step) {
case 'detection':
return renderDetectionStep()
case 'configuration':
return renderConfigurationStep()
case 'funnel':
return renderFunnelStep()
case 'success':
return renderSuccessStep()
default:
return null
}
}
const canGoNext = (): boolean => {
switch (step) {
case 'detection':
return (
status?.installed === true &&
status?.running === true &&
status?.logged_in === true
)
case 'configuration':
return true // Can always proceed (Serve is optional)
case 'funnel':
return true // Can always proceed (Funnel is optional)
case 'success':
return true
default:
return false
}
}
const canGoBack = (): boolean => {
return step !== 'detection' && step !== 'success'
}
const handleNext = () => {
switch (step) {
case 'detection':
setStep('configuration')
break
case 'configuration':
setStep('funnel')
break
case 'funnel':
setStep('success')
break
case 'success':
handleSuccess()
break
}
}
const handleBack = () => {
switch (step) {
case 'configuration':
setStep('detection')
break
case 'funnel':
setStep('configuration')
break
default:
break
}
}
const getNextButtonText = (): string => {
switch (step) {
case 'detection':
return 'Continue'
case 'configuration':
return 'Next'
case 'funnel':
return 'Finish'
case 'success':
return 'Done'
default:
return 'Next'
}
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[480px] max-w-[90vw]">
<DialogHeader>
<div className="flex items-center justify-center gap-2 mb-2">
<IconNetwork size={24} className="text-blue-500" />
<DialogTitle>Tailscale Setup</DialogTitle>
</div>
</DialogHeader>
{renderStepIndicator()}
{renderStepContent()}
<DialogFooter className="gap-2 sm:gap-0">
{canGoBack() && (
<Button variant="outline" onClick={handleBack} className="gap-1">
<IconArrowLeft size={16} />
Back
</Button>
)}
{step === 'detection' && !canGoNext() && !isLoading && (
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
)}
{(canGoNext() || step === 'success') && (
<Button onClick={handleNext} disabled={!canGoNext()} className="gap-1">
{getNextButtonText()}
{step !== 'success' && <IconArrowRight size={16} />}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,577 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { invoke } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogHeader,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
IconBrandTelegram,
IconCheck,
IconLoader2,
IconAlertCircle,
IconExternalLink,
IconRefresh,
} from '@tabler/icons-react'
import type { TelegramConfig } from '@/types/openclaw'
interface TelegramTokenValidation {
valid: boolean
bot_username: string | null
bot_name: string | null
error: string | null
}
interface TelegramWizardProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConnected?: (config: TelegramConfig) => void
}
type WizardStep = 'instructions' | 'token' | 'configure' | 'pairing' | 'success'
const TOTAL_STEPS = 5
export function TelegramWizard({
isOpen,
onOpenChange,
onConnected,
}: TelegramWizardProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('instructions')
const [token, setToken] = useState('')
const [validation, setValidation] = useState<TelegramTokenValidation | null>(null)
const [isValidating, setIsValidating] = useState(false)
const [isConfiguring, setIsConfiguring] = useState(false)
const [config, setConfig] = useState<TelegramConfig | null>(null)
const [pairingCode, setPairingCode] = useState('')
const [isApproving, setIsApproving] = useState(false)
const [isResettingPairing, setIsResettingPairing] = useState(false)
useEffect(() => {
if (!isOpen) {
setStep('instructions')
setToken('')
setPairingCode('')
setValidation(null)
setConfig(null)
}
}, [isOpen])
// Poll for pending pairing codes to auto-fill the input
useEffect(() => {
if (step !== 'pairing' || !isOpen) return
const pollCodes = async () => {
try {
const codes = await invoke<string[]>('telegram_get_pending_pairing_codes')
if (codes.length > 0 && !pairingCode) {
setPairingCode(codes[codes.length - 1])
}
} catch { /* retry next interval */ }
}
pollCodes()
const interval = setInterval(pollCodes, 3000)
return () => clearInterval(interval)
}, [step, isOpen, pairingCode])
const getStepNumber = (): number => {
const stepMap: Record<WizardStep, number> = {
instructions: 1,
token: 2,
configure: 3,
pairing: 4,
success: 5,
}
return stepMap[step]
}
const handleValidateToken = async () => {
if (!token.trim()) {
toast.error(t('settings:remoteAccess.telegramWizard.errors.tokenRequired'))
return
}
setIsValidating(true)
try {
const result = await invoke<TelegramTokenValidation>('telegram_validate_token', {
token: token.trim(),
})
setValidation(result)
if (result.valid) {
toast.success(
t('settings:remoteAccess.telegramWizard.step2.validToken', {
username: result.bot_username,
})
)
} else {
toast.error(
result.error || t('settings:remoteAccess.telegramWizard.step2.invalidToken')
)
}
} catch {
toast.error(t('settings:remoteAccess.telegramWizard.errors.networkError'))
} finally {
setIsValidating(false)
}
}
const handleConfigure = async () => {
if (!validation?.valid) return
setIsConfiguring(true)
try {
const result = await invoke<TelegramConfig>('telegram_configure', {
token: token.trim(),
})
setConfig(result)
setStep('pairing')
toast.success(t('settings:remoteAccess.telegramWizard.step3.success'))
} catch (error) {
toast.error(
t('settings:remoteAccess.telegramWizard.step3.error', {
error: error instanceof Error ? error.message : String(error),
})
)
} finally {
setIsConfiguring(false)
}
}
const handleApprovePairing = async () => {
if (!pairingCode.trim()) {
toast.error(t('settings:remoteAccess.telegramWizard.step4.codeRequired'))
return
}
setIsApproving(true)
try {
await invoke('telegram_approve_pairing', { code: pairingCode.trim() })
toast.success(t('settings:remoteAccess.telegramWizard.step4.approved'))
const updatedConfig = await invoke<TelegramConfig>('telegram_get_config')
setConfig(updatedConfig)
setStep('success')
if (onConnected) {
onConnected(updatedConfig)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
toast.error(
t('settings:remoteAccess.telegramWizard.step4.approveError', { error: errorMsg })
)
} finally {
setIsApproving(false)
}
}
const handleSkipPairing = () => {
setStep('success')
if (config && onConnected) {
onConnected(config)
}
}
const handleResetPairing = async () => {
setIsResettingPairing(true)
try {
await invoke('telegram_clear_pending_pairing')
setPairingCode('')
toast.success(t('settings:remoteAccess.telegramWizard.step4.resetSuccess'))
} catch {
toast.error(t('settings:remoteAccess.telegramWizard.step4.resetError'))
} finally {
setIsResettingPairing(false)
}
}
const handleDisconnect = async () => {
try {
await invoke('telegram_disconnect')
setConfig(null)
setToken('')
setValidation(null)
setStep('instructions')
toast.success(t('settings:remoteAccess.telegramWizard.status.notConnected'))
} catch { /* already disconnected */ }
}
const renderStepIndicator = () => {
const currentStep = getStepNumber()
return (
<div className="flex items-center justify-center gap-2 mb-6">
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
<div
key={i}
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium ${
i + 1 < currentStep
? 'bg-green-500 dark:bg-green-700 text-white'
: i + 1 === currentStep
? 'bg-secondary border border-primary/60 text-primary'
: 'bg-secondary text-muted-foreground'
}`}
>
{i + 1 < currentStep ? (
<IconCheck size={16} />
) : (
i + 1
)}
</div>
))}
</div>
)
}
const renderInstructionsStep = () => (
<div className="space-y-4">
<DialogDescription className="text-center">
{t('settings:remoteAccess.telegramWizard.description')}
</DialogDescription>
<div className="bg-secondary/50 rounded-lg p-4 space-y-3">
<h4 className="font-medium text-foreground">
{t('settings:remoteAccess.telegramWizard.step1.title')}
</h4>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>{t('settings:remoteAccess.telegramWizard.step1.instruction1')}</li>
<li>{t('settings:remoteAccess.telegramWizard.step1.instruction2')}</li>
<li>{t('settings:remoteAccess.telegramWizard.step1.instruction3')}</li>
<li>{t('settings:remoteAccess.telegramWizard.step1.instruction4')}</li>
</ol>
</div>
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => openUrl('https://t.me/BotFather')}
className="gap-2"
>
<IconBrandTelegram size={18} />
Open BotFather
</Button>
</div>
</div>
)
const renderTokenStep = () => (
<div className="space-y-4">
<DialogDescription className="text-center">
{t('settings:remoteAccess.telegramWizard.step2.title')}
</DialogDescription>
<div className="space-y-2">
<Input
type="password"
value={token}
onChange={(e) => {
setToken(e.target.value)
setValidation(null)
}}
placeholder={t('settings:remoteAccess.telegramWizard.step2.placeholder')}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isValidating) {
handleValidateToken()
}
}}
/>
</div>
{validation?.valid && (
<div className="flex items-center gap-1 text-green-500 text-sm">
<IconCheck size={16} />
<span>
{t('settings:remoteAccess.telegramWizard.step2.validToken', {
username: validation.bot_username,
})}
</span>
</div>
)}
{validation && !validation.valid && (
<div className="flex items-center gap-1 text-red-500 text-sm">
<IconAlertCircle size={16} />
<span>{validation.error}</span>
</div>
)}
<Button
onClick={handleValidateToken}
disabled={isValidating || !token.trim()}
className="w-full"
variant="outline"
>
{isValidating ? (
<>
<IconLoader2 className="animate-spin size-3" />
{t('settings:remoteAccess.telegramWizard.step2.validating')}
</>
) : (
t('settings:remoteAccess.telegramWizard.step2.validate')
)}
</Button>
</div>
)
const renderConfigureStep = () => (
<div className="space-y-4">
<DialogDescription className="text-center">
{t('settings:remoteAccess.telegramWizard.step3.title')}
</DialogDescription>
<div className="flex flex-col items-center justify-center py-8">
<IconBrandTelegram size={48} className="text-blue-500 mb-4" />
<p className="text-muted-foreground">
{isConfiguring
? t('settings:remoteAccess.telegramWizard.step3.connecting')
: validation?.bot_username
? `@${validation.bot_username}`
: ''}
</p>
</div>
{isConfiguring && (
<div className="flex justify-center">
<IconLoader2 className="animate-spin h-8 w-8 text-primary" />
</div>
)}
</div>
)
const botUsername = config?.bot_username || validation?.bot_username
const renderPairingStep = () => (
<div className="space-y-4">
<DialogDescription className="text-center">
{t('settings:remoteAccess.telegramWizard.step4.title')}
</DialogDescription>
<div className="bg-secondary/50 rounded-lg p-4 space-y-3">
<p className="text-muted-foreground text-sm text-center">
{t('settings:remoteAccess.telegramWizard.step4.instruction')}
</p>
{botUsername && (
<a
href={`https://t.me/${botUsername}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 text-primary hover:underline font-medium"
>
<IconBrandTelegram size={18} />
{t('settings:remoteAccess.telegramWizard.step4.openBot', { username: botUsername })}
<IconExternalLink size={14} />
</a>
)}
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>{t('settings:remoteAccess.telegramWizard.step4.instruction1')}</li>
<li>{t('settings:remoteAccess.telegramWizard.step4.instruction2')}</li>
<li>{t('settings:remoteAccess.telegramWizard.step4.instruction3')}</li>
</ol>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">
{t('settings:remoteAccess.telegramWizard.step4.codeLabel')}
</label>
<Input
value={pairingCode}
onChange={(e) => setPairingCode(e.target.value.toUpperCase())}
placeholder={t('settings:remoteAccess.telegramWizard.step4.codePlaceholder')}
className="font-mono text-center text-lg tracking-wider"
/>
<button
type="button"
onClick={handleResetPairing}
disabled={isResettingPairing}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors mx-auto"
>
<IconRefresh size={12} className={isResettingPairing ? 'animate-spin' : ''} />
{t('settings:remoteAccess.telegramWizard.step4.resetPairing')}
</button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleSkipPairing}
className="flex-1"
>
{t('settings:remoteAccess.telegramWizard.step4.skipForNow')}
</Button>
<Button
onClick={handleApprovePairing}
disabled={isApproving || !pairingCode.trim()}
className="flex-1"
>
{isApproving ? (
<>
<IconLoader2 className="animate-spin h-4 w-4 mr-2" />
{t('settings:remoteAccess.telegramWizard.step4.approving')}
</>
) : (
t('settings:remoteAccess.telegramWizard.step4.approve')
)}
</Button>
</div>
</div>
)
const renderSuccessStep = () => (
<div className="space-y-4">
<div className="flex flex-col items-center justify-center py-4">
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mb-4">
<IconCheck size={32} className="text-green-500" />
</div>
<DialogTitle className="text-center">
{t('settings:remoteAccess.telegramWizard.step5.success')}
</DialogTitle>
</div>
<DialogDescription className="text-center">
{t('settings:remoteAccess.telegramWizard.step5.instruction')}
</DialogDescription>
<div className="bg-secondary/50 rounded-lg p-4 text-center space-y-2">
{config?.bot_username && (
<p className="text-foreground">
{t('settings:remoteAccess.telegramWizard.step5.botUsername', {
username: config.bot_username,
})}
</p>
)}
<p className="text-muted-foreground text-sm">
{t('settings:remoteAccess.telegramWizard.step5.pairedUsers', {
count: config?.paired_users || 0,
})}
</p>
</div>
<Button variant="outline" onClick={handleDisconnect} className="w-full">
{t('settings:remoteAccess.telegramWizard.buttons.disconnect')}
</Button>
</div>
)
const renderStep = () => {
switch (step) {
case 'instructions':
return renderInstructionsStep()
case 'token':
return renderTokenStep()
case 'configure':
return renderConfigureStep()
case 'pairing':
return renderPairingStep()
case 'success':
return renderSuccessStep()
default:
return null
}
}
const handleNext = () => {
switch (step) {
case 'instructions':
setStep('token')
break
case 'token':
if (validation?.valid) {
setStep('configure')
handleConfigure()
}
break
case 'configure':
break
case 'pairing':
handleApprovePairing()
break
case 'success':
onOpenChange(false)
break
}
}
const handleBack = () => {
switch (step) {
case 'token':
setStep('instructions')
break
case 'configure':
setStep('token')
setValidation(null)
break
case 'pairing':
setStep('configure')
break
case 'success':
break
}
}
const canGoNext = (): boolean => {
switch (step) {
case 'instructions':
return true
case 'token':
return validation?.valid ?? false
case 'configure':
return false
case 'pairing':
return false
case 'success':
return true
default:
return false
}
}
const canGoBack = (): boolean => {
return step !== 'instructions' && step !== 'success' && step !== 'configure'
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px] max-w-[90vw]" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center justify-center gap-2 mb-2">
<IconBrandTelegram size={24} className="text-blue-500" />
<DialogTitle>{t('settings:remoteAccess.telegramWizard.title')}</DialogTitle>
</div>
</DialogHeader>
{renderStepIndicator()}
{renderStep()}
{step !== 'configure' && step !== 'success' && (
<DialogFooter className="gap-2">
{canGoBack() && (
<Button variant="ghost" onClick={handleBack} className="gap-1">
{t('settings:remoteAccess.telegramWizard.buttons.back')}
</Button>
)}
{step !== 'pairing' && (
<Button
onClick={handleNext}
disabled={!canGoNext()}
className="gap-1"
>
{t('settings:remoteAccess.telegramWizard.buttons.next')}
</Button>
)}
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -1,708 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { invoke } from '@tauri-apps/api/core'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
import {
IconLoader2,
IconCheck,
IconChevronDown,
IconExternalLink,
IconCopy,
IconRefresh,
IconPlayerPlay,
IconPlayerStop,
IconAlertCircle,
} from '@tabler/icons-react'
import type { TunnelProvider } from '@/types/openclaw'
interface TunnelProviderStatus {
provider: TunnelProvider
installed: boolean
authenticated: boolean
version: string | null
error: string | null
}
interface TunnelProvidersStatus {
tailscale: TunnelProviderStatus
ngrok: TunnelProviderStatus
cloudflare: TunnelProviderStatus
active_provider: TunnelProvider
active_tunnel: TunnelInfo | null
}
interface TunnelInfo {
provider: TunnelProvider
url: string
started_at: string
port: number
is_public: boolean
}
interface TunnelConfig {
preferred_provider: TunnelProvider
ngrok_auth_token: string | null
cloudflare_tunnel_id: string | null
auto_start: boolean
}
interface TunnelSelectionDialogProps {
isOpen: boolean
onClose: () => void
onTailscaleSetup?: () => void
onSave?: () => void
}
interface ProviderCardProps {
provider: TunnelProvider
name: string
description: string
features: string[]
status: TunnelProviderStatus | null
selected: boolean
onSelect: () => void
installUrl?: string
isLoading?: boolean
children?: React.ReactNode
}
function ProviderCard({
provider,
name,
description,
features,
status,
selected,
onSelect,
installUrl,
isLoading,
children,
}: ProviderCardProps) {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(selected)
useEffect(() => {
if (selected) {
setIsExpanded(true)
}
}, [selected])
const isInstalled = status?.installed ?? false
return (
<div
className={cn(
'rounded-lg border p-4 transition-all cursor-pointer',
selected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
)}
onClick={onSelect}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1">
<div
className={cn(
'w-4 h-4 rounded-full border-2 mt-0.5 shrink-0 flex items-center justify-center',
selected ? 'border-primary bg-primary' : 'border-muted-foreground'
)}
>
{selected && <IconCheck size={10} className="text-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{name}</h3>
{provider !== 'none' && provider !== 'localonly' && (
<div className="flex items-center gap-1.5">
{isLoading ? (
<IconLoader2 size={14} className="animate-spin text-muted-foreground" />
) : isInstalled ? (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-xs text-muted-foreground">
{status?.version || t('settings:tunnel.installed')}
</span>
</div>
) : (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-xs text-muted-foreground">
{t('settings:tunnel.notInstalled')}
</span>
</div>
)}
</div>
)}
</div>
<p className="text-sm text-muted-foreground">{description}</p>
{features.length > 0 && (
<ul className="mt-2 space-y-1">
{features.map((feature, index) => (
<li
key={index}
className="text-xs text-muted-foreground flex items-center gap-1.5"
>
<span className="text-primary">-</span>
{feature}
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/* Expandable configuration section */}
{selected && children && (
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleTrigger
className="flex items-center gap-1 text-sm text-muted-foreground mt-3 hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
>
<IconChevronDown
size={16}
className={cn(
'transition-transform',
isExpanded && 'rotate-180'
)}
/>
{t('settings:tunnel.configuration')}
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div
className="border-t border-border pt-3"
onClick={(e) => e.stopPropagation()}
>
{!isInstalled && installUrl && (
<div className="mb-3 p-3 bg-muted/50 rounded-md">
<p className="text-sm text-muted-foreground mb-2">
{t('settings:tunnel.installRequired')}
</p>
<Button
variant="outline"
size="sm"
onClick={() => window.open(installUrl, '_blank')}
>
<IconExternalLink size={14} className="mr-1.5" />
{t('settings:tunnel.installInstructions')}
</Button>
</div>
)}
{children}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
)
}
export function TunnelSelectionDialog({
isOpen,
onClose,
onTailscaleSetup,
onSave,
}: TunnelSelectionDialogProps) {
const { t } = useTranslation()
// State
const [providersStatus, setProvidersStatus] = useState<TunnelProvidersStatus | null>(null)
// Config is used for initial state hydration
const [, setConfig] = useState<TunnelConfig | null>(null)
const [selectedProvider, setSelectedProvider] = useState<TunnelProvider>('none')
const [isDetecting, setIsDetecting] = useState(false)
const [isStarting, setIsStarting] = useState(false)
const [isStopping, setIsStopping] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [showStopConfirm, setShowStopConfirm] = useState(false)
// Configuration inputs
const [ngrokToken, setNgrokToken] = useState('')
const [cloudflareTunnelId, setCloudflareTunnelId] = useState('')
const [autoStart, setAutoStart] = useState(false)
const fetchProvidersStatus = useCallback(async () => {
try {
setIsDetecting(true)
const status = await invoke<TunnelProvidersStatus>('tunnel_get_providers')
setProvidersStatus(status)
if (status.active_provider) {
setSelectedProvider(status.active_provider)
}
} catch {
toast.error(t('settings:tunnel.fetchStatusError'))
} finally {
setIsDetecting(false)
}
}, [t])
const fetchConfig = useCallback(async () => {
try {
const tunnelConfig = await invoke<TunnelConfig>('tunnel_get_config')
setConfig(tunnelConfig)
setSelectedProvider(tunnelConfig.preferred_provider || 'none')
setNgrokToken(tunnelConfig.ngrok_auth_token || '')
setCloudflareTunnelId(tunnelConfig.cloudflare_tunnel_id || '')
setAutoStart(tunnelConfig.auto_start)
} catch {
// Config may not exist yet
}
}, [])
// Fetch providers status and config on open
useEffect(() => {
if (isOpen) {
fetchProvidersStatus()
fetchConfig()
}
}, [isOpen, fetchProvidersStatus, fetchConfig])
// Reset state when dialog closes
useEffect(() => {
if (!isOpen) {
setShowStopConfirm(false)
}
}, [isOpen])
const handleDetectAll = useCallback(async () => {
try {
setIsDetecting(true)
const status = await invoke<TunnelProvidersStatus>('tunnel_detect_all')
setProvidersStatus(status)
toast.success(t('settings:tunnel.detectionComplete'))
} catch {
toast.error(t('settings:tunnel.detectionError'))
} finally {
setIsDetecting(false)
}
}, [t])
const handleSelectProvider = useCallback(async (provider: TunnelProvider) => {
setSelectedProvider(provider)
}, [])
const handleSaveNgrokToken = useCallback(async () => {
if (!ngrokToken.trim()) {
toast.error(t('settings:tunnel.ngrok.tokenRequired'))
return
}
try {
await invoke('tunnel_set_ngrok_token', { token: ngrokToken.trim() })
toast.success(t('settings:tunnel.ngrok.tokenSaved'))
await fetchProvidersStatus()
} catch {
toast.error(t('settings:tunnel.ngrok.tokenSaveError'))
}
}, [ngrokToken, t, fetchProvidersStatus])
const handleSaveCloudflareTunnel = useCallback(async () => {
if (!cloudflareTunnelId.trim()) {
toast.error(t('settings:tunnel.cloudflare.tunnelIdRequired'))
return
}
try {
await invoke('tunnel_set_cloudflare_tunnel', { tunnelId: cloudflareTunnelId.trim() })
toast.success(t('settings:tunnel.cloudflare.tunnelIdSaved'))
await fetchProvidersStatus()
} catch {
toast.error(t('settings:tunnel.cloudflare.tunnelIdSaveError'))
}
}, [cloudflareTunnelId, t, fetchProvidersStatus])
const handleStartTunnel = useCallback(async () => {
try {
setIsStarting(true)
await invoke('tunnel_set_provider', { provider: selectedProvider })
await invoke('tunnel_start')
toast.success(t('settings:tunnel.startSuccess'))
await fetchProvidersStatus()
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
toast.error(t('settings:tunnel.startError', { error: errorMsg }))
} finally {
setIsStarting(false)
}
}, [selectedProvider, t, fetchProvidersStatus])
const handleStopTunnel = useCallback(async () => {
try {
setIsStopping(true)
await invoke('tunnel_stop')
toast.success(t('settings:tunnel.stopSuccess'))
setShowStopConfirm(false)
await fetchProvidersStatus()
} catch {
toast.error(t('settings:tunnel.stopError'))
} finally {
setIsStopping(false)
}
}, [t, fetchProvidersStatus])
const handleSave = useCallback(async () => {
try {
setIsSaving(true)
await invoke('tunnel_set_provider', { provider: selectedProvider })
toast.success(t('settings:tunnel.saved'))
onSave?.()
onClose()
} catch {
toast.error(t('settings:tunnel.saveError'))
} finally {
setIsSaving(false)
}
}, [selectedProvider, t, onSave, onClose])
const handleCopyUrl = useCallback(() => {
if (providersStatus?.active_tunnel?.url) {
navigator.clipboard.writeText(providersStatus.active_tunnel.url)
toast.success(t('settings:tunnel.urlCopied'))
}
}, [providersStatus?.active_tunnel?.url, t])
const activeTunnel = providersStatus?.active_tunnel
const hasTunnelRunning = !!activeTunnel
// Get provider status helper
const getProviderStatus = (provider: TunnelProvider): TunnelProviderStatus | null => {
if (!providersStatus) return null
switch (provider) {
case 'tailscale':
return providersStatus.tailscale
case 'ngrok':
return providersStatus.ngrok
case 'cloudflare':
return providersStatus.cloudflare
default:
return null
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[600px] max-w-[90vw]">
<DialogHeader>
<DialogTitle>{t('settings:tunnel.title')}</DialogTitle>
<DialogDescription>
{t('settings:tunnel.description')}
</DialogDescription>
</DialogHeader>
{/* Active Tunnel Status */}
{hasTunnelRunning && (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
<div>
<p className="font-medium text-foreground">
{t('settings:tunnel.activeStatus')}
</p>
<p className="text-sm text-muted-foreground">
{t('settings:tunnel.provider')}: {activeTunnel.provider}
</p>
</div>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setShowStopConfirm(true)}
disabled={isStopping}
>
{isStopping ? (
<IconLoader2 className="animate-spin mr-1.5 h-4 w-4" />
) : (
<IconPlayerStop className="mr-1.5 h-4 w-4" />
)}
{t('settings:tunnel.stop')}
</Button>
</div>
<div className="mt-3 flex items-center gap-2">
<Input
value={activeTunnel.url}
readOnly
className="font-mono text-sm bg-background"
/>
<Button variant="outline" size="icon" onClick={handleCopyUrl}>
<IconCopy size={16} />
</Button>
</div>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span>
{t('settings:tunnel.port')}: {activeTunnel.port}
</span>
<span>
{activeTunnel.is_public
? t('settings:tunnel.publicAccess')
: t('settings:tunnel.privateAccess')}
</span>
</div>
</div>
)}
{/* Stop Confirmation */}
{showStopConfirm && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<IconAlertCircle className="text-destructive shrink-0 mt-0.5" size={20} />
<div className="flex-1">
<p className="font-medium text-foreground">
{t('settings:tunnel.stopConfirmTitle')}
</p>
<p className="text-sm text-muted-foreground mt-1">
{t('settings:tunnel.stopConfirmDescription')}
</p>
<div className="flex items-center gap-2 mt-3">
<Button
variant="destructive"
size="sm"
onClick={handleStopTunnel}
disabled={isStopping}
>
{isStopping && (
<IconLoader2 className="animate-spin mr-1.5 h-4 w-4" />
)}
{t('settings:tunnel.confirmStop')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowStopConfirm(false)}
>
{t('common:cancel')}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Detect Providers Button */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{t('settings:tunnel.selectProvider')}
</span>
<Button
variant="outline"
size="sm"
onClick={handleDetectAll}
disabled={isDetecting}
>
{isDetecting ? (
<IconLoader2 className="animate-spin mr-1.5 h-4 w-4" />
) : (
<IconRefresh className="mr-1.5 h-4 w-4" />
)}
{t('settings:tunnel.detectProviders')}
</Button>
</div>
{/* Provider Selection */}
<div className="space-y-3 max-h-[40vh] overflow-y-auto pr-1">
{/* None / Local Only */}
<ProviderCard
provider="none"
name={t('settings:tunnel.providers.none.name')}
description={t('settings:tunnel.providers.none.description')}
features={[]}
status={null}
selected={selectedProvider === 'none' || selectedProvider === 'localonly'}
onSelect={() => handleSelectProvider('none')}
/>
{/* Tailscale */}
<ProviderCard
provider="tailscale"
name={t('settings:tunnel.providers.tailscale.name')}
description={t('settings:tunnel.providers.tailscale.description')}
features={[
t('settings:tunnel.providers.tailscale.feature1'),
t('settings:tunnel.providers.tailscale.feature2'),
t('settings:tunnel.providers.tailscale.feature3'),
]}
status={getProviderStatus('tailscale')}
selected={selectedProvider === 'tailscale'}
onSelect={() => handleSelectProvider('tailscale')}
installUrl="https://tailscale.com/download"
isLoading={isDetecting}
>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{t('settings:tunnel.providers.tailscale.configInfo')}
</p>
{onTailscaleSetup && (
<Button variant="outline" size="sm" onClick={onTailscaleSetup}>
<IconExternalLink size={14} className="mr-1.5" />
{t('settings:tunnel.providers.tailscale.openSetup')}
</Button>
)}
</div>
</ProviderCard>
{/* ngrok */}
<ProviderCard
provider="ngrok"
name={t('settings:tunnel.providers.ngrok.name')}
description={t('settings:tunnel.providers.ngrok.description')}
features={[
t('settings:tunnel.providers.ngrok.feature1'),
t('settings:tunnel.providers.ngrok.feature2'),
t('settings:tunnel.providers.ngrok.feature3'),
]}
status={getProviderStatus('ngrok')}
selected={selectedProvider === 'ngrok'}
onSelect={() => handleSelectProvider('ngrok')}
installUrl="https://ngrok.com/download"
isLoading={isDetecting}
>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
{t('settings:tunnel.providers.ngrok.authToken')}
</label>
<div className="flex items-center gap-2">
<Input
type="password"
value={ngrokToken}
onChange={(e) => setNgrokToken(e.target.value)}
placeholder={t('settings:tunnel.providers.ngrok.tokenPlaceholder')}
className="font-mono"
/>
<Button
variant="outline"
size="sm"
onClick={handleSaveNgrokToken}
disabled={!ngrokToken.trim()}
>
{t('common:save')}
</Button>
</div>
</div>
<Button
variant="link"
size="sm"
className="p-0 h-auto"
onClick={() =>
window.open('https://dashboard.ngrok.com/get-started/your-authtoken', '_blank')
}
>
<IconExternalLink size={14} className="mr-1" />
{t('settings:tunnel.providers.ngrok.getToken')}
</Button>
</div>
</ProviderCard>
{/* Cloudflare */}
<ProviderCard
provider="cloudflare"
name={t('settings:tunnel.providers.cloudflare.name')}
description={t('settings:tunnel.providers.cloudflare.description')}
features={[
t('settings:tunnel.providers.cloudflare.feature1'),
t('settings:tunnel.providers.cloudflare.feature2'),
t('settings:tunnel.providers.cloudflare.feature3'),
]}
status={getProviderStatus('cloudflare')}
selected={selectedProvider === 'cloudflare'}
onSelect={() => handleSelectProvider('cloudflare')}
installUrl="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
isLoading={isDetecting}
>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground mb-1.5 block">
{t('settings:tunnel.providers.cloudflare.tunnelId')}
</label>
<div className="flex items-center gap-2">
<Input
value={cloudflareTunnelId}
onChange={(e) => setCloudflareTunnelId(e.target.value)}
placeholder={t('settings:tunnel.providers.cloudflare.tunnelIdPlaceholder')}
className="font-mono"
/>
<Button
variant="outline"
size="sm"
onClick={handleSaveCloudflareTunnel}
disabled={!cloudflareTunnelId.trim()}
>
{t('common:save')}
</Button>
</div>
</div>
<Button
variant="link"
size="sm"
className="p-0 h-auto"
onClick={() =>
window.open('https://one.dash.cloudflare.com/', '_blank')
}
>
<IconExternalLink size={14} className="mr-1" />
{t('settings:tunnel.providers.cloudflare.openDashboard')}
</Button>
</div>
</ProviderCard>
</div>
{/* Auto-start Toggle */}
<div className="flex items-center justify-between py-2 border-t border-border">
<div>
<p className="font-medium text-foreground">
{t('settings:tunnel.autoStart')}
</p>
<p className="text-sm text-muted-foreground">
{t('settings:tunnel.autoStartDescription')}
</p>
</div>
<Switch
checked={autoStart}
onCheckedChange={setAutoStart}
disabled={selectedProvider === 'none'}
/>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={onClose}>
{t('common:cancel')}
</Button>
{!hasTunnelRunning && selectedProvider !== 'none' && (
<Button
variant="secondary"
onClick={handleStartTunnel}
disabled={isStarting || !getProviderStatus(selectedProvider)?.installed}
>
{isStarting ? (
<IconLoader2 className="animate-spin mr-1.5 h-4 w-4" />
) : (
<IconPlayerPlay className="mr-1.5 h-4 w-4" />
)}
{t('settings:tunnel.startTunnel')}
</Button>
)}
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <IconLoader2 className="animate-spin mr-1.5 h-4 w-4" />}
{t('common:save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,453 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { invoke } from '@tauri-apps/api/core'
import { toast } from 'sonner'
import {
IconBrandWhatsapp,
IconQrcode,
IconCheck,
IconX,
IconRefresh,
IconArrowLeft,
} from '@tabler/icons-react'
import {
Dialog,
DialogContent,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import type { WhatsAppConfig } from '@/types/openclaw'
export type { WhatsAppConfig }
interface WhatsAppAuthStatus {
in_progress: boolean
qr_code_ready: boolean
qr_code: string | null
authenticated: boolean
error: string | null
}
interface WhatsAppWizardDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConnected: (config: WhatsAppConfig) => void
}
type WizardStep = 'welcome' | 'setting_up' | 'scanning' | 'verifying' | 'success' | 'error'
export function WhatsAppWizardDialog({
isOpen,
onOpenChange,
onConnected,
}: WhatsAppWizardDialogProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('welcome')
const [isLoading, setIsLoading] = useState(false)
const [isPolling, setIsPolling] = useState(false)
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null)
const [, setAuthStatus] = useState<WhatsAppAuthStatus | null>(null)
const [qrCodeImage, setQrCodeImage] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [config, setConfig] = useState<WhatsAppConfig | null>(null)
useEffect(() => {
return () => {
if (pollingInterval) {
clearInterval(pollingInterval)
}
}
}, [pollingInterval])
useEffect(() => {
if (!isOpen && pollingInterval) {
clearInterval(pollingInterval)
setPollingInterval(null)
}
}, [isOpen, pollingInterval])
useEffect(() => {
if (isOpen) {
setStep('welcome')
setIsLoading(false)
setIsPolling(false)
setAuthStatus(null)
setQrCodeImage(null)
setErrorMessage(null)
setConfig(null)
setPollingInterval((prev) => {
if (prev) {
clearInterval(prev)
}
return null
})
}
}, [isOpen])
const startPolling = useCallback(() => {
const interval = setInterval(async () => {
try {
const status = await invoke<WhatsAppAuthStatus>('whatsapp_check_auth')
setAuthStatus(status)
if (status.qr_code) {
setQrCodeImage(status.qr_code)
}
if (status.authenticated) {
clearInterval(interval)
setPollingInterval(null)
setIsPolling(false)
const whatsappConfig = await invoke<WhatsAppConfig>('whatsapp_get_config')
setConfig(whatsappConfig)
setStep('success')
onConnected(whatsappConfig)
}
if (status.error && !status.in_progress) {
const transientPatterns = [
'515',
'restart required',
'Stream Errored',
'not linked',
'not configured',
'timed out',
'Request timed out',
'Connection closed',
'Gateway is not running',
]
const isTransient = transientPatterns.some((p) =>
status.error!.includes(p)
)
if (!isTransient) {
clearInterval(interval)
setPollingInterval(null)
setIsPolling(false)
setErrorMessage(status.error)
setStep('error')
}
}
} catch { /* retry next interval */ }
}, 5000)
setPollingInterval(interval)
setIsPolling(true)
}, [onConnected])
const startAuthentication = useCallback(async () => {
try {
setIsLoading(true)
setErrorMessage(null)
setStep('setting_up')
const status = await invoke<WhatsAppAuthStatus>('whatsapp_start_auth')
setAuthStatus(status)
if (status.error) {
setErrorMessage(status.error)
setStep('error')
return
}
if (status.authenticated) {
const whatsappConfig = await invoke<WhatsAppConfig>('whatsapp_get_config')
setConfig(whatsappConfig)
setStep('success')
onConnected(whatsappConfig)
return
}
if (status.qr_code) {
setQrCodeImage(status.qr_code)
}
setStep('scanning')
startPolling()
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : String(error)
)
setStep('error')
} finally {
setIsLoading(false)
}
}, [onConnected, startPolling])
const handleDisconnect = useCallback(async () => {
try {
setIsLoading(true)
await invoke('whatsapp_disconnect')
setStep('welcome')
setConfig(null)
setQrCodeImage(null)
if (pollingInterval) {
clearInterval(pollingInterval)
setPollingInterval(null)
}
setIsPolling(false)
} catch {
toast.error(t('settings:remoteAccess.disconnectError'))
} finally {
setIsLoading(false)
}
}, [pollingInterval, t])
const handleClose = useCallback(() => {
if (pollingInterval) {
clearInterval(pollingInterval)
setPollingInterval(null)
}
onOpenChange(false)
}, [pollingInterval, onOpenChange])
const renderStepContent = () => {
switch (step) {
case 'welcome':
return (
<div className="flex flex-col items-center text-center py-4">
<div className="w-16 h-16 rounded-full bg-green-500/10 flex items-center justify-center mb-4">
<IconBrandWhatsapp size={32} className="text-green-500" />
</div>
<h3 className="text-lg font-semibold mb-2">
{t('settings:remoteAccess.whatsappWizard.setupTitle')}
</h3>
<p className="text-muted-foreground text-sm mb-6 max-w-xs">
{t('settings:remoteAccess.whatsappWizard.setupDescription')}
</p>
<div className="bg-muted/50 rounded-lg p-4 w-full text-left">
<h4 className="font-medium text-sm mb-2">
{t('settings:remoteAccess.whatsappWizard.howItWorks')}
</h4>
<ol className="text-muted-foreground text-sm space-y-2">
<li className="flex items-start gap-2">
<span className="font-medium text-foreground">1.</span>
{t('settings:remoteAccess.whatsappWizard.whatsappStep1')}
</li>
<li className="flex items-start gap-2">
<span className="font-medium text-foreground">2.</span>
{t('settings:remoteAccess.whatsappWizard.whatsappStep2')}
</li>
<li className="flex items-start gap-2">
<span className="font-medium text-foreground">3.</span>
{t('settings:remoteAccess.whatsappWizard.whatsappStep3')}
</li>
</ol>
</div>
</div>
)
case 'setting_up':
return (
<div className="flex flex-col items-center text-center py-4">
<div className="w-16 h-16 rounded-full bg-blue-500/10 flex items-center justify-center mb-4">
<IconRefresh size={32} className="text-blue-500 animate-spin" />
</div>
<h3 className="text-lg font-semibold mb-2">
{t('settings:remoteAccess.whatsappWizard.settingUp') || 'Setting up...'}
</h3>
<p className="text-muted-foreground text-sm mb-4">
{t('settings:remoteAccess.whatsappWizard.settingUpDescription') || 'Configuring OpenClaw and preparing WhatsApp connection. This may take a moment...'}
</p>
<div className="bg-muted/50 rounded-lg p-4 w-full text-left">
<ol className="text-muted-foreground text-sm space-y-2">
<li className="flex items-center gap-2">
<IconCheck size={16} className="text-green-500" />
<span>{t('settings:remoteAccess.whatsappWizard.checkingOpenClaw') || 'Checking OpenClaw installation'}</span>
</li>
<li className="flex items-center gap-2">
<IconRefresh size={16} className="animate-spin" />
<span>{t('settings:remoteAccess.whatsappWizard.configuringGateway') || 'Configuring Gateway connection'}</span>
</li>
<li className="flex items-center gap-2 text-muted-foreground/50">
<span className="w-4 h-4" />
<span>{t('settings:remoteAccess.whatsappWizard.enablingWhatsApp') || 'Enabling WhatsApp channel'}</span>
</li>
</ol>
</div>
</div>
)
case 'scanning':
return (
<div className="flex flex-col items-center text-center py-4">
<div className="w-16 h-16 rounded-full bg-green-500/10 flex items-center justify-center mb-4">
<IconQrcode size={32} className="text-green-500" />
</div>
<h3 className="text-lg font-semibold mb-2">
{t('settings:remoteAccess.whatsappWizard.scanQrCode')}
</h3>
<p className="text-muted-foreground text-sm mb-4">
{t('settings:remoteAccess.whatsappWizard.qrCodeInstructions')}
</p>
<div className="bg-white p-4 rounded-lg mb-4 min-h-[200px] min-w-[200px] flex items-center justify-center">
{qrCodeImage ? (
<img
src={qrCodeImage}
alt="WhatsApp QR Code"
className="w-48 h-48"
/>
) : (
<div className="flex flex-col items-center text-muted-foreground">
<IconQrcode size={48} className="mb-2" />
<span className="text-sm">
{t('settings:remoteAccess.whatsappWizard.generatingQrCode')}
</span>
</div>
)}
</div>
{isPolling && (
<p className="text-muted-foreground text-xs">
{t('settings:remoteAccess.whatsappWizard.waitingForScan')}
</p>
)}
</div>
)
case 'verifying':
return (
<div className="flex flex-col items-center text-center py-4">
<div className="w-16 h-16 rounded-full bg-blue-500/10 flex items-center justify-center mb-4">
<IconRefresh size={32} className="text-blue-500 animate-spin" />
</div>
<h3 className="text-lg font-semibold mb-2">
{t('settings:remoteAccess.whatsappWizard.verifyingConnection')}
</h3>
<p className="text-muted-foreground text-sm">
{t('settings:remoteAccess.whatsappWizard.pleaseWait')}
</p>
</div>
)
case 'success':
return (
<div className="flex flex-col items-center text-center py-4">
<div className="w-16 h-16 rounded-full bg-green-500/10 flex items-center justify-center mb-4">
<IconCheck size={32} className="text-green-500" />
</div>
<h3 className="text-lg font-semibold mb-2">
{t('settings:remoteAccess.whatsappWizard.whatsappConnected')}
</h3>
<p className="text-muted-foreground text-sm mb-4">
{t('settings:remoteAccess.whatsappWizard.whatsappConnectedDescription')}
</p>
{config?.phone_number && (
<div className="bg-muted/50 rounded-lg px-4 py-2">
<span className="text-sm font-medium">{config.phone_number}</span>
</div>
)}
</div>
)
case 'error':
return (
<div className="flex flex-col items-center text-center py-4">
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center mb-4">
<IconX size={32} className="text-red-500" />
</div>
<h3 className="text-lg font-semibold mb-2">
{t('settings:remoteAccess.whatsappWizard.connectionFailed')}
</h3>
<p className="text-muted-foreground text-sm mb-4">
{errorMessage || t('settings:remoteAccess.whatsappWizard.unknownError')}
</p>
</div>
)
default:
return null
}
}
const renderFooter = () => {
switch (step) {
case 'welcome':
return (
<>
<Button variant="outline" onClick={handleClose}>
{t('common:cancel')}
</Button>
<Button onClick={startAuthentication} disabled={isLoading}>
{isLoading ? t('settings:remoteAccess.whatsappWizard.connecting') : t('settings:remoteAccess.whatsappWizard.startSetup')}
</Button>
</>
)
case 'scanning':
return (
<>
<Button
variant="outline"
onClick={() => {
if (pollingInterval) {
clearInterval(pollingInterval)
setPollingInterval(null)
}
setIsPolling(false)
setStep('welcome')
}}
disabled={isLoading}
>
<IconArrowLeft className="mr-2 h-4 w-4" />
{t('settings:remoteAccess.whatsappWizard.back')}
</Button>
<Button onClick={startAuthentication} disabled={isLoading}>
<IconRefresh className="mr-2 h-4 w-4" />
{t('settings:remoteAccess.whatsappWizard.refreshQrCode')}
</Button>
</>
)
case 'verifying':
return null
case 'success':
return (
<>
<Button variant="outline" onClick={handleDisconnect} disabled={isLoading}>
{t('settings:remoteAccess.disconnect')}
</Button>
<Button onClick={handleClose}>
{t('common:done')}
</Button>
</>
)
case 'error':
return (
<>
<Button
variant="outline"
onClick={() => {
setStep('welcome')
setErrorMessage(null)
}}
>
<IconArrowLeft className="mr-2 h-4 w-4" />
{t('settings:remoteAccess.whatsappWizard.tryAgain')}
</Button>
<Button onClick={handleClose}>
{t('common:close')}
</Button>
</>
)
default:
return null
}
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
<div className="py-2">{renderStepContent()}</div>
<DialogFooter className="sm:justify-between">
{renderFooter()}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -10,7 +10,7 @@ type AgentModeState = {
toggleAgentMode: (threadId: string) => void
setAgentMode: (threadId: string, enabled: boolean) => void
removeThread: (threadId: string) => void
/** Clear agent mode for all threads (e.g. when OpenClaw is stopped). */
/** Clear agent mode for all threads. */
clearAll: () => void
}

View File

@@ -22,7 +22,6 @@ type AppState = {
ragToolNames: Set<string>
mcpToolNames: Set<string>
serverStatus: 'running' | 'stopped' | 'pending'
openClawRunning: boolean
abortControllers: Record<string, AbortController>
tokenSpeed?: TokenSpeed
showOutOfContextDialog?: boolean
@@ -31,7 +30,6 @@ type AppState = {
activeModels: string[]
cancelToolCall?: () => void
setServerStatus: (value: 'running' | 'stopped' | 'pending') => void
setOpenClawRunning: (running: boolean) => void
updateStreamingContent: (content: ThreadMessage | undefined) => void
updateLoadingModel: (loading: boolean) => void
updateTools: (tools: MCPTool[]) => void
@@ -60,7 +58,6 @@ export const useAppState = create<AppState>()((set) => ({
ragToolNames: new Set<string>(),
mcpToolNames: new Set<string>(),
serverStatus: 'stopped',
openClawRunning: false,
abortControllers: {},
tokenSpeed: undefined,
currentToolCall: undefined,
@@ -90,7 +87,6 @@ export const useAppState = create<AppState>()((set) => ({
set({ mcpToolNames: new Set(names) })
},
setServerStatus: (value) => set({ serverStatus: value }),
setOpenClawRunning: (running) => set({ openClawRunning: running }),
setAbortController: (threadId, controller) => {
set((state) => ({
abortControllers: {

View File

@@ -15,8 +15,6 @@ import { useToolAvailable } from '@/hooks/useToolAvailable'
import { ModelFactory } from './model-factory'
import { useModelProvider } from '@/hooks/useModelProvider'
import { useAssistant } from '@/hooks/useAssistant'
import { useAgentMode } from '@/hooks/useAgentMode'
import { getOpenClawAuthToken, ensureOpenClawHttpApi, checkOpenClawGateway, OPENCLAW_GATEWAY_URL } from '@/utils/openclaw'
import { useThreads } from '@/hooks/useThreads'
import { useAttachments } from '@/hooks/useAttachments'
import { ExtensionManager } from '@/lib/extension'
@@ -273,83 +271,38 @@ export class CustomChatTransport implements ChatTransport<UIMessage> {
// Ensure tools updated before sending messages
await this.refreshTools()
// Check if agent mode is active for this thread
const isAgentMode = this.threadId
? useAgentMode.getState().isAgentMode(this.threadId)
: false
// Capture the effective provider name early so the Anthropic serial
// tool-use repair later uses the same value that was used to create the
// model, even if the user switches provider mid-request.
let effectiveProviderName: string | undefined
if (isAgentMode) {
// Agent mode: route to OpenClaw gateway
effectiveProviderName = 'openclaw'
await ensureOpenClawHttpApi()
const gatewayReachable = await checkOpenClawGateway()
if (!gatewayReachable) {
throw new Error('Cannot reach the OpenClaw gateway. Please check that Remote Access is running in Settings.')
}
const authToken = await getOpenClawAuthToken()
if (!authToken) {
throw new Error('OpenClaw is not available. Please check that it is running in Settings > Remote Access.')
}
const openclawProvider: ProviderObject = {
active: true,
provider: 'openclaw',
api_key: authToken,
base_url: OPENCLAW_GATEWAY_URL,
settings: [],
models: [],
custom_header: this.threadId
? [{ header: 'x-openclaw-session-key', value: this.threadId }]
: [],
}
const modelId = useModelProvider.getState().selectedModel?.id
const providerId = useModelProvider.getState().selectedProvider
const effectiveProviderName = providerId
const provider = useModelProvider.getState().getProviderByName(providerId)
if (this.serviceHub && modelId && provider) {
try {
this.model = await ModelFactory.createModel('openclaw', openclawProvider)
const updatedProvider = useModelProvider
.getState()
.getProviderByName(providerId)
// Get assistant parameters from current assistant
const currentAssistant = useAssistant.getState().currentAssistant
const inferenceParams = currentAssistant?.parameters
// Create the model using the factory
// For llamacpp provider, startModel is called internally in ModelFactory.createLlamaCppModel
this.model = await ModelFactory.createModel(
modelId,
updatedProvider ?? provider,
inferenceParams ?? {}
)
} catch (error) {
console.error('Failed to create OpenClaw model:', error)
console.error('Failed to create model:', error)
throw new Error(
`Failed to connect to OpenClaw: ${error instanceof Error ? error.message : JSON.stringify(error)}`
`Failed to create model: ${error instanceof Error ? error.message : JSON.stringify(error)}`
)
}
} else {
// Normal mode: use selected provider
const modelId = useModelProvider.getState().selectedModel?.id
const providerId = useModelProvider.getState().selectedProvider
effectiveProviderName = providerId
const provider = useModelProvider.getState().getProviderByName(providerId)
if (this.serviceHub && modelId && provider) {
try {
const updatedProvider = useModelProvider
.getState()
.getProviderByName(providerId)
// Get assistant parameters from current assistant
const currentAssistant = useAssistant.getState().currentAssistant
const inferenceParams = currentAssistant?.parameters
// Create the model using the factory
// For llamacpp provider, startModel is called internally in ModelFactory.createLlamaCppModel
this.model = await ModelFactory.createModel(
modelId,
updatedProvider ?? provider,
inferenceParams ?? {}
)
} catch (error) {
console.error('Failed to create model:', error)
throw new Error(
`Failed to create model: ${error instanceof Error ? error.message : JSON.stringify(error)}`
)
}
} else {
throw new Error('ServiceHub not initialized or model/provider missing.')
}
throw new Error('ServiceHub not initialized or model/provider missing.')
}
// Fix for Anthropic serial tool-use (error 400): when an assistant message
@@ -358,7 +311,7 @@ export class CustomChatTransport implements ChatTransport<UIMessage> {
// tool_use / tool_result pairing that the Claude API requires.
// See: https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use#parallel-tool-use
const messagesToConvert = (() => {
if (isAgentMode || effectiveProviderName !== 'anthropic') {
if (effectiveProviderName !== 'anthropic') {
return options.messages
}
return options.messages.flatMap((message) => {
@@ -415,11 +368,10 @@ export class CustomChatTransport implements ChatTransport<UIMessage> {
: baseMessages
// Include tools only if we have tools loaded AND model supports them
// In agent mode, OpenClaw manages its own tools
const hasTools = Object.keys(this.tools).length > 0
const selectedModel = useModelProvider.getState().selectedModel
const modelSupportsTools = selectedModel?.capabilities?.includes('tools') ?? this.modelSupportsTools
const shouldEnableTools = !isAgentMode && hasTools && modelSupportsTools
const shouldEnableTools = hasTools && modelSupportsTools
// Track stream timing and token count for token speed calculation
let streamStartTime: number | undefined
@@ -499,40 +451,6 @@ export class CustomChatTransport implements ChatTransport<UIMessage> {
? error.message
: JSON.stringify(error)
if (isAgentMode) {
const lower = errorMessage.toLowerCase()
if (
lower.includes('connection refused') ||
lower.includes('econnrefused') ||
lower.includes('failed to fetch') ||
lower.includes('network error') ||
lower.includes('connect error')
) {
return `Cannot connect to the local server. Please check that the API server is running.\n\nOriginal error: ${errorMessage}`
}
if (
lower.includes('model not found') ||
lower.includes('model_not_found') ||
lower.includes('no model loaded') ||
lower.includes('no running session') ||
lower.includes('no slot available')
) {
return `No model is currently loaded. Please start a model first in the model settings.\n\nOriginal error: ${errorMessage}`
}
if (
(lower.includes('context') && (lower.includes('size') || lower.includes('length') || lower.includes('limit') || lower.includes('exceed'))) ||
lower.includes('prompt is too long') ||
lower.includes('maximum context') ||
lower.includes('token limit') ||
lower.includes('too many tokens')
) {
return `The prompt exceeds the model's context size. Try increasing the context size in model settings or using a shorter prompt.\n\nOriginal error: ${errorMessage}`
}
}
return errorMessage
},
onFinish: ({ responseMessage }) => {
@@ -556,35 +474,7 @@ export class CustomChatTransport implements ChatTransport<UIMessage> {
? prependTextDeltaToUIStream(uiStream, continueContent)
: uiStream
// In agent mode, detect empty responses (HTTP 200 but no text content)
// and inject an error chunk so the UI shows a message instead of an empty bubble.
if (!isAgentMode) return finalStream
let hasTextContent = false
let hasError = false
return finalStream.pipeThrough(
new TransformStream<UIMessageChunk, UIMessageChunk>({
transform(chunk, controller) {
if ((chunk as { type: string }).type === 'text-delta') {
hasTextContent = true
}
if ((chunk as { type: string }).type === 'error') {
hasError = true
}
controller.enqueue(chunk)
},
flush(controller) {
if (!hasTextContent && !hasError) {
controller.enqueue({
type: 'error',
errorText:
'The agent returned an empty response. This usually means ' +
'the local model server is not running or the model failed to process the request.',
} as UIMessageChunk)
}
},
})
)
return finalStream
}
async reconnectToStream(

View File

@@ -188,9 +188,6 @@ export class ModelFactory {
case 'xai':
return this.createXaiModel(modelId, provider)
case 'openclaw':
return this.createOpenAICompatibleModel(modelId, provider)
default:
return this.createOpenAICompatibleModel(modelId, provider, parameters)
}

View File

@@ -18,7 +18,6 @@
"newChat": "Nový chat",
"newAgentChat": "Nový chat agenta",
"chats": "Chaty",
"openclawAgent": "OpenClaw Agent",
"favorites": "Oblíbené",
"recents": "Nedávné",
"hub": "Centrum",

View File

@@ -102,10 +102,8 @@
"threadScrollDesc": "Vyberte, jak má viewport chatu reagovat při příchodu nových zpráv.",
"threadScrollFlowTitle": "Plynulé rolování",
"threadScrollFlowHint": "Ukotvuje viewport k poslední zprávě, kterou odešlete.",
"threadScrollStickyTitle": "Lepkavé rolování",
"threadScrollStickyHint": "Automaticky sleduje příchozí odpovědi v reálném čase.",
"tokenCounterCompact": "Kompaktní počítadlo tokenů",
"tokenCounterCompactDesc": "Zobrazit počítadlo tokenů uvnitř chatového vstupu. Pokud je zakázáno, počítadlo tokenů se zobrazí pod vstupem.",
"codeBlockTitle": "Blok kódu",
@@ -306,175 +304,5 @@
"updateError": "Nepodařilo se aktualizovat Llamacpp"
},
"backendInstallSuccess": "Backend úspěšně nainstalován",
"backendInstallError": "Nepodařilo se nainstalovat backend",
"remoteAccess": {
"title": "Vzdálený přístup",
"running": "Vzdálený přístup je nyní spuštěn",
"stopped": "Vzdálený přístup byl zastaven",
"urlCopied": "Odkaz zkopírován do schránky",
"port": "Port",
"runtimeVersion": "Verze runtime",
"openclawVersion": "Verze OpenClaw",
"openclawIntegration": "Integrace OpenClaw",
"enableRemoteAccess": "Povolit OpenClaw",
"disableRemoteAccess": "Zakázat OpenClaw",
"settings": "Nastavení",
"install": "Nainstalovat",
"start": "Spustit",
"stop": "Zastavit",
"startError": "Nepodařilo se spustit vzdálený přístup. Zkuste to prosím znovu.",
"stopError": "Nepodařilo se zastavit vzdálený přístup. Zkuste to prosím znovu.",
"nodeRequired": "Je vyžadován Node.js 22+. Nainstalujte jej nejprve.",
"portInUse": "Port 18789 je používán. Zavřete ostatní aplikace, které jej používají.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Kanály",
"runtimeMode": "Režim běhu",
"dockerSandbox": "Docker kontejner",
"directProcess": "Přímý proces",
"securityAdvisory": "OpenClaw běží bez izolace sandboxu. Pro lepší bezpečnost nainstalujte a otevřete Docker Desktop, poté restartujte OpenClaw pro spuštění v kontejneru.",
"logViewer": "Logy pískoviště",
"noLogs": "Žádné logy",
"copyLogs": "Kopírovat vše",
"downloadLogs": "Stáhnout",
"restartSandbox": "Restartovat pískoviště",
"restarting": "Restartuji pískoviště...",
"localNetworkOnly": "Pouze místní síť",
"notConfigured": "Není nakonfigurováno",
"publicAccess": "Veřejný přístup",
"tunnelUrl": "URL tunelu",
"copyUrl": "Kopírovat URL",
"openUrl": "Otevřít URL",
"activeTunnel": "Aktivní tunel",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Upravte Soul.md, User.md a další konfigurační soubory",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Otevřete webový dashboard",
"addChannel": {
"title": "Přidat kanál",
"description": "Vyberte messaging platformu k připojení",
"cancel": "Zrušit",
"continue": "Pokračovat"
},
"channelDescriptions": {
"telegram": "Připojit přes Telegram bot pro bezpečné zasílání zpráv",
"whatsapp": "Připojit přes WhatsApp pro business zasílání zpráv"
},
"connected": "Připojeno",
"notConnected": "Není připojeno",
"manage": "Spravovat",
"disconnect": "Odpojit",
"channelDisconnected": "{{channel}} odpojen",
"disconnectError": "Nepodařilo se odpojit. Zkuste to prosím znovu.",
"channelNotConnected": "Kliknutím na nastavení připojíte {{channel}}",
"copiedToClipboard": "Zkopírováno do schránky",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve povolen",
"serveDisabled": "Tailscale Serve zakázán",
"funnelEnabled": "Tailscale Funnel povolen - Váš koncový bod je nyní veřejně přístupný",
"funnelDisabled": "Tailscale Funnel zakázán",
"serveError": "Nepodařilo se nakonfigurovat Tailscale Serve",
"funnelError": "Nepodařilo se nakonfigurovat Tailscale Funnel",
"urlCopied": "URL zkopírována do schránky"
},
"telegramWizard": {
"title": "Připojit Telegram",
"description": "Nastavte Telegram pro vzdálený chat s Jan",
"step1": {
"title": "Vytvořit bota",
"instruction1": "Otevřete Telegram a vyhledejte @BotFather",
"instruction2": "Odešlete /newbot pro vytvoření nového bota",
"instruction3": "Postupujte podle pokynů a pojmenujte bota",
"instruction4": "Zkopírujte token bota (vypadá jako: 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Zadejte token",
"placeholder": "Vložte token bota sem",
"validate": "Ověřit token",
"validating": "Ověřování...",
"invalidToken": "Neplatný token. Zkontrolujte a zkuste znovu.",
"validToken": "Token ověřen! Bot: @{{username}}"
},
"step3": {
"title": "Konfigurovat a spustit",
"connecting": "Konfigurace Telegram kanálu...",
"success": "Telegram kanál úspěšně nakonfigurován!",
"error": "Nepodařilo se nakonfigurovat Telegram: {{error}}"
},
"step4": {
"title": "Spárovat zařízení",
"instruction": "Otevřete bota na Telegramu a odešlete zprávu. Bot odpoví párovacím kódem — vložte jej níže pro připojení.",
"openBot": "Otevřít @{{username}} na Telegramu",
"instruction1": "Otevřete bota na Telegramu pomocí odkazu výše",
"instruction2": "Stiskněte \"Start\" nebo odešlete jakoukoli zprávu botovi",
"instruction3": "Zkopírujte párovací kód z odpovědi bota a vložte jej níže",
"codeLabel": "Párovací kód",
"codePlaceholder": "např. ZNAV9KZ3",
"approve": "Schválit a připojit",
"approving": "Schvalování...",
"approved": "Zařízení úspěšně spárováno!",
"approveError": "Nepodařilo se schválit párování: {{error}}",
"codeRequired": "Zadejte prosím párovací kód ze zprávy bota",
"notConnected": "Kanál ještě není připojen. Ujistěte se, že brána OpenClaw běží.",
"skipForNow": "Přeskočit prozatím",
"resetPairing": "Nemáte kód? Klikněte pro reset a znovu odešlete /start",
"resetSuccess": "Párovací kódy vymazány — odešlete /start botovi znovu",
"resetError": "Nepodařilo se resetovat párovací kódy"
},
"step5": {
"title": "Připojeno!",
"success": "Telegram je nyní připojen!",
"instruction": "Nyní můžete chatovat s Jan odesíláním zpráv botovi.",
"botUsername": "Váš bot: @{{username}}",
"pairedUsers": "{{count}} spárovaných zařízení"
},
"buttons": {
"next": "Další",
"back": "Zpět",
"connect": "Připojit",
"cancel": "Zrušit",
"done": "Hotovo",
"retry": "Opakovat",
"disconnect": "Odpojit"
},
"status": {
"notConnected": "Není připojeno",
"connected": "Připojeno",
"connecting": "Připojování..."
},
"errors": {
"networkError": "Chyba sítě. Zkontrolujte připojení.",
"tokenRequired": "Zadejte prosím token bota",
"configError": "Nepodařilo se uložit konfiguraci"
}
},
"whatsappWizard": {
"setupTitle": "Nastavení WhatsApp",
"setupDescription": "Připojte svůj účet WhatsApp pro vzdálené zasílání zpráv",
"howItWorks": "Jak to funguje",
"whatsappStep1": "Klikněte na \"Zahájit nastavení\" — vše nakonfigurujeme automaticky",
"whatsappStep2": "Naskenujte QR kód aplikací WhatsApp",
"whatsappStep3": "Začněte chatovat s Jan z telefonu!",
"settingUp": "Nastavování...",
"settingUpDescription": "Konfigurace OpenClaw a příprava připojení WhatsApp. Může to chvíli trvat...",
"checkingOpenClaw": "Kontrola instalace OpenClaw",
"configuringGateway": "Konfigurace připojení brány",
"enablingWhatsApp": "Povolování kanálu WhatsApp",
"scanQrCode": "Naskenujte QR kód",
"qrCodeInstructions": "Otevřete WhatsApp na telefonu a naskenujte tento QR kód",
"generatingQrCode": "Generování QR kódu...",
"waitingForScan": "Čekání na sken...",
"verifyingConnection": "Ověřování připojení",
"pleaseWait": "Počkejte prosím, ověřujeme vaše připojení",
"whatsappConnected": "WhatsApp připojen!",
"whatsappConnectedDescription": "Váš WhatsApp je nyní připojen. Můžete chatovat s Jan odesíláním zpráv.",
"connectionFailed": "Připojení selhalo",
"unknownError": "Došlo k neznámé chybě",
"back": "Zpět",
"refreshQrCode": "Obnovit QR kód",
"tryAgain": "Zkusit znovu",
"startSetup": "Zahájit nastavení",
"connecting": "Připojování..."
}
}
"backendInstallError": "Nepodařilo se nainstalovat backend"
}

View File

@@ -18,7 +18,6 @@
"newChat": "Neuer Chat",
"newAgentChat": "Neuer Agent-Chat",
"chats": "Chats",
"openclawAgent": "OpenClaw Agent",
"favorites": "Favoriten",
"recents": "Kürzlich",
"hub": "Hub",

View File

@@ -274,175 +274,5 @@
"updateError": "Fehler beim Aktualisieren von Llamacpp"
},
"backendInstallSuccess": "Backend erfolgreich installiert",
"backendInstallError": "Backend-Installation fehlgeschlagen",
"remoteAccess": {
"title": "Fernzugriff",
"running": "Fernzugriff läuft",
"stopped": "Fernzugriff wurde gestoppt",
"urlCopied": "Link in die Zwischenablage kopiert",
"port": "Port",
"runtimeVersion": "Runtime-Version",
"openclawVersion": "OpenClaw-Version",
"openclawIntegration": "OpenClaw-Integration",
"enableRemoteAccess": "OpenClaw aktivieren",
"disableRemoteAccess": "OpenClaw deaktivieren",
"settings": "Einstellungen",
"install": "Installieren",
"start": "Starten",
"stop": "Stoppen",
"startError": "Fernzugriff konnte nicht gestartet werden. Bitte versuchen Sie es erneut.",
"stopError": "Fernzugriff konnte nicht gestoppt werden. Bitte versuchen Sie es erneut.",
"nodeRequired": "Node.js 22+ ist erforderlich. Bitte installieren Sie es zuerst.",
"portInUse": "Port 18789 wird bereits verwendet. Bitte schließen Sie andere Apps, die ihn verwenden.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Kanäle",
"runtimeMode": "Laufzeitmodus",
"dockerSandbox": "Docker-Container",
"directProcess": "Direkter Prozess",
"securityAdvisory": "OpenClaw läuft ohne Sandbox-Isolation. Für bessere Sicherheit installieren und öffnen Sie Docker Desktop, dann starten Sie OpenClaw neu, um es in einem Container auszuführen.",
"logViewer": "Sandbox-Logs",
"noLogs": "Keine Logs verfügbar",
"copyLogs": "Alle kopieren",
"downloadLogs": "Herunterladen",
"restartSandbox": "Sandbox neu starten",
"restarting": "Sandbox wird neu gestartet...",
"localNetworkOnly": "Nur lokales Netzwerk",
"notConfigured": "Nicht konfiguriert",
"publicAccess": "Öffentlicher Zugriff",
"tunnelUrl": "Tunnel-URL",
"copyUrl": "URL kopieren",
"openUrl": "URL öffnen",
"activeTunnel": "Aktiver Tunnel",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Soul.md, User.md und andere Konfigurationsdateien bearbeiten",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Das Web-Dashboard öffnen",
"addChannel": {
"title": "Kanal hinzufügen",
"description": "Wählen Sie eine Messaging-Plattform zum Verbinden",
"cancel": "Abbrechen",
"continue": "Weiter"
},
"channelDescriptions": {
"telegram": "Verbindung über Telegram-Bot für sichere Nachrichten",
"whatsapp": "Verbindung über WhatsApp für geschäftliche Nachrichten"
},
"connected": "Verbunden",
"notConnected": "Nicht verbunden",
"manage": "Verwalten",
"disconnect": "Trennen",
"channelDisconnected": "{{channel}} getrennt",
"disconnectError": "Trennung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"channelNotConnected": "Klicken Sie auf Einstellungen, um {{channel}} zu verbinden",
"copiedToClipboard": "In die Zwischenablage kopiert",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve aktiviert",
"serveDisabled": "Tailscale Serve deaktiviert",
"funnelEnabled": "Tailscale Funnel aktiviert - Ihr Endpunkt ist jetzt öffentlich zugänglich",
"funnelDisabled": "Tailscale Funnel deaktiviert",
"serveError": "Tailscale Serve konnte nicht konfiguriert werden",
"funnelError": "Tailscale Funnel konnte nicht konfiguriert werden",
"urlCopied": "URL in die Zwischenablage kopiert"
},
"telegramWizard": {
"title": "Telegram verbinden",
"description": "Telegram einrichten, um remote mit Jan zu chatten",
"step1": {
"title": "Bot erstellen",
"instruction1": "Öffnen Sie Telegram und suchen Sie nach @BotFather",
"instruction2": "Senden Sie /newbot, um einen neuen Bot zu erstellen",
"instruction3": "Folgen Sie den Anweisungen, um Ihren Bot zu benennen",
"instruction4": "Kopieren Sie das Bot-Token (sieht aus wie: 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Token eingeben",
"placeholder": "Bot-Token hier einfügen",
"validate": "Token validieren",
"validating": "Wird validiert...",
"invalidToken": "Ungültiges Token. Bitte überprüfen und erneut versuchen.",
"validToken": "Token validiert! Bot: @{{username}}"
},
"step3": {
"title": "Konfigurieren & Starten",
"connecting": "Telegram-Kanal wird konfiguriert...",
"success": "Telegram-Kanal erfolgreich konfiguriert!",
"error": "Telegram-Konfiguration fehlgeschlagen: {{error}}"
},
"step4": {
"title": "Gerät koppeln",
"instruction": "Öffnen Sie Ihren Bot auf Telegram und senden Sie eine Nachricht. Der Bot antwortet mit einem Kopplungscode — fügen Sie ihn unten ein, um sich zu verbinden.",
"openBot": "@{{username}} auf Telegram öffnen",
"instruction1": "Öffnen Sie Ihren Bot auf Telegram über den Link oben",
"instruction2": "Drücken Sie \"Start\" oder senden Sie eine beliebige Nachricht an den Bot",
"instruction3": "Kopieren Sie den Kopplungscode aus der Antwort des Bots und fügen Sie ihn unten ein",
"codeLabel": "Kopplungscode",
"codePlaceholder": "z.B. ZNAV9KZ3",
"approve": "Genehmigen & Verbinden",
"approving": "Wird genehmigt...",
"approved": "Gerät erfolgreich gekoppelt!",
"approveError": "Kopplung fehlgeschlagen: {{error}}",
"codeRequired": "Bitte geben Sie den Kopplungscode aus der Bot-Nachricht ein",
"notConnected": "Kanal noch nicht verbunden. Stellen Sie sicher, dass das OpenClaw-Gateway läuft.",
"skipForNow": "Vorerst überspringen",
"resetPairing": "Kein Code? Klicken Sie zum Zurücksetzen & versuchen Sie /start erneut",
"resetSuccess": "Kopplungscodes gelöscht — senden Sie /start erneut an Ihren Bot",
"resetError": "Kopplungscodes konnten nicht zurückgesetzt werden"
},
"step5": {
"title": "Verbunden!",
"success": "Telegram ist jetzt verbunden!",
"instruction": "Sie können jetzt mit Jan chatten, indem Sie Nachrichten an Ihren Bot senden.",
"botUsername": "Ihr Bot: @{{username}}",
"pairedUsers": "{{count}} Gerät(e) gekoppelt"
},
"buttons": {
"next": "Weiter",
"back": "Zurück",
"connect": "Verbinden",
"cancel": "Abbrechen",
"done": "Fertig",
"retry": "Erneut versuchen",
"disconnect": "Trennen"
},
"status": {
"notConnected": "Nicht verbunden",
"connected": "Verbunden",
"connecting": "Verbindung wird hergestellt..."
},
"errors": {
"networkError": "Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.",
"tokenRequired": "Bitte geben Sie ein Bot-Token ein",
"configError": "Konfiguration konnte nicht gespeichert werden"
}
},
"whatsappWizard": {
"setupTitle": "WhatsApp-Einrichtung",
"setupDescription": "Verbinden Sie Ihr WhatsApp-Konto, um Remote-Nachrichten zu aktivieren",
"howItWorks": "So funktioniert es",
"whatsappStep1": "Klicken Sie auf \"Einrichtung starten\" — wir konfigurieren alles automatisch",
"whatsappStep2": "Scannen Sie den QR-Code mit Ihrer WhatsApp-App",
"whatsappStep3": "Beginnen Sie, von Ihrem Telefon aus mit Jan zu chatten!",
"settingUp": "Wird eingerichtet...",
"settingUpDescription": "OpenClaw wird konfiguriert und die WhatsApp-Verbindung vorbereitet. Dies kann einen Moment dauern...",
"checkingOpenClaw": "OpenClaw-Installation wird überprüft",
"configuringGateway": "Gateway-Verbindung wird konfiguriert",
"enablingWhatsApp": "WhatsApp-Kanal wird aktiviert",
"scanQrCode": "QR-Code scannen",
"qrCodeInstructions": "Öffnen Sie WhatsApp auf Ihrem Telefon und scannen Sie diesen QR-Code",
"generatingQrCode": "QR-Code wird generiert...",
"waitingForScan": "Warten auf Scan...",
"verifyingConnection": "Verbindung wird überprüft",
"pleaseWait": "Bitte warten Sie, während wir Ihre Verbindung überprüfen",
"whatsappConnected": "WhatsApp verbunden!",
"whatsappConnectedDescription": "Ihr WhatsApp ist jetzt verbunden. Sie können mit Jan chatten, indem Sie Nachrichten senden.",
"connectionFailed": "Verbindung fehlgeschlagen",
"unknownError": "Ein unbekannter Fehler ist aufgetreten",
"back": "Zurück",
"refreshQrCode": "QR-Code aktualisieren",
"tryAgain": "Erneut versuchen",
"startSetup": "Einrichtung starten",
"connecting": "Verbindung wird hergestellt..."
}
}
"backendInstallError": "Backend-Installation fehlgeschlagen"
}

View File

@@ -5,7 +5,6 @@
"local_api_server": "Local API Server",
"remote_access": "Remote Access",
"integrations": "Integrations",
"openclaw": "OpenClaw",
"experimental": "Experimental",
"claude_code": "Claude Code",
"https_proxy": "HTTPS Proxy",
@@ -24,7 +23,6 @@
"newChat": "New Chat",
"newAgentChat": "New Agent Chat",
"chats": "Chats",
"openclawAgent": "OpenClaw Agent",
"favorites": "Favorites",
"recents": "Recents",
"hub": "Hub",

View File

@@ -106,10 +106,8 @@
"threadScrollDesc": "Choose how the chat viewport should react when new messages arrive.",
"threadScrollFlowTitle": "Flow scroll",
"threadScrollFlowHint": "Keeps the viewport anchored to the latest message you send.",
"threadScrollStickyTitle": "Sticky scroll",
"threadScrollStickyHint": "Automatically follows along as replies stream in real time.",
"tokenCounterCompact": "Compact Token Counter",
"tokenCounterCompactDesc": "Show token counter inside chat input. When disabled, token counter appears below the input.",
"codeBlockTitle": "Code Block",
@@ -320,179 +318,6 @@
"updateSuccess": "Llamacpp updated successfully",
"updateError": "Failed to update Llamacpp"
},
"backendInstallSuccess": "OpenClaw installed successfully!",
"backendInstallError": "Failed to install backend",
"remoteAccess": {
"title": "Remote Access",
"running": "Remote Access is now running",
"stopped": "Remote Access has been stopped",
"urlCopied": "Link copied to clipboard",
"port": "Port",
"runtimeVersion": "Runtime Version",
"openclawVersion": "OpenClaw Version",
"openclawIntegration": "OpenClaw Integration",
"enableRemoteAccess": "Enable OpenClaw",
"disableRemoteAccess": "Disable OpenClaw",
"settings": "Settings",
"install": "Install",
"start": "Start",
"noLocalModelAvailable": "A local model is required to enable OpenClaw. Download a model first.",
"noModelAvailable": "A model is required to enable OpenClaw. Download a local model or configure a remote provider with an API key.",
"nodejsPrerequisite": "Node.js (v22 or later) is required to run OpenClaw. Please ensure it is installed before enabling.",
"stop": "Stop",
"startError": "Couldn't start Remote Access. Please try again.",
"stopError": "Couldn't stop Remote Access. Please try again.",
"nodeRequired": "Node.js 22+ is required. Please install it first.",
"portInUse": "Port 18789 is in use. Please close other apps using it.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Channels",
"runtimeMode": "Runtime Mode",
"dockerSandbox": "Docker Container",
"directProcess": "Direct Process",
"securityAdvisory": "OpenClaw is running without sandbox isolation. For better security, install and open Docker Desktop, then restart OpenClaw to run it in a container.",
"logViewer": "Sandbox Logs",
"noLogs": "No logs available",
"copyLogs": "Copy All",
"downloadLogs": "Download",
"restartSandbox": "Restart Sandbox",
"restarting": "Restarting sandbox...",
"localNetworkOnly": "Local Network Only",
"notConfigured": "Not Configured",
"publicAccess": "Public Access",
"tunnelUrl": "Tunnel URL",
"copyUrl": "Copy URL",
"openUrl": "Open URL",
"activeTunnel": "Active Tunnel",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Open the folder to edit Soul.md, User.md and other config files",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Open the web dashboard",
"addChannel": {
"title": "Add Channel",
"description": "Select a messaging platform to connect",
"cancel": "Cancel",
"continue": "Continue"
},
"channelDescriptions": {
"telegram": "Connect via Telegram bot for secure messaging",
"whatsapp": "Connect via WhatsApp for business messaging"
},
"connected": "Connected",
"notConnected": "Not Connected",
"manage": "Manage",
"disconnect": "Disconnect",
"channelDisconnected": "{{channel}} disconnected",
"disconnectError": "Couldn't disconnect. Please try again.",
"channelNotConnected": "Click settings to connect {{channel}}",
"copiedToClipboard": "Copied to clipboard",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve enabled",
"serveDisabled": "Tailscale Serve disabled",
"funnelEnabled": "Tailscale Funnel enabled - Your endpoint is now publicly accessible",
"funnelDisabled": "Tailscale Funnel disabled",
"serveError": "Failed to configure Tailscale Serve",
"funnelError": "Failed to configure Tailscale Funnel",
"urlCopied": "URL copied to clipboard"
},
"telegramWizard": {
"title": "Connect Telegram",
"description": "Set up Telegram to chat with Jan remotely",
"step1": {
"title": "Create a Bot",
"instruction1": "Open Telegram and search for @BotFather",
"instruction2": "Send /newbot to create a new bot",
"instruction3": "Follow the prompts to name your bot",
"instruction4": "Copy the bot token (it looks like: 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Enter Token",
"placeholder": "Paste your bot token here",
"validate": "Validate Token",
"validating": "Validating...",
"invalidToken": "Invalid token. Please check and try again.",
"validToken": "Token validated! Bot: @{{username}}"
},
"step3": {
"title": "Configure & Start",
"connecting": "Configuring Telegram channel...",
"success": "Telegram channel configured successfully!",
"error": "Failed to configure Telegram: {{error}}"
},
"step4": {
"title": "Pair Your Device",
"instruction": "Open your bot on Telegram and send a message. The bot will reply with a pairing code — paste it below to connect.",
"openBot": "Open @{{username}} on Telegram",
"instruction1": "Open your bot on Telegram using the link above",
"instruction2": "Press \"Start\" or send any message to the bot",
"instruction3": "Copy the pairing code from the bot's reply and paste it below",
"codeLabel": "Pairing Code",
"codePlaceholder": "e.g. ZNAV9KZ3",
"approve": "Approve & Connect",
"approving": "Approving...",
"approved": "Device paired successfully!",
"approveError": "Failed to approve pairing: {{error}}",
"codeRequired": "Please enter the pairing code from the bot's message",
"notConnected": "Channel not connected yet. Make sure the OpenClaw gateway is running.",
"skipForNow": "Skip for Now",
"resetPairing": "No code? Click to reset & try /start again",
"resetSuccess": "Pairing codes cleared — send /start to your bot again",
"resetError": "Failed to reset pairing codes"
},
"step5": {
"title": "Connected!",
"success": "Telegram is now connected!",
"instruction": "You can now chat with Jan by sending messages to your bot.",
"botUsername": "Your bot: @{{username}}",
"pairedUsers": "{{count}} device(s) paired"
},
"buttons": {
"next": "Next",
"back": "Back",
"connect": "Connect",
"cancel": "Cancel",
"done": "Done",
"retry": "Retry",
"disconnect": "Disconnect"
},
"status": {
"notConnected": "Not Connected",
"connected": "Connected",
"connecting": "Connecting..."
},
"errors": {
"networkError": "Network error. Please check your connection.",
"tokenRequired": "Please enter a bot token",
"configError": "Failed to save configuration"
}
},
"whatsappWizard": {
"setupTitle": "WhatsApp Setup",
"setupDescription": "Connect your WhatsApp account to enable remote messaging",
"howItWorks": "How it works",
"whatsappStep1": "Click 'Start Setup' - we'll configure everything automatically",
"whatsappStep2": "Scan the QR code with your WhatsApp app",
"whatsappStep3": "Start chatting with Jan from your phone!",
"settingUp": "Setting up...",
"settingUpDescription": "Configuring OpenClaw and preparing WhatsApp connection. This may take a moment...",
"checkingOpenClaw": "Checking OpenClaw installation",
"configuringGateway": "Configuring Gateway connection",
"enablingWhatsApp": "Enabling WhatsApp channel",
"scanQrCode": "Scan QR Code",
"qrCodeInstructions": "Open WhatsApp on your phone and scan this QR code",
"generatingQrCode": "Generating QR code...",
"waitingForScan": "Waiting for scan...",
"verifyingConnection": "Verifying Connection",
"pleaseWait": "Please wait while we verify your connection",
"whatsappConnected": "WhatsApp Connected!",
"whatsappConnectedDescription": "Your WhatsApp is now connected. You can chat with Jan by sending messages.",
"connectionFailed": "Connection Failed",
"unknownError": "An unknown error occurred",
"back": "Back",
"refreshQrCode": "Refresh QR Code",
"tryAgain": "Try Again",
"startSetup": "Start Setup",
"connecting": "Connecting..."
}
}
"backendInstallSuccess": "Backend installed successfully!",
"backendInstallError": "Failed to install backend"
}

View File

@@ -18,7 +18,6 @@
"newChat": "Nouvelle discussion",
"newAgentChat": "Nouvelle discussion Agent",
"chats": "Discussions",
"openclawAgent": "OpenClaw Agent",
"favorites": "Favoris",
"recents": "Récents",
"hub": "Concentrateur Hub",

View File

@@ -312,175 +312,5 @@
"updateError": "Échec de la mise à jour de Llamacpp"
},
"backendInstallSuccess": "Backend installé avec succès",
"backendInstallError": "Échec de l'installation du backend",
"remoteAccess": {
"title": "Accès à distance",
"running": "L'accès à distance est maintenant actif",
"stopped": "L'accès à distance a été arrêté",
"urlCopied": "Lien copié dans le presse-papiers",
"port": "Port",
"runtimeVersion": "Version du runtime",
"openclawVersion": "Version OpenClaw",
"openclawIntegration": "Intégration OpenClaw",
"enableRemoteAccess": "Activer OpenClaw",
"disableRemoteAccess": "Désactiver OpenClaw",
"settings": "Paramètres",
"install": "Installer",
"start": "Démarrer",
"stop": "Arrêter",
"startError": "Impossible de démarrer l'accès à distance. Veuillez réessayer.",
"stopError": "Impossible d'arrêter l'accès à distance. Veuillez réessayer.",
"nodeRequired": "Node.js 22+ est requis. Veuillez l'installer d'abord.",
"portInUse": "Le port 18789 est utilisé. Veuillez fermer les autres applications qui l'utilisent.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Canaux",
"runtimeMode": "Mode d'exécution",
"dockerSandbox": "Conteneur Docker",
"directProcess": "Processus direct",
"securityAdvisory": "OpenClaw s'exécute sans isolation sandbox. Pour une meilleure sécurité, installez et ouvrez Docker Desktop, puis redémarrez OpenClaw pour l'exécuter dans un conteneur.",
"logViewer": "Logs du bac à sable",
"noLogs": "Aucun log disponible",
"copyLogs": "Tout copier",
"downloadLogs": "Télécharger",
"restartSandbox": "Redémarrer le bac à sable",
"restarting": "Redémarrage du bac à sable...",
"localNetworkOnly": "Réseau local uniquement",
"notConfigured": "Non configuré",
"publicAccess": "Accès public",
"tunnelUrl": "URL du tunnel",
"copyUrl": "Copier l'URL",
"openUrl": "Ouvrir l'URL",
"activeTunnel": "Tunnel actif",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Modifier Soul.md, User.md et d'autres fichiers de configuration",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Ouvrir le tableau de bord web",
"addChannel": {
"title": "Ajouter un canal",
"description": "Sélectionnez une plateforme de messagerie à connecter",
"cancel": "Annuler",
"continue": "Continuer"
},
"channelDescriptions": {
"telegram": "Connecter via le bot Telegram pour la messagerie sécurisée",
"whatsapp": "Connecter via WhatsApp pour la messagerie professionnelle"
},
"connected": "Connecté",
"notConnected": "Non connecté",
"manage": "Gérer",
"disconnect": "Déconnecter",
"channelDisconnected": "{{channel}} déconnecté",
"disconnectError": "Impossible de déconnecter. Veuillez réessayer.",
"channelNotConnected": "Cliquez sur Paramètres pour connecter {{channel}}",
"copiedToClipboard": "Copié dans le presse-papiers",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve activé",
"serveDisabled": "Tailscale Serve désactivé",
"funnelEnabled": "Tailscale Funnel activé - Votre point de terminaison est maintenant accessible au public",
"funnelDisabled": "Tailscale Funnel désactivé",
"serveError": "Échec de la configuration de Tailscale Serve",
"funnelError": "Échec de la configuration de Tailscale Funnel",
"urlCopied": "URL copiée dans le presse-papiers"
},
"telegramWizard": {
"title": "Connecter Telegram",
"description": "Configurez Telegram pour discuter avec Jan à distance",
"step1": {
"title": "Créer un bot",
"instruction1": "Ouvrez Telegram et recherchez @BotFather",
"instruction2": "Envoyez /newbot pour créer un nouveau bot",
"instruction3": "Suivez les instructions pour nommer votre bot",
"instruction4": "Copiez le token du bot (il ressemble à : 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Entrer le token",
"placeholder": "Collez votre token de bot ici",
"validate": "Valider le token",
"validating": "Validation...",
"invalidToken": "Token invalide. Veuillez vérifier et réessayer.",
"validToken": "Token validé ! Bot : @{{username}}"
},
"step3": {
"title": "Configurer et démarrer",
"connecting": "Configuration du canal Telegram...",
"success": "Canal Telegram configuré avec succès !",
"error": "Échec de la configuration de Telegram : {{error}}"
},
"step4": {
"title": "Associer votre appareil",
"instruction": "Ouvrez votre bot sur Telegram et envoyez un message. Le bot répondra avec un code d'association — collez-le ci-dessous pour vous connecter.",
"openBot": "Ouvrir @{{username}} sur Telegram",
"instruction1": "Ouvrez votre bot sur Telegram en utilisant le lien ci-dessus",
"instruction2": "Appuyez sur « Démarrer » ou envoyez un message au bot",
"instruction3": "Copiez le code d'association de la réponse du bot et collez-le ci-dessous",
"codeLabel": "Code d'association",
"codePlaceholder": "ex. ZNAV9KZ3",
"approve": "Approuver et connecter",
"approving": "Approbation...",
"approved": "Appareil associé avec succès !",
"approveError": "Échec de l'approbation : {{error}}",
"codeRequired": "Veuillez entrer le code d'association du message du bot",
"notConnected": "Canal non encore connecté. Assurez-vous que la passerelle OpenClaw est en cours d'exécution.",
"skipForNow": "Passer pour l'instant",
"resetPairing": "Pas de code ? Cliquez pour réinitialiser et réessayer /start",
"resetSuccess": "Codes d'association effacés — envoyez /start à votre bot à nouveau",
"resetError": "Échec de la réinitialisation des codes d'association"
},
"step5": {
"title": "Connecté !",
"success": "Telegram est maintenant connecté !",
"instruction": "Vous pouvez maintenant discuter avec Jan en envoyant des messages à votre bot.",
"botUsername": "Votre bot : @{{username}}",
"pairedUsers": "{{count}} appareil(s) associé(s)"
},
"buttons": {
"next": "Suivant",
"back": "Retour",
"connect": "Connecter",
"cancel": "Annuler",
"done": "Terminé",
"retry": "Réessayer",
"disconnect": "Déconnecter"
},
"status": {
"notConnected": "Non connecté",
"connected": "Connecté",
"connecting": "Connexion..."
},
"errors": {
"networkError": "Erreur réseau. Veuillez vérifier votre connexion.",
"tokenRequired": "Veuillez entrer un token de bot",
"configError": "Échec de l'enregistrement de la configuration"
}
},
"whatsappWizard": {
"setupTitle": "Configuration WhatsApp",
"setupDescription": "Connectez votre compte WhatsApp pour activer la messagerie à distance",
"howItWorks": "Comment ça fonctionne",
"whatsappStep1": "Cliquez sur « Démarrer la configuration » — nous configurerons tout automatiquement",
"whatsappStep2": "Scannez le code QR avec votre application WhatsApp",
"whatsappStep3": "Commencez à discuter avec Jan depuis votre téléphone !",
"settingUp": "Configuration en cours...",
"settingUpDescription": "Configuration d'OpenClaw et préparation de la connexion WhatsApp. Cela peut prendre un moment...",
"checkingOpenClaw": "Vérification de l'installation d'OpenClaw",
"configuringGateway": "Configuration de la connexion passerelle",
"enablingWhatsApp": "Activation du canal WhatsApp",
"scanQrCode": "Scanner le code QR",
"qrCodeInstructions": "Ouvrez WhatsApp sur votre téléphone et scannez ce code QR",
"generatingQrCode": "Génération du code QR...",
"waitingForScan": "En attente du scan...",
"verifyingConnection": "Vérification de la connexion",
"pleaseWait": "Veuillez patienter pendant que nous vérifions votre connexion",
"whatsappConnected": "WhatsApp connecté !",
"whatsappConnectedDescription": "Votre WhatsApp est maintenant connecté. Vous pouvez discuter avec Jan en envoyant des messages.",
"connectionFailed": "Échec de la connexion",
"unknownError": "Une erreur inconnue s'est produite",
"back": "Retour",
"refreshQrCode": "Actualiser le code QR",
"tryAgain": "Réessayer",
"startSetup": "Démarrer la configuration",
"connecting": "Connexion..."
}
}
"backendInstallError": "Échec de l'installation du backend"
}

View File

@@ -18,7 +18,6 @@
"newChat": "Obrolan Baru",
"newAgentChat": "Obrolan Agent Baru",
"chats": "Obrolan",
"openclawAgent": "OpenClaw Agent",
"favorites": "Favorit",
"recents": "Terbaru",
"hub": "Hub",

View File

@@ -274,175 +274,5 @@
"updateError": "Gagal memperbarui Llamacpp"
},
"backendInstallSuccess": "Backend berhasil diinstal",
"backendInstallError": "Gagal menginstal backend",
"remoteAccess": {
"title": "Akses Jarak Jauh",
"running": "Akses jarak jauh sekarang berjalan",
"stopped": "Akses jarak jauh telah dihentikan",
"urlCopied": "Link disalin ke clipboard",
"port": "Port",
"runtimeVersion": "Versi Runtime",
"openclawVersion": "Versi OpenClaw",
"openclawIntegration": "Integrasi OpenClaw",
"enableRemoteAccess": "Aktifkan OpenClaw",
"disableRemoteAccess": "Nonaktifkan OpenClaw",
"settings": "Pengaturan",
"install": "Pasang",
"start": "Mulai",
"stop": "Berhenti",
"startError": "Gagal memulai akses jarak jauh. Silakan coba lagi.",
"stopError": "Gagal menghentikan akses jarak jauh. Silakan coba lagi.",
"nodeRequired": "Node.js 22+ diperlukan. Silakan instal terlebih dahulu.",
"portInUse": "Port 18789 sedang digunakan. Tutup aplikasi lain yang menggunakannya.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Channel",
"runtimeMode": "Mode Berjalan",
"dockerSandbox": "Kontainer Docker",
"directProcess": "Proses Langsung",
"securityAdvisory": "OpenClaw berjalan tanpa isolasi sandbox. Untuk keamanan lebih baik, instal dan buka Docker Desktop, lalu mulai ulang OpenClaw untuk menjalankannya dalam kontainer.",
"logViewer": "Log Sandbox",
"noLogs": "Tidak ada log",
"copyLogs": "Salin Semua",
"downloadLogs": "Unduh",
"restartSandbox": "Mulai Ulang Sandbox",
"restarting": "Menyalakan ulang sandbox...",
"localNetworkOnly": "Hanya Jaringan Lokal",
"notConfigured": "Belum Dikonfigurasi",
"publicAccess": "Akses Publik",
"tunnelUrl": "URL Terowongan",
"copyUrl": "Salin URL",
"openUrl": "Buka URL",
"activeTunnel": "Terowongan Aktif",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Edit Soul.md, User.md dan file konfigurasi lainnya",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Buka dasbor web",
"addChannel": {
"title": "Tambah Channel",
"description": "Pilih platform pesan untuk disambungkan",
"cancel": "Batal",
"continue": "Lanjutkan"
},
"channelDescriptions": {
"telegram": "Sambungkan melalui bot Telegram untuk pesan aman",
"whatsapp": "Sambungkan melalui WhatsApp untuk pesan bisnis"
},
"connected": "Terhubung",
"notConnected": "Belum Terhubung",
"manage": "Kelola",
"disconnect": "Putuskan",
"channelDisconnected": "{{channel}} terputus",
"disconnectError": "Gagal memutuskan. Silakan coba lagi.",
"channelNotConnected": "Klik pengaturan untuk menyambungkan {{channel}}",
"copiedToClipboard": "Disalin ke clipboard",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve diaktifkan",
"serveDisabled": "Tailscale Serve dinonaktifkan",
"funnelEnabled": "Tailscale Funnel diaktifkan - Endpoint Anda sekarang dapat diakses publik",
"funnelDisabled": "Tailscale Funnel dinonaktifkan",
"serveError": "Gagal mengonfigurasi Tailscale Serve",
"funnelError": "Gagal mengonfigurasi Tailscale Funnel",
"urlCopied": "URL disalin ke clipboard"
},
"telegramWizard": {
"title": "Sambungkan Telegram",
"description": "Siapkan Telegram untuk mengobrol dengan Jan dari jarak jauh",
"step1": {
"title": "Buat Bot",
"instruction1": "Buka Telegram dan cari @BotFather",
"instruction2": "Kirim /newbot untuk membuat bot baru",
"instruction3": "Ikuti petunjuk untuk memberi nama bot Anda",
"instruction4": "Salin token bot (tampak seperti: 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Masukkan Token",
"placeholder": "Tempel token bot Anda di sini",
"validate": "Validasi Token",
"validating": "Memvalidasi...",
"invalidToken": "Token tidak valid. Silakan periksa dan coba lagi.",
"validToken": "Token tervalidasi! Bot: @{{username}}"
},
"step3": {
"title": "Konfigurasi & Mulai",
"connecting": "Mengonfigurasi channel Telegram...",
"success": "Channel Telegram berhasil dikonfigurasi!",
"error": "Gagal mengonfigurasi Telegram: {{error}}"
},
"step4": {
"title": "Pasangkan Perangkat",
"instruction": "Buka bot Anda di Telegram dan kirim pesan. Bot akan membalas dengan kode pairing — tempel di bawah untuk terhubung.",
"openBot": "Buka @{{username}} di Telegram",
"instruction1": "Buka bot Anda di Telegram menggunakan tautan di atas",
"instruction2": "Tekan \"Start\" atau kirim pesan apa pun ke bot",
"instruction3": "Salin kode pairing dari balasan bot dan tempel di bawah",
"codeLabel": "Kode Pairing",
"codePlaceholder": "cth. ZNAV9KZ3",
"approve": "Setujui & Sambungkan",
"approving": "Menyetujui...",
"approved": "Perangkat berhasil dipasangkan!",
"approveError": "Gagal menyetujui pairing: {{error}}",
"codeRequired": "Masukkan kode pairing dari pesan bot",
"notConnected": "Channel belum terhubung. Pastikan gateway OpenClaw berjalan.",
"skipForNow": "Lewati Dulu",
"resetPairing": "Tidak ada kode? Klik untuk reset & kirim /start lagi",
"resetSuccess": "Kode pairing dihapus — kirim /start ke bot Anda lagi",
"resetError": "Gagal mereset kode pairing"
},
"step5": {
"title": "Terhubung!",
"success": "Telegram sekarang terhubung!",
"instruction": "Anda sekarang dapat mengobrol dengan Jan dengan mengirim pesan ke bot Anda.",
"botUsername": "Bot Anda: @{{username}}",
"pairedUsers": "{{count}} perangkat dipasangkan"
},
"buttons": {
"next": "Selanjutnya",
"back": "Kembali",
"connect": "Sambungkan",
"cancel": "Batal",
"done": "Selesai",
"retry": "Coba Lagi",
"disconnect": "Putuskan"
},
"status": {
"notConnected": "Belum Terhubung",
"connected": "Terhubung",
"connecting": "Menyambungkan..."
},
"errors": {
"networkError": "Kesalahan jaringan. Periksa koneksi Anda.",
"tokenRequired": "Masukkan token bot",
"configError": "Gagal menyimpan konfigurasi"
}
},
"whatsappWizard": {
"setupTitle": "Pengaturan WhatsApp",
"setupDescription": "Sambungkan akun WhatsApp Anda untuk mengaktifkan pesan jarak jauh",
"howItWorks": "Cara kerjanya",
"whatsappStep1": "Klik 'Mulai Pengaturan' — kami akan mengonfigurasi semuanya secara otomatis",
"whatsappStep2": "Pindai kode QR dengan aplikasi WhatsApp Anda",
"whatsappStep3": "Mulai mengobrol dengan Jan dari ponsel Anda!",
"settingUp": "Menyiapkan...",
"settingUpDescription": "Mengonfigurasi OpenClaw dan mempersiapkan koneksi WhatsApp. Ini mungkin memerlukan waktu...",
"checkingOpenClaw": "Memeriksa instalasi OpenClaw",
"configuringGateway": "Mengonfigurasi koneksi Gateway",
"enablingWhatsApp": "Mengaktifkan channel WhatsApp",
"scanQrCode": "Pindai Kode QR",
"qrCodeInstructions": "Buka WhatsApp di ponsel Anda dan pindai kode QR ini",
"generatingQrCode": "Menghasilkan kode QR...",
"waitingForScan": "Menunggu pemindaian...",
"verifyingConnection": "Memverifikasi Koneksi",
"pleaseWait": "Silakan tunggu sementara kami memverifikasi koneksi Anda",
"whatsappConnected": "WhatsApp Terhubung!",
"whatsappConnectedDescription": "WhatsApp Anda sekarang terhubung. Anda dapat mengobrol dengan Jan dengan mengirim pesan.",
"connectionFailed": "Koneksi Gagal",
"unknownError": "Terjadi kesalahan yang tidak diketahui",
"back": "Kembali",
"refreshQrCode": "Segarkan Kode QR",
"tryAgain": "Coba Lagi",
"startSetup": "Mulai Pengaturan",
"connecting": "Menyambungkan..."
}
}
"backendInstallError": "Gagal menginstal backend"
}

View File

@@ -18,7 +18,6 @@
"newChat": "新しいチャット",
"newAgentChat": "新しいエージェントチャット",
"chats": "チャット",
"openclawAgent": "OpenClaw エージェント",
"favorites": "お気に入り",
"recents": "最近の項目",
"hub": "ハブ",

View File

@@ -271,175 +271,5 @@
"updateError": "Llamacppの更新に失敗しました"
},
"backendInstallSuccess": "バックエンドは正常にインストールされました",
"backendInstallError": "バックエンドのインストールに失敗しました",
"remoteAccess": {
"title": "リモートアクセス",
"running": "リモートアクセスが実行中です",
"stopped": "リモートアクセスが停止しました",
"urlCopied": "リンクがクリップボードにコピーされました",
"port": "ポート",
"runtimeVersion": "ランタイムバージョン",
"openclawVersion": "OpenClaw バージョン",
"openclawIntegration": "OpenClaw 連携",
"enableRemoteAccess": "OpenClawを有効にする",
"disableRemoteAccess": "OpenClawを無効にする",
"settings": "設定",
"install": "インストール",
"start": "開始",
"stop": "停止",
"startError": "リモートアクセスを開始できませんでした。もう一度お試しください。",
"stopError": "リモートアクセスを停止できませんでした。もう一度お試しください。",
"nodeRequired": "Node.js 22以上が必要です。先にインストールしてください。",
"portInUse": "ポート18789が使用中です。使用中の他のアプリを閉じてください。",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "チャンネル",
"runtimeMode": "実行モード",
"dockerSandbox": "Docker コンテナ",
"directProcess": "ダイレクトプロセス",
"securityAdvisory": "OpenClawはサンドボックス隔離なしで実行されています。より安全な環境にするには、Docker Desktopをインストールして開き、OpenClawを再起動してコンテナで実行してください。",
"logViewer": "サンドボックスログ",
"noLogs": "ログがありません",
"copyLogs": "すべてコピー",
"downloadLogs": "ダウンロード",
"restartSandbox": "サンドボックスを再起動",
"restarting": "サンドボックスを再起動中...",
"localNetworkOnly": "ローカルのみ",
"notConfigured": "未設定",
"publicAccess": "パブリックアクセス",
"tunnelUrl": "トンネルURL",
"copyUrl": "URLをコピー",
"openUrl": "URLを開く",
"activeTunnel": "アクティブトンネル",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Soul.md、User.md などの設定ファイルを編集する",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "ウェブダッシュボードを開く",
"addChannel": {
"title": "チャンネルを追加",
"description": "接続するメッセージングプラットフォームを選択",
"cancel": "キャンセル",
"continue": "続ける"
},
"channelDescriptions": {
"telegram": "安全なメッセージングのためにTelegramボットで接続",
"whatsapp": "ビジネスメッセージングのためにWhatsAppで接続"
},
"connected": "接続済み",
"notConnected": "未接続",
"manage": "管理",
"disconnect": "切断",
"channelDisconnected": "{{channel}}が切断されました",
"disconnectError": "切断できませんでした。もう一度お試しください。",
"channelNotConnected": "設定をクリックして{{channel}}を接続",
"copiedToClipboard": "クリップボードにコピーしました",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serveが有効になりました",
"serveDisabled": "Tailscale Serveが無効になりました",
"funnelEnabled": "Tailscale Funnelが有効になりました - エンドポイントに公開アクセス可能",
"funnelDisabled": "Tailscale Funnelが無効になりました",
"serveError": "Tailscale Serveの設定に失敗しました",
"funnelError": "Tailscale Funnelの設定に失敗しました",
"urlCopied": "URLがクリップボードにコピーされました"
},
"telegramWizard": {
"title": "Telegramを接続",
"description": "Telegramを設定してJanとリモートでチャット",
"step1": {
"title": "ボットを作成",
"instruction1": "Telegramを開いて @BotFather を検索",
"instruction2": "/newbot を送信して新しいボットを作成",
"instruction3": "プロンプトに従ってボットに名前を付ける",
"instruction4": "ボットトークンをコピー(例: 123456789:ABCdefGHI..."
},
"step2": {
"title": "トークンを入力",
"placeholder": "ボットトークンをここに貼り付け",
"validate": "トークンを検証",
"validating": "検証中...",
"invalidToken": "無効なトークンです。確認してもう一度お試しください。",
"validToken": "トークンが検証されました!ボット: @{{username}}"
},
"step3": {
"title": "設定と開始",
"connecting": "Telegramチャンネルを設定中...",
"success": "Telegramチャンネルが正常に設定されました",
"error": "Telegramの設定に失敗しました: {{error}}"
},
"step4": {
"title": "デバイスをペアリング",
"instruction": "Telegramでボットを開いてメッセージを送信してください。ボットがペアリングコードを返信します — 以下に貼り付けて接続してください。",
"openBot": "Telegramで @{{username}} を開く",
"instruction1": "上のリンクを使ってTelegramでボットを開く",
"instruction2": "「開始」を押すか、ボットにメッセージを送信",
"instruction3": "ボットの返信からペアリングコードをコピーして以下に貼り付け",
"codeLabel": "ペアリングコード",
"codePlaceholder": "例: ZNAV9KZ3",
"approve": "承認して接続",
"approving": "承認中...",
"approved": "デバイスが正常にペアリングされました!",
"approveError": "ペアリングの承認に失敗しました: {{error}}",
"codeRequired": "ボットのメッセージからペアリングコードを入力してください",
"notConnected": "チャンネルがまだ接続されていません。OpenClawゲートウェイが実行中であることを確認してください。",
"skipForNow": "今はスキップ",
"resetPairing": "コードがない場合はクリックしてリセットし、/start を再送信してください",
"resetSuccess": "ペアリングコードがクリアされました — ボットに /start を再送信してください",
"resetError": "ペアリングコードのリセットに失敗しました"
},
"step5": {
"title": "接続完了!",
"success": "Telegramが接続されました",
"instruction": "ボットにメッセージを送信してJanとチャットできます。",
"botUsername": "あなたのボット: @{{username}}",
"pairedUsers": "{{count}} 台のデバイスがペアリング済み"
},
"buttons": {
"next": "次へ",
"back": "戻る",
"connect": "接続",
"cancel": "キャンセル",
"done": "完了",
"retry": "再試行",
"disconnect": "切断"
},
"status": {
"notConnected": "未接続",
"connected": "接続済み",
"connecting": "接続中..."
},
"errors": {
"networkError": "ネットワークエラー。接続を確認してください。",
"tokenRequired": "ボットトークンを入力してください",
"configError": "設定の保存に失敗しました"
}
},
"whatsappWizard": {
"setupTitle": "WhatsApp設定",
"setupDescription": "WhatsAppアカウントを接続してリモートメッセージングを有効にする",
"howItWorks": "仕組み",
"whatsappStep1": "「セットアップ開始」をクリック — 自動的に設定します",
"whatsappStep2": "WhatsAppアプリでQRコードをスキャン",
"whatsappStep3": "スマートフォンからJanとチャットを開始",
"settingUp": "セットアップ中...",
"settingUpDescription": "OpenClawを設定し、WhatsApp接続を準備中です。少々お待ちください...",
"checkingOpenClaw": "OpenClawのインストールを確認中",
"configuringGateway": "ゲートウェイ接続を設定中",
"enablingWhatsApp": "WhatsAppチャンネルを有効化中",
"scanQrCode": "QRコードをスキャン",
"qrCodeInstructions": "スマートフォンでWhatsAppを開いてこのQRコードをスキャン",
"generatingQrCode": "QRコードを生成中...",
"waitingForScan": "スキャンを待機中...",
"verifyingConnection": "接続を確認中",
"pleaseWait": "接続を確認中です。お待ちください",
"whatsappConnected": "WhatsAppが接続されました",
"whatsappConnectedDescription": "WhatsAppが接続されました。メッセージを送信してJanとチャットできます。",
"connectionFailed": "接続に失敗しました",
"unknownError": "不明なエラーが発生しました",
"back": "戻る",
"refreshQrCode": "QRコードを更新",
"tryAgain": "もう一度試す",
"startSetup": "セットアップ開始",
"connecting": "接続中..."
}
}
"backendInstallError": "バックエンドのインストールに失敗しました"
}

View File

@@ -18,7 +18,6 @@
"newChat": "Nowy Czat",
"newAgentChat": "Nowy Czat Agenta",
"chats": "Czaty",
"openclawAgent": "OpenClaw Agent",
"favorites": "Ulubione",
"recents": "Ostatnie",
"hub": "Centrum Modeli",

View File

@@ -274,175 +274,5 @@
"updateError": "Nie udało się zaktualizować Llamacpp"
},
"backendInstallSuccess": "Backend został pomyślnie zainstalowany",
"backendInstallError": "Nie udało się zainstalować backendu",
"remoteAccess": {
"title": "Dostęp zdalny",
"running": "Dostęp zdalny jest teraz uruchomiony",
"stopped": "Dostęp zdalny został zatrzymany",
"urlCopied": "Link skopiowany do schowka",
"port": "Port",
"runtimeVersion": "Wersja środowiska uruchomieniowego",
"openclawVersion": "Wersja OpenClaw",
"openclawIntegration": "Integracja OpenClaw",
"enableRemoteAccess": "Włącz OpenClaw",
"disableRemoteAccess": "Wyłącz OpenClaw",
"settings": "Ustawienia",
"install": "Zainstaluj",
"start": "Uruchom",
"stop": "Zatrzymaj",
"startError": "Nie udało się uruchomić dostępu zdalnego. Spróbuj ponownie.",
"stopError": "Nie udało się zatrzymać dostępu zdalnego. Spróbuj ponownie.",
"nodeRequired": "Wymagany jest Node.js 22+. Zainstaluj go najpierw.",
"portInUse": "Port 18789 jest w użyciu. Zamknij inne aplikacje, które go używają.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Kanały",
"runtimeMode": "Tryb uruchomienia",
"dockerSandbox": "Kontener Docker",
"directProcess": "Proces bezpośredni",
"securityAdvisory": "OpenClaw działa bez izolacji sandbox. Aby uzyskać lepsze bezpieczeństwo, zainstaluj i otwórz Docker Desktop, a następnie uruchom ponownie OpenClaw, aby uruchomić go w kontenerze.",
"logViewer": "Logi piaskownicy",
"noLogs": "Brak logów",
"copyLogs": "Kopiuj wszystko",
"downloadLogs": "Pobierz",
"restartSandbox": "Uruchom ponownie piaskownicę",
"restarting": "Uruchamianie piaskownicy ponownie...",
"localNetworkOnly": "Tylko sieć lokalna",
"notConfigured": "Nie skonfigurowano",
"publicAccess": "Dostęp publiczny",
"tunnelUrl": "URL tunelu",
"copyUrl": "Kopiuj URL",
"openUrl": "Otwórz URL",
"activeTunnel": "Aktywny tunel",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Edytuj Soul.md, User.md i inne pliki konfiguracyjne",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Otwórz panel sterowania w przeglądarce",
"addChannel": {
"title": "Dodaj kanał",
"description": "Wybierz platformę komunikacyjną do połączenia",
"cancel": "Anuluj",
"continue": "Kontynuuj"
},
"channelDescriptions": {
"telegram": "Połącz przez bota Telegram dla bezpiecznej komunikacji",
"whatsapp": "Połącz przez WhatsApp dla komunikacji biznesowej"
},
"connected": "Połączono",
"notConnected": "Nie połączono",
"manage": "Zarządzaj",
"disconnect": "Rozłącz",
"channelDisconnected": "{{channel}} rozłączono",
"disconnectError": "Nie udało się rozłączyć. Spróbuj ponownie.",
"channelNotConnected": "Kliknij ustawienia, aby połączyć {{channel}}",
"copiedToClipboard": "Skopiowano do schowka",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve włączony",
"serveDisabled": "Tailscale Serve wyłączony",
"funnelEnabled": "Tailscale Funnel włączony - Twój punkt końcowy jest teraz dostępny publicznie",
"funnelDisabled": "Tailscale Funnel wyłączony",
"serveError": "Nie udało się skonfigurować Tailscale Serve",
"funnelError": "Nie udało się skonfigurować Tailscale Funnel",
"urlCopied": "URL skopiowany do schowka"
},
"telegramWizard": {
"title": "Połącz Telegram",
"description": "Skonfiguruj Telegram, aby rozmawiać z Jan zdalnie",
"step1": {
"title": "Utwórz bota",
"instruction1": "Otwórz Telegram i wyszukaj @BotFather",
"instruction2": "Wyślij /newbot, aby utworzyć nowego bota",
"instruction3": "Postępuj zgodnie z instrukcjami, aby nazwać bota",
"instruction4": "Skopiuj token bota (wygląda jak: 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Wprowadź token",
"placeholder": "Wklej token bota tutaj",
"validate": "Zweryfikuj token",
"validating": "Weryfikacja...",
"invalidToken": "Nieprawidłowy token. Sprawdź i spróbuj ponownie.",
"validToken": "Token zweryfikowany! Bot: @{{username}}"
},
"step3": {
"title": "Konfiguruj i uruchom",
"connecting": "Konfigurowanie kanału Telegram...",
"success": "Kanał Telegram skonfigurowany pomyślnie!",
"error": "Nie udało się skonfigurować Telegram: {{error}}"
},
"step4": {
"title": "Sparuj urządzenie",
"instruction": "Otwórz bota na Telegramie i wyślij wiadomość. Bot odpowie kodem parowania — wklej go poniżej, aby się połączyć.",
"openBot": "Otwórz @{{username}} na Telegramie",
"instruction1": "Otwórz bota na Telegramie za pomocą powyższego linku",
"instruction2": "Naciśnij \"Start\" lub wyślij dowolną wiadomość do bota",
"instruction3": "Skopiuj kod parowania z odpowiedzi bota i wklej go poniżej",
"codeLabel": "Kod parowania",
"codePlaceholder": "np. ZNAV9KZ3",
"approve": "Zatwierdź i połącz",
"approving": "Zatwierdzanie...",
"approved": "Urządzenie sparowane pomyślnie!",
"approveError": "Nie udało się zatwierdzić parowania: {{error}}",
"codeRequired": "Wprowadź kod parowania z wiadomości bota",
"notConnected": "Kanał nie jest jeszcze połączony. Upewnij się, że bramka OpenClaw jest uruchomiona.",
"skipForNow": "Pomiń na razie",
"resetPairing": "Brak kodu? Kliknij, aby zresetować i ponownie wyślij /start",
"resetSuccess": "Kody parowania wyczyszczone — wyślij /start do bota ponownie",
"resetError": "Nie udało się zresetować kodów parowania"
},
"step5": {
"title": "Połączono!",
"success": "Telegram jest teraz połączony!",
"instruction": "Możesz teraz rozmawiać z Jan, wysyłając wiadomości do bota.",
"botUsername": "Twój bot: @{{username}}",
"pairedUsers": "{{count}} sparowanych urządzeń"
},
"buttons": {
"next": "Dalej",
"back": "Wstecz",
"connect": "Połącz",
"cancel": "Anuluj",
"done": "Gotowe",
"retry": "Ponów",
"disconnect": "Rozłącz"
},
"status": {
"notConnected": "Nie połączono",
"connected": "Połączono",
"connecting": "Łączenie..."
},
"errors": {
"networkError": "Błąd sieci. Sprawdź połączenie.",
"tokenRequired": "Wprowadź token bota",
"configError": "Nie udało się zapisać konfiguracji"
}
},
"whatsappWizard": {
"setupTitle": "Konfiguracja WhatsApp",
"setupDescription": "Połącz konto WhatsApp, aby włączyć zdalne wiadomości",
"howItWorks": "Jak to działa",
"whatsappStep1": "Kliknij \"Rozpocznij konfigurację\" — skonfigurujemy wszystko automatycznie",
"whatsappStep2": "Zeskanuj kod QR aplikacją WhatsApp",
"whatsappStep3": "Zacznij rozmawiać z Jan ze swojego telefonu!",
"settingUp": "Konfigurowanie...",
"settingUpDescription": "Konfigurowanie OpenClaw i przygotowanie połączenia WhatsApp. To może chwilę potrwać...",
"checkingOpenClaw": "Sprawdzanie instalacji OpenClaw",
"configuringGateway": "Konfigurowanie połączenia bramki",
"enablingWhatsApp": "Włączanie kanału WhatsApp",
"scanQrCode": "Zeskanuj kod QR",
"qrCodeInstructions": "Otwórz WhatsApp na telefonie i zeskanuj ten kod QR",
"generatingQrCode": "Generowanie kodu QR...",
"waitingForScan": "Oczekiwanie na skan...",
"verifyingConnection": "Weryfikowanie połączenia",
"pleaseWait": "Proszę czekać, weryfikujemy połączenie",
"whatsappConnected": "WhatsApp połączony!",
"whatsappConnectedDescription": "WhatsApp jest teraz połączony. Możesz rozmawiać z Jan, wysyłając wiadomości.",
"connectionFailed": "Połączenie nie powiodło się",
"unknownError": "Wystąpił nieznany błąd",
"back": "Wstecz",
"refreshQrCode": "Odśwież kod QR",
"tryAgain": "Spróbuj ponownie",
"startSetup": "Rozpocznij konfigurację",
"connecting": "Łączenie..."
}
}
"backendInstallError": "Nie udało się zainstalować backendu"
}

View File

@@ -18,7 +18,6 @@
"newChat": "Novo Chat",
"newAgentChat": "Novo Chat de Agente",
"chats": "Conversas",
"openclawAgent": "OpenClaw Agent",
"favorites": "Favoritos",
"recents": "Recentes",
"hub": "Hub",

View File

@@ -306,175 +306,5 @@
"updateError": "Falha ao atualizar Llamacpp"
},
"backendInstallSuccess": "Backend instalado com sucesso",
"backendInstallError": "Falha ao instalar backend",
"remoteAccess": {
"title": "Acesso Remoto",
"running": "Acesso Remoto está em execução",
"stopped": "Acesso Remoto foi parado",
"urlCopied": "Link copiado para a área de transferência",
"port": "Porta",
"runtimeVersion": "Versão do Runtime",
"openclawVersion": "Versão do OpenClaw",
"openclawIntegration": "Integração OpenClaw",
"enableRemoteAccess": "Ativar OpenClaw",
"disableRemoteAccess": "Desativar OpenClaw",
"settings": "Configurações",
"install": "Instalar",
"start": "Iniciar",
"stop": "Parar",
"startError": "Não foi possível iniciar o Acesso Remoto. Tente novamente.",
"stopError": "Não foi possível parar o Acesso Remoto. Tente novamente.",
"nodeRequired": "Node.js 22+ é necessário. Instale-o primeiro.",
"portInUse": "Porta 18789 está em uso. Feche outros apps que a estão usando.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Canais",
"runtimeMode": "Modo de execução",
"dockerSandbox": "Contêiner Docker",
"directProcess": "Processo direto",
"securityAdvisory": "OpenClaw está sendo executado sem isolamento de sandbox. Para maior segurança, instale e abra o Docker Desktop, depois reinicie o OpenClaw para executá-lo em um contêiner.",
"logViewer": "Logs do Sandbox",
"noLogs": "Sem logs disponíveis",
"copyLogs": "Copiar tudo",
"downloadLogs": "Baixar",
"restartSandbox": "Reiniciar Sandbox",
"restarting": "Reiniciando sandbox...",
"localNetworkOnly": "Apenas Rede Local",
"notConfigured": "Não configurado",
"publicAccess": "Acesso Público",
"tunnelUrl": "URL do Tunnel",
"copyUrl": "Copiar URL",
"openUrl": "Abrir URL",
"activeTunnel": "Tunnel Ativo",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Edite Soul.md, User.md e outros arquivos de configuração",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Abrir o painel web",
"addChannel": {
"title": "Adicionar Canal",
"description": "Selecione uma plataforma de mensagens para conectar",
"cancel": "Cancelar",
"continue": "Continuar"
},
"channelDescriptions": {
"telegram": "Conectar via bot do Telegram para mensagens seguras",
"whatsapp": "Conectar via WhatsApp para mensagens empresariais"
},
"connected": "Conectado",
"notConnected": "Não conectado",
"manage": "Gerenciar",
"disconnect": "Desconectar",
"channelDisconnected": "{{channel}} desconectado",
"disconnectError": "Não foi possível desconectar. Tente novamente.",
"channelNotConnected": "Clique em configurações para conectar {{channel}}",
"copiedToClipboard": "Copiado para a área de transferência",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve ativado",
"serveDisabled": "Tailscale Serve desativado",
"funnelEnabled": "Tailscale Funnel ativado - Seu endpoint agora é acessível publicamente",
"funnelDisabled": "Tailscale Funnel desativado",
"serveError": "Falha ao configurar Tailscale Serve",
"funnelError": "Falha ao configurar Tailscale Funnel",
"urlCopied": "URL copiada para a área de transferência"
},
"telegramWizard": {
"title": "Conectar Telegram",
"description": "Configure o Telegram para conversar com Jan remotamente",
"step1": {
"title": "Criar um Bot",
"instruction1": "Abra o Telegram e pesquise @BotFather",
"instruction2": "Envie /newbot para criar um novo bot",
"instruction3": "Siga as instruções para nomear seu bot",
"instruction4": "Copie o token do bot (parece: 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Inserir Token",
"placeholder": "Cole o token do bot aqui",
"validate": "Validar Token",
"validating": "Validando...",
"invalidToken": "Token inválido. Verifique e tente novamente.",
"validToken": "Token validado! Bot: @{{username}}"
},
"step3": {
"title": "Configurar & Iniciar",
"connecting": "Configurando canal do Telegram...",
"success": "Canal do Telegram configurado com sucesso!",
"error": "Falha ao configurar Telegram: {{error}}"
},
"step4": {
"title": "Parear seu Dispositivo",
"instruction": "Abra seu bot no Telegram e envie uma mensagem. O bot responderá com um código de pareamento — cole abaixo para conectar.",
"openBot": "Abrir @{{username}} no Telegram",
"instruction1": "Abra seu bot no Telegram usando o link acima",
"instruction2": "Pressione \"Iniciar\" ou envie qualquer mensagem ao bot",
"instruction3": "Copie o código de pareamento da resposta do bot e cole abaixo",
"codeLabel": "Código de Pareamento",
"codePlaceholder": "ex: ZNAV9KZ3",
"approve": "Aprovar & Conectar",
"approving": "Aprovando...",
"approved": "Dispositivo pareado com sucesso!",
"approveError": "Falha ao aprovar pareamento: {{error}}",
"codeRequired": "Insira o código de pareamento da mensagem do bot",
"notConnected": "Canal ainda não conectado. Verifique se o gateway OpenClaw está em execução.",
"skipForNow": "Pular por agora",
"resetPairing": "Sem código? Clique para resetar & tente /start novamente",
"resetSuccess": "Códigos de pareamento limpos — envie /start ao bot novamente",
"resetError": "Falha ao resetar códigos de pareamento"
},
"step5": {
"title": "Conectado!",
"success": "Telegram está conectado!",
"instruction": "Agora você pode conversar com Jan enviando mensagens ao seu bot.",
"botUsername": "Seu bot: @{{username}}",
"pairedUsers": "{{count}} dispositivo(s) pareado(s)"
},
"buttons": {
"next": "Próximo",
"back": "Voltar",
"connect": "Conectar",
"cancel": "Cancelar",
"done": "Concluído",
"retry": "Tentar novamente",
"disconnect": "Desconectar"
},
"status": {
"notConnected": "Não conectado",
"connected": "Conectado",
"connecting": "Conectando..."
},
"errors": {
"networkError": "Erro de rede. Verifique sua conexão.",
"tokenRequired": "Insira um token de bot",
"configError": "Falha ao salvar configuração"
}
},
"whatsappWizard": {
"setupTitle": "Configuração do WhatsApp",
"setupDescription": "Conecte sua conta WhatsApp para ativar mensagens remotas",
"howItWorks": "Como funciona",
"whatsappStep1": "Clique em 'Iniciar Configuração' — configuraremos tudo automaticamente",
"whatsappStep2": "Escaneie o código QR com seu app WhatsApp",
"whatsappStep3": "Comece a conversar com Jan do seu celular!",
"settingUp": "Configurando...",
"settingUpDescription": "Configurando OpenClaw e preparando conexão WhatsApp. Isso pode levar um momento...",
"checkingOpenClaw": "Verificando instalação do OpenClaw",
"configuringGateway": "Configurando conexão do Gateway",
"enablingWhatsApp": "Ativando canal WhatsApp",
"scanQrCode": "Escanear Código QR",
"qrCodeInstructions": "Abra o WhatsApp no seu celular e escaneie este código QR",
"generatingQrCode": "Gerando código QR...",
"waitingForScan": "Aguardando escaneamento...",
"verifyingConnection": "Verificando Conexão",
"pleaseWait": "Aguarde enquanto verificamos sua conexão",
"whatsappConnected": "WhatsApp Conectado!",
"whatsappConnectedDescription": "Seu WhatsApp está conectado. Você pode conversar com Jan enviando mensagens.",
"connectionFailed": "Conexão Falhou",
"unknownError": "Ocorreu um erro desconhecido",
"back": "Voltar",
"refreshQrCode": "Atualizar Código QR",
"tryAgain": "Tentar Novamente",
"startSetup": "Iniciar Configuração",
"connecting": "Conectando..."
}
}
"backendInstallError": "Falha ao instalar backend"
}

View File

@@ -18,7 +18,6 @@
"newChat": "Новый чат",
"newAgentChat": "Новый чат агента",
"chats": "Чаты",
"openclawAgent": "OpenClaw Агент",
"favorites": "Избранное",
"recents": "Недавние",
"hub": "Хаб",

View File

@@ -312,175 +312,5 @@
"updateError": "Не удалось обновить Llamacpp"
},
"backendInstallSuccess": "Компонент успешно установлен",
"backendInstallError": "Не удалось установить компонент",
"remoteAccess": {
"title": "Удалённый доступ",
"running": "Удалённый доступ запущен",
"stopped": "Удалённый доступ остановлен",
"urlCopied": "Ссылка скопирована в буфер обмена",
"port": "Порт",
"runtimeVersion": "Версия runtime",
"openclawVersion": "Версия OpenClaw",
"openclawIntegration": "Интеграция OpenClaw",
"enableRemoteAccess": "Включить OpenClaw",
"disableRemoteAccess": "Отключить OpenClaw",
"settings": "Настройки",
"install": "Установить",
"start": "Запустить",
"stop": "Остановить",
"startError": "Не удалось запустить удалённый доступ. Попробуйте снова.",
"stopError": "Не удалось остановить удалённый доступ. Попробуйте снова.",
"nodeRequired": "Требуется Node.js 22+. Сначала установите его.",
"portInUse": "Порт 18789 занят. Закройте другие приложения, использующие его.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Каналы",
"runtimeMode": "Режим запуска",
"dockerSandbox": "Docker-контейнер",
"directProcess": "Прямой процесс",
"securityAdvisory": "OpenClaw работает без изоляции песочницы. Для лучшей безопасности установите и откройте Docker Desktop, затем перезапустите OpenClaw для запуска в контейнере.",
"logViewer": "Логи песочницы",
"noLogs": "Нет логов",
"copyLogs": "Копировать всё",
"downloadLogs": "Скачать",
"restartSandbox": "Перезапустить песочницу",
"restarting": "Перезапуск песочницы...",
"localNetworkOnly": "Только локальная сеть",
"notConfigured": "Не настроено",
"publicAccess": "Публичный доступ",
"tunnelUrl": "URL туннеля",
"copyUrl": "Копировать URL",
"openUrl": "Открыть URL",
"activeTunnel": "Активный туннель",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Редактируйте Soul.md, User.md и другие конфигурационные файлы",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Открыть веб-панель управления",
"addChannel": {
"title": "Добавить канал",
"description": "Выберите мессенджер для подключения",
"cancel": "Отмена",
"continue": "Продолжить"
},
"channelDescriptions": {
"telegram": "Подключить через бота Telegram для безопасных сообщений",
"whatsapp": "Подключить через WhatsApp для бизнес-сообщений"
},
"connected": "Подключено",
"notConnected": "Не подключено",
"manage": "Управление",
"disconnect": "Отключить",
"channelDisconnected": "{{channel}} отключён",
"disconnectError": "Не удалось отключить. Попробуйте снова.",
"channelNotConnected": "Нажмите настройки, чтобы подключить {{channel}}",
"copiedToClipboard": "Скопировано в буфер обмена",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve включён",
"serveDisabled": "Tailscale Serve выключен",
"funnelEnabled": "Tailscale Funnel включён - Ваша конечная точка теперь общедоступна",
"funnelDisabled": "Tailscale Funnel выключен",
"serveError": "Не удалось настроить Tailscale Serve",
"funnelError": "Не удалось настроить Tailscale Funnel",
"urlCopied": "URL скопирован в буфер обмена"
},
"telegramWizard": {
"title": "Подключить Telegram",
"description": "Настройте Telegram для удалённого чата с Jan",
"step1": {
"title": "Создать бота",
"instruction1": "Откройте Telegram и найдите @BotFather",
"instruction2": "Отправьте /newbot для создания нового бота",
"instruction3": "Следуйте инструкциям, чтобы назвать бота",
"instruction4": "Скопируйте токен бота (выглядит как: 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Введите токен",
"placeholder": "Вставьте токен бота здесь",
"validate": "Проверить токен",
"validating": "Проверка...",
"invalidToken": "Недействительный токен. Проверьте и попробуйте снова.",
"validToken": "Токен подтверждён! Бот: @{{username}}"
},
"step3": {
"title": "Настроить и запустить",
"connecting": "Настройка канала Telegram...",
"success": "Канал Telegram успешно настроен!",
"error": "Не удалось настроить Telegram: {{error}}"
},
"step4": {
"title": "Сопряжение устройства",
"instruction": "Откройте бота в Telegram и отправьте сообщение. Бот ответит кодом сопряжения — вставьте его ниже для подключения.",
"openBot": "Открыть @{{username}} в Telegram",
"instruction1": "Откройте бота в Telegram по ссылке выше",
"instruction2": "Нажмите «Старт» или отправьте любое сообщение боту",
"instruction3": "Скопируйте код сопряжения из ответа бота и вставьте ниже",
"codeLabel": "Код сопряжения",
"codePlaceholder": "например: ZNAV9KZ3",
"approve": "Одобрить и подключить",
"approving": "Одобрение...",
"approved": "Устройство успешно сопряжено!",
"approveError": "Не удалось одобрить сопряжение: {{error}}",
"codeRequired": "Введите код сопряжения из сообщения бота",
"notConnected": "Канал ещё не подключён. Убедитесь, что шлюз OpenClaw запущен.",
"skipForNow": "Пропустить пока",
"resetPairing": "Нет кода? Нажмите для сброса и отправьте /start заново",
"resetSuccess": "Коды сопряжения очищены — отправьте /start боту заново",
"resetError": "Не удалось сбросить коды сопряжения"
},
"step5": {
"title": "Подключено!",
"success": "Telegram подключён!",
"instruction": "Теперь вы можете общаться с Jan, отправляя сообщения боту.",
"botUsername": "Ваш бот: @{{username}}",
"pairedUsers": "{{count}} устройств(о) сопряжено"
},
"buttons": {
"next": "Далее",
"back": "Назад",
"connect": "Подключить",
"cancel": "Отмена",
"done": "Готово",
"retry": "Повторить",
"disconnect": "Отключить"
},
"status": {
"notConnected": "Не подключено",
"connected": "Подключено",
"connecting": "Подключение..."
},
"errors": {
"networkError": "Ошибка сети. Проверьте подключение.",
"tokenRequired": "Введите токен бота",
"configError": "Не удалось сохранить конфигурацию"
}
},
"whatsappWizard": {
"setupTitle": "Настройка WhatsApp",
"setupDescription": "Подключите аккаунт WhatsApp для удалённых сообщений",
"howItWorks": "Как это работает",
"whatsappStep1": "Нажмите «Начать настройку» — мы настроим всё автоматически",
"whatsappStep2": "Отсканируйте QR-код приложением WhatsApp",
"whatsappStep3": "Начните общаться с Jan с телефона!",
"settingUp": "Настройка...",
"settingUpDescription": "Настройка OpenClaw и подготовка подключения WhatsApp. Это может занять некоторое время...",
"checkingOpenClaw": "Проверка установки OpenClaw",
"configuringGateway": "Настройка подключения шлюза",
"enablingWhatsApp": "Активация канала WhatsApp",
"scanQrCode": "Сканировать QR-код",
"qrCodeInstructions": "Откройте WhatsApp на телефоне и отсканируйте этот QR-код",
"generatingQrCode": "Генерация QR-кода...",
"waitingForScan": "Ожидание сканирования...",
"verifyingConnection": "Проверка подключения",
"pleaseWait": "Подождите, пока мы проверяем подключение",
"whatsappConnected": "WhatsApp подключён!",
"whatsappConnectedDescription": "WhatsApp подключён. Вы можете общаться с Jan, отправляя сообщения.",
"connectionFailed": "Подключение не удалось",
"unknownError": "Произошла неизвестная ошибка",
"back": "Назад",
"refreshQrCode": "Обновить QR-код",
"tryAgain": "Попробовать снова",
"startSetup": "Начать настройку",
"connecting": "Подключение..."
}
}
"backendInstallError": "Не удалось установить компонент"
}

View File

@@ -18,7 +18,6 @@
"newChat": "Trò chuyện Mới",
"newAgentChat": "Trò chuyện Agent Mới",
"chats": "Trò chuyện",
"openclawAgent": "OpenClaw Agent",
"favorites": "Yêu thích",
"recents": "Gần đây",
"hub": "Hub",

View File

@@ -274,175 +274,5 @@
"updateError": "Không thể cập nhật Llamacpp"
},
"backendInstallSuccess": "Backend đã được cài đặt thành công",
"backendInstallError": "Không thể cài đặt backend",
"remoteAccess": {
"title": "Truy cập từ xa",
"running": "Truy cập từ xa đang chạy",
"stopped": "Truy cập từ xa đã dừng",
"urlCopied": "Liên kết đã sao chép vào clipboard",
"port": "Cổng",
"runtimeVersion": "Phiên bản Runtime",
"openclawVersion": "Phiên bản OpenClaw",
"openclawIntegration": "Tích hợp OpenClaw",
"enableRemoteAccess": "Bật OpenClaw",
"disableRemoteAccess": "Tắt OpenClaw",
"settings": "Cài đặt",
"install": "Cài đặt",
"start": "Bắt đầu",
"stop": "Dừng",
"startError": "Không thể bắt đầu Truy cập từ xa. Vui lòng thử lại.",
"stopError": "Không thể dừng Truy cập từ xa. Vui lòng thử lại.",
"nodeRequired": "Cần Node.js 22+. Vui lòng cài đặt trước.",
"portInUse": "Cổng 18789 đang được sử dụng. Vui lòng đóng các ứng dụng khác đang sử dụng nó.",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "Kênh",
"runtimeMode": "Chế độ chạy",
"dockerSandbox": "Docker Container",
"directProcess": "Tiến trình trực tiếp",
"securityAdvisory": "OpenClaw đang chạy không có sandbox. Để bảo mật tốt hơn, hãy cài đặt và mở Docker Desktop, sau đó khởi động lại OpenClaw để chạy trong container.",
"logViewer": "Logs Sandbox",
"noLogs": "Không có logs",
"copyLogs": "Sao chép tất cả",
"downloadLogs": "Tải xuống",
"restartSandbox": "Khởi động lại Sandbox",
"restarting": "Đang khởi động lại sandbox...",
"localNetworkOnly": "Chỉ mạng cục bộ",
"notConfigured": "Chưa cấu hình",
"publicAccess": "Truy cập công khai",
"tunnelUrl": "URL đường hầm",
"copyUrl": "Sao chép URL",
"openUrl": "Mở URL",
"activeTunnel": "Đường hầm hoạt động",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "Chỉnh sửa Soul.md, User.md và các tệp cấu hình khác",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "Mở bảng điều khiển web",
"addChannel": {
"title": "Thêm kênh",
"description": "Chọn nền tảng nhắn tin để kết nối",
"cancel": "Hủy",
"continue": "Tiếp tục"
},
"channelDescriptions": {
"telegram": "Kết nối qua bot Telegram để nhắn tin an toàn",
"whatsapp": "Kết nối qua WhatsApp để nhắn tin kinh doanh"
},
"connected": "Đã kết nối",
"notConnected": "Chưa kết nối",
"manage": "Quản lý",
"disconnect": "Ngắt kết nối",
"channelDisconnected": "{{channel}} đã ngắt kết nối",
"disconnectError": "Không thể ngắt kết nối. Vui lòng thử lại.",
"channelNotConnected": "Nhấp vào cài đặt để kết nối {{channel}}",
"copiedToClipboard": "Đã sao chép vào clipboard",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve đã bật",
"serveDisabled": "Tailscale Serve đã tắt",
"funnelEnabled": "Tailscale Funnel đã bật - Endpoint của bạn hiện có thể truy cập công khai",
"funnelDisabled": "Tailscale Funnel đã tắt",
"serveError": "Không thể cấu hình Tailscale Serve",
"funnelError": "Không thể cấu hình Tailscale Funnel",
"urlCopied": "URL đã sao chép vào clipboard"
},
"telegramWizard": {
"title": "Kết nối Telegram",
"description": "Thiết lập Telegram để trò chuyện với Jan từ xa",
"step1": {
"title": "Tạo Bot",
"instruction1": "Mở Telegram và tìm @BotFather",
"instruction2": "Gửi /newbot để tạo bot mới",
"instruction3": "Làm theo hướng dẫn để đặt tên bot",
"instruction4": "Sao chép mã bot (dạng: 123456789:ABCdefGHI...)"
},
"step2": {
"title": "Nhập mã",
"placeholder": "Dán mã bot vào đây",
"validate": "Xác thực mã",
"validating": "Đang xác thực...",
"invalidToken": "Mã không hợp lệ. Vui lòng kiểm tra và thử lại.",
"validToken": "Mã đã xác thực! Bot: @{{username}}"
},
"step3": {
"title": "Cấu hình & Bắt đầu",
"connecting": "Đang cấu hình kênh Telegram...",
"success": "Kênh Telegram đã cấu hình thành công!",
"error": "Cấu hình Telegram thất bại: {{error}}"
},
"step4": {
"title": "Ghép nối thiết bị",
"instruction": "Mở bot trên Telegram và gửi tin nhắn. Bot sẽ trả lời mã ghép nối — dán vào bên dưới để kết nối.",
"openBot": "Mở @{{username}} trên Telegram",
"instruction1": "Mở bot trên Telegram bằng liên kết ở trên",
"instruction2": "Nhấn \"Bắt đầu\" hoặc gửi tin nhắn bất kỳ cho bot",
"instruction3": "Sao chép mã ghép nối từ phản hồi của bot và dán vào bên dưới",
"codeLabel": "Mã ghép nối",
"codePlaceholder": "ví dụ: ZNAV9KZ3",
"approve": "Phê duyệt & Kết nối",
"approving": "Đang phê duyệt...",
"approved": "Thiết bị đã ghép nối thành công!",
"approveError": "Phê duyệt ghép nối thất bại: {{error}}",
"codeRequired": "Vui lòng nhập mã ghép nối từ tin nhắn của bot",
"notConnected": "Kênh chưa kết nối. Đảm bảo OpenClaw gateway đang chạy.",
"skipForNow": "Bỏ qua tạm thời",
"resetPairing": "Không có mã? Nhấp để đặt lại & thử /start lại",
"resetSuccess": "Đã xóa mã ghép nối — gửi /start cho bot lại",
"resetError": "Đặt lại mã ghép nối thất bại"
},
"step5": {
"title": "Đã kết nối!",
"success": "Telegram đã kết nối!",
"instruction": "Bạn có thể trò chuyện với Jan bằng cách gửi tin nhắn cho bot.",
"botUsername": "Bot của bạn: @{{username}}",
"pairedUsers": "{{count}} thiết bị đã ghép nối"
},
"buttons": {
"next": "Tiếp",
"back": "Quay lại",
"connect": "Kết nối",
"cancel": "Hủy",
"done": "Xong",
"retry": "Thử lại",
"disconnect": "Ngắt kết nối"
},
"status": {
"notConnected": "Chưa kết nối",
"connected": "Đã kết nối",
"connecting": "Đang kết nối..."
},
"errors": {
"networkError": "Lỗi mạng. Vui lòng kiểm tra kết nối.",
"tokenRequired": "Vui lòng nhập mã bot",
"configError": "Lưu cấu hình thất bại"
}
},
"whatsappWizard": {
"setupTitle": "Thiết lập WhatsApp",
"setupDescription": "Kết nối tài khoản WhatsApp để nhắn tin từ xa",
"howItWorks": "Cách hoạt động",
"whatsappStep1": "Nhấp 'Bắt đầu thiết lập' - chúng tôi sẽ tự động cấu hình",
"whatsappStep2": "Quét mã QR bằng ứng dụng WhatsApp",
"whatsappStep3": "Bắt đầu trò chuyện với Jan từ điện thoại!",
"settingUp": "Đang thiết lập...",
"settingUpDescription": "Đang cấu hình OpenClaw và chuẩn bị kết nối WhatsApp. Vui lòng đợi...",
"checkingOpenClaw": "Kiểm tra cài đặt OpenClaw",
"configuringGateway": "Cấu hình kết nối Gateway",
"enablingWhatsApp": "Kích hoạt kênh WhatsApp",
"scanQrCode": "Quét mã QR",
"qrCodeInstructions": "Mở WhatsApp trên điện thoại và quét mã QR này",
"generatingQrCode": "Đang tạo mã QR...",
"waitingForScan": "Đang chờ quét...",
"verifyingConnection": "Xác minh kết nối",
"pleaseWait": "Vui lòng đợi trong khi chúng tôi xác minh kết nối",
"whatsappConnected": "WhatsApp đã kết nối!",
"whatsappConnectedDescription": "WhatsApp đã kết nối. Bạn có thể trò chuyện với Jan bằng cách gửi tin nhắn.",
"connectionFailed": "Kết nối thất bại",
"unknownError": "Đã xảy ra lỗi không xác định",
"back": "Quay lại",
"refreshQrCode": "Làm mới mã QR",
"tryAgain": "Thử lại",
"startSetup": "Bắt đầu thiết lập",
"connecting": "Đang kết nối..."
}
}
"backendInstallError": "Không thể cài đặt backend"
}

View File

@@ -18,7 +18,6 @@
"newChat": "新建聊天",
"newAgentChat": "新建智能体聊天",
"chats": "聊天",
"openclawAgent": "OpenClaw 智能体",
"favorites": "收藏",
"recents": "最近",
"hub": "中心",
@@ -106,7 +105,7 @@
"increaseContextSizeDescription": "您想要增加上下文大小吗?",
"increaseContextSize": "增加上下文大小"
},
"customAvatar": "自定义头像",
"customAvatar": "自定义头像",
"editAssistant": "编辑助手",
"jan": "Jan",
"metadata": "元数据",

View File

@@ -274,175 +274,5 @@
"updateError": "更新 Llamacpp 失败"
},
"backendInstallSuccess": "后端安装成功",
"backendInstallError": "后端安装失败",
"remoteAccess": {
"title": "远程访问",
"running": "远程访问正在运行",
"stopped": "远程访问已停止",
"urlCopied": "链接已复制到剪贴板",
"port": "端口",
"runtimeVersion": "运行时版本",
"openclawVersion": "OpenClaw 版本",
"openclawIntegration": "OpenClaw 集成",
"enableRemoteAccess": "启用 OpenClaw",
"disableRemoteAccess": "禁用 OpenClaw",
"settings": "设置",
"install": "安装",
"start": "启动",
"stop": "停止",
"startError": "无法启动远程访问。请重试。",
"stopError": "无法停止远程访问。请重试。",
"nodeRequired": "需要 Node.js 22+。请先安装。",
"portInUse": "端口 18789 已被占用。请关闭占用该端口的其他应用。",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "频道",
"runtimeMode": "运行模式",
"dockerSandbox": "Docker 容器",
"directProcess": "直接进程",
"securityAdvisory": "OpenClaw 正在无沙箱隔离的环境下运行。为了更好的安全性,请安装并打开 Docker Desktop然后重新启动 OpenClaw 以在容器中运行。",
"logViewer": "沙箱日志",
"noLogs": "暂无日志",
"copyLogs": "全部复制",
"downloadLogs": "下载",
"restartSandbox": "重启沙箱",
"restarting": "正在重启沙箱...",
"localNetworkOnly": "仅限本地网络",
"notConfigured": "未配置",
"publicAccess": "公开访问",
"tunnelUrl": "隧道 URL",
"copyUrl": "复制 URL",
"openUrl": "打开 URL",
"activeTunnel": "活跃隧道",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "编辑 Soul.md、User.md 及其他配置文件",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "打开网页控制台",
"addChannel": {
"title": "添加频道",
"description": "选择要连接的消息平台",
"cancel": "取消",
"continue": "继续"
},
"channelDescriptions": {
"telegram": "通过 Telegram 机器人连接以实现安全消息传递",
"whatsapp": "通过 WhatsApp 连接以实现商业消息传递"
},
"connected": "已连接",
"notConnected": "未连接",
"manage": "管理",
"disconnect": "断开",
"channelDisconnected": "{{channel}} 已断开",
"disconnectError": "无法断开连接。请重试。",
"channelNotConnected": "点击设置连接 {{channel}}",
"copiedToClipboard": "已复制到剪贴板",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve 已启用",
"serveDisabled": "Tailscale Serve 已禁用",
"funnelEnabled": "Tailscale Funnel 已启用 - 您的端点现在可公开访问",
"funnelDisabled": "Tailscale Funnel 已禁用",
"serveError": "配置 Tailscale Serve 失败",
"funnelError": "配置 Tailscale Funnel 失败",
"urlCopied": "URL 已复制到剪贴板"
},
"telegramWizard": {
"title": "连接 Telegram",
"description": "设置 Telegram 以远程与 Jan 聊天",
"step1": {
"title": "创建机器人",
"instruction1": "打开 Telegram 并搜索 @BotFather",
"instruction2": "发送 /newbot 创建新机器人",
"instruction3": "按照提示为机器人命名",
"instruction4": "复制机器人令牌格式如123456789:ABCdefGHI..."
},
"step2": {
"title": "输入令牌",
"placeholder": "在此粘贴机器人令牌",
"validate": "验证令牌",
"validating": "验证中...",
"invalidToken": "令牌无效。请检查后重试。",
"validToken": "令牌已验证!机器人:@{{username}}"
},
"step3": {
"title": "配置并启动",
"connecting": "正在配置 Telegram 频道...",
"success": "Telegram 频道配置成功!",
"error": "Telegram 配置失败:{{error}}"
},
"step4": {
"title": "配对您的设备",
"instruction": "在 Telegram 上打开您的机器人并发送消息。机器人将回复配对码——粘贴到下方以连接。",
"openBot": "在 Telegram 上打开 @{{username}}",
"instruction1": "使用上方链接在 Telegram 上打开您的机器人",
"instruction2": "按\"开始\"或向机器人发送任意消息",
"instruction3": "从机器人的回复中复制配对码并粘贴到下方",
"codeLabel": "配对码",
"codePlaceholder": "例如ZNAV9KZ3",
"approve": "批准并连接",
"approving": "批准中...",
"approved": "设备配对成功!",
"approveError": "配对批准失败:{{error}}",
"codeRequired": "请输入机器人消息中的配对码",
"notConnected": "频道尚未连接。请确保 OpenClaw 网关正在运行。",
"skipForNow": "暂时跳过",
"resetPairing": "没有代码?点击重置并重新发送 /start",
"resetSuccess": "配对码已清除——请重新向机器人发送 /start",
"resetError": "重置配对码失败"
},
"step5": {
"title": "已连接!",
"success": "Telegram 已连接!",
"instruction": "您现在可以通过向机器人发送消息来与 Jan 聊天。",
"botUsername": "您的机器人:@{{username}}",
"pairedUsers": "{{count}} 台设备已配对"
},
"buttons": {
"next": "下一步",
"back": "返回",
"connect": "连接",
"cancel": "取消",
"done": "完成",
"retry": "重试",
"disconnect": "断开"
},
"status": {
"notConnected": "未连接",
"connected": "已连接",
"connecting": "连接中..."
},
"errors": {
"networkError": "网络错误。请检查您的连接。",
"tokenRequired": "请输入机器人令牌",
"configError": "保存配置失败"
}
},
"whatsappWizard": {
"setupTitle": "WhatsApp 设置",
"setupDescription": "连接您的 WhatsApp 帐户以启用远程消息",
"howItWorks": "工作原理",
"whatsappStep1": "点击\"开始设置\" — 我们将自动配置一切",
"whatsappStep2": "用 WhatsApp 应用扫描二维码",
"whatsappStep3": "从手机开始与 Jan 聊天!",
"settingUp": "设置中...",
"settingUpDescription": "正在配置 OpenClaw 并准备 WhatsApp 连接。请稍候...",
"checkingOpenClaw": "检查 OpenClaw 安装",
"configuringGateway": "配置网关连接",
"enablingWhatsApp": "启用 WhatsApp 频道",
"scanQrCode": "扫描二维码",
"qrCodeInstructions": "在手机上打开 WhatsApp 并扫描此二维码",
"generatingQrCode": "正在生成二维码...",
"waitingForScan": "等待扫描...",
"verifyingConnection": "验证连接",
"pleaseWait": "请稍候,我们正在验证您的连接",
"whatsappConnected": "WhatsApp 已连接!",
"whatsappConnectedDescription": "您的 WhatsApp 已连接。您可以通过发送消息与 Jan 聊天。",
"connectionFailed": "连接失败",
"unknownError": "发生未知错误",
"back": "返回",
"refreshQrCode": "刷新二维码",
"tryAgain": "重试",
"startSetup": "开始设置",
"connecting": "连接中..."
}
}
"backendInstallError": "后端安装失败"
}

View File

@@ -18,7 +18,6 @@
"newChat": "新聊天",
"newAgentChat": "新智能體聊天",
"chats": "聊天",
"openclawAgent": "OpenClaw 智能體",
"favorites": "我的最愛",
"recents": "最近",
"hub": "中心",

View File

@@ -274,175 +274,5 @@
"updateError": "更新 Llamacpp 失敗"
},
"backendInstallSuccess": "後端安裝成功",
"backendInstallError": "後端安裝失敗",
"remoteAccess": {
"title": "遠端存取",
"running": "遠端存取正在執行",
"stopped": "遠端存取已停止",
"urlCopied": "連結已複製到剪貼簿",
"port": "連接埠",
"runtimeVersion": "執行階段版本",
"openclawVersion": "OpenClaw 版本",
"openclawIntegration": "OpenClaw 整合",
"enableRemoteAccess": "啟用 OpenClaw",
"disableRemoteAccess": "停用 OpenClaw",
"settings": "設定",
"install": "安裝",
"start": "啟動",
"stop": "停止",
"startError": "無法啟動遠端存取。請重試。",
"stopError": "無法停止遠端存取。請重試。",
"nodeRequired": "需要 Node.js 22+。請先安裝。",
"portInUse": "連接埠 18789 已被佔用。請關閉佔用該連接埠的其他應用。",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"channels": "頻道",
"runtimeMode": "執行模式",
"dockerSandbox": "Docker 容器",
"directProcess": "直接程序",
"securityAdvisory": "OpenClaw 正在無沙箱隔離的環境下執行。為了更好的安全性,請安裝並開啟 Docker Desktop然後重新啟動 OpenClaw 以在容器中執行。",
"logViewer": "沙箱日誌",
"noLogs": "暫無日誌",
"copyLogs": "全部複製",
"downloadLogs": "下載",
"restartSandbox": "重新啟動沙箱",
"restarting": "正在重新啟動沙箱...",
"localNetworkOnly": "僅限本地網路",
"notConfigured": "未設定",
"publicAccess": "公開存取",
"tunnelUrl": "隧道 URL",
"copyUrl": "複製 URL",
"openUrl": "開啟 URL",
"activeTunnel": "活躍隧道",
"openclawFolder": "OpenClaw Folder",
"openclawFolderDesc": "編輯 Soul.md、User.md 及其他設定檔",
"gatewayUrl": "OpenClaw Dashboard",
"gatewayUrlDesc": "開啟網頁控制台",
"addChannel": {
"title": "新增頻道",
"description": "選擇要連接的訊息平台",
"cancel": "取消",
"continue": "繼續"
},
"channelDescriptions": {
"telegram": "透過 Telegram 機器人連接以實現安全訊息傳遞",
"whatsapp": "透過 WhatsApp 連接以實現商業訊息傳遞"
},
"connected": "已連接",
"notConnected": "未連接",
"manage": "管理",
"disconnect": "斷開",
"channelDisconnected": "{{channel}} 已斷開",
"disconnectError": "無法斷開連接。請重試。",
"channelNotConnected": "點擊設定連接 {{channel}}",
"copiedToClipboard": "已複製到剪貼簿",
"tailscaleSettings": {
"serveEnabled": "Tailscale Serve 已啟用",
"serveDisabled": "Tailscale Serve 已停用",
"funnelEnabled": "Tailscale Funnel 已啟用 - 您的端點現在可公開存取",
"funnelDisabled": "Tailscale Funnel 已停用",
"serveError": "設定 Tailscale Serve 失敗",
"funnelError": "設定 Tailscale Funnel 失敗",
"urlCopied": "URL 已複製到剪貼簿"
},
"telegramWizard": {
"title": "連接 Telegram",
"description": "設定 Telegram 以遠端與 Jan 聊天",
"step1": {
"title": "建立機器人",
"instruction1": "開啟 Telegram 並搜尋 @BotFather",
"instruction2": "傳送 /newbot 建立新機器人",
"instruction3": "依照提示為機器人命名",
"instruction4": "複製機器人權杖格式如123456789:ABCdefGHI..."
},
"step2": {
"title": "輸入權杖",
"placeholder": "在此貼上機器人權杖",
"validate": "驗證權杖",
"validating": "驗證中...",
"invalidToken": "權杖無效。請檢查後重試。",
"validToken": "權杖已驗證!機器人:@{{username}}"
},
"step3": {
"title": "設定並啟動",
"connecting": "正在設定 Telegram 頻道...",
"success": "Telegram 頻道設定成功!",
"error": "Telegram 設定失敗:{{error}}"
},
"step4": {
"title": "配對您的裝置",
"instruction": "在 Telegram 上開啟您的機器人並傳送訊息。機器人將回覆配對碼——貼到下方以連接。",
"openBot": "在 Telegram 上開啟 @{{username}}",
"instruction1": "使用上方連結在 Telegram 上開啟您的機器人",
"instruction2": "按「開始」或向機器人傳送任意訊息",
"instruction3": "從機器人的回覆中複製配對碼並貼到下方",
"codeLabel": "配對碼",
"codePlaceholder": "例如ZNAV9KZ3",
"approve": "批准並連接",
"approving": "批准中...",
"approved": "裝置配對成功!",
"approveError": "配對批准失敗:{{error}}",
"codeRequired": "請輸入機器人訊息中的配對碼",
"notConnected": "頻道尚未連接。請確保 OpenClaw 閘道正在執行。",
"skipForNow": "暫時跳過",
"resetPairing": "沒有代碼?點擊重設並重新傳送 /start",
"resetSuccess": "配對碼已清除——請重新向機器人傳送 /start",
"resetError": "重設配對碼失敗"
},
"step5": {
"title": "已連接!",
"success": "Telegram 已連接!",
"instruction": "您現在可以透過向機器人傳送訊息來與 Jan 聊天。",
"botUsername": "您的機器人:@{{username}}",
"pairedUsers": "{{count}} 台裝置已配對"
},
"buttons": {
"next": "下一步",
"back": "返回",
"connect": "連接",
"cancel": "取消",
"done": "完成",
"retry": "重試",
"disconnect": "斷開"
},
"status": {
"notConnected": "未連接",
"connected": "已連接",
"connecting": "連接中..."
},
"errors": {
"networkError": "網路錯誤。請檢查您的連線。",
"tokenRequired": "請輸入機器人權杖",
"configError": "儲存設定失敗"
}
},
"whatsappWizard": {
"setupTitle": "WhatsApp 設定",
"setupDescription": "連接您的 WhatsApp 帳戶以啟用遠端訊息",
"howItWorks": "運作方式",
"whatsappStep1": "點擊「開始設定」——我們將自動設定一切",
"whatsappStep2": "用 WhatsApp 應用掃描 QR 碼",
"whatsappStep3": "從手機開始與 Jan 聊天!",
"settingUp": "設定中...",
"settingUpDescription": "正在設定 OpenClaw 並準備 WhatsApp 連線。請稍候...",
"checkingOpenClaw": "檢查 OpenClaw 安裝",
"configuringGateway": "設定閘道連線",
"enablingWhatsApp": "啟用 WhatsApp 頻道",
"scanQrCode": "掃描 QR 碼",
"qrCodeInstructions": "在手機上開啟 WhatsApp 並掃描此 QR 碼",
"generatingQrCode": "正在產生 QR 碼...",
"waitingForScan": "等待掃描...",
"verifyingConnection": "驗證連線",
"pleaseWait": "請稍候,我們正在驗證您的連線",
"whatsappConnected": "WhatsApp 已連接!",
"whatsappConnectedDescription": "您的 WhatsApp 已連接。您可以透過傳送訊息與 Jan 聊天。",
"connectionFailed": "連線失敗",
"unknownError": "發生未知錯誤",
"back": "返回",
"refreshQrCode": "重新整理 QR 碼",
"tryAgain": "重試",
"startSetup": "開始設定",
"connecting": "連接中..."
}
}
"backendInstallError": "後端安裝失敗"
}

View File

@@ -15,7 +15,6 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as HubIndexRouteImport } from './routes/hub/index'
import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId'
import { Route as SettingsShortcutsRouteImport } from './routes/settings/shortcuts'
import { Route as SettingsRemoteAccessRouteImport } from './routes/settings/remote-access'
import { Route as SettingsPrivacyRouteImport } from './routes/settings/privacy'
import { Route as SettingsMcpServersRouteImport } from './routes/settings/mcp-servers'
import { Route as SettingsLocalApiServerRouteImport } from './routes/settings/local-api-server'
@@ -63,11 +62,6 @@ const SettingsShortcutsRoute = SettingsShortcutsRouteImport.update({
path: '/settings/shortcuts',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRemoteAccessRoute = SettingsRemoteAccessRouteImport.update({
id: '/settings/remote-access',
path: '/settings/remote-access',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsPrivacyRoute = SettingsPrivacyRouteImport.update({
id: '/settings/privacy',
path: '/settings/privacy',
@@ -168,7 +162,6 @@ export interface FileRoutesByFullPath {
'/settings/local-api-server': typeof SettingsLocalApiServerRoute
'/settings/mcp-servers': typeof SettingsMcpServersRoute
'/settings/privacy': typeof SettingsPrivacyRoute
'/settings/remote-access': typeof SettingsRemoteAccessRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub/': typeof HubIndexRoute
@@ -193,7 +186,6 @@ export interface FileRoutesByTo {
'/settings/local-api-server': typeof SettingsLocalApiServerRoute
'/settings/mcp-servers': typeof SettingsMcpServersRoute
'/settings/privacy': typeof SettingsPrivacyRoute
'/settings/remote-access': typeof SettingsRemoteAccessRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub': typeof HubIndexRoute
@@ -219,7 +211,6 @@ export interface FileRoutesById {
'/settings/local-api-server': typeof SettingsLocalApiServerRoute
'/settings/mcp-servers': typeof SettingsMcpServersRoute
'/settings/privacy': typeof SettingsPrivacyRoute
'/settings/remote-access': typeof SettingsRemoteAccessRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/threads/$threadId': typeof ThreadsThreadIdRoute
'/hub/': typeof HubIndexRoute
@@ -246,7 +237,6 @@ export interface FileRouteTypes {
| '/settings/local-api-server'
| '/settings/mcp-servers'
| '/settings/privacy'
| '/settings/remote-access'
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub/'
@@ -271,7 +261,6 @@ export interface FileRouteTypes {
| '/settings/local-api-server'
| '/settings/mcp-servers'
| '/settings/privacy'
| '/settings/remote-access'
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub'
@@ -296,7 +285,6 @@ export interface FileRouteTypes {
| '/settings/local-api-server'
| '/settings/mcp-servers'
| '/settings/privacy'
| '/settings/remote-access'
| '/settings/shortcuts'
| '/threads/$threadId'
| '/hub/'
@@ -322,7 +310,6 @@ export interface RootRouteChildren {
SettingsLocalApiServerRoute: typeof SettingsLocalApiServerRoute
SettingsMcpServersRoute: typeof SettingsMcpServersRoute
SettingsPrivacyRoute: typeof SettingsPrivacyRoute
SettingsRemoteAccessRoute: typeof SettingsRemoteAccessRoute
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
HubIndexRoute: typeof HubIndexRoute
@@ -374,13 +361,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsShortcutsRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/remote-access': {
id: '/settings/remote-access'
path: '/settings/remote-access'
fullPath: '/settings/remote-access'
preLoaderRoute: typeof SettingsRemoteAccessRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/privacy': {
id: '/settings/privacy'
path: '/settings/privacy'
@@ -514,7 +494,6 @@ const rootRouteChildren: RootRouteChildren = {
SettingsLocalApiServerRoute: SettingsLocalApiServerRoute,
SettingsMcpServersRoute: SettingsMcpServersRoute,
SettingsPrivacyRoute: SettingsPrivacyRoute,
SettingsRemoteAccessRoute: SettingsRemoteAccessRoute,
SettingsShortcutsRoute: SettingsShortcutsRoute,
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
HubIndexRoute: HubIndexRoute,

View File

@@ -1,540 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { route } from '@/constants/routes'
import SettingsMenu from '@/containers/SettingsMenu'
import HeaderPage from '@/containers/HeaderPage'
import { Button } from '@/components/ui/button'
import { Card, CardItem } from '@/containers/Card'
import { TelegramWizard as TelegramWizardDialog } from '@/containers/dialogs/TelegramWizardDialog'
import { WhatsAppWizardDialog } from '@/containers/dialogs/WhatsAppWizardDialog'
import { TailscaleSetupDialog } from '@/containers/dialogs/TailscaleSetupDialog'
import { SecurityConfigDialog } from '@/containers/dialogs/SecurityConfigDialog'
import { TunnelSelectionDialog } from '@/containers/dialogs/TunnelSelectionDialog'
import { EnableProgressDialog } from '@/containers/dialogs/EnableProgressDialog'
import { SandboxLogsDialog } from '@/containers/dialogs/SandboxLogsDialog'
import { ChannelCard } from '@/containers/ChannelCard'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { useEffect, useState, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useModelProvider } from '@/hooks/useModelProvider'
import { getLastUsedModel } from '@/utils/getModelToStart'
import { isLocalProvider } from '@/lib/utils'
import { toast } from 'sonner'
import { setOpenClawRunningState } from '@/utils/openclaw'
import {
IconLoader2,
IconCopy,
IconExternalLink,
IconFileText,
IconAlertTriangle,
IconFolder,
} from '@tabler/icons-react'
import type {
ChannelType,
TelegramConfig,
WhatsAppConfig,
OpenClawStatus,
TunnelProvider,
TunnelProvidersStatus,
SecurityStatus,
} from '@/types/openclaw'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.remote_access as any)({
component: RemoteAccess,
})
// const OPENCLAW_PORT = 18789
function RemoteAccess() {
const { t } = useTranslation()
const { selectedModel, providers, selectModelProvider } = useModelProvider()
const firstAvailableModel = providers
.filter((p) => isLocalProvider(p.provider) || !!p.api_key)
.flatMap((p) => p.models.map((m) => ({ provider: p.provider, model: m })))
.at(0)
const hasAnyModel = firstAvailableModel !== undefined
const handleStartEnable = useCallback(() => {
if (!selectedModel) {
const lastUsed = getLastUsedModel()
const lastUsedExists = lastUsed && providers.some(
(p) =>
p.provider === lastUsed.provider &&
(isLocalProvider(p.provider) || !!p.api_key) &&
p.models.some((m) => m.id === lastUsed.model)
)
if (lastUsedExists) {
selectModelProvider(lastUsed!.provider, lastUsed!.model)
} else if (firstAvailableModel) {
selectModelProvider(firstAvailableModel.provider, firstAvailableModel.model.id)
}
}
setIsEnableDialogOpen(true)
}, [selectedModel, providers, firstAvailableModel, selectModelProvider])
const [status, setStatus] = useState<OpenClawStatus | null>(null)
const [isStopping, setIsStopping] = useState(false)
const [disconnectingChannel, setDisconnectingChannel] = useState<ChannelType | null>(null)
const [isTelegramWizardOpen, setIsTelegramWizardOpen] = useState(false)
const [isWhatsAppWizardOpen, setIsWhatsAppWizardOpen] = useState(false)
const [isTailscaleDialogOpen, setIsTailscaleDialogOpen] = useState(false)
const [isSecurityDialogOpen, setIsSecurityDialogOpen] = useState(false)
const [isTunnelDialogOpen, setIsTunnelDialogOpen] = useState(false)
const [isEnableDialogOpen, setIsEnableDialogOpen] = useState(false)
const [isLogsDialogOpen, setIsLogsDialogOpen] = useState(false)
const [tunnelStatus, setTunnelStatus] = useState<TunnelProvidersStatus | null>(null)
const [, setSecurityStatus] = useState<SecurityStatus | null>(null)
const [telegramConfig, setTelegramConfig] = useState<TelegramConfig | null>(null)
const [whatsappConfig, setWhatsAppConfig] = useState<WhatsAppConfig | null>(null)
const [gatewayToken, setGatewayToken] = useState<string>('')
const [gatewayPort, setGatewayPort] = useState<number>(18789)
const fetchStatus = useCallback(async () => {
try {
const statusData = await invoke<OpenClawStatus>('openclaw_status')
setStatus(statusData)
// Ensure Jan's origin is configured when OpenClaw was started externally
if (statusData.running) {
await invoke('openclaw_ensure_jan_origin').catch(() => { })
}
} catch {
toast.error(t('settings:remoteAccess.startError'))
}
}, [t])
const fetchTelegramConfig = useCallback(async () => {
try {
const config = await invoke<TelegramConfig>('telegram_get_config')
setTelegramConfig(config)
} catch {
// Telegram may not be configured yet
}
}, [])
const fetchWhatsAppConfig = useCallback(async () => {
try {
const config = await invoke<WhatsAppConfig>('whatsapp_get_config')
setWhatsAppConfig(config)
} catch {
// WhatsApp may not be configured yet
}
}, [])
const fetchTunnelStatus = useCallback(async () => {
try {
const tunnelData = await invoke<TunnelProvidersStatus>('tunnel_get_providers')
setTunnelStatus(tunnelData)
} catch {
// Tunnel providers may not be available
}
}, [])
const fetchSecurityStatus = useCallback(async () => {
try {
const securityData = await invoke<SecurityStatus>('security_get_status')
setSecurityStatus(securityData)
} catch {
// Security config may not be available
}
}, [])
useEffect(() => {
fetchStatus()
fetchTelegramConfig()
fetchWhatsAppConfig()
fetchTunnelStatus()
fetchSecurityStatus()
}, [fetchStatus, fetchTelegramConfig, fetchWhatsAppConfig, fetchTunnelStatus, fetchSecurityStatus])
const refreshAllStatus = useCallback(async () => {
await Promise.all([fetchStatus(), fetchTunnelStatus(), fetchSecurityStatus()])
}, [fetchStatus, fetchTunnelStatus, fetchSecurityStatus])
const handleCopyUrl = useCallback((url: string) => {
navigator.clipboard.writeText(url)
toast.success(t('settings:remoteAccess.urlCopied'))
}, [t])
const getTunnelProviderName = (provider: TunnelProvider): string => {
switch (provider) {
case 'tailscale': return 'Tailscale'
case 'ngrok': return 'ngrok'
case 'cloudflare': return 'Cloudflare Tunnel'
case 'localonly': return t('settings:remoteAccess.localNetworkOnly')
default: return t('settings:remoteAccess.notConfigured')
}
}
const handleStop = async () => {
try {
setIsStopping(true)
await invoke('openclaw_stop')
setOpenClawRunningState(false)
setStatus((prev) => prev ? { ...prev, running: false } : prev)
toast.success(t('settings:remoteAccess.stopped'))
} catch {
toast.error(t('settings:remoteAccess.stopError'))
} finally {
setIsStopping(false)
}
}
const getChannelName = (channel: string): string => {
switch (channel) {
case 'telegram': return t('settings:remoteAccess.telegram')
case 'whatsapp': return t('settings:remoteAccess.whatsapp')
default: return channel
}
}
const handleAddChannel = (channel: ChannelType) => {
switch (channel) {
case 'telegram': setIsTelegramWizardOpen(true); break
case 'whatsapp': setIsWhatsAppWizardOpen(true); break
}
}
const handleDisconnectChannel = async (channel: ChannelType) => {
setDisconnectingChannel(channel)
try {
switch (channel) {
case 'telegram':
await invoke('telegram_disconnect')
setTelegramConfig(null)
break
case 'whatsapp':
await invoke('whatsapp_disconnect')
setWhatsAppConfig(null)
break
}
toast.success(
t('settings:remoteAccess.channelDisconnected', { channel: getChannelName(channel) })
)
} catch {
toast.error(t('settings:remoteAccess.disconnectError'))
} finally {
setDisconnectingChannel(null)
}
}
const isRunning = status?.running ?? false
const isInstalled = status?.installed ?? false
useEffect(() => {
if (isRunning) {
invoke<string>('openclaw_get_auth_token').then(setGatewayToken).catch(() => { })
invoke<{ gateway: { port: number } }>('openclaw_get_config').then((c) => setGatewayPort(c.gateway.port)).catch(() => { })
}
}, [isRunning])
return (
<div className="flex flex-col h-svh w-full">
<HeaderPage>
<div className="flex items-center gap-2 w-full">
<span className="font-medium text-base font-studio">
{t('common:settings')}
</span>
</div>
</HeaderPage>
<div className="flex h-[calc(100%-60px)]">
<SettingsMenu />
<div className="p-4 pt-0 w-full overflow-y-auto">
<div className="flex flex-col justify-between gap-4 gap-y-3 w-full">
{/* Status Card */}
{/* <Card title={t('settings:remoteAccess.title')}>
<CardItem
title={t('settings:remoteAccess.status')}
actions={
<div className="flex items-center gap-2">
<div
className={cn("size-2 rounded-full",
isRunning ? 'bg-green-500' : 'bg-red-500'
)}
/>
<span className="text-foreground font-medium">
{isRunning
? t('settings:remoteAccess.enabled')
: t('settings:remoteAccess.disabled')}
</span>
</div>
}
/>
<CardItem
title={t('settings:remoteAccess.activeChannels')}
actions={
<span className="text-foreground">
{isRunning && getConnectedChannels().length > 0
? getConnectedChannels()
.map((ch) => getChannelName(ch))
.join(', ')
: '-'}
</span>
}
/>
<CardItem
title={t('settings:remoteAccess.connectedUsers')}
actions={
<span className="text-foreground">
{isRunning
? String(
(telegramConfig?.paired_users ?? 0) +
(whatsappConfig?.contacts_count ?? 0)
)
: '0'}
</span>
}
/>
<CardItem
title={t('settings:remoteAccess.port')}
actions={<span className="text-foreground">{OPENCLAW_PORT}</span>}
/>
{status?.runtime_version && (
<CardItem
title={t('settings:remoteAccess.runtimeVersion')}
actions={<span className="text-foreground">{status.runtime_version}</span>}
/>
)}
{status?.openclaw_version && (
<CardItem
title={t('settings:remoteAccess.openclawVersion')}
actions={<span className="text-foreground">{status.openclaw_version}</span>}
/>
)}
</Card> */}
<Card title={t('settings:remoteAccess.openclawIntegration')}>
<CardItem
title={
isRunning
? t('settings:remoteAccess.disableRemoteAccess')
: t('settings:remoteAccess.enableRemoteAccess')
}
actions={
<div className="flex items-center gap-2">
{isRunning ? (
<Button
variant="destructive"
size="sm"
onClick={handleStop}
disabled={isStopping}
>
{isStopping && <IconLoader2 className="animate-spin size-4" />}
{t('settings:remoteAccess.stop')}
</Button>
) : (
<Button size="sm" onClick={handleStartEnable} disabled={!hasAnyModel}>
{t('settings:remoteAccess.start')}
</Button>
)}
</div>
}
description={!isRunning && !hasAnyModel ? t('settings:remoteAccess.noModelAvailable') : undefined}
/>
{!isRunning && (
<p className="text-sm text-muted-foreground px-1">
{t('settings:remoteAccess.nodejsPrerequisite')}
</p>
)}
{isRunning && status?.sandbox_type && (
<CardItem
title={t('settings:remoteAccess.runtimeMode')}
actions={
<div className="flex items-center gap-2">
<span className="text-foreground text-sm">
{status.isolation_tier === 'full_container'
? t('settings:remoteAccess.dockerSandbox')
: t('settings:remoteAccess.directProcess')}
</span>
{status.isolation_tier === 'full_container' && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setIsLogsDialogOpen(true)}
title={t('settings:viewLogs')}
>
<IconFileText className="h-4 w-4" />
</Button>
)}
</div>
}
/>
)}
{/* Security Advisory - show when no sandbox (Tier 0) */}
{isRunning && (!status?.isolation_tier || status.isolation_tier === 'none') && (
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3">
<IconAlertTriangle className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
<p className="text-sm text-yellow-600 dark:text-yellow-400">
{t('settings:remoteAccess.securityAdvisory')}
</p>
</div>
)}
{isRunning && (<CardItem
title={t('settings:remoteAccess.openclawFolder')}
description={t('settings:remoteAccess.openclawFolderDesc')}
actions={
<Button
variant="ghost"
size="icon-sm"
onClick={async () => {
const dir = await invoke<string>('openclaw_get_config_dir')
await invoke('open_file_explorer', { path: dir })
}}
title={t('settings:remoteAccess.openclawFolder')}
>
<IconFolder className="h-4 w-4" />
</Button>
}
/>
)}
{isRunning && (
<CardItem
title={t('settings:remoteAccess.gatewayUrl')}
description={t('settings:remoteAccess.gatewayUrlDesc')}
actions={
<div className="flex items-center gap-2">
<code className="text-sm font-mono text-foreground bg-secondary px-2 py-1 rounded">
{gatewayToken
? `http://localhost:${gatewayPort}?token=****`
: `http://localhost:${gatewayPort}`}
</code>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleCopyUrl(gatewayToken ? `http://localhost:${gatewayPort}?token=${gatewayToken}` : `http://localhost:${gatewayPort}`)}
title={t('settings:remoteAccess.copyUrl')}
>
<IconCopy className="h-4 w-4" />
</Button>
<a
href={gatewayToken ? `http://localhost:${gatewayPort}?token=${gatewayToken}` : `http://localhost:${gatewayPort}`}
target="_blank"
rel="noopener noreferrer"
title={t('settings:remoteAccess.openUrl')}
className="inline-flex items-center justify-center h-7 w-7 rounded-md hover:bg-accent transition-colors"
>
<IconExternalLink className="h-4 w-4" />
</a>
</div>
}
/>
)}
<div className="space-y-3 mt-4">
<p className='text-foreground font-medium mb-2'>{t('settings:remoteAccess.channels')}</p>
<ChannelCard
type="telegram"
config={telegramConfig}
onSettings={() => handleAddChannel('telegram')}
onDisconnect={() => handleDisconnectChannel('telegram')}
OCIsInstalled={isInstalled}
isDisconnecting={disconnectingChannel === 'telegram'}
/>
<ChannelCard
type="whatsapp"
config={whatsappConfig}
onSettings={() => handleAddChannel('whatsapp')}
onDisconnect={() => handleDisconnectChannel('whatsapp')}
OCIsInstalled={isInstalled}
isDisconnecting={disconnectingChannel === 'whatsapp'}
/>
</div>
</Card>
{tunnelStatus?.active_tunnel && (
<Card title={t('settings:remoteAccess.activeTunnel')}>
<div className="flex items-center gap-3 mb-4">
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
<span className="text-foreground font-medium">
{getTunnelProviderName(tunnelStatus.active_tunnel.provider)}
</span>
{tunnelStatus.active_tunnel.is_public && (
<span className="text-xs px-2 py-0.5 rounded-full bg-orange-500/10 text-orange-500 border border-orange-500/30">
{t('settings:remoteAccess.publicAccess')}
</span>
)}
</div>
<CardItem
title={t('settings:remoteAccess.tunnelUrl')}
actions={
<div className="flex items-center gap-2">
<code className="text-sm font-mono text-foreground bg-secondary px-2 py-1 rounded max-w-[200px] truncate">
{tunnelStatus.active_tunnel.url}
</code>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleCopyUrl(tunnelStatus.active_tunnel!.url)}
title={t('settings:remoteAccess.copyUrl')}
>
<IconCopy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => window.open(tunnelStatus.active_tunnel!.url, '_blank')}
title={t('settings:remoteAccess.openUrl')}
>
<IconExternalLink className="h-4 w-4" />
</Button>
</div>
}
/>
<CardItem
title={t('settings:remoteAccess.port')}
actions={
<span className="text-foreground">{tunnelStatus.active_tunnel.port}</span>
}
/>
</Card>
)}
</div>
</div>
</div>
<TelegramWizardDialog
isOpen={isTelegramWizardOpen}
onOpenChange={setIsTelegramWizardOpen}
onConnected={setTelegramConfig}
/>
<WhatsAppWizardDialog
isOpen={isWhatsAppWizardOpen}
onOpenChange={setIsWhatsAppWizardOpen}
onConnected={setWhatsAppConfig}
/>
<TailscaleSetupDialog
isOpen={isTailscaleDialogOpen}
onClose={() => setIsTailscaleDialogOpen(false)}
onSuccess={refreshAllStatus}
/>
<SecurityConfigDialog
isOpen={isSecurityDialogOpen}
onClose={() => setIsSecurityDialogOpen(false)}
onSave={refreshAllStatus}
/>
<TunnelSelectionDialog
isOpen={isTunnelDialogOpen}
onClose={() => setIsTunnelDialogOpen(false)}
onTailscaleSetup={() => {
setIsTunnelDialogOpen(false)
setIsTailscaleDialogOpen(true)
}}
onSave={refreshAllStatus}
/>
<EnableProgressDialog
isOpen={isEnableDialogOpen}
onOpenChange={setIsEnableDialogOpen}
onSuccess={fetchStatus}
/>
<SandboxLogsDialog
isOpen={isLogsDialogOpen}
onOpenChange={setIsLogsDialogOpen}
/>
</div>
)
}

View File

@@ -1,63 +0,0 @@
/**
* Shared types for the OpenClaw remote access integration.
* These mirror the Rust backend types in src-tauri/src/core/openclaw/models.rs.
*/
export type ChannelType = 'telegram' | 'whatsapp'
export interface TelegramConfig {
bot_token: string
bot_username: string | null
connected: boolean
pairing_code: string | null
paired_users: number
}
export interface WhatsAppConfig {
account_id: string
session_path: string
connected: boolean
phone_number: string | null
qr_code: string | null
contacts_count: number
}
export type ChannelConfig = TelegramConfig | WhatsAppConfig
export interface OpenClawStatus {
installed: boolean
running: boolean
runtime_version: string | null
openclaw_version: string | null
port_available: boolean
error: string | null
sandbox_type?: string | null
isolation_tier?: string | null
}
export type TunnelProvider = 'none' | 'tailscale' | 'ngrok' | 'cloudflare' | 'localonly'
export interface TunnelInfo {
provider: TunnelProvider
url: string
started_at: string
port: number
is_public: boolean
}
export interface TunnelProvidersStatus {
tailscale: { installed: boolean; authenticated: boolean; version: string | null }
ngrok: { installed: boolean; authenticated: boolean; version: string | null }
cloudflare: { installed: boolean; authenticated: boolean; version: string | null }
active_provider: TunnelProvider
active_tunnel: TunnelInfo | null
}
export interface SecurityStatus {
auth_mode: 'token' | 'password' | 'none'
has_token: boolean
has_password: boolean
require_pairing: boolean
approved_device_count: number
recent_auth_failures: number
}

View File

@@ -1,195 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
import { useAgentMode } from '@/hooks/useAgentMode'
import { useAppState } from '@/hooks/useAppState'
/** OpenClaw gateway base URL for the OpenAI-compatible HTTP API. */
export const OPENCLAW_GATEWAY_URL = 'http://127.0.0.1:18789/v1'
let openClawRunningCache: boolean | null = null
let lastCheckTime = 0
const CACHE_TTL_MS = 30_000
/** Check if OpenClaw is running, with a 30s cache to avoid excessive IPC calls. */
export async function isOpenClawRunning(forceRefresh = false): Promise<boolean> {
const now = Date.now()
if (!forceRefresh && openClawRunningCache !== null && (now - lastCheckTime) < CACHE_TTL_MS) {
return openClawRunningCache
}
try {
const status = await invoke<{ running: boolean }>('openclaw_status')
openClawRunningCache = status.running
lastCheckTime = now
return status.running
} catch {
openClawRunningCache = false
lastCheckTime = now
return false
}
}
/** Update the running cache directly (called when user enables/disables OpenClaw). */
export function setOpenClawRunningState(isRunning: boolean): void {
openClawRunningCache = isRunning
lastCheckTime = Date.now()
useAppState.getState().setOpenClawRunning(isRunning)
// When OpenClaw is stopped, clear agent mode for all threads so the UI reverts
if (!isRunning) {
useAgentMode.getState().clearAll()
}
}
/** Clear the cache (e.g., on app startup). */
export function clearOpenClawCache(): void {
openClawRunningCache = null
lastCheckTime = 0
}
/** Get cached state without IPC. Returns null if stale. */
export function getOpenClawRunningCached(): boolean | null {
const now = Date.now()
if (openClawRunningCache !== null && (now - lastCheckTime) < CACHE_TTL_MS) {
return openClawRunningCache
}
return null
}
/**
* Sync the selected model to OpenClaw. Skips silently if OpenClaw is not running.
* Designed to have minimal impact on users who don't use OpenClaw.
*/
export async function syncModelToOpenClaw(
modelId: string,
provider?: string,
modelName?: string
): Promise<void> {
try {
const cachedState = getOpenClawRunningCached()
if (cachedState === false) return
const running = await isOpenClawRunning()
if (!running) return
await invoke('openclaw_sync_model', {
modelId,
provider: provider || null,
modelName: modelName || null,
})
} catch {
// Background sync — fail silently
}
}
/**
* Bulk sync all models from Jan's provider store to OpenClaw.
* Called after Remote Access starts so OpenClaw knows the full catalog.
*/
export async function syncAllModelsToOpenClaw(
providers: ModelProvider[],
selectedModelId?: string
): Promise<number> {
try {
const models: Array<{ modelId: string; provider: string; displayName: string; contextWindow?: number }> = []
for (const provider of providers) {
if (!provider.active) continue
if (!provider.models || provider.models.length === 0) continue
for (const model of provider.models) {
const modelId = model.id ?? model.model
if (!modelId || typeof modelId !== 'string') continue
if (model.embedding) continue
// Extract context window from Jan's model settings (ctx_len)
const ctxLenRaw = model.settings?.ctx_len?.controller_props?.value
const contextWindow = typeof ctxLenRaw === 'number' && ctxLenRaw > 0
? ctxLenRaw
: typeof ctxLenRaw === 'string' && parseInt(ctxLenRaw) > 0
? parseInt(ctxLenRaw)
: undefined
models.push({
modelId,
provider: provider.provider,
displayName: model.displayName || model.name || modelId,
contextWindow,
})
}
}
if (models.length === 0) return 0
const result = await invoke<{ synced_count: number; default_model: string | null }>(
'openclaw_sync_all_models',
{ models, defaultModelId: selectedModelId || null }
)
return result.synced_count
} catch {
return 0
}
}
// --- Auth token ---
let authTokenCache: string | null = null
let authTokenFetchTime = 0
const AUTH_TOKEN_CACHE_TTL = 60_000
let httpApiEnsured = false
/**
* Ensure the OpenClaw HTTP chat completions endpoint is enabled.
* Called once before first agent mode request; no-op after that.
* If the config was changed, restarts the gateway so it picks up the new setting.
*/
export async function ensureOpenClawHttpApi(): Promise<void> {
if (httpApiEnsured) return
try {
const changed = await invoke<boolean>('openclaw_ensure_http_api')
if (changed) {
await invoke('openclaw_restart')
await new Promise((resolve) => setTimeout(resolve, 2000))
}
httpApiEnsured = true
} catch (error) {
throw new Error(
`Failed to initialize OpenClaw HTTP API: ${error instanceof Error ? error.message : String(error)}`
)
}
}
/** Get the OpenClaw gateway auth token, with a 60s cache. */
export async function getOpenClawAuthToken(forceRefresh = false): Promise<string | null> {
const now = Date.now()
if (!forceRefresh && authTokenCache && (now - authTokenFetchTime) < AUTH_TOKEN_CACHE_TTL) {
return authTokenCache
}
try {
const token = await invoke<string>('openclaw_get_auth_token')
authTokenCache = token
authTokenFetchTime = now
return token
} catch {
authTokenCache = null
return null
}
}
/** TCP-level gateway reachability check via Tauri IPC. Works on all platforms. */
export async function checkOpenClawGateway(): Promise<boolean> {
try {
return await invoke<boolean>('openclaw_check_gateway')
} catch {
return false
}
}
/** Get the currently configured model in OpenClaw. */
export async function getOpenClawModel(): Promise<string | null> {
try {
return await invoke<string>('openclaw_get_model')
} catch {
return null
}
}

View File

@@ -11176,11 +11176,11 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^12.36.0":
version: 12.36.0
resolution: "framer-motion@npm:12.36.0"
"framer-motion@npm:^12.37.0":
version: 12.37.0
resolution: "framer-motion@npm:12.37.0"
dependencies:
motion-dom: "npm:^12.36.0"
motion-dom: "npm:^12.37.0"
motion-utils: "npm:^12.36.0"
tslib: "npm:^2.4.0"
peerDependencies:
@@ -11194,7 +11194,7 @@ __metadata:
optional: true
react-dom:
optional: true
checksum: 10c0/3acbb210d374ab80f4b086bcb8e7f722271f5d8e5d3e9f643ee0b5e7641bf34e100aaead68c99a4d478e67ccdda4b3ff89779bc0c9e5fe9aab8a955a5a6cfaa4
checksum: 10c0/6bdf132876e5a323c60ba3930bf2d894338f6235d942b219cf3068b330ecc7c915d9b8ee6631f89ddcde7a4ba7373bad3abf40d19d2c882b186697cbe11ee311
languageName: node
linkType: hard
@@ -15026,12 +15026,12 @@ __metadata:
languageName: node
linkType: hard
"motion-dom@npm:^12.36.0":
version: 12.36.0
resolution: "motion-dom@npm:12.36.0"
"motion-dom@npm:^12.37.0":
version: 12.37.0
resolution: "motion-dom@npm:12.37.0"
dependencies:
motion-utils: "npm:^12.36.0"
checksum: 10c0/7f97b1e310310b44e6144f7303604d7c3f37f1e278b41ac5b57dca2abe082213d40a9f59cf5a2cb3442d27b8fd82e29f17f31d7b24d4b240f9b908fc97b42d80
checksum: 10c0/e3b2be1e6796658d021921fed8f5ce690b833c4ce5f63d0ca11c86b5520b35a626d29ef9d97bd8551bd946333c3d4ab159d95aae9951c92ce8733039560b4c0c
languageName: node
linkType: hard
@@ -15078,10 +15078,10 @@ __metadata:
linkType: hard
"motion@npm:^12.35.0":
version: 12.36.0
resolution: "motion@npm:12.36.0"
version: 12.37.0
resolution: "motion@npm:12.37.0"
dependencies:
framer-motion: "npm:^12.36.0"
framer-motion: "npm:^12.37.0"
tslib: "npm:^2.4.0"
peerDependencies:
"@emotion/is-prop-valid": "*"
@@ -15094,7 +15094,7 @@ __metadata:
optional: true
react-dom:
optional: true
checksum: 10c0/3ad56cbc4d7de107dc8e72c4e5738b885fa455224461ca1e68545e5e790b9d5e27f4b01a6d9129899ee7e214cff10247f7b9b6edff25d3ef5ed292b212ed95a6
checksum: 10c0/e1bb6ebde3d25f7afb119a51df5b9bd531482e94a367e8a1b6cc67376714445276f1105f158fbb63ed506a33c622fdc7e2842d840792e06582863a114ff5c16f
languageName: node
linkType: hard