8 Commits

14 changed files with 145 additions and 159 deletions

View File

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

2
src-tauri/Cargo.lock generated
View File

@ -1040,7 +1040,7 @@ dependencies = [
[[package]] [[package]]
name = "creddy" name = "creddy"
version = "0.2.3" version = "0.3.1"
dependencies = [ dependencies = [
"argon2", "argon2",
"auto-launch", "auto-launch",

View File

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

View File

@ -98,7 +98,7 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
let name: OsString = cmd_name.into(); let name: OsString = cmd_name.into();
Err(ExecError::NotFound(name).into()) Err(ExecError::NotFound(name).into())
} }
e => Err(ExecError::ExecutionFailed(e).into()), _ => Err(ExecError::ExecutionFailed(e).into()),
} }
} }

View File

@ -116,6 +116,8 @@ pub enum SendResponseError {
NotFound, NotFound,
#[error("The specified request was already closed by the client")] #[error("The specified request was already closed by the client")]
Abandoned, Abandoned,
#[error("A response has already been received for the specified request")]
Fulfilled,
#[error("Could not renew AWS sesssion: {0}")] #[error("Could not renew AWS sesssion: {0}")]
SessionRenew(#[from] GetSessionError), SessionRenew(#[from] GetSessionError),
} }

View File

@ -10,7 +10,7 @@ use tokio::net::{
TcpStream, TcpStream,
}; };
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot; use tokio::sync::oneshot::{self, Sender, Receiver};
use tokio::time::sleep; use tokio::time::sleep;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
@ -23,24 +23,55 @@ use crate::ipc::{Request, Approval};
use crate::state::AppState; use crate::state::AppState;
#[derive(Debug)]
pub struct RequestWaiter {
pub rehide_after: bool,
pub sender: Option<Sender<Approval>>,
}
impl RequestWaiter {
pub fn notify(&mut self, approval: Approval) -> Result<(), SendResponseError> {
let chan = self.sender
.take()
.ok_or(SendResponseError::Fulfilled)?;
chan.send(approval)
.map_err(|_| SendResponseError::Abandoned)
}
}
struct Handler { struct Handler {
request_id: u64, request_id: u64,
stream: TcpStream, stream: TcpStream,
receiver: Option<oneshot::Receiver<Approval>>, rehide_after: bool,
receiver: Option<Receiver<Approval>>,
app: AppHandle, app: AppHandle,
} }
impl Handler { impl Handler {
async fn new(stream: TcpStream, app: AppHandle) -> Self { async fn new(stream: TcpStream, app: AppHandle) -> Result<Self, HandlerError> {
let state = app.state::<AppState>(); let state = app.state::<AppState>();
// determine whether we should re-hide the window after handling this request
let is_currently_visible = app.get_window("main")
.ok_or(HandlerError::NoMainWindow)?
.is_visible()?;
let rehide_after = state.current_rehide_status()
.await
.unwrap_or(!is_currently_visible);
let (chan_send, chan_recv) = oneshot::channel(); let (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send).await; let waiter = RequestWaiter {rehide_after, sender: Some(chan_send)};
Handler { let request_id = state.register_request(waiter).await;
let handler = Handler {
request_id, request_id,
stream, stream,
rehide_after,
receiver: Some(chan_recv), receiver: Some(chan_recv),
app app
} };
Ok(handler)
} }
async fn handle(mut self) { async fn handle(mut self) {
@ -62,7 +93,7 @@ impl Handler {
let req = Request {id: self.request_id, clients, base}; let req = Request {id: self.request_id, clients, base};
self.app.emit_all("credentials-request", &req)?; self.app.emit_all("credentials-request", &req)?;
let starting_visibility = self.show_window()?; self.show_window()?;
match self.wait_for_response().await? { match self.wait_for_response().await? {
Approval::Approved => { Approval::Approved => {
@ -94,9 +125,11 @@ impl Handler {
}; };
sleep(delay).await; sleep(delay).await;
if !starting_visibility && state.req_count().await == 0 { if self.rehide_after && state.req_count().await == 1 {
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?; self.app
window.hide()?; .get_window("main")
.ok_or(HandlerError::NoMainWindow)?
.hide()?;
} }
Ok(()) Ok(())
@ -143,15 +176,14 @@ impl Handler {
false false
} }
fn show_window(&self) -> Result<bool, HandlerError> { fn show_window(&self) -> Result<(), HandlerError> {
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?; let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?;
let starting_visibility = window.is_visible()?; if !window.is_visible()? {
if !starting_visibility {
window.unminimize()?; window.unminimize()?;
window.show()?; window.show()?;
} }
window.set_focus()?; window.set_focus()?;
Ok(starting_visibility) Ok(())
} }
async fn wait_for_response(&mut self) -> Result<Approval, HandlerError> { async fn wait_for_response(&mut self) -> Result<Approval, HandlerError> {
@ -231,12 +263,12 @@ impl Server {
loop { loop {
match listener.accept().await { match listener.accept().await {
Ok((stream, _)) => { Ok((stream, _)) => {
let handler = Handler::new(stream, app_handle.app_handle()).await; match Handler::new(stream, app_handle.app_handle()).await {
rt::spawn(handler.handle()); Ok(handler) => { rt::spawn(handler.handle()); }
Err(e) => { eprintln!("Error handling request: {e}"); }
}
}, },
Err(e) => { Err(e) => { eprintln!("Error accepting connection: {e}"); }
eprintln!("Error accepting connection: {e}");
}
} }
} }
} }

View File

@ -2,7 +2,6 @@ use std::collections::{HashMap, HashSet};
use std::time::Duration; use std::time::Duration;
use tokio::{ use tokio::{
sync::oneshot::Sender,
sync::RwLock, sync::RwLock,
time::sleep, time::sleep,
}; };
@ -20,7 +19,7 @@ use crate::{config, config::AppConfig};
use crate::ipc::{self, Approval}; use crate::ipc::{self, Approval};
use crate::clientinfo::Client; use crate::clientinfo::Client;
use crate::errors::*; use crate::errors::*;
use crate::server::Server; use crate::server::{Server, RequestWaiter};
#[derive(Debug)] #[derive(Debug)]
@ -28,7 +27,7 @@ pub struct AppState {
pub config: RwLock<AppConfig>, pub config: RwLock<AppConfig>,
pub session: RwLock<Session>, pub session: RwLock<Session>,
pub request_count: RwLock<u64>, pub request_count: RwLock<u64>,
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>, pub waiting_requests: RwLock<HashMap<u64, RequestWaiter>>,
pub pending_terminal_request: RwLock<bool>, pub pending_terminal_request: RwLock<bool>,
pub bans: RwLock<std::collections::HashSet<Option<Client>>>, pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
server: RwLock<Server>, server: RwLock<Server>,
@ -41,7 +40,7 @@ impl AppState {
config: RwLock::new(config), config: RwLock::new(config),
session: RwLock::new(session), session: RwLock::new(session),
request_count: RwLock::new(0), request_count: RwLock::new(0),
open_requests: RwLock::new(HashMap::new()), waiting_requests: RwLock::new(HashMap::new()),
pending_terminal_request: RwLock::new(false), pending_terminal_request: RwLock::new(false),
bans: RwLock::new(HashSet::new()), bans: RwLock::new(HashSet::new()),
server: RwLock::new(server), server: RwLock::new(server),
@ -84,26 +83,33 @@ impl AppState {
Ok(()) Ok(())
} }
pub async fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 { pub async fn register_request(&self, waiter: RequestWaiter) -> 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;
c c
}; };
let mut open_requests = self.open_requests.write().await; let mut waiting_requests = self.waiting_requests.write().await;
open_requests.insert(*count, chan); // `count` is the request id waiting_requests.insert(*count, waiter); // `count` is the request id
*count *count
} }
pub async fn unregister_request(&self, id: u64) { pub async fn unregister_request(&self, id: u64) {
let mut open_requests = self.open_requests.write().await; let mut waiting_requests = self.waiting_requests.write().await;
open_requests.remove(&id); waiting_requests.remove(&id);
} }
pub async fn req_count(&self) -> usize { pub async fn req_count(&self) -> usize {
let open_requests = self.open_requests.read().await; let waiting_requests = self.waiting_requests.read().await;
open_requests.len() waiting_requests.len()
}
pub async fn current_rehide_status(&self) -> Option<bool> {
// since all requests that are pending at a given time should have the same
// value for rehide_after, it doesn't matter which one we use
let waiting_requests = self.waiting_requests.read().await;
waiting_requests.iter().next().map(|(_id, w)| w.rehide_after)
} }
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> { pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
@ -112,14 +118,11 @@ impl AppState {
session.renew_if_expired().await?; session.renew_if_expired().await?;
} }
let mut open_requests = self.open_requests.write().await; let mut waiting_requests = self.waiting_requests.write().await;
let chan = open_requests waiting_requests
.remove(&response.id) .get_mut(&response.id)
.ok_or(SendResponseError::NotFound) .ok_or(SendResponseError::NotFound)?
?; .notify(response.approval)
chan.send(response.approval)
.map_err(|_e| SendResponseError::Abandoned)
} }
pub async fn add_ban(&self, client: Option<Client>) { pub async fn add_ban(&self, client: Option<Client>) {

View File

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

View File

@ -1,113 +1,42 @@
<script> <script>
export let color = 'base-content'; export let thickness = 8;
export let thickness = '2px';
let classes = ''; let classes = '';
export { classes as class }; export { classes as class };
const colorVars = { const radius = (100 - thickness) / 2;
'primary': 'p', // the px are fake, but we need them to satisfy css calc()
'primary-focus': 'pf', const circumference = `${2 * Math.PI * radius}px`;
'primary-content': 'pc',
'secondary': 's',
'secondary-focus': 'sf',
'secondary-content': 'sc',
'accent': 'a',
'accent-focus': 'af',
'accent-content': 'ac',
'neutral': 'n',
'neutral-focus': 'nf',
'neutral-content': 'nc',
'base-100': 'b1',
'base-200': 'b2',
'base-300': 'b3',
'base-content': 'bc',
'info': 'in',
'info-content': 'inc',
'success': 'su',
'success-content': 'suc',
'warning': 'wa',
'warning-content': 'wac',
'error': 'er',
'error-content': 'erc',
}
let arcStyle = `border-width: ${thickness};`;
arcStyle += `border-color: hsl(var(--${colorVars[color]})) transparent transparent transparent;`;
</script> </script>
<style>
#spinner {
position: relative;
animation: spin; <svg
animation-duration: 1.5s; style:--circumference={circumference}
animation-iteration-count: infinite; class={classes}
animation-timing-function: linear; viewBox="0 0 100 100"
stroke="currentColor"
>
<circle cx="50" cy="50" r={radius} stroke-width={thickness} />
</svg>
<style>
circle {
fill: transparent;
stroke-dasharray: var(--circumference);
transform: rotate(-90deg);
transform-origin: center;
animation: chase 3s infinite,
spin 1.5s linear infinite;
}
@keyframes chase {
0% { stroke-dashoffset: calc(-1 * var(--circumference)); }
50% { stroke-dashoffset: calc(-2 * var(--circumference)); }
100% { stroke-dashoffset: calc(-3 * var(--circumference)); }
} }
@keyframes spin { @keyframes spin {
50% { transform: rotate(225deg); } 50% { transform: rotate(135deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(270deg); }
} }
</style>
.arc {
position: absolute;
top: 0;
left: 0;
border-radius: 9999px;
}
.arc-top {
transform: rotate(-45deg);
}
.arc-right {
animation: spin-right;
animation-duration: 3s;
animation-iteration-count: infinite;
}
.arc-bottom {
animation: spin-bottom;
animation-duration: 3s;
animation-iteration-count: infinite;
}
.arc-left {
animation: spin-left;
animation-duration: 3s;
animation-iteration-count: infinite;
}
@keyframes spin-top {
0% { transform: rotate(-45deg); }
50% { transform: rotate(315deg); }
100% { transform: rotate(-45deg); }
}
@keyframes spin-right {
0% { transform: rotate(45deg); }
50% { transform: rotate(315deg); }
100% { transform: rotate(405deg); }
}
@keyframes spin-bottom {
0% { transform: rotate(135deg); }
50% { transform: rotate(315deg); }
100% { transform: rotate(495deg); }
}
@keyframes spin-left {
0% { transform: rotate(225deg); }
50% { transform: rotate(315deg); }
100% { transform: rotate(585deg); }
}
</style>
<div id="spinner" class="w-6 h-6 {classes}">
<div class="arc arc-top w-full h-full" style={arcStyle}></div>
<div class="arc arc-right w-full h-full" style={arcStyle}></div>
<div class="arc arc-bottom w-full h-full" style={arcStyle}></div>
<div class="arc arc-left w-full h-full" style={arcStyle}></div>
</div>

View File

@ -7,6 +7,7 @@
const id = Math.random().toString().slice(2); const id = Math.random().toString().slice(2);
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const modifierKeys = new Set(['Alt', 'AltGraph', 'Control', 'Fn', 'FnLock', 'Meta', 'Shift', 'Super', ]);
let listening = false; let listening = false;
function listen() { function listen() {
@ -20,12 +21,15 @@
} }
function setKeybind(event) { function setKeybind(event) {
console.log(event); // separate events fire for modifier keys, even when they are combined with a regular key
if (modifierKeys.has(event.key)) return;
let keys = []; let keys = [];
if (event.ctrlKey) keys.push('ctrl'); if (event.ctrlKey) keys.push('Ctrl');
if (event.altKey) keys.push('alt'); if (event.altKey) keys.push('Alt');
if (event.metaKey) keys.push('meta'); if (event.metaKey) keys.push('Meta');
if (event.shiftKey) keys.push('shift'); if (event.shiftKey) keys.push('Shift');
// capitalize
keys.push(event.key); keys.push(event.key);
value.keys = keys.join('+'); value.keys = keys.join('+');

View File

@ -79,8 +79,8 @@
<input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} /> <input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} />
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
{#if saving} {#if saving }
<Spinner class="w-5 h-5" color="primary-content" thickness="2px"/> <Spinner class="w-5 h-5" thickness="12"/>
{:else} {:else}
Submit Submit
{/if} {/if}

View File

@ -1,11 +1,6 @@
<script context="module">
import { type } from '@tauri-apps/api/os';
const osType = await type();
</script>
<script> <script>
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { type } from '@tauri-apps/api/os';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
import Nav from '../ui/Nav.svelte'; import Nav from '../ui/Nav.svelte';
@ -30,6 +25,9 @@
$appState.config = await invoke('get_config'); $appState.config = await invoke('get_config');
} }
} }
let osType = null;
type().then(t => osType = t);
</script> </script>

View File

@ -76,7 +76,7 @@
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
{#if saving} {#if saving}
<Spinner class="w-5 h-5" color="primary-content" thickness="2px"/> <Spinner class="w-5 h-5" thickness="12"/>
{:else} {:else}
Submit Submit
{/if} {/if}

18
todo.md Normal file
View File

@ -0,0 +1,18 @@
## Definitely
* Switch to "process" provider for AWS credentials (much less hacky)
* Session timeout (plain duration, or activity-based?)
* Fix rehide behavior when new request comes in while old one is still being resolved
* Additional hotkey configuration (approve/deny at the very least)
* Logging
* Icon
* Auto-updates
* SSH key handling
## Maybe
* Flatten error type hierarchy
* Rehide after terminal launch from locked
* Generalize Request across both credentials and terminal launch?
* Make hotkey configuration a little more tolerant of slight mistiming
* Distinguish between request that was denied and request that was canceled (e.g. due to error)