Merge branch 'terminal'

This commit is contained in:
Joseph Montanaro 2023-09-11 16:10:58 -07:00
commit c16f21bba3
27 changed files with 650 additions and 117 deletions

12
src-tauri/Cargo.lock generated
View File

@ -1066,6 +1066,7 @@ dependencies = [
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"thiserror", "thiserror",
"tokio", "tokio",
"which",
] ]
[[package]] [[package]]
@ -5145,6 +5146,17 @@ dependencies = [
"windows-metadata", "windows-metadata",
] ]
[[package]]
name = "which"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269"
dependencies = [
"either",
"libc",
"once_cell",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"

View File

@ -25,7 +25,7 @@ tauri-build = { version = "1.0.4", features = [] }
[dependencies] [dependencies]
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = ["dialog", "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" } tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
sodiumoxide = "0.2.7" sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] } tokio = { version = ">=1.19", features = ["full"] }
@ -46,6 +46,7 @@ clap = { version = "3.2.23", features = ["derive"] }
is-terminal = "0.4.7" is-terminal = "0.4.7"
argon2 = { version = "0.5.0", features = ["std"] } argon2 = { version = "0.5.0", features = ["std"] }
chacha20poly1305 = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] }
which = "4.4.0"
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode

View File

@ -42,6 +42,7 @@ pub fn run() -> tauri::Result<()> {
ipc::save_credentials, ipc::save_credentials,
ipc::get_config, ipc::get_config,
ipc::save_config, ipc::save_config,
ipc::launch_terminal,
]) ])
.setup(|app| rt::block_on(setup(app))) .setup(|app| rt::block_on(setup(app)))
.build(tauri::generate_context!())? .build(tauri::generate_context!())?
@ -82,6 +83,7 @@ async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?; let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?;
config::set_auto_launch(conf.start_on_login)?; config::set_auto_launch(conf.start_on_login)?;
config::register_hotkeys(&conf.hotkeys)?;
// if session is empty, this is probably the first launch, so don't autohide // if session is empty, this is probably the first launch, so don't autohide
if !conf.start_minimized || is_first_launch { if !conf.start_minimized || is_first_launch {
app.get_window("main") app.get_window("main")

View File

@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::process::Command as ChildCommand; use std::process::Command as ChildCommand;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
@ -90,15 +91,28 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
#[cfg(unix)] #[cfg(unix)]
{ {
let e = cmd.exec(); // never returns if successful // cmd.exec() never returns if successful
Err(ExecError::ExecutionFailed(e))?; let e = cmd.exec();
Ok(()) match e.kind() {
std::io::ErrorKind::NotFound => {
let name: OsString = cmd_name.into();
Err(ExecError::NotFound(name).into())
}
e => Err(ExecError::ExecutionFailed(e).into()),
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let mut child = cmd.spawn() let mut child = match cmd.spawn() {
.map_err(|e| ExecError::ExecutionFailed(e))?; 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() let status = child.wait()
.map_err(|e| ExecError::ExecutionFailed(e))?; .map_err(|e| ExecError::ExecutionFailed(e))?;
std::process::exit(status.code().unwrap_or(1)); std::process::exit(status.code().unwrap_or(1));

View File

@ -5,10 +5,41 @@ use auto_launch::AutoLaunchBuilder;
use is_terminal::IsTerminal; use is_terminal::IsTerminal;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tauri::{
Manager,
GlobalShortcutManager,
async_runtime as rt,
};
use crate::errors::*; use crate::errors::*;
#[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)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
#[serde(default = "default_listen_addr")] #[serde(default = "default_listen_addr")]
@ -21,6 +52,10 @@ pub struct AppConfig {
pub start_minimized: bool, pub start_minimized: bool,
#[serde(default = "default_start_on_login")] #[serde(default = "default_start_on_login")]
pub start_on_login: bool, 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(), rehide_ms: default_rehide_ms(),
start_minimized: default_start_minimized(), start_minimized: default_start_minimized(),
start_on_login: default_start_on_login(), start_on_login: default_start_on_login(),
terminal: default_term_config(),
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_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
fn default_rehide_ms() -> u64 { 1000 } fn default_rehide_ms() -> u64 { 1000 }
// start minimized and on login only in production mode // start minimized and on login only in production mode

View File

@ -81,6 +81,16 @@ impl Session {
Session::Empty => Err(GetSessionError::CredentialsEmpty), 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))
}
}
} }

View File

@ -1,5 +1,6 @@
use std::error::Error; use std::error::Error;
use std::convert::AsRef; use std::convert::AsRef;
use std::ffi::OsString;
use std::sync::mpsc; use std::sync::mpsc;
use strum_macros::AsRefStr; use strum_macros::AsRefStr;
@ -94,6 +95,8 @@ pub enum SetupError {
ServerSetupError(#[from] std::io::Error), ServerSetupError(#[from] std::io::Error),
#[error("Failed to resolve data directory: {0}")] #[error("Failed to resolve data directory: {0}")]
DataDir(#[from] DataDirError), DataDir(#[from] DataDirError),
#[error("Failed to register hotkeys: {0}")]
RegisterHotkeys(#[from] tauri::Error),
} }
@ -216,16 +219,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)] #[derive(Debug, ThisError, AsRefStr)]
pub enum CliError { pub enum CliError {
#[error(transparent)] #[error(transparent)]
@ -237,6 +230,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 // Serialize implementations
// ========================= // =========================
@ -327,3 +347,33 @@ impl Serialize for UnlockError {
map.end() 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()
}
}

View File

@ -6,6 +6,7 @@ use crate::credentials::{Session,BaseCredentials};
use crate::errors::*; use crate::errors::*;
use crate::clientinfo::Client; use crate::clientinfo::Client;
use crate::state::AppState; use crate::state::AppState;
use crate::terminal;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@ -78,3 +79,9 @@ pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> R
.map_err(|e| format!("Error saving config: {e}"))?; .map_err(|e| format!("Error saving config: {e}"))?;
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
terminal::launch(base).await
}

View File

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

View File

@ -29,6 +29,7 @@ pub struct AppState {
pub session: RwLock<Session>, pub session: RwLock<Session>,
pub request_count: RwLock<u64>, pub request_count: RwLock<u64>,
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>, pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
pub pending_terminal_request: RwLock<bool>,
pub bans: RwLock<std::collections::HashSet<Option<Client>>>, pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
server: RwLock<Server>, server: RwLock<Server>,
pool: sqlx::SqlitePool, pool: sqlx::SqlitePool,
@ -41,6 +42,7 @@ impl AppState {
session: RwLock::new(session), session: RwLock::new(session),
request_count: RwLock::new(0), request_count: RwLock::new(0),
open_requests: RwLock::new(HashMap::new()), open_requests: RwLock::new(HashMap::new()),
pending_terminal_request: RwLock::new(false),
bans: RwLock::new(HashSet::new()), bans: RwLock::new(HashSet::new()),
server: RwLock::new(server), server: RwLock::new(server),
pool, pool,
@ -59,15 +61,23 @@ impl AppState {
pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> { pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
let mut live_config = self.config.write().await; let mut live_config = self.config.write().await;
// update autostart if necessary
if new_config.start_on_login != live_config.start_on_login { if new_config.start_on_login != live_config.start_on_login {
config::set_auto_launch(new_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 if new_config.listen_addr != live_config.listen_addr
|| new_config.listen_port != live_config.listen_port || new_config.listen_port != live_config.listen_port
{ {
let mut sv = self.server.write().await; let mut sv = self.server.write().await;
sv.rebind(new_config.listen_addr, new_config.listen_port).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?; new_config.save(&self.pool).await?;
*live_config = new_config; *live_config = new_config;
@ -141,22 +151,21 @@ impl AppState {
Ok(()) Ok(())
} }
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> { pub async fn is_unlocked(&self) -> bool {
let session = self.session.read().await; let session = self.session.read().await;
match *session { matches!(*session, Session::Unlocked{..})
Session::Unlocked{ref base, ..} => Ok(serde_json::to_string(base).unwrap()),
Session::Locked(_) => Err(GetCredentialsError::Locked),
Session::Empty => Err(GetCredentialsError::Empty),
} }
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> { pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().await; let app_session = self.session.read().await;
match *session { let (_bsae, session) = app_session.try_get()?;
Session::Unlocked{ref session, ..} => Ok(serde_json::to_string(session).unwrap()), Ok(serde_json::to_string(session).unwrap())
Session::Locked(_) => Err(GetCredentialsError::Locked),
Session::Empty => Err(GetCredentialsError::Empty),
}
} }
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> { async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
@ -165,4 +174,21 @@ impl AppState {
*app_session = Session::Unlocked {base, session}; *app_session = Session::Unlocked {base, session};
Ok(()) 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
View 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(())
}

View File

@ -12,7 +12,8 @@
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
"os": {"all": true} "os": {"all": true},
"dialog": {"open": true}
}, },
"bundle": { "bundle": {
"active": true, "active": true,

View File

@ -16,6 +16,20 @@ listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload); $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)
}
})
acceptRequest(); acceptRequest();
</script> </script>

View File

@ -9,6 +9,10 @@ export default function() {
resolvers: [], resolvers: [],
size() {
return this.items.length;
},
put(item) { put(item) {
this.items.push(item); this.items.push(item);
let resolver = this.resolvers.shift(); let resolver = this.resolvers.shift();

13
src/ui/KeyCombo.svelte Normal file
View 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>

View 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>

View File

@ -0,0 +1,61 @@
<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();
let listening = false;
function listen() {
// don't re-listen if we already are
if (listening) return;
listening = true;
window.addEventListener('keyup', setKeybind, {once: true});
// setTimeout avoids reacting to the click event that we are currently processing
setTimeout(() => window.addEventListener('click', cancel, {once: true}), 0);
}
function setKeybind(event) {
console.log(event);
let keys = [];
if (event.ctrlKey) keys.push('ctrl');
if (event.altKey) keys.push('alt');
if (event.metaKey) keys.push('meta');
if (event.shiftKey) keys.push('shift');
keys.push(event.key);
value.keys = keys.join('+');
dispatch('update', {value});
listening = false;
window.removeEventListener('click', cancel, {once: true});
event.preventDefault();
event.stopPropagation();
}
function cancel() {
listening = false;
window.removeEventListener('keyup', setKeybind, {once: true});
}
</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>

View File

@ -5,6 +5,7 @@
export let title; export let title;
export let value; export let value;
export let unit = ''; export let unit = '';
export let min = null; export let min = null;
export let max = null; export let max = null;

View File

@ -6,10 +6,12 @@
</script> </script>
<div class="divider"></div> <div>
<div class="flex justify-between"> <div class="flex flex-wrap justify-between gap-y-4">
<h3 class="text-lg font-bold">{title}</h3> <h3 class="text-lg font-bold shrink-0">{title}</h3>
{#if $$slots.input}
<slot name="input"></slot> <slot name="input"></slot>
{/if}
</div> </div>
{#if $$slots.description} {#if $$slots.description}
@ -17,3 +19,4 @@
<slot name="description"></slot> <slot name="description"></slot>
</p> </p>
{/if} {/if}
</div>

View 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>

View 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>

View File

@ -1,3 +1,5 @@
export { default as Setting } from './Setting.svelte'; export { default as Setting } from './Setting.svelte';
export { default as ToggleSetting } from './ToggleSetting.svelte'; export { default as ToggleSetting } from './ToggleSetting.svelte';
export { default as NumericSetting } from './NumericSetting.svelte'; export { default as NumericSetting } from './NumericSetting.svelte';
export { default as FileSetting } from './FileSetting.svelte';
export { default as TextSetting } from './TextSetting.svelte';

View File

@ -6,6 +6,7 @@
import { appState, completeRequest } from '../lib/state.js'; import { appState, completeRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte'; import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import KeyCombo from '../ui/KeyCombo.svelte';
// Send response to backend, display error if applicable // Send response to backend, display error if applicable
@ -108,17 +109,15 @@
<div class="w-full flex justify-between"> <div class="w-full flex justify-between">
<Link target={deny} hotkey="Escape"> <Link target={deny} hotkey="Escape">
<button class="btn btn-error justify-self-start"> <button class="btn btn-error justify-self-start">
Deny <span class="mr-2">Deny</span>
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Esc</kbd> <KeyCombo keys={['Esc']} />
</button> </button>
</Link> </Link>
<Link target={approve} hotkey="Enter" shift="{true}"> <Link target={approve} hotkey="Enter" shift="{true}">
<button class="btn btn-success justify-self-end"> <button class="btn btn-success justify-self-end">
Approve <span class="mr-2">Approve</span>
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Shift</kbd> <KeyCombo keys={['Shift', 'Enter']} />
<span class="mx-0.5">+</span>
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">Enter</kbd>
</button> </button>
</Link> </Link>
</div> </div>

View File

@ -31,6 +31,7 @@
try { try {
saving = true; saving = true;
await invoke('save_credentials', {credentials, passphrase}); await invoke('save_credentials', {credentials, passphrase});
emit('credentials-event', 'entered');
if ($appState.currentRequest) { if ($appState.currentRequest) {
navigate('Approve'); navigate('Approve');
} }
@ -56,6 +57,11 @@
saving = false; saving = false;
} }
} }
function cancel() {
emit('credentials-event', 'enter-canceled');
navigate('Home');
}
</script> </script>
@ -79,7 +85,7 @@
Submit Submit
{/if} {/if}
</button> </button>
<Link target="Home" hotkey="Escape"> <Link target={cancel} hotkey="Escape">
<button class="btn btn-sm btn-outline w-full">Cancel</button> <button class="btn btn-sm btn-outline w-full">Cancel</button>
</Link> </Link>
</form> </form>

View File

@ -10,13 +10,11 @@
import vaultDoorSvg from '../assets/vault_door.svg?raw'; import vaultDoorSvg from '../assets/vault_door.svg?raw';
let launchBase = false;
// onMount(async () => { function launchTerminal() {
// // will block until a request comes in invoke('launch_terminal', {base: launchBase});
// let req = await $appState.pendingRequests.get(); launchBase = false;
// $appState.currentRequest = req; }
// navigate('Approve');
// });
</script> </script>
@ -25,21 +23,27 @@
</Nav> </Nav>
<div class="flex flex-col h-screen items-center justify-center p-4 space-y-4"> <div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
<div class="flex flex-col items-center space-y-4">
{@html vaultDoorSvg}
{#await invoke('get_session_status') then status} {#await invoke('get_session_status') then status}
{#if status === 'locked'} {#if status === 'locked'}
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">Creddy is locked</h2> <h2 class="text-2xl font-bold">Creddy is locked</h2>
<Link target="Unlock" hotkey="Enter" class="w-64"> <Link target="Unlock" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Unlock</button> <button class="btn btn-primary w-full">Unlock</button>
</Link> </Link>
{:else if status === 'unlocked'} {:else if status === 'unlocked'}
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">Waiting for requests</h2> <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'} {:else if status === 'empty'}
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">No credentials found</h2> <h2 class="text-2xl font-bold">No credentials found</h2>
<Link target="EnterCredentials" hotkey="Enter" class="w-64"> <Link target="EnterCredentials" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Enter Credentials</button> <button class="btn btn-primary w-full">Enter Credentials</button>
@ -47,3 +51,4 @@
{/if} {/if}
{/await} {/await}
</div> </div>
</div>

View File

@ -1,12 +1,19 @@
<script context="module">
import { type } from '@tauri-apps/api/os';
const osType = await type();
</script>
<script> <script>
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { type } from '@tauri-apps/api/os';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
import Nav from '../ui/Nav.svelte'; import Nav from '../ui/Nav.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.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 { fly } from 'svelte/transition';
import { backInOut } from 'svelte/easing'; import { backInOut } from 'svelte/easing';
@ -14,6 +21,7 @@
let error = null; let error = null;
async function save() { async function save() {
console.log('updating config');
try { try {
await invoke('save_config', {config: $appState.config}); await invoke('save_config', {config: $appState.config});
} }
@ -22,20 +30,16 @@
$appState.config = await invoke('get_config'); $appState.config = await invoke('get_config');
} }
} }
let osType = '';
type().then(t => osType = t);
</script> </script>
<Nav> <Nav>
<h2 slot="title" class="text-2xl font-bold">Settings</h2> <h1 slot="title" class="text-2xl font-bold">Settings</h1>
</Nav> </Nav>
{#await invoke('get_config') then config} {#await invoke('get_config') then config}
<div class="max-w-md mx-auto mt-1.5 p-4"> <div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16">
<!-- <h2 class="text-2xl font-bold text-center">Settings</h2> --> <SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}> <ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Start Creddy when you log in to your computer. Start Creddy when you log in to your computer.
@ -76,6 +80,29 @@
Update or re-enter your encrypted credentials. Update or re-enter your encrypted credentials.
</svelte:fragment> </svelte:fragment>
</Setting> </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>
</div> </div>
{/await} {/await}

View File

@ -1,5 +1,6 @@
<script> <script>
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { emit } from '@tauri-apps/api/event';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
@ -26,6 +27,7 @@
saving = true; saving = true;
let r = await invoke('unlock', {passphrase}); let r = await invoke('unlock', {passphrase});
$appState.credentialStatus = 'unlocked'; $appState.credentialStatus = 'unlocked';
emit('credentials-event', 'unlocked');
if ($appState.currentRequest) { if ($appState.currentRequest) {
navigate('Approve'); navigate('Approve');
} }
@ -51,6 +53,11 @@
} }
} }
function cancel() {
emit('credentials-event', 'unlock-canceled');
navigate('Home');
}
onMount(() => { onMount(() => {
loadTime = Date.now(); loadTime = Date.now();
}) })
@ -75,7 +82,7 @@
{/if} {/if}
</button> </button>
<Link target="Home" hotkey="Escape"> <Link target={cancel} hotkey="Escape">
<button class="btn btn-outline btn-sm w-full">Cancel</button> <button class="btn btn-sm btn-outline w-full">Cancel</button>
</Link> </Link>
</form> </form>