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:
@@ -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
116
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "Nový chat",
|
||||
"newAgentChat": "Nový chat agenta",
|
||||
"chats": "Chaty",
|
||||
"openclawAgent": "OpenClaw Agent",
|
||||
"favorites": "Oblíbené",
|
||||
"recents": "Nedávné",
|
||||
"hub": "Centrum",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "Neuer Chat",
|
||||
"newAgentChat": "Neuer Agent-Chat",
|
||||
"chats": "Chats",
|
||||
"openclawAgent": "OpenClaw Agent",
|
||||
"favorites": "Favoriten",
|
||||
"recents": "Kürzlich",
|
||||
"hub": "Hub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "Nouvelle discussion",
|
||||
"newAgentChat": "Nouvelle discussion Agent",
|
||||
"chats": "Discussions",
|
||||
"openclawAgent": "OpenClaw Agent",
|
||||
"favorites": "Favoris",
|
||||
"recents": "Récents",
|
||||
"hub": "Concentrateur Hub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "Obrolan Baru",
|
||||
"newAgentChat": "Obrolan Agent Baru",
|
||||
"chats": "Obrolan",
|
||||
"openclawAgent": "OpenClaw Agent",
|
||||
"favorites": "Favorit",
|
||||
"recents": "Terbaru",
|
||||
"hub": "Hub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "新しいチャット",
|
||||
"newAgentChat": "新しいエージェントチャット",
|
||||
"chats": "チャット",
|
||||
"openclawAgent": "OpenClaw エージェント",
|
||||
"favorites": "お気に入り",
|
||||
"recents": "最近の項目",
|
||||
"hub": "ハブ",
|
||||
|
||||
@@ -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": "バックエンドのインストールに失敗しました"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "Nowy Czat",
|
||||
"newAgentChat": "Nowy Czat Agenta",
|
||||
"chats": "Czaty",
|
||||
"openclawAgent": "OpenClaw Agent",
|
||||
"favorites": "Ulubione",
|
||||
"recents": "Ostatnie",
|
||||
"hub": "Centrum Modeli",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "Novo Chat",
|
||||
"newAgentChat": "Novo Chat de Agente",
|
||||
"chats": "Conversas",
|
||||
"openclawAgent": "OpenClaw Agent",
|
||||
"favorites": "Favoritos",
|
||||
"recents": "Recentes",
|
||||
"hub": "Hub",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "Новый чат",
|
||||
"newAgentChat": "Новый чат агента",
|
||||
"chats": "Чаты",
|
||||
"openclawAgent": "OpenClaw Агент",
|
||||
"favorites": "Избранное",
|
||||
"recents": "Недавние",
|
||||
"hub": "Хаб",
|
||||
|
||||
@@ -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": "Не удалось установить компонент"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "新建聊天",
|
||||
"newAgentChat": "新建智能体聊天",
|
||||
"chats": "聊天",
|
||||
"openclawAgent": "OpenClaw 智能体",
|
||||
"favorites": "收藏",
|
||||
"recents": "最近",
|
||||
"hub": "中心",
|
||||
@@ -106,7 +105,7 @@
|
||||
"increaseContextSizeDescription": "您想要增加上下文大小吗?",
|
||||
"increaseContextSize": "增加上下文大小"
|
||||
},
|
||||
"customAvatar": "自定义头像",
|
||||
"customAvatar": "自定义头像",
|
||||
"editAssistant": "编辑助手",
|
||||
"jan": "Jan",
|
||||
"metadata": "元数据",
|
||||
|
||||
@@ -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": "后端安装失败"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"newChat": "新聊天",
|
||||
"newAgentChat": "新智能體聊天",
|
||||
"chats": "聊天",
|
||||
"openclawAgent": "OpenClaw 智能體",
|
||||
"favorites": "我的最愛",
|
||||
"recents": "最近",
|
||||
"hub": "中心",
|
||||
|
||||
@@ -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": "後端安裝失敗"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
26
yarn.lock
26
yarn.lock
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user