初始化提交
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
This commit is contained in:
38
crates/openfang-desktop/Cargo.toml
Normal file
38
crates/openfang-desktop/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "openfang-desktop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Native desktop application for the OpenFang Agent OS (Tauri 2.0)"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
openfang-kernel = { path = "../openfang-kernel" }
|
||||
openfang-api = { path = "../openfang-api" }
|
||||
openfang-types = { path = "../openfang-types" }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
open = "5"
|
||||
|
||||
[features]
|
||||
# Tauri uses custom-protocol in production builds
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[[bin]]
|
||||
name = "openfang-desktop"
|
||||
path = "src/main.rs"
|
||||
3
crates/openfang-desktop/build.rs
Normal file
3
crates/openfang-desktop/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
17
crates/openfang-desktop/capabilities/default.json
Normal file
17
crates/openfang-desktop/capabilities/default.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/tauri/refs/heads/dev/crates/tauri-utils/schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for the OpenFang desktop app",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"notification:default",
|
||||
"shell:default",
|
||||
"dialog:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"global-shortcut:allow-is-registered",
|
||||
"autostart:default",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
1
crates/openfang-desktop/gen/schemas/acl-manifests.json
Normal file
1
crates/openfang-desktop/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
crates/openfang-desktop/gen/schemas/capabilities.json
Normal file
1
crates/openfang-desktop/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default permissions for the OpenFang desktop app","local":true,"windows":["main"],"permissions":["core:default","notification:default","shell:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","global-shortcut:allow-is-registered","autostart:default","updater:default"]}}
|
||||
2990
crates/openfang-desktop/gen/schemas/desktop-schema.json
Normal file
2990
crates/openfang-desktop/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2990
crates/openfang-desktop/gen/schemas/windows-schema.json
Normal file
2990
crates/openfang-desktop/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
crates/openfang-desktop/icons/128x128.png
Normal file
BIN
crates/openfang-desktop/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
BIN
crates/openfang-desktop/icons/128x128@2x.png
Normal file
BIN
crates/openfang-desktop/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
crates/openfang-desktop/icons/32x32.png
Normal file
BIN
crates/openfang-desktop/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
crates/openfang-desktop/icons/icon.ico
Normal file
BIN
crates/openfang-desktop/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
crates/openfang-desktop/icons/icon.png
Normal file
BIN
crates/openfang-desktop/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
171
crates/openfang-desktop/src/commands.rs
Normal file
171
crates/openfang-desktop/src/commands.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
//! Tauri IPC command handlers.
|
||||
|
||||
use crate::{KernelState, PortState};
|
||||
use openfang_kernel::config::openfang_home;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tracing::info;
|
||||
|
||||
/// Get the port the embedded server is listening on.
|
||||
#[tauri::command]
|
||||
pub fn get_port(port: tauri::State<'_, PortState>) -> u16 {
|
||||
port.0
|
||||
}
|
||||
|
||||
/// Get a status summary of the running kernel.
|
||||
#[tauri::command]
|
||||
pub fn get_status(
|
||||
port: tauri::State<'_, PortState>,
|
||||
kernel_state: tauri::State<'_, KernelState>,
|
||||
) -> serde_json::Value {
|
||||
let agents = kernel_state.kernel.registry.list().len();
|
||||
let uptime_secs = kernel_state.started_at.elapsed().as_secs();
|
||||
|
||||
serde_json::json!({
|
||||
"status": "running",
|
||||
"port": port.0,
|
||||
"agents": agents,
|
||||
"uptime_secs": uptime_secs,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the number of registered agents.
|
||||
#[tauri::command]
|
||||
pub fn get_agent_count(kernel_state: tauri::State<'_, KernelState>) -> usize {
|
||||
kernel_state.kernel.registry.list().len()
|
||||
}
|
||||
|
||||
/// Open a native file picker to import an agent TOML manifest.
|
||||
///
|
||||
/// Validates the TOML as a valid `AgentManifest`, copies it to
|
||||
/// `~/.openfang/agents/{name}/agent.toml`, then spawns the agent.
|
||||
#[tauri::command]
|
||||
pub fn import_agent_toml(
|
||||
app: tauri::AppHandle,
|
||||
kernel_state: tauri::State<'_, KernelState>,
|
||||
) -> Result<String, String> {
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Import Agent Manifest")
|
||||
.add_filter("TOML files", &["toml"])
|
||||
.blocking_pick_file();
|
||||
|
||||
let file_path = match path {
|
||||
Some(p) => p,
|
||||
None => return Err("No file selected".to_string()),
|
||||
};
|
||||
|
||||
let content = std::fs::read_to_string(file_path.as_path().ok_or("Invalid file path")?)
|
||||
.map_err(|e| format!("Failed to read file: {e}"))?;
|
||||
|
||||
let manifest: openfang_types::agent::AgentManifest =
|
||||
toml::from_str(&content).map_err(|e| format!("Invalid agent manifest: {e}"))?;
|
||||
|
||||
let agent_name = manifest.name.clone();
|
||||
let agent_dir = openfang_home().join("agents").join(&agent_name);
|
||||
std::fs::create_dir_all(&agent_dir)
|
||||
.map_err(|e| format!("Failed to create agent directory: {e}"))?;
|
||||
|
||||
let dest = agent_dir.join("agent.toml");
|
||||
std::fs::write(&dest, &content).map_err(|e| format!("Failed to write manifest: {e}"))?;
|
||||
|
||||
kernel_state
|
||||
.kernel
|
||||
.spawn_agent(manifest)
|
||||
.map_err(|e| format!("Failed to spawn agent: {e}"))?;
|
||||
|
||||
info!("Imported and spawned agent \"{agent_name}\"");
|
||||
Ok(agent_name)
|
||||
}
|
||||
|
||||
/// Open a native file picker to import a skill file.
|
||||
///
|
||||
/// Copies the selected file to `~/.openfang/skills/` and triggers a
|
||||
/// hot-reload of the skill registry.
|
||||
#[tauri::command]
|
||||
pub fn import_skill_file(
|
||||
app: tauri::AppHandle,
|
||||
kernel_state: tauri::State<'_, KernelState>,
|
||||
) -> Result<String, String> {
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("Import Skill File")
|
||||
.add_filter("Skill files", &["md", "toml", "py", "js", "wasm"])
|
||||
.blocking_pick_file();
|
||||
|
||||
let file_path = match path {
|
||||
Some(p) => p,
|
||||
None => return Err("No file selected".to_string()),
|
||||
};
|
||||
|
||||
let src = file_path.as_path().ok_or("Invalid file path")?;
|
||||
let file_name = src
|
||||
.file_name()
|
||||
.ok_or("No filename")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let skills_dir = openfang_home().join("skills");
|
||||
std::fs::create_dir_all(&skills_dir)
|
||||
.map_err(|e| format!("Failed to create skills directory: {e}"))?;
|
||||
|
||||
let dest = skills_dir.join(&file_name);
|
||||
std::fs::copy(src, &dest).map_err(|e| format!("Failed to copy skill file: {e}"))?;
|
||||
|
||||
kernel_state.kernel.reload_skills();
|
||||
|
||||
info!("Imported skill file \"{file_name}\" and reloaded registry");
|
||||
Ok(file_name)
|
||||
}
|
||||
|
||||
/// Check whether auto-start on login is enabled.
|
||||
#[tauri::command]
|
||||
pub fn get_autostart(app: tauri::AppHandle) -> Result<bool, String> {
|
||||
app.autolaunch().is_enabled().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Enable or disable auto-start on login.
|
||||
#[tauri::command]
|
||||
pub fn set_autostart(app: tauri::AppHandle, enabled: bool) -> Result<bool, String> {
|
||||
let manager = app.autolaunch();
|
||||
if enabled {
|
||||
manager.enable().map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
manager.disable().map_err(|e| e.to_string())?;
|
||||
}
|
||||
manager.is_enabled().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Perform an on-demand update check.
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<crate::updater::UpdateInfo, String> {
|
||||
crate::updater::check_for_update(&app).await
|
||||
}
|
||||
|
||||
/// Download and install the latest update, then restart the app.
|
||||
/// Returns Ok(()) which triggers an app restart — the command will not return
|
||||
/// if the update succeeds (the app restarts). On error, returns Err(message).
|
||||
#[tauri::command]
|
||||
pub async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
|
||||
crate::updater::download_and_install_update(&app).await
|
||||
}
|
||||
|
||||
/// Open the OpenFang config directory (`~/.openfang/`) in the OS file manager.
|
||||
#[tauri::command]
|
||||
pub fn open_config_dir() -> Result<(), String> {
|
||||
let dir = openfang_home();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
open::that(&dir).map_err(|e| format!("Failed to open directory: {e}"))
|
||||
}
|
||||
|
||||
/// Open the OpenFang logs directory (`~/.openfang/logs/`) in the OS file manager.
|
||||
#[tauri::command]
|
||||
pub fn open_logs_dir() -> Result<(), String> {
|
||||
let dir = openfang_home().join("logs");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create logs dir: {e}"))?;
|
||||
open::that(&dir).map_err(|e| format!("Failed to open directory: {e}"))
|
||||
}
|
||||
211
crates/openfang-desktop/src/lib.rs
Normal file
211
crates/openfang-desktop/src/lib.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
//! OpenFang Desktop — Native Tauri 2.0 wrapper for the OpenFang Agent OS.
|
||||
//!
|
||||
//! Boots the kernel + embedded API server, then opens a native window pointing
|
||||
//! at the WebUI. Includes system tray, single-instance enforcement, native OS
|
||||
//! notifications, global shortcuts, auto-start, and update checking.
|
||||
|
||||
mod commands;
|
||||
mod server;
|
||||
mod shortcuts;
|
||||
mod tray;
|
||||
mod updater;
|
||||
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_types::event::{EventPayload, LifecycleEvent, SystemEvent};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Managed state: the port the embedded server listens on.
|
||||
pub struct PortState(pub u16);
|
||||
|
||||
/// Managed state: the kernel instance and startup time.
|
||||
pub struct KernelState {
|
||||
pub kernel: Arc<OpenFangKernel>,
|
||||
pub started_at: Instant,
|
||||
}
|
||||
|
||||
/// Entry point for the Tauri application.
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Init tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "openfang=info,tauri=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
info!("Starting OpenFang Desktop...");
|
||||
|
||||
// Boot kernel + embedded server (blocks until port is known)
|
||||
let server_handle = server::start_server().expect("Failed to start OpenFang server");
|
||||
let port = server_handle.port;
|
||||
let kernel_for_notifications = server_handle.kernel.clone();
|
||||
|
||||
info!("OpenFang server running on port {port}");
|
||||
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init());
|
||||
|
||||
// Desktop-only plugins
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
// Another instance tried to launch — focus the existing window
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}));
|
||||
|
||||
builder = builder.plugin(
|
||||
tauri_plugin_autostart::Builder::new()
|
||||
.args(["--minimized"])
|
||||
.build(),
|
||||
);
|
||||
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
|
||||
// Global shortcuts — non-fatal on registration failure
|
||||
match shortcuts::build_shortcut_plugin() {
|
||||
Ok(plugin) => {
|
||||
builder = builder.plugin(plugin);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to register global shortcuts: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
.manage(PortState(port))
|
||||
.manage(KernelState {
|
||||
kernel: server_handle.kernel.clone(),
|
||||
started_at: Instant::now(),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_port,
|
||||
commands::get_status,
|
||||
commands::get_agent_count,
|
||||
commands::import_agent_toml,
|
||||
commands::import_skill_file,
|
||||
commands::get_autostart,
|
||||
commands::set_autostart,
|
||||
commands::check_for_updates,
|
||||
commands::install_update,
|
||||
commands::open_config_dir,
|
||||
commands::open_logs_dir,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// Create the main window pointing directly at the embedded HTTP server.
|
||||
// We do NOT define windows in tauri.conf.json because Tauri would try to
|
||||
// load index.html from embedded assets (which don't exist), causing a race
|
||||
// condition where AssetNotFound overwrites the navigated page.
|
||||
let _window = WebviewWindowBuilder::new(
|
||||
app,
|
||||
"main",
|
||||
WebviewUrl::External(url.parse().expect("Invalid server URL")),
|
||||
)
|
||||
.title("OpenFang")
|
||||
.inner_size(1280.0, 800.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.center()
|
||||
.visible(true)
|
||||
.build()?;
|
||||
|
||||
// Set up system tray (desktop only)
|
||||
#[cfg(desktop)]
|
||||
tray::setup_tray(app)?;
|
||||
|
||||
// Spawn background task to forward critical kernel events as native
|
||||
// OS notifications. Only truly critical events — crashes, hard quota
|
||||
// limits, and kernel shutdown. Health checks and quota warnings are
|
||||
// too noisy for desktop notifications.
|
||||
let app_handle = app.handle().clone();
|
||||
let mut event_rx = kernel_for_notifications.event_bus.subscribe_all();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok(event) => {
|
||||
let (title, body) = match &event.payload {
|
||||
EventPayload::Lifecycle(LifecycleEvent::Crashed {
|
||||
agent_id,
|
||||
error,
|
||||
}) => (
|
||||
"Agent Crashed".to_string(),
|
||||
format!("Agent {agent_id} crashed: {error}"),
|
||||
),
|
||||
EventPayload::System(SystemEvent::KernelStopping) => (
|
||||
"Kernel Stopping".to_string(),
|
||||
"OpenFang kernel is shutting down".to_string(),
|
||||
),
|
||||
EventPayload::System(SystemEvent::QuotaEnforced {
|
||||
agent_id,
|
||||
spent,
|
||||
limit,
|
||||
}) => (
|
||||
"Quota Enforced".to_string(),
|
||||
format!(
|
||||
"Agent {agent_id} quota hit: ${spent:.4} / ${limit:.4}"
|
||||
),
|
||||
),
|
||||
// Skip everything else (health checks, spawns, suspends, etc.)
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title(&title)
|
||||
.body(&body)
|
||||
.show()
|
||||
{
|
||||
warn!("Failed to send desktop notification: {e}");
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!("Notification listener lagged, skipped {n} events");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
info!("Event bus closed, stopping notification listener");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn startup update check (desktop only, after event forwarding is set up)
|
||||
#[cfg(desktop)]
|
||||
updater::spawn_startup_check(app.handle().clone());
|
||||
|
||||
info!("OpenFang Desktop window created");
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// Hide to tray on close instead of quitting (desktop)
|
||||
#[cfg(desktop)]
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
let _ = window.hide();
|
||||
api.prevent_close();
|
||||
}
|
||||
})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("Failed to build Tauri application")
|
||||
.run(|_app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
info!("Tauri app exit requested");
|
||||
}
|
||||
});
|
||||
|
||||
// App event loop has ended — shut down the embedded server + kernel
|
||||
info!("Tauri app closed, shutting down embedded server...");
|
||||
server_handle.shutdown();
|
||||
}
|
||||
6
crates/openfang-desktop/src/main.rs
Normal file
6
crates/openfang-desktop/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
openfang_desktop::run();
|
||||
}
|
||||
128
crates/openfang-desktop/src/server.rs
Normal file
128
crates/openfang-desktop/src/server.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! Kernel lifecycle management for the desktop app.
|
||||
//!
|
||||
//! Boots the OpenFang kernel, binds to a random localhost port, and runs the
|
||||
//! API server on a background thread with its own tokio runtime.
|
||||
|
||||
use openfang_api::server::build_router;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Handle to the running embedded server. Drop or call `shutdown()` to stop.
|
||||
pub struct ServerHandle {
|
||||
/// The port the server is listening on.
|
||||
pub port: u16,
|
||||
/// The kernel instance (shared with the server).
|
||||
pub kernel: Arc<OpenFangKernel>,
|
||||
/// Send `true` to trigger graceful shutdown.
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
/// Join handle for the background server thread.
|
||||
server_thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ServerHandle {
|
||||
/// Signal the server to shut down and wait for the background thread.
|
||||
pub fn shutdown(mut self) {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
if let Some(handle) = self.server_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
self.kernel.shutdown();
|
||||
info!("OpenFang embedded server stopped");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServerHandle {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
// Best-effort: don't block in drop, the thread will exit on its own.
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot the kernel and start the embedded API server on a background thread.
|
||||
///
|
||||
/// Binds to `127.0.0.1:0` on the calling thread so the port is known before
|
||||
/// any Tauri window is created. The actual axum server runs on a dedicated
|
||||
/// thread with its own tokio runtime.
|
||||
pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
|
||||
// Boot kernel (sync — no tokio needed)
|
||||
let kernel = OpenFangKernel::boot(None)?;
|
||||
let kernel = Arc::new(kernel);
|
||||
kernel.set_self_handle();
|
||||
|
||||
// Bind to a random free port on localhost (main thread — guarantees port)
|
||||
let std_listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = std_listener.local_addr()?.port();
|
||||
let listen_addr: SocketAddr = std_listener.local_addr()?;
|
||||
|
||||
info!("OpenFang embedded server bound to http://127.0.0.1:{port}");
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let kernel_clone = kernel.clone();
|
||||
|
||||
let server_thread = std::thread::Builder::new()
|
||||
.name("openfang-server".into())
|
||||
.spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to create tokio runtime for embedded server");
|
||||
|
||||
rt.block_on(async move {
|
||||
// start_background_agents() uses tokio::spawn, so it must
|
||||
// run inside a tokio runtime context.
|
||||
kernel_clone.start_background_agents();
|
||||
run_embedded_server(kernel_clone, std_listener, listen_addr, shutdown_rx).await;
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(ServerHandle {
|
||||
port,
|
||||
kernel,
|
||||
shutdown_tx,
|
||||
server_thread: Some(server_thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the axum server inside a tokio runtime, shut down when the watch
|
||||
/// channel fires.
|
||||
async fn run_embedded_server(
|
||||
kernel: Arc<OpenFangKernel>,
|
||||
std_listener: TcpListener,
|
||||
listen_addr: SocketAddr,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) {
|
||||
let (app, state) = build_router(kernel, listen_addr).await;
|
||||
|
||||
// Convert std TcpListener → tokio TcpListener
|
||||
std_listener
|
||||
.set_nonblocking(true)
|
||||
.expect("Failed to set listener to non-blocking");
|
||||
let listener = tokio::net::TcpListener::from_std(std_listener)
|
||||
.expect("Failed to convert std TcpListener to tokio");
|
||||
|
||||
info!("OpenFang embedded server listening on http://{listen_addr}");
|
||||
|
||||
let server = axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.wait_for(|v| *v).await;
|
||||
info!("Embedded server received shutdown signal");
|
||||
});
|
||||
|
||||
if let Err(e) = server.await {
|
||||
error!("Embedded server error: {e}");
|
||||
}
|
||||
|
||||
// Clean up channel bridges
|
||||
{
|
||||
let mut guard = state.bridge_manager.lock().await;
|
||||
if let Some(ref mut b) = *guard {
|
||||
b.stop().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
crates/openfang-desktop/src/shortcuts.rs
Normal file
44
crates/openfang-desktop/src/shortcuts.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! System-wide keyboard shortcuts for the OpenFang desktop app.
|
||||
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_global_shortcut::{Code, Modifiers, ShortcutState};
|
||||
use tracing::warn;
|
||||
|
||||
/// Build the global shortcut plugin with 3 system-wide shortcuts:
|
||||
///
|
||||
/// - `Ctrl+Shift+O` — Show/focus the OpenFang window
|
||||
/// - `Ctrl+Shift+N` — Show window + navigate to agents page
|
||||
/// - `Ctrl+Shift+C` — Show window + navigate to chat page
|
||||
///
|
||||
/// Returns `Result` so `lib.rs` can handle registration failure gracefully.
|
||||
pub fn build_shortcut_plugin<R: tauri::Runtime>(
|
||||
) -> Result<tauri::plugin::TauriPlugin<R>, tauri_plugin_global_shortcut::Error> {
|
||||
let plugin = tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_shortcuts(["ctrl+shift+o", "ctrl+shift+n", "ctrl+shift+c"])?
|
||||
.with_handler(|app, shortcut, event| {
|
||||
if event.state != ShortcutState::Pressed {
|
||||
return;
|
||||
}
|
||||
|
||||
// All shortcuts show/focus the window first
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
|
||||
if shortcut.matches(Modifiers::CONTROL | Modifiers::SHIFT, Code::KeyN) {
|
||||
if let Err(e) = app.emit("navigate", "agents") {
|
||||
warn!("Failed to emit navigate event: {e}");
|
||||
}
|
||||
} else if shortcut.matches(Modifiers::CONTROL | Modifiers::SHIFT, Code::KeyC) {
|
||||
if let Err(e) = app.emit("navigate", "chat") {
|
||||
warn!("Failed to emit navigate event: {e}");
|
||||
}
|
||||
}
|
||||
// Ctrl+Shift+O just shows the window (already done above)
|
||||
})
|
||||
.build();
|
||||
|
||||
Ok(plugin)
|
||||
}
|
||||
225
crates/openfang-desktop/src/tray.rs
Normal file
225
crates/openfang-desktop/src/tray.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! System tray setup for the OpenFang desktop app.
|
||||
|
||||
use openfang_kernel::config::openfang_home;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager,
|
||||
};
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Format seconds into a human-readable uptime string.
|
||||
fn format_uptime(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3600 {
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{m}m {s}s")
|
||||
} else {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
format!("{h}h {m}m")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and register the system tray icon with enhanced menu.
|
||||
pub fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Action items
|
||||
let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
|
||||
let browser = MenuItem::with_id(app, "browser", "Open in Browser", true, None::<&str>)?;
|
||||
let sep1 = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
// Informational items (disabled — display only)
|
||||
let agent_count = if let Some(ks) = app.try_state::<crate::KernelState>() {
|
||||
ks.kernel.registry.list().len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let uptime = if let Some(ks) = app.try_state::<crate::KernelState>() {
|
||||
format_uptime(ks.started_at.elapsed().as_secs())
|
||||
} else {
|
||||
"0s".to_string()
|
||||
};
|
||||
let agents_info = MenuItem::with_id(
|
||||
app,
|
||||
"agents_info",
|
||||
format!("Agents: {agent_count} running"),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let status_info = MenuItem::with_id(
|
||||
app,
|
||||
"status_info",
|
||||
format!("Status: Running ({uptime})"),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let sep2 = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
// Settings items
|
||||
let autostart_enabled = app.autolaunch().is_enabled().unwrap_or(false);
|
||||
let launch_at_login = CheckMenuItem::with_id(
|
||||
app,
|
||||
"launch_at_login",
|
||||
"Launch at Login",
|
||||
true,
|
||||
autostart_enabled,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let check_updates = MenuItem::with_id(
|
||||
app,
|
||||
"check_updates",
|
||||
"Check for Updates...",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_config = MenuItem::with_id(
|
||||
app,
|
||||
"open_config",
|
||||
"Open Config Directory",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let sep3 = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit OpenFang", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&show,
|
||||
&browser,
|
||||
&sep1,
|
||||
&agents_info,
|
||||
&status_info,
|
||||
&sep2,
|
||||
&launch_at_login,
|
||||
&check_updates,
|
||||
&open_config,
|
||||
&sep3,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
|
||||
// Load the tray icon from embedded PNG bytes
|
||||
let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png"))
|
||||
.expect("Failed to decode tray icon PNG");
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(tray_icon)
|
||||
.menu(&menu)
|
||||
.tooltip("OpenFang Agent OS")
|
||||
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||||
"show" => {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
"browser" => {
|
||||
if let Some(port) = app.try_state::<crate::PortState>() {
|
||||
let url = format!("http://127.0.0.1:{}", port.0);
|
||||
let _ = open::that(&url);
|
||||
}
|
||||
}
|
||||
"launch_at_login" => {
|
||||
let manager = app.autolaunch();
|
||||
let currently_enabled = manager.is_enabled().unwrap_or(false);
|
||||
if currently_enabled {
|
||||
if let Err(e) = manager.disable() {
|
||||
warn!("Failed to disable autostart: {e}");
|
||||
}
|
||||
} else if let Err(e) = manager.enable() {
|
||||
warn!("Failed to enable autostart: {e}");
|
||||
}
|
||||
info!(
|
||||
"Autostart toggled: {}",
|
||||
manager.is_enabled().unwrap_or(false)
|
||||
);
|
||||
}
|
||||
"check_updates" => {
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// First check what's available
|
||||
match crate::updater::check_for_update(&app_handle).await {
|
||||
Ok(info) if info.available => {
|
||||
let version = info.version.as_deref().unwrap_or("unknown");
|
||||
// Notify user we're starting install
|
||||
let _ = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Installing Update...")
|
||||
.body(format!(
|
||||
"Downloading OpenFang v{version}. App will restart shortly."
|
||||
))
|
||||
.show();
|
||||
// Perform install
|
||||
if let Err(e) =
|
||||
crate::updater::download_and_install_update(&app_handle).await
|
||||
{
|
||||
warn!("Manual update install failed: {e}");
|
||||
let _ = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Update Failed")
|
||||
.body(format!("Could not install update: {e}"))
|
||||
.show();
|
||||
}
|
||||
// If we reach here, install failed (success causes restart)
|
||||
}
|
||||
Ok(_) => {
|
||||
let _ = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Up to Date")
|
||||
.body("You're running the latest version of OpenFang.")
|
||||
.show();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Tray update check failed: {e}");
|
||||
let _ = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Update Check Failed")
|
||||
.body("Could not check for updates. Try again later.")
|
||||
.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
"open_config" => {
|
||||
let dir = openfang_home();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
if let Err(e) = open::that(&dir) {
|
||||
warn!("Failed to open config dir: {e}");
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
info!("Quit requested from system tray");
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
93
crates/openfang-desktop/src/updater.rs
Normal file
93
crates/openfang-desktop/src/updater.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
//! Update checker for the OpenFang desktop app.
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Structured result from an update check.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UpdateInfo {
|
||||
/// Whether a newer version is available.
|
||||
pub available: bool,
|
||||
/// The new version string, if available.
|
||||
pub version: Option<String>,
|
||||
/// Release notes body, if available.
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
/// Spawn a background task that checks for updates after a 10-second delay.
|
||||
///
|
||||
/// If an update is found, installs it silently and restarts the app.
|
||||
/// All errors are logged but never panic.
|
||||
pub fn spawn_startup_check(app_handle: tauri::AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||
|
||||
match do_check(&app_handle).await {
|
||||
Ok(info) if info.available => {
|
||||
let version = info.version.as_deref().unwrap_or("unknown");
|
||||
info!("Update available: v{version}, installing silently...");
|
||||
// Notify user first, then install
|
||||
let _ = app_handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("OpenFang Updating...")
|
||||
.body(format!("Installing v{version}. App will restart shortly."))
|
||||
.show();
|
||||
// Small delay so notification is visible
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
if let Err(e) = download_and_install_update(&app_handle).await {
|
||||
warn!("Auto-update install failed: {e}");
|
||||
}
|
||||
}
|
||||
Ok(_) => info!("No updates available"),
|
||||
Err(e) => warn!("Startup update check failed: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Perform an on-demand update check. Returns structured result.
|
||||
pub async fn check_for_update(app_handle: &tauri::AppHandle) -> Result<UpdateInfo, String> {
|
||||
do_check(app_handle).await
|
||||
}
|
||||
|
||||
/// Download and install the latest update, then restart the app.
|
||||
/// Should only be called after `check_for_update()` confirms availability.
|
||||
///
|
||||
/// On success, calls `app_handle.restart()` which terminates the process —
|
||||
/// the function never returns `Ok`. On failure, returns `Err(message)`.
|
||||
pub async fn download_and_install_update(app_handle: &tauri::AppHandle) -> Result<(), String> {
|
||||
let updater = app_handle.updater().map_err(|e| e.to_string())?;
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "No update available".to_string())?;
|
||||
|
||||
info!("Downloading update v{}...", update.version);
|
||||
update
|
||||
.download_and_install(|_downloaded, _total| {}, || {})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
info!("Update installed, restarting...");
|
||||
app_handle.restart()
|
||||
}
|
||||
|
||||
async fn do_check(app_handle: &tauri::AppHandle) -> Result<UpdateInfo, String> {
|
||||
let updater = app_handle.updater().map_err(|e| e.to_string())?;
|
||||
match updater.check().await {
|
||||
Ok(Some(update)) => Ok(UpdateInfo {
|
||||
available: true,
|
||||
version: Some(update.version.clone()),
|
||||
body: update.body.clone(),
|
||||
}),
|
||||
Ok(None) => Ok(UpdateInfo {
|
||||
available: false,
|
||||
version: None,
|
||||
body: None,
|
||||
}),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
59
crates/openfang-desktop/tauri.conf.json
Normal file
59
crates/openfang-desktop/tauri.conf.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self' http://127.0.0.1:* ws://127.0.0.1:* https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob: http://127.0.0.1:*; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self' 'unsafe-inline' 'unsafe-eval'; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; media-src 'self' blob: http://127.0.0.1:*; frame-src 'self' blob: http://127.0.0.1:*; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJDOTE5MDhCRDNGMTUyMEQKUldRTlV2SFRpNUNSdkZRZDcvektwZkN6bUsyM0NJZC9OeXhHRU5id1FnZWllQmNlZGRoSGRQOHkK",
|
||||
"endpoints": [
|
||||
"https://github.com/RightNow-AI/openfang/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png",
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png"
|
||||
],
|
||||
"category": "Productivity",
|
||||
"shortDescription": "Open-source Agent Operating System",
|
||||
"longDescription": "OpenFang is an open-source Agent Operating System — run, orchestrate, and extend AI agents across every channel from your desktop.",
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": []
|
||||
},
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": null,
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "12.0"
|
||||
},
|
||||
"windows": {
|
||||
"digestAlgorithm": "sha256",
|
||||
"certificateThumbprint": null,
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user