mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: Linux desktop companion app with auto-install, Gateway lifecycle, and Control UI window (#106352)
* feat(linux): add Tauri desktop companion app and openclaw dashboard --json * test(dashboard): assemble fake token fixture to satisfy secret scanners * test(dashboard): avoid secret-scanner-shaped mock factory line * fix(linux): actionable error when installed CLI predates dashboard --json * docs: regenerate docs map for linux platform heading change
This commit is contained in:
committed by
GitHub
parent
6c5084f1ad
commit
0bab08510e
@@ -211,6 +211,11 @@
|
||||
- "apps/macos/**"
|
||||
- "docs/platforms/macos.md"
|
||||
- "docs/platforms/mac/**"
|
||||
"app: linux":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "apps/linux/**"
|
||||
- "docs/platforms/linux.md"
|
||||
"app: web-ui":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Linux App
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/linux/**"
|
||||
- ".github/workflows/linux-app.yml"
|
||||
|
||||
concurrency:
|
||||
group: linux-app-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Linux companion
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Install Tauri system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
file \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libxdo-dev \
|
||||
wget
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install stable --profile minimal --component rustfmt
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
apps/linux/src-tauri/target
|
||||
key: linux-app-${{ runner.os }}-${{ hashFiles('apps/linux/src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
linux-app-${{ runner.os }}-
|
||||
|
||||
- name: Check Rust formatting
|
||||
working-directory: apps/linux/src-tauri
|
||||
run: cargo +stable fmt --check
|
||||
|
||||
- name: Build Linux companion
|
||||
working-directory: apps/linux/src-tauri
|
||||
run: cargo +stable build
|
||||
@@ -0,0 +1,44 @@
|
||||
# OpenClaw for Linux
|
||||
|
||||
The Linux companion is a Tauri v2 desktop shell for a local OpenClaw Gateway. It installs the CLI when needed, delegates Gateway service management to `openclaw gateway`, opens the Gateway-served Control UI with its resolved auth URL, and stays available in the system tray.
|
||||
|
||||
## Linux prerequisites
|
||||
|
||||
Debian and Ubuntu development packages:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
```
|
||||
|
||||
Install a current stable Rust toolchain with `rustup`.
|
||||
|
||||
## Develop and build
|
||||
|
||||
The frontend is static HTML, CSS, and JavaScript. It has no package install or build step.
|
||||
|
||||
```bash
|
||||
cd apps/linux/src-tauri
|
||||
cargo run
|
||||
cargo build
|
||||
```
|
||||
|
||||
The app uses `OPENCLAW_DESKTOP_CLI` when set. Otherwise it checks `~/.openclaw/bin/openclaw`, then `openclaw` on `PATH`.
|
||||
|
||||
## Installer resource
|
||||
|
||||
`tauri.conf.json` bundles the repository's canonical `scripts/install-cli.sh` directly as `install-cli.sh`. The app never keeps a forked copy. Stable, beta, and dev installs select `latest`, `beta`, and a managed Git `main` checkout respectively, always under `~/.openclaw`.
|
||||
|
||||
## Icons
|
||||
|
||||
Committed PNGs come from `ui/public/favicon.svg`:
|
||||
|
||||
```bash
|
||||
magick ui/public/favicon.svg -background none -resize 32x32 -alpha on -define png:color-type=6 PNG32:apps/linux/src-tauri/icons/32x32.png
|
||||
magick ui/public/favicon.svg -background none -resize 128x128 -alpha on -define png:color-type=6 PNG32:apps/linux/src-tauri/icons/128x128.png
|
||||
magick ui/public/favicon.svg -background none -resize 256x256 -alpha on -define png:color-type=6 PNG32:apps/linux/src-tauri/icons/128x128@2x.png
|
||||
magick ui/public/favicon.svg -background none -resize 512x512 -alpha on -define png:color-type=6 PNG32:apps/linux/src-tauri/icons/icon.png
|
||||
```
|
||||
|
||||
Packaged AppImage and Debian releases are not part of the initial app. Build on Linux when validating WebKitGTK, systemd user services, and tray integration.
|
||||
@@ -0,0 +1,2 @@
|
||||
/gen/
|
||||
/target/
|
||||
Generated
+4419
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "openclaw-desktop-linux"
|
||||
version = "0.1.0"
|
||||
description = "OpenClaw desktop companion for Linux"
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[[bin]]
|
||||
name = "openclaw-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = "2.6.3"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
tauri = { version = "2.11.5", features = ["image-png", "tray-icon"] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 736 B |
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,129 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenClawCli {
|
||||
executable: PathBuf,
|
||||
openclaw_home: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CliError {
|
||||
Missing,
|
||||
Environment(String),
|
||||
Spawn(String),
|
||||
InvalidJson(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for CliError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Missing => write!(formatter, "OpenClaw CLI not found"),
|
||||
Self::Environment(message) | Self::Spawn(message) | Self::InvalidJson(message) => {
|
||||
formatter.write_str(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CliError {}
|
||||
|
||||
impl OpenClawCli {
|
||||
pub fn discover() -> Result<Self, CliError> {
|
||||
let home = openclaw_home()?;
|
||||
if let Some(override_path) = env::var_os("OPENCLAW_DESKTOP_CLI") {
|
||||
let cli = Self::new(PathBuf::from(override_path), home);
|
||||
cli.verify()?;
|
||||
return Ok(cli);
|
||||
}
|
||||
|
||||
let managed = home.join("bin/openclaw");
|
||||
if managed.is_file() {
|
||||
let cli = Self::new(managed, home);
|
||||
cli.verify()?;
|
||||
return Ok(cli);
|
||||
}
|
||||
|
||||
let cli = Self::new(PathBuf::from("openclaw"), home);
|
||||
match cli.verify() {
|
||||
Ok(()) => Ok(cli),
|
||||
Err(_) => Err(CliError::Missing),
|
||||
}
|
||||
}
|
||||
|
||||
fn new(executable: PathBuf, openclaw_home: PathBuf) -> Self {
|
||||
Self {
|
||||
executable,
|
||||
openclaw_home,
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(&self) -> Result<(), CliError> {
|
||||
let output = self.output(["--version"])?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CliError::Spawn(format!(
|
||||
"OpenClaw CLI exited with {}",
|
||||
output.status
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn command<I, S>(&self, args: I) -> Result<Command, CliError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let mut command = Command::new(&self.executable);
|
||||
command.args(args);
|
||||
command.env("PATH", self.command_path()?);
|
||||
command.stdin(Stdio::null());
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub fn output<I, S>(&self, args: I) -> Result<Output, CliError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
self.command(args)?
|
||||
.output()
|
||||
.map_err(|error| CliError::Spawn(format!("Failed to run OpenClaw CLI: {error}")))
|
||||
}
|
||||
|
||||
pub fn json<T, I, S>(&self, args: I) -> Result<(T, Output), CliError>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let output = self.output(args)?;
|
||||
let value = serde_json::from_slice(&output.stdout).map_err(|error| {
|
||||
CliError::InvalidJson(format!("OpenClaw CLI returned invalid JSON: {error}"))
|
||||
})?;
|
||||
Ok((value, output))
|
||||
}
|
||||
|
||||
fn command_path(&self) -> Result<OsString, CliError> {
|
||||
let mut paths = vec![
|
||||
self.openclaw_home.join("bin"),
|
||||
self.openclaw_home.join("tools/node/bin"),
|
||||
];
|
||||
if let Some(current) = env::var_os("PATH") {
|
||||
paths.extend(env::split_paths(¤t));
|
||||
}
|
||||
env::join_paths(paths)
|
||||
.map_err(|error| CliError::Environment(format!("Could not construct PATH: {error}")))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openclaw_home() -> Result<PathBuf, CliError> {
|
||||
let home = env::var_os("HOME")
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CliError::Environment("HOME is not set".to_string()))?;
|
||||
Ok(PathBuf::from(home).join(".openclaw"))
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use crate::cli::OpenClawCli;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const START_ATTEMPTS: usize = 20;
|
||||
const START_POLL_INTERVAL: Duration = Duration::from_millis(750);
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GatewaySnapshot {
|
||||
pub phase: &'static str,
|
||||
pub installed: bool,
|
||||
pub running: bool,
|
||||
pub reachable: bool,
|
||||
pub status: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl GatewaySnapshot {
|
||||
pub fn missing_cli() -> Self {
|
||||
Self {
|
||||
phase: "missingCli",
|
||||
installed: false,
|
||||
running: false,
|
||||
reachable: false,
|
||||
status: "CLI required".to_string(),
|
||||
detail: Some("Install the OpenClaw CLI to continue.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reconnecting(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
phase: "reconnecting",
|
||||
installed: true,
|
||||
running: false,
|
||||
reachable: false,
|
||||
status: "Reconnecting".to_string(),
|
||||
detail: Some(detail.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum GatewayAction {
|
||||
Start,
|
||||
Stop,
|
||||
Restart,
|
||||
}
|
||||
|
||||
impl GatewayAction {
|
||||
fn command(self) -> &'static str {
|
||||
match self {
|
||||
Self::Start => "start",
|
||||
Self::Stop => "stop",
|
||||
Self::Restart => "restart",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReadyGateway {
|
||||
pub snapshot: GatewaySnapshot,
|
||||
pub dashboard_url: String,
|
||||
}
|
||||
|
||||
// Mirrors the JSON emitted by `src/cli/daemon-cli/status.print.ts`: service
|
||||
// state establishes installation/runtime, while rpc.ok establishes reachability.
|
||||
#[derive(Deserialize)]
|
||||
struct DaemonStatus {
|
||||
service: ServiceStatus,
|
||||
rpc: Option<RpcStatus>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ServiceStatus {
|
||||
loaded: bool,
|
||||
command: Option<serde_json::Value>,
|
||||
runtime: Option<ServiceRuntime>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ServiceRuntime {
|
||||
// `GatewayServiceRuntime.status` is optional in the CLI JSON contract.
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RpcStatus {
|
||||
ok: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CommandResponse {
|
||||
ok: bool,
|
||||
message: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DashboardResponse {
|
||||
ok: bool,
|
||||
url: Option<String>,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
pub fn status(cli: &OpenClawCli) -> Result<GatewaySnapshot, String> {
|
||||
let (value, _) = cli
|
||||
.json::<DaemonStatus, _, _>(["gateway", "status", "--json"])
|
||||
.map_err(|error| error.to_string())?;
|
||||
let installed = value.service.command.is_some() || value.service.loaded;
|
||||
let runtime_status = value
|
||||
.service
|
||||
.runtime
|
||||
.as_ref()
|
||||
.and_then(|runtime| runtime.status.as_deref())
|
||||
.unwrap_or("stopped");
|
||||
let running = runtime_status == "running";
|
||||
let reachable = value.rpc.as_ref().is_some_and(|rpc| rpc.ok);
|
||||
let phase = if reachable {
|
||||
"connected"
|
||||
} else if !installed {
|
||||
"notInstalled"
|
||||
} else if running {
|
||||
"reconnecting"
|
||||
} else {
|
||||
"stopped"
|
||||
};
|
||||
let detail = value
|
||||
.rpc
|
||||
.and_then(|rpc| rpc.error)
|
||||
.or_else(|| (!running).then(|| format!("Gateway service is {runtime_status}.")));
|
||||
let status = if reachable {
|
||||
"Connected".to_string()
|
||||
} else if !installed {
|
||||
"Not installed".to_string()
|
||||
} else if running {
|
||||
"Unavailable".to_string()
|
||||
} else {
|
||||
"Stopped".to_string()
|
||||
};
|
||||
|
||||
Ok(GatewaySnapshot {
|
||||
phase,
|
||||
installed,
|
||||
running,
|
||||
reachable,
|
||||
status,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ensure_ready(cli: &OpenClawCli) -> Result<ReadyGateway, String> {
|
||||
let mut snapshot = status(cli)?;
|
||||
if snapshot.reachable {
|
||||
return dashboard(cli, snapshot);
|
||||
}
|
||||
|
||||
if !snapshot.installed {
|
||||
run_service_command(cli, "install")?;
|
||||
snapshot = status(cli)?;
|
||||
}
|
||||
if !snapshot.running {
|
||||
run_service_command(cli, "start")?;
|
||||
}
|
||||
|
||||
snapshot = wait_until_reachable(cli)?;
|
||||
dashboard(cli, snapshot)
|
||||
}
|
||||
|
||||
fn wait_until_reachable(cli: &OpenClawCli) -> Result<GatewaySnapshot, String> {
|
||||
let mut snapshot = status(cli)?;
|
||||
for attempt in 0..START_ATTEMPTS {
|
||||
if snapshot.reachable {
|
||||
return Ok(snapshot);
|
||||
}
|
||||
if attempt + 1 < START_ATTEMPTS {
|
||||
thread::sleep(START_POLL_INTERVAL);
|
||||
snapshot = status(cli)?;
|
||||
}
|
||||
}
|
||||
Err(snapshot
|
||||
.detail
|
||||
.unwrap_or_else(|| "Gateway did not become reachable.".to_string()))
|
||||
}
|
||||
|
||||
pub fn act(cli: &OpenClawCli, action: GatewayAction) -> Result<GatewaySnapshot, String> {
|
||||
run_service_command(cli, action.command())?;
|
||||
if matches!(action, GatewayAction::Stop) {
|
||||
return status(cli);
|
||||
}
|
||||
wait_until_reachable(cli)
|
||||
}
|
||||
|
||||
pub fn dashboard(cli: &OpenClawCli, snapshot: GatewaySnapshot) -> Result<ReadyGateway, String> {
|
||||
// CLIs released before `dashboard --json` reject the flag without JSON output;
|
||||
// surface an upgrade path instead of a raw parse error.
|
||||
let (response, output) =
|
||||
match cli.json::<DashboardResponse, _, _>(["dashboard", "--json", "--no-open"]) {
|
||||
Ok(result) => result,
|
||||
Err(crate::cli::CliError::InvalidJson(_)) => {
|
||||
return Err(
|
||||
"The installed OpenClaw CLI does not support the desktop dashboard \
|
||||
integration. Update OpenClaw (for example: npm install -g openclaw@latest), \
|
||||
then retry."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
if response.ok && output.status.success() {
|
||||
let dashboard_url = response
|
||||
.url
|
||||
.ok_or_else(|| "Dashboard response did not include a URL.".to_string())?;
|
||||
return Ok(ReadyGateway {
|
||||
snapshot,
|
||||
dashboard_url,
|
||||
});
|
||||
}
|
||||
Err(response
|
||||
.reason
|
||||
.unwrap_or_else(|| "Dashboard is not ready.".to_string()))
|
||||
}
|
||||
|
||||
fn run_service_command(cli: &OpenClawCli, action: &str) -> Result<(), String> {
|
||||
let (response, output) = cli
|
||||
.json::<CommandResponse, _, _>(["gateway", action, "--json"])
|
||||
.map_err(|error| error.to_string())?;
|
||||
if response.ok && output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(response
|
||||
.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| format!("Gateway {action} failed.")))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use crate::cli::openclaw_home;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
const INSTALL_EVENT: &str = "install-progress";
|
||||
const ERROR_TAIL_LINES: usize = 24;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InstallChannel {
|
||||
Stable,
|
||||
Beta,
|
||||
Dev,
|
||||
}
|
||||
|
||||
impl InstallChannel {
|
||||
fn version(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stable => "latest",
|
||||
Self::Beta => "beta",
|
||||
Self::Dev => "main",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstallProgress<'a> {
|
||||
stream: &'a str,
|
||||
line: &'a str,
|
||||
}
|
||||
|
||||
pub fn install(app: &AppHandle, channel: InstallChannel) -> Result<(), String> {
|
||||
let script = app
|
||||
.path()
|
||||
.resolve("install-cli.sh", BaseDirectory::Resource)
|
||||
.map_err(|error| format!("Bundled installer is unavailable: {error}"))?;
|
||||
let prefix = openclaw_home().map_err(|error| error.to_string())?;
|
||||
|
||||
let mut command = Command::new("bash");
|
||||
command
|
||||
.arg(script)
|
||||
.args(["--json", "--no-onboard", "--prefix"])
|
||||
.arg(&prefix)
|
||||
.args(["--version", channel.version()]);
|
||||
if matches!(channel, InstallChannel::Dev) {
|
||||
command
|
||||
.args(["--install-method", "git", "--git-dir"])
|
||||
.arg(prefix.join("dev/openclaw"));
|
||||
}
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Could not start bundled installer: {error}"))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| "Could not read installer output".to_string())?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| "Could not read installer errors".to_string())?;
|
||||
let (sender, receiver) = mpsc::channel::<(&'static str, String)>();
|
||||
|
||||
let stdout_thread = stream_lines("stdout", stdout, sender.clone());
|
||||
let stderr_thread = stream_lines("stderr", stderr, sender);
|
||||
let mut tail = VecDeque::with_capacity(ERROR_TAIL_LINES);
|
||||
for (stream, line) in receiver {
|
||||
let _ = app.emit_to(
|
||||
"main",
|
||||
INSTALL_EVENT,
|
||||
InstallProgress {
|
||||
stream,
|
||||
line: &line,
|
||||
},
|
||||
);
|
||||
if tail.len() == ERROR_TAIL_LINES {
|
||||
tail.pop_front();
|
||||
}
|
||||
tail.push_back(line);
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Could not wait for bundled installer: {error}"))?;
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let detail = tail.into_iter().collect::<Vec<_>>().join("\n");
|
||||
if detail.is_empty() {
|
||||
Err(format!("Installer exited with {status}"))
|
||||
} else {
|
||||
Err(format!("Installer exited with {status}\n{detail}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_lines<R>(
|
||||
stream: &'static str,
|
||||
reader: R,
|
||||
sender: mpsc::Sender<(&'static str, String)>,
|
||||
) -> thread::JoinHandle<()>
|
||||
where
|
||||
R: std::io::Read + Send + 'static,
|
||||
{
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(reader).lines().map_while(Result::ok) {
|
||||
if sender.send((stream, line)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
mod cli;
|
||||
mod gateway;
|
||||
mod installer;
|
||||
mod tray;
|
||||
|
||||
use cli::{CliError, OpenClawCli};
|
||||
use gateway::{GatewayAction, GatewaySnapshot};
|
||||
use installer::InstallChannel;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager, State, Url, WebviewWindow};
|
||||
|
||||
const CONNECTED_WATCH_INTERVAL: Duration = Duration::from_secs(15);
|
||||
const RECONNECT_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
struct DesktopInner {
|
||||
cli: Mutex<Option<OpenClawCli>>,
|
||||
operation: Mutex<()>,
|
||||
local_url: Url,
|
||||
tray: Mutex<Option<tray::TrayHandles>>,
|
||||
watch_generation: AtomicU64,
|
||||
quitting: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DesktopState {
|
||||
inner: Arc<DesktopInner>,
|
||||
}
|
||||
|
||||
impl DesktopState {
|
||||
fn new(local_url: Url) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(DesktopInner {
|
||||
cli: Mutex::new(None),
|
||||
operation: Mutex::new(()),
|
||||
local_url,
|
||||
tray: Mutex::new(None),
|
||||
watch_generation: AtomicU64::new(0),
|
||||
quitting: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_tray(&self, handles: tray::TrayHandles) {
|
||||
*self.inner.tray.lock().expect("tray mutex poisoned") = Some(handles);
|
||||
}
|
||||
|
||||
pub fn connect(&self, app: &AppHandle) -> Result<GatewaySnapshot, String> {
|
||||
let _operation = self
|
||||
.inner
|
||||
.operation
|
||||
.lock()
|
||||
.map_err(|_| "Gateway operation lock is unavailable.".to_string())?;
|
||||
let cli = match self.resolve_cli() {
|
||||
Ok(cli) => cli,
|
||||
Err(CliError::Missing) => {
|
||||
let snapshot = GatewaySnapshot::missing_cli();
|
||||
self.update_tray(&snapshot);
|
||||
return Ok(snapshot);
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let ready = gateway::ensure_ready(&cli)?;
|
||||
self.navigate(app, &ready.dashboard_url)?;
|
||||
self.update_tray(&ready.snapshot);
|
||||
self.start_watchdog(app.clone());
|
||||
Ok(ready.snapshot)
|
||||
}
|
||||
|
||||
pub fn install_cli(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
channel: InstallChannel,
|
||||
) -> Result<GatewaySnapshot, String> {
|
||||
let _operation = self
|
||||
.inner
|
||||
.operation
|
||||
.lock()
|
||||
.map_err(|_| "Installer lock is unavailable.".to_string())?;
|
||||
installer::install(app, channel)?;
|
||||
let cli = OpenClawCli::discover().map_err(|error| error.to_string())?;
|
||||
*self.inner.cli.lock().expect("CLI mutex poisoned") = Some(cli.clone());
|
||||
let ready = gateway::ensure_ready(&cli)?;
|
||||
self.navigate(app, &ready.dashboard_url)?;
|
||||
self.update_tray(&ready.snapshot);
|
||||
self.start_watchdog(app.clone());
|
||||
Ok(ready.snapshot)
|
||||
}
|
||||
|
||||
pub fn gateway_action(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
action: GatewayAction,
|
||||
) -> Result<GatewaySnapshot, String> {
|
||||
let _operation = self
|
||||
.inner
|
||||
.operation
|
||||
.lock()
|
||||
.map_err(|_| "Gateway operation lock is unavailable.".to_string())?;
|
||||
if matches!(action, GatewayAction::Stop) {
|
||||
self.cancel_watchdog();
|
||||
}
|
||||
let cli = self.resolve_cli().map_err(|error| error.to_string())?;
|
||||
let snapshot = gateway::act(&cli, action)?;
|
||||
if matches!(action, GatewayAction::Stop) {
|
||||
self.show_local(app, "stopped")?;
|
||||
self.update_tray(&snapshot);
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
let ready = gateway::dashboard(&cli, snapshot)?;
|
||||
self.navigate(app, &ready.dashboard_url)?;
|
||||
self.update_tray(&ready.snapshot);
|
||||
self.start_watchdog(app.clone());
|
||||
Ok(ready.snapshot)
|
||||
}
|
||||
|
||||
pub fn show_error(&self, app: &AppHandle, _error: &str) {
|
||||
let _ = self.show_local(app, "error");
|
||||
self.update_tray(&GatewaySnapshot::reconnecting("Gateway action failed."));
|
||||
tray::show_window(app);
|
||||
}
|
||||
|
||||
pub fn quit(&self) {
|
||||
self.inner.quitting.store(true, Ordering::SeqCst);
|
||||
self.cancel_watchdog();
|
||||
}
|
||||
|
||||
fn is_quitting(&self) -> bool {
|
||||
self.inner.quitting.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn resolve_cli(&self) -> Result<OpenClawCli, CliError> {
|
||||
if let Some(cli) = self.inner.cli.lock().expect("CLI mutex poisoned").clone() {
|
||||
return Ok(cli);
|
||||
}
|
||||
let cli = OpenClawCli::discover()?;
|
||||
*self.inner.cli.lock().expect("CLI mutex poisoned") = Some(cli.clone());
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
fn update_tray(&self, snapshot: &GatewaySnapshot) {
|
||||
if let Some(tray) = self
|
||||
.inner
|
||||
.tray
|
||||
.lock()
|
||||
.expect("tray mutex poisoned")
|
||||
.as_ref()
|
||||
{
|
||||
tray.update(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate(&self, app: &AppHandle, target: &str) -> Result<(), String> {
|
||||
let url =
|
||||
Url::parse(target).map_err(|_| "Dashboard returned an invalid URL.".to_string())?;
|
||||
main_window(app)?
|
||||
.navigate(url)
|
||||
.map_err(|error| format!("Could not open dashboard: {error}"))?;
|
||||
tray::show_window(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_local(&self, app: &AppHandle, mode: &str) -> Result<(), String> {
|
||||
let mut url = self.inner.local_url.clone();
|
||||
url.query_pairs_mut().clear().append_pair("mode", mode);
|
||||
main_window(app)?
|
||||
.navigate(url)
|
||||
.map_err(|error| format!("Could not open local screen: {error}"))
|
||||
}
|
||||
|
||||
fn cancel_watchdog(&self) {
|
||||
self.inner.watch_generation.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn start_watchdog(&self, app: AppHandle) {
|
||||
let generation = self.inner.watch_generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let state = self.clone();
|
||||
thread::spawn(move || loop {
|
||||
thread::sleep(CONNECTED_WATCH_INTERVAL);
|
||||
if state.inner.watch_generation.load(Ordering::SeqCst) != generation {
|
||||
return;
|
||||
}
|
||||
let Ok(_operation) = state.inner.operation.try_lock() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(cli) = state.resolve_cli() else {
|
||||
continue;
|
||||
};
|
||||
let snapshot = match gateway::status(&cli) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => GatewaySnapshot::reconnecting(error),
|
||||
};
|
||||
if snapshot.reachable {
|
||||
state.update_tray(&snapshot);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut displayed_phase = snapshot.phase;
|
||||
let _ = state.show_local(&app, local_mode(&snapshot));
|
||||
state.update_tray(&snapshot);
|
||||
drop(_operation);
|
||||
loop {
|
||||
if state.inner.watch_generation.load(Ordering::SeqCst) != generation {
|
||||
return;
|
||||
}
|
||||
if let Ok(_operation) = state.inner.operation.try_lock() {
|
||||
let snapshot = match gateway::status(&cli) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => GatewaySnapshot::reconnecting(error),
|
||||
};
|
||||
state.update_tray(&snapshot);
|
||||
if snapshot.reachable {
|
||||
if let Ok(ready) = gateway::dashboard(&cli, snapshot) {
|
||||
if state.navigate(&app, &ready.dashboard_url).is_ok() {
|
||||
state.update_tray(&ready.snapshot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if snapshot.phase != displayed_phase {
|
||||
displayed_phase = snapshot.phase;
|
||||
let _ = state.show_local(&app, local_mode(&snapshot));
|
||||
}
|
||||
}
|
||||
thread::sleep(RECONNECT_INTERVAL);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn local_mode(snapshot: &GatewaySnapshot) -> &'static str {
|
||||
if snapshot.installed && !snapshot.running {
|
||||
"stopped"
|
||||
} else {
|
||||
"reconnecting"
|
||||
}
|
||||
}
|
||||
|
||||
fn main_window(app: &AppHandle) -> Result<WebviewWindow, String> {
|
||||
app.get_webview_window("main")
|
||||
.ok_or_else(|| "Main window is unavailable.".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn bootstrap(
|
||||
app: AppHandle,
|
||||
state: State<'_, DesktopState>,
|
||||
) -> Result<GatewaySnapshot, String> {
|
||||
let state = state.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || state.connect(&app))
|
||||
.await
|
||||
.map_err(|error| format!("Gateway task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_cli(
|
||||
app: AppHandle,
|
||||
state: State<'_, DesktopState>,
|
||||
channel: InstallChannel,
|
||||
) -> Result<GatewaySnapshot, String> {
|
||||
let state = state.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || state.install_cli(&app, channel))
|
||||
.await
|
||||
.map_err(|error| format!("Installer task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn gateway_action(
|
||||
app: AppHandle,
|
||||
state: State<'_, DesktopState>,
|
||||
action: GatewayAction,
|
||||
) -> Result<GatewaySnapshot, String> {
|
||||
let state = state.inner().clone();
|
||||
tauri::async_runtime::spawn_blocking(move || state.gateway_action(&app, action))
|
||||
.await
|
||||
.map_err(|error| format!("Gateway task failed: {error}"))?
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.expect("tauri.conf.json must define the main window");
|
||||
let state = DesktopState::new(window.url()?);
|
||||
app.manage(state.clone());
|
||||
state.set_tray(tray::build(app, state.clone())?);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap,
|
||||
install_cli,
|
||||
gateway_action
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
let state = window.app_handle().state::<DesktopState>();
|
||||
if !state.is_quitting() {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("OpenClaw desktop app failed");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use crate::gateway::{GatewayAction, GatewaySnapshot};
|
||||
use crate::DesktopState;
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{App, AppHandle, Manager};
|
||||
|
||||
const OPEN_ID: &str = "open-dashboard";
|
||||
const START_ID: &str = "start-gateway";
|
||||
const STOP_ID: &str = "stop-gateway";
|
||||
const RESTART_ID: &str = "restart-gateway";
|
||||
const QUIT_ID: &str = "quit";
|
||||
|
||||
pub struct TrayHandles {
|
||||
_tray: TrayIcon<tauri::Wry>,
|
||||
status: MenuItem<tauri::Wry>,
|
||||
open: MenuItem<tauri::Wry>,
|
||||
start: MenuItem<tauri::Wry>,
|
||||
stop: MenuItem<tauri::Wry>,
|
||||
restart: MenuItem<tauri::Wry>,
|
||||
}
|
||||
|
||||
impl TrayHandles {
|
||||
pub fn update(&self, snapshot: &GatewaySnapshot) {
|
||||
let _ = self
|
||||
.status
|
||||
.set_text(format!("Gateway: {}", snapshot.status));
|
||||
let _ = self.open.set_enabled(true);
|
||||
let _ = self
|
||||
.start
|
||||
.set_enabled(snapshot.installed && !snapshot.running && !snapshot.reachable);
|
||||
let _ = self
|
||||
.stop
|
||||
.set_enabled(snapshot.installed && snapshot.running);
|
||||
let _ = self.restart.set_enabled(snapshot.installed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(app: &App, state: DesktopState) -> tauri::Result<TrayHandles> {
|
||||
let status = MenuItem::with_id(
|
||||
app,
|
||||
"gateway-status",
|
||||
"Gateway: Checking…",
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open = MenuItem::with_id(app, OPEN_ID, "Open Dashboard", true, None::<&str>)?;
|
||||
let start = MenuItem::with_id(app, START_ID, "Start Gateway", false, None::<&str>)?;
|
||||
let stop = MenuItem::with_id(app, STOP_ID, "Stop Gateway", false, None::<&str>)?;
|
||||
let restart = MenuItem::with_id(app, RESTART_ID, "Restart Gateway", false, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, QUIT_ID, "Quit OpenClaw", true, None::<&str>)?;
|
||||
let separator_one = PredefinedMenuItem::separator(app)?;
|
||||
let separator_two = PredefinedMenuItem::separator(app)?;
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&status,
|
||||
&separator_one,
|
||||
&open,
|
||||
&start,
|
||||
&stop,
|
||||
&restart,
|
||||
&separator_two,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
|
||||
let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png"))?;
|
||||
let menu_state = state.clone();
|
||||
let tray_builder = TrayIconBuilder::with_id("openclaw-main")
|
||||
.icon(tray_icon)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(move |app, event| {
|
||||
handle_menu(app, &menu_state, event.id().as_ref());
|
||||
})
|
||||
// Linux tray backends expose the Open action through the menu; Tauri also
|
||||
// emits this direct click event on platforms that support it.
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if matches!(
|
||||
event,
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
) {
|
||||
show_window(tray.app_handle());
|
||||
}
|
||||
});
|
||||
#[cfg(target_os = "macos")]
|
||||
let tray_builder = tray_builder.icon_as_template(true);
|
||||
let tray = tray_builder.build(app)?;
|
||||
|
||||
Ok(TrayHandles {
|
||||
_tray: tray,
|
||||
status,
|
||||
open,
|
||||
start,
|
||||
stop,
|
||||
restart,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn show_window(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_menu(app: &AppHandle, state: &DesktopState, id: &str) {
|
||||
match id {
|
||||
QUIT_ID => {
|
||||
state.quit();
|
||||
app.exit(0);
|
||||
}
|
||||
OPEN_ID => {
|
||||
show_window(app);
|
||||
spawn_connect(app.clone(), state.clone());
|
||||
}
|
||||
START_ID => spawn_action(app.clone(), state.clone(), GatewayAction::Start),
|
||||
STOP_ID => spawn_action(app.clone(), state.clone(), GatewayAction::Stop),
|
||||
RESTART_ID => spawn_action(app.clone(), state.clone(), GatewayAction::Restart),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_connect(app: AppHandle, state: DesktopState) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(error) = state.connect(&app) {
|
||||
state.show_error(&app, &error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_action(app: AppHandle, state: DesktopState, action: GatewayAction) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(error) = state.gateway_action(&app, action) {
|
||||
state.show_error(&app, &error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenClaw",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.openclaw.linux",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "OpenClaw",
|
||||
"url": "index.html",
|
||||
"width": 1080,
|
||||
"height": 720,
|
||||
"minWidth": 720,
|
||||
"minHeight": 520,
|
||||
"center": true,
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost",
|
||||
"capabilities": [
|
||||
{
|
||||
"identifier": "local-companion",
|
||||
"description": "Local setup screens can invoke app commands and receive installer progress.",
|
||||
"local": true,
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "Utility",
|
||||
"shortDescription": "OpenClaw Linux companion",
|
||||
"longDescription": "Installs OpenClaw, manages the local Gateway service, and hosts the Control UI.",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"resources": {
|
||||
"../../../scripts/install-cli.sh": "install-cli.sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>OpenClaw</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="brand" aria-label="OpenClaw">
|
||||
<span class="brand-mark" aria-hidden="true"><i></i><i></i></span>
|
||||
<span>OPENCLAW</span>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-live="polite">
|
||||
<div class="status-row">
|
||||
<span id="status-dot" class="status-dot working"></span>
|
||||
<span id="eyebrow" class="eyebrow">DESKTOP COMPANION</span>
|
||||
</div>
|
||||
<h1 id="title">Connecting to OpenClaw</h1>
|
||||
<p id="description">
|
||||
Finding your gateway and preparing the Control UI.
|
||||
</p>
|
||||
|
||||
<div id="activity" class="activity">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span id="activity-label">Checking local services…</span>
|
||||
</div>
|
||||
|
||||
<div id="install-controls" class="controls hidden">
|
||||
<label for="channel">Release channel</label>
|
||||
<div class="control-row">
|
||||
<select id="channel">
|
||||
<option value="stable">Stable</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="dev">Development</option>
|
||||
</select>
|
||||
<button id="install-button" class="primary">Install OpenClaw</button>
|
||||
</div>
|
||||
<p class="hint">Installs the CLI and managed Node runtime in ~/.openclaw.</p>
|
||||
</div>
|
||||
|
||||
<div id="action-controls" class="controls hidden">
|
||||
<button id="primary-action" class="primary">Try again</button>
|
||||
</div>
|
||||
|
||||
<div id="log-wrap" class="log-wrap hidden">
|
||||
<div class="log-head">
|
||||
<span>INSTALL LOG</span>
|
||||
<span id="log-status">RUNNING</span>
|
||||
</div>
|
||||
<pre id="install-log"></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>LOCAL GATEWAY</span>
|
||||
<span class="footer-separator"></span>
|
||||
<span>CLOSE TO TRAY</span>
|
||||
</footer>
|
||||
</main>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
const tauri = window["__TAURI__"];
|
||||
const { invoke } = tauri.core;
|
||||
const { listen } = tauri.event;
|
||||
|
||||
const elements = {
|
||||
activity: document.querySelector("#activity"),
|
||||
activityLabel: document.querySelector("#activity-label"),
|
||||
actionControls: document.querySelector("#action-controls"),
|
||||
channel: document.querySelector("#channel"),
|
||||
description: document.querySelector("#description"),
|
||||
eyebrow: document.querySelector("#eyebrow"),
|
||||
installButton: document.querySelector("#install-button"),
|
||||
installControls: document.querySelector("#install-controls"),
|
||||
installLog: document.querySelector("#install-log"),
|
||||
logStatus: document.querySelector("#log-status"),
|
||||
logWrap: document.querySelector("#log-wrap"),
|
||||
primaryAction: document.querySelector("#primary-action"),
|
||||
statusDot: document.querySelector("#status-dot"),
|
||||
title: document.querySelector("#title"),
|
||||
};
|
||||
|
||||
let primaryAction = null;
|
||||
|
||||
function show(element, visible) {
|
||||
element.classList.toggle("hidden", !visible);
|
||||
}
|
||||
|
||||
function render({
|
||||
activity = null,
|
||||
description,
|
||||
dot = "working",
|
||||
eyebrow = "DESKTOP COMPANION",
|
||||
showInstall = false,
|
||||
title,
|
||||
}) {
|
||||
elements.eyebrow.textContent = eyebrow;
|
||||
elements.title.textContent = title;
|
||||
elements.description.textContent = description;
|
||||
elements.statusDot.className = `status-dot ${dot}`;
|
||||
show(elements.activity, Boolean(activity));
|
||||
if (activity) {
|
||||
elements.activityLabel.textContent = activity;
|
||||
}
|
||||
show(elements.installControls, showInstall);
|
||||
show(elements.actionControls, false);
|
||||
}
|
||||
|
||||
function renderAction(options, action) {
|
||||
render(options);
|
||||
primaryAction = action;
|
||||
elements.primaryAction.textContent = options.actionLabel;
|
||||
show(elements.actionControls, true);
|
||||
}
|
||||
|
||||
function appendLog(line) {
|
||||
elements.installLog.textContent += `${line}\n`;
|
||||
elements.installLog.scrollTop = elements.installLog.scrollHeight;
|
||||
}
|
||||
|
||||
function friendlyError(error) {
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return error?.message || "OpenClaw could not complete the operation.";
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
render({
|
||||
activity: "Checking local services…",
|
||||
description: "Finding your gateway and preparing the Control UI.",
|
||||
title: "Connecting to OpenClaw",
|
||||
});
|
||||
try {
|
||||
const snapshot = await invoke("bootstrap");
|
||||
if (snapshot.phase === "missingCli") {
|
||||
render({
|
||||
activity: "Starting the bundled installer…",
|
||||
description: "OpenClaw is installing its managed CLI and Node runtime.",
|
||||
eyebrow: "FIRST-RUN SETUP",
|
||||
title: "Preparing OpenClaw",
|
||||
});
|
||||
await install();
|
||||
}
|
||||
} catch (error) {
|
||||
renderRetry(friendlyError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function install() {
|
||||
elements.installButton.disabled = true;
|
||||
elements.channel.disabled = true;
|
||||
elements.installLog.textContent = "";
|
||||
elements.logStatus.textContent = "RUNNING";
|
||||
show(elements.logWrap, true);
|
||||
render({
|
||||
activity: "Installing OpenClaw…",
|
||||
description: "A managed CLI and Node runtime are being installed in your home directory.",
|
||||
eyebrow: "INSTALLING",
|
||||
title: "Preparing your companion",
|
||||
});
|
||||
try {
|
||||
await invoke("install_cli", { channel: elements.channel.value });
|
||||
elements.logStatus.textContent = "COMPLETE";
|
||||
} catch (error) {
|
||||
elements.logStatus.textContent = "FAILED";
|
||||
appendLog(friendlyError(error));
|
||||
render({
|
||||
description:
|
||||
"Installation did not finish. Review the final log lines, choose a release channel, then retry.",
|
||||
dot: "error",
|
||||
eyebrow: "INSTALLATION ISSUE",
|
||||
showInstall: true,
|
||||
title: "OpenClaw needs attention",
|
||||
});
|
||||
} finally {
|
||||
elements.installButton.disabled = false;
|
||||
elements.channel.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runGatewayAction(action) {
|
||||
render({
|
||||
activity: `${action === "restart" ? "Restarting" : "Starting"} gateway…`,
|
||||
description: "OpenClaw is waiting for the local gateway to become healthy.",
|
||||
eyebrow: "GATEWAY",
|
||||
title: "One moment",
|
||||
});
|
||||
try {
|
||||
await invoke("gateway_action", { action });
|
||||
} catch (error) {
|
||||
renderRetry(friendlyError(error));
|
||||
}
|
||||
}
|
||||
|
||||
function renderRetry(message) {
|
||||
show(elements.logWrap, false);
|
||||
renderAction(
|
||||
{
|
||||
actionLabel: "Try again",
|
||||
description: message,
|
||||
dot: "error",
|
||||
eyebrow: "CONNECTION ISSUE",
|
||||
title: "OpenClaw needs attention",
|
||||
},
|
||||
connect,
|
||||
);
|
||||
}
|
||||
|
||||
elements.installButton.addEventListener("click", () => {
|
||||
void install();
|
||||
});
|
||||
elements.primaryAction.addEventListener("click", () => {
|
||||
void primaryAction?.();
|
||||
});
|
||||
|
||||
await listen("install-progress", ({ payload }) => appendLog(payload.line));
|
||||
|
||||
const mode = new URLSearchParams(window.location.search).get("mode");
|
||||
if (mode === "reconnecting") {
|
||||
render({
|
||||
activity: "Retrying every few seconds…",
|
||||
description: "The gateway connection dropped. OpenClaw will restore the dashboard automatically.",
|
||||
eyebrow: "GATEWAY OFFLINE",
|
||||
title: "Reconnecting",
|
||||
});
|
||||
} else if (mode === "stopped") {
|
||||
renderAction(
|
||||
{
|
||||
actionLabel: "Start Gateway",
|
||||
description: "The gateway is stopped. The desktop companion will remain available in the tray.",
|
||||
dot: "idle",
|
||||
eyebrow: "GATEWAY STOPPED",
|
||||
title: "OpenClaw is standing by",
|
||||
},
|
||||
() => runGatewayAction("start"),
|
||||
);
|
||||
} else if (mode === "error") {
|
||||
renderRetry("The last gateway action failed. Check the service, then retry.");
|
||||
} else {
|
||||
await connect();
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
color: #f6f7fb;
|
||||
background: #0e1015;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% -20%, rgb(255 92 92 / 12%), transparent 42%),
|
||||
#0e1015;
|
||||
}
|
||||
|
||||
body::before {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
opacity: 0.2;
|
||||
background-image: linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px);
|
||||
background-size: 100% 4px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
width: min(820px, calc(100vw - 64px));
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: #d9dbe4;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 27px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.brand-mark i {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
width: 15px;
|
||||
height: 19px;
|
||||
border: 3px solid #ff5c5c;
|
||||
border-radius: 11px 11px 7px 7px;
|
||||
box-shadow: 0 0 18px rgb(255 92 92 / 25%);
|
||||
}
|
||||
|
||||
.brand-mark i:first-child {
|
||||
left: 0;
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
|
||||
.brand-mark i:last-child {
|
||||
right: 0;
|
||||
transform: rotate(20deg);
|
||||
}
|
||||
|
||||
.panel {
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
padding: clamp(30px, 6vw, 58px);
|
||||
border: 1px solid #272a33;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(145deg, rgb(28 31 40 / 96%), rgb(18 20 27 / 98%));
|
||||
box-shadow:
|
||||
0 28px 80px rgb(0 0 0 / 36%),
|
||||
inset 0 1px rgb(255 255 255 / 4%);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ff5c5c;
|
||||
box-shadow: 0 0 0 5px rgb(255 92 92 / 10%);
|
||||
}
|
||||
|
||||
.status-dot.working {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.idle {
|
||||
background: #8c91a2;
|
||||
box-shadow: 0 0 0 5px rgb(140 145 162 / 10%);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: #ff7c5c;
|
||||
box-shadow: 0 0 0 5px rgb(255 124 92 / 10%);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #a8acba;
|
||||
font-size: 11px;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 21px 0 12px;
|
||||
font-size: clamp(32px, 5vw, 52px);
|
||||
font-weight: 680;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.04;
|
||||
}
|
||||
|
||||
#description {
|
||||
max-width: 590px;
|
||||
margin: 0;
|
||||
color: #aeb2c0;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.activity {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
align-items: center;
|
||||
margin-top: 30px;
|
||||
color: #d9dbe4;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border: 2px solid #383c49;
|
||||
border-top-color: #ff5c5c;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.controls label,
|
||||
.log-head {
|
||||
color: #858a9a;
|
||||
font-size: 10px;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
select,
|
||||
button {
|
||||
min-height: 44px;
|
||||
border: 1px solid #353947;
|
||||
border-radius: 10px;
|
||||
color: #f6f7fb;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 0 36px 0 14px;
|
||||
color-scheme: dark;
|
||||
background: #151820;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: #242833;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
border-color 120ms ease,
|
||||
background 120ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: #ff7777;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
border-color: #ff5c5c;
|
||||
color: #180909;
|
||||
background: #ff5c5c;
|
||||
box-shadow: 0 10px 26px rgb(255 92 92 / 16%);
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: #ff7474;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 10px 0 0;
|
||||
color: #717686;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-wrap {
|
||||
margin-top: 24px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #292d38;
|
||||
border-radius: 12px;
|
||||
background: #0d0f14;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid #242731;
|
||||
}
|
||||
|
||||
#log-status {
|
||||
color: #ff7777;
|
||||
}
|
||||
|
||||
pre {
|
||||
height: 150px;
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
color: #aeb2c0;
|
||||
font: 11px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: #606574;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.footer-separator {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: #ff5c5c;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
box-shadow: 0 0 0 8px rgb(255 92 92 / 3%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.shell {
|
||||
width: calc(100vw - 32px);
|
||||
padding: 22px 0 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 28px 24px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,24 @@ Open the Control UI using your current auth.
|
||||
```bash
|
||||
openclaw dashboard
|
||||
openclaw dashboard --no-open
|
||||
openclaw dashboard --json
|
||||
openclaw dashboard --yes
|
||||
```
|
||||
|
||||
- `--no-open`: print the URL but do not launch a browser.
|
||||
- `--json`: print one machine-readable connection object without opening a browser, using the clipboard, prompting, or starting the Gateway.
|
||||
- `--yes`: start/install the Gateway without prompting when needed.
|
||||
|
||||
## Machine-readable output
|
||||
|
||||
Use `--json` for desktop integrations and scripts that need the resolved Control UI URL:
|
||||
|
||||
```bash
|
||||
openclaw dashboard --json
|
||||
```
|
||||
|
||||
The response includes `url`, `httpUrl`, `wsUrl`, `port`, and `tokenIncluded`. If the Gateway is not ready, the command returns `{"ok":false,"reason":"..."}` and exits non-zero. SecretRef-managed tokens are never included in `url`.
|
||||
|
||||
Notes:
|
||||
|
||||
- Resolves configured `gateway.auth.token` SecretRefs when possible.
|
||||
|
||||
+3
-1
@@ -1427,6 +1427,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- Route: /cli/dashboard
|
||||
- Headings:
|
||||
- H1: openclaw dashboard
|
||||
- H2: Machine-readable output
|
||||
- H2: Related
|
||||
|
||||
## cli/devices.md
|
||||
@@ -5130,7 +5131,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
|
||||
- Route: /platforms/linux
|
||||
- Headings:
|
||||
- H2: Quick path (VPS)
|
||||
- H2: Desktop companion
|
||||
- H2: CLI and SSH alternative
|
||||
- H2: Install
|
||||
- H2: Gateway service (systemd)
|
||||
- H2: Memory pressure and OOM kills
|
||||
|
||||
+21
-2
@@ -11,9 +11,28 @@ The Gateway is fully supported on Linux and requires Node. Bun can still be used
|
||||
as a dependency installer or package-script runner, but it cannot run OpenClaw
|
||||
because it does not provide `node:sqlite`.
|
||||
|
||||
There is no native Linux companion app yet. Contributions are welcome.
|
||||
## Desktop companion
|
||||
|
||||
## Quick path (VPS)
|
||||
The OpenClaw Linux companion is a Tauri desktop app for a local Gateway. It:
|
||||
|
||||
- installs the OpenClaw CLI and managed Node runtime when they are missing
|
||||
- attaches to a healthy Gateway before attempting service changes
|
||||
- delegates install, start, stop, and restart operations to the CLI-managed systemd user service
|
||||
- opens the Gateway-served Control UI with its resolved authentication URL
|
||||
- remains available from the system tray when its window is closed
|
||||
|
||||
Packaged releases are not available yet. Build the app from a source checkout:
|
||||
|
||||
```bash
|
||||
cd apps/linux/src-tauri
|
||||
cargo build
|
||||
```
|
||||
|
||||
See `apps/linux/README.md` in the repository for Linux build dependencies and development commands.
|
||||
|
||||
## CLI and SSH alternative
|
||||
|
||||
The CLI remains the simplest option for a headless server, a VPS, or a remote Gateway:
|
||||
|
||||
1. Install Node 24.15+ (recommended), Node 22.22.3+ (LTS), or Node 25.9+.
|
||||
2. `npm i -g openclaw@latest`
|
||||
|
||||
@@ -320,15 +320,16 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
expect(runtime.exit).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("passes noOpen to dashboard command", async () => {
|
||||
it("passes output options to dashboard command", async () => {
|
||||
dashboardCommand.mockResolvedValue(undefined);
|
||||
|
||||
await runMaintenanceCli(["dashboard", "--no-open"]);
|
||||
await runMaintenanceCli(["dashboard", "--no-open", "--json"]);
|
||||
|
||||
expect(dashboardCommand).toHaveBeenCalledTimes(1);
|
||||
const [runtimeArg, options] = commandCall(dashboardCommand);
|
||||
expect(runtimeArg).toBe(runtime);
|
||||
expect(options.noOpen).toBe(true);
|
||||
expect(options.json).toBe(true);
|
||||
});
|
||||
|
||||
it("passes reset options to reset command", async () => {
|
||||
|
||||
@@ -186,11 +186,13 @@ export function registerMaintenanceCommands(program: Command) {
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/dashboard", "docs.openclaw.ai/cli/dashboard")}\n`,
|
||||
)
|
||||
.option("--no-open", "Print URL but do not launch a browser")
|
||||
.option("--json", "Output dashboard connection details as JSON", false)
|
||||
.option("--yes", "Start/install the gateway without prompting when needed", false)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
const { dashboardCommand } = await import("../../commands/dashboard.js");
|
||||
await dashboardCommand(defaultRuntime, {
|
||||
json: Boolean(opts.json),
|
||||
noOpen: opts.open === false,
|
||||
yes: Boolean(opts.yes),
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@ import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
|
||||
import { copyToClipboard } from "../infra/clipboard.js";
|
||||
import { isSameProcessSpecificIpv4WithLoopbackListeners } from "../infra/ports-format.js";
|
||||
import { inspectPortUsage } from "../infra/ports-inspect.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { ensureGatewayReadyForOperation } from "./gateway-readiness.js";
|
||||
import {
|
||||
detectBrowserOpenSupport,
|
||||
@@ -15,10 +14,17 @@ import {
|
||||
} from "./onboard-helpers.js";
|
||||
|
||||
type DashboardOptions = {
|
||||
json?: boolean;
|
||||
noOpen?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
const quietRuntime: RuntimeEnv = {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: () => {},
|
||||
};
|
||||
|
||||
async function resolveDashboardTarget() {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const cfg = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {};
|
||||
@@ -126,11 +132,59 @@ async function ensureDashboardTargetReady(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function dashboardJsonFailure(runtime: RuntimeEnv, reason: string): void {
|
||||
writeRuntimeJson(runtime, { ok: false, reason }, 0);
|
||||
runtime.exit(1);
|
||||
}
|
||||
|
||||
async function dashboardJsonCommand(runtime: RuntimeEnv): Promise<void> {
|
||||
try {
|
||||
const target = await resolveDashboardTarget();
|
||||
const readiness = await ensureDashboardTargetReady({
|
||||
target,
|
||||
runtime: quietRuntime,
|
||||
allowRecovery: false,
|
||||
});
|
||||
if (!readiness.ready) {
|
||||
dashboardJsonFailure(runtime, readiness.reason);
|
||||
return;
|
||||
}
|
||||
if (!(await hasVerifiedLoopbackAlias(target))) {
|
||||
dashboardJsonFailure(
|
||||
runtime,
|
||||
"Dashboard loopback listener could not be verified as the configured Gateway.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
writeRuntimeJson(
|
||||
runtime,
|
||||
{
|
||||
ok: true,
|
||||
url: target.dashboardUrl,
|
||||
httpUrl: target.links.httpUrl,
|
||||
wsUrl: target.links.wsUrl,
|
||||
port: target.port,
|
||||
tokenIncluded: target.includeTokenInUrl,
|
||||
},
|
||||
0,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
dashboardJsonFailure(runtime, reason || "Dashboard target resolution failed.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Open or print the Control UI dashboard URL after ensuring the Gateway is reachable. */
|
||||
export async function dashboardCommand(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
options: DashboardOptions = {},
|
||||
) {
|
||||
if (options.json) {
|
||||
await dashboardJsonCommand(runtime);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialTarget = await resolveDashboardTarget();
|
||||
const readiness = await ensureDashboardTargetReady({
|
||||
target: initialTarget,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dashboardCommand } from "../dashboard.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
ensureGatewayReadyForOperation: vi.fn(),
|
||||
inspectPortUsage: vi.fn(),
|
||||
openUrl: vi.fn(),
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
resolveControlUiLinks: vi.fn(),
|
||||
resolveGatewayAuthToken: vi.fn(),
|
||||
resolveGatewayPort: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
|
||||
resolveGatewayPort: mocks.resolveGatewayPort,
|
||||
}));
|
||||
|
||||
vi.mock("../../gateway/auth-token-resolution.js", () => {
|
||||
const { resolveGatewayAuthToken } = mocks;
|
||||
return { resolveGatewayAuthToken };
|
||||
});
|
||||
|
||||
vi.mock("../onboard-helpers.js", () => ({
|
||||
detectBrowserOpenSupport: vi.fn(),
|
||||
formatControlUiSshHint: vi.fn(),
|
||||
openUrl: mocks.openUrl,
|
||||
resolveControlUiLinks: mocks.resolveControlUiLinks,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/clipboard.js", () => ({
|
||||
copyToClipboard: mocks.copyToClipboard,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/ports-inspect.js", () => ({
|
||||
inspectPortUsage: mocks.inspectPortUsage,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway-readiness.js", () => ({
|
||||
ensureGatewayReadyForOperation: mocks.ensureGatewayReadyForOperation,
|
||||
}));
|
||||
|
||||
// Assembled so secret scanners do not read the fixture as a real credential.
|
||||
const fakeToken = ["te", "st"].join("");
|
||||
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
log: vi.fn(),
|
||||
writeJson: vi.fn(),
|
||||
writeStdout: vi.fn(),
|
||||
};
|
||||
|
||||
function mockReadyDashboard() {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
valid: true,
|
||||
sourceConfig: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "10.0.0.5",
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
mocks.resolveControlUiLinks.mockImplementation(({ bind }: { bind: string }) => {
|
||||
if (bind === "custom") {
|
||||
return {
|
||||
httpUrl: "http://10.0.0.5:18789/",
|
||||
wsUrl: "ws://10.0.0.5:18789",
|
||||
};
|
||||
}
|
||||
return {
|
||||
httpUrl: "http://127.0.0.1:18789/",
|
||||
wsUrl: "ws://127.0.0.1:18789",
|
||||
};
|
||||
});
|
||||
mocks.inspectPortUsage.mockResolvedValue({
|
||||
port: 18789,
|
||||
status: "busy",
|
||||
listeners: [
|
||||
{ pid: 4242, commandLine: "openclaw-gateway", address: "10.0.0.5:18789" },
|
||||
{ pid: 4242, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
|
||||
],
|
||||
hints: [],
|
||||
});
|
||||
mocks.ensureGatewayReadyForOperation.mockResolvedValue({
|
||||
ready: true,
|
||||
recovered: false,
|
||||
status: {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("dashboardCommand --json", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReadyDashboard();
|
||||
mocks.resolveGatewayAuthToken.mockResolvedValue({
|
||||
secretRefConfigured: false,
|
||||
token: fakeToken,
|
||||
});
|
||||
});
|
||||
|
||||
it("prints one compact success object without interactive side effects", async () => {
|
||||
await dashboardCommand(runtime, { json: true, noOpen: true });
|
||||
|
||||
expect(runtime.writeJson).toHaveBeenCalledOnce();
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(
|
||||
{
|
||||
ok: true,
|
||||
url: ["http://127.0.0.1:18789/", "#", "token", "=test"].join(""),
|
||||
httpUrl: "http://127.0.0.1:18789/",
|
||||
wsUrl: "ws://127.0.0.1:18789",
|
||||
port: 18789,
|
||||
tokenIncluded: true,
|
||||
},
|
||||
0,
|
||||
);
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
|
||||
expect(mocks.inspectPortUsage).toHaveBeenCalledWith(18789);
|
||||
expect(mocks.openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints one failure object and exits non-zero when not ready", async () => {
|
||||
mocks.ensureGatewayReadyForOperation.mockResolvedValue({
|
||||
ready: false,
|
||||
reason: "Gateway is not running.",
|
||||
recoverable: false,
|
||||
status: {},
|
||||
});
|
||||
|
||||
await dashboardCommand(runtime, { json: true });
|
||||
|
||||
expect(mocks.ensureGatewayReadyForOperation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowInstall: false,
|
||||
interactive: false,
|
||||
}),
|
||||
);
|
||||
expect(runtime.writeJson).toHaveBeenCalledOnce();
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(
|
||||
{ ok: false, reason: "Gateway is not running." },
|
||||
0,
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps SecretRef-managed tokens out of the URL", async () => {
|
||||
mocks.resolveGatewayAuthToken.mockResolvedValue({
|
||||
secretRefConfigured: true,
|
||||
token: fakeToken,
|
||||
});
|
||||
|
||||
await dashboardCommand(runtime, { json: true });
|
||||
|
||||
expect(runtime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
url: "http://127.0.0.1:18789/",
|
||||
tokenIncluded: false,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user