7 Commits

25 changed files with 934 additions and 1407 deletions

View File

@ -1,13 +1,17 @@
## Definitely ## Definitely
* Switch to "process" provider for AWS credentials (much less hacky) * ~~Switch to "process" provider for AWS credentials (much less hacky)~~
* ~~Frontend needs to react when request is cancelled from backend~~
* Session timeout (plain duration, or activity-based?) * Session timeout (plain duration, or activity-based?)
* ~Fix rehide behavior when new request comes in while old one is still being resolved~ * Indicate on approval screen when additional requests are pending
* ~~Fix rehide behavior when new request comes in while old one is still being resolved~~
* Additional hotkey configuration (approve/deny at the very least) * Additional hotkey configuration (approve/deny at the very least)
* ~~Switch tray menu item to Hide when window is visible~~
* Logging * Logging
* Icon * Icon
* Auto-updates * Auto-updates
* SSH key handling * SSH key handling
* Encrypted sync server
## Maybe ## Maybe

1854
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "creddy", "name": "creddy",
"version": "0.4.1", "version": "0.4.5",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",

27
src-tauri/Cargo.lock generated
View File

@ -1035,7 +1035,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy" name = "creddy"
version = "0.4.0" version = "0.4.6"
dependencies = [ dependencies = [
"argon2", "argon2",
"auto-launch", "auto-launch",
@ -1059,6 +1059,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"which", "which",
"windows 0.51.1", "windows 0.51.1",
@ -1202,10 +1203,11 @@ dependencies = [
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.3.8" version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [ dependencies = [
"powerfmt",
"serde", "serde",
] ]
@ -3158,6 +3160,12 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.17" version = "0.2.17"
@ -4536,12 +4544,13 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.28" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17f6bb557fd245c28e6411aa56b6403c689ad95061f50e4be16c274e70a17e48" checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa 1.0.9", "itoa 1.0.9",
"powerfmt",
"serde", "serde",
"time-core", "time-core",
"time-macros", "time-macros",
@ -4549,15 +4558,15 @@ dependencies = [
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.1" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7300fbefb4dadc1af235a9cef3737cea692a9d97e1b9cbcd4ebdae6f8868e6fb" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.14" version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a942f44339478ef67935ab2bbaec2fb0322496cf3cbe84b261e06ac3814c572" checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f"
dependencies = [ dependencies = [
"time-core", "time-core",
] ]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "creddy" name = "creddy"
version = "0.4.1" version = "0.4.6"
description = "A friendly AWS credentials manager" description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"] authors = ["Joseph Montanaro"]
license = "" license = ""
@ -25,7 +25,7 @@ tauri-build = { version = "1.0.4", features = [] }
[dependencies] [dependencies]
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["dialog", "dialog-open", "global-shortcut", "os-all", "system-tray"] } tauri = { version = "1.2", features = [ "app-all", "dialog", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" } tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
sodiumoxide = "0.2.7" sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] } tokio = { version = ">=1.19", features = ["full"] }
@ -47,6 +47,7 @@ argon2 = { version = "0.5.0", features = ["std"] }
chacha20poly1305 = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] }
which = "4.4.0" which = "4.4.0"
windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] } windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] }
time = "0.3.31"
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode

View File

@ -1,4 +1,5 @@
use std::error::Error; use std::error::Error;
use std::time::Duration;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use sqlx::{ use sqlx::{
@ -31,8 +32,8 @@ pub static APP: OnceCell<AppHandle> = OnceCell::new();
pub fn run() -> tauri::Result<()> { pub fn run() -> tauri::Result<()> {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
app.get_window("main") show_main_window(app)
.map(|w| w.show().error_popup("Failed to show main window")); .error_popup("Failed to show main window");
})) }))
.system_tray(tray::create()) .system_tray(tray::create())
.on_system_tray_event(tray::handle_event) .on_system_tray_event(tray::handle_event)
@ -40,6 +41,7 @@ pub fn run() -> tauri::Result<()> {
ipc::unlock, ipc::unlock,
ipc::respond, ipc::respond,
ipc::get_session_status, ipc::get_session_status,
ipc::signal_activity,
ipc::save_credentials, ipc::save_credentials,
ipc::get_config, ipc::get_config,
ipc::save_config, ipc::save_config,
@ -49,9 +51,9 @@ pub fn run() -> tauri::Result<()> {
.setup(|app| rt::block_on(setup(app))) .setup(|app| rt::block_on(setup(app)))
.build(tauri::generate_context!())? .build(tauri::generate_context!())?
.run(|app, run_event| match run_event { .run(|app, run_event| match run_event {
tauri::RunEvent::WindowEvent { label, event, .. } => match event { tauri::RunEvent::WindowEvent { event, .. } => match event {
tauri::WindowEvent::CloseRequested { api, .. } => { tauri::WindowEvent::CloseRequested { api, .. } => {
let _ = app.get_window(&label).map(|w| w.hide()); let _ = hide_main_window(app);
api.prevent_close(); api.prevent_close();
} }
_ => () _ => ()
@ -114,12 +116,61 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
// if session is empty, this is probably the first launch, so don't autohide // if session is empty, this is probably the first launch, so don't autohide
if !conf.start_minimized || is_first_launch { if !conf.start_minimized || is_first_launch {
app.get_window("main") show_main_window(&app.handle())?;
.ok_or(HandlerError::NoMainWindow)?
.show()?;
} }
let state = AppState::new(conf, session, pool, setup_errors, desktop_is_gnome); let state = AppState::new(conf, session, pool, setup_errors, desktop_is_gnome);
app.manage(state); app.manage(state);
// make sure we do this after managing app state, so that it doesn't panic
start_auto_locker(app.app_handle());
Ok(()) Ok(())
} }
fn start_auto_locker(app: AppHandle) {
rt::spawn(async move {
let state = app.state::<AppState>();
loop {
// this gives our session-timeout a minimum resolution of 10s, which seems fine?
let delay = Duration::from_secs(10);
tokio::time::sleep(delay).await;
if state.should_auto_lock().await {
state.lock().await.error_popup("Failed to lock Creddy");
}
}
});
}
pub fn show_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
w.show()?;
app.tray_handle()
.get_item("show_hide")
.set_title("Hide")?;
Ok(())
}
pub fn hide_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
w.hide()?;
app.tray_handle()
.get_item("show_hide")
.set_title("Show")?;
Ok(())
}
pub fn toggle_main_window(app: &AppHandle) -> Result<(), WindowError> {
let w = app.get_window("main").ok_or(WindowError::NoMainWindow)?;
if w.is_visible()? {
hide_main_window(app)
}
else {
show_main_window(app)
}
}

View File

@ -14,7 +14,6 @@ pub struct Client {
pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> { pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
dbg!(pid);
let sys_pid = Pid::from_u32(pid); let sys_pid = Pid::from_u32(pid);
let mut sys = System::new(); let mut sys = System::new();
sys.refresh_process(sys_pid); sys.refresh_process(sys_pid);

View File

@ -1,4 +1,5 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration;
use auto_launch::AutoLaunchBuilder; use auto_launch::AutoLaunchBuilder;
use is_terminal::IsTerminal; use is_terminal::IsTerminal;
@ -45,6 +46,10 @@ impl HotkeysConfig {
pub struct AppConfig { pub struct AppConfig {
#[serde(default = "default_rehide_ms")] #[serde(default = "default_rehide_ms")]
pub rehide_ms: u64, pub rehide_ms: u64,
#[serde(default = "default_auto_lock")]
pub auto_lock: bool,
#[serde(default = "default_lock_after")]
pub lock_after: Duration,
#[serde(default = "default_start_minimized")] #[serde(default = "default_start_minimized")]
pub start_minimized: bool, pub start_minimized: bool,
#[serde(default = "default_start_on_login")] #[serde(default = "default_start_on_login")]
@ -60,6 +65,8 @@ impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
AppConfig { AppConfig {
rehide_ms: default_rehide_ms(), rehide_ms: default_rehide_ms(),
auto_lock: default_auto_lock(),
lock_after: default_lock_after(),
start_minimized: default_start_minimized(), start_minimized: default_start_minimized(),
start_on_login: default_start_on_login(), start_on_login: default_start_on_login(),
terminal: default_term_config(), terminal: default_term_config(),
@ -187,6 +194,30 @@ fn default_hotkey_config() -> HotkeysConfig {
fn default_rehide_ms() -> u64 { 1000 } fn default_rehide_ms() -> u64 { 1000 }
fn default_auto_lock() -> bool { true }
fn default_lock_after() -> Duration { Duration::from_secs(43200) }
// start minimized and on login only in production mode // start minimized and on login only in production mode
fn default_start_minimized() -> bool { !cfg!(debug_assertions) } fn default_start_minimized() -> bool { !cfg!(debug_assertions) }
fn default_start_on_login() -> bool { !cfg!(debug_assertions) } fn default_start_on_login() -> bool { !cfg!(debug_assertions) }
// struct DurationVisitor;
// impl<'de> Visitor<'de> for DurationVisitor {
// type Value = Duration;
// fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
// write!(formatter, "an integer between 0 and 2^64 - 1")
// }
// fn visit_u64<E: de::Error>(self, v: u64) -> Result<Duration, E> {
// Ok(Duration::from_secs(v))
// }
// }
// fn duration_from_secs<'de, D>(deserializer: D) -> Result<Duration, D::Error>
// where D: Deserializer<'de>
// {
// deserializer.deserialize_u64(DurationVisitor)
// }

View File

@ -126,10 +126,10 @@ impl LockedCredentials {
let secret_access_key = String::from_utf8(decrypted) let secret_access_key = String::from_utf8(decrypted)
.map_err(|_| UnlockError::InvalidUtf8)?; .map_err(|_| UnlockError::InvalidUtf8)?;
let creds = BaseCredentials { let creds = BaseCredentials::new(
access_key_id: self.access_key_id.clone(), self.access_key_id.clone(),
secret_access_key, secret_access_key,
}; );
Ok(creds) Ok(creds)
} }
} }
@ -138,11 +138,16 @@ impl LockedCredentials {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
pub struct BaseCredentials { pub struct BaseCredentials {
pub version: usize,
pub access_key_id: String, pub access_key_id: String,
pub secret_access_key: String, pub secret_access_key: String,
} }
impl BaseCredentials { impl BaseCredentials {
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
Self {version: 1, access_key_id, secret_access_key}
}
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> { pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
let salt = Crypto::salt(); let salt = Crypto::salt();
let crypto = Crypto::new(passphrase, &salt)?; let crypto = Crypto::new(passphrase, &salt)?;

View File

@ -18,6 +18,7 @@ use tauri::api::dialog::{
MessageDialogBuilder, MessageDialogBuilder,
MessageDialogKind, MessageDialogKind,
}; };
use tokio::sync::oneshot::error::RecvError;
use serde::{ use serde::{
Serialize, Serialize,
Serializer, Serializer,
@ -82,15 +83,33 @@ where
} }
struct SerializeUpstream<E>(pub E);
impl<E: Error> Serialize for SerializeUpstream<E> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let msg = format!("{}", self.0);
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &None::<&str>)?;
map.end()
}
}
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error> fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error>
where where
E: Error, E: Error,
M: serde::ser::SerializeMap, M: serde::ser::SerializeMap,
{ {
let msg = err.source().map(|s| format!("{s}")); // let msg = err.source().map(|s| format!("{s}"));
map.serialize_entry("msg", &msg)?; // map.serialize_entry("msg", &msg)?;
map.serialize_entry("code", &None::<&str>)?; // map.serialize_entry("code", &None::<&str>)?;
map.serialize_entry("source", &None::<&str>)?; // map.serialize_entry("source", &None::<&str>)?;
match err.source() {
Some(src) => map.serialize_entry("source", &SerializeUpstream(src))?,
None => map.serialize_entry("source", &None::<&str>)?,
}
Ok(()) Ok(())
} }
@ -152,7 +171,7 @@ pub enum SendResponseError {
} }
// errors encountered while handling an HTTP request // errors encountered while handling a client request
#[derive(Debug, ThisError, AsRefStr)] #[derive(Debug, ThisError, AsRefStr)]
pub enum HandlerError { pub enum HandlerError {
#[error("Error writing to stream: {0}")] #[error("Error writing to stream: {0}")]
@ -163,8 +182,10 @@ pub enum HandlerError {
BadRequest(#[from] serde_json::Error), BadRequest(#[from] serde_json::Error),
#[error("HTTP request too large")] #[error("HTTP request too large")]
RequestTooLarge, RequestTooLarge,
#[error("Connection closed early by client")]
Abandoned,
#[error("Internal server error")] #[error("Internal server error")]
Internal, Internal(#[from] RecvError),
#[error("Error accessing credentials: {0}")] #[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError), NoCredentials(#[from] GetCredentialsError),
#[error("Error getting client details: {0}")] #[error("Error getting client details: {0}")]
@ -226,6 +247,19 @@ pub enum UnlockError {
} }
#[derive(Debug, ThisError, AsRefStr)]
pub enum LockError {
#[error("App is not unlocked")]
NotUnlocked,
#[error("Database error: {0}")]
DbError(#[from] SqlxError),
#[error(transparent)]
Setup(#[from] SetupError),
#[error(transparent)]
TauriError(#[from] tauri::Error),
}
#[derive(Debug, ThisError, AsRefStr)] #[derive(Debug, ThisError, AsRefStr)]
pub enum CryptoError { pub enum CryptoError {
#[error(transparent)] #[error(transparent)]
@ -344,11 +378,11 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> {
} }
impl_serialize_basic!(SetupError); impl_serialize_basic!(SetupError);
impl_serialize_basic!(GetCredentialsError); impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError); impl_serialize_basic!(ClientInfoError);
impl_serialize_basic!(WindowError); impl_serialize_basic!(WindowError);
impl_serialize_basic!(LockError);
impl Serialize for HandlerError { impl Serialize for HandlerError {

View File

@ -21,6 +21,7 @@ pub struct AwsRequestNotification {
pub struct RequestResponse { pub struct RequestResponse {
pub id: u64, pub id: u64,
pub approval: Approval, pub approval: Approval,
pub base: bool,
} }
@ -55,6 +56,13 @@ pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String
} }
#[tauri::command]
pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> {
app_state.signal_activity().await;
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn save_credentials( pub async fn save_credentials(
credentials: BaseCredentials, credentials: BaseCredentials,

View File

@ -43,6 +43,24 @@ pub enum Response {
} }
struct CloseWaiter<'s> {
stream: &'s mut Stream,
}
impl<'s> CloseWaiter<'s> {
async fn wait_for_close(&mut self) -> std::io::Result<()> {
let mut buf = [0u8; 8];
loop {
match self.stream.read(&mut buf).await {
Ok(0) => break Ok(()),
Ok(_) => (),
Err(e) => break Err(e),
}
}
}
}
async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError> async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError>
{ {
// read from stream until delimiter is reached // read from stream until delimiter is reached
@ -59,13 +77,21 @@ async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> R
} }
let client = clientinfo::get_process_parent_info(client_pid)?; let client = clientinfo::get_process_parent_info(client_pid)?;
let waiter = CloseWaiter { stream: &mut stream };
let req: Request = serde_json::from_slice(&buf)?; let req: Request = serde_json::from_slice(&buf)?;
let res = match req { let res = match req {
Request::GetAwsCredentials{ base } => get_aws_credentials(base, client, app_handle).await, Request::GetAwsCredentials{ base } => get_aws_credentials(
base, client, app_handle, waiter
).await,
Request::InvokeShortcut(action) => invoke_shortcut(action).await, Request::InvokeShortcut(action) => invoke_shortcut(action).await,
}; };
// doesn't make sense to send the error to the client if the client has already left
if let Err(HandlerError::Abandoned) = res {
return Err(HandlerError::Abandoned);
}
let res = serde_json::to_vec(&res).unwrap(); let res = serde_json::to_vec(&res).unwrap();
stream.write_all(&res).await?; stream.write_all(&res).await?;
Ok(()) Ok(())
@ -78,7 +104,12 @@ async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerErro
} }
async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) -> Result<Response, HandlerError> { async fn get_aws_credentials(
base: bool,
client: Client,
app_handle: AppHandle,
mut waiter: CloseWaiter<'_>,
) -> Result<Response, HandlerError> {
let state = app_handle.state::<AppState>(); let state = app_handle.state::<AppState>();
let rehide_ms = { let rehide_ms = {
let config = state.config.read().await; let config = state.config.read().await;
@ -97,9 +128,17 @@ async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle)
let notification = AwsRequestNotification {id: request_id, client, base}; let notification = AwsRequestNotification {id: request_id, client, base};
app_handle.emit_all("credentials-request", &notification)?; app_handle.emit_all("credentials-request", &notification)?;
match chan_recv.await { let response = tokio::select! {
Ok(Approval::Approved) => { r = chan_recv => r?,
if base { _ = waiter.wait_for_close() => {
app_handle.emit_all("request-cancelled", request_id)?;
return Err(HandlerError::Abandoned);
},
};
match response.approval {
Approval::Approved => {
if response.base {
let creds = state.base_creds_cloned().await?; let creds = state.base_creds_cloned().await?;
Ok(Response::Aws(Credentials::Base(creds))) Ok(Response::Aws(Credentials::Base(creds)))
} }
@ -108,8 +147,7 @@ async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle)
Ok(Response::Aws(Credentials::Session(creds))) Ok(Response::Aws(Credentials::Session(creds)))
} }
}, },
Ok(Approval::Denied) => Err(HandlerError::Denied), Approval::Denied => Err(HandlerError::Denied),
Err(_e) => Err(HandlerError::Internal),
} }
}; };

View File

@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use time::OffsetDateTime;
use tokio::{ use tokio::{
sync::RwLock, sync::RwLock,
@ -11,13 +12,14 @@ use tauri::{
async_runtime as rt, async_runtime as rt,
}; };
use crate::app;
use crate::credentials::{ use crate::credentials::{
Session, Session,
BaseCredentials, BaseCredentials,
SessionCredentials, SessionCredentials,
}; };
use crate::{config, config::AppConfig}; use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval}; use crate::ipc::{self, Approval, RequestResponse};
use crate::errors::*; use crate::errors::*;
use crate::shortcuts; use crate::shortcuts;
@ -42,19 +44,19 @@ impl Visibility {
// `original` represents the visibility of the window before any leases were acquired // `original` represents the visibility of the window before any leases were acquired
// None means we don't know, Some(false) means it was previously hidden, // None means we don't know, Some(false) means it was previously hidden,
// Some(true) means it was previously visible // Some(true) means it was previously visible
let is_visible = window.is_visible()?;
if self.original.is_none() { if self.original.is_none() {
let is_visible = window.is_visible()?;
self.original = Some(is_visible); self.original = Some(is_visible);
} }
let state = app.state::<AppState>(); let state = app.state::<AppState>();
if matches!(self.original, Some(true)) && state.desktop_is_gnome { if is_visible && state.desktop_is_gnome {
// Gnome has a really annoying "focus-stealing prevention" behavior means we // Gnome has a really annoying "focus-stealing prevention" behavior means we
// can't just pop up when the window is already visible, so to work around it // can't just pop up when the window is already visible, so to work around it
// we hide and then immediately unhide the window // we hide and then immediately unhide the window
window.hide()?; window.hide()?;
} }
window.show()?; app::show_main_window(&app)?;
window.set_focus()?; window.set_focus()?;
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
@ -72,7 +74,7 @@ impl Visibility {
visibility.leases -= 1; visibility.leases -= 1;
if visibility.leases == 0 { if visibility.leases == 0 {
if let Some(false) = visibility.original { if let Some(false) = visibility.original {
window.hide().error_print(); app::hide_main_window(&handle).error_print();
} }
visibility.original = None; visibility.original = None;
} }
@ -101,8 +103,9 @@ impl VisibilityLease {
pub struct AppState { pub struct AppState {
pub config: RwLock<AppConfig>, pub config: RwLock<AppConfig>,
pub session: RwLock<Session>, pub session: RwLock<Session>,
pub last_activity: RwLock<OffsetDateTime>,
pub request_count: RwLock<u64>, pub request_count: RwLock<u64>,
pub waiting_requests: RwLock<HashMap<u64, Sender<Approval>>>, pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
pub pending_terminal_request: RwLock<bool>, pub pending_terminal_request: RwLock<bool>,
// these are never modified and so don't need to be wrapped in RwLocks // these are never modified and so don't need to be wrapped in RwLocks
pub setup_errors: Vec<String>, pub setup_errors: Vec<String>,
@ -122,6 +125,7 @@ impl AppState {
AppState { AppState {
config: RwLock::new(config), config: RwLock::new(config),
session: RwLock::new(session), session: RwLock::new(session),
last_activity: RwLock::new(OffsetDateTime::now_utc()),
request_count: RwLock::new(0), request_count: RwLock::new(0),
waiting_requests: RwLock::new(HashMap::new()), waiting_requests: RwLock::new(HashMap::new()),
pending_terminal_request: RwLock::new(false), pending_terminal_request: RwLock::new(false),
@ -161,7 +165,7 @@ impl AppState {
Ok(()) Ok(())
} }
pub async fn register_request(&self, sender: Sender<Approval>) -> u64 { pub async fn register_request(&self, sender: Sender<RequestResponse>) -> u64 {
let count = { let count = {
let mut c = self.request_count.write().await; let mut c = self.request_count.write().await;
*c += 1; *c += 1;
@ -193,7 +197,7 @@ impl AppState {
waiting_requests waiting_requests
.remove(&response.id) .remove(&response.id)
.ok_or(SendResponseError::NotFound)? .ok_or(SendResponseError::NotFound)?
.send(response.approval) .send(response)
.map_err(|_| SendResponseError::Abandoned) .map_err(|_| SendResponseError::Abandoned)
} }
@ -209,6 +213,38 @@ impl AppState {
Ok(()) Ok(())
} }
pub async fn lock(&self) -> Result<(), LockError> {
let mut session = self.session.write().await;
match *session {
Session::Empty => Err(LockError::NotUnlocked),
Session::Locked(_) => Err(LockError::NotUnlocked),
Session::Unlocked{..} => {
*session = Session::load(&self.pool).await?;
let app_handle = app::APP.get().unwrap();
app_handle.emit_all("locked", None::<usize>)?;
Ok(())
}
}
}
pub async fn signal_activity(&self) {
let mut last_activity = self.last_activity.write().await;
*last_activity = OffsetDateTime::now_utc();
}
pub async fn should_auto_lock(&self) -> bool {
let config = self.config.read().await;
if !config.auto_lock || !self.is_unlocked().await {
return false;
}
let last_activity = self.last_activity.read().await;
let elapsed = OffsetDateTime::now_utc() - *last_activity;
elapsed >= config.lock_after
}
pub async fn is_unlocked(&self) -> bool { pub async fn is_unlocked(&self) -> bool {
let session = self.session.read().await; let session = self.session.read().await;
matches!(*session, Session::Unlocked{..}) matches!(*session, Session::Unlocked{..})
@ -222,7 +258,7 @@ impl AppState {
pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> { pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> {
let app_session = self.session.read().await; let app_session = self.session.read().await;
let (_bsae, session) = app_session.try_get()?; let (_base, session) = app_session.try_get()?;
Ok(session.clone()) Ok(session.clone())
} }

View File

@ -1,15 +1,19 @@
use tauri::{ use tauri::{
AppHandle, AppHandle,
CustomMenuItem,
Manager, Manager,
SystemTray, SystemTray,
SystemTrayEvent, SystemTrayEvent,
SystemTrayMenu, SystemTrayMenu,
CustomMenuItem, async_runtime as rt,
}; };
use crate::app;
use crate::state::AppState;
pub fn create() -> SystemTray { pub fn create() -> SystemTray {
let show = CustomMenuItem::new("show".to_string(), "Show"); let show = CustomMenuItem::new("show_hide".to_string(), "Show");
let quit = CustomMenuItem::new("exit".to_string(), "Exit"); let quit = CustomMenuItem::new("exit".to_string(), "Exit");
let menu = SystemTrayMenu::new() let menu = SystemTrayMenu::new()
@ -20,13 +24,18 @@ pub fn create() -> SystemTray {
} }
pub fn handle_event(app: &AppHandle, event: SystemTrayEvent) { pub fn handle_event(app_handle: &AppHandle, event: SystemTrayEvent) {
match event { match event {
SystemTrayEvent::MenuItemClick{ id, .. } => { SystemTrayEvent::MenuItemClick{ id, .. } => {
match id.as_str() { match id.as_str() {
"exit" => app.exit(0), "exit" => app_handle.exit(0),
"show" => { "show_hide" => {
let _ = app.get_window("main").map(|w| w.show()); let _ = app::toggle_main_window(app_handle);
let new_handle = app_handle.app_handle();
rt::spawn(async move {
let state = new_handle.state::<AppState>();
state.signal_activity().await;
});
} }
_ => (), _ => (),
} }

View File

@ -8,10 +8,15 @@
}, },
"package": { "package": {
"productName": "creddy", "productName": "creddy",
"version": "0.4.1" "version": "0.4.6"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
"app": {
"all": true,
"show": false,
"hide": false
},
"os": {"all": true}, "os": {"all": true},
"dialog": {"open": true} "dialog": {"open": true}
}, },

View File

@ -3,7 +3,7 @@ import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { appState, acceptRequest } from './lib/state.js'; import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
import { views, currentView, navigate } from './lib/routing.js'; import { views, currentView, navigate } from './lib/routing.js';
@ -16,6 +16,16 @@ listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload); $appState.pendingRequests.put(tauriEvent.payload);
}); });
listen('request-cancelled', (tauriEvent) => {
const id = tauriEvent.payload;
if (id === $appState.currentRequest?.id) {
cleanupRequest()
}
else {
const found = $appState.pendingRequests.find_remove(r => r.id === id);
}
});
listen('launch-terminal-request', async (tauriEvent) => { listen('launch-terminal-request', async (tauriEvent) => {
if ($appState.currentRequest === null) { if ($appState.currentRequest === null) {
let status = await invoke('get_session_status'); let status = await invoke('get_session_status');

View File

@ -30,5 +30,15 @@ export default function() {
return this.items.shift(); return this.items.shift();
}, },
find_remove(pred) {
for (let i=0; i<this.items.length; i++) {
if (pred(this.items[i])) {
this.items.splice(i, 1);
return true;
}
}
return false;
},
} }
} }

View File

@ -23,7 +23,7 @@ export async function acceptRequest() {
} }
export function completeRequest() { export function cleanupRequest() {
appState.update($appState => { appState.update($appState => {
$appState.currentRequest = null; $appState.currentRequest = null;
return $appState; return $appState;

View File

@ -5,3 +5,8 @@
.btn-alert-error { .btn-alert-error {
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content @apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content
} }
/* I like alert icons to be top-aligned */
.alert > :where(*) {
align-items: flex-start;
}

View File

@ -1,13 +1,15 @@
<script> <script>
export let keys; export let keys;
let classes;
export {classes as class};
</script> </script>
<div class="flex gap-x-[0.2em] items-center"> <span class="inline-flex gap-x-[0.2em] items-center {classes}">
{#each keys as key, i} {#each keys as key, i}
{#if i > 0} {#if i > 0}
<span class="mt-[-0.1em]">+</span> <span class="mt-[-0.1em]">+</span>
{/if} {/if}
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd> <kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
{/each} {/each}
</div> </span>

View File

@ -21,15 +21,15 @@
throw(`Link target is not a string or a function: ${target}`) throw(`Link target is not a string or a function: ${target}`)
} }
} }
function handleHotkey(event) { function handleHotkey(event) {
if (!hotkey) return; if (
if (ctrl && !event.ctrlKey) return; hotkey === event.key
if (alt && !event.altKey) return; && ctrl === event.ctrlKey
if (shift && !event.shiftKey) return; && alt === event.altKey
&& shift === event.shiftKey
if (event.key === hotkey) { ) {
click(); click();
} }
} }

View File

@ -3,7 +3,7 @@
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { navigate } from '../lib/routing.js'; import { navigate } from '../lib/routing.js';
import { appState, completeRequest } from '../lib/state.js'; import { appState, cleanupRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte'; import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import KeyCombo from '../ui/KeyCombo.svelte'; import KeyCombo from '../ui/KeyCombo.svelte';
@ -12,9 +12,12 @@
// Send response to backend, display error if applicable // Send response to backend, display error if applicable
let error, alert; let error, alert;
async function respond() { async function respond() {
let {id, approval} = $appState.currentRequest; const response = {
id: $appState.currentRequest.id,
...$appState.currentRequest.response,
};
try { try {
await invoke('respond', {response: {id, approval}}); await invoke('respond', {response});
navigate('ShowResponse'); navigate('ShowResponse');
} }
catch (e) { catch (e) {
@ -26,8 +29,8 @@
} }
// Approval has one of several outcomes depending on current credential state // Approval has one of several outcomes depending on current credential state
async function approve() { async function approve(base) {
$appState.currentRequest.approval = 'Approved'; $appState.currentRequest.response = {approval: 'Approved', base};
let status = await invoke('get_session_status'); let status = await invoke('get_session_status');
if (status === 'unlocked') { if (status === 'unlocked') {
await respond(); await respond();
@ -40,9 +43,16 @@
} }
} }
function approve_base() {
approve(true);
}
function approve_session() {
approve(false);
}
// Denial has only one // Denial has only one
async function deny() { async function deny() {
$appState.currentRequest.approval = 'Denied'; $appState.currentRequest.response = {approval: 'Denied', base: false};
await respond(); await respond();
} }
@ -58,32 +68,32 @@
// if the request has already been approved/denied, send response immediately // if the request has already been approved/denied, send response immediately
onMount(async () => { onMount(async () => {
if ($appState.currentRequest.approval) { if ($appState.currentRequest.response) {
await respond(); await respond();
} }
}) });
</script> </script>
<!-- Don't render at all if we're just going to immediately proceed to the next screen --> <!-- Don't render at all if we're just going to immediately proceed to the next screen -->
{#if error || !$appState.currentRequest.approval} {#if error || !$appState.currentRequest?.response}
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center"> <div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
{#if error} {#if error}
<ErrorAlert bind:this={alert}> <ErrorAlert bind:this={alert}>
{error} {error.msg}
<svelte:fragment slot="buttons"> <svelte:fragment slot="buttons">
<button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button> <button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button>
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button> <button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
</svelte:fragment> </svelte:fragment>
</ErrorAlert> </ErrorAlert>
{/if} {/if}
{#if $appState.currentRequest.base} {#if $appState.currentRequest?.base}
<div class="alert alert-warning shadow-lg"> <div class="alert alert-warning shadow-lg">
<div> <div>
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span> <span>
WARNING: This application is requesting your base (long-lived) AWS credentials. WARNING: This application is requesting your base AWS credentials.
These credentials are less secure than session credentials, since they don't expire automatically. These credentials are less secure than session credentials, since they don't expire automatically.
</span> </span>
</div> </div>
@ -101,20 +111,42 @@
</div> </div>
</div> </div>
<div class="w-full flex justify-between"> <div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6">
<Link target={deny} hotkey="Escape"> <!-- Don't display the option to approve with session credentials if base was specifically requested -->
<button class="btn btn-error justify-self-start"> {#if !$appState.currentRequest?.base}
<span class="mr-2">Deny</span> <h3 class="font-semibold">
<KeyCombo keys={['Esc']} /> Approve with session credentials
</button> </h3>
</Link> <Link target={() => approve(false)} hotkey="Enter" shift={true}>
<button class="w-full btn btn-success">
<KeyCombo keys={['Shift', 'Enter']} />
</button>
</Link>
{/if}
<Link target={approve} hotkey="Enter" shift="{true}"> <h3 class="font-semibold">
<button class="btn btn-success justify-self-end"> <span class="mr-2">
<span class="mr-2">Approve</span> {#if $appState.currentRequest?.base}
<KeyCombo keys={['Shift', 'Enter']} /> Approve
</button> {:else}
</Link> Approve with base credentials
{/if}
</span>
</h3>
<Link target={() => approve(true)} hotkey="Enter" shift={true} ctrl={true}>
<button class="w-full btn btn-warning">
<KeyCombo keys={['Ctrl', 'Shift', 'Enter']} />
</button>
</Link>
<h3 class="font-semibold">
<span class="mr-2">Deny</span>
</h3>
<Link target={deny} hotkey="Escape">
<button class="w-full btn btn-error">
<KeyCombo keys={['Esc']} />
</button>
</Link>
</div> </div>
</div> </div>
{/if} {/if}

View File

@ -39,8 +39,8 @@
Launch Terminal Launch Terminal
</button> </button>
<label class="label cursor-pointer flex items-center space-x-2"> <label class="label cursor-pointer flex items-center space-x-2">
<span class="label-text">Launch with long-lived credentials</span>
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}> <input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
<span class="label-text">Launch with base credentials</span>
</label> </label>
{:else if status === 'empty'} {:else if status === 'empty'}

View File

@ -112,7 +112,7 @@
<div> <div>
<!-- <button class="btn btn-sm btn-ghost">Cancel</button> --> <!-- <button class="btn btn-sm btn-ghost">Cancel</button> -->
<buton class="btn btn-sm btn-primary" on:click={save}>Save</buton> <button class="btn btn-sm btn-primary" on:click={save}>Save</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition'; import { draw, fade } from 'svelte/transition';
import { appState, completeRequest } from '../lib/state.js'; import { appState, cleanupRequest } from '../lib/state.js';
let success = false; let success = false;
let error = null; let error = null;
@ -13,7 +13,7 @@
onMount(() => { onMount(() => {
window.setTimeout( window.setTimeout(
completeRequest, cleanupRequest,
// Extra 50ms so the window can finish disappearing before the redraw // Extra 50ms so the window can finish disappearing before the redraw
Math.min(5000, $appState.config.rehide_ms + 50), Math.min(5000, $appState.config.rehide_ms + 50),
) )
@ -22,7 +22,7 @@
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto"> <div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
{#if $appState.currentRequest.approval === 'Approved'} {#if $appState.currentRequest.response.approval === 'Approved'}
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
@ -33,6 +33,6 @@
{/if} {/if}
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold"> <div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
{$appState.currentRequest.approval}! {$appState.currentRequest.response.approval}!
</div> </div>
</div> </div>