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:
Peter Steinberger
2026-07-13 05:17:27 -07:00
committed by GitHub
parent 6c5084f1ad
commit 0bab08510e
27 changed files with 6389 additions and 7 deletions
+5
View File
@@ -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:
+67
View 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
+44
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
/gen/
/target/
+4419
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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"] }
+3
View File
@@ -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

+129
View File
@@ -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(&current));
}
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"))
}
+238
View File
@@ -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.")))
}
+125
View File
@@ -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;
}
}
})
}
+308
View File
@@ -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");
}
+143
View File
@@ -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);
}
});
}
+52
View File
@@ -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"
}
}
}
+66
View File
@@ -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>
+181
View File
@@ -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();
}
+324
View File
@@ -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;
}
}
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+3 -2
View File
@@ -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 () => {
+2
View File
@@ -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),
});
+56 -2
View File
@@ -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,
+168
View File
@@ -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,
);
});
});