#![cfg_attr( all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows" )] #[cfg(target_os = "macos")] #[macro_use] extern crate objc; use std::collections::HashMap; use std::env::current_dir; use std::fs::{create_dir_all, File}; use std::process::exit; use fern::colors::ColoredLevelConfig; use log::{debug, info, warn}; use rand::random; use serde::Serialize; use sqlx::{Pool, Sqlite, SqlitePool}; use sqlx::migrate::Migrator; use sqlx::types::Json; use tauri::{AppHandle, Menu, RunEvent, State, Submenu, Window, WindowUrl, Wry}; use tauri::{CustomMenuItem, Manager, WindowEvent}; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; use tauri_plugin_log::{fern, LogTarget}; use tauri_plugin_window_state::{StateFlags, WindowExt}; use tokio::sync::Mutex; use window_ext::TrafficLightWindowExt; use crate::analytics::{AnalyticsAction, AnalyticsResource, track_event}; use crate::plugin::{ImportResources, ImportResult}; use crate::send::actually_send_request; use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater}; mod analytics; mod models; mod plugin; mod render; mod send; mod updates; mod window_ext; mod window_menu; #[derive(serde::Serialize)] pub struct CustomResponse { status: u16, body: String, url: String, method: String, elapsed: u128, elapsed2: u128, headers: HashMap, pub status_reason: Option<&'static str>, } async fn migrate_db( app_handle: AppHandle, db_instance: &Mutex>, ) -> Result<(), String> { let pool = &*db_instance.lock().await; let p = app_handle .path_resolver() .resolve_resource("migrations") .expect("failed to resolve resource"); info!("Running migrations at {}", p.to_string_lossy()); let m = Migrator::new(p).await.expect("Failed to load migrations"); m.run(pool).await.expect("Failed to run migrations"); info!("Migrations complete"); Ok(()) } #[tauri::command] async fn send_ephemeral_request( mut request: models::HttpRequest, environment_id: Option<&str>, app_handle: AppHandle, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let response = models::HttpResponse::new(); let environment_id2 = environment_id.unwrap_or("n/a").to_string(); request.id = "".to_string(); actually_send_request(request, &response, &environment_id2, &app_handle, pool).await } #[tauri::command] async fn import_data( window: Window, db_instance: State<'_, Mutex>>, file_paths: Vec<&str>, ) -> Result { let pool = &*db_instance.lock().await; let mut result: Option = None; let plugins = vec!["importer-yaak", "importer-insomnia", "importer-postman"]; for plugin_name in plugins { if let Some(r) = plugin::run_plugin_import( &window.app_handle(), plugin_name, file_paths.first().unwrap(), ) .await { result = Some(r); break; } } match result { None => Err("No importers found for the chosen file".to_string()), Some(r) => { let mut imported_resources = ImportResources::default(); info!("Importing resources"); for w in r.resources.workspaces { let x = models::upsert_workspace(pool, w) .await .expect("Failed to create workspace"); imported_resources.workspaces.push(x.clone()); info!("Imported workspace: {}", x.name); } for e in r.resources.environments { let x = models::upsert_environment(pool, e) .await .expect("Failed to create environment"); imported_resources.environments.push(x.clone()); info!("Imported environment: {}", x.name); } for f in r.resources.folders { let x = models::upsert_folder(pool, f) .await .expect("Failed to create folder"); imported_resources.folders.push(x.clone()); info!("Imported folder: {}", x.name); } for r in r.resources.requests { let x = models::upsert_request(pool, r) .await .expect("Failed to create request"); imported_resources.requests.push(x.clone()); info!("Imported request: {}", x.name); } Ok(imported_resources) } } } #[tauri::command] async fn export_data( app_handle: AppHandle, db_instance: State<'_, Mutex>>, export_path: &str, workspace_id: &str, ) -> Result<(), String> { let pool = &*db_instance.lock().await; let export_data = models::get_workspace_export_resources(&app_handle, pool, workspace_id).await; let f = File::options() .create(true) .truncate(true) .write(true) .open(export_path) .expect("Unable to create file"); serde_json::to_writer_pretty(&f, &export_data) .map_err(|e| e.to_string()) .expect("Failed to write"); f.sync_all().expect("Failed to sync"); info!("Exported Yaak workspace to {:?}", export_path); Ok(()) } #[tauri::command] async fn send_request( window: Window, db_instance: State<'_, Mutex>>, request_id: &str, environment_id: Option<&str>, ) -> Result { let pool = &*db_instance.lock().await; let req = models::get_request(request_id, pool) .await .expect("Failed to get request"); let response = models::create_response(&req.id, 0, "", 0, None, None, None, None, vec![], pool) .await .expect("Failed to create response"); let response2 = response.clone(); let environment_id2 = environment_id.unwrap_or("n/a").to_string(); let app_handle2 = window.app_handle().clone(); let pool2 = pool.clone(); tokio::spawn(async move { if let Err(e) = actually_send_request(req, &response2, &environment_id2, &app_handle2, &pool2).await { response_err(&response2, e, &app_handle2, &pool2) .await .expect("Failed to update response"); } }); emit_and_return(&window, "created_model", response) } async fn response_err( response: &models::HttpResponse, error: String, app_handle: &AppHandle, pool: &Pool, ) -> Result { let mut response = response.clone(); response.elapsed = -1; response.error = Some(error.clone()); response = models::update_response_if_id(&response, pool) .await .expect("Failed to update response"); emit_side_effect(app_handle, "updated_model", &response); Ok(response) } #[tauri::command] async fn set_update_mode( update_mode: &str, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { set_key_value("app", "update_mode", update_mode, window, db_instance).await } #[tauri::command] async fn get_key_value( namespace: &str, key: &str, db_instance: State<'_, Mutex>>, ) -> Result, ()> { let pool = &*db_instance.lock().await; let result = models::get_key_value(namespace, key, pool).await; Ok(result) } #[tauri::command] async fn set_key_value( namespace: &str, key: &str, value: &str, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let (key_value, created) = models::set_key_value(namespace, key, value, pool).await; if created { emit_and_return(&window, "created_model", key_value) } else { emit_and_return(&window, "updated_model", key_value) } } #[tauri::command] async fn create_workspace( name: &str, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let created_workspace = models::upsert_workspace( pool, models::Workspace { name: name.to_string(), ..Default::default() }, ) .await .expect("Failed to create Workspace"); emit_and_return(&window, "created_model", created_workspace) } #[tauri::command] async fn create_environment( workspace_id: &str, name: &str, variables: Vec, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let created_environment = models::upsert_environment( pool, models::Environment { workspace_id: workspace_id.to_string(), name: name.to_string(), variables: Json(variables), ..Default::default() }, ) .await .expect("Failed to create environment"); emit_and_return(&window, "created_model", created_environment) } #[tauri::command] async fn create_request( workspace_id: &str, name: &str, sort_priority: f64, folder_id: Option<&str>, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let created_request = models::upsert_request( pool, models::HttpRequest { workspace_id: workspace_id.to_string(), name: name.to_string(), method: "GET".to_string(), folder_id: folder_id.map(|s| s.to_string()), sort_priority, ..Default::default() }, ) .await .expect("Failed to create request"); emit_and_return(&window, "created_model", created_request) } #[tauri::command] async fn duplicate_request( id: &str, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let request = models::duplicate_request(id, pool) .await .expect("Failed to duplicate request"); emit_and_return(&window, "updated_model", request) } #[tauri::command] async fn update_workspace( workspace: models::Workspace, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let updated_workspace = models::upsert_workspace(pool, workspace) .await .expect("Failed to update request"); emit_and_return(&window, "updated_model", updated_workspace) } #[tauri::command] async fn update_environment( environment: models::Environment, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let updated_environment = models::upsert_environment(pool, environment) .await .expect("Failed to update environment"); emit_and_return(&window, "updated_model", updated_environment) } #[tauri::command] async fn update_request( request: models::HttpRequest, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let updated_request = models::upsert_request(pool, request) .await .expect("Failed to update request"); emit_and_return(&window, "updated_model", updated_request) } #[tauri::command] async fn delete_request( window: Window, db_instance: State<'_, Mutex>>, request_id: &str, ) -> Result { let pool = &*db_instance.lock().await; let req = models::delete_request(request_id, pool) .await .expect("Failed to delete request"); emit_and_return(&window, "deleted_model", req) } #[tauri::command] async fn list_folders( workspace_id: &str, db_instance: State<'_, Mutex>>, ) -> Result, String> { let pool = &*db_instance.lock().await; models::find_folders(workspace_id, pool) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn create_folder( workspace_id: &str, name: &str, sort_priority: f64, folder_id: Option<&str>, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let created_request = models::upsert_folder( pool, models::Folder { workspace_id: workspace_id.to_string(), name: name.to_string(), folder_id: folder_id.map(|s| s.to_string()), sort_priority, ..Default::default() }, ) .await .expect("Failed to create folder"); emit_and_return(&window, "created_model", created_request) } #[tauri::command] async fn update_folder( folder: models::Folder, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let updated_folder = models::upsert_folder(pool, folder) .await .expect("Failed to update request"); emit_and_return(&window, "updated_model", updated_folder) } #[tauri::command] async fn delete_folder( window: Window, db_instance: State<'_, Mutex>>, folder_id: &str, ) -> Result { let pool = &*db_instance.lock().await; let req = models::delete_folder(folder_id, pool) .await .expect("Failed to delete folder"); emit_and_return(&window, "deleted_model", req) } #[tauri::command] async fn delete_environment( window: Window, db_instance: State<'_, Mutex>>, environment_id: &str, ) -> Result { let pool = &*db_instance.lock().await; let req = models::delete_environment(environment_id, pool) .await .expect("Failed to delete environment"); emit_and_return(&window, "deleted_model", req) } #[tauri::command] async fn list_requests( workspace_id: &str, db_instance: State<'_, Mutex>>, ) -> Result, String> { let pool = &*db_instance.lock().await; let requests = models::find_requests(workspace_id, pool) .await .expect("Failed to find requests"); // .map_err(|e| e.to_string()) Ok(requests) } #[tauri::command] async fn list_environments( workspace_id: &str, db_instance: State<'_, Mutex>>, ) -> Result, String> { let pool = &*db_instance.lock().await; let environments = models::find_environments(workspace_id, pool) .await .expect("Failed to find environments"); Ok(environments) } #[tauri::command] async fn get_folder( id: &str, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; models::get_folder(id, pool) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn get_request( id: &str, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; models::get_request(id, pool) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn get_environment( id: &str, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; models::get_environment(id, pool) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn get_workspace( id: &str, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; models::get_workspace(id, pool) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn list_responses( request_id: &str, db_instance: State<'_, Mutex>>, ) -> Result, String> { let pool = &*db_instance.lock().await; models::find_responses(request_id, pool) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn delete_response( id: &str, window: Window, db_instance: State<'_, Mutex>>, ) -> Result { let pool = &*db_instance.lock().await; let response = models::delete_response(id, pool) .await .expect("Failed to delete response"); emit_and_return(&window, "deleted_model", response) } #[tauri::command] async fn delete_all_responses( request_id: &str, db_instance: State<'_, Mutex>>, ) -> Result<(), String> { let pool = &*db_instance.lock().await; models::delete_all_responses(request_id, pool) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn list_workspaces( db_instance: State<'_, Mutex>>, ) -> Result, String> { let pool = &*db_instance.lock().await; let workspaces = models::find_workspaces(pool) .await .expect("Failed to find workspaces"); if workspaces.is_empty() { let workspace = models::upsert_workspace( pool, models::Workspace { name: "Yaak".to_string(), ..Default::default() }, ) .await .expect("Failed to create Workspace"); Ok(vec![workspace]) } else { Ok(workspaces) } } #[tauri::command] async fn new_window(window: Window, url: &str) -> Result<(), String> { create_window(&window.app_handle(), Some(url)); Ok(()) } #[tauri::command] async fn delete_workspace( window: Window, db_instance: State<'_, Mutex>>, workspace_id: &str, ) -> Result { let pool = &*db_instance.lock().await; let workspace = models::delete_workspace(workspace_id, pool) .await .expect("Failed to delete Workspace"); emit_and_return(&window, "deleted_model", workspace) } #[tauri::command] async fn check_for_updates( app_handle: AppHandle, db_instance: State<'_, Mutex>>, yaak_updater: State<'_, Mutex>, ) -> Result<(), String> { let pool = &*db_instance.lock().await; let update_mode = get_update_mode(pool).await; yaak_updater .lock() .await .force_check(&app_handle, update_mode) .await .map_err(|e| e.to_string()) } fn main() { tauri::Builder::default() .plugin( tauri_plugin_log::Builder::default() .targets([LogTarget::LogDir, LogTarget::Stdout, LogTarget::Webview]) .level_for("tao", log::LevelFilter::Info) .level_for("sqlx", log::LevelFilter::Warn) .level_for("hyper", log::LevelFilter::Info) .level_for("tracing", log::LevelFilter::Info) .level_for("reqwest", log::LevelFilter::Debug) .with_colors(ColoredLevelConfig::default()) .level(log::LevelFilter::Trace) .build(), ) .plugin(tauri_plugin_window_state::Builder::default().build()) .setup(|app| { let dir = match is_dev() { true => current_dir().unwrap(), false => app.path_resolver().app_data_dir().unwrap(), }; create_dir_all(dir.clone()).expect("Problem creating App directory!"); let p = dir.join("db.sqlite"); File::options().write(true).create(true).open(&p).expect("Problem creating database file!"); let p_string = p.to_string_lossy().replace(' ', "%20"); let url = format!("sqlite://{}?mode=rwc", p_string); println!("Connecting to database at {}", url); tauri::async_runtime::block_on(async move { let pool = SqlitePool::connect(p.to_str().unwrap()) .await .expect("Failed to connect to database"); // Setup the DB handle let m = Mutex::new(pool.clone()); migrate_db(app.handle(), &m) .await .expect("Failed to migrate database"); app.manage(m); let yaak_updater = YaakUpdater::new(); app.manage(Mutex::new(yaak_updater)); let _ = models::cancel_pending_responses(&pool).await; Ok(()) }) }) .invoke_handler(tauri::generate_handler![ check_for_updates, create_environment, create_folder, create_request, create_workspace, delete_all_responses, delete_environment, delete_folder, delete_request, delete_response, delete_workspace, duplicate_request, export_data, get_key_value, get_environment, get_folder, get_request, get_workspace, import_data, list_environments, list_folders, list_requests, list_responses, list_workspaces, new_window, send_ephemeral_request, send_request, set_key_value, set_update_mode, update_environment, update_folder, update_request, update_workspace, ]) .build(tauri::generate_context!()) .expect("error while running tauri application") .run(|app_handle, event| { match event { RunEvent::Updater(updater_event) => match updater_event { tauri::UpdaterEvent::Pending => { debug!("Updater pending"); } tauri::UpdaterEvent::Updated => { debug!("Updater updated"); } tauri::UpdaterEvent::UpdateAvailable { body, version, date: _, } => { debug!("Updater update available body={} version={}", body, version); } tauri::UpdaterEvent::Downloaded => { debug!("Updater downloaded"); } tauri::UpdaterEvent::Error(e) => { warn!("Updater received error: {:?}", e); } _ => {} }, RunEvent::Ready => { let w = create_window(app_handle, None); w.restore_state(StateFlags::all()) .expect("Failed to restore window state"); track_event( app_handle, AnalyticsResource::App, AnalyticsAction::Launch, None, ); } RunEvent::WindowEvent { label: _label, event: WindowEvent::Focused(true), .. } => { let h = app_handle.clone(); // Run update check whenever window is focused tauri::async_runtime::spawn(async move { let val: State<'_, Mutex> = h.state(); let db_instance: State<'_, Mutex>> = h.state(); let pool = &*db_instance.lock().await; let update_mode = get_update_mode(pool).await; _ = val.lock().await.check(&h, update_mode).await; }); } _ => {} }; }); } fn is_dev() -> bool { let env = option_env!("YAAK_ENV"); env.unwrap_or("production") != "production" } fn create_window(handle: &AppHandle, url: Option<&str>) -> Window { let mut app_menu = window_menu::os_default("Yaak".to_string().as_str()); if is_dev() { let submenu = Submenu::new( "Developer", Menu::new() .add_item( CustomMenuItem::new("refresh".to_string(), "Refresh") .accelerator("CmdOrCtrl + Shift + r"), ) .add_item( CustomMenuItem::new("toggle_devtools".to_string(), "Open Devtools") .accelerator("CmdOrCtrl + Option + i"), ), ); app_menu = app_menu.add_submenu(submenu); } let window_num = handle.windows().len(); let window_id = format!("wnd_{}", window_num); let mut win_builder = tauri::WindowBuilder::new( handle, window_id, WindowUrl::App(url.unwrap_or_default().into()), ) .menu(app_menu) .fullscreen(false) .resizable(true) .inner_size(1100.0, 600.0) .position( // Randomly offset so windows don't stack exactly 100.0 + random::() * 30.0, 100.0 + random::() * 30.0, ) .title(handle.package_info().name.to_string()); // Add macOS-only things #[cfg(target_os = "macos")] { win_builder = win_builder .hidden_title(true) .title_bar_style(TitleBarStyle::Overlay); } let win = win_builder.build().expect("failed to build window"); let win2 = win.clone(); let handle2 = handle.clone(); win.on_menu_event(move |event| match event.menu_item_id() { "quit" => exit(0), "close" => win2.close().unwrap(), "zoom_reset" => win2.emit("zoom", 0).unwrap(), "zoom_in" => win2.emit("zoom", 1).unwrap(), "zoom_out" => win2.emit("zoom", -1).unwrap(), "toggle_sidebar" => win2.emit("toggle_sidebar", true).unwrap(), "focus_url" => win2.emit("focus_url", true).unwrap(), "focus_sidebar" => win2.emit("focus_sidebar", true).unwrap(), "send_request" => win2.emit("send_request", true).unwrap(), "new_request" => win2.emit("new_request", true).unwrap(), "toggle_settings" => win2.emit("toggle_settings", true).unwrap(), "duplicate_request" => win2.emit("duplicate_request", true).unwrap(), "refresh" => win2.eval("location.reload()").unwrap(), "new_window" => _ = create_window(&handle2, None), "toggle_devtools" => { if win2.is_devtools_open() { win2.close_devtools(); } else { win2.open_devtools(); } } _ => {} }); let win3 = win.clone(); win.on_window_event(move |e| { let apply_offset = || { win3.position_traffic_lights(); }; match e { WindowEvent::Resized(..) => apply_offset(), WindowEvent::ThemeChanged(..) => apply_offset(), WindowEvent::Focused(..) => apply_offset(), WindowEvent::ScaleFactorChanged { .. } => apply_offset(), WindowEvent::CloseRequested { .. } => { println!("CLOSE REQUESTED"); // api.prevent_close(); } _ => {} } }); win.position_traffic_lights(); win } /// Emit an event to all windows, with a source window fn emit_and_return( current_window: &Window, event: &str, payload: S, ) -> Result { current_window.emit_all(event, &payload).unwrap(); Ok(payload) } /// Emit an event to all windows, used for side-effects where there is no source window to attribute. This fn emit_side_effect(app_handle: &AppHandle, event: &str, payload: S) { app_handle.emit_all(event, &payload).unwrap(); } async fn get_update_mode(pool: &Pool) -> UpdateMode { let mode = models::get_key_value_string("app", "update_mode", pool) .await; match mode { Some(mode) => update_mode_from_str(&mode), None => UpdateMode::Stable, } }