初始化提交
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:
iven
2026-03-01 16:24:24 +08:00
commit 92e5def702
492 changed files with 211343 additions and 0 deletions

View 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"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

File diff suppressed because one or more lines are too long

View 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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View 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}"))
}

View 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();
}

View 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();
}

View 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;
}
}
}

View 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)
}

View 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(())
}

View 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()),
}
}

View 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"
}
}
}
}