324 lines
13 KiB
Rust
324 lines
13 KiB
Rust
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<Connection>,
|
|
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");
|
|
}
|