diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index b5df31b..aeff75f 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -19,6 +19,7 @@ use crate::{ ipc, server::Server, errors::*, + shortcuts, state::AppState, tray, }; @@ -99,7 +100,7 @@ async fn setup(app: &mut App) -> Result<(), Box> { if let Err(_e) = config::set_auto_launch(conf.start_on_login) { setup_errors.push("Error: Failed to manage autolaunch.".into()); } - if let Err(e) = config::register_hotkeys(&conf.hotkeys) { + if let Err(e) = shortcuts::register_hotkeys(&conf.hotkeys) { setup_errors.push(format!("{e}")); } diff --git a/src-tauri/src/bin/creddy_cli.rs b/src-tauri/src/bin/creddy_cli.rs index 5a13637..59e4c4f 100644 --- a/src-tauri/src/bin/creddy_cli.rs +++ b/src-tauri/src/bin/creddy_cli.rs @@ -21,7 +21,8 @@ fn main() { None | Some(("run", _)) => launch_gui(), Some(("get", m)) => cli::get(m), Some(("exec", m)) => cli::exec(m), - _ => unreachable!(), + Some(("shortcut", m)) => cli::invoke_shortcut(m), + _ => unreachable!("Unknown subcommand"), }; if let Err(e) = res { diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index f37d085..c4b8b86 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -4,15 +4,17 @@ use std::time::Duration; use clap::{ Command, - Arg, - ArgMatches, - ArgAction + Arg, + ArgMatches, + ArgAction, + builder::PossibleValuesParser, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::credentials::Credentials; use crate::errors::*; use crate::server::{Request, Response}; +use crate::shortcuts::ShortcutAction; #[cfg(unix)] use { @@ -63,6 +65,16 @@ pub fn parser() -> Command<'static> { .multiple_values(true) ) ) + .subcommand( + Command::new("shortcut") + .about("Invoke an action normally trigged by hotkey (e.g. launch terminal)") + .arg( + Arg::new("action") + .value_parser( + PossibleValuesParser::new(["show_window", "launch_terminal"]) + ) + ) + ) } @@ -129,10 +141,35 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> { } -#[tokio::main] -async fn get_credentials(base: bool) -> Result { +pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> { + let action = match args.get_one::("action").map(|s| s.as_str()) { + Some("show_window") => ShortcutAction::ShowWindow, + Some("launch_terminal") => ShortcutAction::LaunchTerminal, + Some(&_) | None => unreachable!("Unknown shortcut action"), // guaranteed by clap + }; + + let req = Request::InvokeShortcut(action); + match make_request(&req) { + Ok(Response::Empty) => Ok(()), + Ok(r) => Err(RequestError::Unexpected(r).into()), + Err(e) => Err(e.into()), + } +} + + +fn get_credentials(base: bool) -> Result { let req = Request::GetAwsCredentials { base }; - let mut data = serde_json::to_string(&req).unwrap(); + match make_request(&req) { + Ok(Response::Aws(creds)) => Ok(creds), + Ok(r) => Err(RequestError::Unexpected(r)), + Err(e) => Err(e), + } +} + + +#[tokio::main] +async fn make_request(req: &Request) -> Result { + let mut data = serde_json::to_string(req).unwrap(); // server expects newline marking end of request data.push('\n'); @@ -142,12 +179,7 @@ async fn get_credentials(base: bool) -> Result { let mut buf = Vec::with_capacity(1024); stream.read_to_end(&mut buf).await?; let res: Result = serde_json::from_slice(&buf)?; - match res { - Ok(Response::Aws(creds)) => Ok(creds), - // Eventually we will want this - // Ok(r) => Err(RequestError::Unexpected(r)), - Err(e) => Err(RequestError::Server(e)), - } + Ok(res?) } diff --git a/src-tauri/src/errors.rs b/src-tauri/src/errors.rs index a36ca09..537980d 100644 --- a/src-tauri/src/errors.rs +++ b/src-tauri/src/errors.rs @@ -26,12 +26,14 @@ use serde::{ }; -pub trait ErrorPopup { +pub trait ShowError { fn error_popup(self, title: &str); fn error_popup_nowait(self, title: &str); + fn error_print(self); + fn error_print_prefix(self, prefix: &str); } -impl ErrorPopup for Result<(), E> { +impl ShowError for Result<(), E> { fn error_popup(self, title: &str) { if let Err(e) = self { let (tx, rx) = mpsc::channel(); @@ -50,6 +52,18 @@ impl ErrorPopup for Result<(), E> { .show(|_| {}) } } + + fn error_print(self) { + if let Err(e) = self { + eprintln!("{e}"); + } + } + + fn error_print_prefix(self, prefix: &str) { + if let Err(e) = self { + eprintln!("{prefix}: {e}"); + } + } } @@ -164,6 +178,15 @@ pub enum HandlerError { } +#[derive(Debug, ThisError, AsRefStr)] +pub enum WindowError { + #[error("Failed to find main application window")] + NoMainWindow, + #[error(transparent)] + ManageFailure(#[from] tauri::Error), +} + + #[derive(Debug, ThisError, AsRefStr)] pub enum GetCredentialsError { #[error("Credentials are currently locked")] @@ -324,6 +347,7 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> { impl_serialize_basic!(SetupError); impl_serialize_basic!(GetCredentialsError); impl_serialize_basic!(ClientInfoError); +impl_serialize_basic!(WindowError); impl Serialize for HandlerError { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c1fbeb..70c2e98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,5 +7,6 @@ mod clientinfo; mod ipc; mod state; mod server; +mod shortcuts; mod terminal; mod tray; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 547992f..6b6f9ba 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,7 +6,7 @@ use creddy::{ app, cli, - errors::ErrorPopup, + errors::ShowError, }; diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 461b84f..0afa5c6 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - #[cfg(windows)] use tokio::net::windows::named_pipe::{ NamedPipeServer, @@ -21,6 +19,7 @@ use crate::clientinfo::{self, Client}; use crate::credentials::Credentials; use crate::ipc::{Approval, AwsRequestNotification}; use crate::state::AppState; +use crate::shortcuts::{self, ShortcutAction}; #[derive(Serialize, Deserialize)] @@ -28,12 +27,14 @@ pub enum Request { GetAwsCredentials{ base: bool, }, + InvokeShortcut(ShortcutAction), } #[derive(Debug, Serialize, Deserialize)] pub enum Response { - Aws(Credentials) + Aws(Credentials), + Empty, } @@ -102,17 +103,25 @@ async fn handle(stream: &mut NamedPipeServer, app_handle: AppHandle) -> Result get_aws_credentials(base, client, app_handle).await, - // etc + Request::InvokeShortcut(action) => invoke_shortcut(action).await, } } +async fn invoke_shortcut(action: ShortcutAction) -> Result { + shortcuts::exec_shortcut(action); + Ok(Response::Empty) +} + + async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) -> Result { let state = app_handle.state::(); - - let main_window = app_handle.get_window("main").ok_or(HandlerError::NoMainWindow)?; - let is_currently_visible = main_window.is_visible()?; - let rehide_after = state.get_or_set_rehide(!is_currently_visible).await; + let rehide_ms = { + let config = state.config.read().await; + config.rehide_ms + }; + let lease = state.acquire_visibility_lease(rehide_ms).await + .map_err(|_e| HandlerError::NoMainWindow)?; // automate this conversion eventually? let (chan_send, chan_recv) = oneshot::channel(); let request_id = state.register_request(chan_send).await; @@ -124,12 +133,6 @@ async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) let notification = AwsRequestNotification {id: request_id, client, base}; app_handle.emit_all("credentials-request", ¬ification)?; - if !main_window.is_visible()? { - main_window.unminimize()?; - main_window.show()?; - } - main_window.set_focus()?; - match chan_recv.await { Ok(Approval::Approved) => { if base { @@ -154,31 +157,6 @@ async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) } }; - rt::spawn( - handle_rehide(rehide_after, app_handle.app_handle()) - ); + lease.release(); result } - - -async fn handle_rehide(rehide_after: bool, app_handle: AppHandle) { - let state = app_handle.state::(); - let delay = { - let config = state.config.read().await; - Duration::from_millis(config.rehide_ms) - }; - tokio::time::sleep(delay).await; - - // if there are no other pending requests, set rehide status back to None - if state.req_count().await == 0 { - state.clear_rehide().await; - // and hide the window if necessary - if rehide_after { - app_handle.get_window("main").map(|w| { - if let Err(e) = w.hide() { - eprintln!("{e}"); - } - }); - } - } -} diff --git a/src-tauri/src/shortcuts.rs b/src-tauri/src/shortcuts.rs index c40d758..84abdc0 100644 --- a/src-tauri/src/shortcuts.rs +++ b/src-tauri/src/shortcuts.rs @@ -1,12 +1,14 @@ use serde::{Serialize, Deserialize}; use tauri::{ - AppHandle, + GlobalShortcutManager, Manager, + async_runtime as rt, }; use crate::app::APP; use crate::config::HotkeysConfig; +use crate::errors::*; use crate::terminal; @@ -19,11 +21,18 @@ pub enum ShortcutAction { pub fn exec_shortcut(action: ShortcutAction) { match action { - ShowWindow => { + ShortcutAction::ShowWindow => { let app = APP.get().unwrap(); - app.get_window("main").map(|w| w.show()); + app.get_window("main") + .ok_or("Couldn't find application main window") + .map(|w| w.show().error_popup("Failed to show window")) + .error_popup("Failed to show window"); + }, + ShortcutAction::LaunchTerminal => { + rt::spawn(async { + terminal::launch(false).await.error_popup("Failed to launch terminal"); + }); }, - LaunchTerminal => terminal::launch(false), } } @@ -35,7 +44,7 @@ pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> { if hotkeys.show_window.enabled { manager.register( - hotkeys.show_window.keys, + &hotkeys.show_window.keys, || exec_shortcut(ShortcutAction::ShowWindow) )?; } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7cd266c..cd30af1 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,10 +1,15 @@ use std::collections::HashMap; +use std::time::Duration; use tokio::{ sync::RwLock, - sync::oneshot::Sender, + sync::oneshot::{self, Sender}, }; use sqlx::SqlitePool; +use tauri::{ + Manager, + async_runtime as rt, +}; use crate::credentials::{ Session, @@ -14,6 +19,73 @@ use crate::credentials::{ use crate::{config, config::AppConfig}; use crate::ipc::{self, Approval}; use crate::errors::*; +use crate::shortcuts; + + +#[derive(Debug)] +struct Visibility { + leases: usize, + original: Option, +} + +impl Visibility { + fn new() -> Self { + Visibility { leases: 0, original: None } + } + + fn acquire(&mut self, delay_ms: u64) -> Result { + let app = crate::app::APP.get().unwrap(); + let window = app.get_window("main") + .ok_or(WindowError::NoMainWindow)?; + + self.leases += 1; + if self.original.is_none() { + let is_visible = window.is_visible()?; + self.original = Some(is_visible); + if !is_visible { + window.show()?; + } + } + window.set_focus()?; + + let (tx, rx) = oneshot::channel(); + let lease = VisibilityLease { notify: tx }; + + let delay = Duration::from_millis(delay_ms); + let handle = app.app_handle(); + rt::spawn(async move { + // We don't care if it's an error; lease being dropped should be handled identically + let _ = rx.await; + tokio::time::sleep(delay).await; + // we can't use `self` here because we would have to move it into the async block + let state = handle.state::(); + let mut visibility = state.visibility.write().await; + visibility.leases -= 1; + if visibility.leases == 0 { + if let Some(false) = visibility.original { + window.hide().error_print(); + } + visibility.original = None; + } + }); + + Ok(lease) + } +} + +pub struct VisibilityLease { + notify: Sender<()>, +} + +impl VisibilityLease { + pub fn release(self) { + rt::spawn(async move { + if let Err(_) = self.notify.send(()) { + eprintln!("Error releasing visibility lease") + } + }); + } +} #[derive(Debug)] @@ -22,11 +94,11 @@ pub struct AppState { pub session: RwLock, pub request_count: RwLock, pub waiting_requests: RwLock>>, - pub current_rehide_status: RwLock>, pub pending_terminal_request: RwLock, // setup_errors is never modified and so doesn't need to be wrapped in RwLock pub setup_errors: Vec, pool: sqlx::SqlitePool, + visibility: RwLock, } impl AppState { @@ -41,10 +113,10 @@ impl AppState { session: RwLock::new(session), request_count: RwLock::new(0), waiting_requests: RwLock::new(HashMap::new()), - current_rehide_status: RwLock::new(None), pending_terminal_request: RwLock::new(false), setup_errors, pool, + visibility: RwLock::new(Visibility::new()), } } @@ -69,7 +141,7 @@ impl AppState { if new_config.hotkeys.show_window != live_config.hotkeys.show_window || new_config.hotkeys.launch_terminal != live_config.hotkeys.launch_terminal { - config::register_hotkeys(&new_config.hotkeys)?; + shortcuts::register_hotkeys(&new_config.hotkeys)?; } new_config.save(&self.pool).await?; @@ -94,25 +166,9 @@ impl AppState { waiting_requests.remove(&id); } - pub async fn req_count(&self) -> usize { - let waiting_requests = self.waiting_requests.read().await; - waiting_requests.len() - } - - pub async fn get_or_set_rehide(&self, new_value: bool) -> bool { - let mut rehide = self.current_rehide_status.write().await; - match *rehide { - Some(original) => original, - None => { - *rehide = Some(new_value); - new_value - } - } - } - - pub async fn clear_rehide(&self) { - let mut rehide = self.current_rehide_status.write().await; - *rehide = None; + pub async fn acquire_visibility_lease(&self, delay: u64) -> Result { + let mut visibility = self.visibility.write().await; + visibility.acquire(delay) } pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> { diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index fa95736..4fb7f6d 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -26,13 +26,8 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> { // if session is unlocked or empty, wait for credentials from frontend if !state.is_unlocked().await { app.emit_all("launch-terminal-request", ())?; - let window = app.get_window("main") - .ok_or(LaunchTerminalError::NoMainWindow)?; - if !window.is_visible()? { - window.unminimize()?; - window.show()?; - } - window.set_focus()?; + let lease = state.acquire_visibility_lease(0).await + .map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually? let (tx, rx) = tokio::sync::oneshot::channel(); app.once_global("credentials-event", move |e| { @@ -47,6 +42,7 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> { state.unregister_terminal_request().await; return Ok(()); // request was canceled by user } + lease.release(); } // more lock-management