Compare commits
27 Commits
c260e37e78
...
v0.3.3
Author | SHA1 | Date | |
---|---|---|---|
3d093a3a45 | |||
992d2a4d06 | |||
12f0f187a6 | |||
997e8b419f | |||
1d9132de3b | |||
e1c2618dc8 | |||
a7df7adc8e | |||
03d164c9d3 | |||
f522674a1c | |||
51fcccafa2 | |||
e3913ab4c9 | |||
c16f21bba3 | |||
61d9acc7c6 | |||
8d7b01629d | |||
5685948608 | |||
c98a065587 | |||
e46c3d2b4d | |||
fa228acc3a | |||
e7e0f9d33e | |||
a51b20add7 | |||
890f715388 | |||
89bc74e644 | |||
60c24e3ee4 | |||
486001b584 | |||
52c949e396 | |||
d7c5c2f37b | |||
ae5b8f31db |
18
doc/todo.md
Normal file
18
doc/todo.md
Normal 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)
|
664
package-lock.json
generated
664
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "creddy",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.3",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
1350
src-tauri/Cargo.lock
generated
1350
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "creddy"
|
||||
version = "0.2.2"
|
||||
version = "0.3.3"
|
||||
description = "A friendly AWS credentials manager"
|
||||
authors = ["Joseph Montanaro"]
|
||||
license = ""
|
||||
@ -25,7 +25,7 @@ tauri-build = { version = "1.0.4", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["dialog", "os-all", "system-tray"] }
|
||||
tauri = { version = "1.2", features = ["dialog", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
|
||||
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
|
||||
sodiumoxide = "0.2.7"
|
||||
tokio = { version = ">=1.19", features = ["full"] }
|
||||
@ -46,6 +46,7 @@ clap = { version = "3.2.23", features = ["derive"] }
|
||||
is-terminal = "0.4.7"
|
||||
argon2 = { version = "0.5.0", features = ["std"] }
|
||||
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
||||
which = "4.4.0"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
@ -42,6 +42,8 @@ pub fn run() -> tauri::Result<()> {
|
||||
ipc::save_credentials,
|
||||
ipc::get_config,
|
||||
ipc::save_config,
|
||||
ipc::launch_terminal,
|
||||
ipc::get_setup_errors,
|
||||
])
|
||||
.setup(|app| rt::block_on(setup(app)))
|
||||
.build(tauri::generate_context!())?
|
||||
@ -74,19 +76,41 @@ pub async fn connect_db() -> Result<SqlitePool, SetupError> {
|
||||
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
|
||||
APP.set(app.handle()).unwrap();
|
||||
|
||||
// get_or_create_db_path doesn't create the actual db file, just the directory
|
||||
let is_first_launch = !config::get_or_create_db_path()?.exists();
|
||||
let pool = connect_db().await?;
|
||||
let conf = AppConfig::load(&pool).await?;
|
||||
let mut setup_errors: Vec<String> = vec![];
|
||||
|
||||
let conf = match AppConfig::load(&pool).await {
|
||||
Ok(c) => c,
|
||||
Err(SetupError::ConfigParseError(_)) => {
|
||||
setup_errors.push(
|
||||
"Could not load configuration from database. Reverting to defaults.".into()
|
||||
);
|
||||
AppConfig::default()
|
||||
},
|
||||
err => err?,
|
||||
};
|
||||
|
||||
let session = Session::load(&pool).await?;
|
||||
let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?;
|
||||
|
||||
config::set_auto_launch(conf.start_on_login)?;
|
||||
if !conf.start_minimized {
|
||||
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 session is empty, this is probably the first launch, so don't autohide
|
||||
if !conf.start_minimized || is_first_launch {
|
||||
app.get_window("main")
|
||||
.ok_or(HandlerError::NoMainWindow)?
|
||||
.show()?;
|
||||
}
|
||||
|
||||
let state = AppState::new(conf, session, srv, pool);
|
||||
let state = AppState::new(conf, session, srv, pool, setup_errors);
|
||||
app.manage(state);
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command as ChildCommand;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
@ -22,6 +23,7 @@ use crate::errors::*;
|
||||
|
||||
pub fn parser() -> Command<'static> {
|
||||
Command::new("creddy")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.about("A friendly AWS credentials manager")
|
||||
.subcommand(
|
||||
Command::new("run")
|
||||
@ -90,15 +92,28 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let e = cmd.exec(); // never returns if successful
|
||||
Err(ExecError::ExecutionFailed(e))?;
|
||||
Ok(())
|
||||
// cmd.exec() never returns if successful
|
||||
let e = cmd.exec();
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
let name: OsString = cmd_name.into();
|
||||
Err(ExecError::NotFound(name).into())
|
||||
}
|
||||
_ => Err(ExecError::ExecutionFailed(e).into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut child = cmd.spawn()
|
||||
.map_err(|e| ExecError::ExecutionFailed(e))?;
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let name: OsString = cmd_name.into();
|
||||
return Err(ExecError::NotFound(name).into());
|
||||
}
|
||||
Err(e) => return Err(ExecError::ExecutionFailed(e).into()),
|
||||
};
|
||||
|
||||
let status = child.wait()
|
||||
.map_err(|e| ExecError::ExecutionFailed(e))?;
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
|
@ -5,10 +5,41 @@ 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::*;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TermConfig {
|
||||
pub name: String,
|
||||
// we call it exec because it isn't always the actual path,
|
||||
// in some cases it's just the name and relies on path-searching
|
||||
// it's a string because it can come from the frontend as json
|
||||
pub exec: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub struct Hotkey {
|
||||
pub keys: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
|
||||
pub struct HotkeysConfig {
|
||||
// tauri uses strings to represent keybinds, so we will as well
|
||||
pub show_window: Hotkey,
|
||||
pub launch_terminal: Hotkey,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default = "default_listen_addr")]
|
||||
@ -21,6 +52,10 @@ pub struct AppConfig {
|
||||
pub start_minimized: bool,
|
||||
#[serde(default = "default_start_on_login")]
|
||||
pub start_on_login: bool,
|
||||
#[serde(default = "default_term_config")]
|
||||
pub terminal: TermConfig,
|
||||
#[serde(default = "default_hotkey_config")]
|
||||
pub hotkeys: HotkeysConfig,
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +67,8 @@ impl Default for AppConfig {
|
||||
rehide_ms: default_rehide_ms(),
|
||||
start_minimized: default_start_minimized(),
|
||||
start_on_login: default_start_on_login(),
|
||||
terminal: default_term_config(),
|
||||
hotkeys: default_hotkey_config(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -116,6 +153,91 @@ fn default_listen_port() -> u16 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn default_term_config() -> TermConfig {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let shell = if which::which("pwsh.exe").is_ok() {
|
||||
"pwsh.exe".to_string()
|
||||
}
|
||||
else {
|
||||
"powershell.exe".to_string()
|
||||
};
|
||||
|
||||
let (exec, args) = if cfg!(debug_assertions) {
|
||||
("conhost.exe".to_string(), vec![shell.clone()])
|
||||
} else {
|
||||
(shell.clone(), vec![])
|
||||
};
|
||||
|
||||
TermConfig { name: shell, exec, args }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
for bin in ["gnome-terminal", "konsole"] {
|
||||
if let Ok(_) = which::which(bin) {
|
||||
return TermConfig {
|
||||
name: bin.into(),
|
||||
exec: bin.into(),
|
||||
args: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
return TermConfig {
|
||||
name: "gnome-terminal".into(),
|
||||
exec: "gnome-terminal".into(),
|
||||
args: vec![],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn default_hotkey_config() -> HotkeysConfig {
|
||||
HotkeysConfig {
|
||||
show_window: Hotkey {keys: "alt+shift+C".into(), enabled: true},
|
||||
launch_terminal: Hotkey {keys: "alt+shift+T".into(), enabled: true},
|
||||
}
|
||||
}
|
||||
|
||||
// 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_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
|
||||
fn default_rehide_ms() -> u64 { 1000 }
|
||||
// start minimized and on login only in production mode
|
||||
|
@ -81,6 +81,16 @@ impl Session {
|
||||
Session::Empty => Err(GetSessionError::CredentialsEmpty),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_get(
|
||||
&self
|
||||
) -> Result<(&BaseCredentials, &SessionCredentials), GetCredentialsError> {
|
||||
match self {
|
||||
Self::Empty => Err(GetCredentialsError::Empty),
|
||||
Self::Locked(_) => Err(GetCredentialsError::Locked),
|
||||
Self::Unlocked{ ref base, ref session } => Ok((base, session))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
use std::error::Error;
|
||||
use std::convert::AsRef;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::mpsc;
|
||||
use strum_macros::AsRefStr;
|
||||
|
||||
@ -21,9 +22,10 @@ use serde::{Serialize, Serializer, ser::SerializeMap};
|
||||
|
||||
pub trait ErrorPopup {
|
||||
fn error_popup(self, title: &str);
|
||||
fn error_popup_nowait(self, title: &str);
|
||||
}
|
||||
|
||||
impl<E: Error> ErrorPopup for Result<(), E> {
|
||||
impl<E: std::fmt::Display> ErrorPopup for Result<(), E> {
|
||||
fn error_popup(self, title: &str) {
|
||||
if let Err(e) = self {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@ -34,6 +36,14 @@ impl<E: Error> ErrorPopup for Result<(), E> {
|
||||
rx.recv().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn error_popup_nowait(self, title: &str) {
|
||||
if let Err(e) = self {
|
||||
MessageDialogBuilder::new(title, format!("{e}"))
|
||||
.kind(MessageDialogKind::Error)
|
||||
.show(|_| {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -57,8 +67,12 @@ where
|
||||
E: Error,
|
||||
M: serde::ser::SerializeMap,
|
||||
{
|
||||
let src = err.source().map(|s| format!("{s}"));
|
||||
map.serialize_entry("source", &src)
|
||||
let msg = err.source().map(|s| format!("{s}"));
|
||||
map.serialize_entry("msg", &msg)?;
|
||||
map.serialize_entry("code", &None::<&str>)?;
|
||||
map.serialize_entry("source", &None::<&str>)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -90,6 +104,8 @@ pub enum SetupError {
|
||||
ServerSetupError(#[from] std::io::Error),
|
||||
#[error("Failed to resolve data directory: {0}")]
|
||||
DataDir(#[from] DataDirError),
|
||||
#[error("Failed to register hotkeys: {0}")]
|
||||
RegisterHotkeys(#[from] tauri::Error),
|
||||
}
|
||||
|
||||
|
||||
@ -109,6 +125,8 @@ pub enum SendResponseError {
|
||||
NotFound,
|
||||
#[error("The specified request was already closed by the client")]
|
||||
Abandoned,
|
||||
#[error("A response has already been received for the specified request")]
|
||||
Fulfilled,
|
||||
#[error("Could not renew AWS sesssion: {0}")]
|
||||
SessionRenew(#[from] GetSessionError),
|
||||
}
|
||||
@ -212,16 +230,6 @@ pub enum RequestError {
|
||||
}
|
||||
|
||||
|
||||
// Errors encountered while running a subprocess (via creddy exec)
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum ExecError {
|
||||
#[error("Please specify a command")]
|
||||
NoCommand,
|
||||
#[error("Failed to execute command: {0}")]
|
||||
ExecutionFailed(#[from] std::io::Error)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum CliError {
|
||||
#[error(transparent)]
|
||||
@ -233,6 +241,33 @@ pub enum CliError {
|
||||
}
|
||||
|
||||
|
||||
// Errors encountered while trying to launch a child process
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum ExecError {
|
||||
#[error("Please specify a command")]
|
||||
NoCommand,
|
||||
#[error("Executable not found: {0:?}")]
|
||||
NotFound(OsString),
|
||||
#[error("Failed to execute command: {0}")]
|
||||
ExecutionFailed(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
GetCredentials(#[from] GetCredentialsError),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum LaunchTerminalError {
|
||||
#[error("Could not discover main window")]
|
||||
NoMainWindow,
|
||||
#[error("Failed to communicate with main Creddy window")]
|
||||
IpcFailed(#[from] tauri::Error),
|
||||
#[error("Failed to launch terminal: {0}")]
|
||||
Exec(#[from] ExecError),
|
||||
#[error(transparent)]
|
||||
GetCredentials(#[from] GetCredentialsError),
|
||||
}
|
||||
|
||||
|
||||
// =========================
|
||||
// Serialize implementations
|
||||
// =========================
|
||||
@ -323,3 +358,33 @@ impl Serialize for UnlockError {
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Serialize for ExecError {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("code", self.as_ref())?;
|
||||
map.serialize_entry("msg", &format!("{self}"))?;
|
||||
|
||||
match self {
|
||||
ExecError::GetCredentials(src) => map.serialize_entry("source", &src)?,
|
||||
_ => serialize_upstream_err(self, &mut map)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Serialize for LaunchTerminalError {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("code", self.as_ref())?;
|
||||
map.serialize_entry("msg", &format!("{self}"))?;
|
||||
|
||||
match self {
|
||||
LaunchTerminalError::Exec(src) => map.serialize_entry("source", &src)?,
|
||||
_ => serialize_upstream_err(self, &mut map)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ use crate::credentials::{Session,BaseCredentials};
|
||||
use crate::errors::*;
|
||||
use crate::clientinfo::Client;
|
||||
use crate::state::AppState;
|
||||
use crate::terminal;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@ -78,3 +79,15 @@ pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> R
|
||||
.map_err(|e| format!("Error saving config: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
|
||||
terminal::launch(base).await
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> {
|
||||
Ok(app_state.setup_errors.clone())
|
||||
}
|
||||
|
@ -7,4 +7,5 @@ mod clientinfo;
|
||||
mod ipc;
|
||||
mod state;
|
||||
mod server;
|
||||
mod terminal;
|
||||
mod tray;
|
||||
|
@ -10,7 +10,7 @@ use tokio::net::{
|
||||
TcpStream,
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::oneshot::{self, Sender, Receiver};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
@ -23,24 +23,55 @@ use crate::ipc::{Request, Approval};
|
||||
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 {
|
||||
request_id: u64,
|
||||
stream: TcpStream,
|
||||
receiver: Option<oneshot::Receiver<Approval>>,
|
||||
rehide_after: bool,
|
||||
receiver: Option<Receiver<Approval>>,
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
// 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 request_id = state.register_request(chan_send).await;
|
||||
Handler {
|
||||
let waiter = RequestWaiter {rehide_after, sender: Some(chan_send)};
|
||||
let request_id = state.register_request(waiter).await;
|
||||
let handler = Handler {
|
||||
request_id,
|
||||
stream,
|
||||
rehide_after,
|
||||
receiver: Some(chan_recv),
|
||||
app
|
||||
}
|
||||
};
|
||||
Ok(handler)
|
||||
}
|
||||
|
||||
async fn handle(mut self) {
|
||||
@ -62,7 +93,7 @@ impl Handler {
|
||||
|
||||
let req = Request {id: self.request_id, clients, base};
|
||||
self.app.emit_all("credentials-request", &req)?;
|
||||
let starting_visibility = self.show_window()?;
|
||||
self.show_window()?;
|
||||
|
||||
match self.wait_for_response().await? {
|
||||
Approval::Approved => {
|
||||
@ -94,9 +125,11 @@ impl Handler {
|
||||
};
|
||||
sleep(delay).await;
|
||||
|
||||
if !starting_visibility && state.req_count().await == 0 {
|
||||
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?;
|
||||
window.hide()?;
|
||||
if self.rehide_after && state.req_count().await == 1 {
|
||||
self.app
|
||||
.get_window("main")
|
||||
.ok_or(HandlerError::NoMainWindow)?
|
||||
.hide()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -143,15 +176,14 @@ impl Handler {
|
||||
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 starting_visibility = window.is_visible()?;
|
||||
if !starting_visibility {
|
||||
if !window.is_visible()? {
|
||||
window.unminimize()?;
|
||||
window.show()?;
|
||||
}
|
||||
window.set_focus()?;
|
||||
Ok(starting_visibility)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_response(&mut self) -> Result<Approval, HandlerError> {
|
||||
@ -231,12 +263,12 @@ impl Server {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let handler = Handler::new(stream, app_handle.app_handle()).await;
|
||||
rt::spawn(handler.handle());
|
||||
match Handler::new(stream, app_handle.app_handle()).await {
|
||||
Ok(handler) => { rt::spawn(handler.handle()); }
|
||||
Err(e) => { eprintln!("Error handling request: {e}"); }
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Error accepting connection: {e}");
|
||||
}
|
||||
Err(e) => { eprintln!("Error accepting connection: {e}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::{
|
||||
sync::oneshot::Sender,
|
||||
sync::RwLock,
|
||||
time::sleep,
|
||||
};
|
||||
@ -20,7 +19,7 @@ use crate::{config, config::AppConfig};
|
||||
use crate::ipc::{self, Approval};
|
||||
use crate::clientinfo::Client;
|
||||
use crate::errors::*;
|
||||
use crate::server::Server;
|
||||
use crate::server::{Server, RequestWaiter};
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -28,20 +27,31 @@ pub struct AppState {
|
||||
pub config: RwLock<AppConfig>,
|
||||
pub session: RwLock<Session>,
|
||||
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 bans: RwLock<std::collections::HashSet<Option<Client>>>,
|
||||
// setup_errors is never modified and so doesn't need to be wrapped in RwLock
|
||||
pub setup_errors: Vec<String>,
|
||||
server: RwLock<Server>,
|
||||
pool: sqlx::SqlitePool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig, session: Session, server: Server, pool: SqlitePool) -> AppState {
|
||||
pub fn new(
|
||||
config: AppConfig,
|
||||
session: Session,
|
||||
server: Server,
|
||||
pool: SqlitePool,
|
||||
setup_errors: Vec<String>,
|
||||
) -> AppState {
|
||||
AppState {
|
||||
config: RwLock::new(config),
|
||||
session: RwLock::new(session),
|
||||
request_count: RwLock::new(0),
|
||||
open_requests: RwLock::new(HashMap::new()),
|
||||
waiting_requests: RwLock::new(HashMap::new()),
|
||||
pending_terminal_request: RwLock::new(false),
|
||||
bans: RwLock::new(HashSet::new()),
|
||||
setup_errors,
|
||||
server: RwLock::new(server),
|
||||
pool,
|
||||
}
|
||||
@ -59,41 +69,56 @@ impl AppState {
|
||||
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
|
||||
let mut live_config = self.config.write().await;
|
||||
|
||||
// update autostart if necessary
|
||||
if new_config.start_on_login != live_config.start_on_login {
|
||||
config::set_auto_launch(new_config.start_on_login)?;
|
||||
}
|
||||
// rebind socket if necessary
|
||||
if new_config.listen_addr != live_config.listen_addr
|
||||
|| new_config.listen_port != live_config.listen_port
|
||||
{
|
||||
let mut sv = self.server.write().await;
|
||||
sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
|
||||
}
|
||||
// re-register hotkeys if necessary
|
||||
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)?;
|
||||
}
|
||||
|
||||
new_config.save(&self.pool).await?;
|
||||
*live_config = new_config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
|
||||
pub async fn register_request(&self, waiter: RequestWaiter) -> u64 {
|
||||
let count = {
|
||||
let mut c = self.request_count.write().await;
|
||||
*c += 1;
|
||||
c
|
||||
};
|
||||
|
||||
let mut open_requests = self.open_requests.write().await;
|
||||
open_requests.insert(*count, chan); // `count` is the request id
|
||||
let mut waiting_requests = self.waiting_requests.write().await;
|
||||
waiting_requests.insert(*count, waiter); // `count` is the request id
|
||||
*count
|
||||
}
|
||||
|
||||
pub async fn unregister_request(&self, id: u64) {
|
||||
let mut open_requests = self.open_requests.write().await;
|
||||
open_requests.remove(&id);
|
||||
let mut waiting_requests = self.waiting_requests.write().await;
|
||||
waiting_requests.remove(&id);
|
||||
}
|
||||
|
||||
pub async fn req_count(&self) -> usize {
|
||||
let open_requests = self.open_requests.read().await;
|
||||
open_requests.len()
|
||||
let waiting_requests = self.waiting_requests.read().await;
|
||||
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> {
|
||||
@ -102,14 +127,11 @@ impl AppState {
|
||||
session.renew_if_expired().await?;
|
||||
}
|
||||
|
||||
let mut open_requests = self.open_requests.write().await;
|
||||
let chan = open_requests
|
||||
.remove(&response.id)
|
||||
.ok_or(SendResponseError::NotFound)
|
||||
?;
|
||||
|
||||
chan.send(response.approval)
|
||||
.map_err(|_e| SendResponseError::Abandoned)
|
||||
let mut waiting_requests = self.waiting_requests.write().await;
|
||||
waiting_requests
|
||||
.get_mut(&response.id)
|
||||
.ok_or(SendResponseError::NotFound)?
|
||||
.notify(response.approval)
|
||||
}
|
||||
|
||||
pub async fn add_ban(&self, client: Option<Client>) {
|
||||
@ -141,22 +163,21 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
|
||||
pub async fn is_unlocked(&self) -> bool {
|
||||
let session = self.session.read().await;
|
||||
match *session {
|
||||
Session::Unlocked{ref base, ..} => Ok(serde_json::to_string(base).unwrap()),
|
||||
Session::Locked(_) => Err(GetCredentialsError::Locked),
|
||||
Session::Empty => Err(GetCredentialsError::Empty),
|
||||
}
|
||||
matches!(*session, Session::Unlocked{..})
|
||||
}
|
||||
|
||||
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
|
||||
let app_session = self.session.read().await;
|
||||
let (base, _session) = app_session.try_get()?;
|
||||
Ok(serde_json::to_string(base).unwrap())
|
||||
}
|
||||
|
||||
pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
|
||||
let session = self.session.read().await;
|
||||
match *session {
|
||||
Session::Unlocked{ref session, ..} => Ok(serde_json::to_string(session).unwrap()),
|
||||
Session::Locked(_) => Err(GetCredentialsError::Locked),
|
||||
Session::Empty => Err(GetCredentialsError::Empty),
|
||||
}
|
||||
let app_session = self.session.read().await;
|
||||
let (_bsae, session) = app_session.try_get()?;
|
||||
Ok(serde_json::to_string(session).unwrap())
|
||||
}
|
||||
|
||||
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
|
||||
@ -165,4 +186,21 @@ impl AppState {
|
||||
*app_session = Session::Unlocked {base, session};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn register_terminal_request(&self) -> Result<(), ()> {
|
||||
let mut req = self.pending_terminal_request.write().await;
|
||||
if *req {
|
||||
// if a request is already pending, we can't register a new one
|
||||
Err(())
|
||||
}
|
||||
else {
|
||||
*req = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unregister_terminal_request(&self) {
|
||||
let mut req = self.pending_terminal_request.write().await;
|
||||
*req = false;
|
||||
}
|
||||
}
|
||||
|
82
src-tauri/src/terminal.rs
Normal file
82
src-tauri/src/terminal.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use std::process::Command;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::app::APP;
|
||||
use crate::errors::*;
|
||||
use crate::state::AppState;
|
||||
|
||||
|
||||
pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
|
||||
let app = APP.get().unwrap();
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
// register_terminal_request() returns Err if there is another request pending
|
||||
if state.register_terminal_request().await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut cmd = {
|
||||
let config = state.config.read().await;
|
||||
let mut cmd = Command::new(&config.terminal.exec);
|
||||
cmd.args(&config.terminal.args);
|
||||
cmd
|
||||
};
|
||||
|
||||
// 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 (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.once_global("credentials-event", move |e| {
|
||||
let success = match e.payload() {
|
||||
Some("\"unlocked\"") | Some("\"entered\"") => true,
|
||||
_ => false,
|
||||
};
|
||||
let _ = tx.send(success);
|
||||
});
|
||||
|
||||
if !rx.await.unwrap_or(false) {
|
||||
state.unregister_terminal_request().await;
|
||||
return Ok(()); // request was canceled by user
|
||||
}
|
||||
}
|
||||
|
||||
// more lock-management
|
||||
{
|
||||
let app_session = state.session.read().await;
|
||||
// session should really be unlocked at this point, but if the frontend misbehaves
|
||||
// (i.e. lies about unlocking) we could end up here with a locked session
|
||||
// this will result in an error popup to the user (see main hotkey handler)
|
||||
let (base_creds, session_creds) = app_session.try_get()?;
|
||||
if use_base {
|
||||
cmd.env("AWS_ACCESS_KEY_ID", &base_creds.access_key_id);
|
||||
cmd.env("AWS_SECRET_ACCESS_KEY", &base_creds.secret_access_key);
|
||||
}
|
||||
else {
|
||||
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
|
||||
cmd.env("AWS_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
|
||||
cmd.env("AWS_SESSION_TOKEN", &session_creds.token);
|
||||
}
|
||||
}
|
||||
|
||||
let res = match cmd.spawn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) if std::io::ErrorKind::NotFound == e.kind() => {
|
||||
Err(ExecError::NotFound(cmd.get_program().to_owned()))
|
||||
},
|
||||
Err(e) => Err(ExecError::ExecutionFailed(e)),
|
||||
};
|
||||
|
||||
state.unregister_terminal_request().await;
|
||||
|
||||
res?; // ? auto-conversion is more liberal than .into()
|
||||
Ok(())
|
||||
}
|
@ -8,11 +8,12 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "creddy",
|
||||
"version": "0.2.2"
|
||||
"version": "0.3.3"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"os": {"all": true}
|
||||
"os": {"all": true},
|
||||
"dialog": {"open": true}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
|
@ -16,6 +16,25 @@ listen('credentials-request', (tauriEvent) => {
|
||||
$appState.pendingRequests.put(tauriEvent.payload);
|
||||
});
|
||||
|
||||
listen('launch-terminal-request', async (tauriEvent) => {
|
||||
if ($appState.currentRequest === null) {
|
||||
let status = await invoke('get_session_status');
|
||||
if (status === 'locked') {
|
||||
navigate('Unlock');
|
||||
}
|
||||
else if (status === 'empty') {
|
||||
navigate('EnterCredentials');
|
||||
}
|
||||
// else, session is unlocked, so do nothing
|
||||
// (although we shouldn't even get the event in that case)
|
||||
}
|
||||
});
|
||||
|
||||
invoke('get_setup_errors')
|
||||
.then(errs => {
|
||||
$appState.setupErrors = errs.map(e => ({msg: e, show: true}));
|
||||
});
|
||||
|
||||
acceptRequest();
|
||||
</script>
|
||||
|
||||
|
@ -9,6 +9,10 @@ export default function() {
|
||||
|
||||
resolvers: [],
|
||||
|
||||
size() {
|
||||
return this.items.length;
|
||||
},
|
||||
|
||||
put(item) {
|
||||
this.items.push(item);
|
||||
let resolver = this.resolvers.shift();
|
||||
|
@ -8,6 +8,7 @@ export let appState = writable({
|
||||
currentRequest: null,
|
||||
pendingRequests: queue(),
|
||||
credentialStatus: 'locked',
|
||||
setupErrors: [],
|
||||
});
|
||||
|
||||
|
||||
|
13
src/ui/KeyCombo.svelte
Normal file
13
src/ui/KeyCombo.svelte
Normal file
@ -0,0 +1,13 @@
|
||||
<script>
|
||||
export let keys;
|
||||
</script>
|
||||
|
||||
|
||||
<div class="flex gap-x-[0.2em] items-center">
|
||||
{#each keys as key, i}
|
||||
{#if i > 0}
|
||||
<span class="mt-[-0.1em]">+</span>
|
||||
{/if}
|
||||
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
|
||||
{/each}
|
||||
</div>
|
@ -1,113 +1,42 @@
|
||||
<script>
|
||||
export let color = 'base-content';
|
||||
export let thickness = '2px';
|
||||
export let thickness = 8;
|
||||
let classes = '';
|
||||
export { classes as class };
|
||||
|
||||
const colorVars = {
|
||||
'primary': 'p',
|
||||
'primary-focus': 'pf',
|
||||
'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;`;
|
||||
const radius = (100 - thickness) / 2;
|
||||
// the px are fake, but we need them to satisfy css calc()
|
||||
const circumference = `${2 * Math.PI * radius}px`;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#spinner {
|
||||
position: relative;
|
||||
|
||||
animation: spin;
|
||||
animation-duration: 1.5s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
<svg
|
||||
style:--circumference={circumference}
|
||||
class={classes}
|
||||
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 {
|
||||
50% { transform: rotate(225deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
50% { transform: rotate(135deg); }
|
||||
100% { transform: rotate(270deg); }
|
||||
}
|
||||
|
||||
.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>
|
||||
</style>
|
27
src/ui/settings/FileSetting.svelte
Normal file
27
src/ui/settings/FileSetting.svelte
Normal file
@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
|
||||
<Setting {title}>
|
||||
<div slot="input">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered grow text-right"
|
||||
bind:value
|
||||
on:change={() => dispatch('update', {value})}
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
on:click={async () => value = await open()}
|
||||
>Browse</button>
|
||||
</div>
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
72
src/ui/settings/Keybind.svelte
Normal file
72
src/ui/settings/Keybind.svelte
Normal file
@ -0,0 +1,72 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import KeyCombo from '../KeyCombo.svelte';
|
||||
|
||||
export let description;
|
||||
export let value;
|
||||
|
||||
const id = Math.random().toString().slice(2);
|
||||
const dispatch = createEventDispatcher();
|
||||
const MODIFIERS = new Set(['Alt', 'AltGraph', 'Control', 'Fn', 'FnLock', 'Meta', 'Shift', 'Super', ]);
|
||||
|
||||
|
||||
let listening = false;
|
||||
let keysPressed = [];
|
||||
|
||||
function addModifiers(event) {
|
||||
// add modifier key if it isn't already present
|
||||
if (MODIFIERS.has(event.key) && keysPressed.indexOf(event.key) === -1) {
|
||||
keysPressed.push(event.key);
|
||||
}
|
||||
}
|
||||
|
||||
function addMainKey(event) {
|
||||
if (!MODIFIERS.has(event.key)) {
|
||||
keysPressed.push(event.key);
|
||||
|
||||
value.keys = keysPressed.join('+');
|
||||
dispatch('update', {value});
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
|
||||
function listen() {
|
||||
// don't re-listen if we already are
|
||||
if (listening) return;
|
||||
|
||||
listening = true;
|
||||
window.addEventListener('keydown', addModifiers);
|
||||
window.addEventListener('keyup', addMainKey);
|
||||
// setTimeout avoids reacting to the click event that we are currently processing
|
||||
setTimeout(() => window.addEventListener('click', unlisten), 0);
|
||||
}
|
||||
|
||||
function unlisten() {
|
||||
listening = false;
|
||||
keysPressed = [];
|
||||
window.removeEventListener('keydown', addModifiers);
|
||||
window.removeEventListener('keyup', addMainKey);
|
||||
window.removeEventListener('click', unlisten);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<input
|
||||
{id}
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={value.enabled}
|
||||
on:change={() => dispatch('update', {value})}
|
||||
>
|
||||
<label for={id} class="cursor-pointer ml-4 text-lg">{description}</label>
|
||||
|
||||
<button class="h-12 p-2 rounded border border-neutral cursor-pointer text-center" on:click={listen}>
|
||||
{#if listening}
|
||||
Click to cancel
|
||||
{:else}
|
||||
<KeyCombo keys={value.keys.split('+')} />
|
||||
{/if}
|
||||
</button>
|
@ -5,6 +5,7 @@
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
|
||||
export let unit = '';
|
||||
export let min = null;
|
||||
export let max = null;
|
||||
|
@ -6,14 +6,17 @@
|
||||
</script>
|
||||
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="flex justify-between">
|
||||
<h3 class="text-lg font-bold">{title}</h3>
|
||||
<slot name="input"></slot>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-wrap justify-between gap-y-4">
|
||||
<h3 class="text-lg font-bold shrink-0">{title}</h3>
|
||||
{#if $$slots.input}
|
||||
<slot name="input"></slot>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $$slots.description}
|
||||
<p class="mt-3">
|
||||
<slot name="description"></slot>
|
||||
</p>
|
||||
{/if}
|
||||
{#if $$slots.description}
|
||||
<p class="mt-3">
|
||||
<slot name="description"></slot>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
14
src/ui/settings/SettingsGroup.svelte
Normal file
14
src/ui/settings/SettingsGroup.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<script>
|
||||
export let name;
|
||||
</script>
|
||||
|
||||
|
||||
<div>
|
||||
<div class="divider mt-0 mb-8">
|
||||
<h2 class="text-xl font-bold">{name}</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-12">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
22
src/ui/settings/TextSetting.svelte
Normal file
22
src/ui/settings/TextSetting.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Setting from './Setting.svelte';
|
||||
|
||||
export let title;
|
||||
export let value;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
|
||||
<Setting {title}>
|
||||
<div slot="input">
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered grow text-right"
|
||||
bind:value
|
||||
on:change={() => dispatch('update', {value})}
|
||||
>
|
||||
</div>
|
||||
<slot name="description" slot="description"></slot>
|
||||
</Setting>
|
@ -1,3 +1,5 @@
|
||||
export { default as Setting } from './Setting.svelte';
|
||||
export { default as ToggleSetting } from './ToggleSetting.svelte';
|
||||
export { default as NumericSetting } from './NumericSetting.svelte';
|
||||
export { default as FileSetting } from './FileSetting.svelte';
|
||||
export { default as TextSetting } from './TextSetting.svelte';
|
||||
|
@ -6,6 +6,7 @@
|
||||
import { appState, completeRequest } from '../lib/state.js';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import KeyCombo from '../ui/KeyCombo.svelte';
|
||||
|
||||
|
||||
// Send response to backend, display error if applicable
|
||||
@ -108,17 +109,15 @@
|
||||
<div class="w-full flex justify-between">
|
||||
<Link target={deny} hotkey="Escape">
|
||||
<button class="btn btn-error justify-self-start">
|
||||
Deny
|
||||
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Esc</kbd>
|
||||
<span class="mr-2">Deny</span>
|
||||
<KeyCombo keys={['Esc']} />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<Link target={approve} hotkey="Enter" shift="{true}">
|
||||
<button class="btn btn-success justify-self-end">
|
||||
Approve
|
||||
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Shift</kbd>
|
||||
<span class="mx-0.5">+</span>
|
||||
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">Enter</kbd>
|
||||
<span class="mr-2">Approve</span>
|
||||
<KeyCombo keys={['Shift', 'Enter']} />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -31,6 +31,7 @@
|
||||
try {
|
||||
saving = true;
|
||||
await invoke('save_credentials', {credentials, passphrase});
|
||||
emit('credentials-event', 'entered');
|
||||
if ($appState.currentRequest) {
|
||||
navigate('Approve');
|
||||
}
|
||||
@ -39,14 +40,16 @@
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if (e.code === "GetSession") {
|
||||
let root = getRootCause(e);
|
||||
window.error = e;
|
||||
const root = getRootCause(e);
|
||||
if (e.code === 'GetSession' && root.code) {
|
||||
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
||||
}
|
||||
else {
|
||||
errorMsg = e.msg;
|
||||
}
|
||||
|
||||
// if the alert already existed, shake it
|
||||
if (alert) {
|
||||
alert.shake();
|
||||
}
|
||||
@ -54,6 +57,11 @@
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('credentials-event', 'enter-canceled');
|
||||
navigate('Home');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -71,13 +79,13 @@
|
||||
<input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} />
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{#if saving}
|
||||
<Spinner class="w-5 h-5" color="primary-content" thickness="2px"/>
|
||||
{#if saving }
|
||||
<Spinner class="w-5 h-5" thickness="12"/>
|
||||
{:else}
|
||||
Submit
|
||||
{/if}
|
||||
</button>
|
||||
<Link target="Home" hotkey="Escape">
|
||||
<Link target={cancel} hotkey="Escape">
|
||||
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
||||
</Link>
|
||||
</form>
|
||||
|
@ -10,13 +10,11 @@
|
||||
|
||||
import vaultDoorSvg from '../assets/vault_door.svg?raw';
|
||||
|
||||
|
||||
// onMount(async () => {
|
||||
// // will block until a request comes in
|
||||
// let req = await $appState.pendingRequests.get();
|
||||
// $appState.currentRequest = req;
|
||||
// navigate('Approve');
|
||||
// });
|
||||
let launchBase = false;
|
||||
function launchTerminal() {
|
||||
invoke('launch_terminal', {base: launchBase});
|
||||
launchBase = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -25,25 +23,45 @@
|
||||
</Nav>
|
||||
|
||||
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
|
||||
{#await invoke('get_session_status') then status}
|
||||
{#if status === 'locked'}
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
{@html vaultDoorSvg}
|
||||
{#await invoke('get_session_status') then status}
|
||||
{#if status === 'locked'}
|
||||
|
||||
{@html vaultDoorSvg}
|
||||
<h2 class="text-2xl font-bold">Creddy is locked</h2>
|
||||
<Link target="Unlock" hotkey="Enter" class="w-64">
|
||||
<button class="btn btn-primary w-full">Unlock</button>
|
||||
</Link>
|
||||
<h2 class="text-2xl font-bold">Creddy is locked</h2>
|
||||
<Link target="Unlock" hotkey="Enter" class="w-64">
|
||||
<button class="btn btn-primary w-full">Unlock</button>
|
||||
</Link>
|
||||
|
||||
{:else if status === 'unlocked'}
|
||||
{@html vaultDoorSvg}
|
||||
<h2 class="text-2xl font-bold">Waiting for requests</h2>
|
||||
{:else if status === 'unlocked'}
|
||||
<h2 class="text-2xl font-bold">Waiting for requests</h2>
|
||||
<button class="btn btn-primary w-full" on:click={launchTerminal}>
|
||||
Launch Terminal
|
||||
</button>
|
||||
<label class="label cursor-pointer flex items-center space-x-2">
|
||||
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
|
||||
<span class="label-text">Launch with base credentials</span>
|
||||
</label>
|
||||
|
||||
{:else if status === 'empty'}
|
||||
{@html vaultDoorSvg}
|
||||
<h2 class="text-2xl font-bold">No credentials found</h2>
|
||||
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
|
||||
<button class="btn btn-primary w-full">Enter Credentials</button>
|
||||
</Link>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
{:else if status === 'empty'}
|
||||
<h2 class="text-2xl font-bold">No credentials found</h2>
|
||||
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
|
||||
<button class="btn btn-primary w-full">Enter Credentials</button>
|
||||
</Link>
|
||||
{/if}
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $appState.setupErrors.some(e => e.show)}
|
||||
<div class="toast">
|
||||
{#each $appState.setupErrors as error}
|
||||
{#if error.show}
|
||||
<div class="alert alert-error shadow-lg">
|
||||
{error.msg}
|
||||
<button class="btn btn-sm btn-alert-error" on:click={() => error.show = false}>Ok</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
@ -6,7 +6,9 @@
|
||||
import Nav from '../ui/Nav.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import { Setting, ToggleSetting, NumericSetting } from '../ui/settings';
|
||||
import SettingsGroup from '../ui/settings/SettingsGroup.svelte';
|
||||
import Keybind from '../ui/settings/Keybind.svelte';
|
||||
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting } from '../ui/settings';
|
||||
|
||||
import { fly } from 'svelte/transition';
|
||||
import { backInOut } from 'svelte/easing';
|
||||
@ -14,6 +16,7 @@
|
||||
|
||||
let error = null;
|
||||
async function save() {
|
||||
console.log('updating config');
|
||||
try {
|
||||
await invoke('save_config', {config: $appState.config});
|
||||
}
|
||||
@ -23,59 +26,81 @@
|
||||
}
|
||||
}
|
||||
|
||||
let osType = '';
|
||||
let osType = null;
|
||||
type().then(t => osType = t);
|
||||
</script>
|
||||
|
||||
|
||||
<Nav>
|
||||
<h2 slot="title" class="text-2xl font-bold">Settings</h2>
|
||||
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
|
||||
</Nav>
|
||||
|
||||
{#await invoke('get_config') then config}
|
||||
<div class="max-w-md mx-auto mt-1.5 p-4">
|
||||
<!-- <h2 class="text-2xl font-bold text-center">Settings</h2> -->
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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={$appState.config.start_minimized} on:update={save}>
|
||||
<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={$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="Listen port"
|
||||
bind:value={$appState.config.listen_port}
|
||||
min={osType === 'Windows_NT' ? 1 : 0}
|
||||
on:update={save}
|
||||
>
|
||||
<svelte:fragment slot="description">
|
||||
Listen for credentials requests on this port.
|
||||
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
|
||||
</svelte:fragment>
|
||||
</NumericSetting>
|
||||
|
||||
<NumericSetting
|
||||
title="Listen port"
|
||||
bind:value={$appState.config.listen_port}
|
||||
min={osType === 'Windows_NT' ? 1 : 0}
|
||||
on:update={save}
|
||||
>
|
||||
<svelte:fragment slot="description">
|
||||
Listen for credentials requests on this port.
|
||||
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</SettingsGroup>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { appState } from '../lib/state.js';
|
||||
@ -26,6 +27,7 @@
|
||||
saving = true;
|
||||
let r = await invoke('unlock', {passphrase});
|
||||
$appState.credentialStatus = 'unlocked';
|
||||
emit('credentials-event', 'unlocked');
|
||||
if ($appState.currentRequest) {
|
||||
navigate('Approve');
|
||||
}
|
||||
@ -34,23 +36,28 @@
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
window.error = e;
|
||||
if (e.code === 'GetSession') {
|
||||
let root = getRootCause(e);
|
||||
const root = getRootCause(e);
|
||||
if (e.code === 'GetSession' && root.code) {
|
||||
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
||||
}
|
||||
else {
|
||||
errorMsg = e.msg;
|
||||
}
|
||||
|
||||
// if the alert already existed, shake it
|
||||
if (alert) {
|
||||
alert.shake();
|
||||
}
|
||||
|
||||
saving = true;
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('credentials-event', 'unlock-canceled');
|
||||
navigate('Home');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadTime = Date.now();
|
||||
})
|
||||
@ -69,13 +76,13 @@
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{#if saving}
|
||||
<Spinner class="w-5 h-5" color="primary-content" thickness="2px"/>
|
||||
<Spinner class="w-5 h-5" thickness="12"/>
|
||||
{:else}
|
||||
Submit
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Link target="Home" hotkey="Escape">
|
||||
<button class="btn btn-outline btn-sm w-full">Cancel</button>
|
||||
<Link target={cancel} hotkey="Escape">
|
||||
<button class="btn btn-sm btn-outline w-full">Cancel</button>
|
||||
</Link>
|
||||
</form>
|
||||
|
Reference in New Issue
Block a user