#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app_state;
mod config;
mod key_store;
mod models;
mod providers;
mod tray;
use app_state::AppState;
use models::{AccountInput, AccountView, AppSettings, DashboardState, LoginFailed, ProviderKind};
use std::time::Duration;
#[cfg(target_os = "macos")]
use tauri::menu::{IsMenuItem, PredefinedMenuItem, Submenu};
use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, LogicalUnit, Manager, PhysicalPosition,
PhysicalSize, PixelUnit, Position, Size, State, WindowSizeConstraints, Wry, menu::Menu,
};
const BACKGROUND_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const PREFERENCES_MIN_WIDTH: f64 = 360.0;
const PREFERENCES_MIN_HEIGHT: f64 = 360.0;
const PREFERENCES_MAX_CONTENT_WIDTH: f64 = 1180.0;
const PREFERENCES_SCREEN_MARGIN: f64 = 18.0;
const TRAY_CONTENT_WIDTH: f64 = 360.0;
const TRAY_MIN_HEIGHT: f64 = 200.0;
const TRAY_SCREEN_MARGIN: f64 = 8.0;
const TRAY_OFFSET_Y: f64 = 12.0;
#[tauri::command]
async fn dashboard(app: AppHandle, state: State<'_, AppState>) -> Result<DashboardState, String> {
let dashboard = state.dashboard().await.map_err(|error| error.to_string())?;
tray::update_summary(&app, &dashboard.tray_summary);
Ok(dashboard)
}
#[tauri::command]
fn list_accounts(state: State<'_, AppState>) -> Result<Vec<AccountView>, String> {
state.list_accounts().map_err(|error| error.to_string())
}
#[tauri::command]
fn save_account(
state: State<'_, AppState>,
input: AccountInput,
) -> Result<Vec<AccountView>, String> {
state.save_account(input).map_err(|error| error.to_string())
}
#[tauri::command]
fn save_settings(
app: AppHandle,
state: State<'_, AppState>,
settings: AppSettings,
) -> Result<AppSettings, String> {
let settings = state
.save_settings(settings)
.map_err(|error| error.to_string())?;
let _ = app.emit("burnrate-settings-updated", &settings);
Ok(settings)
}
#[tauri::command]
async fn remove_account(
state: State<'_, AppState>,
id: String,
) -> Result<Vec<AccountView>, String> {
state
.remove_account(&id)
.await
.map_err(|error| error.to_string())
}
#[tauri::command]
fn detect_accounts(state: State<'_, AppState>) -> Result<Vec<AccountView>, String> {
state.detect_accounts().map_err(|error| error.to_string())
}
#[tauri::command]
fn reorder_accounts(
state: State<'_, AppState>,
ids: Vec<String>,
) -> Result<Vec<AccountView>, String> {
state
.reorder_accounts(&ids)
.map_err(|error| error.to_string())
}
#[tauri::command]
async fn start_account_login(
app: AppHandle,
state: State<'_, AppState>,
provider: ProviderKind,
label: String,
account_id: Option<String>,
) -> Result<AccountView, String> {
state
.start_account_login(app.clone(), provider, label, account_id)
.map_err(|error| error.to_string())
}
#[tauri::command]
fn cancel_account_login(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<Vec<AccountView>, String> {
let (canceled, accounts) = state
.cancel_account_login(&id)
.map_err(|error| error.to_string())?;
if canceled {
let _ = app.emit(
"burnrate-login-failed",
LoginFailed {
id,
error: "Sign-in canceled.".to_string(),
},
);
}
Ok(accounts)
}
#[tauri::command]
async fn logout_account(
app: AppHandle,
state: State<'_, AppState>,
id: String,
) -> Result<Vec<AccountView>, String> {
let accounts = state
.logout_account(&id)
.await
.map_err(|error| error.to_string())?;
refresh_dashboard_for_app(&app).await;
Ok(accounts)
}
#[tauri::command]
async fn refresh_snapshots(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<DashboardState, String> {
let dashboard = state.dashboard().await.map_err(|error| error.to_string())?;
tray::update_summary(&app, &dashboard.tray_summary);
let _ = app.emit("burnrate-dashboard-updated", &dashboard);
Ok(dashboard)
}
#[tauri::command]
fn resize_preferences_to_content(app: AppHandle, width: f64, height: f64) -> Result<(), String> {
let window = app
.get_webview_window(tray::MAIN_WINDOW)
.ok_or_else(|| "preferences window is unavailable".to_string())?;
let scale_factor = window.scale_factor().map_err(|error| error.to_string())?;
let inner = window
.inner_size()
.map_err(|error| error.to_string())?
.to_logical::<f64>(scale_factor);
let outer = window
.outer_size()
.map_err(|error| error.to_string())?
.to_logical::<f64>(scale_factor);
let chrome_width = (outer.width - inner.width).max(0.0);
let chrome_height = (outer.height - inner.height).max(0.0);
let monitor = window
.current_monitor()
.map_err(|error| error.to_string())?
.or(window
.primary_monitor()
.map_err(|error| error.to_string())?)
.ok_or_else(|| "no monitor available for preferences window".to_string())?;
let work_area = monitor.work_area();
let work_size = work_area.size.to_logical::<f64>(monitor.scale_factor());
let work_position = work_area.position.to_logical::<f64>(monitor.scale_factor());
let available_width = (work_size.width - (PREFERENCES_SCREEN_MARGIN * 2.0)).max(1.0);
let available_height = (work_size.height - (PREFERENCES_SCREEN_MARGIN * 2.0)).max(1.0);
let min_width = PREFERENCES_MIN_WIDTH.min(available_width);
let min_height = PREFERENCES_MIN_HEIGHT.min(available_height);
let preferred_width = width.min(PREFERENCES_MAX_CONTENT_WIDTH);
let target_width = (preferred_width + chrome_width)
.ceil()
.clamp(min_width, available_width);
let target_height = (height + chrome_height)
.ceil()
.clamp(min_height, available_height);
window
.set_size_constraints(WindowSizeConstraints {
min_width: Some(PixelUnit::Logical(LogicalUnit::new(min_width))),
min_height: Some(PixelUnit::Logical(LogicalUnit::new(min_height))),
max_width: Some(PixelUnit::Logical(LogicalUnit::new(available_width))),
max_height: Some(PixelUnit::Logical(LogicalUnit::new(available_height))),
})
.map_err(|error| error.to_string())?;
window
.set_size(Size::Logical(LogicalSize::new(target_width, target_height)))
.map_err(|error| error.to_string())?;
let current_position = window
.outer_position()
.map_err(|error| error.to_string())?
.to_logical::<f64>(scale_factor);
let min_x = work_position.x + PREFERENCES_SCREEN_MARGIN;
let min_y = work_position.y + PREFERENCES_SCREEN_MARGIN;
let max_x =
(work_position.x + work_size.width - target_width - PREFERENCES_SCREEN_MARGIN).max(min_x);
let max_y =
(work_position.y + work_size.height - target_height - PREFERENCES_SCREEN_MARGIN).max(min_y);
let target_x = current_position.x.clamp(min_x, max_x);
let target_y = current_position.y.clamp(min_y, max_y);
window
.set_position(Position::Logical(LogicalPosition::new(target_x, target_y)))
.map_err(|error| error.to_string())
}
#[tauri::command]
fn resize_tray_to_content(
app: AppHandle,
state: State<'_, tray::TrayWindowState>,
height: f64,
) -> Result<(), String> {
let window = app
.get_webview_window(tray::TRAY_WINDOW)
.ok_or_else(|| "tray window is unavailable".to_string())?;
let scale_factor = window.scale_factor().map_err(|error| error.to_string())?;
let inner = window
.inner_size()
.map_err(|error| error.to_string())?
.to_logical::<f64>(scale_factor);
let outer = window
.outer_size()
.map_err(|error| error.to_string())?
.to_logical::<f64>(scale_factor);
let chrome_width = (outer.width - inner.width).max(0.0);
let chrome_height = (outer.height - inner.height).max(0.0);
let monitor = window
.current_monitor()
.map_err(|error| error.to_string())?
.or(window
.primary_monitor()
.map_err(|error| error.to_string())?)
.ok_or_else(|| "no monitor available for tray window".to_string())?;
let work_area = monitor.work_area();
let work_size = work_area.size.to_logical::<f64>(monitor.scale_factor());
let available_width = (work_size.width - (TRAY_SCREEN_MARGIN * 2.0)).max(1.0);
let available_height = (work_size.height - (TRAY_SCREEN_MARGIN * 2.0)).max(1.0);
let target_width = (TRAY_CONTENT_WIDTH + chrome_width)
.ceil()
.min(available_width);
let target_height = tray::clamp_tray_height(
height,
chrome_height,
work_size.height,
TRAY_SCREEN_MARGIN,
TRAY_MIN_HEIGHT,
);
window
.set_size_constraints(WindowSizeConstraints {
min_width: Some(PixelUnit::Logical(LogicalUnit::new(target_width))),
min_height: Some(PixelUnit::Logical(LogicalUnit::new(
TRAY_MIN_HEIGHT.min(target_height),
))),
max_width: Some(PixelUnit::Logical(LogicalUnit::new(target_width))),
max_height: Some(PixelUnit::Logical(LogicalUnit::new(available_height))),
})
.map_err(|error| error.to_string())?;
window
.set_size(Size::Logical(LogicalSize::new(target_width, target_height)))
.map_err(|error| error.to_string())?;
let m_scale = monitor.scale_factor();
let work_pos_phys =
PhysicalPosition::new(work_area.position.x as f64, work_area.position.y as f64);
let work_size_phys =
PhysicalSize::new(work_area.size.width as f64, work_area.size.height as f64);
let window_phys = PhysicalSize::new(target_width * m_scale, target_height * m_scale);
let margin = TRAY_SCREEN_MARGIN * m_scale;
let target = match state.anchor() {
Some(anchor) => tray::popup_position(
anchor,
window_phys,
work_pos_phys,
work_size_phys,
margin,
TRAY_OFFSET_Y * m_scale,
),
None => {
let current = window.outer_position().map_err(|error| error.to_string())?;
let min_x = work_pos_phys.x + margin;
let min_y = work_pos_phys.y + margin;
let max_x =
(work_pos_phys.x + work_size_phys.width - window_phys.width - margin).max(min_x);
let max_y =
(work_pos_phys.y + work_size_phys.height - window_phys.height - margin).max(min_y);
PhysicalPosition::new(
(current.x as f64).clamp(min_x, max_x),
(current.y as f64).clamp(min_y, max_y),
)
}
};
window
.set_position(PhysicalPosition::new(
target.x.round() as i32,
target.y.round() as i32,
))
.map_err(|error| error.to_string())
}
#[tauri::command]
fn close_preferences(app: AppHandle) {
tray::close_main_window(&app);
}
fn spawn_background_refresh(app: AppHandle) {
tauri::async_runtime::spawn(async move {
loop {
refresh_dashboard_for_app(&app).await;
tokio::time::sleep(BACKGROUND_REFRESH_INTERVAL).await;
}
});
}
async fn refresh_dashboard_for_app(app: &AppHandle) {
let state = app.state::<AppState>();
match state.dashboard().await {
Ok(dashboard) => {
tray::update_summary(app, &dashboard.tray_summary);
let _ = app.emit("burnrate-dashboard-updated", &dashboard);
}
Err(error) => {
eprintln!("Burnrate background refresh failed: {error}");
}
}
}
#[cfg(target_os = "macos")]
fn build_app_menu(app: &AppHandle<Wry>) -> tauri::Result<Menu<Wry>> {
let undo = PredefinedMenuItem::undo(app, None)?;
let redo = PredefinedMenuItem::redo(app, None)?;
let separator_one = PredefinedMenuItem::separator(app)?;
let cut = PredefinedMenuItem::cut(app, None)?;
let copy = PredefinedMenuItem::copy(app, None)?;
let paste = PredefinedMenuItem::paste(app, None)?;
let select_all = PredefinedMenuItem::select_all(app, None)?;
let separator_two = PredefinedMenuItem::separator(app)?;
let edit_items: [&dyn IsMenuItem<Wry>; 8] = [
&undo,
&redo,
&separator_one,
&cut,
©,
&paste,
&select_all,
&separator_two,
];
let edit = Submenu::with_items(app, "Edit", true, &edit_items)?;
let close = PredefinedMenuItem::close_window(app, None)?;
let window = Submenu::with_items(app, "Window", true, &[&close])?;
Menu::with_items(app, &[&edit, &window])
}
#[cfg(not(target_os = "macos"))]
fn build_app_menu(app: &AppHandle<Wry>) -> tauri::Result<Menu<Wry>> {
Menu::new(app)
}
fn main() {
let state = AppState::load().expect("failed to initialize Burnrate state");
tauri::Builder::default()
.menu(build_app_menu)
.manage(state)
.manage(tray::TrayWindowState::default())
.setup(move |app| {
tray::apply_activation_policy(app.handle(), true);
tray::set_dock_icon_if_unbundled();
if let Some(window) = app.get_webview_window(tray::MAIN_WINDOW) {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
tray::close_main_window(&app_handle);
}
});
}
if let Some(window) = app.get_webview_window(tray::TRAY_WINDOW) {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
tray::hide_tray_window(&app_handle);
}
});
}
tray::install(app)?;
tray::apply_tray_vibrancy(app.handle());
spawn_background_refresh(app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![
dashboard,
list_accounts,
save_account,
save_settings,
remove_account,
detect_accounts,
reorder_accounts,
start_account_login,
cancel_account_login,
logout_account,
refresh_snapshots,
resize_preferences_to_content,
resize_tray_to_content,
close_preferences
])
.run(tauri::generate_context!())
.expect("error while running Burnrate");
}