feat: refactor sgclaw around zeroclaw compat runtime
29
third_party/zeroclaw/apps/tauri/Cargo.toml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "zeroclaw-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "ZeroClaw Desktop — Tauri-powered system tray app"
|
||||
publish = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2.0"
|
||||
tauri-plugin-store = "2.0"
|
||||
tauri-plugin-single-instance = "2.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
tokio = { version = "1.50", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
anyhow = "1.0"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSData"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
third_party/zeroclaw/apps/tauri/build.rs
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
14
third_party/zeroclaw/apps/tauri/capabilities/default.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability set for ZeroClaw Desktop",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"store:allow-get",
|
||||
"store:allow-set",
|
||||
"store:allow-save",
|
||||
"store:allow-load"
|
||||
]
|
||||
}
|
||||
14
third_party/zeroclaw/apps/tauri/capabilities/desktop.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"identifier": "desktop",
|
||||
"description": "Desktop-specific permissions for ZeroClaw",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"store:allow-get",
|
||||
"store:allow-set",
|
||||
"store:allow-save",
|
||||
"store:allow-load"
|
||||
]
|
||||
}
|
||||
8
third_party/zeroclaw/apps/tauri/capabilities/mobile.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"identifier": "mobile",
|
||||
"description": "Mobile-specific permissions for ZeroClaw",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
0
third_party/zeroclaw/apps/tauri/gen/android/.gitkeep
vendored
Normal file
0
third_party/zeroclaw/apps/tauri/gen/apple/.gitkeep
vendored
Normal file
0
third_party/zeroclaw/apps/tauri/icons/.gitkeep
vendored
Normal file
BIN
third_party/zeroclaw/apps/tauri/icons/128x128.png
vendored
Normal file
|
After Width: | Height: | Size: 1002 B |
BIN
third_party/zeroclaw/apps/tauri/icons/32x32.png
vendored
Normal file
|
After Width: | Height: | Size: 243 B |
BIN
third_party/zeroclaw/apps/tauri/icons/icon.icns
vendored
Normal file
BIN
third_party/zeroclaw/apps/tauri/icons/icon.ico
vendored
Normal file
|
After Width: | Height: | Size: 243 B |
4
third_party/zeroclaw/apps/tauri/icons/icon.svg
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="16" fill="#DC322F"/>
|
||||
<text x="64" y="80" font-size="64" font-family="monospace" font-weight="bold" fill="white" text-anchor="middle">Z</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
BIN
third_party/zeroclaw/apps/tauri/icons/tray-disconnected.png
vendored
Normal file
|
After Width: | Height: | Size: 199 B |
BIN
third_party/zeroclaw/apps/tauri/icons/tray-error.png
vendored
Normal file
|
After Width: | Height: | Size: 208 B |
BIN
third_party/zeroclaw/apps/tauri/icons/tray-idle.png
vendored
Normal file
|
After Width: | Height: | Size: 168 B |
BIN
third_party/zeroclaw/apps/tauri/icons/tray-working.png
vendored
Normal file
|
After Width: | Height: | Size: 201 B |
17
third_party/zeroclaw/apps/tauri/src/commands/agent.rs
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::gateway_client::GatewayClient;
|
||||
use crate::state::SharedState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message(
|
||||
state: State<'_, SharedState>,
|
||||
message: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let s = state.read().await;
|
||||
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
|
||||
drop(s);
|
||||
client
|
||||
.send_webhook_message(&message)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
11
third_party/zeroclaw/apps/tauri/src/commands/channels.rs
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
use crate::gateway_client::GatewayClient;
|
||||
use crate::state::SharedState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_channels(state: State<'_, SharedState>) -> Result<serde_json::Value, String> {
|
||||
let s = state.read().await;
|
||||
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
|
||||
drop(s);
|
||||
client.get_status().await.map_err(|e| e.to_string())
|
||||
}
|
||||
19
third_party/zeroclaw/apps/tauri/src/commands/gateway.rs
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
use crate::gateway_client::GatewayClient;
|
||||
use crate::state::SharedState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_status(state: State<'_, SharedState>) -> Result<serde_json::Value, String> {
|
||||
let s = state.read().await;
|
||||
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
|
||||
drop(s);
|
||||
client.get_status().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_health(state: State<'_, SharedState>) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
|
||||
drop(s);
|
||||
client.get_health().await.map_err(|e| e.to_string())
|
||||
}
|
||||
4
third_party/zeroclaw/apps/tauri/src/commands/mod.rs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod agent;
|
||||
pub mod channels;
|
||||
pub mod gateway;
|
||||
pub mod pairing;
|
||||
19
third_party/zeroclaw/apps/tauri/src/commands/pairing.rs
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
use crate::gateway_client::GatewayClient;
|
||||
use crate::state::SharedState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn initiate_pairing(state: State<'_, SharedState>) -> Result<serde_json::Value, String> {
|
||||
let s = state.read().await;
|
||||
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
|
||||
drop(s);
|
||||
client.initiate_pairing().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_devices(state: State<'_, SharedState>) -> Result<serde_json::Value, String> {
|
||||
let s = state.read().await;
|
||||
let client = GatewayClient::new(&s.gateway_url, s.token.as_deref());
|
||||
drop(s);
|
||||
client.get_devices().await.map_err(|e| e.to_string())
|
||||
}
|
||||
213
third_party/zeroclaw/apps/tauri/src/gateway_client.rs
vendored
Normal file
@@ -0,0 +1,213 @@
|
||||
//! HTTP client for communicating with the ZeroClaw gateway.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub struct GatewayClient {
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) token: Option<String>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GatewayClient {
|
||||
pub fn new(base_url: &str, token: Option<&str>) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
base_url: base_url.to_string(),
|
||||
token: token.map(String::from),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auth_header(&self) -> Option<String> {
|
||||
self.token.as_ref().map(|t| format!("Bearer {t}"))
|
||||
}
|
||||
|
||||
pub async fn get_status(&self) -> Result<serde_json::Value> {
|
||||
let mut req = self.client.get(format!("{}/api/status", self.base_url));
|
||||
if let Some(auth) = self.auth_header() {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
let resp = req.send().await.context("status request failed")?;
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_health(&self) -> Result<bool> {
|
||||
match self
|
||||
.client
|
||||
.get(format!("{}/health", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => Ok(resp.status().is_success()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_devices(&self) -> Result<serde_json::Value> {
|
||||
let mut req = self.client.get(format!("{}/api/devices", self.base_url));
|
||||
if let Some(auth) = self.auth_header() {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
let resp = req.send().await.context("devices request failed")?;
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
pub async fn initiate_pairing(&self) -> Result<serde_json::Value> {
|
||||
let mut req = self
|
||||
.client
|
||||
.post(format!("{}/api/pairing/initiate", self.base_url));
|
||||
if let Some(auth) = self.auth_header() {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
let resp = req.send().await.context("pairing request failed")?;
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Check whether the gateway requires pairing.
|
||||
pub async fn requires_pairing(&self) -> Result<bool> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/health", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.context("health request failed")?;
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
Ok(body["require_pairing"].as_bool().unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Request a new pairing code from the gateway (localhost-only admin endpoint).
|
||||
pub async fn request_new_paircode(&self) -> Result<String> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/admin/paircode/new", self.base_url))
|
||||
.send()
|
||||
.await
|
||||
.context("paircode request failed")?;
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
body["pairing_code"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.context("no pairing_code in response")
|
||||
}
|
||||
|
||||
/// Exchange a pairing code for a bearer token.
|
||||
pub async fn pair_with_code(&self, code: &str) -> Result<String> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/pair", self.base_url))
|
||||
.header("X-Pairing-Code", code)
|
||||
.send()
|
||||
.await
|
||||
.context("pair request failed")?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("pair request returned {}", resp.status());
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
body["token"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.context("no token in pair response")
|
||||
}
|
||||
|
||||
/// Validate an existing token by calling a protected endpoint.
|
||||
pub async fn validate_token(&self) -> Result<bool> {
|
||||
let mut req = self.client.get(format!("{}/api/status", self.base_url));
|
||||
if let Some(auth) = self.auth_header() {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(resp) => Ok(resp.status().is_success()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-pair with the gateway: request a new code and exchange it for a token.
|
||||
pub async fn auto_pair(&self) -> Result<String> {
|
||||
let code = self.request_new_paircode().await?;
|
||||
self.pair_with_code(&code).await
|
||||
}
|
||||
|
||||
pub async fn send_webhook_message(&self, message: &str) -> Result<serde_json::Value> {
|
||||
let mut req = self
|
||||
.client
|
||||
.post(format!("{}/webhook", self.base_url))
|
||||
.json(&serde_json::json!({ "message": message }));
|
||||
if let Some(auth) = self.auth_header() {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
let resp = req.send().await.context("webhook request failed")?;
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_creation_no_token() {
|
||||
let client = GatewayClient::new("http://127.0.0.1:42617", None);
|
||||
assert_eq!(client.base_url, "http://127.0.0.1:42617");
|
||||
assert!(client.token.is_none());
|
||||
assert!(client.auth_header().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_creation_with_token() {
|
||||
let client = GatewayClient::new("http://localhost:8080", Some("test-token"));
|
||||
assert_eq!(client.base_url, "http://localhost:8080");
|
||||
assert_eq!(client.token.as_deref(), Some("test-token"));
|
||||
assert_eq!(client.auth_header().unwrap(), "Bearer test-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_custom_url() {
|
||||
let client = GatewayClient::new("https://zeroclaw.example.com:9999", None);
|
||||
assert_eq!(client.base_url, "https://zeroclaw.example.com:9999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_header_format() {
|
||||
let client = GatewayClient::new("http://localhost", Some("zc_abc123"));
|
||||
assert_eq!(client.auth_header().unwrap(), "Bearer zc_abc123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_returns_false_for_unreachable_host() {
|
||||
// Connect to a port that should not be listening.
|
||||
let client = GatewayClient::new("http://127.0.0.1:1", None);
|
||||
let result = client.get_health().await.unwrap();
|
||||
assert!(!result, "health should be false for unreachable host");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_fails_for_unreachable_host() {
|
||||
let client = GatewayClient::new("http://127.0.0.1:1", None);
|
||||
let result = client.get_status().await;
|
||||
assert!(result.is_err(), "status should fail for unreachable host");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn devices_fails_for_unreachable_host() {
|
||||
let client = GatewayClient::new("http://127.0.0.1:1", None);
|
||||
let result = client.get_devices().await;
|
||||
assert!(result.is_err(), "devices should fail for unreachable host");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pairing_fails_for_unreachable_host() {
|
||||
let client = GatewayClient::new("http://127.0.0.1:1", None);
|
||||
let result = client.initiate_pairing().await;
|
||||
assert!(result.is_err(), "pairing should fail for unreachable host");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_fails_for_unreachable_host() {
|
||||
let client = GatewayClient::new("http://127.0.0.1:1", None);
|
||||
let result = client.send_webhook_message("hello").await;
|
||||
assert!(result.is_err(), "webhook should fail for unreachable host");
|
||||
}
|
||||
}
|
||||
40
third_party/zeroclaw/apps/tauri/src/health.rs
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Background health polling for the ZeroClaw gateway.
|
||||
|
||||
use crate::gateway_client::GatewayClient;
|
||||
use crate::state::SharedState;
|
||||
use crate::tray::icon;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Spawn a background task that polls gateway health and updates state + tray.
|
||||
pub fn spawn_health_poller<R: Runtime>(app: AppHandle<R>, state: SharedState) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
let (url, token) = {
|
||||
let s = state.read().await;
|
||||
(s.gateway_url.clone(), s.token.clone())
|
||||
};
|
||||
|
||||
let client = GatewayClient::new(&url, token.as_deref());
|
||||
let healthy = client.get_health().await.unwrap_or(false);
|
||||
|
||||
let (connected, agent_status) = {
|
||||
let mut s = state.write().await;
|
||||
s.connected = healthy;
|
||||
(s.connected, s.agent_status)
|
||||
};
|
||||
|
||||
// Update the tray icon and tooltip to reflect current state.
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
let _ = tray.set_icon(Some(icon::icon_for_state(connected, agent_status)));
|
||||
let _ = tray.set_tooltip(Some(icon::tooltip_for_state(connected, agent_status)));
|
||||
}
|
||||
|
||||
let _ = app.emit("zeroclaw://status-changed", healthy);
|
||||
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
136
third_party/zeroclaw/apps/tauri/src/lib.rs
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
//! ZeroClaw Desktop — Tauri application library.
|
||||
|
||||
pub mod commands;
|
||||
pub mod gateway_client;
|
||||
pub mod health;
|
||||
pub mod state;
|
||||
pub mod tray;
|
||||
|
||||
use gateway_client::GatewayClient;
|
||||
use state::shared_state;
|
||||
use tauri::{Manager, RunEvent};
|
||||
|
||||
/// Attempt to auto-pair with the gateway so the WebView has a valid token
|
||||
/// before the React frontend mounts. Runs on localhost so the admin endpoints
|
||||
/// are accessible without auth.
|
||||
async fn auto_pair(state: &state::SharedState) -> Option<String> {
|
||||
let url = {
|
||||
let s = state.read().await;
|
||||
s.gateway_url.clone()
|
||||
};
|
||||
|
||||
let client = GatewayClient::new(&url, None);
|
||||
|
||||
// Check if gateway is reachable and requires pairing.
|
||||
if !client.requires_pairing().await.unwrap_or(false) {
|
||||
return None; // Pairing disabled — no token needed.
|
||||
}
|
||||
|
||||
// Check if we already have a valid token in state.
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Some(ref token) = s.token {
|
||||
let authed = GatewayClient::new(&url, Some(token));
|
||||
if authed.validate_token().await.unwrap_or(false) {
|
||||
return Some(token.clone()); // Existing token is valid.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid token — auto-pair by requesting a new code and exchanging it.
|
||||
let client = GatewayClient::new(&url, None);
|
||||
match client.auto_pair().await {
|
||||
Ok(token) => {
|
||||
let mut s = state.write().await;
|
||||
s.token = Some(token.clone());
|
||||
Some(token)
|
||||
}
|
||||
Err(_) => None, // Gateway may not be ready yet; health poller will retry.
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a bearer token into the WebView's localStorage so the React app
|
||||
/// skips the pairing dialog. Uses Tauri's WebviewWindow scripting API.
|
||||
fn inject_token_into_webview<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>, token: &str) {
|
||||
let escaped = token.replace('\\', "\\\\").replace('\'', "\\'");
|
||||
let script = format!("localStorage.setItem('zeroclaw_token', '{escaped}')");
|
||||
// WebviewWindow scripting is the standard Tauri API for running JS in the WebView.
|
||||
let _ = window.eval(&script);
|
||||
}
|
||||
|
||||
/// Set the macOS dock icon programmatically so it shows even in dev builds
|
||||
/// (which don't have a proper .app bundle).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_dock_icon() {
|
||||
use objc2::{AnyThread, MainThreadMarker};
|
||||
use objc2_app_kit::NSApplication;
|
||||
use objc2_app_kit::NSImage;
|
||||
use objc2_foundation::NSData;
|
||||
|
||||
let icon_bytes = include_bytes!("../icons/128x128.png");
|
||||
// Safety: setup() runs on the main thread in Tauri.
|
||||
let mtm = unsafe { MainThreadMarker::new_unchecked() };
|
||||
let data = NSData::with_bytes(icon_bytes);
|
||||
if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) {
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
unsafe { app.setApplicationIconImage(Some(&image)) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure and run the Tauri application.
|
||||
pub fn run() {
|
||||
let shared = shared_state();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
// When a second instance launches, focus the existing window.
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.manage(shared.clone())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::gateway::get_status,
|
||||
commands::gateway::get_health,
|
||||
commands::channels::list_channels,
|
||||
commands::pairing::initiate_pairing,
|
||||
commands::pairing::get_devices,
|
||||
commands::agent::send_message,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// Set macOS dock icon (needed for dev builds without .app bundle).
|
||||
#[cfg(target_os = "macos")]
|
||||
set_dock_icon();
|
||||
|
||||
// Set up the system tray.
|
||||
let _ = tray::setup_tray(app);
|
||||
|
||||
// Auto-pair with gateway and inject token into the WebView.
|
||||
let app_handle = app.handle().clone();
|
||||
let pair_state = shared.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(token) = auto_pair(&pair_state).await {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
inject_token_into_webview(&window, &token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start background health polling.
|
||||
health::spawn_health_poller(app.handle().clone(), shared.clone());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|_app, event| {
|
||||
// Keep the app running in the background when all windows are closed.
|
||||
// This is the standard pattern for menu bar / tray apps.
|
||||
if let RunEvent::ExitRequested { api, .. } = event {
|
||||
api.prevent_exit();
|
||||
}
|
||||
});
|
||||
}
|
||||
8
third_party/zeroclaw/apps/tauri/src/main.rs
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
//! ZeroClaw Desktop — main entry point.
|
||||
//!
|
||||
//! Prevents an additional console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
zeroclaw_desktop::run();
|
||||
}
|
||||
6
third_party/zeroclaw/apps/tauri/src/mobile.rs
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
//! Mobile entry point for ZeroClaw Desktop (iOS/Android).
|
||||
|
||||
#[tauri::mobile_entry_point]
|
||||
fn main() {
|
||||
zeroclaw_desktop::run();
|
||||
}
|
||||
99
third_party/zeroclaw/apps/tauri/src/state.rs
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
//! Shared application state for Tauri.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Agent status as reported by the gateway.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentStatus {
|
||||
Idle,
|
||||
Working,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Shared application state behind an `Arc<RwLock<_>>`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
pub gateway_url: String,
|
||||
pub token: Option<String>,
|
||||
pub connected: bool,
|
||||
pub agent_status: AgentStatus,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gateway_url: "http://127.0.0.1:42617".to_string(),
|
||||
token: None,
|
||||
connected: false,
|
||||
agent_status: AgentStatus::Idle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper around `AppState`.
|
||||
pub type SharedState = Arc<RwLock<AppState>>;
|
||||
|
||||
/// Create the default shared state.
|
||||
pub fn shared_state() -> SharedState {
|
||||
Arc::new(RwLock::new(AppState::default()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_state() {
|
||||
let state = AppState::default();
|
||||
assert_eq!(state.gateway_url, "http://127.0.0.1:42617");
|
||||
assert!(state.token.is_none());
|
||||
assert!(!state.connected);
|
||||
assert_eq!(state.agent_status, AgentStatus::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_state_is_cloneable() {
|
||||
let s1 = shared_state();
|
||||
let s2 = s1.clone();
|
||||
// Both references point to the same allocation.
|
||||
assert!(Arc::ptr_eq(&s1, &s2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_state_concurrent_read_write() {
|
||||
let state = shared_state();
|
||||
|
||||
// Write from one handle.
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
s.connected = true;
|
||||
s.agent_status = AgentStatus::Working;
|
||||
s.token = Some("zc_test".to_string());
|
||||
}
|
||||
|
||||
// Read from cloned handle.
|
||||
let state2 = state.clone();
|
||||
let s = state2.read().await;
|
||||
assert!(s.connected);
|
||||
assert_eq!(s.agent_status, AgentStatus::Working);
|
||||
assert_eq!(s.token.as_deref(), Some("zc_test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_status_serialization() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AgentStatus::Idle).unwrap(),
|
||||
"\"idle\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AgentStatus::Working).unwrap(),
|
||||
"\"working\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AgentStatus::Error).unwrap(),
|
||||
"\"error\""
|
||||
);
|
||||
}
|
||||
}
|
||||
25
third_party/zeroclaw/apps/tauri/src/tray/events.rs
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
//! Tray menu event handling.
|
||||
|
||||
use tauri::{menu::MenuEvent, AppHandle, Manager, Runtime};
|
||||
|
||||
pub fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, event: MenuEvent) {
|
||||
match event.id().as_ref() {
|
||||
"show" => show_main_window(app, None),
|
||||
"chat" => show_main_window(app, Some("/agent")),
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main_window<R: Runtime>(app: &AppHandle<R>, navigate_to: Option<&str>) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
if let Some(path) = navigate_to {
|
||||
let script = format!("window.location.hash = '{path}'");
|
||||
let _ = window.eval(&script);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
third_party/zeroclaw/apps/tauri/src/tray/icon.rs
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Tray icon management — swap icon based on connection/agent status.
|
||||
|
||||
use crate::state::AgentStatus;
|
||||
use tauri::image::Image;
|
||||
|
||||
/// Embedded tray icon PNGs (22x22, RGBA).
|
||||
const ICON_IDLE: &[u8] = include_bytes!("../../icons/tray-idle.png");
|
||||
const ICON_WORKING: &[u8] = include_bytes!("../../icons/tray-working.png");
|
||||
const ICON_ERROR: &[u8] = include_bytes!("../../icons/tray-error.png");
|
||||
const ICON_DISCONNECTED: &[u8] = include_bytes!("../../icons/tray-disconnected.png");
|
||||
|
||||
/// Select the appropriate tray icon for the current state.
|
||||
pub fn icon_for_state(connected: bool, status: AgentStatus) -> Image<'static> {
|
||||
let bytes: &[u8] = if !connected {
|
||||
ICON_DISCONNECTED
|
||||
} else {
|
||||
match status {
|
||||
AgentStatus::Idle => ICON_IDLE,
|
||||
AgentStatus::Working => ICON_WORKING,
|
||||
AgentStatus::Error => ICON_ERROR,
|
||||
}
|
||||
};
|
||||
Image::from_bytes(bytes).expect("embedded tray icon is a valid PNG")
|
||||
}
|
||||
|
||||
/// Tooltip text for the current state.
|
||||
pub fn tooltip_for_state(connected: bool, status: AgentStatus) -> &'static str {
|
||||
if !connected {
|
||||
return "ZeroClaw — Disconnected";
|
||||
}
|
||||
match status {
|
||||
AgentStatus::Idle => "ZeroClaw — Idle",
|
||||
AgentStatus::Working => "ZeroClaw — Working",
|
||||
AgentStatus::Error => "ZeroClaw — Error",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn icon_disconnected_when_not_connected() {
|
||||
// Should not panic — icon bytes are valid PNGs.
|
||||
let _img = icon_for_state(false, AgentStatus::Idle);
|
||||
let _img = icon_for_state(false, AgentStatus::Working);
|
||||
let _img = icon_for_state(false, AgentStatus::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_connected_variants() {
|
||||
let _idle = icon_for_state(true, AgentStatus::Idle);
|
||||
let _working = icon_for_state(true, AgentStatus::Working);
|
||||
let _error = icon_for_state(true, AgentStatus::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_disconnected() {
|
||||
assert_eq!(
|
||||
tooltip_for_state(false, AgentStatus::Idle),
|
||||
"ZeroClaw — Disconnected"
|
||||
);
|
||||
// Agent status is irrelevant when disconnected.
|
||||
assert_eq!(
|
||||
tooltip_for_state(false, AgentStatus::Working),
|
||||
"ZeroClaw — Disconnected"
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for_state(false, AgentStatus::Error),
|
||||
"ZeroClaw — Disconnected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tooltip_connected_variants() {
|
||||
assert_eq!(
|
||||
tooltip_for_state(true, AgentStatus::Idle),
|
||||
"ZeroClaw — Idle"
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for_state(true, AgentStatus::Working),
|
||||
"ZeroClaw — Working"
|
||||
);
|
||||
assert_eq!(
|
||||
tooltip_for_state(true, AgentStatus::Error),
|
||||
"ZeroClaw — Error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_icons_are_valid_png() {
|
||||
// Verify the PNG signature (first 8 bytes) of each embedded icon.
|
||||
let png_sig: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
assert!(ICON_IDLE.starts_with(png_sig), "idle icon not valid PNG");
|
||||
assert!(
|
||||
ICON_WORKING.starts_with(png_sig),
|
||||
"working icon not valid PNG"
|
||||
);
|
||||
assert!(ICON_ERROR.starts_with(png_sig), "error icon not valid PNG");
|
||||
assert!(
|
||||
ICON_DISCONNECTED.starts_with(png_sig),
|
||||
"disconnected icon not valid PNG"
|
||||
);
|
||||
}
|
||||
}
|
||||
19
third_party/zeroclaw/apps/tauri/src/tray/menu.rs
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Tray menu construction.
|
||||
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItemBuilder, PredefinedMenuItem},
|
||||
App, Runtime,
|
||||
};
|
||||
|
||||
pub fn create_tray_menu<R: Runtime>(app: &App<R>) -> Result<Menu<R>, tauri::Error> {
|
||||
let show = MenuItemBuilder::with_id("show", "Show Dashboard").build(app)?;
|
||||
let chat = MenuItemBuilder::with_id("chat", "Agent Chat").build(app)?;
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
let status = MenuItemBuilder::with_id("status", "Status: Checking...")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit ZeroClaw").build(app)?;
|
||||
|
||||
Menu::with_items(app, &[&show, &chat, &sep1, &status, &sep2, &quit])
|
||||
}
|
||||
34
third_party/zeroclaw/apps/tauri/src/tray/mod.rs
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
//! System tray integration for ZeroClaw Desktop.
|
||||
|
||||
pub mod events;
|
||||
pub mod icon;
|
||||
pub mod menu;
|
||||
|
||||
use tauri::{
|
||||
tray::{TrayIcon, TrayIconBuilder, TrayIconEvent},
|
||||
App, Manager, Runtime,
|
||||
};
|
||||
|
||||
/// Set up the system tray icon and menu.
|
||||
pub fn setup_tray<R: Runtime>(app: &App<R>) -> Result<TrayIcon<R>, tauri::Error> {
|
||||
let menu = menu::create_tray_menu(app)?;
|
||||
|
||||
TrayIconBuilder::with_id("main")
|
||||
.tooltip("ZeroClaw — Disconnected")
|
||||
.icon(icon::icon_for_state(false, crate::state::AgentStatus::Idle))
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(events::handle_menu_event)
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click { button, .. } = event {
|
||||
if button == tauri::tray::MouseButton::Left {
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
}
|
||||
35
third_party/zeroclaw/apps/tauri/tauri.conf.json
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/config.schema.json",
|
||||
"productName": "ZeroClaw",
|
||||
"version": "0.6.3",
|
||||
"identifier": "ai.zeroclawlabs.desktop",
|
||||
"build": {
|
||||
"devUrl": "http://127.0.0.1:42617/_app/",
|
||||
"frontendDist": "http://127.0.0.1:42617/_app/"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ZeroClaw",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self' 'unsafe-inline' http://127.0.0.1:*; style-src 'self' 'unsafe-inline' http://127.0.0.1:*; img-src 'self' http://127.0.0.1:* data:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||