use rusqlite::Connection; use std::sync::Mutex; use std::path::PathBuf; use tauri::Manager; mod database; mod commands; #[cfg(target_os = "windows")] #[path = "os_detection_windows.rs"] mod os_detection; #[cfg(target_os = "linux")] #[path = "os_detection_linux.rs"] mod os_detection; pub struct AppState { pub db: Mutex, pub data_dir: PathBuf, } fn get_data_dir() -> PathBuf { // On Linux AppImage: $APPIMAGE points to the .AppImage file itself. // Store data next to the AppImage so it's fully portable. let base = if let Ok(appimage_path) = std::env::var("APPIMAGE") { PathBuf::from(appimage_path) .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| std::env::current_exe().unwrap().parent().unwrap().to_path_buf()) } else { std::env::current_exe().unwrap().parent().unwrap().to_path_buf() }; let data_dir = base.join("data"); std::fs::create_dir_all(&data_dir).ok(); data_dir } #[cfg_attr(mobile, tauri::mobile_entry_point)] /// On Linux AppImage, install a .desktop file and icon into the user's local /// XDG directories so GNOME/KDE can show the correct dock icon. Also cleans up /// stale entries if the AppImage has been moved or deleted. #[cfg(target_os = "linux")] fn install_desktop_entry() { let appimage_path = match std::env::var("APPIMAGE") { Ok(p) => p, Err(_) => return, // Not running as AppImage }; let appdir = std::env::var("APPDIR").unwrap_or_default(); let home = match std::env::var("HOME") { Ok(h) => h, Err(_) => return, }; // Install .desktop file let apps_dir = format!("{home}/.local/share/applications"); std::fs::create_dir_all(&apps_dir).ok(); let desktop_content = format!( "[Desktop Entry]\n\ Name=ZeroClock\n\ Comment=Local time tracking with invoicing\n\ Exec={appimage_path}\n\ Icon=zeroclock\n\ Type=Application\n\ Terminal=false\n\ StartupWMClass=zeroclock\n\ Categories=Office;ProjectManagement;\n" ); std::fs::write(format!("{apps_dir}/zeroclock.desktop"), &desktop_content).ok(); // Install icons from the AppImage's bundled hicolor theme if !appdir.is_empty() { for size in &["32x32", "128x128", "256x256@2"] { let src = format!("{appdir}/usr/share/icons/hicolor/{size}/apps/zeroclock.png"); if std::path::Path::new(&src).exists() { let dest_dir = format!("{home}/.local/share/icons/hicolor/{size}/apps"); std::fs::create_dir_all(&dest_dir).ok(); std::fs::copy(&src, format!("{dest_dir}/zeroclock.png")).ok(); } } } } pub fn run() { env_logger::init(); #[cfg(target_os = "linux")] install_desktop_entry(); let data_dir = get_data_dir(); let db_path = data_dir.join("timetracker.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); database::init_db(&conn).expect("Failed to initialize database"); commands::seed_default_templates(&data_dir); tauri::Builder::default() .plugin(tauri_plugin_window_state::Builder::new() .with_denylist(&["mini-timer"]) .build()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .manage(AppState { db: Mutex::new(conn), data_dir: data_dir.clone() }) .invoke_handler(tauri::generate_handler![ commands::get_clients, commands::create_client, commands::update_client, commands::delete_client, commands::get_client_dependents, commands::get_projects, commands::create_project, commands::update_project, commands::delete_project, commands::get_project_dependents, commands::get_tasks, commands::create_task, commands::delete_task, commands::update_task, commands::get_time_entries, commands::create_time_entry, commands::update_time_entry, commands::delete_time_entry, commands::get_reports, commands::create_invoice, commands::get_invoices, commands::update_invoice, commands::delete_invoice, commands::update_invoice_template, commands::get_invoice_items, commands::create_invoice_item, commands::delete_invoice_items, commands::save_invoice_items_batch, commands::get_settings, commands::update_settings, commands::export_data, commands::clear_all_data, commands::get_idle_seconds, commands::get_visible_windows, commands::get_running_processes, commands::get_tracked_apps, commands::add_tracked_app, commands::remove_tracked_app, commands::get_tags, commands::create_tag, commands::update_tag, commands::delete_tag, commands::get_entry_tags, commands::set_entry_tags, commands::get_project_budget_status, commands::get_favorites, commands::create_favorite, commands::delete_favorite, commands::reorder_favorites, commands::get_goal_progress, commands::get_profitability_report, commands::get_timesheet_data, commands::import_entries, commands::import_json_data, commands::save_binary_file, commands::open_mini_timer, commands::close_mini_timer, commands::get_invoice_templates, commands::get_recurring_entries, commands::create_recurring_entry, commands::update_recurring_entry, commands::delete_recurring_entry, commands::update_recurring_last_triggered, commands::get_expenses, commands::create_expense, commands::update_expense, commands::delete_expense, commands::get_uninvoiced_expenses, commands::mark_expenses_invoiced, commands::get_timeline_events, commands::create_timeline_event, commands::update_timeline_event_ended, commands::delete_timeline_events, commands::clear_all_timeline_data, commands::get_calendar_sources, commands::create_calendar_source, commands::update_calendar_source, commands::delete_calendar_source, commands::import_ics_file, commands::get_calendar_events, commands::lock_timesheet_week, commands::unlock_timesheet_week, commands::get_timesheet_locks, commands::is_week_locked, commands::update_invoice_status, commands::check_overdue_invoices, commands::get_time_entries_paginated, commands::bulk_delete_entries, commands::bulk_update_entries_project, commands::bulk_update_entries_billable, commands::upsert_timesheet_entry, commands::get_entry_templates, commands::create_entry_template, commands::delete_entry_template, commands::update_entry_template, commands::get_timesheet_rows, commands::save_timesheet_rows, commands::get_previous_week_structure, commands::auto_backup, commands::search_entries, commands::list_backup_files, commands::delete_backup_file, commands::get_recent_descriptions, commands::check_entry_overlap, commands::get_task_actuals, commands::get_invoice_payments, commands::add_invoice_payment, commands::delete_invoice_payment, commands::get_recurring_invoices, commands::create_recurring_invoice, commands::update_recurring_invoice, commands::delete_recurring_invoice, commands::check_recurring_invoices, commands::quit_app, commands::get_platform, commands::play_sound, ]) .setup(|app| { // On Wayland, `decorations: false` in tauri.conf.json is ignored due to a // GTK bug. We set an empty CSD titlebar so the compositor doesn't add // rectangular SSD, then strip GTK's default CSD shadow/border via CSS. // See: https://github.com/tauri-apps/tauri/issues/6562 #[cfg(target_os = "linux")] { use gtk::prelude::{CssProviderExt, GtkWindowExt, WidgetExt}; if let Some(window) = app.get_webview_window("main") { if let Ok(gtk_window) = window.gtk_window() { // Strip GTK's CSD shadow and border from the decoration node let provider = gtk::CssProvider::new(); provider.load_from_data(b"\ decoration { \ box-shadow: none; \ margin: 0; \ border: none; \ } \ ").ok(); if let Some(screen) = WidgetExt::screen(>k_window) { gtk::StyleContext::add_provider_for_screen( &screen, &provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1, ); } // Set an empty zero-height CSD titlebar so the compositor // doesn't add rectangular server-side decorations. // Suppress the harmless "called on a realized window" GTK warning // by briefly redirecting stderr to /dev/null. let empty = gtk::Box::new(gtk::Orientation::Horizontal, 0); empty.set_size_request(-1, 0); empty.set_visible(true); unsafe { extern "C" { fn dup(fd: i32) -> i32; fn dup2(fd: i32, fd2: i32) -> i32; fn close(fd: i32) -> i32; } let saved = dup(2); if let Ok(devnull) = std::fs::File::open("/dev/null") { use std::os::unix::io::AsRawFd; dup2(devnull.as_raw_fd(), 2); gtk_window.set_titlebar(Some(&empty)); dup2(saved, 2); } else { gtk_window.set_titlebar(Some(&empty)); } if saved >= 0 { close(saved); } } } } } #[cfg(desktop)] { use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::menu::{Menu, MenuItem}; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?; let menu = Menu::with_items(app, &[&show, &quit])?; let _tray = TrayIconBuilder::new() .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| { match event.id.as_ref() { "quit" => { app.exit(0); } "show" => { if let Some(window) = app.get_webview_window("main") { window.show().ok(); window.set_focus().ok(); } } _ => {} } }) .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(window) = app.get_webview_window("main") { window.show().ok(); window.set_focus().ok(); } } }) .build(app)?; } Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); }