20 Commits

Author SHA1 Message Date
141334f7e2 add idle timeout and version on settings screen 2024-01-31 13:14:08 -08:00
69f6a39396 change tray menu text when toggling visibility 2024-01-26 21:03:45 -08:00
70e23c7e20 add version to BaseCredentials 2024-01-23 10:58:39 -08:00
1df849442e cancel approval flow on frontend when request is abandoned by client 2024-01-21 13:46:39 -08:00
7fdb336c79 rework approval buttons and add hotkey for base approval 2024-01-20 11:06:27 -08:00
46b8d810c5 allow user to choose whether to send base credentials at approval screen 2024-01-10 17:10:14 -08:00
dd40eb379e update dependencies 2024-01-10 16:31:10 -08:00
13545ac725 v0.4.1 2023-11-09 14:25:20 -08:00
040a01536a work around Gnome focus-stealing prevention 2023-11-09 14:24:44 -08:00
4e2a90b15b remove old client info code 2023-11-09 13:46:15 -08:00
e0d919ed4a fix Windows pipe server 2023-10-09 16:29:41 -07:00
3f4efc5f8f bump version to 0.4.0 2023-10-09 10:06:28 -07:00
4881b90b0b merge branch 'pipe' 2023-10-09 08:54:26 -07:00
1b749a857c disable hotkeys if initial registration fails 2023-10-09 08:50:31 -07:00
2079f99d04 bump version to 0.3.4 2023-10-08 22:53:22 -07:00
5e0ffc1155 use save dialog for settings instead of autosaving 2023-10-08 22:06:30 -07:00
d4fa8966b2 add unix listener, split win/unix into separate submodules 2023-09-23 11:10:54 -07:00
a293d8f92c ignore .env so it can be system-specific 2023-09-22 12:43:44 -07:00
367a140e2a disable keyboard shortcuts if registration fails 2023-09-21 14:55:02 -07:00
4b06dce7f4 keep working on cli shortcuts, unify visibility management 2023-09-21 10:44:35 -07:00
38 changed files with 1464 additions and 1822 deletions

3
.gitignore vendored
View File

@ -2,6 +2,9 @@ dist
**/node_modules **/node_modules
src-tauri/target/ src-tauri/target/
**/creddy.db **/creddy.db
# .env is system-specific
.env
.vscode
# just in case # just in case
credentials* credentials*

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.3.3", "version": "0.4.5",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",

View File

@ -1 +0,0 @@
DATABASE_URL=sqlite://C:/Users/Joe/AppData/Roaming/creddy/creddy.dev.db

27
src-tauri/Cargo.lock generated
View File

@ -1035,7 +1035,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy" name = "creddy"
version = "0.3.3" 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.3.3" 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::{
@ -19,6 +20,7 @@ use crate::{
ipc, ipc,
server::Server, server::Server,
errors::*, errors::*,
shortcuts,
state::AppState, state::AppState,
tray, tray,
}; };
@ -30,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)
@ -39,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,
@ -48,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();
} }
_ => () _ => ()
@ -81,7 +84,7 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
let pool = connect_db().await?; let pool = connect_db().await?;
let mut setup_errors: Vec<String> = vec![]; let mut setup_errors: Vec<String> = vec![];
let conf = match AppConfig::load(&pool).await { let mut conf = match AppConfig::load(&pool).await {
Ok(c) => c, Ok(c) => c,
Err(SetupError::ConfigParseError(_)) => { Err(SetupError::ConfigParseError(_)) => {
setup_errors.push( setup_errors.push(
@ -99,18 +102,75 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
if let Err(_e) = config::set_auto_launch(conf.start_on_login) { if let Err(_e) = config::set_auto_launch(conf.start_on_login) {
setup_errors.push("Error: Failed to manage autolaunch.".into()); setup_errors.push("Error: Failed to manage autolaunch.".into());
} }
if let Err(e) = config::register_hotkeys(&conf.hotkeys) {
setup_errors.push(format!("{e}")); // if hotkeys fail to register, disable them so that this error doesn't have to keep showing up
if let Err(_e) = shortcuts::register_hotkeys(&conf.hotkeys) {
conf.hotkeys.disable_all();
conf.save(&pool).await?;
setup_errors.push("Failed to register hotkeys. Hotkey settings have been disabled.".into());
} }
let desktop_is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
.map(|names| names.split(':').any(|n| n == "GNOME"))
.unwrap_or(false);
// 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); 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

@ -21,7 +21,8 @@ fn main() {
None | Some(("run", _)) => launch_gui(), None | Some(("run", _)) => launch_gui(),
Some(("get", m)) => cli::get(m), Some(("get", m)) => cli::get(m),
Some(("exec", m)) => cli::exec(m), Some(("exec", m)) => cli::exec(m),
_ => unreachable!(), Some(("shortcut", m)) => cli::invoke_shortcut(m),
_ => unreachable!("Unknown subcommand"),
}; };
if let Err(e) = res { if let Err(e) = res {

View File

@ -1,23 +1,25 @@
use std::ffi::OsString; use std::ffi::OsString;
use std::process::Command as ChildCommand; use std::process::Command as ChildCommand;
#[cfg(windows)]
use std::time::Duration; use std::time::Duration;
use clap::{ use clap::{
Command, Command,
Arg, Arg,
ArgMatches, ArgMatches,
ArgAction ArgAction,
builder::PossibleValuesParser,
}; };
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::credentials::Credentials; use crate::credentials::Credentials;
use crate::errors::*; use crate::errors::*;
use crate::server::{Request, Response}; use crate::server::{Request, Response};
use crate::shortcuts::ShortcutAction;
#[cfg(unix)] #[cfg(unix)]
use { use {
std::os::unix::process::CommandExt, std::os::unix::process::CommandExt,
std::path::Path,
tokio::net::UnixStream, tokio::net::UnixStream,
}; };
@ -63,6 +65,16 @@ pub fn parser() -> Command<'static> {
.multiple_values(true) .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] pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> {
async fn get_credentials(base: bool) -> Result<Credentials, RequestError> { let action = match args.get_one::<String>("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<Credentials, RequestError> {
let req = Request::GetAwsCredentials { base }; 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<Response, RequestError> {
let mut data = serde_json::to_string(req).unwrap();
// server expects newline marking end of request // server expects newline marking end of request
data.push('\n'); data.push('\n');
@ -142,12 +179,7 @@ async fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
let mut buf = Vec::with_capacity(1024); let mut buf = Vec::with_capacity(1024);
stream.read_to_end(&mut buf).await?; stream.read_to_end(&mut buf).await?;
let res: Result<Response, ServerError> = serde_json::from_slice(&buf)?; let res: Result<Response, ServerError> = serde_json::from_slice(&buf)?;
match res { Ok(res?)
Ok(Response::Aws(creds)) => Ok(creds),
// Eventually we will want this
// Ok(r) => Err(RequestError::Unexpected(r)),
Err(e) => Err(RequestError::Server(e)),
}
} }
@ -167,7 +199,5 @@ async fn connect() -> Result<NamedPipeClient, std::io::Error> {
#[cfg(unix)] #[cfg(unix)]
async fn connect() -> Result<UnixStream, std::io::Error> { async fn connect() -> Result<UnixStream, std::io::Error> {
let path = Path::from("/tmp/creddy-requests"); UnixStream::connect("/tmp/creddy.sock").await
std::fs::remove_file(path)?;
UnixStream::connect(path)
} }

View File

@ -2,19 +2,6 @@ use std::path::{Path, PathBuf};
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt}; use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use std::os::windows::io::AsRawHandle;
#[cfg(windows)]
use {
tokio::net::windows::named_pipe::NamedPipeServer,
windows::Win32::{
Foundation::HANDLE,
System::Pipes::GetNamedPipeClientProcessId,
},
};
#[cfg(unix)]
use tokio::net::UnixStream;
use crate::errors::*; use crate::errors::*;
@ -26,25 +13,7 @@ pub struct Client {
} }
#[cfg(unix)] pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
pub fn get_client_parent(stream: &UnixStream) -> Result<Client, ClientInfoError> {
let pid = stream.peer_cred()?;
get_process_parent_info(pid)?
}
#[cfg(windows)]
pub fn get_client_parent(stream: &NamedPipeServer) -> Result<Client, ClientInfoError> {
let raw_handle = stream.as_raw_handle();
let mut pid = 0u32;
let handle = HANDLE(raw_handle as _);
unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? };
get_process_parent_info(pid)
}
fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
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);
@ -64,59 +33,3 @@ fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
Ok(Client { pid: parent_pid_sys.as_u32(), exe }) Ok(Client { pid: parent_pid_sys.as_u32(), exe })
} }
// async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
// let state = APP.get().unwrap().state::<AppState>();
// let AppConfig {
// listen_addr: app_listen_addr,
// listen_port: app_listen_port,
// ..
// } = *state.config.read().await;
// let sockets_iter = netstat2::iterate_sockets_info(
// AddressFamilyFlags::IPV4,
// ProtocolFlags::TCP
// )?;
// for item in sockets_iter {
// let sock_info = item?;
// let proto_info = match sock_info.protocol_socket_info {
// ProtocolSocketInfo::Tcp(tcp_info) => tcp_info,
// ProtocolSocketInfo::Udp(_) => {continue;}
// };
// if proto_info.local_port == local_port
// && proto_info.remote_port == app_listen_port
// && proto_info.local_addr == app_listen_addr
// && proto_info.remote_addr == app_listen_addr
// {
// return Ok(sock_info.associated_pids)
// }
// }
// Ok(vec![])
// }
// Theoretically, on some systems, multiple processes can share a socket
// pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> {
// let mut clients = Vec::new();
// let mut sys = System::new();
// for p in get_associated_pids(local_port).await? {
// let pid = Pid::from_u32(p);
// sys.refresh_process(pid);
// let proc = sys.process(pid)
// .ok_or(ClientInfoError::ProcessNotFound)?;
// let client = Client {
// pid: p,
// exe: proc.exe().to_path_buf(),
// };
// clients.push(Some(client));
// }
// if clients.is_empty() {
// clients.push(None);
// }
// Ok(clients)
// }

View File

@ -1,14 +1,10 @@
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;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tauri::{
Manager,
GlobalShortcutManager,
async_runtime as rt,
};
use crate::errors::*; use crate::errors::*;
@ -38,11 +34,22 @@ pub struct HotkeysConfig {
pub launch_terminal: Hotkey, pub launch_terminal: Hotkey,
} }
impl HotkeysConfig {
pub fn disable_all(&mut self) {
self.show_window.enabled = false;
self.launch_terminal.enabled = false;
}
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
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")]
@ -58,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(),
@ -183,45 +192,32 @@ fn default_hotkey_config() -> HotkeysConfig {
} }
} }
// note: will panic if called before APP is set
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> {
let app = crate::app::APP.get().unwrap();
let mut manager = app.global_shortcut_manager();
manager.unregister_all()?;
if hotkeys.show_window.enabled {
let handle = app.app_handle();
manager.register(
&hotkeys.show_window.keys,
move || {
handle.get_window("main")
.map(|w| w.show().error_popup("Failed to show"))
.ok_or(HandlerError::NoMainWindow)
.error_popup("No main window");
},
)?;
}
if hotkeys.launch_terminal.enabled {
// register() doesn't take an async fn, so we have to use spawn
manager.register(
&hotkeys.launch_terminal.keys,
|| {
rt::spawn(async {
crate::terminal::launch(false)
.await
.error_popup("Failed to launch");
});
}
)?;
}
Ok(())
}
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,
@ -26,12 +27,14 @@ use serde::{
}; };
pub trait ErrorPopup { pub trait ShowError {
fn error_popup(self, title: &str); fn error_popup(self, title: &str);
fn error_popup_nowait(self, title: &str); fn error_popup_nowait(self, title: &str);
fn error_print(self);
fn error_print_prefix(self, prefix: &str);
} }
impl<E: std::fmt::Display> ErrorPopup for Result<(), E> { impl<E: std::fmt::Display> ShowError for Result<(), E> {
fn error_popup(self, title: &str) { fn error_popup(self, title: &str) {
if let Err(e) = self { if let Err(e) = self {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
@ -50,6 +53,18 @@ impl<E: std::fmt::Display> ErrorPopup for Result<(), E> {
.show(|_| {}) .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}");
}
}
} }
@ -68,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(())
} }
@ -138,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}")]
@ -149,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}")]
@ -164,6 +199,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)] #[derive(Debug, ThisError, AsRefStr)]
pub enum GetCredentialsError { pub enum GetCredentialsError {
#[error("Credentials are currently locked")] #[error("Credentials are currently locked")]
@ -203,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)]
@ -221,6 +278,7 @@ pub enum ClientInfoError {
ParentPidNotFound, ParentPidNotFound,
#[error("Found PID for parent process of client, but no corresponding process")] #[error("Found PID for parent process of client, but no corresponding process")]
ParentProcessNotFound, ParentProcessNotFound,
#[cfg(windows)]
#[error("Could not determine PID of connected client")] #[error("Could not determine PID of connected client")]
WindowsError(#[from] windows::core::Error), WindowsError(#[from] windows::core::Error),
#[error(transparent)] #[error(transparent)]
@ -320,10 +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!(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

@ -7,5 +7,6 @@ mod clientinfo;
mod ipc; mod ipc;
mod state; mod state;
mod server; mod server;
mod shortcuts;
mod terminal; mod terminal;
mod tray; mod tray;

View File

@ -6,7 +6,7 @@
use creddy::{ use creddy::{
app, app,
cli, cli,
errors::ErrorPopup, errors::ShowError,
}; };
@ -18,6 +18,7 @@ fn main() {
}, },
Some(("get", m)) => cli::get(m), Some(("get", m)) => cli::get(m),
Some(("exec", m)) => cli::exec(m), Some(("exec", m)) => cli::exec(m),
Some(("shortcut", m)) => cli::invoke_shortcut(m),
_ => unreachable!(), _ => unreachable!(),
}; };

View File

@ -1,184 +0,0 @@
use std::time::Duration;
#[cfg(windows)]
use tokio::net::windows::named_pipe::{
NamedPipeServer,
ServerOptions,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use serde::{Serialize, Deserialize};
use tauri::{
AppHandle,
Manager,
async_runtime as rt,
};
use crate::errors::*;
use crate::clientinfo::{self, Client};
use crate::credentials::Credentials;
use crate::ipc::{Approval, AwsRequestNotification};
use crate::state::AppState;
#[derive(Serialize, Deserialize)]
pub enum Request {
GetAwsCredentials{
base: bool,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Response {
Aws(Credentials)
}
pub struct Server {
listener: tokio::net::windows::named_pipe::NamedPipeServer,
app_handle: AppHandle,
}
impl Server {
pub fn start(app_handle: AppHandle) -> std::io::Result<()> {
let listener = ServerOptions::new()
.first_pipe_instance(true)
.create(r"\\.\pipe\creddy-requests")?;
let srv = Server {listener, app_handle};
rt::spawn(srv.serve());
Ok(())
}
async fn serve(mut self) {
loop {
if let Err(e) = self.try_serve().await {
eprintln!("Error accepting connection: {e}");
}
}
}
async fn try_serve(&mut self) -> std::io::Result<()> {
// connect() just waits for a client to connect, it doesn't return anything
self.listener.connect().await?;
// create a new pipe instance to listen for the next client, and swap it in
let new_listener = ServerOptions::new().create(r"\\.\pipe\creddy-requests")?;
let mut stream = std::mem::replace(&mut self.listener, new_listener);
let new_handle = self.app_handle.app_handle();
rt::spawn(async move {
let res = serde_json::to_string(
&handle(&mut stream, new_handle).await
).unwrap();
if let Err(e) = stream.write_all(res.as_bytes()).await {
eprintln!("Error responding to request: {e}");
}
});
Ok(())
}
}
async fn handle(stream: &mut NamedPipeServer, app_handle: AppHandle) -> Result<Response, HandlerError> {
// read from stream until delimiter is reached
let mut buf: Vec<u8> = Vec::with_capacity(1024); // requests are small, 1KiB is more than enough
let mut n = 0;
loop {
n += stream.read_buf(&mut buf).await?;
if let Some(&b'\n') = buf.last() {
break;
}
else if n >= 1024 {
return Err(HandlerError::RequestTooLarge);
}
}
let client = clientinfo::get_client_parent(&stream)?;
let req: Request = serde_json::from_slice(&buf)?;
match req {
Request::GetAwsCredentials{ base } => get_aws_credentials(base, client, app_handle).await,
// etc
}
}
async fn get_aws_credentials(base: bool, client: Client, app_handle: AppHandle) -> Result<Response, HandlerError> {
let state = app_handle.state::<AppState>();
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 (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send).await;
// if an error occurs in any of the following, we want to abort the operation
// but ? returns immediately, and we want to unregister the request before returning
// so we bundle it all up in an async block and return a Result so we can handle errors
let proceed = async {
let notification = AwsRequestNotification {id: request_id, client, base};
app_handle.emit_all("credentials-request", &notification)?;
if !main_window.is_visible()? {
main_window.unminimize()?;
main_window.show()?;
}
main_window.set_focus()?;
match chan_recv.await {
Ok(Approval::Approved) => {
if base {
let creds = state.base_creds_cloned().await?;
Ok(Response::Aws(Credentials::Base(creds)))
}
else {
let creds = state.session_creds_cloned().await?;
Ok(Response::Aws(Credentials::Session(creds)))
}
},
Ok(Approval::Denied) => Err(HandlerError::Denied),
Err(_e) => Err(HandlerError::Internal),
}
};
let result = match proceed.await {
Ok(r) => Ok(r),
Err(e) => {
state.unregister_request(request_id).await;
Err(e)
}
};
rt::spawn(
handle_rehide(rehide_after, app_handle.app_handle())
);
result
}
async fn handle_rehide(rehide_after: bool, app_handle: AppHandle) {
let state = app_handle.state::<AppState>();
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}");
}
});
}
}
}

164
src-tauri/src/server/mod.rs Normal file
View File

@ -0,0 +1,164 @@
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use serde::{Serialize, Deserialize};
use tauri::{AppHandle, Manager};
use crate::errors::*;
use crate::clientinfo::{self, Client};
use crate::credentials::Credentials;
use crate::ipc::{Approval, AwsRequestNotification};
use crate::state::AppState;
use crate::shortcuts::{self, ShortcutAction};
#[cfg(windows)]
mod server_win;
#[cfg(windows)]
pub use server_win::Server;
#[cfg(windows)]
use server_win::Stream;
#[cfg(unix)]
mod server_unix;
#[cfg(unix)]
pub use server_unix::Server;
#[cfg(unix)]
use server_unix::Stream;
#[derive(Serialize, Deserialize)]
pub enum Request {
GetAwsCredentials{
base: bool,
},
InvokeShortcut(ShortcutAction),
}
#[derive(Debug, Serialize, Deserialize)]
pub enum Response {
Aws(Credentials),
Empty,
}
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>
{
// read from stream until delimiter is reached
let mut buf: Vec<u8> = Vec::with_capacity(1024); // requests are small, 1KiB is more than enough
let mut n = 0;
loop {
n += stream.read_buf(&mut buf).await?;
if let Some(&b'\n') = buf.last() {
break;
}
else if n >= 1024 {
return Err(HandlerError::RequestTooLarge);
}
}
let client = clientinfo::get_process_parent_info(client_pid)?;
let waiter = CloseWaiter { stream: &mut stream };
let req: Request = serde_json::from_slice(&buf)?;
let res = match req {
Request::GetAwsCredentials{ base } => get_aws_credentials(
base, client, app_handle, waiter
).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();
stream.write_all(&res).await?;
Ok(())
}
async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerError> {
shortcuts::exec_shortcut(action);
Ok(Response::Empty)
}
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 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;
// if an error occurs in any of the following, we want to abort the operation
// but ? returns immediately, and we want to unregister the request before returning
// so we bundle it all up in an async block and return a Result so we can handle errors
let proceed = async {
let notification = AwsRequestNotification {id: request_id, client, base};
app_handle.emit_all("credentials-request", &notification)?;
let response = tokio::select! {
r = chan_recv => r?,
_ = 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?;
Ok(Response::Aws(Credentials::Base(creds)))
}
else {
let creds = state.session_creds_cloned().await?;
Ok(Response::Aws(Credentials::Session(creds)))
}
},
Approval::Denied => Err(HandlerError::Denied),
}
};
let result = match proceed.await {
Ok(r) => Ok(r),
Err(e) => {
state.unregister_request(request_id).await;
Err(e)
}
};
lease.release();
result
}

View File

@ -0,0 +1,59 @@
use std::io::ErrorKind;
use tokio::net::{UnixListener, UnixStream};
use tauri::{
AppHandle,
Manager,
async_runtime as rt,
};
use crate::errors::*;
pub type Stream = UnixStream;
pub struct Server {
listener: UnixListener,
app_handle: AppHandle,
}
impl Server {
pub fn start(app_handle: AppHandle) -> std::io::Result<()> {
match std::fs::remove_file("/tmp/creddy.sock") {
Ok(_) => (),
Err(e) if e.kind() == ErrorKind::NotFound => (),
Err(e) => return Err(e),
}
let listener = UnixListener::bind("/tmp/creddy.sock")?;
let srv = Server { listener, app_handle };
rt::spawn(srv.serve());
Ok(())
}
async fn serve(self) {
loop {
self.try_serve()
.await
.error_print_prefix("Error accepting request: ");
}
}
async fn try_serve(&self) -> Result<(), HandlerError> {
let (stream, _addr) = self.listener.accept().await?;
let new_handle = self.app_handle.app_handle();
let client_pid = get_client_pid(&stream)?;
rt::spawn(async move {
super::handle(stream, new_handle, client_pid)
.await
.error_print_prefix("Error responding to request: ");
});
Ok(())
}
}
fn get_client_pid(stream: &UnixStream) -> std::io::Result<u32> {
let cred = stream.peer_cred()?;
Ok(cred.pid().unwrap() as u32)
}

View File

@ -0,0 +1,74 @@
use tokio::net::windows::named_pipe::{
NamedPipeServer,
ServerOptions,
};
use tauri::{AppHandle, Manager};
use windows::Win32:: {
Foundation::HANDLE,
System::Pipes::GetNamedPipeClientProcessId,
};
use std::os::windows::io::AsRawHandle;
use tauri::async_runtime as rt;
use crate::errors::*;
// used by parent module
pub type Stream = NamedPipeServer;
pub struct Server {
listener: NamedPipeServer,
app_handle: AppHandle,
}
impl Server {
pub fn start(app_handle: AppHandle) -> std::io::Result<()> {
let listener = ServerOptions::new()
.first_pipe_instance(true)
.create(r"\\.\pipe\creddy-requests")?;
let srv = Server {listener, app_handle};
rt::spawn(srv.serve());
Ok(())
}
async fn serve(mut self) {
loop {
if let Err(e) = self.try_serve().await {
eprintln!("Error accepting connection: {e}");
}
}
}
async fn try_serve(&mut self) -> Result<(), HandlerError> {
// connect() just waits for a client to connect, it doesn't return anything
self.listener.connect().await?;
// create a new pipe instance to listen for the next client, and swap it in
let new_listener = ServerOptions::new().create(r"\\.\pipe\creddy-requests")?;
let stream = std::mem::replace(&mut self.listener, new_listener);
let new_handle = self.app_handle.app_handle();
let client_pid = get_client_pid(&stream)?;
rt::spawn(async move {
super::handle(stream, new_handle, client_pid)
.await
.error_print_prefix("Error responding to request: ");
});
Ok(())
}
}
fn get_client_pid(pipe: &NamedPipeServer) -> Result<u32, ClientInfoError> {
let raw_handle = pipe.as_raw_handle();
let mut pid = 0u32;
let handle = HANDLE(raw_handle as _);
unsafe { GetNamedPipeClientProcessId(handle, &mut pid as *mut u32)? };
Ok(pid)
}

View File

@ -1,12 +1,14 @@
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tauri::{ use tauri::{
AppHandle, GlobalShortcutManager,
Manager, Manager,
async_runtime as rt,
}; };
use crate::app::APP; use crate::app::APP;
use crate::config::HotkeysConfig; use crate::config::HotkeysConfig;
use crate::errors::*;
use crate::terminal; use crate::terminal;
@ -19,11 +21,18 @@ pub enum ShortcutAction {
pub fn exec_shortcut(action: ShortcutAction) { pub fn exec_shortcut(action: ShortcutAction) {
match action { match action {
ShowWindow => { ShortcutAction::ShowWindow => {
let app = APP.get().unwrap(); 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 { if hotkeys.show_window.enabled {
manager.register( manager.register(
hotkeys.show_window.keys, &hotkeys.show_window.keys,
|| exec_shortcut(ShortcutAction::ShowWindow) || exec_shortcut(ShortcutAction::ShowWindow)
)?; )?;
} }

View File

@ -1,32 +1,117 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::{ use tokio::{
sync::RwLock, sync::RwLock,
sync::oneshot::Sender, sync::oneshot::{self, Sender},
}; };
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tauri::{
Manager,
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;
#[derive(Debug)]
struct Visibility {
leases: usize,
original: Option<bool>,
}
impl Visibility {
fn new() -> Self {
Visibility { leases: 0, original: None }
}
fn acquire(&mut self, delay_ms: u64) -> Result<VisibilityLease, WindowError> {
let app = crate::app::APP.get().unwrap();
let window = app.get_window("main")
.ok_or(WindowError::NoMainWindow)?;
self.leases += 1;
// `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,
// Some(true) means it was previously visible
let is_visible = window.is_visible()?;
if self.original.is_none() {
self.original = Some(is_visible);
}
let state = app.state::<AppState>();
if is_visible && state.desktop_is_gnome {
// 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
// we hide and then immediately unhide the window
window.hide()?;
}
app::show_main_window(&app)?;
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::<AppState>();
let mut visibility = state.visibility.write().await;
visibility.leases -= 1;
if visibility.leases == 0 {
if let Some(false) = visibility.original {
app::hide_main_window(&handle).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)] #[derive(Debug)]
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 current_rehide_status: RwLock<Option<bool>>,
pub pending_terminal_request: RwLock<bool>, pub pending_terminal_request: RwLock<bool>,
// setup_errors is never modified and so doesn't need to be wrapped in RwLock // these are never modified and so don't need to be wrapped in RwLocks
pub setup_errors: Vec<String>, pub setup_errors: Vec<String>,
pub desktop_is_gnome: bool,
pool: sqlx::SqlitePool, pool: sqlx::SqlitePool,
visibility: RwLock<Visibility>,
} }
impl AppState { impl AppState {
@ -35,16 +120,19 @@ impl AppState {
session: Session, session: Session,
pool: SqlitePool, pool: SqlitePool,
setup_errors: Vec<String>, setup_errors: Vec<String>,
desktop_is_gnome: bool,
) -> AppState { ) -> 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()),
current_rehide_status: RwLock::new(None),
pending_terminal_request: RwLock::new(false), pending_terminal_request: RwLock::new(false),
setup_errors, setup_errors,
desktop_is_gnome,
pool, pool,
visibility: RwLock::new(Visibility::new()),
} }
} }
@ -69,7 +157,7 @@ impl AppState {
if new_config.hotkeys.show_window != live_config.hotkeys.show_window if new_config.hotkeys.show_window != live_config.hotkeys.show_window
|| new_config.hotkeys.launch_terminal != live_config.hotkeys.launch_terminal || 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?; new_config.save(&self.pool).await?;
@ -77,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;
@ -94,25 +182,9 @@ impl AppState {
waiting_requests.remove(&id); waiting_requests.remove(&id);
} }
pub async fn req_count(&self) -> usize { pub async fn acquire_visibility_lease(&self, delay: u64) -> Result<VisibilityLease, WindowError> {
let waiting_requests = self.waiting_requests.read().await; let mut visibility = self.visibility.write().await;
waiting_requests.len() visibility.acquire(delay)
}
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 send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> { pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
@ -125,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)
} }
@ -141,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{..})
@ -154,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

@ -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 session is unlocked or empty, wait for credentials from frontend
if !state.is_unlocked().await { if !state.is_unlocked().await {
app.emit_all("launch-terminal-request", ())?; app.emit_all("launch-terminal-request", ())?;
let window = app.get_window("main") let lease = state.acquire_visibility_lease(0).await
.ok_or(LaunchTerminalError::NoMainWindow)?; .map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
if !window.is_visible()? {
window.unminimize()?;
window.show()?;
}
window.set_focus()?;
let (tx, rx) = tokio::sync::oneshot::channel(); let (tx, rx) = tokio::sync::oneshot::channel();
app.once_global("credentials-event", move |e| { 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; state.unregister_terminal_request().await;
return Ok(()); // request was canceled by user return Ok(()); // request was canceled by user
} }
lease.release();
} }
// more lock-management // more lock-management

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.3.3" "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

@ -10,15 +10,21 @@
export let min = null; export let min = null;
export let max = null; export let max = null;
export let decimal = false; export let decimal = false;
export let debounceInterval = 0;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
$: localValue = value.toString(); $: localValue = value.toString();
let lastInputTime = null; let lastInputTime = null;
function debounce(event) { function debounce(event) {
lastInputTime = Date.now();
localValue = localValue.replace(/[^-0-9.]/g, ''); localValue = localValue.replace(/[^-0-9.]/g, '');
if (debounceInterval === 0) {
updateValue(localValue);
return;
}
lastInputTime = Date.now();
const eventTime = lastInputTime; const eventTime = lastInputTime;
const pendingValue = localValue; const pendingValue = localValue;
window.setTimeout( window.setTimeout(
@ -28,7 +34,7 @@
updateValue(pendingValue); updateValue(pendingValue);
} }
}, },
500 debounceInterval,
) )
} }

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

@ -1,6 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { emit } from '@tauri-apps/api/event';
import { getRootCause } from '../lib/errors.js'; import { getRootCause } from '../lib/errors.js';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';

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

@ -14,15 +14,18 @@
import { backInOut } from 'svelte/easing'; import { backInOut } from 'svelte/easing';
// make an independent copy so it can differ from the main config object
let config = JSON.parse(JSON.stringify($appState.config));
$: configModified = JSON.stringify(config) !== JSON.stringify($appState.config);
let error = null; let error = null;
async function save() { async function save() {
console.log('updating config');
try { try {
await invoke('save_config', {config: $appState.config}); await invoke('save_config', {config});
$appState.config = await invoke('get_config');
} }
catch (e) { catch (e) {
error = e; error = e;
$appState.config = await invoke('get_config');
} }
} }
@ -35,62 +38,60 @@
<h1 slot="title" class="text-2xl font-bold">Settings</h1> <h1 slot="title" class="text-2xl font-bold">Settings</h1>
</Nav> </Nav>
{#await invoke('get_config') then config} <div class="max-w-lg mx-auto mt-1.5 mb-24 p-4 space-y-16">
<div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16"> <SettingsGroup name="General">
<SettingsGroup name="General"> <ToggleSetting title="Start on login" bind:value={config.start_on_login}>
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}> <svelte:fragment slot="description">
<svelte:fragment slot="description"> Start Creddy when you log in to your computer.
Start Creddy when you log in to your computer. </svelte:fragment>
</svelte:fragment> </ToggleSetting>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}> <ToggleSetting title="Start minimized" bind:value={config.start_minimized}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Minimize to the system tray at startup. Minimize to the system tray at startup.
</svelte:fragment> </svelte:fragment>
</ToggleSetting> </ToggleSetting>
<NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}> <NumericSetting title="Re-hide delay" bind:value={config.rehide_ms} min={0} unit="Milliseconds">
<svelte:fragment slot="description"> <svelte:fragment slot="description">
How long to wait after a request is approved/denied before minimizing How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized the window to tray. Only applicable if the window was minimized
to tray before the request was received. to tray before the request was received.
</svelte:fragment> </svelte:fragment>
</NumericSetting> </NumericSetting>
<Setting title="Update credentials"> <Setting title="Update credentials">
<Link slot="input" target="EnterCredentials"> <Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button> <button class="btn btn-sm btn-primary">Update</button>
</Link> </Link>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Update or re-enter your encrypted credentials. Update or re-enter your encrypted credentials.
</svelte:fragment> </svelte:fragment>
</Setting> </Setting>
<FileSetting <FileSetting
title="Terminal emulator" title="Terminal emulator"
bind:value={$appState.config.terminal.exec} bind:value={config.terminal.exec}
on:update={save}
> >
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>. Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
</svelte:fragment> </svelte:fragment>
</FileSetting> </FileSetting>
</SettingsGroup> </SettingsGroup>
<SettingsGroup name="Hotkeys"> <SettingsGroup name="Hotkeys">
<div class="space-y-4"> <div class="space-y-4">
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p> <p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center"> <div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
<Keybind description="Show Creddy" value={$appState.config.hotkeys.show_window} on:update={save} /> <Keybind description="Show Creddy" bind:value={config.hotkeys.show_window} />
<Keybind description="Launch terminal" value={$appState.config.hotkeys.launch_terminal} on:update={save} /> <Keybind description="Launch terminal" bind:value={config.hotkeys.launch_terminal} />
</div>
</div> </div>
</SettingsGroup> </div>
</SettingsGroup>
</div> </div>
{/await}
{#if error} {#if error}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast"> <div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
@ -104,4 +105,15 @@
</div> </div>
</div> </div>
</div> </div>
{:else if configModified}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
<div class="alert shadow-lg no-animation">
<span>You have unsaved changes.</span>
<div>
<!-- <button class="btn btn-sm btn-ghost">Cancel</button> -->
<button class="btn btn-sm btn-primary" on:click={save}>Save</button>
</div>
</div>
</div>
{/if} {/if}

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>