Skip to main content

max / goingson

17.9 KB · 464 lines History Blame Raw
1 //! GoingsOn desktop application entry point.
2
3 // Prevents additional console window on Windows in release
4 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
5
6 use goingson_desktop::backup_scheduler;
7 use goingson_desktop::blob_gc;
8 use goingson_desktop::commands;
9 use goingson_desktop::email_sync_scheduler;
10 use goingson_desktop::sync_scheduler;
11 use goingson_desktop::state::AppState;
12 use std::sync::Arc;
13 use std::sync::atomic::{AtomicBool, Ordering};
14 use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
15 use tauri::{Emitter, Manager, RunEvent};
16 use tokio_util::sync::CancellationToken;
17 use tracing::info;
18 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
19
20 // Desktop-only imports
21 #[cfg(not(any(target_os = "ios", target_os = "android")))]
22 use goingson_desktop::db_watcher;
23 #[cfg(not(any(target_os = "ios", target_os = "android")))]
24 use goingson_desktop::notifications;
25 #[cfg(not(any(target_os = "ios", target_os = "android")))]
26 use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
27
28 /// Report a fatal startup error to the user, then exit.
29 ///
30 /// Never returns, and must not panic. This runs inside NSApp's
31 /// `didFinishLaunching` callback, which is nounwind, so a panic here aborts the
32 /// process and reduces the message to a bare SIGABRT with no diagnostic text.
33 ///
34 /// Uses rfd rather than tauri-plugin-dialog because Tauri's event loop has not
35 /// started yet at this point in setup: the plugin's `show` has no loop to paint
36 /// from or deliver its callback to, and its `blocking_show` is documented as
37 /// unsafe to call on the main thread. rfd drives a native modal directly.
38 #[cfg(not(any(target_os = "ios", target_os = "android")))]
39 fn fatal_startup_error(message: &str) -> ! {
40 tracing::error!("{}", message);
41
42 rfd::MessageDialog::new()
43 .set_level(rfd::MessageLevel::Error)
44 .set_title("GoingsOn cannot start")
45 .set_description(message)
46 .show();
47
48 std::process::exit(1);
49 }
50
51 /// Mobile has no rfd backend, so the error goes to the log and the process exits.
52 #[cfg(any(target_os = "ios", target_os = "android"))]
53 fn fatal_startup_error(message: &str) -> ! {
54 tracing::error!("{}", message);
55 std::process::exit(1);
56 }
57
58 /// Set up the macOS menu bar tray icon showing "GO" in Reglo.
59 #[cfg(not(any(target_os = "ios", target_os = "android")))]
60 fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
61 use tauri::menu::{MenuBuilder, MenuItemBuilder};
62
63 let show = MenuItemBuilder::with_id("tray_show", "Show GoingsOn").build(app)?;
64 let quit = MenuItemBuilder::with_id("tray_quit", "Quit").build(app)?;
65 let menu = MenuBuilder::new(app).items(&[&show, &quit]).build()?;
66
67 let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray-icon@2x.png"))?;
68
69 let _tray = TrayIconBuilder::new()
70 .icon(icon)
71 .icon_as_template(true)
72 .menu(&menu)
73 .show_menu_on_left_click(false)
74 .tooltip("GoingsOn")
75 .on_tray_icon_event(|tray, event| {
76 if let TrayIconEvent::Click {
77 button: MouseButton::Left,
78 button_state: MouseButtonState::Up,
79 ..
80 } = event
81 && let Some(window) = tray.app_handle().get_webview_window("main") {
82 let _ = window.show();
83 let _ = window.unminimize();
84 let _ = window.set_focus();
85 }
86 })
87 .on_menu_event(|app, event| match event.id().as_ref() {
88 "tray_show" => {
89 if let Some(window) = app.get_webview_window("main") {
90 let _ = window.show();
91 let _ = window.unminimize();
92 let _ = window.set_focus();
93 }
94 }
95 "tray_quit" => {
96 app.exit(0);
97 }
98 _ => {}
99 })
100 .build(app)?;
101
102 Ok(())
103 }
104
105 fn main() {
106 // Initialize structured logging with tracing
107 tracing_subscriber::registry()
108 .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
109 // Default log level: info for our crates, warn for dependencies
110 "goingson_desktop=info,goingson_core=debug,goingson_db_sqlite=debug,warn".into()
111 }))
112 .with(tracing_subscriber::fmt::layer())
113 .init();
114
115 info!("Starting GoingsOn application");
116
117 let mut builder = tauri::Builder::default();
118
119 // Desktop-only plugins
120 #[cfg(not(any(target_os = "ios", target_os = "android")))]
121 {
122 builder = builder
123 .plugin(tauri_plugin_shell::init())
124 .plugin(tauri_plugin_notification::init())
125 .plugin(tauri_plugin_window_state::Builder::new().build())
126 .plugin(tauri_plugin_updater::Builder::new().build())
127 .plugin(tauri_plugin_process::init());
128 }
129
130 builder
131 .plugin(tauri_plugin_dialog::init())
132 .menu(|app| {
133 // App menu (macOS) - contains About, Settings, Quit
134 #[cfg(target_os = "macos")]
135 let app_menu = Submenu::with_items(
136 app,
137 "GoingsOn",
138 true,
139 &[
140 &MenuItem::with_id(app, "about", "About GoingsOn", true, None::<&str>)?,
141 &PredefinedMenuItem::separator(app)?,
142 &MenuItem::with_id(app, "settings", "Settings...", true, Some("CmdOrCtrl+,"))?,
143 &PredefinedMenuItem::separator(app)?,
144 &PredefinedMenuItem::hide(app, Some("Hide GoingsOn"))?,
145 &PredefinedMenuItem::hide_others(app, Some("Hide Others"))?,
146 &PredefinedMenuItem::show_all(app, Some("Show All"))?,
147 &PredefinedMenuItem::separator(app)?,
148 &PredefinedMenuItem::quit(app, Some("Quit GoingsOn"))?,
149 ],
150 )?;
151
152 // File menu
153 let file_menu = Submenu::with_items(
154 app,
155 "File",
156 true,
157 &[
158 &MenuItem::with_id(app, "new_task", "New Task", true, Some("CmdOrCtrl+N"))?,
159 &MenuItem::with_id(
160 app,
161 "new_project",
162 "New Project",
163 true,
164 Some("CmdOrCtrl+Shift+N"),
165 )?,
166 &PredefinedMenuItem::separator(app)?,
167 &MenuItem::with_id(app, "import", "Import...", true, Some("CmdOrCtrl+I"))?,
168 &MenuItem::with_id(app, "save_view", "Save View", true, Some("CmdOrCtrl+S"))?,
169 &PredefinedMenuItem::separator(app)?,
170 &PredefinedMenuItem::close_window(app, Some("Close Window"))?,
171 #[cfg(not(target_os = "macos"))]
172 &PredefinedMenuItem::separator(app)?,
173 #[cfg(not(target_os = "macos"))]
174 &PredefinedMenuItem::quit(app, Some("Exit"))?,
175 ],
176 )?;
177
178 // Edit menu
179 let edit_menu = Submenu::with_items(
180 app,
181 "Edit",
182 true,
183 &[
184 &PredefinedMenuItem::undo(app, Some("Undo"))?,
185 &PredefinedMenuItem::redo(app, Some("Redo"))?,
186 &PredefinedMenuItem::separator(app)?,
187 &PredefinedMenuItem::cut(app, Some("Cut"))?,
188 &PredefinedMenuItem::copy(app, Some("Copy"))?,
189 &PredefinedMenuItem::paste(app, Some("Paste"))?,
190 &PredefinedMenuItem::separator(app)?,
191 &PredefinedMenuItem::select_all(app, Some("Select All"))?,
192 ],
193 )?;
194
195 // View menu - grouped by tab
196 let work_submenu = Submenu::with_items(
197 app,
198 "Work",
199 true,
200 &[
201 &MenuItem::with_id(app, "view_tasks", "Tasks", true, None::<&str>)?,
202 &MenuItem::with_id(app, "view_projects", "Projects", true, None::<&str>)?,
203 ],
204 )?;
205 let time_submenu = Submenu::with_items(
206 app,
207 "Time",
208 true,
209 &[
210 &MenuItem::with_id(app, "view_day_plan", "Day", true, None::<&str>)?,
211 &MenuItem::with_id(
212 app,
213 "view_weekly_review",
214 "Week",
215 true,
216 None::<&str>,
217 )?,
218 &MenuItem::with_id(
219 app,
220 "view_monthly_review",
221 "Month",
222 true,
223 None::<&str>,
224 )?,
225 &MenuItem::with_id(app, "view_events", "Events", true, None::<&str>)?,
226 ],
227 )?;
228 let messages_submenu = Submenu::with_items(
229 app,
230 "Messages",
231 true,
232 &[
233 &MenuItem::with_id(app, "view_emails", "Email", true, None::<&str>)?,
234 &MenuItem::with_id(app, "view_contacts", "Contacts", true, None::<&str>)?,
235 ],
236 )?;
237 let view_menu = Submenu::with_items(
238 app,
239 "View",
240 true,
241 &[
242 &MenuItem::with_id(app, "view_work", "Work", true, Some("CmdOrCtrl+1"))?,
243 &MenuItem::with_id(app, "view_time", "Time", true, Some("CmdOrCtrl+2"))?,
244 &MenuItem::with_id(
245 app,
246 "view_messages",
247 "Messages",
248 true,
249 Some("CmdOrCtrl+3"),
250 )?,
251 &PredefinedMenuItem::separator(app)?,
252 &work_submenu,
253 &time_submenu,
254 &messages_submenu,
255 &PredefinedMenuItem::separator(app)?,
256 &MenuItem::with_id(
257 app,
258 "toggle_sidebar",
259 "Toggle Sidebar",
260 true,
261 Some("CmdOrCtrl+\\"),
262 )?,
263 ],
264 )?;
265
266 // Tools menu
267 let tools_menu = Submenu::with_items(
268 app,
269 "Tools",
270 true,
271 &[
272 &MenuItem::with_id(
273 app,
274 "sync_email",
275 "Sync Email",
276 true,
277 Some("CmdOrCtrl+Shift+E"),
278 )?,
279 &PredefinedMenuItem::separator(app)?,
280 &MenuItem::with_id(app, "settings", "Settings", true, Some("CmdOrCtrl+,"))?,
281 ],
282 )?;
283
284 // Help menu
285 let help_menu = Submenu::with_items(
286 app,
287 "Help",
288 true,
289 &[
290 &MenuItem::with_id(
291 app,
292 "keyboard_shortcuts",
293 "Keyboard Shortcuts",
294 true,
295 Some("?"),
296 )?,
297 &PredefinedMenuItem::separator(app)?,
298 &MenuItem::with_id(app, "check_updates", "Check for Updates...", true, None::<&str>)?,
299 &PredefinedMenuItem::separator(app)?,
300 &MenuItem::with_id(app, "about", "About GoingsOn", true, None::<&str>)?,
301 ],
302 )?;
303
304 #[cfg(target_os = "macos")]
305 {
306 Menu::with_items(
307 app,
308 &[&app_menu, &file_menu, &edit_menu, &view_menu, &tools_menu, &help_menu],
309 )
310 }
311 #[cfg(not(target_os = "macos"))]
312 {
313 Menu::with_items(
314 app,
315 &[&file_menu, &edit_menu, &view_menu, &tools_menu, &help_menu],
316 )
317 }
318 })
319 .on_menu_event(|app, event| {
320 let event_id = event.id().as_ref();
321 if let Some(window) = app.get_webview_window("main") {
322 let _ = window.emit(&format!("menu:{}", event_id), ());
323 }
324 })
325 .setup(|app| {
326 // Set up menu bar tray icon (desktop only)
327 #[cfg(not(any(target_os = "ios", target_os = "android")))]
328 {
329 if let Err(e) = setup_tray(app) {
330 tracing::warn!("Failed to set up tray icon: {}", e);
331 }
332 }
333
334 // Initialize database
335 let app_handle = app.handle().clone();
336 tauri::async_runtime::block_on(async move {
337 let state = match AppState::new(&app_handle).await {
338 Ok(state) => state,
339 Err(e) => fatal_startup_error(&e),
340 };
341 // Reclaim orphaned attachment blobs before the schedulers spawn
342 // and before the UI can create attachments — race-free here.
343 blob_gc::reconcile(&state.pool, &state.data_dir).await;
344 app_handle.manage(Arc::new(state));
345 });
346
347 // Create shutdown coordination handles
348 let cancel_token = CancellationToken::new();
349 let db_watcher_shutdown = Arc::new(AtomicBool::new(false));
350
351 // Store shutdown handles in Tauri managed state for the run event handler
352 app.manage(cancel_token.clone());
353 app.manage(db_watcher_shutdown.clone());
354
355 // Desktop-only background services
356 #[cfg(not(any(target_os = "ios", target_os = "android")))]
357 {
358 // Start background notification checker
359 let handle = app.handle().clone();
360 let notify_cancel = cancel_token.clone();
361 tauri::async_runtime::spawn(async move {
362 notifications::start_snooze_watcher(handle, notify_cancel).await;
363 });
364
365 // Start database file watcher for external changes
366 let watcher_handle = app.handle().clone();
367 db_watcher::start_db_watcher(watcher_handle, db_watcher_shutdown);
368 }
369
370 // Start background backup scheduler (works on all platforms)
371 let backup_handle = app.handle().clone();
372 let backup_cancel = cancel_token.clone();
373 tauri::async_runtime::spawn(async move {
374 backup_scheduler::start_backup_scheduler(backup_handle, backup_cancel).await;
375 });
376
377 // Start background email sync scheduler (works on all platforms)
378 let sync_handle = app.handle().clone();
379 let email_cancel = cancel_token.clone();
380 tauri::async_runtime::spawn(async move {
381 email_sync_scheduler::start_email_sync_scheduler(sync_handle, email_cancel).await;
382 });
383
384 // Start background cloud sync scheduler
385 let cloud_sync_handle = app.handle().clone();
386 let cloud_cancel = cancel_token;
387 tauri::async_runtime::spawn(async move {
388 sync_scheduler::start_sync_scheduler(cloud_sync_handle, cloud_cancel).await;
389 });
390
391 // Clean up stale email preview + attachment temp files from previous sessions
392 tauri::async_runtime::spawn(async {
393 commands::cleanup_stale_temp_files().await;
394 commands::cleanup_stale_attachment_temp().await;
395 });
396
397 // Check for OTA updates after a short delay (desktop only).
398 // Gated on the user's preference (default true).
399 #[cfg(not(any(target_os = "ios", target_os = "android")))]
400 {
401 let update_handle = app.handle().clone();
402 if commands::load_preferences(&update_handle).update_check_on_launch {
403 tauri::async_runtime::spawn(async move {
404 // Wait before checking to let the app finish starting
405 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
406 check_for_updates(update_handle).await;
407 });
408 }
409 }
410
411 Ok(())
412 })
413 .invoke_handler(goingson_desktop::all_commands!())
414 .build(tauri::generate_context!())
415 .expect("error while building tauri application")
416 .run(|app_handle, event| {
417 if let RunEvent::Exit = event {
418 info!("Application exiting, signaling background tasks to stop");
419
420 // Cancel all async schedulers
421 if let Some(token) = app_handle.try_state::<CancellationToken>() {
422 token.cancel();
423 }
424
425 // Signal db_watcher threads to stop
426 if let Some(flag) = app_handle.try_state::<Arc<AtomicBool>>() {
427 flag.store(true, Ordering::Relaxed);
428 }
429 }
430 });
431 }
432
433 /// Check for OTA updates and emit an event to the frontend if one is available.
434 #[cfg(not(any(target_os = "ios", target_os = "android")))]
435 async fn check_for_updates(app: tauri::AppHandle) {
436 use tauri_plugin_updater::UpdaterExt;
437
438 let updater = match app.updater() {
439 Ok(u) => u,
440 Err(e) => {
441 tracing::warn!("Failed to initialize updater: {e}");
442 return;
443 }
444 };
445 match updater.check().await {
446 Ok(Some(update)) => {
447 info!("Update available: v{}", update.version);
448 let _ = app.emit(
449 "update-available",
450 serde_json::json!({
451 "version": update.version,
452 "body": update.body.unwrap_or_default(),
453 }),
454 );
455 }
456 Ok(None) => {
457 info!("App is up to date");
458 }
459 Err(e) => {
460 tracing::warn!("Update check failed: {e}");
461 }
462 }
463 }
464