15 Commits

29 changed files with 1117 additions and 1749 deletions

3
.gitignore vendored
View File

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

1854
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

2
src-tauri/Cargo.lock generated
View File

@ -1035,7 +1035,7 @@ dependencies = [
[[package]]
name = "creddy"
version = "0.3.3"
version = "0.4.1"
dependencies = [
"argon2",
"auto-launch",

View File

@ -1,6 +1,6 @@
[package]
name = "creddy"
version = "0.3.3"
version = "0.4.2"
description = "A friendly AWS credentials manager"
authors = ["Joseph Montanaro"]
license = ""

View File

@ -19,6 +19,7 @@ use crate::{
ipc,
server::Server,
errors::*,
shortcuts,
state::AppState,
tray,
};
@ -81,7 +82,7 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
let pool = connect_db().await?;
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,
Err(SetupError::ConfigParseError(_)) => {
setup_errors.push(
@ -99,10 +100,18 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
if let Err(_e) = config::set_auto_launch(conf.start_on_login) {
setup_errors.push("Error: Failed to manage autolaunch.".into());
}
if let Err(e) = config::register_hotkeys(&conf.hotkeys) {
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 !conf.start_minimized || is_first_launch {
app.get_window("main")
@ -110,7 +119,7 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
.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);
Ok(())
}

View File

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

View File

@ -1,23 +1,25 @@
use std::ffi::OsString;
use std::process::Command as ChildCommand;
#[cfg(windows)]
use std::time::Duration;
use clap::{
Command,
Arg,
ArgMatches,
ArgAction
Arg,
ArgMatches,
ArgAction,
builder::PossibleValuesParser,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::credentials::Credentials;
use crate::errors::*;
use crate::server::{Request, Response};
use crate::shortcuts::ShortcutAction;
#[cfg(unix)]
use {
std::os::unix::process::CommandExt,
std::path::Path,
tokio::net::UnixStream,
};
@ -63,6 +65,16 @@ pub fn parser() -> Command<'static> {
.multiple_values(true)
)
)
.subcommand(
Command::new("shortcut")
.about("Invoke an action normally trigged by hotkey (e.g. launch terminal)")
.arg(
Arg::new("action")
.value_parser(
PossibleValuesParser::new(["show_window", "launch_terminal"])
)
)
)
}
@ -129,10 +141,35 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
}
#[tokio::main]
async fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> {
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 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
data.push('\n');
@ -142,12 +179,7 @@ async fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
let mut buf = Vec::with_capacity(1024);
stream.read_to_end(&mut buf).await?;
let res: Result<Response, ServerError> = serde_json::from_slice(&buf)?;
match res {
Ok(Response::Aws(creds)) => Ok(creds),
// Eventually we will want this
// Ok(r) => Err(RequestError::Unexpected(r)),
Err(e) => Err(RequestError::Server(e)),
}
Ok(res?)
}
@ -167,7 +199,5 @@ async fn connect() -> Result<NamedPipeClient, std::io::Error> {
#[cfg(unix)]
async fn connect() -> Result<UnixStream, std::io::Error> {
let path = Path::from("/tmp/creddy-requests");
std::fs::remove_file(path)?;
UnixStream::connect(path)
UnixStream::connect("/tmp/creddy.sock").await
}

View File

@ -2,19 +2,6 @@ use std::path::{Path, PathBuf};
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
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::*;
@ -26,25 +13,8 @@ pub struct Client {
}
#[cfg(unix)]
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> {
pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
dbg!(pid);
let sys_pid = Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_process(sys_pid);
@ -64,59 +34,3 @@ fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
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

@ -4,11 +4,6 @@ use auto_launch::AutoLaunchBuilder;
use is_terminal::IsTerminal;
use serde::{Serialize, Deserialize};
use sqlx::SqlitePool;
use tauri::{
Manager,
GlobalShortcutManager,
async_runtime as rt,
};
use crate::errors::*;
@ -38,6 +33,13 @@ pub struct HotkeysConfig {
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)]
pub struct AppConfig {
@ -183,43 +185,6 @@ 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 }
// start minimized and on login only in production mode

View File

@ -18,6 +18,7 @@ use tauri::api::dialog::{
MessageDialogBuilder,
MessageDialogKind,
};
use tokio::sync::oneshot::error::RecvError;
use serde::{
Serialize,
Serializer,
@ -26,12 +27,14 @@ use serde::{
};
pub trait ErrorPopup {
pub trait ShowError {
fn error_popup(self, title: &str);
fn error_popup_nowait(self, title: &str);
fn error_print(self);
fn error_print_prefix(self, prefix: &str);
}
impl<E: std::fmt::Display> ErrorPopup for Result<(), E> {
impl<E: std::fmt::Display> ShowError for Result<(), E> {
fn error_popup(self, title: &str) {
if let Err(e) = self {
let (tx, rx) = mpsc::channel();
@ -50,6 +53,18 @@ impl<E: std::fmt::Display> ErrorPopup for Result<(), E> {
.show(|_| {})
}
}
fn error_print(self) {
if let Err(e) = self {
eprintln!("{e}");
}
}
fn error_print_prefix(self, prefix: &str) {
if let Err(e) = self {
eprintln!("{prefix}: {e}");
}
}
}
@ -150,7 +165,7 @@ pub enum HandlerError {
#[error("HTTP request too large")]
RequestTooLarge,
#[error("Internal server error")]
Internal,
Internal(#[from] RecvError),
#[error("Error accessing credentials: {0}")]
NoCredentials(#[from] GetCredentialsError),
#[error("Error getting client details: {0}")]
@ -164,6 +179,15 @@ pub enum HandlerError {
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum WindowError {
#[error("Failed to find main application window")]
NoMainWindow,
#[error(transparent)]
ManageFailure(#[from] tauri::Error),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum GetCredentialsError {
#[error("Credentials are currently locked")]
@ -221,6 +245,7 @@ pub enum ClientInfoError {
ParentPidNotFound,
#[error("Found PID for parent process of client, but no corresponding process")]
ParentProcessNotFound,
#[cfg(windows)]
#[error("Could not determine PID of connected client")]
WindowsError(#[from] windows::core::Error),
#[error(transparent)]
@ -324,6 +349,7 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> {
impl_serialize_basic!(SetupError);
impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError);
impl_serialize_basic!(WindowError);
impl Serialize for HandlerError {

View File

@ -21,6 +21,7 @@ pub struct AwsRequestNotification {
pub struct RequestResponse {
pub id: u64,
pub approval: Approval,
pub base: bool,
}

View File

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

View File

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

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

@ -0,0 +1,126 @@
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,
}
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 req: Request = serde_json::from_slice(&buf)?;
let res = match req {
Request::GetAwsCredentials{ base } => get_aws_credentials(base, client, app_handle).await,
Request::InvokeShortcut(action) => invoke_shortcut(action).await,
};
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) -> 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 = chan_recv.await?;
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 tauri::{
AppHandle,
GlobalShortcutManager,
Manager,
async_runtime as rt,
};
use crate::app::APP;
use crate::config::HotkeysConfig;
use crate::errors::*;
use crate::terminal;
@ -19,11 +21,18 @@ pub enum ShortcutAction {
pub fn exec_shortcut(action: ShortcutAction) {
match action {
ShowWindow => {
ShortcutAction::ShowWindow => {
let app = APP.get().unwrap();
app.get_window("main").map(|w| w.show());
app.get_window("main")
.ok_or("Couldn't find application main window")
.map(|w| w.show().error_popup("Failed to show window"))
.error_popup("Failed to show window");
},
ShortcutAction::LaunchTerminal => {
rt::spawn(async {
terminal::launch(false).await.error_popup("Failed to launch terminal");
});
},
LaunchTerminal => terminal::launch(false),
}
}
@ -35,7 +44,7 @@ pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> {
if hotkeys.show_window.enabled {
manager.register(
hotkeys.show_window.keys,
&hotkeys.show_window.keys,
|| exec_shortcut(ShortcutAction::ShowWindow)
)?;
}

View File

@ -1,10 +1,15 @@
use std::collections::HashMap;
use std::time::Duration;
use tokio::{
sync::RwLock,
sync::oneshot::Sender,
sync::oneshot::{self, Sender},
};
use sqlx::SqlitePool;
use tauri::{
Manager,
async_runtime as rt,
};
use crate::credentials::{
Session,
@ -12,8 +17,84 @@ use crate::credentials::{
SessionCredentials,
};
use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval};
use crate::ipc::{self, Approval, RequestResponse};
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
if self.original.is_none() {
let is_visible = window.is_visible()?;
self.original = Some(is_visible);
}
let state = app.state::<AppState>();
if matches!(self.original, Some(true)) && 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()?;
}
window.show()?;
window.set_focus()?;
let (tx, rx) = oneshot::channel();
let lease = VisibilityLease { notify: tx };
let delay = Duration::from_millis(delay_ms);
let handle = app.app_handle();
rt::spawn(async move {
// We don't care if it's an error; lease being dropped should be handled identically
let _ = rx.await;
tokio::time::sleep(delay).await;
// we can't use `self` here because we would have to move it into the async block
let state = handle.state::<AppState>();
let mut visibility = state.visibility.write().await;
visibility.leases -= 1;
if visibility.leases == 0 {
if let Some(false) = visibility.original {
window.hide().error_print();
}
visibility.original = None;
}
});
Ok(lease)
}
}
pub struct VisibilityLease {
notify: Sender<()>,
}
impl VisibilityLease {
pub fn release(self) {
rt::spawn(async move {
if let Err(_) = self.notify.send(()) {
eprintln!("Error releasing visibility lease")
}
});
}
}
#[derive(Debug)]
@ -21,12 +102,13 @@ pub struct AppState {
pub config: RwLock<AppConfig>,
pub session: RwLock<Session>,
pub request_count: RwLock<u64>,
pub waiting_requests: RwLock<HashMap<u64, Sender<Approval>>>,
pub current_rehide_status: RwLock<Option<bool>>,
pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
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 desktop_is_gnome: bool,
pool: sqlx::SqlitePool,
visibility: RwLock<Visibility>,
}
impl AppState {
@ -35,16 +117,18 @@ impl AppState {
session: Session,
pool: SqlitePool,
setup_errors: Vec<String>,
desktop_is_gnome: bool,
) -> AppState {
AppState {
config: RwLock::new(config),
session: RwLock::new(session),
request_count: RwLock::new(0),
waiting_requests: RwLock::new(HashMap::new()),
current_rehide_status: RwLock::new(None),
pending_terminal_request: RwLock::new(false),
setup_errors,
desktop_is_gnome,
pool,
visibility: RwLock::new(Visibility::new()),
}
}
@ -69,7 +153,7 @@ impl AppState {
if new_config.hotkeys.show_window != live_config.hotkeys.show_window
|| new_config.hotkeys.launch_terminal != live_config.hotkeys.launch_terminal
{
config::register_hotkeys(&new_config.hotkeys)?;
shortcuts::register_hotkeys(&new_config.hotkeys)?;
}
new_config.save(&self.pool).await?;
@ -77,7 +161,7 @@ impl AppState {
Ok(())
}
pub async fn register_request(&self, sender: Sender<Approval>) -> u64 {
pub async fn register_request(&self, sender: Sender<RequestResponse>) -> u64 {
let count = {
let mut c = self.request_count.write().await;
*c += 1;
@ -94,25 +178,9 @@ impl AppState {
waiting_requests.remove(&id);
}
pub async fn req_count(&self) -> usize {
let waiting_requests = self.waiting_requests.read().await;
waiting_requests.len()
}
pub async fn get_or_set_rehide(&self, new_value: bool) -> bool {
let mut rehide = self.current_rehide_status.write().await;
match *rehide {
Some(original) => original,
None => {
*rehide = Some(new_value);
new_value
}
}
}
pub async fn clear_rehide(&self) {
let mut rehide = self.current_rehide_status.write().await;
*rehide = None;
pub async fn acquire_visibility_lease(&self, delay: u64) -> Result<VisibilityLease, WindowError> {
let mut visibility = self.visibility.write().await;
visibility.acquire(delay)
}
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
@ -125,7 +193,7 @@ impl AppState {
waiting_requests
.remove(&response.id)
.ok_or(SendResponseError::NotFound)?
.send(response.approval)
.send(response)
.map_err(|_| SendResponseError::Abandoned)
}

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 !state.is_unlocked().await {
app.emit_all("launch-terminal-request", ())?;
let window = app.get_window("main")
.ok_or(LaunchTerminalError::NoMainWindow)?;
if !window.is_visible()? {
window.unminimize()?;
window.show()?;
}
window.set_focus()?;
let lease = state.acquire_visibility_lease(0).await
.map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
let (tx, rx) = tokio::sync::oneshot::channel();
app.once_global("credentials-event", move |e| {
@ -47,6 +42,7 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
state.unregister_terminal_request().await;
return Ok(()); // request was canceled by user
}
lease.release();
}
// more lock-management

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "creddy",
"version": "0.3.3"
"version": "0.4.2"
},
"tauri": {
"allowlist": {

View File

@ -5,3 +5,8 @@
.btn-alert-error {
@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

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

View File

@ -11,10 +11,11 @@
// Send response to backend, display error if applicable
let error, alert;
let base = $appState.currentRequest.base;
async function respond() {
let {id, approval} = $appState.currentRequest;
try {
await invoke('respond', {response: {id, approval}});
await invoke('respond', {response: {id, approval, base}});
navigate('ShowResponse');
}
catch (e) {
@ -83,7 +84,7 @@
<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>
<span>
WARNING: This application is requesting your base (long-lived) AWS credentials.
WARNING: This application is requesting your long-lived AWS credentials.
These credentials are less secure than session credentials, since they don't expire automatically.
</span>
</div>
@ -116,5 +117,12 @@
</button>
</Link>
</div>
<div class="w-full">
<label class="label cursor-pointer justify-end gap-x-2">
<span class="label-text">Send long-lived credentials</span>
<input type="checkbox" class="checkbox checkbox-success" bind:checked={base}>
</label>
</div>
</div>
{/if}
{/if}

View File

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

View File

@ -39,8 +39,8 @@
Launch Terminal
</button>
<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}>
<span class="label-text">Launch with base credentials</span>
</label>
{:else if status === 'empty'}

View File

@ -14,15 +14,18 @@
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;
async function save() {
console.log('updating config');
try {
await invoke('save_config', {config: $appState.config});
await invoke('save_config', {config});
$appState.config = await invoke('get_config');
}
catch (e) {
error = e;
$appState.config = await invoke('get_config');
}
}
@ -35,62 +38,60 @@
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
</Nav>
{#await invoke('get_config') then config}
<div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16">
<SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<div class="max-w-lg mx-auto mt-1.5 mb-24 p-4 space-y-16">
<SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={config.start_on_login}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}>
<svelte:fragment slot="description">
Minimize to the system tray at startup.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={config.start_minimized}>
<svelte:fragment slot="description">
Minimize to the system tray at startup.
</svelte:fragment>
</ToggleSetting>
<NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}>
<svelte:fragment slot="description">
How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized
to tray before the request was received.
</svelte:fragment>
</NumericSetting>
<NumericSetting title="Re-hide delay" bind:value={config.rehide_ms} min={0} unit="Milliseconds">
<svelte:fragment slot="description">
How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized
to tray before the request was received.
</svelte:fragment>
</NumericSetting>
<Setting title="Update credentials">
<Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button>
</Link>
<svelte:fragment slot="description">
Update or re-enter your encrypted credentials.
</svelte:fragment>
</Setting>
<Setting title="Update credentials">
<Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button>
</Link>
<svelte:fragment slot="description">
Update or re-enter your encrypted credentials.
</svelte:fragment>
</Setting>
<FileSetting
title="Terminal emulator"
bind:value={$appState.config.terminal.exec}
on:update={save}
>
<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>.
</svelte:fragment>
</FileSetting>
</SettingsGroup>
<FileSetting
title="Terminal emulator"
bind:value={config.terminal.exec}
>
<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>.
</svelte:fragment>
</FileSetting>
</SettingsGroup>
<SettingsGroup name="Hotkeys">
<div class="space-y-4">
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
<SettingsGroup name="Hotkeys">
<div class="space-y-4">
<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">
<Keybind description="Show Creddy" value={$appState.config.hotkeys.show_window} on:update={save} />
<Keybind description="Launch terminal" value={$appState.config.hotkeys.launch_terminal} on:update={save} />
</div>
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
<Keybind description="Show Creddy" bind:value={config.hotkeys.show_window} />
<Keybind description="Launch terminal" bind:value={config.hotkeys.launch_terminal} />
</div>
</SettingsGroup>
</div>
</SettingsGroup>
</div>
{/await}
</div>
{#if error}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
@ -104,4 +105,15 @@
</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> -->
<buton class="btn btn-sm btn-primary" on:click={save}>Save</buton>
</div>
</div>
</div>
{/if}