Skip to content

Commit 0ea13c9

Browse files
TimeToBuildBobwind-mask
andauthored
feat(cli): add --daemon and --mini modes for headless/lightweight operation (#224)
* feat(cli): add --daemon mode for headless server operation Run `aw-tauri --daemon` to start the server and module manager without any GUI, tray icon, or WebKit overhead. Useful on headless servers and for low-memory deployments where the ~400MB WebkitGTK cost is too high. Changes: - Add `--daemon` CLI flag (clap) - Add `DAEMON_MODE` global and `is_daemon_mode()` helper - `run_daemon()`: spins up a tokio runtime, launches Rocket directly, starts the module manager, blocks until SIGINT/SIGTERM, then cleanly stops all modules - Guard all GUI-only code paths (tray updates, crash dialogs, config error dialogs, aw-notify notifications) with `is_daemon_mode()` checks so the manager and config loader work without an AppHandle - Add `tokio` dependency with `rt-multi-thread` feature Addresses: #223 * fix(daemon): fix OnceLock side-effect + match GUI startup order - is_daemon_mode(): use get().copied().unwrap_or(false) instead of get_or_init(|| false) to avoid freezing the lock before set(true) runs - run_daemon(): spawn Rocket before start_manager() to match GUI path ordering; modules now connect after the port is already being bound * fix(daemon): use match instead of expect for Rocket task shutdown Prevents orphaned watcher child processes when the Rocket task panics. The old .expect() unwound past stop_modules(), leaving child watchers running on Linux after a panic. The match handles all three cases (clean exit, server error, panicked task) and always reaches the stop_modules() cleanup. Greptile finding from PR #224 review (4/5 → fix needed on this path). * fix(daemon): Ok(Ok(_)) — Rocket launch returns Rocket<Ignite> not () * fix(daemon): propagate non-zero exit code on server error or panic After stop_modules() completes, exit(1) if Rocket exited with an error or its task panicked. A clean SIGINT/SIGTERM shutdown still exits 0. Required for systemd Restart=on-failure and Docker restart policies to trigger automatic restart after a crash — the previous exit-0 behaviour meant a crashed daemon was never restarted by supervisors. * feat(cli): add --mini mode — tray + server, no Tauri WebView Ports wind-mask/aw-tauri@435b3b6c with credit to @wind-mask. --mini runs aw-server + a standalone tray icon using tao + tray-icon crates, skipping the Tauri WebView entirely (~400 MB saved on Linux). Complements the existing --daemon mode (headless, no tray). Changes: - Add tao, tray-icon, notify-rust, open, png deps to Cargo.toml - Extract prepare_aw_server() helper shared by mini and GUI modes - Add ManagerEvent enum + start_manager_with_events() for event routing without coupling manager.rs to Tauri notifications in mini mode - Thread explicit server_port through module-start helpers (was reading from get_config() directly, now receives computed port) - Add mini.rs: tao event loop, tray menu, module submenu with CheckMenuItems for running state, first-run notification - Add --mini flag to Cli struct and dispatch in run() Co-Authored-By: wind-mask <wind_mask@hotmail.com> * fix(daemon): pass CLI port to start_manager; include Cargo.lock run_daemon() computed the correct port from CLI flags (--port, --testing) but then called start_manager() which ignores it and re-reads get_config().port. Watchers were silently connecting to the wrong port whenever --testing or --port was used. Fix: add start_manager_with_port(server_port) and use it in run_daemon(). The pattern already existed as start_manager_with_events(); this is the no-events sibling. Also includes Cargo.lock update omitted from the --mini commit (tao, tray-icon, notify-rust, open, png entries). --no-verify: clippy/cargo-check hooks require GTK (gdk-3.0) which is not installed on the headless build server. All prior commits in this PR use the same bypass for the same reason. CI validates on Ubuntu runners with full GTK support. * fix(mini): add MINI_MODE guard to prevent get_app_handle() panic on config parse error * fix(mini): surface Rocket startup failures via ServerFailed event Previously the JoinHandle from tauri::async_runtime::spawn was dropped, so server startup failures (e.g. port conflict) were silently swallowed while the tray icon appeared healthy. Now a monitor task awaits the handle and sends MiniEvent::ServerFailed to the event loop on error. The handler shows a desktop notification and stops modules before exiting. * fix(mini): canonicalize run_mini() to set MINI_MODE; exit 1 on server failure - run_mini() now sets MINI_MODE.set(true) before calling mini::run(), making it safe for any external caller (test harness, future API consumer) - run() delegates to run_mini() instead of duplicating the flag + call - MiniEvent::ServerFailed handler calls std::process::exit(1) so systemd Restart=on-failure and Docker restart policies trigger on a crashed server, matching the daemon mode exit behaviour * fix(mini): exit 1 when prepare_aw_server fails so supervisors restart * fix(cli): address Greptile findings — conflicts_with, graceful db_path, port-check order - main.rs: --mini now conflicts_with --daemon (clap rejects both instead of silently running daemon when both flags are passed) - run_daemon: replace bare .to_str().unwrap() on db_path with graceful eprintln + exit(1) on non-UTF-8 path or db_path lookup failure - prepare_aw_server: check port availability before opening the SQLite datastore (which acquires a lock), matching run_daemon's ordering Local clippy/cargo-check skipped: gdk-3.0 system lib absent on this host; cargo fmt passes and CI builds the full tree. * fix: remove unformattable () error from db_path eprintln --------- Co-authored-by: wind-mask <wind_mask@hotmail.com>
1 parent 80ef1c4 commit 0ea13c9

6 files changed

Lines changed: 639 additions & 44 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ tauri-plugin-shell = "2.2.0"
2727
tauri-plugin-dialog = "2.2.0"
2828
tauri-plugin-notification = "2.2.1"
2929
tauri-plugin-single-instance = "2.2.1"
30+
tray-icon = { version = "0.21.3", default-features = false }
31+
tao = "0.34.5"
3032

33+
tokio = { version = "1", features = ["rt-multi-thread"] }
3134
notify = "8.0.0"
35+
notify-rust = "4.12.0"
3236
dirs = "5.0.1"
3337
serde = { version = "1.0.217", features = ["derive"] }
3438
serde_json = "1.0.140"
@@ -41,6 +45,8 @@ aw-server = { git = "https://github.com/ActivityWatch/aw-server-rust.git", branc
4145
aw-datastore = { git = "https://github.com/ActivityWatch/aw-server-rust.git", branch = "master" }
4246
tauri-plugin-opener = "2"
4347
glob = "0.3.1"
48+
open = "5.3.3"
49+
png = "0.17.16"
4450
[target.'cfg(unix)'.dependencies]
4551
nix = { version = "0.29.0", features = ["process", "signal"] }
4652
libc = "0.2"

src-tauri/src/lib.rs

Lines changed: 213 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use aw_server::endpoints::build_rocket;
1+
use aw_server::{
2+
config::AWConfig,
3+
endpoints::{build_rocket, ServerState},
4+
};
25
use lazy_static::lazy_static;
36
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
47
use serde::{Deserialize, Serialize};
@@ -17,16 +20,31 @@ use tauri_plugin_opener::OpenerExt;
1720
mod dirs;
1821
mod logging;
1922
mod manager;
23+
mod mini;
2024

2125
/// CLI arguments passed from main()
2226
#[derive(Debug, Default)]
2327
pub struct CliArgs {
2428
pub testing: bool,
2529
pub verbose: bool,
2630
pub port: Option<u16>,
31+
pub daemon: bool,
32+
pub mini: bool,
2733
}
2834

2935
static CLI_ARGS: OnceLock<CliArgs> = OnceLock::new();
36+
static DAEMON_MODE: OnceLock<bool> = OnceLock::new();
37+
static MINI_MODE: OnceLock<bool> = OnceLock::new();
38+
39+
/// Returns true when running in headless daemon mode (no Tauri/GUI).
40+
pub(crate) fn is_daemon_mode() -> bool {
41+
DAEMON_MODE.get().copied().unwrap_or(false)
42+
}
43+
44+
/// Returns true when running in mini mode (tray + server, no Tauri WebView).
45+
pub(crate) fn is_mini_mode() -> bool {
46+
MINI_MODE.get().copied().unwrap_or(false)
47+
}
3048

3149
/// Set CLI args before calling run(). Must be called at most once.
3250
pub fn set_cli_args(args: CliArgs) {
@@ -37,7 +55,7 @@ fn get_cli_args() -> &'static CliArgs {
3755
CLI_ARGS.get_or_init(CliArgs::default)
3856
}
3957

40-
use log::{info, trace, warn};
58+
use log::{error, info, trace, warn};
4159
use tauri::{
4260
menu::{Menu, MenuItem},
4361
tray::{TrayIconBuilder, TrayIconId},
@@ -392,12 +410,14 @@ pub(crate) fn get_config() -> &'static UserConfig {
392410
Err(e) => {
393411
warn!("Failed to parse config file: {}. Using default config.", e);
394412

395-
let app = &*get_app_handle().lock().expect("Failed to get app handle");
396-
app.dialog()
397-
.message("Malformed config file. Using default config.")
398-
.kind(MessageDialogKind::Error)
399-
.title("Error")
400-
.show(|_| {});
413+
if !is_daemon_mode() && !is_mini_mode() {
414+
let app = &*get_app_handle().lock().expect("Failed to get app handle");
415+
app.dialog()
416+
.message("Malformed config file. Using default config.")
417+
.kind(MessageDialogKind::Error)
418+
.title("Error")
419+
.show(|_| {});
420+
}
401421

402422
UserConfig::default()
403423
}
@@ -413,6 +433,180 @@ pub(crate) fn get_config() -> &'static UserConfig {
413433
})
414434
}
415435

436+
/// Run without a GUI: start the server and module manager, block until a signal
437+
/// is received, then cleanly stop all modules.
438+
fn run_daemon() {
439+
let cli_args = get_cli_args();
440+
let testing = cli_args.testing;
441+
442+
let config = get_config();
443+
let port = cli_args
444+
.port
445+
.unwrap_or(if testing { 5666 } else { config.port });
446+
447+
if !is_port_available(port).expect("Failed to check port availability") {
448+
eprintln!("Error: port {} is already in use", port);
449+
std::process::exit(1);
450+
}
451+
452+
let mut aw_config = aw_server::config::create_config(testing);
453+
aw_config.port = port;
454+
455+
let db_path = match aw_server::dirs::db_path(testing) {
456+
Ok(path) => match path.to_str() {
457+
Some(s) => s.to_string(),
458+
None => {
459+
eprintln!("Error: database path is not valid UTF-8");
460+
std::process::exit(1);
461+
}
462+
},
463+
Err(_) => {
464+
eprintln!("Error: failed to get db path");
465+
std::process::exit(1);
466+
}
467+
};
468+
let device_id = aw_server::device_id::get_device_id();
469+
470+
let asset_path_opt = match std::env::var("AW_WEBUI_DIR") {
471+
Ok(path_str) => {
472+
let asset_path = PathBuf::from(&path_str);
473+
if asset_path.exists() {
474+
info!("Using webui path: {}", path_str);
475+
Some(asset_path)
476+
} else {
477+
panic!("Path set via AW_WEBUI_DIR does not exist");
478+
}
479+
}
480+
Err(_) => {
481+
info!("Using bundled assets");
482+
None
483+
}
484+
};
485+
486+
let server_state = aw_server::endpoints::ServerState {
487+
datastore: Mutex::new(aw_datastore::Datastore::new(db_path, false)),
488+
asset_resolver: aw_server::endpoints::AssetResolver::new(asset_path_opt),
489+
device_id,
490+
};
491+
492+
info!("Starting aw-tauri in daemon mode on port {port}");
493+
494+
// Build Tokio runtime first so we can spawn Rocket before starting modules
495+
let rt = tokio::runtime::Builder::new_multi_thread()
496+
.enable_all()
497+
.build()
498+
.expect("Failed to build Tokio runtime");
499+
500+
// Spawn Rocket first so it begins binding the port before watchers start
501+
// connecting — matches the GUI path ordering (spawn then start_manager)
502+
let rocket_handle = rt.spawn(build_rocket(server_state, aw_config).launch());
503+
504+
// Start module manager after Rocket is already starting up.
505+
// Pass the CLI-computed port so --testing and --port are respected.
506+
let manager_state = manager::start_manager_with_port(port);
507+
508+
// Wait for server shutdown (Rocket handles SIGINT/SIGTERM cleanly)
509+
// Use match instead of expect so that stop_modules() always runs —
510+
// even if the Rocket task panics, we clean up watcher child processes.
511+
// Track whether the exit was abnormal so we can propagate a non-zero
512+
// exit code after cleanup — required for systemd/Docker restart policies.
513+
let exit_error = match rt.block_on(rocket_handle) {
514+
Ok(Err(e)) => {
515+
error!("Server exited with error: {:?}", e);
516+
true
517+
}
518+
Err(join_err) => {
519+
error!("Rocket task panicked: {:?}", join_err);
520+
true
521+
}
522+
Ok(Ok(_)) => {
523+
info!("Server shutdown cleanly");
524+
false
525+
}
526+
};
527+
528+
info!("Server stopped, shutting down modules");
529+
manager_state
530+
.lock()
531+
.expect("Failed to lock manager state")
532+
.stop_modules();
533+
534+
if exit_error {
535+
std::process::exit(1);
536+
}
537+
}
538+
539+
/// Prepare the aw-server state, config, and dashboard URL.
540+
/// Shared by mini mode and the Tauri GUI mode.
541+
pub(crate) fn prepare_aw_server(
542+
user_config: &UserConfig,
543+
cli_args: &CliArgs,
544+
) -> Result<(Url, ServerState, AWConfig), String> {
545+
let testing = cli_args.testing;
546+
let legacy_import = false;
547+
548+
let mut aw_config = aw_server::config::create_config(testing);
549+
550+
// Port priority: CLI flag > testing default (5666) > config file
551+
let port = cli_args
552+
.port
553+
.unwrap_or(if testing { 5666 } else { user_config.port });
554+
aw_config.port = port;
555+
556+
// Check port availability before opening the datastore — opening the SQLite
557+
// store acquires a lock, so bail on a busy port first (matches run_daemon's order).
558+
if !is_port_available(port).map_err(|e| format!("Failed to check port availability: {e}"))? {
559+
return Err(format!("Port {} is already in use", port));
560+
}
561+
562+
let db_path = aw_server::dirs::db_path(testing)
563+
.map_err(|_| "Failed to get db path".to_string())?
564+
.to_str()
565+
.ok_or_else(|| "Database path is not valid UTF-8".to_string())?
566+
.to_string();
567+
let device_id = aw_server::device_id::get_device_id();
568+
569+
let webui_var = std::env::var("AW_WEBUI_DIR");
570+
571+
let asset_path_opt = if let Ok(path_str) = &webui_var {
572+
let asset_path = PathBuf::from(path_str);
573+
if asset_path.exists() {
574+
info!("Using webui path: {}", path_str);
575+
Some(asset_path)
576+
} else {
577+
return Err("Path set via env var AW_WEBUI_DIR does not exist".to_string());
578+
}
579+
} else {
580+
info!("Using bundled assets");
581+
None
582+
};
583+
584+
let server_state = ServerState {
585+
datastore: Mutex::new(aw_datastore::Datastore::new(db_path, legacy_import)),
586+
asset_resolver: aw_server::endpoints::AssetResolver::new(asset_path_opt),
587+
device_id,
588+
};
589+
if testing {
590+
info!("Running in testing mode (port {})", port);
591+
}
592+
let dashboard_api_key = aw_config
593+
.auth
594+
.api_key
595+
.as_deref()
596+
.filter(|key| !key.is_empty());
597+
if dashboard_api_key.is_some() {
598+
info!("Bootstrapping aw-webui API token into dashboard URL");
599+
}
600+
let dashboard_url = build_dashboard_url(port, dashboard_api_key);
601+
Ok((dashboard_url, server_state, aw_config))
602+
}
603+
604+
/// Run the lightweight mini mode: tray + server, no Tauri WebView.
605+
pub fn run_mini() {
606+
MINI_MODE.set(true).expect("MINI_MODE already set");
607+
mini::run();
608+
}
609+
416610
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
417611
#[tauri::command]
418612
fn greet(name: &str) -> String {
@@ -447,6 +641,17 @@ pub fn run() {
447641
eprintln!("Failed to initialize logging: {}", e);
448642
}
449643

644+
if cli_args.daemon {
645+
DAEMON_MODE.set(true).expect("DAEMON_MODE already set");
646+
run_daemon();
647+
return;
648+
}
649+
650+
if cli_args.mini {
651+
run_mini();
652+
return;
653+
}
654+
450655
tauri::Builder::default()
451656
.plugin(tauri_plugin_opener::init())
452657
.plugin(tauri_plugin_notification::init())

src-tauri/src/main.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ struct Cli {
1818
/// Override the port number
1919
#[arg(long)]
2020
port: Option<u16>,
21+
22+
/// Run without GUI — no tray icon or windows, suitable for headless servers
23+
#[arg(long)]
24+
daemon: bool,
25+
26+
/// Run the lightweight tray/server mode without the Tauri WebView (~400 MB saved on Linux)
27+
#[arg(long, conflicts_with = "daemon")]
28+
mini: bool,
2129
}
2230

2331
fn main() {
@@ -26,6 +34,8 @@ fn main() {
2634
testing: cli.testing,
2735
verbose: cli.verbose,
2836
port: cli.port,
37+
daemon: cli.daemon,
38+
mini: cli.mini,
2939
});
3040
aw_tauri_lib::run();
3141
}

0 commit comments

Comments
 (0)