//! GoingsOn desktop application entry point. // Prevents additional console window on Windows in release #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use goingson_desktop::backup_scheduler; use goingson_desktop::blob_gc; use goingson_desktop::commands; use goingson_desktop::email_sync_scheduler; use goingson_desktop::sync_scheduler; use goingson_desktop::state::AppState; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{Emitter, Manager, RunEvent}; use tokio_util::sync::CancellationToken; use tracing::info; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; // Desktop-only imports #[cfg(not(any(target_os = "ios", target_os = "android")))] use goingson_desktop::db_watcher; #[cfg(not(any(target_os = "ios", target_os = "android")))] use goingson_desktop::notifications; #[cfg(not(any(target_os = "ios", target_os = "android")))] use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; /// Report a fatal startup error to the user, then exit. /// /// Never returns, and must not panic. This runs inside NSApp's /// `didFinishLaunching` callback, which is nounwind, so a panic here aborts the /// process and reduces the message to a bare SIGABRT with no diagnostic text. /// /// Uses rfd rather than tauri-plugin-dialog because Tauri's event loop has not /// started yet at this point in setup: the plugin's `show` has no loop to paint /// from or deliver its callback to, and its `blocking_show` is documented as /// unsafe to call on the main thread. rfd drives a native modal directly. #[cfg(not(any(target_os = "ios", target_os = "android")))] fn fatal_startup_error(message: &str) -> ! { tracing::error!("{}", message); rfd::MessageDialog::new() .set_level(rfd::MessageLevel::Error) .set_title("GoingsOn cannot start") .set_description(message) .show(); std::process::exit(1); } /// Mobile has no rfd backend, so the error goes to the log and the process exits. #[cfg(any(target_os = "ios", target_os = "android"))] fn fatal_startup_error(message: &str) -> ! { tracing::error!("{}", message); std::process::exit(1); } /// Set up the macOS menu bar tray icon showing "GO" in Reglo. #[cfg(not(any(target_os = "ios", target_os = "android")))] fn setup_tray(app: &tauri::App) -> Result<(), Box> { use tauri::menu::{MenuBuilder, MenuItemBuilder}; let show = MenuItemBuilder::with_id("tray_show", "Show GoingsOn").build(app)?; let quit = MenuItemBuilder::with_id("tray_quit", "Quit").build(app)?; let menu = MenuBuilder::new(app).items(&[&show, &quit]).build()?; let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray-icon@2x.png"))?; let _tray = TrayIconBuilder::new() .icon(icon) .icon_as_template(true) .menu(&menu) .show_menu_on_left_click(false) .tooltip("GoingsOn") .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event && let Some(window) = tray.app_handle().get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } }) .on_menu_event(|app, event| match event.id().as_ref() { "tray_show" => { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } } "tray_quit" => { app.exit(0); } _ => {} }) .build(app)?; Ok(()) } fn main() { // Initialize structured logging with tracing tracing_subscriber::registry() .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| { // Default log level: info for our crates, warn for dependencies "goingson_desktop=info,goingson_core=debug,goingson_db_sqlite=debug,warn".into() })) .with(tracing_subscriber::fmt::layer()) .init(); info!("Starting GoingsOn application"); let mut builder = tauri::Builder::default(); // Desktop-only plugins #[cfg(not(any(target_os = "ios", target_os = "android")))] { builder = builder .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_window_state::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()); } builder .plugin(tauri_plugin_dialog::init()) .menu(|app| { // App menu (macOS) - contains About, Settings, Quit #[cfg(target_os = "macos")] let app_menu = Submenu::with_items( app, "GoingsOn", true, &[ &MenuItem::with_id(app, "about", "About GoingsOn", true, None::<&str>)?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "settings", "Settings...", true, Some("CmdOrCtrl+,"))?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::hide(app, Some("Hide GoingsOn"))?, &PredefinedMenuItem::hide_others(app, Some("Hide Others"))?, &PredefinedMenuItem::show_all(app, Some("Show All"))?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::quit(app, Some("Quit GoingsOn"))?, ], )?; // File menu let file_menu = Submenu::with_items( app, "File", true, &[ &MenuItem::with_id(app, "new_task", "New Task", true, Some("CmdOrCtrl+N"))?, &MenuItem::with_id( app, "new_project", "New Project", true, Some("CmdOrCtrl+Shift+N"), )?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "import", "Import...", true, Some("CmdOrCtrl+I"))?, &MenuItem::with_id(app, "save_view", "Save View", true, Some("CmdOrCtrl+S"))?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::close_window(app, Some("Close Window"))?, #[cfg(not(target_os = "macos"))] &PredefinedMenuItem::separator(app)?, #[cfg(not(target_os = "macos"))] &PredefinedMenuItem::quit(app, Some("Exit"))?, ], )?; // Edit menu let edit_menu = Submenu::with_items( app, "Edit", true, &[ &PredefinedMenuItem::undo(app, Some("Undo"))?, &PredefinedMenuItem::redo(app, Some("Redo"))?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::cut(app, Some("Cut"))?, &PredefinedMenuItem::copy(app, Some("Copy"))?, &PredefinedMenuItem::paste(app, Some("Paste"))?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::select_all(app, Some("Select All"))?, ], )?; // View menu - grouped by tab let work_submenu = Submenu::with_items( app, "Work", true, &[ &MenuItem::with_id(app, "view_tasks", "Tasks", true, None::<&str>)?, &MenuItem::with_id(app, "view_projects", "Projects", true, None::<&str>)?, ], )?; let time_submenu = Submenu::with_items( app, "Time", true, &[ &MenuItem::with_id(app, "view_day_plan", "Day", true, None::<&str>)?, &MenuItem::with_id( app, "view_weekly_review", "Week", true, None::<&str>, )?, &MenuItem::with_id( app, "view_monthly_review", "Month", true, None::<&str>, )?, &MenuItem::with_id(app, "view_events", "Events", true, None::<&str>)?, ], )?; let messages_submenu = Submenu::with_items( app, "Messages", true, &[ &MenuItem::with_id(app, "view_emails", "Email", true, None::<&str>)?, &MenuItem::with_id(app, "view_contacts", "Contacts", true, None::<&str>)?, ], )?; let view_menu = Submenu::with_items( app, "View", true, &[ &MenuItem::with_id(app, "view_work", "Work", true, Some("CmdOrCtrl+1"))?, &MenuItem::with_id(app, "view_time", "Time", true, Some("CmdOrCtrl+2"))?, &MenuItem::with_id( app, "view_messages", "Messages", true, Some("CmdOrCtrl+3"), )?, &PredefinedMenuItem::separator(app)?, &work_submenu, &time_submenu, &messages_submenu, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id( app, "toggle_sidebar", "Toggle Sidebar", true, Some("CmdOrCtrl+\\"), )?, ], )?; // Tools menu let tools_menu = Submenu::with_items( app, "Tools", true, &[ &MenuItem::with_id( app, "sync_email", "Sync Email", true, Some("CmdOrCtrl+Shift+E"), )?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "settings", "Settings", true, Some("CmdOrCtrl+,"))?, ], )?; // Help menu let help_menu = Submenu::with_items( app, "Help", true, &[ &MenuItem::with_id( app, "keyboard_shortcuts", "Keyboard Shortcuts", true, Some("?"), )?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "check_updates", "Check for Updates...", true, None::<&str>)?, &PredefinedMenuItem::separator(app)?, &MenuItem::with_id(app, "about", "About GoingsOn", true, None::<&str>)?, ], )?; #[cfg(target_os = "macos")] { Menu::with_items( app, &[&app_menu, &file_menu, &edit_menu, &view_menu, &tools_menu, &help_menu], ) } #[cfg(not(target_os = "macos"))] { Menu::with_items( app, &[&file_menu, &edit_menu, &view_menu, &tools_menu, &help_menu], ) } }) .on_menu_event(|app, event| { let event_id = event.id().as_ref(); if let Some(window) = app.get_webview_window("main") { let _ = window.emit(&format!("menu:{}", event_id), ()); } }) .setup(|app| { // Set up menu bar tray icon (desktop only) #[cfg(not(any(target_os = "ios", target_os = "android")))] { if let Err(e) = setup_tray(app) { tracing::warn!("Failed to set up tray icon: {}", e); } } // Initialize database let app_handle = app.handle().clone(); tauri::async_runtime::block_on(async move { let state = match AppState::new(&app_handle).await { Ok(state) => state, Err(e) => fatal_startup_error(&e), }; // Reclaim orphaned attachment blobs before the schedulers spawn // and before the UI can create attachments — race-free here. blob_gc::reconcile(&state.pool, &state.data_dir).await; app_handle.manage(Arc::new(state)); }); // Create shutdown coordination handles let cancel_token = CancellationToken::new(); let db_watcher_shutdown = Arc::new(AtomicBool::new(false)); // Store shutdown handles in Tauri managed state for the run event handler app.manage(cancel_token.clone()); app.manage(db_watcher_shutdown.clone()); // Desktop-only background services #[cfg(not(any(target_os = "ios", target_os = "android")))] { // Start background notification checker let handle = app.handle().clone(); let notify_cancel = cancel_token.clone(); tauri::async_runtime::spawn(async move { notifications::start_snooze_watcher(handle, notify_cancel).await; }); // Start database file watcher for external changes let watcher_handle = app.handle().clone(); db_watcher::start_db_watcher(watcher_handle, db_watcher_shutdown); } // Start background backup scheduler (works on all platforms) let backup_handle = app.handle().clone(); let backup_cancel = cancel_token.clone(); tauri::async_runtime::spawn(async move { backup_scheduler::start_backup_scheduler(backup_handle, backup_cancel).await; }); // Start background email sync scheduler (works on all platforms) let sync_handle = app.handle().clone(); let email_cancel = cancel_token.clone(); tauri::async_runtime::spawn(async move { email_sync_scheduler::start_email_sync_scheduler(sync_handle, email_cancel).await; }); // Start background cloud sync scheduler let cloud_sync_handle = app.handle().clone(); let cloud_cancel = cancel_token; tauri::async_runtime::spawn(async move { sync_scheduler::start_sync_scheduler(cloud_sync_handle, cloud_cancel).await; }); // Clean up stale email preview + attachment temp files from previous sessions tauri::async_runtime::spawn(async { commands::cleanup_stale_temp_files().await; commands::cleanup_stale_attachment_temp().await; }); // Check for OTA updates after a short delay (desktop only). // Gated on the user's preference (default true). #[cfg(not(any(target_os = "ios", target_os = "android")))] { let update_handle = app.handle().clone(); if commands::load_preferences(&update_handle).update_check_on_launch { tauri::async_runtime::spawn(async move { // Wait before checking to let the app finish starting tokio::time::sleep(std::time::Duration::from_secs(10)).await; check_for_updates(update_handle).await; }); } } Ok(()) }) .invoke_handler(goingson_desktop::all_commands!()) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { if let RunEvent::Exit = event { info!("Application exiting, signaling background tasks to stop"); // Cancel all async schedulers if let Some(token) = app_handle.try_state::() { token.cancel(); } // Signal db_watcher threads to stop if let Some(flag) = app_handle.try_state::>() { flag.store(true, Ordering::Relaxed); } } }); } /// Check for OTA updates and emit an event to the frontend if one is available. #[cfg(not(any(target_os = "ios", target_os = "android")))] async fn check_for_updates(app: tauri::AppHandle) { use tauri_plugin_updater::UpdaterExt; let updater = match app.updater() { Ok(u) => u, Err(e) => { tracing::warn!("Failed to initialize updater: {e}"); return; } }; match updater.check().await { Ok(Some(update)) => { info!("Update available: v{}", update.version); let _ = app.emit( "update-available", serde_json::json!({ "version": update.version, "body": update.body.unwrap_or_default(), }), ); } Ok(None) => { info!("App is up to date"); } Err(e) => { tracing::warn!("Update check failed: {e}"); } } }