Compare commits
18 Commits
47a3e1cfef
...
v0.4.5
Author | SHA1 | Date | |
---|---|---|---|
70e23c7e20 | |||
1df849442e | |||
7fdb336c79 | |||
46b8d810c5 | |||
dd40eb379e | |||
13545ac725 | |||
040a01536a | |||
4e2a90b15b | |||
e0d919ed4a | |||
3f4efc5f8f | |||
4881b90b0b | |||
1b749a857c | |||
2079f99d04 | |||
5e0ffc1155 | |||
d4fa8966b2 | |||
a293d8f92c | |||
367a140e2a | |||
4b06dce7f4 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,6 +2,9 @@ dist
|
||||
**/node_modules
|
||||
src-tauri/target/
|
||||
**/creddy.db
|
||||
# .env is system-specific
|
||||
.env
|
||||
.vscode
|
||||
|
||||
# just in case
|
||||
credentials*
|
||||
|
@ -1,13 +1,15 @@
|
||||
## Definitely
|
||||
|
||||
* Switch to "process" provider for AWS credentials (much less hacky)
|
||||
* ~~Switch to "process" provider for AWS credentials (much less hacky)~~
|
||||
* ~~Frontend needs to react when request is cancelled from backend~~
|
||||
* Session timeout (plain duration, or activity-based?)
|
||||
* ~Fix rehide behavior when new request comes in while old one is still being resolved~
|
||||
* ~~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
|
||||
* Encrypted sync server
|
||||
|
||||
## Maybe
|
||||
|
||||
|
1854
package-lock.json
generated
1854
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "creddy",
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.5",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
@ -1 +0,0 @@
|
||||
DATABASE_URL=sqlite://C:/Users/Joe/AppData/Roaming/creddy/creddy.dev.db
|
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@ -1035,7 +1035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "creddy"
|
||||
version = "0.3.3"
|
||||
version = "0.4.5"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"auto-launch",
|
||||
|
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "creddy"
|
||||
version = "0.3.3"
|
||||
version = "0.4.5"
|
||||
description = "A friendly AWS credentials manager"
|
||||
authors = ["Joseph Montanaro"]
|
||||
license = ""
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,7 @@ 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> {
|
||||
let sys_pid = Pid::from_u32(pid);
|
||||
let mut sys = System::new();
|
||||
sys.refresh_process(sys_pid);
|
||||
@ -64,59 +33,3 @@ fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
|
||||
|
||||
Ok(Client { pid: parent_pid_sys.as_u32(), exe })
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
// }
|
||||
|
@ -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
|
||||
|
@ -126,10 +126,10 @@ impl LockedCredentials {
|
||||
let secret_access_key = String::from_utf8(decrypted)
|
||||
.map_err(|_| UnlockError::InvalidUtf8)?;
|
||||
|
||||
let creds = BaseCredentials {
|
||||
access_key_id: self.access_key_id.clone(),
|
||||
let creds = BaseCredentials::new(
|
||||
self.access_key_id.clone(),
|
||||
secret_access_key,
|
||||
};
|
||||
);
|
||||
Ok(creds)
|
||||
}
|
||||
}
|
||||
@ -138,11 +138,16 @@ impl LockedCredentials {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct BaseCredentials {
|
||||
pub version: usize,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
}
|
||||
|
||||
impl BaseCredentials {
|
||||
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
||||
Self {version: 1, access_key_id, secret_access_key}
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
|
||||
let salt = Crypto::salt();
|
||||
let crypto = Crypto::new(passphrase, &salt)?;
|
||||
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -68,15 +83,33 @@ where
|
||||
}
|
||||
|
||||
|
||||
struct SerializeUpstream<E>(pub E);
|
||||
|
||||
impl<E: Error> Serialize for SerializeUpstream<E> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let msg = format!("{}", self.0);
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("msg", &msg)?;
|
||||
map.serialize_entry("code", &None::<&str>)?;
|
||||
map.serialize_entry("source", &None::<&str>)?;
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error>
|
||||
where
|
||||
E: Error,
|
||||
M: serde::ser::SerializeMap,
|
||||
{
|
||||
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>)?;
|
||||
// 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>)?;
|
||||
|
||||
match err.source() {
|
||||
Some(src) => map.serialize_entry("source", &SerializeUpstream(src))?,
|
||||
None => map.serialize_entry("source", &None::<&str>)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -138,7 +171,7 @@ pub enum SendResponseError {
|
||||
}
|
||||
|
||||
|
||||
// errors encountered while handling an HTTP request
|
||||
// errors encountered while handling a client request
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum HandlerError {
|
||||
#[error("Error writing to stream: {0}")]
|
||||
@ -149,8 +182,10 @@ pub enum HandlerError {
|
||||
BadRequest(#[from] serde_json::Error),
|
||||
#[error("HTTP request too large")]
|
||||
RequestTooLarge,
|
||||
#[error("Connection closed early by client")]
|
||||
Abandoned,
|
||||
#[error("Internal server error")]
|
||||
Internal,
|
||||
Internal(#[from] RecvError),
|
||||
#[error("Error accessing credentials: {0}")]
|
||||
NoCredentials(#[from] GetCredentialsError),
|
||||
#[error("Error getting client details: {0}")]
|
||||
@ -164,6 +199,15 @@ pub enum HandlerError {
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum WindowError {
|
||||
#[error("Failed to find main application window")]
|
||||
NoMainWindow,
|
||||
#[error(transparent)]
|
||||
ManageFailure(#[from] tauri::Error),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, ThisError, AsRefStr)]
|
||||
pub enum GetCredentialsError {
|
||||
#[error("Credentials are currently locked")]
|
||||
@ -221,6 +265,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)]
|
||||
@ -320,10 +365,10 @@ 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 {
|
||||
|
@ -21,6 +21,7 @@ pub struct AwsRequestNotification {
|
||||
pub struct RequestResponse {
|
||||
pub id: u64,
|
||||
pub approval: Approval,
|
||||
pub base: bool,
|
||||
}
|
||||
|
||||
|
||||
|
@ -7,5 +7,6 @@ mod clientinfo;
|
||||
mod ipc;
|
||||
mod state;
|
||||
mod server;
|
||||
mod shortcuts;
|
||||
mod terminal;
|
||||
mod tray;
|
||||
|
@ -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!(),
|
||||
};
|
||||
|
||||
|
@ -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", ¬ification)?;
|
||||
|
||||
if !main_window.is_visible()? {
|
||||
main_window.unminimize()?;
|
||||
main_window.show()?;
|
||||
}
|
||||
main_window.set_focus()?;
|
||||
|
||||
match chan_recv.await {
|
||||
Ok(Approval::Approved) => {
|
||||
if base {
|
||||
let creds = state.base_creds_cloned().await?;
|
||||
Ok(Response::Aws(Credentials::Base(creds)))
|
||||
}
|
||||
else {
|
||||
let creds = state.session_creds_cloned().await?;
|
||||
Ok(Response::Aws(Credentials::Session(creds)))
|
||||
}
|
||||
},
|
||||
Ok(Approval::Denied) => Err(HandlerError::Denied),
|
||||
Err(_e) => Err(HandlerError::Internal),
|
||||
}
|
||||
};
|
||||
|
||||
let result = match proceed.await {
|
||||
Ok(r) => Ok(r),
|
||||
Err(e) => {
|
||||
state.unregister_request(request_id).await;
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
|
||||
rt::spawn(
|
||||
handle_rehide(rehide_after, app_handle.app_handle())
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
async fn handle_rehide(rehide_after: bool, app_handle: AppHandle) {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let delay = {
|
||||
let config = state.config.read().await;
|
||||
Duration::from_millis(config.rehide_ms)
|
||||
};
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
// if there are no other pending requests, set rehide status back to None
|
||||
if state.req_count().await == 0 {
|
||||
state.clear_rehide().await;
|
||||
// and hide the window if necessary
|
||||
if rehide_after {
|
||||
app_handle.get_window("main").map(|w| {
|
||||
if let Err(e) = w.hide() {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
164
src-tauri/src/server/mod.rs
Normal file
164
src-tauri/src/server/mod.rs
Normal file
@ -0,0 +1,164 @@
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::errors::*;
|
||||
use crate::clientinfo::{self, Client};
|
||||
use crate::credentials::Credentials;
|
||||
use crate::ipc::{Approval, AwsRequestNotification};
|
||||
use crate::state::AppState;
|
||||
use crate::shortcuts::{self, ShortcutAction};
|
||||
|
||||
#[cfg(windows)]
|
||||
mod server_win;
|
||||
#[cfg(windows)]
|
||||
pub use server_win::Server;
|
||||
#[cfg(windows)]
|
||||
use server_win::Stream;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod server_unix;
|
||||
#[cfg(unix)]
|
||||
pub use server_unix::Server;
|
||||
#[cfg(unix)]
|
||||
use server_unix::Stream;
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Request {
|
||||
GetAwsCredentials{
|
||||
base: bool,
|
||||
},
|
||||
InvokeShortcut(ShortcutAction),
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum Response {
|
||||
Aws(Credentials),
|
||||
Empty,
|
||||
}
|
||||
|
||||
|
||||
struct CloseWaiter<'s> {
|
||||
stream: &'s mut Stream,
|
||||
}
|
||||
|
||||
impl<'s> CloseWaiter<'s> {
|
||||
async fn wait_for_close(&mut self) -> std::io::Result<()> {
|
||||
let mut buf = [0u8; 8];
|
||||
loop {
|
||||
match self.stream.read(&mut buf).await {
|
||||
Ok(0) => break Ok(()),
|
||||
Ok(_) => (),
|
||||
Err(e) => break Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn handle(mut stream: Stream, app_handle: AppHandle, client_pid: u32) -> Result<(), HandlerError>
|
||||
{
|
||||
// read from stream until delimiter is reached
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(1024); // requests are small, 1KiB is more than enough
|
||||
let mut n = 0;
|
||||
loop {
|
||||
n += stream.read_buf(&mut buf).await?;
|
||||
if let Some(&b'\n') = buf.last() {
|
||||
break;
|
||||
}
|
||||
else if n >= 1024 {
|
||||
return Err(HandlerError::RequestTooLarge);
|
||||
}
|
||||
}
|
||||
|
||||
let client = clientinfo::get_process_parent_info(client_pid)?;
|
||||
let waiter = CloseWaiter { stream: &mut stream };
|
||||
|
||||
let req: Request = serde_json::from_slice(&buf)?;
|
||||
let res = match req {
|
||||
Request::GetAwsCredentials{ base } => get_aws_credentials(
|
||||
base, client, app_handle, waiter
|
||||
).await,
|
||||
Request::InvokeShortcut(action) => invoke_shortcut(action).await,
|
||||
};
|
||||
|
||||
// doesn't make sense to send the error to the client if the client has already left
|
||||
if let Err(HandlerError::Abandoned) = res {
|
||||
return Err(HandlerError::Abandoned);
|
||||
}
|
||||
|
||||
let res = serde_json::to_vec(&res).unwrap();
|
||||
stream.write_all(&res).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
async fn invoke_shortcut(action: ShortcutAction) -> Result<Response, HandlerError> {
|
||||
shortcuts::exec_shortcut(action);
|
||||
Ok(Response::Empty)
|
||||
}
|
||||
|
||||
|
||||
async fn get_aws_credentials(
|
||||
base: bool,
|
||||
client: Client,
|
||||
app_handle: AppHandle,
|
||||
mut waiter: CloseWaiter<'_>,
|
||||
) -> Result<Response, HandlerError> {
|
||||
let state = app_handle.state::<AppState>();
|
||||
let rehide_ms = {
|
||||
let config = state.config.read().await;
|
||||
config.rehide_ms
|
||||
};
|
||||
let lease = state.acquire_visibility_lease(rehide_ms).await
|
||||
.map_err(|_e| HandlerError::NoMainWindow)?; // automate this conversion eventually?
|
||||
|
||||
let (chan_send, chan_recv) = oneshot::channel();
|
||||
let request_id = state.register_request(chan_send).await;
|
||||
|
||||
// if an error occurs in any of the following, we want to abort the operation
|
||||
// but ? returns immediately, and we want to unregister the request before returning
|
||||
// so we bundle it all up in an async block and return a Result so we can handle errors
|
||||
let proceed = async {
|
||||
let notification = AwsRequestNotification {id: request_id, client, base};
|
||||
app_handle.emit_all("credentials-request", ¬ification)?;
|
||||
|
||||
let response = tokio::select! {
|
||||
r = chan_recv => r?,
|
||||
_ = waiter.wait_for_close() => {
|
||||
app_handle.emit_all("request-cancelled", request_id)?;
|
||||
return Err(HandlerError::Abandoned);
|
||||
},
|
||||
};
|
||||
|
||||
match response.approval {
|
||||
Approval::Approved => {
|
||||
if response.base {
|
||||
let creds = state.base_creds_cloned().await?;
|
||||
Ok(Response::Aws(Credentials::Base(creds)))
|
||||
}
|
||||
else {
|
||||
let creds = state.session_creds_cloned().await?;
|
||||
Ok(Response::Aws(Credentials::Session(creds)))
|
||||
}
|
||||
},
|
||||
Approval::Denied => Err(HandlerError::Denied),
|
||||
}
|
||||
};
|
||||
|
||||
let result = match proceed.await {
|
||||
Ok(r) => Ok(r),
|
||||
Err(e) => {
|
||||
state.unregister_request(request_id).await;
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
|
||||
lease.release();
|
||||
result
|
||||
}
|
59
src-tauri/src/server/server_unix.rs
Normal file
59
src-tauri/src/server/server_unix.rs
Normal 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)
|
||||
}
|
74
src-tauri/src/server/server_win.rs
Normal file
74
src-tauri/src/server/server_win.rs
Normal 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)
|
||||
}
|
@ -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)
|
||||
)?;
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "creddy",
|
||||
"version": "0.3.3"
|
||||
"version": "0.4.5"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
@ -3,7 +3,7 @@ import { onMount } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
import { appState, acceptRequest } from './lib/state.js';
|
||||
import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
|
||||
import { views, currentView, navigate } from './lib/routing.js';
|
||||
|
||||
|
||||
@ -16,6 +16,16 @@ listen('credentials-request', (tauriEvent) => {
|
||||
$appState.pendingRequests.put(tauriEvent.payload);
|
||||
});
|
||||
|
||||
listen('request-cancelled', (tauriEvent) => {
|
||||
const id = tauriEvent.payload;
|
||||
if (id === $appState.currentRequest?.id) {
|
||||
cleanupRequest()
|
||||
}
|
||||
else {
|
||||
const found = $appState.pendingRequests.find_remove(r => r.id === id);
|
||||
}
|
||||
});
|
||||
|
||||
listen('launch-terminal-request', async (tauriEvent) => {
|
||||
if ($appState.currentRequest === null) {
|
||||
let status = await invoke('get_session_status');
|
||||
|
@ -30,5 +30,15 @@ export default function() {
|
||||
|
||||
return this.items.shift();
|
||||
},
|
||||
|
||||
find_remove(pred) {
|
||||
for (let i=0; i<this.items.length; i++) {
|
||||
if (pred(this.items[i])) {
|
||||
this.items.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}
|
||||
}
|
@ -23,7 +23,7 @@ export async function acceptRequest() {
|
||||
}
|
||||
|
||||
|
||||
export function completeRequest() {
|
||||
export function cleanupRequest() {
|
||||
appState.update($appState => {
|
||||
$appState.currentRequest = null;
|
||||
return $appState;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -1,13 +1,15 @@
|
||||
<script>
|
||||
export let keys;
|
||||
let classes;
|
||||
export {classes as class};
|
||||
</script>
|
||||
|
||||
|
||||
<div class="flex gap-x-[0.2em] items-center">
|
||||
<span class="inline-flex gap-x-[0.2em] items-center {classes}">
|
||||
{#each keys as key, i}
|
||||
{#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>
|
||||
</span>
|
||||
|
@ -21,15 +21,15 @@
|
||||
throw(`Link target is not a string or a function: ${target}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function handleHotkey(event) {
|
||||
if (!hotkey) return;
|
||||
if (ctrl && !event.ctrlKey) return;
|
||||
if (alt && !event.altKey) return;
|
||||
if (shift && !event.shiftKey) return;
|
||||
|
||||
if (event.key === hotkey) {
|
||||
if (
|
||||
hotkey === event.key
|
||||
&& ctrl === event.ctrlKey
|
||||
&& alt === event.altKey
|
||||
&& shift === event.shiftKey
|
||||
) {
|
||||
click();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
|
||||
import { navigate } from '../lib/routing.js';
|
||||
import { appState, completeRequest } from '../lib/state.js';
|
||||
import { appState, cleanupRequest } from '../lib/state.js';
|
||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||
import Link from '../ui/Link.svelte';
|
||||
import KeyCombo from '../ui/KeyCombo.svelte';
|
||||
@ -12,9 +12,12 @@
|
||||
// Send response to backend, display error if applicable
|
||||
let error, alert;
|
||||
async function respond() {
|
||||
let {id, approval} = $appState.currentRequest;
|
||||
const response = {
|
||||
id: $appState.currentRequest.id,
|
||||
...$appState.currentRequest.response,
|
||||
};
|
||||
try {
|
||||
await invoke('respond', {response: {id, approval}});
|
||||
await invoke('respond', {response});
|
||||
navigate('ShowResponse');
|
||||
}
|
||||
catch (e) {
|
||||
@ -26,8 +29,8 @@
|
||||
}
|
||||
|
||||
// Approval has one of several outcomes depending on current credential state
|
||||
async function approve() {
|
||||
$appState.currentRequest.approval = 'Approved';
|
||||
async function approve(base) {
|
||||
$appState.currentRequest.response = {approval: 'Approved', base};
|
||||
let status = await invoke('get_session_status');
|
||||
if (status === 'unlocked') {
|
||||
await respond();
|
||||
@ -40,9 +43,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function approve_base() {
|
||||
approve(true);
|
||||
}
|
||||
function approve_session() {
|
||||
approve(false);
|
||||
}
|
||||
|
||||
// Denial has only one
|
||||
async function deny() {
|
||||
$appState.currentRequest.approval = 'Denied';
|
||||
$appState.currentRequest.response = {approval: 'Denied', base: false};
|
||||
await respond();
|
||||
}
|
||||
|
||||
@ -58,32 +68,32 @@
|
||||
|
||||
// if the request has already been approved/denied, send response immediately
|
||||
onMount(async () => {
|
||||
if ($appState.currentRequest.approval) {
|
||||
if ($appState.currentRequest.response) {
|
||||
await respond();
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<!-- Don't render at all if we're just going to immediately proceed to the next screen -->
|
||||
{#if error || !$appState.currentRequest.approval}
|
||||
{#if error || !$appState.currentRequest?.response}
|
||||
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
|
||||
{#if error}
|
||||
<ErrorAlert bind:this={alert}>
|
||||
{error}
|
||||
{error.msg}
|
||||
<svelte:fragment slot="buttons">
|
||||
<button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button>
|
||||
<button class="btn btn-sm btn-alert-error" on:click={cleanupRequest}>Cancel</button>
|
||||
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
|
||||
</svelte:fragment>
|
||||
</ErrorAlert>
|
||||
{/if}
|
||||
|
||||
{#if $appState.currentRequest.base}
|
||||
{#if $appState.currentRequest?.base}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<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 base AWS credentials.
|
||||
These credentials are less secure than session credentials, since they don't expire automatically.
|
||||
</span>
|
||||
</div>
|
||||
@ -101,20 +111,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between">
|
||||
<Link target={deny} hotkey="Escape">
|
||||
<button class="btn btn-error justify-self-start">
|
||||
<span class="mr-2">Deny</span>
|
||||
<KeyCombo keys={['Esc']} />
|
||||
</button>
|
||||
</Link>
|
||||
<div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6">
|
||||
<!-- Don't display the option to approve with session credentials if base was specifically requested -->
|
||||
{#if !$appState.currentRequest?.base}
|
||||
<h3 class="font-semibold">
|
||||
Approve with session credentials
|
||||
</h3>
|
||||
<Link target={() => approve(false)} hotkey="Enter" shift={true}>
|
||||
<button class="w-full btn btn-success">
|
||||
<KeyCombo keys={['Shift', 'Enter']} />
|
||||
</button>
|
||||
</Link>
|
||||
{/if}
|
||||
|
||||
<Link target={approve} hotkey="Enter" shift="{true}">
|
||||
<button class="btn btn-success justify-self-end">
|
||||
<span class="mr-2">Approve</span>
|
||||
<KeyCombo keys={['Shift', 'Enter']} />
|
||||
</button>
|
||||
</Link>
|
||||
<h3 class="font-semibold">
|
||||
<span class="mr-2">
|
||||
{#if $appState.currentRequest?.base}
|
||||
Approve
|
||||
{:else}
|
||||
Approve with base credentials
|
||||
{/if}
|
||||
</span>
|
||||
</h3>
|
||||
<Link target={() => approve(true)} hotkey="Enter" shift={true} ctrl={true}>
|
||||
<button class="w-full btn btn-warning">
|
||||
<KeyCombo keys={['Ctrl', 'Shift', 'Enter']} />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<h3 class="font-semibold">
|
||||
<span class="mr-2">Deny</span>
|
||||
</h3>
|
||||
<Link target={deny} hotkey="Escape">
|
||||
<button class="w-full btn btn-error">
|
||||
<KeyCombo keys={['Esc']} />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -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';
|
||||
|
@ -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'}
|
||||
|
@ -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> -->
|
||||
<button class="btn btn-sm btn-primary" on:click={save}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { draw, fade } from 'svelte/transition';
|
||||
|
||||
import { appState, completeRequest } from '../lib/state.js';
|
||||
import { appState, cleanupRequest } from '../lib/state.js';
|
||||
|
||||
let success = false;
|
||||
let error = null;
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
onMount(() => {
|
||||
window.setTimeout(
|
||||
completeRequest,
|
||||
cleanupRequest,
|
||||
// Extra 50ms so the window can finish disappearing before the redraw
|
||||
Math.min(5000, $appState.config.rehide_ms + 50),
|
||||
)
|
||||
@ -22,7 +22,7 @@
|
||||
|
||||
|
||||
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
|
||||
{#if $appState.currentRequest.approval === 'Approved'}
|
||||
{#if $appState.currentRequest.response.approval === 'Approved'}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
|
||||
<path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@ -33,6 +33,6 @@
|
||||
{/if}
|
||||
|
||||
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
|
||||
{$appState.currentRequest.approval}!
|
||||
{$appState.currentRequest.response.approval}!
|
||||
</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user