app_lib/
lib.rs

1//! Wactorz desktop entry point.
2//!
3//! Boots the full Rust backend (actor system + REST/WS server) in the same
4//! process as the Tauri WebView, then opens a window that talks to it over
5//! localhost.  The backend port is injected into the page as
6//! `window.__WACTORZ_API_PORT` before any JavaScript runs.
7//!
8//! ## Config loading order (highest priority first)
9//! 1. `<app-config-dir>/config.json`  — persisted via the settings panel
10//! 2. `.env` file in the working directory
11//! 3. Environment variables
12//! 4. Compiled-in defaults
13
14use std::net::SocketAddr;
15use std::sync::{Arc, Mutex};
16
17use anyhow::Result;
18use serde::{Deserialize, Serialize};
19use tauri::{Manager};
20use wactorz_agents::{LlmConfig, LlmProvider, MainActor};
21use wactorz_core::{ActorConfig, ActorSystem, EventPublisher, Supervisor, SupervisorStrategy};
22use wactorz_interfaces::ws::WsEnvelope;
23use wactorz_interfaces::{RestServer, RuntimeConfig, WsBridge};
24use wactorz_mqtt::{MqttClient, MqttConfig};
25const DEFAULT_PORT: u16 = 8888;
26
27// ── App config ────────────────────────────────────────────────────────────────
28
29#[derive(Clone, Serialize, Deserialize)]
30#[serde(default)]
31pub struct AppConfig {
32    pub api_port: u16,
33    pub llm_provider: String,
34    pub llm_model: String,
35    pub llm_api_key: String,
36    pub mqtt_host: String,
37    pub mqtt_port: u16,
38    pub mqtt_ws_port: u16,
39    pub ha_url: String,
40    pub ha_token: String,
41    pub static_dir: String,
42}
43
44impl Default for AppConfig {
45    fn default() -> Self {
46        Self {
47            api_port: env_u16("API_PORT", DEFAULT_PORT),
48            llm_provider: env_str("LLM_PROVIDER", "anthropic"),
49            llm_model: env_str("LLM_MODEL", "claude-sonnet-4-6"),
50            llm_api_key: env_str("LLM_API_KEY", ""),
51            mqtt_host: env_str("MQTT_HOST", "localhost"),
52            mqtt_port: env_u16("MQTT_PORT", 1883),
53            mqtt_ws_port: env_u16("MQTT_WS_PORT", 9001),
54            ha_url: env_str("HA_URL", ""),
55            ha_token: env_str("HA_TOKEN", ""),
56            static_dir: env_str("STATIC_DIR", "static/app"),
57        }
58    }
59}
60
61fn env_str(key: &str, default: &str) -> String {
62    std::env::var(key).unwrap_or_else(|_| default.to_owned())
63}
64
65fn env_u16(key: &str, default: u16) -> u16 {
66    std::env::var(key)
67        .ok()
68        .and_then(|v| v.parse().ok())
69        .unwrap_or(default)
70}
71
72fn config_path(app: &tauri::AppHandle) -> std::path::PathBuf {
73    app.path()
74        .app_config_dir()
75        .unwrap_or_else(|_| std::path::PathBuf::from("."))
76        .join("config.json")
77}
78
79/// Load config: saved JSON wins over env/defaults for every stored key.
80fn load_config(app: &tauri::AppHandle) -> AppConfig {
81    let path = config_path(app);
82    if let Ok(bytes) = std::fs::read(&path)
83        && let Ok(saved) = serde_json::from_slice::<AppConfig>(&bytes) {
84            tracing::info!("Loaded config from {}", path.display());
85            return saved;
86        }
87    tracing::info!(
88        "Using default/env config (no saved config at {})",
89        path.display()
90    );
91    AppConfig::default()
92}
93
94// ── Tauri state ───────────────────────────────────────────────────────────────
95
96struct ConfigState(Mutex<AppConfig>);
97struct BadgeState(Mutex<u32>);
98
99// ── Tauri commands ────────────────────────────────────────────────────────────
100
101/// Send a native OS notification. Called from JS via invoke('notify', ...).
102#[tauri::command]
103fn notify(app: tauri::AppHandle, title: String, body: String) {
104    use tauri_plugin_notification::NotificationExt;
105    let _ = app.notification().builder().title(title).body(body).show();
106}
107
108/// Increment the unread badge on the tray icon. Called from JS after a notification fires.
109#[tauri::command]
110fn add_unread(app: tauri::AppHandle, badge: tauri::State<BadgeState>) {
111    let count = {
112        let mut n = badge.0.lock().unwrap();
113        *n += 1;
114        *n
115    };
116    update_tray_tooltip(&app, count);
117}
118
119/// Clear the unread badge. Called from JS when the window gains focus.
120#[tauri::command]
121fn clear_unread(app: tauri::AppHandle, badge: tauri::State<BadgeState>) {
122    *badge.0.lock().unwrap() = 0;
123    update_tray_tooltip(&app, 0);
124}
125
126fn update_tray_tooltip(app: &tauri::AppHandle, count: u32) {
127    if let Some(tray) = app.tray_by_id("main") {
128        let tip = if count == 0 {
129            "Wactorz".to_string()
130        } else {
131            format!("Wactorz · {count} unread")
132        };
133        let _ = tray.set_tooltip(Some(&tip));
134    }
135}
136
137#[tauri::command]
138fn get_api_port(state: tauri::State<ConfigState>) -> u16 {
139    state.0.lock().unwrap().api_port
140}
141
142#[tauri::command]
143fn get_config(state: tauri::State<ConfigState>) -> AppConfig {
144    state.0.lock().unwrap().clone()
145}
146
147#[tauri::command]
148fn save_config(
149    config: AppConfig,
150    state: tauri::State<ConfigState>,
151    app: tauri::AppHandle,
152) -> Result<(), String> {
153    let path = config_path(&app);
154    if let Some(parent) = path.parent() {
155        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
156    }
157    let json = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
158    std::fs::write(&path, json).map_err(|e| e.to_string())?;
159    *state.0.lock().unwrap() = config;
160    tracing::info!("Config saved to {}", path.display());
161    Ok(())
162}
163// ── Tray ──────────────────────────────────────────────────────────────────────
164
165fn build_tray(app: &tauri::App) -> tauri::Result<()> {
166    use tauri::{
167        menu::{MenuBuilder, MenuItemBuilder},
168        tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
169    };
170
171    let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
172    let quit = MenuItemBuilder::with_id("quit", "Quit Wactorz").build(app)?;
173    let menu = MenuBuilder::new(app)
174        .items(&[&show_hide, &quit])
175        .build()?;
176
177    TrayIconBuilder::with_id("main")
178        .icon(app.default_window_icon().unwrap().clone())
179        .tooltip("Wactorz")
180        .menu(&menu)
181        .on_menu_event(|app, event| match event.id().as_ref() {
182            "show_hide" => toggle_window(app),
183            "quit" => app.exit(0),
184            _ => {}
185        })
186        .on_tray_icon_event(|tray, event| {
187            if let TrayIconEvent::Click {
188                button: MouseButton::Left,
189                button_state: MouseButtonState::Up,
190                ..
191            } = event
192            {
193                toggle_window(tray.app_handle());
194            }
195        })
196        .build(app)?;
197
198    Ok(())
199}
200
201fn toggle_window(app: &tauri::AppHandle) {
202    if let Some(win) = app.get_webview_window("main") {
203        if win.is_visible().unwrap_or(false) {
204            let _ = win.hide();
205        } else {
206            let _ = win.unminimize();
207            let _ = win.show();
208            let _ = win.set_focus();
209        }
210    }
211}
212
213// ── Embedded backend ──────────────────────────────────────────────────────────
214
215async fn start_backend(cfg: AppConfig) -> Result<()> {
216    let (publisher, mut pub_rx) = EventPublisher::channel();
217    let system = ActorSystem::with_publisher(publisher.clone());
218
219    let mqtt_config = MqttConfig {
220        host: cfg.mqtt_host.clone(),
221        port: cfg.mqtt_port,
222        client_id: "wactorz-desktop".into(),
223        ..Default::default()
224    };
225    let (mqtt_client, mut event_loop) = MqttClient::new(mqtt_config)?;
226    let mqtt_client = Arc::new(mqtt_client);
227
228    let (ws_tx, _) = tokio::sync::broadcast::channel::<WsEnvelope>(100);
229    let ws_tx_mqtt = ws_tx.clone();
230
231    let reg_route = system.registry.clone();
232    let reg_qa = system.registry.clone();
233    let reg_wik = system.registry.clone();
234    let reg_switch = system.registry.clone();
235
236    tokio::spawn(async move {
237        MqttClient::run_event_loop(&mut event_loop, move |evt| {
238            if let wactorz_mqtt::MqttEvent::Incoming { topic, payload } = evt
239                && let Ok(json_val) = serde_json::from_slice::<serde_json::Value>(&payload) {
240                    let _ = ws_tx_mqtt.send(WsEnvelope {
241                        topic: topic.clone(),
242                        payload: json_val.clone(),
243                    });
244
245                    if topic == wactorz_mqtt::topics::SYSTEM_LLM_ERROR {
246                        let reg = reg_wik.clone();
247                        let s = serde_json::to_string(&json_val).unwrap_or_default();
248                        tokio::spawn(async move {
249                            if let Some(e) = reg.get_by_name("wik-agent").await {
250                                let _ = reg
251                                    .send(
252                                        &e.id,
253                                        wactorz_core::Message::text(
254                                            Some("system".into()),
255                                            Some(e.id.clone()),
256                                            s,
257                                        ),
258                                    )
259                                    .await;
260                            }
261                        });
262                    }
263
264                    if topic == wactorz_mqtt::topics::SYSTEM_LLM_SWITCH {
265                        let reg = reg_switch.clone();
266                        let p = json_val.clone();
267                        tokio::spawn(async move {
268                            if let Some(e) = reg.get_by_name("main-actor").await {
269                                let _ = reg
270                                    .send(
271                                        &e.id,
272                                        wactorz_core::Message::new(
273                                            Some("wik-agent".into()),
274                                            Some(e.id.clone()),
275                                            wactorz_core::MessageType::Task {
276                                                task_id: "wik/switch".into(),
277                                                description: "LLM provider switch".into(),
278                                                payload: p,
279                                            },
280                                        ),
281                                    )
282                                    .await;
283                            }
284                        });
285                    }
286
287                    if topic.ends_with("/chat") {
288                        let from = json_val.get("from").and_then(|v| v.as_str()).unwrap_or("");
289                        let content = json_val
290                            .get("content")
291                            .and_then(|v| v.as_str())
292                            .unwrap_or("")
293                            .to_string();
294
295                        if !content.is_empty() && (from == "user" || from.is_empty()) {
296                            if topic == wactorz_mqtt::topics::IO_CHAT {
297                                let reg = reg_route.clone();
298                                tokio::spawn(async move {
299                                    if let Some(e) = reg.get_by_name("io-agent").await {
300                                        let _ = reg
301                                            .send(
302                                                &e.id,
303                                                wactorz_core::Message::text(
304                                                    Some("user".into()),
305                                                    Some(e.id.clone()),
306                                                    content,
307                                                ),
308                                            )
309                                            .await;
310                                    }
311                                });
312                            } else if let Some(id) = topic
313                                .strip_prefix("agents/")
314                                .and_then(|s| s.strip_suffix("/chat"))
315                            {
316                                let reg = reg_route.clone();
317                                let id = id.to_string();
318                                tokio::spawn(async move {
319                                    let _ = reg
320                                        .send(
321                                            &id,
322                                            wactorz_core::Message::text(
323                                                Some("user".into()),
324                                                Some(id.clone()),
325                                                content,
326                                            ),
327                                        )
328                                        .await;
329                                });
330                            }
331                        }
332
333                        let reg = reg_qa.clone();
334                        let s = serde_json::to_string(&json_val).unwrap_or_default();
335                        tokio::spawn(async move {
336                            if let Some(e) = reg.get_by_name("qa-agent").await {
337                                let _ = reg
338                                    .send(
339                                        &e.id,
340                                        wactorz_core::Message::text(
341                                            Some("mqtt-router".into()),
342                                            Some(e.id.clone()),
343                                            s,
344                                        ),
345                                    )
346                                    .await;
347                            }
348                        });
349                    }
350                }
351        })
352        .await;
353    });
354
355    for topic in ["agents/#", "system/#", "system/llm/#", "nodes/#"] {
356        if let Err(e) = mqtt_client.subscribe(topic).await {
357            tracing::warn!("MQTT subscribe {topic} failed (broker may not be running): {e}");
358        }
359    }
360    if let Err(e) = mqtt_client.subscribe(wactorz_mqtt::topics::IO_CHAT).await {
361        tracing::warn!("MQTT subscribe io/chat failed: {e}");
362    }
363
364    let mqtt_bridge = Arc::clone(&mqtt_client);
365    tokio::spawn(async move {
366        while let Some((topic, payload)) = pub_rx.recv().await {
367            if let Err(e) = mqtt_bridge.publish_raw(&topic, payload).await {
368                tracing::error!("MQTT publish error: {e}");
369            }
370        }
371    });
372
373    let llm_provider = match cfg.llm_provider.as_str() {
374        "openai" => LlmProvider::OpenAI,
375        "ollama" => LlmProvider::Ollama,
376        "gemini" => LlmProvider::Gemini,
377        "nim" => LlmProvider::Nim,
378        _ => LlmProvider::Anthropic,
379    };
380    let llm_config = LlmConfig {
381        provider: llm_provider,
382        model: cfg.llm_model.clone(),
383        api_key: Some(cfg.llm_api_key.clone()).filter(|s| !s.is_empty()),
384        ..Default::default()
385    };
386
387    let mut sup = Supervisor::new(system.clone());
388
389    {
390        let lc = llm_config.clone();
391        let sys = system.clone();
392        let pub_ = publisher.clone();
393        sup.supervise(
394            "main-actor",
395            Arc::new(move || {
396                Box::new(
397                    MainActor::new(
398                        ActorConfig::new_with_node("main-actor", "alpha").protected(),
399                        lc.clone(),
400                        sys.clone(),
401                    )
402                    .with_publisher(pub_.clone()),
403                )
404            }),
405            SupervisorStrategy::OneForOne,
406            10,
407            60.0,
408            2.0,
409        );
410    }
411
412    sup.start().await?;
413    tracing::info!(
414        "Wactorz desktop: all agents started, serving on port {}",
415        cfg.api_port
416    );
417
418    let addr: SocketAddr = format!("127.0.0.1:{}", cfg.api_port).parse()?;
419    let ws_bridge = WsBridge::new(
420        ws_tx,
421        mqtt_client,
422        system.clone(),
423        cfg.mqtt_host.clone(),
424        cfg.mqtt_ws_port,
425    );
426    RestServer::new(
427        system,
428        addr,
429        RuntimeConfig {
430            ha_url: cfg.ha_url,
431            ha_token: cfg.ha_token,
432            mqtt_host: cfg.mqtt_host,
433            mqtt_port: cfg.mqtt_port,
434            mqtt_ws_port: cfg.mqtt_ws_port,
435            llm_provider: cfg.llm_provider,
436            llm_model: cfg.llm_model,
437            ..Default::default()
438        },
439        cfg.static_dir,
440    )
441    .with_ws(ws_bridge.router())
442    .serve()
443    .await?;
444
445    Ok(())
446}
447
448// ── Tauri entry point ─────────────────────────────────────────────────────────
449
450#[cfg_attr(mobile, tauri::mobile_entry_point)]
451pub fn run() {
452    let _ = dotenvy::dotenv();
453
454    tauri::Builder::default()
455        .setup(|app| {
456            let cfg = load_config(app.handle());
457            let port = cfg.api_port;
458
459            app.manage(ConfigState(Mutex::new(cfg.clone())));
460            app.manage(BadgeState(Mutex::new(0)));
461
462            tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
463                .title("Wactorz")
464                .inner_size(1400.0, 900.0)
465                .min_inner_size(900.0, 600.0)
466                .resizable(true)
467                .center()
468                .initialization_script(format!("window.__WACTORZ_API_PORT={port};"))
469                .build()?;
470
471            build_tray(app)?;
472
473            tauri::async_runtime::spawn(async move {
474                if let Err(e) = start_backend(cfg).await {
475                    tracing::error!("Embedded backend exited: {e}");
476                }
477            });
478
479            if cfg!(debug_assertions) {
480                app.handle().plugin(
481                    tauri_plugin_log::Builder::default()
482                        .level(log::LevelFilter::Info)
483                        .build(),
484                )?;
485            }
486            Ok(())
487        })
488        .plugin(tauri_plugin_notification::init())
489        .invoke_handler(tauri::generate_handler![
490            get_api_port,
491            get_config,
492            save_config,
493            notify,
494            add_unread,
495            clear_unread
496        ])
497        .run(tauri::generate_context!())
498        .expect("error while running tauri application");
499}