Compare commits
37 Commits
v0.3.0
...
0491cb5790
Author | SHA1 | Date | |
---|---|---|---|
0491cb5790 | |||
816bd7db00 | |||
b165965289 | |||
86896d68c2 | |||
64a2927b94 | |||
87617a0726 | |||
141334f7e2 | |||
69f6a39396 | |||
70e23c7e20 | |||
1df849442e | |||
7fdb336c79 | |||
46b8d810c5 | |||
dd40eb379e | |||
13545ac725 | |||
040a01536a | |||
4e2a90b15b | |||
e0d919ed4a | |||
3f4efc5f8f | |||
4881b90b0b | |||
1b749a857c | |||
2079f99d04 | |||
5e0ffc1155 | |||
d4fa8966b2 | |||
a293d8f92c | |||
367a140e2a | |||
4b06dce7f4 | |||
47a3e1cfef | |||
1047818fdc | |||
3d093a3a45 | |||
992d2a4d06 | |||
12f0f187a6 | |||
997e8b419f | |||
1d9132de3b | |||
e1c2618dc8 | |||
a7df7adc8e | |||
03d164c9d3 | |||
f522674a1c |
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,6 +2,9 @@ dist
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
**/creddy.db
|
**/creddy.db
|
||||||
|
# .env is system-specific
|
||||||
|
.env
|
||||||
|
.vscode
|
||||||
|
|
||||||
# just in case
|
# just in case
|
||||||
credentials*
|
credentials*
|
||||||
|
@ -9,7 +9,7 @@ The following is a list of security features that I hope to add eventually, in a
|
|||||||
* To defend against the possibility that an attacker could replace, say, the `aws` executable with a malicious one that snarfs your credentials and then passes the command on to the real one, maybe track the path (and maybe even the hash) of the executable, and raise a warning if this is the first time we've seen that one? Using the hash would be safer, but would also introduce a lot of false positives, since every time the application gets updated it would trigger. On the other hand, users should presumably know when they've updated things, so maybe it would be ok. On the _other_ other hand, if somebody doesn't use `aws` very often then it might be weeks or months in between updating it and actually using the updated executable, in which case they probably won't remember that this is the first time they've used it since updating.
|
* To defend against the possibility that an attacker could replace, say, the `aws` executable with a malicious one that snarfs your credentials and then passes the command on to the real one, maybe track the path (and maybe even the hash) of the executable, and raise a warning if this is the first time we've seen that one? Using the hash would be safer, but would also introduce a lot of false positives, since every time the application gets updated it would trigger. On the other hand, users should presumably know when they've updated things, so maybe it would be ok. On the _other_ other hand, if somebody doesn't use `aws` very often then it might be weeks or months in between updating it and actually using the updated executable, in which case they probably won't remember that this is the first time they've used it since updating.
|
||||||
Another possible approach is to _watch_ the files in question, and alert the user whenever any of them changes. Presumably the user will know whether this change is expected or not.
|
Another possible approach is to _watch_ the files in question, and alert the user whenever any of them changes. Presumably the user will know whether this change is expected or not.
|
||||||
* Downgrade privileges after launching. In particular, if possible, disallow any kind of outgoing network access (obviously we have to bind the listening socket, but maybe we can filter that down to _just_ the ability to bind that particular address/port) and filesystem access outside of state db. I think this is doable on Linux, although it may involve high levels of `seccomp` grossness. No idea whether it's possible on Windows. Probably possible on MacOS although it may require lengths to which I am currently unwilling to go (e.g. pay for a certificate from Apple or something.)
|
* Downgrade privileges after launching. In particular, if possible, disallow any kind of outgoing network access (obviously we have to bind the listening socket, but maybe we can filter that down to _just_ the ability to bind that particular address/port) and filesystem access outside of state db. I think this is doable on Linux, although it may involve high levels of `seccomp` grossness. No idea whether it's possible on Windows. Probably possible on MacOS although it may require lengths to which I am currently unwilling to go (e.g. pay for a certificate from Apple or something.)
|
||||||
* "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. (Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise). Not sure how feasible this is, session credentials may be limited with regard to what kind of IAM operations they can carry out.)
|
* "Panic button" - if a potential attack is detected (e.g. the user denies a request but Creddy discovers the request has already succeeded somehow), offer a one-click option to lock out the current IAM user. Sadly, you can't revoke session tokens, so this is the only way to limit a potential compromise. Obviously this would require the current user having the ability to revoke their own IAM permissions.)
|
||||||
* Some kind of Yubikey or other HST integration. (Optional, since not everyone will have a HST.) This comes in two flavors:
|
* Some kind of Yubikey or other HST integration. (Optional, since not everyone will have a HST.) This comes in two flavors:
|
||||||
1. (Probably doable) Store the encryption key for the passphrase on the HST, and ask the HST to decrypt the passphrase instead of asking the user to enter it. This has the advantage of being a) lower-friction, since the user doesn't have to type in the passphrase, and b) more secure, since the application code never sees the encryption key.
|
1. (Probably doable) Store the encryption key for the passphrase on the HST, and ask the HST to decrypt the passphrase instead of asking the user to enter it. This has the advantage of being a) lower-friction, since the user doesn't have to type in the passphrase, and b) more secure, since the application code never sees the encryption key.
|
||||||
2. (Less doable) Store the actual AWS secret key on the HST, and then ask the HST to just sign the whole `GetSessionToken` request. This requires that the HST support the exact signing algorithm required by AWS, which a) it probably doesn't, and b) is subject to change anyway. So this is probably not doable, but it's worth at least double-checking, since it would provide the maximum theoretical level of security. (That is, after initial setup, the application would never again see the long-lived AWS secret key.)
|
2. (Less doable) Store the actual AWS secret key on the HST, and then ask the HST to just sign the whole `GetSessionToken` request. This requires that the HST support the exact signing algorithm required by AWS, which a) it probably doesn't, and b) is subject to change anyway. So this is probably not doable, but it's worth at least double-checking, since it would provide the maximum theoretical level of security. (That is, after initial setup, the application would never again see the long-lived AWS secret key.)
|
||||||
@ -19,9 +19,9 @@ Another possible approach is to _watch_ the files in question, and alert the use
|
|||||||
|
|
||||||
Who exactly are we defending against and why?
|
Who exactly are we defending against and why?
|
||||||
|
|
||||||
The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security boundaries: 1) the older, user-based model, where all code executing as a given user is assumed to have the same level of trust, and 2) the newer, application-based model (most clearly seen on mobile devices) where that bondary instead exists around each _application_.
|
The basic idea behind Creddy is that it provides "gap coverage" between two wildly different security models: 1) the older, user-based model, where all code executing as a given user is assumed to have the same level of trust, and 2) the newer, application-based model (most clearly seen on mobile devices) where that bondary instead exists around each _application_.
|
||||||
|
|
||||||
The unfortunate reality is that desktop environments are unlikely to adopt the latter model any time soon, if ever. This is primarily due to friction: Per-application security is a nightmare to manage. The only reason it works at all on mobile devices is because most mobile apps eschew the local device in favor of cloud-backed services where they can, e.g. for file storage. Arguably, the higher-friction trust model of mobile environments is in part _why_ mobile apps tend to be cloud-first.
|
The unfortunate reality is that desktop environments are unlikely to adopt the latter model any time soon, if ever. This is primarily due to friction: Per-application security is a nightmare to manage. The only reason it works at all on mobile devices is because most mobile apps eschew the local device in favor of cloud-backed services where they can, e.g. for file storage. Arguably, the higher-friction trust model of mobile environments (along with the frequently-replaced nature of mobile devices) is in part _why_ mobile apps tend to be cloud-first.
|
||||||
|
|
||||||
Regardless, we live in a world where it's difficult to run untrusted code without giving it an inordinate level of access to the machine on which it runs. Creddy attempts to prevent that access from including your AWS credentials. The threat model is thus "untrusted code running under your user". This is especially likely to occur in the form of a supply-chain attack, where the compromised code is not your own but rather a dependency, or a dependency of a dependency, etc.
|
Regardless, we live in a world where it's difficult to run untrusted code without giving it an inordinate level of access to the machine on which it runs. Creddy attempts to prevent that access from including your AWS credentials. The threat model is thus "untrusted code running under your user". This is especially likely to occur in the form of a supply-chain attack, where the compromised code is not your own but rather a dependency, or a dependency of a dependency, etc.
|
||||||
|
|
||||||
@ -31,13 +31,13 @@ There are lots of ways that I can imagine someone might try to circumvent Creddy
|
|||||||
|
|
||||||
### Tricking Creddy into allowing a request that it shouldn't
|
### Tricking Creddy into allowing a request that it shouldn't
|
||||||
|
|
||||||
If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because as soon as the request came in, Creddy would pop up requesting permission. When the user (presumably) denied the request, Creddy would discover that the request had already been approved - we could make this a high-alert situation because it would be unlikely to happen unless something fishy were going on. Additionally, the request and (hopefully) what executable made it would be logged.
|
If an attacker is able to compromise Creddy's frontend, e.g. via a JS library that Creddy relies on, they could forge "request accepted" responses and cause the backend to hand out credentials to an unauthorized client. Most likely, the user would immediately be alerted to the fact that Something Is Up because Creddy would pop up requesting then permission, and then immediately disappear again because the request had been approved. Additionally, the request and (hopefully) what executable made it would be logged.
|
||||||
|
|
||||||
### Tricking the user into allowing a request they didn't intend to
|
### Tricking the user into allowing a request they didn't intend to
|
||||||
|
|
||||||
If an attacker can edit the user's .bashrc or similar, they could theoretically insert a function or pre-command hook that wraps, say, the `aws` command, and dump the credentials before continuing on with the user's command. This would most likely alert the user because either a) the attacker is hijacking the original `aws` command and thus it doesn't do what the user told it to, or b) the user's original `aws` command proceeds as normal after the malicious one, and the user is alerted by the second request where there should only have been one.
|
If an attacker can edit the user's .bashrc or similar, they could theoretically insert a function or pre-command hook that wraps, say, the `aws` command, and dump the credentials before continuing on with the user's command. The attacker could inject the credentials into the environment before running the original command, so as to avoid alerting the user by issuing a second credentials request.
|
||||||
|
|
||||||
A similar but more-difficult-to-detect attack would be replacing the `aws` executable, or any other executable that is always expected to ask for AWS credentials, with a malicious wrapper that snarfs the credentials before passing them through to the original command. Creddy could defend against this to a certain extent by storing the hash of the executable, as discussed above.
|
Another attack along the same lines would be replacing the `aws` executable, or any other executable that is always expected to ask for AWS credentials, with a malicious wrapper that snarfs the credentials before passing them through to the original command. Creddy could defend against this to a certain extent by storing the hash of the executable, as discussed above.
|
||||||
|
|
||||||
### Pretending to be the user
|
### Pretending to be the user
|
||||||
|
|
||||||
@ -46,3 +46,5 @@ Most desktop environments don't prevent applications from simulating user-input
|
|||||||
### Twiddling with Creddy's persistent state
|
### Twiddling with Creddy's persistent state
|
||||||
|
|
||||||
The solutions to or mitigations for a lot of these attacks rely on Creddy being able to assume that its local database hasn't been tampered with. Unfortunately, given that our threat model is "other code running as the same user", this isn't a safe assumption.
|
The solutions to or mitigations for a lot of these attacks rely on Creddy being able to assume that its local database hasn't been tampered with. Unfortunately, given that our threat model is "other code running as the same user", this isn't a safe assumption.
|
||||||
|
|
||||||
|
The solution to this problem is probably just to encrypt the entire database. This introduces a bit of complexity since certain settings, like `start_on_login` and `start_minimized`, will need to be accessible before the app is unlocked,but these settings can probably just be stashed alongside the database and kept in sync on every config save.
|
||||||
|
25
doc/todo.md
Normal file
25
doc/todo.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
## Definitely
|
||||||
|
|
||||||
|
* ~~Switch to "process" provider for AWS credentials (much less hacky)~~
|
||||||
|
* ~~Frontend needs to react when request is cancelled from backend~~
|
||||||
|
* ~~Session timeout~~
|
||||||
|
* ~~Fix rehide behavior when new request comes in while old one is still being resolved~~
|
||||||
|
* ~~Switch tray menu item to Hide when window is visible~~
|
||||||
|
* Clear password input after unlock fails
|
||||||
|
* Indicate on approval screen when additional requests are pending
|
||||||
|
* Additional hotkey configuration (approve/deny at the very least)
|
||||||
|
* Logging
|
||||||
|
* Icon
|
||||||
|
* Auto-updates
|
||||||
|
* SSH key handling
|
||||||
|
* Encrypted sync server
|
||||||
|
|
||||||
|
## Maybe
|
||||||
|
|
||||||
|
* Flatten error type hierarchy
|
||||||
|
* Rehide after terminal launch from locked
|
||||||
|
* Generalize Request across both credentials and terminal launch?
|
||||||
|
* Make hotkey configuration a little more tolerant of slight mistiming
|
||||||
|
* Distinguish between request that was denied and request that was canceled (e.g. due to error)
|
||||||
|
* Use atomic types for primitive state values instead of RwLock'd types
|
||||||
|
* Rework approval flow to be a fullscreen overlay instead of mixing with normal navigation (as more views are added the pain of the current situation will only increase)
|
2032
package-lock.json
generated
2032
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "creddy",
|
"name": "creddy",
|
||||||
"version": "0.3.0",
|
"version": "0.4.9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^1.0.1",
|
"@sveltejs/vite-plugin-svelte": "^1.0.1",
|
||||||
"@tauri-apps/cli": "^1.0.5",
|
"@tauri-apps/cli": "^2.0.0-beta.20",
|
||||||
"autoprefixer": "^10.4.8",
|
"autoprefixer": "^10.4.8",
|
||||||
"postcss": "^8.4.16",
|
"postcss": "^8.4.16",
|
||||||
"svelte": "^3.49.0",
|
"svelte": "^3.49.0",
|
||||||
@ -17,7 +17,9 @@
|
|||||||
"vite": "^3.0.7"
|
"vite": "^3.0.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^1.0.2",
|
"@tauri-apps/api": "^2.0.0-beta.13",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.0.0-beta.5",
|
||||||
|
"@tauri-apps/plugin-os": "^2.0.0-beta.5",
|
||||||
"daisyui": "^2.51.5"
|
"daisyui": "^2.51.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
DATABASE_URL=sqlite://C:/Users/Joe/AppData/Roaming/creddy/creddy.dev.db
|
|
2638
src-tauri/Cargo.lock
generated
2638
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "creddy"
|
name = "creddy"
|
||||||
version = "0.3.0"
|
version = "0.4.9"
|
||||||
description = "A friendly AWS credentials manager"
|
description = "A friendly AWS credentials manager"
|
||||||
authors = ["Joseph Montanaro"]
|
authors = ["Joseph Montanaro"]
|
||||||
license = ""
|
license = ""
|
||||||
@ -20,17 +20,15 @@ path = "src/main.rs"
|
|||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "1.0.4", features = [] }
|
tauri-build = { version = "2.0.0-beta", 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", "dialog-open", "global-shortcut", "os-all", "system-tray"] }
|
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
|
||||||
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"] }
|
||||||
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
|
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
|
||||||
netstat2 = "0.9.1"
|
|
||||||
sysinfo = "0.26.8"
|
sysinfo = "0.26.8"
|
||||||
aws-types = "0.52.0"
|
aws-types = "0.52.0"
|
||||||
aws-sdk-sts = "0.22.0"
|
aws-sdk-sts = "0.22.0"
|
||||||
@ -47,6 +45,11 @@ 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"
|
which = "4.4.0"
|
||||||
|
windows = { version = "0.51.1", features = ["Win32_Foundation", "Win32_System_Pipes"] }
|
||||||
|
time = "0.3.31"
|
||||||
|
tauri-plugin-single-instance = "2.0.0-beta.9"
|
||||||
|
tauri-plugin-global-shortcut = "2.0.0-beta.6"
|
||||||
|
rfd = "0.14.1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
|
17
src-tauri/capabilities/migrated.json
Normal file
17
src-tauri/capabilities/migrated.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"identifier": "migrated",
|
||||||
|
"description": "permissions that were migrated from v1",
|
||||||
|
"local": true,
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"path:default",
|
||||||
|
"event:default",
|
||||||
|
"window:default",
|
||||||
|
"app:default",
|
||||||
|
"resources:default",
|
||||||
|
"menu:default",
|
||||||
|
"tray:default"
|
||||||
|
]
|
||||||
|
}
|
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"migrated":{"identifier":"migrated","description":"permissions that were migrated from v1","local":true,"windows":["main"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default"]}}
|
2243
src-tauri/gen/schemas/desktop-schema.json
Normal file
2243
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2243
src-tauri/gen/schemas/linux-schema.json
Normal file
2243
src-tauri/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,11 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use once_cell::sync::OnceCell;
|
use once_cell::sync::OnceCell;
|
||||||
|
use rfd::{
|
||||||
|
MessageDialog,
|
||||||
|
MessageLevel,
|
||||||
|
};
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
SqlitePool,
|
SqlitePool,
|
||||||
sqlite::SqlitePoolOptions,
|
sqlite::SqlitePoolOptions,
|
||||||
@ -9,9 +14,12 @@ use sqlx::{
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
App,
|
App,
|
||||||
AppHandle,
|
AppHandle,
|
||||||
Manager,
|
|
||||||
async_runtime as rt,
|
async_runtime as rt,
|
||||||
|
Manager,
|
||||||
|
RunEvent,
|
||||||
|
WindowEvent,
|
||||||
};
|
};
|
||||||
|
use tauri::menu::MenuItem;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{self, AppConfig},
|
config::{self, AppConfig},
|
||||||
@ -19,6 +27,7 @@ use crate::{
|
|||||||
ipc,
|
ipc,
|
||||||
server::Server,
|
server::Server,
|
||||||
errors::*,
|
errors::*,
|
||||||
|
shortcuts,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
tray,
|
tray,
|
||||||
};
|
};
|
||||||
@ -30,31 +39,40 @@ pub static APP: OnceCell<AppHandle> = OnceCell::new();
|
|||||||
pub fn run() -> tauri::Result<()> {
|
pub fn run() -> tauri::Result<()> {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||||
app.get_window("main")
|
show_main_window(app)
|
||||||
.map(|w| w.show().error_popup("Failed to show main window"));
|
.error_popup("Failed to show main window")
|
||||||
}))
|
}))
|
||||||
.system_tray(tray::create())
|
.plugin(tauri_plugin_global_shortcut::Builder::default().build())
|
||||||
.on_system_tray_event(tray::handle_event)
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
ipc::unlock,
|
ipc::unlock,
|
||||||
ipc::respond,
|
ipc::respond,
|
||||||
ipc::get_session_status,
|
ipc::get_session_status,
|
||||||
|
ipc::signal_activity,
|
||||||
ipc::save_credentials,
|
ipc::save_credentials,
|
||||||
ipc::get_config,
|
ipc::get_config,
|
||||||
ipc::save_config,
|
ipc::save_config,
|
||||||
ipc::launch_terminal,
|
ipc::launch_terminal,
|
||||||
|
ipc::get_setup_errors,
|
||||||
])
|
])
|
||||||
.setup(|app| rt::block_on(setup(app)))
|
.setup(|app| {
|
||||||
|
let res = rt::block_on(setup(app));
|
||||||
|
if let Err(ref e) = res {
|
||||||
|
MessageDialog::new()
|
||||||
|
.set_level(MessageLevel::Error)
|
||||||
|
.set_title("Creddy failed to start")
|
||||||
|
.set_description(format!("{e}"))
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
res
|
||||||
|
})
|
||||||
.build(tauri::generate_context!())?
|
.build(tauri::generate_context!())?
|
||||||
.run(|app, run_event| match run_event {
|
.run(|app, run_event| {
|
||||||
tauri::RunEvent::WindowEvent { label, event, .. } => match event {
|
if let RunEvent::WindowEvent { event, .. } = run_event {
|
||||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
let _ = app.get_window(&label).map(|w| w.hide());
|
let _ = hide_main_window(app);
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
}
|
}
|
||||||
_ => ()
|
|
||||||
}
|
}
|
||||||
_ => ()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -73,25 +91,98 @@ pub async fn connect_db() -> Result<SqlitePool, SetupError> {
|
|||||||
|
|
||||||
|
|
||||||
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
|
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
|
||||||
APP.set(app.handle()).unwrap();
|
APP.set(app.handle().clone()).unwrap();
|
||||||
|
tray::setup(app)?;
|
||||||
let is_first_launch = config::get_or_create_db_path()?.exists();
|
// get_or_create_db_path doesn't create the actual db file, just the directory
|
||||||
|
let is_first_launch = !config::get_or_create_db_path()?.exists();
|
||||||
let pool = connect_db().await?;
|
let pool = connect_db().await?;
|
||||||
let conf = AppConfig::load(&pool).await?;
|
let mut setup_errors: Vec<String> = vec![];
|
||||||
|
|
||||||
|
let mut conf = match AppConfig::load(&pool).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(SetupError::ConfigParseError(_)) => {
|
||||||
|
setup_errors.push(
|
||||||
|
"Could not load configuration from database. Reverting to defaults.".into()
|
||||||
|
);
|
||||||
|
AppConfig::default()
|
||||||
|
},
|
||||||
|
err => err?,
|
||||||
|
};
|
||||||
|
|
||||||
let session = Session::load(&pool).await?;
|
let session = Session::load(&pool).await?;
|
||||||
let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?;
|
Server::start(app.handle().clone())?;
|
||||||
|
|
||||||
config::set_auto_launch(conf.start_on_login)?;
|
config::set_auto_launch(conf.start_on_login)?;
|
||||||
config::register_hotkeys(&conf.hotkeys)?;
|
if let Err(_e) = config::set_auto_launch(conf.start_on_login) {
|
||||||
// if session is empty, this is probably the first launch, so don't autohide
|
setup_errors.push("Error: Failed to manage autolaunch.".into());
|
||||||
if !conf.start_minimized || is_first_launch {
|
|
||||||
app.get_window("main")
|
|
||||||
.ok_or(HandlerError::NoMainWindow)?
|
|
||||||
.show()?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = AppState::new(conf, session, srv, pool);
|
// 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 {
|
||||||
|
show_main_window(&app.handle())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState::new(conf, session, pool, setup_errors, desktop_is_gnome);
|
||||||
app.manage(state);
|
app.manage(state);
|
||||||
|
|
||||||
|
// make sure we do this after managing app state, so that it doesn't panic
|
||||||
|
start_auto_locker(app.app_handle().clone());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn start_auto_locker(app: AppHandle) {
|
||||||
|
rt::spawn(async move {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
loop {
|
||||||
|
// this gives our session-timeout a minimum resolution of 10s, which seems fine?
|
||||||
|
let delay = Duration::from_secs(10);
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
|
||||||
|
if state.should_auto_lock().await {
|
||||||
|
state.lock().await.error_popup("Failed to lock Creddy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn show_main_window(app: &AppHandle) -> Result<(), WindowError> {
|
||||||
|
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
|
||||||
|
w.show()?;
|
||||||
|
let show_hide = app.state::<MenuItem<tauri::Wry>>();
|
||||||
|
show_hide.set_text("Hide")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn hide_main_window(app: &AppHandle) -> Result<(), WindowError> {
|
||||||
|
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
|
||||||
|
w.hide()?;
|
||||||
|
let show_hide = app.state::<MenuItem<tauri::Wry>>();
|
||||||
|
show_hide.set_text("Show")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn toggle_main_window(app: &AppHandle) -> Result<(), WindowError> {
|
||||||
|
let w = app.get_webview_window("main").ok_or(WindowError::NoMainWindow)?;
|
||||||
|
if w.is_visible()? {
|
||||||
|
hide_main_window(app)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
show_main_window(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -19,13 +19,15 @@ fn main() {
|
|||||||
|
|
||||||
let res = match args.subcommand() {
|
let res = match args.subcommand() {
|
||||||
None | Some(("run", _)) => launch_gui(),
|
None | Some(("run", _)) => launch_gui(),
|
||||||
Some(("show", m)) => cli::show(m),
|
Some(("get", m)) => cli::get(m),
|
||||||
Some(("exec", m)) => cli::exec(m),
|
Some(("exec", m)) => cli::exec(m),
|
||||||
_ => unreachable!(),
|
Some(("shortcut", m)) => cli::invoke_shortcut(m),
|
||||||
|
_ => unreachable!("Unknown subcommand"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = res {
|
if let Err(e) = res {
|
||||||
eprintln!("Error: {e}");
|
eprintln!("Error: {e}");
|
||||||
|
process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,36 +1,46 @@
|
|||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::process::Command as ChildCommand;
|
use std::process::Command as ChildCommand;
|
||||||
#[cfg(unix)]
|
#[cfg(windows)]
|
||||||
use std::os::unix::process::CommandExt;
|
use std::time::Duration;
|
||||||
|
|
||||||
use clap::{
|
use clap::{
|
||||||
Command,
|
Command,
|
||||||
Arg,
|
Arg,
|
||||||
ArgMatches,
|
ArgMatches,
|
||||||
ArgAction
|
ArgAction,
|
||||||
|
builder::PossibleValuesParser,
|
||||||
};
|
};
|
||||||
use tokio::{
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
net::TcpStream,
|
|
||||||
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,
|
||||||
|
tokio::net::UnixStream,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
use crate::app;
|
use {
|
||||||
use crate::config::AppConfig;
|
tokio::net::windows::named_pipe::{NamedPipeClient, ClientOptions},
|
||||||
use crate::credentials::{BaseCredentials, SessionCredentials};
|
windows::Win32::Foundation::ERROR_PIPE_BUSY,
|
||||||
use crate::errors::*;
|
};
|
||||||
|
|
||||||
|
|
||||||
pub fn parser() -> Command<'static> {
|
pub fn parser() -> Command<'static> {
|
||||||
Command::new("creddy")
|
Command::new("creddy")
|
||||||
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.about("A friendly AWS credentials manager")
|
.about("A friendly AWS credentials manager")
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("run")
|
Command::new("run")
|
||||||
.about("Launch Creddy")
|
.about("Launch Creddy")
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("show")
|
Command::new("get")
|
||||||
.about("Fetch and display AWS credentials")
|
.about("Request AWS credentials from Creddy and output to stdout")
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("base")
|
Arg::new("base")
|
||||||
.short('b')
|
.short('b')
|
||||||
@ -55,13 +65,26 @@ pub fn parser() -> Command<'static> {
|
|||||||
.multiple_values(true)
|
.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"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn show(args: &ArgMatches) -> Result<(), CliError> {
|
pub fn get(args: &ArgMatches) -> Result<(), CliError> {
|
||||||
let base = args.get_one("base").unwrap_or(&false);
|
let base = args.get_one("base").unwrap_or(&false);
|
||||||
let creds = get_credentials(*base)?;
|
let output = match get_credentials(*base)? {
|
||||||
println!("{creds}");
|
Credentials::Base(creds) => serde_json::to_string(&creds).unwrap(),
|
||||||
|
Credentials::Session(creds) => serde_json::to_string(&creds).unwrap(),
|
||||||
|
};
|
||||||
|
println!("{output}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,18 +98,16 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
|
|||||||
let mut cmd = ChildCommand::new(cmd_name);
|
let mut cmd = ChildCommand::new(cmd_name);
|
||||||
cmd.args(cmd_line);
|
cmd.args(cmd_line);
|
||||||
|
|
||||||
if base {
|
match get_credentials(base)? {
|
||||||
let creds: BaseCredentials = serde_json::from_str(&get_credentials(base)?)
|
Credentials::Base(creds) => {
|
||||||
.map_err(|_| RequestError::InvalidJson)?;
|
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||||
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||||
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
},
|
||||||
}
|
Credentials::Session(creds) => {
|
||||||
else {
|
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
||||||
let creds: SessionCredentials = serde_json::from_str(&get_credentials(base)?)
|
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
||||||
.map_err(|_| RequestError::InvalidJson)?;
|
cmd.env("AWS_SESSION_TOKEN", creds.session_token);
|
||||||
cmd.env("AWS_ACCESS_KEY_ID", creds.access_key_id);
|
}
|
||||||
cmd.env("AWS_SECRET_ACCESS_KEY", creds.secret_access_key);
|
|
||||||
cmd.env("AWS_SESSION_TOKEN", creds.token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@ -98,7 +119,7 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
|
|||||||
let name: OsString = cmd_name.into();
|
let name: OsString = cmd_name.into();
|
||||||
Err(ExecError::NotFound(name).into())
|
Err(ExecError::NotFound(name).into())
|
||||||
}
|
}
|
||||||
e => Err(ExecError::ExecutionFailed(e).into()),
|
_ => Err(ExecError::ExecutionFailed(e).into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,41 +141,63 @@ pub fn exec(args: &ArgMatches) -> Result<(), CliError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tokio::main]
|
pub fn invoke_shortcut(args: &ArgMatches) -> Result<(), CliError> {
|
||||||
async fn get_credentials(base: bool) -> Result<String, RequestError> {
|
let action = match args.get_one::<String>("action").map(|s| s.as_str()) {
|
||||||
let pool = app::connect_db().await?;
|
Some("show_window") => ShortcutAction::ShowWindow,
|
||||||
let config = AppConfig::load(&pool).await?;
|
Some("launch_terminal") => ShortcutAction::LaunchTerminal,
|
||||||
let path = if base {"/creddy/base-credentials"} else {"/"};
|
Some(&_) | None => unreachable!("Unknown shortcut action"), // guaranteed by clap
|
||||||
|
};
|
||||||
|
|
||||||
let mut stream = TcpStream::connect((config.listen_addr, config.listen_port)).await?;
|
let req = Request::InvokeShortcut(action);
|
||||||
let req = format!("GET {path} HTTP/1.0\r\n\r\n");
|
match make_request(&req) {
|
||||||
stream.write_all(req.as_bytes()).await?;
|
Ok(Response::Empty) => Ok(()),
|
||||||
|
Ok(r) => Err(RequestError::Unexpected(r).into()),
|
||||||
// some day we'll have a proper HTTP parser
|
Err(e) => Err(e.into()),
|
||||||
let mut buf = vec![0; 8192];
|
|
||||||
stream.read_to_end(&mut buf).await?;
|
|
||||||
|
|
||||||
let status = buf.split(|&c| &[c] == b" ")
|
|
||||||
.skip(1)
|
|
||||||
.next()
|
|
||||||
.ok_or(RequestError::MalformedHttpResponse)?;
|
|
||||||
|
|
||||||
if status != b"200" {
|
|
||||||
let s = String::from_utf8_lossy(status).to_string();
|
|
||||||
return Err(RequestError::Failed(s));
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let break_idx = buf.windows(4)
|
|
||||||
.position(|w| w == b"\r\n\r\n")
|
|
||||||
.ok_or(RequestError::MalformedHttpResponse)?;
|
fn get_credentials(base: bool) -> Result<Credentials, RequestError> {
|
||||||
let body = &buf[(break_idx + 4)..];
|
let req = Request::GetAwsCredentials { base };
|
||||||
|
match make_request(&req) {
|
||||||
let creds_str = std::str::from_utf8(body)
|
Ok(Response::Aws(creds)) => Ok(creds),
|
||||||
.map_err(|_| RequestError::MalformedHttpResponse)?
|
Ok(r) => Err(RequestError::Unexpected(r)),
|
||||||
.to_string();
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
if creds_str == "Denied!" {
|
}
|
||||||
return Err(RequestError::Rejected);
|
|
||||||
}
|
|
||||||
Ok(creds_str)
|
#[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');
|
||||||
|
|
||||||
|
let mut stream = connect().await?;
|
||||||
|
stream.write_all(&data.as_bytes()).await?;
|
||||||
|
|
||||||
|
let mut buf = Vec::with_capacity(1024);
|
||||||
|
stream.read_to_end(&mut buf).await?;
|
||||||
|
let res: Result<Response, ServerError> = serde_json::from_slice(&buf)?;
|
||||||
|
Ok(res?)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
async fn connect() -> Result<NamedPipeClient, std::io::Error> {
|
||||||
|
// apparently attempting to connect can fail if there's already a client connected
|
||||||
|
loop {
|
||||||
|
match ClientOptions::new().open(r"\\.\pipe\creddy-requests") {
|
||||||
|
Ok(stream) => return Ok(stream),
|
||||||
|
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => (),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn connect() -> Result<UnixStream, std::io::Error> {
|
||||||
|
UnixStream::connect("/tmp/creddy.sock").await
|
||||||
}
|
}
|
||||||
|
@ -1,76 +1,35 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
|
|
||||||
use tauri::Manager;
|
|
||||||
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
|
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::errors::*;
|
||||||
app::APP,
|
|
||||||
errors::*,
|
|
||||||
config::AppConfig,
|
|
||||||
state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
pub exe: PathBuf,
|
pub exe: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
|
pub fn get_process_parent_info(pid: u32) -> Result<Client, ClientInfoError> {
|
||||||
let state = APP.get().unwrap().state::<AppState>();
|
let sys_pid = Pid::from_u32(pid);
|
||||||
let AppConfig {
|
let mut sys = System::new();
|
||||||
listen_addr: app_listen_addr,
|
sys.refresh_process(sys_pid);
|
||||||
listen_port: app_listen_port,
|
let proc = sys.process(sys_pid)
|
||||||
..
|
.ok_or(ClientInfoError::ProcessNotFound)?;
|
||||||
} = *state.config.read().await;
|
|
||||||
|
|
||||||
let sockets_iter = netstat2::iterate_sockets_info(
|
let parent_pid_sys = proc.parent()
|
||||||
AddressFamilyFlags::IPV4,
|
.ok_or(ClientInfoError::ParentPidNotFound)?;
|
||||||
ProtocolFlags::TCP
|
sys.refresh_process(parent_pid_sys);
|
||||||
)?;
|
let parent = sys.process(parent_pid_sys)
|
||||||
for item in sockets_iter {
|
.ok_or(ClientInfoError::ParentProcessNotFound)?;
|
||||||
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
|
let exe = match parent.exe() {
|
||||||
&& proto_info.remote_port == app_listen_port
|
p if p == Path::new("") => None,
|
||||||
&& proto_info.local_addr == app_listen_addr
|
p => Some(PathBuf::from(p)),
|
||||||
&& proto_info.remote_addr == app_listen_addr
|
};
|
||||||
{
|
|
||||||
return Ok(sock_info.associated_pids)
|
Ok(Client { pid: parent_pid_sys.as_u32(), exe })
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
use std::net::Ipv4Addr;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use auto_launch::AutoLaunchBuilder;
|
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::*;
|
||||||
|
|
||||||
@ -39,15 +34,22 @@ pub struct HotkeysConfig {
|
|||||||
pub launch_terminal: Hotkey,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
#[serde(default = "default_listen_addr")]
|
|
||||||
pub listen_addr: Ipv4Addr,
|
|
||||||
#[serde(default = "default_listen_port")]
|
|
||||||
pub listen_port: u16,
|
|
||||||
#[serde(default = "default_rehide_ms")]
|
#[serde(default = "default_rehide_ms")]
|
||||||
pub rehide_ms: u64,
|
pub rehide_ms: u64,
|
||||||
|
#[serde(default = "default_auto_lock")]
|
||||||
|
pub auto_lock: bool,
|
||||||
|
#[serde(default = "default_lock_after")]
|
||||||
|
pub lock_after: Duration,
|
||||||
#[serde(default = "default_start_minimized")]
|
#[serde(default = "default_start_minimized")]
|
||||||
pub start_minimized: bool,
|
pub start_minimized: bool,
|
||||||
#[serde(default = "default_start_on_login")]
|
#[serde(default = "default_start_on_login")]
|
||||||
@ -62,9 +64,9 @@ pub struct AppConfig {
|
|||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
AppConfig {
|
AppConfig {
|
||||||
listen_addr: default_listen_addr(),
|
|
||||||
listen_port: default_listen_port(),
|
|
||||||
rehide_ms: default_rehide_ms(),
|
rehide_ms: default_rehide_ms(),
|
||||||
|
auto_lock: default_auto_lock(),
|
||||||
|
lock_after: default_lock_after(),
|
||||||
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(),
|
terminal: default_term_config(),
|
||||||
@ -144,16 +146,6 @@ pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn default_listen_port() -> u16 {
|
|
||||||
if cfg!(debug_assertions) {
|
|
||||||
12_345
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
19_923
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fn default_term_config() -> TermConfig {
|
fn default_term_config() -> TermConfig {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@ -200,46 +192,32 @@ 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_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
|
|
||||||
fn default_rehide_ms() -> u64 { 1000 }
|
fn default_rehide_ms() -> u64 { 1000 }
|
||||||
|
fn default_auto_lock() -> bool { true }
|
||||||
|
fn default_lock_after() -> Duration { Duration::from_secs(43200) }
|
||||||
// start minimized and on login only in production mode
|
// start minimized and on login only in production mode
|
||||||
fn default_start_minimized() -> bool { !cfg!(debug_assertions) }
|
fn default_start_minimized() -> bool { !cfg!(debug_assertions) }
|
||||||
fn default_start_on_login() -> bool { !cfg!(debug_assertions) }
|
fn default_start_on_login() -> bool { !cfg!(debug_assertions) }
|
||||||
|
|
||||||
|
|
||||||
|
// struct DurationVisitor;
|
||||||
|
|
||||||
|
// impl<'de> Visitor<'de> for DurationVisitor {
|
||||||
|
// type Value = Duration;
|
||||||
|
|
||||||
|
// fn expecting(&self, formatter: &mut Formatter) -> fmt::Result {
|
||||||
|
// write!(formatter, "an integer between 0 and 2^64 - 1")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// fn visit_u64<E: de::Error>(self, v: u64) -> Result<Duration, E> {
|
||||||
|
// Ok(Duration::from_secs(v))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// fn duration_from_secs<'de, D>(deserializer: D) -> Result<Duration, D::Error>
|
||||||
|
// where D: Deserializer<'de>
|
||||||
|
// {
|
||||||
|
// deserializer.deserialize_u64(DurationVisitor)
|
||||||
|
// }
|
||||||
|
@ -126,23 +126,31 @@ impl LockedCredentials {
|
|||||||
let secret_access_key = String::from_utf8(decrypted)
|
let secret_access_key = String::from_utf8(decrypted)
|
||||||
.map_err(|_| UnlockError::InvalidUtf8)?;
|
.map_err(|_| UnlockError::InvalidUtf8)?;
|
||||||
|
|
||||||
let creds = BaseCredentials {
|
let creds = BaseCredentials::new(
|
||||||
access_key_id: self.access_key_id.clone(),
|
self.access_key_id.clone(),
|
||||||
secret_access_key,
|
secret_access_key,
|
||||||
};
|
);
|
||||||
Ok(creds)
|
Ok(creds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn default_credentials_version() -> usize { 1 }
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
pub struct BaseCredentials {
|
pub struct BaseCredentials {
|
||||||
|
#[serde(default = "default_credentials_version")]
|
||||||
|
pub version: usize,
|
||||||
pub access_key_id: String,
|
pub access_key_id: String,
|
||||||
pub secret_access_key: String,
|
pub secret_access_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BaseCredentials {
|
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> {
|
pub fn encrypt(&self, passphrase: &str) -> Result<LockedCredentials, CryptoError> {
|
||||||
let salt = Crypto::salt();
|
let salt = Crypto::salt();
|
||||||
let crypto = Crypto::new(passphrase, &salt)?;
|
let crypto = Crypto::new(passphrase, &salt)?;
|
||||||
@ -162,9 +170,11 @@ impl BaseCredentials {
|
|||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
pub struct SessionCredentials {
|
pub struct SessionCredentials {
|
||||||
|
#[serde(default = "default_credentials_version")]
|
||||||
|
pub version: usize,
|
||||||
pub access_key_id: String,
|
pub access_key_id: String,
|
||||||
pub secret_access_key: String,
|
pub secret_access_key: String,
|
||||||
pub token: String,
|
pub session_token: String,
|
||||||
#[serde(serialize_with = "serialize_expiration")]
|
#[serde(serialize_with = "serialize_expiration")]
|
||||||
#[serde(deserialize_with = "deserialize_expiration")]
|
#[serde(deserialize_with = "deserialize_expiration")]
|
||||||
pub expiration: DateTime,
|
pub expiration: DateTime,
|
||||||
@ -198,7 +208,7 @@ impl SessionCredentials {
|
|||||||
let secret_access_key = aws_session.secret_access_key()
|
let secret_access_key = aws_session.secret_access_key()
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
.ok_or(GetSessionError::EmptyResponse)?
|
||||||
.to_string();
|
.to_string();
|
||||||
let token = aws_session.session_token()
|
let session_token = aws_session.session_token()
|
||||||
.ok_or(GetSessionError::EmptyResponse)?
|
.ok_or(GetSessionError::EmptyResponse)?
|
||||||
.to_string();
|
.to_string();
|
||||||
let expiration = aws_session.expiration()
|
let expiration = aws_session.expiration()
|
||||||
@ -206,9 +216,10 @@ impl SessionCredentials {
|
|||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
let session_creds = SessionCredentials {
|
let session_creds = SessionCredentials {
|
||||||
|
version: 1,
|
||||||
access_key_id,
|
access_key_id,
|
||||||
secret_access_key,
|
secret_access_key,
|
||||||
token,
|
session_token,
|
||||||
expiration,
|
expiration,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -230,6 +241,14 @@ impl SessionCredentials {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum Credentials {
|
||||||
|
Base(BaseCredentials),
|
||||||
|
Session(SessionCredentials),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
|
fn serialize_expiration<S>(exp: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
where S: Serializer
|
where S: Serializer
|
||||||
{
|
{
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::convert::AsRef;
|
use std::convert::AsRef;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::sync::mpsc;
|
use std::string::FromUtf8Error;
|
||||||
use strum_macros::AsRefStr;
|
use strum_macros::AsRefStr;
|
||||||
|
|
||||||
use thiserror::Error as ThisError;
|
use thiserror::Error as ThisError;
|
||||||
@ -9,30 +9,54 @@ use aws_sdk_sts::{
|
|||||||
types::SdkError as AwsSdkError,
|
types::SdkError as AwsSdkError,
|
||||||
error::GetSessionTokenError,
|
error::GetSessionTokenError,
|
||||||
};
|
};
|
||||||
|
use rfd::{
|
||||||
|
AsyncMessageDialog,
|
||||||
|
MessageLevel,
|
||||||
|
};
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
error::Error as SqlxError,
|
error::Error as SqlxError,
|
||||||
migrate::MigrateError,
|
migrate::MigrateError,
|
||||||
};
|
};
|
||||||
use tauri::api::dialog::{
|
use tauri::async_runtime as rt;
|
||||||
MessageDialogBuilder,
|
use tauri_plugin_global_shortcut::Error as ShortcutError;
|
||||||
MessageDialogKind,
|
use tokio::sync::oneshot::error::RecvError;
|
||||||
|
use serde::{
|
||||||
|
Serialize,
|
||||||
|
Serializer,
|
||||||
|
ser::SerializeMap,
|
||||||
|
Deserialize,
|
||||||
};
|
};
|
||||||
use serde::{Serialize, Serializer, ser::SerializeMap};
|
|
||||||
|
|
||||||
|
|
||||||
pub trait ErrorPopup {
|
pub trait ShowError<T, E>
|
||||||
|
{
|
||||||
fn error_popup(self, title: &str);
|
fn error_popup(self, title: &str);
|
||||||
|
fn error_print(self);
|
||||||
|
fn error_print_prefix(self, prefix: &str);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E: Error> ErrorPopup for Result<(), E> {
|
impl<T, E> ShowError<T, E> for Result<T, E>
|
||||||
|
where E: std::fmt::Display
|
||||||
|
{
|
||||||
fn error_popup(self, title: &str) {
|
fn error_popup(self, title: &str) {
|
||||||
if let Err(e) = self {
|
if let Err(e) = self {
|
||||||
let (tx, rx) = mpsc::channel();
|
let dialog = AsyncMessageDialog::new()
|
||||||
MessageDialogBuilder::new(title, format!("{e}"))
|
.set_level(MessageLevel::Error)
|
||||||
.kind(MessageDialogKind::Error)
|
.set_title(title)
|
||||||
.show(move |_| tx.send(true).unwrap());
|
.set_description(format!("{e}"));
|
||||||
|
rt::spawn(async move {dialog.show().await});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rx.recv().unwrap();
|
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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,15 +77,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>
|
fn serialize_upstream_err<E, M>(err: &E, map: &mut M) -> Result<(), M::Error>
|
||||||
where
|
where
|
||||||
E: Error,
|
E: Error,
|
||||||
M: serde::ser::SerializeMap,
|
M: serde::ser::SerializeMap,
|
||||||
{
|
{
|
||||||
let msg = err.source().map(|s| format!("{s}"));
|
// let msg = err.source().map(|s| format!("{s}"));
|
||||||
map.serialize_entry("msg", &msg)?;
|
// map.serialize_entry("msg", &msg)?;
|
||||||
map.serialize_entry("code", &None::<&str>)?;
|
// map.serialize_entry("code", &None::<&str>)?;
|
||||||
map.serialize_entry("source", &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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -96,7 +138,7 @@ pub enum SetupError {
|
|||||||
#[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}")]
|
#[error("Failed to register hotkeys: {0}")]
|
||||||
RegisterHotkeys(#[from] tauri::Error),
|
RegisterHotkeys(#[from] ShortcutError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -116,22 +158,28 @@ pub enum SendResponseError {
|
|||||||
NotFound,
|
NotFound,
|
||||||
#[error("The specified request was already closed by the client")]
|
#[error("The specified request was already closed by the client")]
|
||||||
Abandoned,
|
Abandoned,
|
||||||
|
#[error("A response has already been received for the specified request")]
|
||||||
|
Fulfilled,
|
||||||
#[error("Could not renew AWS sesssion: {0}")]
|
#[error("Could not renew AWS sesssion: {0}")]
|
||||||
SessionRenew(#[from] GetSessionError),
|
SessionRenew(#[from] GetSessionError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// errors encountered while handling an HTTP request
|
// errors encountered while handling a client request
|
||||||
#[derive(Debug, ThisError, AsRefStr)]
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
pub enum HandlerError {
|
pub enum HandlerError {
|
||||||
#[error("Error writing to stream: {0}")]
|
#[error("Error writing to stream: {0}")]
|
||||||
StreamIOError(#[from] std::io::Error),
|
StreamIOError(#[from] std::io::Error),
|
||||||
// #[error("Received invalid UTF-8 in request")]
|
#[error("Received invalid UTF-8 in request")]
|
||||||
// InvalidUtf8,
|
InvalidUtf8(#[from] FromUtf8Error),
|
||||||
#[error("HTTP request malformed")]
|
#[error("HTTP request malformed")]
|
||||||
BadRequest(Vec<u8>),
|
BadRequest(#[from] serde_json::Error),
|
||||||
#[error("HTTP request too large")]
|
#[error("HTTP request too large")]
|
||||||
RequestTooLarge,
|
RequestTooLarge,
|
||||||
|
#[error("Connection closed early by client")]
|
||||||
|
Abandoned,
|
||||||
|
#[error("Internal server error")]
|
||||||
|
Internal(#[from] RecvError),
|
||||||
#[error("Error accessing credentials: {0}")]
|
#[error("Error accessing credentials: {0}")]
|
||||||
NoCredentials(#[from] GetCredentialsError),
|
NoCredentials(#[from] GetCredentialsError),
|
||||||
#[error("Error getting client details: {0}")]
|
#[error("Error getting client details: {0}")]
|
||||||
@ -140,6 +188,17 @@ pub enum HandlerError {
|
|||||||
Tauri(#[from] tauri::Error),
|
Tauri(#[from] tauri::Error),
|
||||||
#[error("No main application window found")]
|
#[error("No main application window found")]
|
||||||
NoMainWindow,
|
NoMainWindow,
|
||||||
|
#[error("Request was denied")]
|
||||||
|
Denied,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum WindowError {
|
||||||
|
#[error("Failed to find main application window")]
|
||||||
|
NoMainWindow,
|
||||||
|
#[error(transparent)]
|
||||||
|
ManageFailure(#[from] tauri::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -158,7 +217,7 @@ pub enum GetSessionError {
|
|||||||
EmptyResponse, // SDK returned successfully but credentials are None
|
EmptyResponse, // SDK returned successfully but credentials are None
|
||||||
#[error("Error response from AWS SDK: {0}")]
|
#[error("Error response from AWS SDK: {0}")]
|
||||||
SdkError(#[from] AwsSdkError<GetSessionTokenError>),
|
SdkError(#[from] AwsSdkError<GetSessionTokenError>),
|
||||||
#[error("Could not construt session: credentials are locked")]
|
#[error("Could not construct session: credentials are locked")]
|
||||||
CredentialsLocked,
|
CredentialsLocked,
|
||||||
#[error("Could not construct session: no credentials are known")]
|
#[error("Could not construct session: no credentials are known")]
|
||||||
CredentialsEmpty,
|
CredentialsEmpty,
|
||||||
@ -182,6 +241,19 @@ pub enum UnlockError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
|
pub enum LockError {
|
||||||
|
#[error("App is not unlocked")]
|
||||||
|
NotUnlocked,
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
DbError(#[from] SqlxError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Setup(#[from] SetupError),
|
||||||
|
#[error(transparent)]
|
||||||
|
TauriError(#[from] tauri::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, ThisError, AsRefStr)]
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
pub enum CryptoError {
|
pub enum CryptoError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@ -196,26 +268,50 @@ pub enum CryptoError {
|
|||||||
pub enum ClientInfoError {
|
pub enum ClientInfoError {
|
||||||
#[error("Found PID for client socket, but no corresponding process")]
|
#[error("Found PID for client socket, but no corresponding process")]
|
||||||
ProcessNotFound,
|
ProcessNotFound,
|
||||||
#[error("Couldn't get client socket details: {0}")]
|
#[error("Could not determine parent PID of connected client")]
|
||||||
NetstatError(#[from] netstat2::error::Error),
|
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)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Technically also an error, but formatted as a struct for easy deserialization
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServerError {
|
||||||
|
code: String,
|
||||||
|
msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ServerError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
|
||||||
|
write!(f, "{} ({})", self.msg, self.code)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Errors encountered while requesting credentials via CLI (creddy show, creddy exec)
|
// Errors encountered while requesting credentials via CLI (creddy show, creddy exec)
|
||||||
#[derive(Debug, ThisError, AsRefStr)]
|
#[derive(Debug, ThisError, AsRefStr)]
|
||||||
pub enum RequestError {
|
pub enum RequestError {
|
||||||
#[error("Credentials request failed: HTTP {0}")]
|
#[error("Error response from server: {0}")]
|
||||||
Failed(String),
|
Server(ServerError),
|
||||||
#[error("Credentials request was rejected")]
|
#[error("Unexpected response from server")]
|
||||||
Rejected,
|
Unexpected(crate::server::Response),
|
||||||
#[error("Couldn't interpret the server's response")]
|
|
||||||
MalformedHttpResponse,
|
|
||||||
#[error("The server did not respond with valid JSON")]
|
#[error("The server did not respond with valid JSON")]
|
||||||
InvalidJson,
|
InvalidJson(#[from] serde_json::Error),
|
||||||
#[error("Error reading/writing stream: {0}")]
|
#[error("Error reading/writing stream: {0}")]
|
||||||
StreamIOError(#[from] std::io::Error),
|
StreamIOError(#[from] std::io::Error),
|
||||||
#[error("Error loading configuration data: {0}")]
|
}
|
||||||
Setup(#[from] SetupError),
|
|
||||||
|
impl From<ServerError> for RequestError {
|
||||||
|
fn from(s: ServerError) -> Self {
|
||||||
|
Self::Server(s)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -276,10 +372,11 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
impl_serialize_basic!(SetupError);
|
impl_serialize_basic!(SetupError);
|
||||||
impl_serialize_basic!(GetCredentialsError);
|
impl_serialize_basic!(GetCredentialsError);
|
||||||
impl_serialize_basic!(ClientInfoError);
|
impl_serialize_basic!(ClientInfoError);
|
||||||
|
impl_serialize_basic!(WindowError);
|
||||||
|
impl_serialize_basic!(LockError);
|
||||||
|
|
||||||
|
|
||||||
impl Serialize for HandlerError {
|
impl Serialize for HandlerError {
|
||||||
@ -287,13 +384,6 @@ impl Serialize for HandlerError {
|
|||||||
let mut map = serializer.serialize_map(None)?;
|
let mut map = serializer.serialize_map(None)?;
|
||||||
map.serialize_entry("code", self.as_ref())?;
|
map.serialize_entry("code", self.as_ref())?;
|
||||||
map.serialize_entry("msg", &format!("{self}"))?;
|
map.serialize_entry("msg", &format!("{self}"))?;
|
||||||
|
|
||||||
match self {
|
|
||||||
HandlerError::NoCredentials(src) => map.serialize_entry("source", &src)?,
|
|
||||||
HandlerError::ClientInfo(src) => map.serialize_entry("source", &src)?,
|
|
||||||
_ => serialize_upstream_err(self, &mut map)?,
|
|
||||||
}
|
|
||||||
|
|
||||||
map.end()
|
map.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -342,6 +432,8 @@ impl Serialize for UnlockError {
|
|||||||
|
|
||||||
match self {
|
match self {
|
||||||
UnlockError::GetSession(src) => map.serialize_entry("source", &src)?,
|
UnlockError::GetSession(src) => map.serialize_entry("source", &src)?,
|
||||||
|
// The string representation of the AEAD error is not very helpful, so skip it
|
||||||
|
UnlockError::Crypto(_src) => map.serialize_entry("source", &None::<&str>)?,
|
||||||
_ => serialize_upstream_err(self, &mut map)?,
|
_ => serialize_upstream_err(self, &mut map)?,
|
||||||
}
|
}
|
||||||
map.end()
|
map.end()
|
||||||
|
@ -10,9 +10,9 @@ use crate::terminal;
|
|||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Request {
|
pub struct AwsRequestNotification {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub clients: Vec<Option<Client>>,
|
pub client: Client,
|
||||||
pub base: bool,
|
pub base: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ pub struct Request {
|
|||||||
pub struct RequestResponse {
|
pub struct RequestResponse {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub approval: Approval,
|
pub approval: Approval,
|
||||||
|
pub base: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -55,6 +56,13 @@ pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn signal_activity(app_state: State<'_, AppState>) -> Result<(), ()> {
|
||||||
|
app_state.signal_activity().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_credentials(
|
pub async fn save_credentials(
|
||||||
credentials: BaseCredentials,
|
credentials: BaseCredentials,
|
||||||
@ -85,3 +93,9 @@ pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> R
|
|||||||
pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
|
pub async fn launch_terminal(base: bool) -> Result<(), LaunchTerminalError> {
|
||||||
terminal::launch(base).await
|
terminal::launch(base).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_setup_errors(app_state: State<'_, AppState>) -> Result<Vec<String>, ()> {
|
||||||
|
Ok(app_state.setup_errors.clone())
|
||||||
|
}
|
||||||
|
@ -7,5 +7,6 @@ mod clientinfo;
|
|||||||
mod ipc;
|
mod ipc;
|
||||||
mod state;
|
mod state;
|
||||||
mod server;
|
mod server;
|
||||||
|
mod shortcuts;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
mod tray;
|
mod tray;
|
||||||
|
@ -6,22 +6,24 @@
|
|||||||
use creddy::{
|
use creddy::{
|
||||||
app,
|
app,
|
||||||
cli,
|
cli,
|
||||||
errors::ErrorPopup,
|
errors::ShowError,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let res = match cli::parser().get_matches().subcommand() {
|
let res = match cli::parser().get_matches().subcommand() {
|
||||||
None | Some(("run", _)) => {
|
None | Some(("run", _)) => {
|
||||||
app::run().error_popup("Creddy failed to start");
|
app::run().error_popup("Creddy encountered an error");
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
Some(("show", m)) => cli::show(m),
|
Some(("get", m)) => cli::get(m),
|
||||||
Some(("exec", m)) => cli::exec(m),
|
Some(("exec", m)) => cli::exec(m),
|
||||||
|
Some(("shortcut", m)) => cli::invoke_shortcut(m),
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = res {
|
if let Err(e) = res {
|
||||||
eprintln!("Error: {e}");
|
eprintln!("Error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,243 +0,0 @@
|
|||||||
use core::time::Duration;
|
|
||||||
use std::io;
|
|
||||||
use std::net::{
|
|
||||||
Ipv4Addr,
|
|
||||||
SocketAddr,
|
|
||||||
SocketAddrV4,
|
|
||||||
};
|
|
||||||
use tokio::net::{
|
|
||||||
TcpListener,
|
|
||||||
TcpStream,
|
|
||||||
};
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::sync::oneshot;
|
|
||||||
use tokio::time::sleep;
|
|
||||||
|
|
||||||
use tauri::{AppHandle, Manager};
|
|
||||||
use tauri::async_runtime as rt;
|
|
||||||
use tauri::async_runtime::JoinHandle;
|
|
||||||
|
|
||||||
use crate::{clientinfo, clientinfo::Client};
|
|
||||||
use crate::errors::*;
|
|
||||||
use crate::ipc::{Request, Approval};
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
|
|
||||||
struct Handler {
|
|
||||||
request_id: u64,
|
|
||||||
stream: TcpStream,
|
|
||||||
receiver: Option<oneshot::Receiver<Approval>>,
|
|
||||||
app: AppHandle,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Handler {
|
|
||||||
async fn new(stream: TcpStream, app: AppHandle) -> Self {
|
|
||||||
let state = app.state::<AppState>();
|
|
||||||
let (chan_send, chan_recv) = oneshot::channel();
|
|
||||||
let request_id = state.register_request(chan_send).await;
|
|
||||||
Handler {
|
|
||||||
request_id,
|
|
||||||
stream,
|
|
||||||
receiver: Some(chan_recv),
|
|
||||||
app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle(mut self) {
|
|
||||||
if let Err(e) = self.try_handle().await {
|
|
||||||
eprintln!("{e}");
|
|
||||||
}
|
|
||||||
let state = self.app.state::<AppState>();
|
|
||||||
state.unregister_request(self.request_id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn try_handle(&mut self) -> Result<(), HandlerError> {
|
|
||||||
let req_path = self.recv_request().await?;
|
|
||||||
let clients = self.get_clients().await?;
|
|
||||||
if self.includes_banned(&clients).await {
|
|
||||||
self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?;
|
|
||||||
return Ok(())
|
|
||||||
}
|
|
||||||
let base = req_path == b"/creddy/base-credentials";
|
|
||||||
|
|
||||||
let req = Request {id: self.request_id, clients, base};
|
|
||||||
self.app.emit_all("credentials-request", &req)?;
|
|
||||||
let starting_visibility = self.show_window()?;
|
|
||||||
|
|
||||||
match self.wait_for_response().await? {
|
|
||||||
Approval::Approved => {
|
|
||||||
let state = self.app.state::<AppState>();
|
|
||||||
let creds = if base {
|
|
||||||
state.serialize_base_creds().await?
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
state.serialize_session_creds().await?
|
|
||||||
};
|
|
||||||
self.send_body(creds.as_bytes()).await?;
|
|
||||||
},
|
|
||||||
Approval::Denied => {
|
|
||||||
let state = self.app.state::<AppState>();
|
|
||||||
for client in req.clients {
|
|
||||||
state.add_ban(client).await;
|
|
||||||
}
|
|
||||||
self.send_body(b"Denied!").await?;
|
|
||||||
self.stream.shutdown().await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// only hide the window if a) it was hidden to start with
|
|
||||||
// and b) there are no other pending requests
|
|
||||||
let state = self.app.state::<AppState>();
|
|
||||||
let delay = {
|
|
||||||
let config = state.config.read().await;
|
|
||||||
Duration::from_millis(config.rehide_ms)
|
|
||||||
};
|
|
||||||
sleep(delay).await;
|
|
||||||
|
|
||||||
if !starting_visibility && state.req_count().await == 0 {
|
|
||||||
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?;
|
|
||||||
window.hide()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn recv_request(&mut self) -> Result<Vec<u8>, HandlerError> {
|
|
||||||
let mut buf = vec![0; 8192]; // it's what tokio's BufReader uses
|
|
||||||
let mut n = 0;
|
|
||||||
loop {
|
|
||||||
n += self.stream.read(&mut buf[n..]).await?;
|
|
||||||
if n >= 4 && &buf[(n - 4)..n] == b"\r\n\r\n" {break;}
|
|
||||||
if n == buf.len() {return Err(HandlerError::RequestTooLarge);}
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = buf.split(|&c| &[c] == b" ")
|
|
||||||
.skip(1)
|
|
||||||
.next()
|
|
||||||
.ok_or(HandlerError::BadRequest(buf.clone()))?;
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)] {
|
|
||||||
println!("Path: {}", std::str::from_utf8(&path).unwrap());
|
|
||||||
println!("{}", std::str::from_utf8(&buf).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(path.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_clients(&self) -> Result<Vec<Option<Client>>, HandlerError> {
|
|
||||||
let peer_addr = match self.stream.peer_addr()? {
|
|
||||||
SocketAddr::V4(addr) => addr,
|
|
||||||
_ => unreachable!(), // we only listen on IPv4
|
|
||||||
};
|
|
||||||
let clients = clientinfo::get_clients(peer_addr.port()).await?;
|
|
||||||
Ok(clients)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn includes_banned(&self, clients: &Vec<Option<Client>>) -> bool {
|
|
||||||
let state = self.app.state::<AppState>();
|
|
||||||
for client in clients {
|
|
||||||
if state.is_banned(client).await {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_window(&self) -> Result<bool, HandlerError> {
|
|
||||||
let window = self.app.get_window("main").ok_or(HandlerError::NoMainWindow)?;
|
|
||||||
let starting_visibility = window.is_visible()?;
|
|
||||||
if !starting_visibility {
|
|
||||||
window.unminimize()?;
|
|
||||||
window.show()?;
|
|
||||||
}
|
|
||||||
window.set_focus()?;
|
|
||||||
Ok(starting_visibility)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_response(&mut self) -> Result<Approval, HandlerError> {
|
|
||||||
self.stream.write(b"HTTP/1.0 200 OK\r\n").await?;
|
|
||||||
self.stream.write(b"Content-Type: application/json\r\n").await?;
|
|
||||||
self.stream.write(b"X-Creddy-delaying-tactic: ").await?;
|
|
||||||
|
|
||||||
#[allow(unreachable_code)] // seems necessary for type inference
|
|
||||||
let stall = async {
|
|
||||||
let delay = std::time::Duration::from_secs(1);
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(delay).await;
|
|
||||||
self.stream.write(b"x").await?;
|
|
||||||
}
|
|
||||||
Ok(Approval::Denied)
|
|
||||||
};
|
|
||||||
|
|
||||||
// this is the only place we even read this field, so it's safe to unwrap
|
|
||||||
let receiver = self.receiver.take().unwrap();
|
|
||||||
tokio::select!{
|
|
||||||
r = receiver => Ok(r.unwrap()), // only panics if the sender is dropped without sending, which shouldn't be possible
|
|
||||||
e = stall => e,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_body(&mut self, body: &[u8]) -> Result<(), HandlerError> {
|
|
||||||
self.stream.write(b"\r\nContent-Length: ").await?;
|
|
||||||
self.stream.write(body.len().to_string().as_bytes()).await?;
|
|
||||||
self.stream.write(b"\r\n\r\n").await?;
|
|
||||||
self.stream.write(body).await?;
|
|
||||||
self.stream.shutdown().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Server {
|
|
||||||
addr: Ipv4Addr,
|
|
||||||
port: u16,
|
|
||||||
app_handle: AppHandle,
|
|
||||||
task: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
impl Server {
|
|
||||||
pub async fn new(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result<Server> {
|
|
||||||
let task = Self::start_server(addr, port, app_handle.app_handle()).await?;
|
|
||||||
Ok(Server { addr, port, app_handle, task})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn rebind(&mut self, addr: Ipv4Addr, port: u16) -> io::Result<()> {
|
|
||||||
if addr == self.addr && port == self.port {
|
|
||||||
return Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_task = Self::start_server(addr, port, self.app_handle.app_handle()).await?;
|
|
||||||
self.task.abort();
|
|
||||||
|
|
||||||
self.addr = addr;
|
|
||||||
self.port = port;
|
|
||||||
self.task = new_task;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// construct the listener before spawning the task so that we can return early if it fails
|
|
||||||
async fn start_server(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result<JoinHandle<()>> {
|
|
||||||
let sock_addr = SocketAddrV4::new(addr, port);
|
|
||||||
let listener = TcpListener::bind(&sock_addr).await?;
|
|
||||||
let task = rt::spawn(
|
|
||||||
Self::serve(listener, app_handle.app_handle())
|
|
||||||
);
|
|
||||||
Ok(task)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve(listener: TcpListener, app_handle: AppHandle) {
|
|
||||||
loop {
|
|
||||||
match listener.accept().await {
|
|
||||||
Ok((stream, _)) => {
|
|
||||||
let handler = Handler::new(stream, app_handle.app_handle()).await;
|
|
||||||
rt::spawn(handler.handle());
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error accepting connection: {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("credentials-request", ¬ification)?;
|
||||||
|
|
||||||
|
let response = tokio::select! {
|
||||||
|
r = chan_recv => r?,
|
||||||
|
_ = waiter.wait_for_close() => {
|
||||||
|
app_handle.emit("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
|
||||||
|
}
|
58
src-tauri/src/server/server_unix.rs
Normal file
58
src-tauri/src/server/server_unix.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
use std::io::ErrorKind;
|
||||||
|
use tokio::net::{UnixListener, UnixStream};
|
||||||
|
use tauri::{
|
||||||
|
AppHandle,
|
||||||
|
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.clone();
|
||||||
|
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.clone();
|
||||||
|
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)
|
||||||
|
}
|
69
src-tauri/src/shortcuts.rs
Normal file
69
src-tauri/src/shortcuts.rs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
use tauri::async_runtime as rt;
|
||||||
|
|
||||||
|
use tauri_plugin_global_shortcut::{
|
||||||
|
GlobalShortcutExt,
|
||||||
|
Error as ShortcutError,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::app::{self, APP};
|
||||||
|
use crate::config::HotkeysConfig;
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::terminal;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum ShortcutAction {
|
||||||
|
ShowWindow,
|
||||||
|
LaunchTerminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn exec_shortcut(action: ShortcutAction) {
|
||||||
|
match action {
|
||||||
|
ShortcutAction::LaunchTerminal => launch_terminal(),
|
||||||
|
ShortcutAction::ShowWindow => {
|
||||||
|
let app = APP.get().unwrap();
|
||||||
|
app::show_main_window(app)
|
||||||
|
.error_popup("Failed to show Creddy");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn launch_terminal() {
|
||||||
|
rt::spawn(async {
|
||||||
|
terminal::launch(false)
|
||||||
|
.await
|
||||||
|
.error_popup("Failed to launch terminal")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> Result<(), ShortcutError> {
|
||||||
|
let app = APP.get().unwrap();
|
||||||
|
let shortcuts = app.global_shortcut();
|
||||||
|
shortcuts.unregister_all([
|
||||||
|
hotkeys.show_window.keys.as_str(),
|
||||||
|
hotkeys.launch_terminal.keys.as_str(),
|
||||||
|
])?;
|
||||||
|
|
||||||
|
if hotkeys.show_window.enabled {
|
||||||
|
shortcuts.on_shortcut(
|
||||||
|
hotkeys.show_window.keys.as_str(),
|
||||||
|
|app, _shortcut, _event| {
|
||||||
|
app::show_main_window(app).error_popup("Failed to show Creddy")
|
||||||
|
}
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if hotkeys.launch_terminal.enabled {
|
||||||
|
shortcuts.on_shortcut(
|
||||||
|
hotkeys.launch_terminal.keys.as_str(),
|
||||||
|
|_app, _shortcut, _event| launch_terminal()
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
@ -1,51 +1,137 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::oneshot::Sender,
|
|
||||||
sync::RwLock,
|
sync::RwLock,
|
||||||
time::sleep,
|
sync::oneshot::{self, Sender},
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use tauri::async_runtime as runtime;
|
use tauri::{
|
||||||
use tauri::Manager;
|
Manager,
|
||||||
|
async_runtime as rt,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::app::APP;
|
use crate::app;
|
||||||
use crate::credentials::{
|
use crate::credentials::{
|
||||||
Session,
|
Session,
|
||||||
BaseCredentials,
|
BaseCredentials,
|
||||||
SessionCredentials,
|
SessionCredentials,
|
||||||
};
|
};
|
||||||
use crate::{config, config::AppConfig};
|
use crate::{config, config::AppConfig};
|
||||||
use crate::ipc::{self, Approval};
|
use crate::ipc::{self, Approval, RequestResponse};
|
||||||
use crate::clientinfo::Client;
|
|
||||||
use crate::errors::*;
|
use crate::errors::*;
|
||||||
use crate::server::Server;
|
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_webview_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
|
||||||
|
let is_visible = window.is_visible()?;
|
||||||
|
if self.original.is_none() {
|
||||||
|
self.original = Some(is_visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
if is_visible && 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()?;
|
||||||
|
}
|
||||||
|
app::show_main_window(&app)?;
|
||||||
|
window.set_focus()?;
|
||||||
|
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let lease = VisibilityLease { notify: tx };
|
||||||
|
|
||||||
|
let delay = Duration::from_millis(delay_ms);
|
||||||
|
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 = app.state::<AppState>();
|
||||||
|
let mut visibility = state.visibility.write().await;
|
||||||
|
visibility.leases -= 1;
|
||||||
|
if visibility.leases == 0 {
|
||||||
|
if let Some(false) = visibility.original {
|
||||||
|
app::hide_main_window(app).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)]
|
#[derive(Debug)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: RwLock<AppConfig>,
|
pub config: RwLock<AppConfig>,
|
||||||
pub session: RwLock<Session>,
|
pub session: RwLock<Session>,
|
||||||
|
pub last_activity: RwLock<OffsetDateTime>,
|
||||||
pub request_count: RwLock<u64>,
|
pub request_count: RwLock<u64>,
|
||||||
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
|
pub waiting_requests: RwLock<HashMap<u64, Sender<RequestResponse>>>,
|
||||||
pub pending_terminal_request: RwLock<bool>,
|
pub pending_terminal_request: RwLock<bool>,
|
||||||
pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
|
// these are never modified and so don't need to be wrapped in RwLocks
|
||||||
server: RwLock<Server>,
|
pub setup_errors: Vec<String>,
|
||||||
|
pub desktop_is_gnome: bool,
|
||||||
pool: sqlx::SqlitePool,
|
pool: sqlx::SqlitePool,
|
||||||
|
visibility: RwLock<Visibility>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(config: AppConfig, session: Session, server: Server, pool: SqlitePool) -> AppState {
|
pub fn new(
|
||||||
|
config: AppConfig,
|
||||||
|
session: Session,
|
||||||
|
pool: SqlitePool,
|
||||||
|
setup_errors: Vec<String>,
|
||||||
|
desktop_is_gnome: bool,
|
||||||
|
) -> AppState {
|
||||||
AppState {
|
AppState {
|
||||||
config: RwLock::new(config),
|
config: RwLock::new(config),
|
||||||
session: RwLock::new(session),
|
session: RwLock::new(session),
|
||||||
|
last_activity: RwLock::new(OffsetDateTime::now_utc()),
|
||||||
request_count: RwLock::new(0),
|
request_count: RwLock::new(0),
|
||||||
open_requests: RwLock::new(HashMap::new()),
|
waiting_requests: RwLock::new(HashMap::new()),
|
||||||
pending_terminal_request: RwLock::new(false),
|
pending_terminal_request: RwLock::new(false),
|
||||||
bans: RwLock::new(HashSet::new()),
|
setup_errors,
|
||||||
server: RwLock::new(server),
|
desktop_is_gnome,
|
||||||
pool,
|
pool,
|
||||||
|
visibility: RwLock::new(Visibility::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,18 +151,12 @@ impl AppState {
|
|||||||
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
|
|
||||||
|| new_config.listen_port != live_config.listen_port
|
|
||||||
{
|
|
||||||
let mut sv = self.server.write().await;
|
|
||||||
sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
|
|
||||||
}
|
|
||||||
// re-register hotkeys if necessary
|
// re-register hotkeys if necessary
|
||||||
if new_config.hotkeys.show_window != live_config.hotkeys.show_window
|
if new_config.hotkeys.show_window != live_config.hotkeys.show_window
|
||||||
|| new_config.hotkeys.launch_terminal != live_config.hotkeys.launch_terminal
|
|| 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?;
|
new_config.save(&self.pool).await?;
|
||||||
@ -84,26 +164,26 @@ impl AppState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
|
pub async fn register_request(&self, sender: Sender<RequestResponse>) -> u64 {
|
||||||
let count = {
|
let count = {
|
||||||
let mut c = self.request_count.write().await;
|
let mut c = self.request_count.write().await;
|
||||||
*c += 1;
|
*c += 1;
|
||||||
c
|
c
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut open_requests = self.open_requests.write().await;
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
open_requests.insert(*count, chan); // `count` is the request id
|
waiting_requests.insert(*count, sender); // `count` is the request id
|
||||||
*count
|
*count
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unregister_request(&self, id: u64) {
|
pub async fn unregister_request(&self, id: u64) {
|
||||||
let mut open_requests = self.open_requests.write().await;
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
open_requests.remove(&id);
|
waiting_requests.remove(&id);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn req_count(&self) -> usize {
|
pub async fn acquire_visibility_lease(&self, delay: u64) -> Result<VisibilityLease, WindowError> {
|
||||||
let open_requests = self.open_requests.read().await;
|
let mut visibility = self.visibility.write().await;
|
||||||
open_requests.len()
|
visibility.acquire(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
|
||||||
@ -112,31 +192,12 @@ impl AppState {
|
|||||||
session.renew_if_expired().await?;
|
session.renew_if_expired().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut open_requests = self.open_requests.write().await;
|
let mut waiting_requests = self.waiting_requests.write().await;
|
||||||
let chan = open_requests
|
waiting_requests
|
||||||
.remove(&response.id)
|
.remove(&response.id)
|
||||||
.ok_or(SendResponseError::NotFound)
|
.ok_or(SendResponseError::NotFound)?
|
||||||
?;
|
.send(response)
|
||||||
|
.map_err(|_| SendResponseError::Abandoned)
|
||||||
chan.send(response.approval)
|
|
||||||
.map_err(|_e| SendResponseError::Abandoned)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add_ban(&self, client: Option<Client>) {
|
|
||||||
let mut bans = self.bans.write().await;
|
|
||||||
bans.insert(client.clone());
|
|
||||||
|
|
||||||
runtime::spawn(async move {
|
|
||||||
sleep(Duration::from_secs(5)).await;
|
|
||||||
let app = APP.get().unwrap();
|
|
||||||
let state = app.state::<AppState>();
|
|
||||||
let mut bans = state.bans.write().await;
|
|
||||||
bans.remove(&client);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn is_banned(&self, client: &Option<Client>) -> bool {
|
|
||||||
self.bans.read().await.contains(&client)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
|
pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
|
||||||
@ -151,21 +212,53 @@ impl AppState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn lock(&self) -> Result<(), LockError> {
|
||||||
|
let mut session = self.session.write().await;
|
||||||
|
match *session {
|
||||||
|
Session::Empty => Err(LockError::NotUnlocked),
|
||||||
|
Session::Locked(_) => Err(LockError::NotUnlocked),
|
||||||
|
Session::Unlocked{..} => {
|
||||||
|
*session = Session::load(&self.pool).await?;
|
||||||
|
|
||||||
|
let app_handle = app::APP.get().unwrap();
|
||||||
|
app_handle.emit("locked", None::<usize>)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn signal_activity(&self) {
|
||||||
|
let mut last_activity = self.last_activity.write().await;
|
||||||
|
*last_activity = OffsetDateTime::now_utc();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn should_auto_lock(&self) -> bool {
|
||||||
|
let config = self.config.read().await;
|
||||||
|
if !config.auto_lock || !self.is_unlocked().await {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_activity = self.last_activity.read().await;
|
||||||
|
let elapsed = OffsetDateTime::now_utc() - *last_activity;
|
||||||
|
elapsed >= config.lock_after
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn is_unlocked(&self) -> bool {
|
pub async fn is_unlocked(&self) -> bool {
|
||||||
let session = self.session.read().await;
|
let session = self.session.read().await;
|
||||||
matches!(*session, Session::Unlocked{..})
|
matches!(*session, Session::Unlocked{..})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
|
pub async fn base_creds_cloned(&self) -> Result<BaseCredentials, GetCredentialsError> {
|
||||||
let app_session = self.session.read().await;
|
let app_session = self.session.read().await;
|
||||||
let (base, _session) = app_session.try_get()?;
|
let (base, _session) = app_session.try_get()?;
|
||||||
Ok(serde_json::to_string(base).unwrap())
|
Ok(base.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
|
pub async fn session_creds_cloned(&self) -> Result<SessionCredentials, GetCredentialsError> {
|
||||||
let app_session = self.session.read().await;
|
let app_session = self.session.read().await;
|
||||||
let (_bsae, session) = app_session.try_get()?;
|
let (_base, session) = app_session.try_get()?;
|
||||||
Ok(serde_json::to_string(session).unwrap())
|
Ok(session.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
|
async fn new_session(&self, base: BaseCredentials) -> Result<(), GetSessionError> {
|
||||||
|
@ -23,21 +23,16 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
|
|||||||
cmd
|
cmd
|
||||||
};
|
};
|
||||||
|
|
||||||
// if session is unlocked or empty, wait for credentials from frontend
|
// if session is locked or empty, wait for credentials from frontend
|
||||||
if !state.is_unlocked().await {
|
if !state.is_unlocked().await {
|
||||||
app.emit_all("launch-terminal-request", ())?;
|
app.emit("launch-terminal-request", ())?;
|
||||||
let window = app.get_window("main")
|
let lease = state.acquire_visibility_lease(0).await
|
||||||
.ok_or(LaunchTerminalError::NoMainWindow)?;
|
.map_err(|_e| LaunchTerminalError::NoMainWindow)?; // automate conversion eventually?
|
||||||
if !window.is_visible()? {
|
|
||||||
window.unminimize()?;
|
|
||||||
window.show()?;
|
|
||||||
}
|
|
||||||
window.set_focus()?;
|
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
app.once_global("credentials-event", move |e| {
|
app.once("credentials-event", move |e| {
|
||||||
let success = match e.payload() {
|
let success = match e.payload() {
|
||||||
Some("\"unlocked\"") | Some("\"entered\"") => true,
|
"\"unlocked\"" | "\"entered\"" => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
let _ = tx.send(success);
|
let _ = tx.send(success);
|
||||||
@ -47,6 +42,7 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
|
|||||||
state.unregister_terminal_request().await;
|
state.unregister_terminal_request().await;
|
||||||
return Ok(()); // request was canceled by user
|
return Ok(()); // request was canceled by user
|
||||||
}
|
}
|
||||||
|
lease.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
// more lock-management
|
// more lock-management
|
||||||
@ -63,7 +59,7 @@ pub async fn launch(use_base: bool) -> Result<(), LaunchTerminalError> {
|
|||||||
else {
|
else {
|
||||||
cmd.env("AWS_ACCESS_KEY_ID", &session_creds.access_key_id);
|
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_SECRET_ACCESS_KEY", &session_creds.secret_access_key);
|
||||||
cmd.env("AWS_SESSION_TOKEN", &session_creds.token);
|
cmd.env("AWS_SESSION_TOKEN", &session_creds.session_token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,36 +1,49 @@
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
|
App,
|
||||||
AppHandle,
|
AppHandle,
|
||||||
Manager,
|
Manager,
|
||||||
SystemTray,
|
async_runtime as rt,
|
||||||
SystemTrayEvent,
|
};
|
||||||
SystemTrayMenu,
|
use tauri::menu::{
|
||||||
CustomMenuItem,
|
MenuBuilder,
|
||||||
|
MenuEvent,
|
||||||
|
MenuItemBuilder,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::app;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub fn create() -> SystemTray {
|
|
||||||
let show = CustomMenuItem::new("show".to_string(), "Show");
|
|
||||||
let quit = CustomMenuItem::new("exit".to_string(), "Exit");
|
|
||||||
|
|
||||||
let menu = SystemTrayMenu::new()
|
pub fn setup(app: &App) -> tauri::Result<()> {
|
||||||
.add_item(show)
|
let show_hide = MenuItemBuilder::with_id("show_hide", "Show").build(app)?;
|
||||||
.add_item(quit);
|
let exit = MenuItemBuilder::with_id("exit", "Exit").build(app)?;
|
||||||
|
|
||||||
SystemTray::new().with_menu(menu)
|
let menu = MenuBuilder::new(app)
|
||||||
|
.items(&[&show_hide, &exit])
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let tray = app.tray_by_id("main").unwrap();
|
||||||
|
tray.set_menu(Some(menu))?;
|
||||||
|
tray.on_menu_event(handle_event);
|
||||||
|
|
||||||
|
// stash this so we can find it later to change the text
|
||||||
|
app.manage(show_hide);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn handle_event(app: &AppHandle, event: SystemTrayEvent) {
|
fn handle_event(app_handle: &AppHandle, event: MenuEvent) {
|
||||||
match event {
|
match event.id.0.as_str() {
|
||||||
SystemTrayEvent::MenuItemClick{ id, .. } => {
|
"exit" => app_handle.exit(0),
|
||||||
match id.as_str() {
|
"show_hide" => {
|
||||||
"exit" => app.exit(0),
|
let _ = app::toggle_main_window(app_handle);
|
||||||
"show" => {
|
let new_handle = app_handle.clone();
|
||||||
let _ = app.get_window("main").map(|w| w.show());
|
rt::spawn(async move {
|
||||||
}
|
let state = new_handle.state::<AppState>();
|
||||||
_ => (),
|
state.signal_activity().await;
|
||||||
}
|
});
|
||||||
}
|
},
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,64 +3,57 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run build",
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devPath": "http://localhost:5173",
|
"frontendDist": "../dist",
|
||||||
"distDir": "../dist"
|
"devUrl": "http://localhost:5173"
|
||||||
},
|
},
|
||||||
"package": {
|
"bundle": {
|
||||||
"productName": "creddy",
|
"active": true,
|
||||||
"version": "0.3.0"
|
"category": "DeveloperTool",
|
||||||
},
|
"copyright": "",
|
||||||
"tauri": {
|
"targets": "all",
|
||||||
"allowlist": {
|
"externalBin": [],
|
||||||
"os": {"all": true},
|
"icon": [
|
||||||
"dialog": {"open": true}
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"certificateThumbprint": null,
|
||||||
|
"digestAlgorithm": "sha256",
|
||||||
|
"timestampUrl": "",
|
||||||
|
"wix": {
|
||||||
|
"fragmentPaths": [
|
||||||
|
"conf/cli.wxs"
|
||||||
|
],
|
||||||
|
"componentRefs": [
|
||||||
|
"CliBinary",
|
||||||
|
"AddToPath"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"longDescription": "",
|
||||||
"active": true,
|
"macOS": {
|
||||||
"category": "DeveloperTool",
|
"entitlements": null,
|
||||||
"copyright": "",
|
"exceptionDomain": "",
|
||||||
|
"frameworks": [],
|
||||||
|
"providerShortName": null,
|
||||||
|
"signingIdentity": null
|
||||||
|
},
|
||||||
|
"resources": [],
|
||||||
|
"shortDescription": "",
|
||||||
|
"linux": {
|
||||||
"deb": {
|
"deb": {
|
||||||
"depends": []
|
"depends": []
|
||||||
},
|
|
||||||
"externalBin": [],
|
|
||||||
"icon": [
|
|
||||||
"icons/32x32.png",
|
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.icns",
|
|
||||||
"icons/icon.ico"
|
|
||||||
],
|
|
||||||
"identifier": "creddy",
|
|
||||||
"longDescription": "",
|
|
||||||
"macOS": {
|
|
||||||
"entitlements": null,
|
|
||||||
"exceptionDomain": "",
|
|
||||||
"frameworks": [],
|
|
||||||
"providerShortName": null,
|
|
||||||
"signingIdentity": null
|
|
||||||
},
|
|
||||||
"resources": [],
|
|
||||||
"shortDescription": "",
|
|
||||||
"targets": "all",
|
|
||||||
"windows": {
|
|
||||||
"certificateThumbprint": null,
|
|
||||||
"digestAlgorithm": "sha256",
|
|
||||||
"timestampUrl": "",
|
|
||||||
"wix": {
|
|
||||||
"fragmentPaths": ["conf/cli.wxs"],
|
|
||||||
"componentRefs": ["CliBinary", "AddToPath"]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"security": {
|
},
|
||||||
"csp": {
|
"productName": "creddy",
|
||||||
"default-src": ["'self'"],
|
"version": "0.4.9",
|
||||||
"style-src": ["'self'", "'unsafe-inline'"]
|
"identifier": "creddy",
|
||||||
}
|
"plugins": {},
|
||||||
},
|
"app": {
|
||||||
"updater": {
|
|
||||||
"active": false
|
|
||||||
},
|
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
@ -72,9 +65,24 @@
|
|||||||
"visible": false
|
"visible": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"systemTray": {
|
"trayIcon": {
|
||||||
|
"id": "main",
|
||||||
"iconPath": "icons/icon.png",
|
"iconPath": "icons/icon.png",
|
||||||
"iconAsTemplate": true
|
"iconAsTemplate": true
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"csp": {
|
||||||
|
"style-src": [
|
||||||
|
"'self'",
|
||||||
|
"'unsafe-inline'"
|
||||||
|
],
|
||||||
|
"default-src": [
|
||||||
|
"'self'"
|
||||||
|
],
|
||||||
|
"connect-src": [
|
||||||
|
"ipc: http://ipc.localhost"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,9 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/tauri';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
|
|
||||||
import { appState, acceptRequest } from './lib/state.js';
|
import { appState, acceptRequest, cleanupRequest } from './lib/state.js';
|
||||||
import { views, currentView, navigate } from './lib/routing.js';
|
import { views, currentView, navigate } from './lib/routing.js';
|
||||||
|
|
||||||
|
|
||||||
@ -11,11 +12,23 @@ $views = import.meta.glob('./views/*.svelte', {eager: true});
|
|||||||
navigate('Home');
|
navigate('Home');
|
||||||
|
|
||||||
invoke('get_config').then(config => $appState.config = config);
|
invoke('get_config').then(config => $appState.config = config);
|
||||||
|
invoke('get_session_status').then(status => $appState.credentialStatus = status);
|
||||||
|
getVersion().then(version => $appState.appVersion = version);
|
||||||
|
|
||||||
listen('credentials-request', (tauriEvent) => {
|
listen('credentials-request', (tauriEvent) => {
|
||||||
$appState.pendingRequests.put(tauriEvent.payload);
|
$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) => {
|
listen('launch-terminal-request', async (tauriEvent) => {
|
||||||
if ($appState.currentRequest === null) {
|
if ($appState.currentRequest === null) {
|
||||||
let status = await invoke('get_session_status');
|
let status = await invoke('get_session_status');
|
||||||
@ -28,10 +41,24 @@ listen('launch-terminal-request', async (tauriEvent) => {
|
|||||||
// else, session is unlocked, so do nothing
|
// else, session is unlocked, so do nothing
|
||||||
// (although we shouldn't even get the event in that case)
|
// (although we shouldn't even get the event in that case)
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
|
listen('locked', () => {
|
||||||
|
$appState.credentialStatus = 'locked';
|
||||||
|
});
|
||||||
|
|
||||||
|
invoke('get_setup_errors')
|
||||||
|
.then(errs => {
|
||||||
|
$appState.setupErrors = errs.map(e => ({msg: e, show: true}));
|
||||||
|
});
|
||||||
|
|
||||||
acceptRequest();
|
acceptRequest();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:click={() => invoke('signal_activity')}
|
||||||
|
on:keydown={() => invoke('signal_activity')}
|
||||||
|
/>
|
||||||
|
|
||||||
<svelte:component this="{$currentView}" />
|
<svelte:component this="{$currentView}" />
|
||||||
|
@ -30,5 +30,15 @@ export default function() {
|
|||||||
|
|
||||||
return this.items.shift();
|
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;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,6 +8,8 @@ export let appState = writable({
|
|||||||
currentRequest: null,
|
currentRequest: null,
|
||||||
pendingRequests: queue(),
|
pendingRequests: queue(),
|
||||||
credentialStatus: 'locked',
|
credentialStatus: 'locked',
|
||||||
|
setupErrors: [],
|
||||||
|
appVersion: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@ -22,7 +24,7 @@ export async function acceptRequest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function completeRequest() {
|
export function cleanupRequest() {
|
||||||
appState.update($appState => {
|
appState.update($appState => {
|
||||||
$appState.currentRequest = null;
|
$appState.currentRequest = null;
|
||||||
return $appState;
|
return $appState;
|
||||||
|
@ -5,3 +5,8 @@
|
|||||||
.btn-alert-error {
|
.btn-alert-error {
|
||||||
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content
|
@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>
|
<script>
|
||||||
export let keys;
|
export let keys;
|
||||||
|
let classes = '';
|
||||||
|
export {classes as class};
|
||||||
</script>
|
</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}
|
{#each keys as key, i}
|
||||||
{#if i > 0}
|
{#if i > 0}
|
||||||
<span class="mt-[-0.1em]">+</span>
|
<span class="mt-[-0.1em]">+</span>
|
||||||
{/if}
|
{/if}
|
||||||
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
|
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</span>
|
||||||
|
@ -21,15 +21,15 @@
|
|||||||
throw(`Link target is not a string or a function: ${target}`)
|
throw(`Link target is not a string or a function: ${target}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleHotkey(event) {
|
function handleHotkey(event) {
|
||||||
if (!hotkey) return;
|
if (
|
||||||
if (ctrl && !event.ctrlKey) return;
|
hotkey === event.key
|
||||||
if (alt && !event.altKey) return;
|
&& ctrl === event.ctrlKey
|
||||||
if (shift && !event.shiftKey) return;
|
&& alt === event.altKey
|
||||||
|
&& shift === event.shiftKey
|
||||||
if (event.key === hotkey) {
|
) {
|
||||||
click();
|
click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { open } from '@tauri-apps/api/dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import Setting from './Setting.svelte';
|
import Setting from './Setting.svelte';
|
||||||
|
|
||||||
export let title;
|
export let title;
|
||||||
|
@ -7,38 +7,49 @@
|
|||||||
|
|
||||||
const id = Math.random().toString().slice(2);
|
const id = Math.random().toString().slice(2);
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
const MODIFIERS = new Set(['Alt', 'AltGraph', 'Control', 'Fn', 'FnLock', 'Meta', 'Shift', 'Super', ]);
|
||||||
|
|
||||||
|
|
||||||
let listening = false;
|
let listening = false;
|
||||||
|
let keysPressed = [];
|
||||||
|
|
||||||
|
function addModifiers(event) {
|
||||||
|
// add modifier key if it isn't already present
|
||||||
|
if (MODIFIERS.has(event.key) && keysPressed.indexOf(event.key) === -1) {
|
||||||
|
keysPressed.push(event.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMainKey(event) {
|
||||||
|
if (!MODIFIERS.has(event.key)) {
|
||||||
|
keysPressed.push(event.key);
|
||||||
|
|
||||||
|
value.keys = keysPressed.join('+');
|
||||||
|
dispatch('update', {value});
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function listen() {
|
function listen() {
|
||||||
// don't re-listen if we already are
|
// don't re-listen if we already are
|
||||||
if (listening) return;
|
if (listening) return;
|
||||||
|
|
||||||
listening = true;
|
listening = true;
|
||||||
window.addEventListener('keyup', setKeybind, {once: true});
|
window.addEventListener('keydown', addModifiers);
|
||||||
|
window.addEventListener('keyup', addMainKey);
|
||||||
// setTimeout avoids reacting to the click event that we are currently processing
|
// setTimeout avoids reacting to the click event that we are currently processing
|
||||||
setTimeout(() => window.addEventListener('click', cancel, {once: true}), 0);
|
setTimeout(() => window.addEventListener('click', unlisten), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setKeybind(event) {
|
function unlisten() {
|
||||||
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;
|
listening = false;
|
||||||
window.removeEventListener('click', cancel, {once: true});
|
keysPressed = [];
|
||||||
event.preventDefault();
|
window.removeEventListener('keydown', addModifiers);
|
||||||
event.stopPropagation();
|
window.removeEventListener('keyup', addMainKey);
|
||||||
}
|
window.removeEventListener('click', unlisten);
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
listening = false;
|
|
||||||
window.removeEventListener('keyup', setKeybind, {once: true});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -10,15 +10,21 @@
|
|||||||
export let min = null;
|
export let min = null;
|
||||||
export let max = null;
|
export let max = null;
|
||||||
export let decimal = false;
|
export let decimal = false;
|
||||||
|
export let debounceInterval = 0;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
$: localValue = value.toString();
|
$: localValue = value.toString();
|
||||||
let lastInputTime = null;
|
let lastInputTime = null;
|
||||||
function debounce(event) {
|
function debounce(event) {
|
||||||
lastInputTime = Date.now();
|
|
||||||
localValue = localValue.replace(/[^-0-9.]/g, '');
|
localValue = localValue.replace(/[^-0-9.]/g, '');
|
||||||
|
|
||||||
|
if (debounceInterval === 0) {
|
||||||
|
updateValue(localValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastInputTime = Date.now();
|
||||||
const eventTime = lastInputTime;
|
const eventTime = lastInputTime;
|
||||||
const pendingValue = localValue;
|
const pendingValue = localValue;
|
||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
@ -28,7 +34,7 @@
|
|||||||
updateValue(pendingValue);
|
updateValue(pendingValue);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
500
|
debounceInterval,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
92
src/ui/settings/TimeSetting.svelte
Normal file
92
src/ui/settings/TimeSetting.svelte
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<script>
|
||||||
|
import Setting from './Setting.svelte';
|
||||||
|
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
|
||||||
|
export let title;
|
||||||
|
// seconds are required
|
||||||
|
export let seconds;
|
||||||
|
|
||||||
|
export let min = 0;
|
||||||
|
export let max = null;
|
||||||
|
|
||||||
|
// best unit is the unit that results in the smallest non-fractional number
|
||||||
|
let unit = null;
|
||||||
|
|
||||||
|
const UNITS = {
|
||||||
|
Seconds: 1,
|
||||||
|
Minutes: 60,
|
||||||
|
Hours: 3600,
|
||||||
|
Days: 86400,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (unit === null) {
|
||||||
|
let min = Infinity;
|
||||||
|
let bestUnit = null;
|
||||||
|
for (const [u, multiplier] of Object.entries(UNITS)) {
|
||||||
|
const v = seconds / multiplier;
|
||||||
|
if (v < min && v >= 1) {
|
||||||
|
min = v;
|
||||||
|
bestUnit = u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit = bestUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// local value is only one-way synced to value so that we can better handle changes
|
||||||
|
$: localValue = (seconds / UNITS[unit]).toString();
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
function updateValue() {
|
||||||
|
localValue = localValue.replace(/[^0-9.]/g, '');
|
||||||
|
// Don't update the value, but also don't error, if it's empty,
|
||||||
|
// or if it could be the start of a float
|
||||||
|
if (localValue === '' || localValue === '.') {
|
||||||
|
error = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = parseFloat(localValue);
|
||||||
|
if (num < 0) {
|
||||||
|
error = `${num} is not a valid duration`
|
||||||
|
}
|
||||||
|
else if (min !== null && num < min) {
|
||||||
|
error = `Too low (minimum ${min * UNITS[unit]}`;
|
||||||
|
}
|
||||||
|
else if (max !== null & num > max) {
|
||||||
|
error = `Too high (maximum ${max * UNITS[unit]}`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
error = null;
|
||||||
|
seconds = Math.round(num * UNITS[unit]);
|
||||||
|
dispatch('update', {seconds});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<Setting {title}>
|
||||||
|
<div slot="input">
|
||||||
|
<select class="select select-bordered select-sm mr-2" bind:value={unit}>
|
||||||
|
{#each Object.keys(UNITS) as u}
|
||||||
|
<option selected={u === unit || null}>{u}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="tooltip tooltip-error" class:tooltip-open={error !== null} data-tip={error}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-sm input-bordered text-right"
|
||||||
|
size={Math.max(5, localValue.length)}
|
||||||
|
class:input-error={error}
|
||||||
|
bind:value={localValue}
|
||||||
|
on:input={updateValue}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot name="description" slot="description"></slot>
|
||||||
|
</Setting>
|
@ -3,3 +3,4 @@ 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 FileSetting } from './FileSetting.svelte';
|
||||||
export { default as TextSetting } from './TextSetting.svelte';
|
export { default as TextSetting } from './TextSetting.svelte';
|
||||||
|
export { default as TimeSetting } from './TimeSetting.svelte';
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/tauri';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
import { navigate } from '../lib/routing.js';
|
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 ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||||
import Link from '../ui/Link.svelte';
|
import Link from '../ui/Link.svelte';
|
||||||
import KeyCombo from '../ui/KeyCombo.svelte';
|
import KeyCombo from '../ui/KeyCombo.svelte';
|
||||||
@ -12,9 +12,12 @@
|
|||||||
// Send response to backend, display error if applicable
|
// Send response to backend, display error if applicable
|
||||||
let error, alert;
|
let error, alert;
|
||||||
async function respond() {
|
async function respond() {
|
||||||
let {id, approval} = $appState.currentRequest;
|
const response = {
|
||||||
|
id: $appState.currentRequest.id,
|
||||||
|
...$appState.currentRequest.response,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await invoke('respond', {response: {id, approval}});
|
await invoke('respond', {response});
|
||||||
navigate('ShowResponse');
|
navigate('ShowResponse');
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@ -26,8 +29,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Approval has one of several outcomes depending on current credential state
|
// Approval has one of several outcomes depending on current credential state
|
||||||
async function approve() {
|
async function approve(base) {
|
||||||
$appState.currentRequest.approval = 'Approved';
|
$appState.currentRequest.response = {approval: 'Approved', base};
|
||||||
let status = await invoke('get_session_status');
|
let status = await invoke('get_session_status');
|
||||||
if (status === 'unlocked') {
|
if (status === 'unlocked') {
|
||||||
await respond();
|
await respond();
|
||||||
@ -42,51 +45,48 @@
|
|||||||
|
|
||||||
// Denial has only one
|
// Denial has only one
|
||||||
async function deny() {
|
async function deny() {
|
||||||
$appState.currentRequest.approval = 'Denied';
|
$appState.currentRequest.response = {approval: 'Denied', base: false};
|
||||||
await respond();
|
await respond();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract executable name from full path
|
// Extract executable name from full path
|
||||||
let appName = null;
|
const client = $appState.currentRequest.client;
|
||||||
if ($appState.currentRequest.clients.length === 1) {
|
const m = client.exe?.match(/\/([^/]+?$)|\\([^\\]+?$)/);
|
||||||
let path = $appState.currentRequest.clients[0].exe;
|
const appName = m[1] || m[2];
|
||||||
let m = path.match(/\/([^/]+?$)|\\([^\\]+?$)/);
|
|
||||||
appName = m[1] || m[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Executable paths can be long, so ensure they only break on \ or /
|
// Executable paths can be long, so ensure they only break on \ or /
|
||||||
function breakPath(client) {
|
function breakPath(path) {
|
||||||
return client.exe.replace(/(\\|\/)/g, '$1<wbr>');
|
return path.replace(/(\\|\/)/g, '$1<wbr>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the request has already been approved/denied, send response immediately
|
// if the request has already been approved/denied, send response immediately
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if ($appState.currentRequest.approval) {
|
if ($appState.currentRequest.response) {
|
||||||
await respond();
|
await respond();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<!-- Don't render at all if we're just going to immediately proceed to the next screen -->
|
<!-- 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">
|
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
|
||||||
{#if error}
|
{#if error}
|
||||||
<ErrorAlert bind:this={alert}>
|
<ErrorAlert bind:this={alert}>
|
||||||
{error}
|
{error.msg}
|
||||||
<svelte:fragment slot="buttons">
|
<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>
|
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</ErrorAlert>
|
</ErrorAlert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $appState.currentRequest.base}
|
{#if $appState.currentRequest?.base}
|
||||||
<div class="alert alert-warning shadow-lg">
|
<div class="alert alert-warning shadow-lg">
|
||||||
<div>
|
<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>
|
<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>
|
<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.
|
These credentials are less secure than session credentials, since they don't expire automatically.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -97,29 +97,49 @@
|
|||||||
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
|
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-[auto_1fr] gap-x-3">
|
<div class="grid grid-cols-[auto_1fr] gap-x-3">
|
||||||
{#each $appState.currentRequest.clients as client}
|
<div class="text-right">Path:</div>
|
||||||
<div class="text-right">Path:</div>
|
<code class="">{@html client.exe ? breakPath(client.exe) : 'Unknown'}</code>
|
||||||
<code class="">{@html client ? breakPath(client) : 'Unknown'}</code>
|
<div class="text-right">PID:</div>
|
||||||
<div class="text-right">PID:</div>
|
<code>{client.pid}</code>
|
||||||
<code>{client ? client.pid : 'Unknown'}</code>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full flex justify-between">
|
<div class="w-full grid grid-cols-[1fr_auto] items-center gap-y-6">
|
||||||
<Link target={deny} hotkey="Escape">
|
<!-- Don't display the option to approve with session credentials if base was specifically requested -->
|
||||||
<button class="btn btn-error justify-self-start">
|
{#if !$appState.currentRequest?.base}
|
||||||
<span class="mr-2">Deny</span>
|
<h3 class="font-semibold">
|
||||||
<KeyCombo keys={['Esc']} />
|
Approve with session credentials
|
||||||
</button>
|
</h3>
|
||||||
</Link>
|
<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}">
|
<h3 class="font-semibold">
|
||||||
<button class="btn btn-success justify-self-end">
|
<span class="mr-2">
|
||||||
<span class="mr-2">Approve</span>
|
{#if $appState.currentRequest?.base}
|
||||||
<KeyCombo keys={['Shift', 'Enter']} />
|
Approve
|
||||||
</button>
|
{:else}
|
||||||
</Link>
|
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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/tauri';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { emit } from '@tauri-apps/api/event';
|
||||||
import { getRootCause } from '../lib/errors.js';
|
import { getRootCause } from '../lib/errors.js';
|
||||||
|
|
||||||
import { appState } from '../lib/state.js';
|
import { appState } from '../lib/state.js';
|
||||||
@ -32,6 +33,7 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
await invoke('save_credentials', {credentials, passphrase});
|
await invoke('save_credentials', {credentials, passphrase});
|
||||||
emit('credentials-event', 'entered');
|
emit('credentials-event', 'entered');
|
||||||
|
$appState.credentialStatus = 'unlocked';
|
||||||
if ($appState.currentRequest) {
|
if ($appState.currentRequest) {
|
||||||
navigate('Approve');
|
navigate('Approve');
|
||||||
}
|
}
|
||||||
@ -40,13 +42,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
window.error = e;
|
|
||||||
const root = getRootCause(e);
|
const root = getRootCause(e);
|
||||||
if (e.code === 'GetSession' && root.code) {
|
if (e.code === 'GetSession' && root.code) {
|
||||||
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
errorMsg = `Error response from AWS (${root.code}): ${root.msg}`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
errorMsg = e.msg;
|
// some of the built-in Tauri errors are plain strings,
|
||||||
|
// so fall back to e if e.msg doesn't exist
|
||||||
|
errorMsg = e.msg || e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the alert already existed, shake it
|
// if the alert already existed, shake it
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/tauri';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
import { appState } from '../lib/state.js';
|
import { appState } from '../lib/state.js';
|
||||||
import { navigate } from '../lib/routing.js';
|
import { navigate } from '../lib/routing.js';
|
||||||
@ -25,30 +25,41 @@
|
|||||||
<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">
|
<div class="flex flex-col items-center space-y-4">
|
||||||
{@html vaultDoorSvg}
|
{@html vaultDoorSvg}
|
||||||
{#await invoke('get_session_status') then status}
|
{#if $appState.credentialStatus === 'locked'}
|
||||||
{#if status === 'locked'}
|
|
||||||
|
|
||||||
<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 $appState.credentialStatus === 'unlocked'}
|
||||||
<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}>
|
<button class="btn btn-primary w-full" on:click={launchTerminal}>
|
||||||
Launch Terminal
|
Launch Terminal
|
||||||
</button>
|
</button>
|
||||||
<label class="label cursor-pointer flex items-center space-x-2">
|
<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 long-lived credentials</span>
|
||||||
<span class="label-text">Launch with base credentials</span>
|
<input type="checkbox" class="checkbox checkbox-sm" bind:checked={launchBase}>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{:else if status === 'empty'}
|
{:else if $appState.credentialStatus === 'empty'}
|
||||||
<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>
|
||||||
</Link>
|
</Link>
|
||||||
{/if}
|
{/if}
|
||||||
{/await}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if $appState.setupErrors.some(e => e.show)}
|
||||||
|
<div class="toast">
|
||||||
|
{#each $appState.setupErrors as error}
|
||||||
|
{#if error.show}
|
||||||
|
<div class="alert alert-error shadow-lg">
|
||||||
|
{error.msg}
|
||||||
|
<button class="btn btn-sm btn-alert-error" on:click={() => error.show = false}>Ok</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { invoke } from '@tauri-apps/api/tauri';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { type } from '@tauri-apps/api/os';
|
import { type } from '@tauri-apps/plugin-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';
|
||||||
@ -8,21 +8,24 @@
|
|||||||
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
import ErrorAlert from '../ui/ErrorAlert.svelte';
|
||||||
import SettingsGroup from '../ui/settings/SettingsGroup.svelte';
|
import SettingsGroup from '../ui/settings/SettingsGroup.svelte';
|
||||||
import Keybind from '../ui/settings/Keybind.svelte';
|
import Keybind from '../ui/settings/Keybind.svelte';
|
||||||
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting } from '../ui/settings';
|
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting, TimeSetting } from '../ui/settings';
|
||||||
|
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { backInOut } from 'svelte/easing';
|
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;
|
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 = await invoke('get_config');
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
error = e;
|
error = e;
|
||||||
$appState.config = await invoke('get_config');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,74 +38,77 @@
|
|||||||
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
|
<h1 slot="title" class="text-2xl font-bold">Settings</h1>
|
||||||
</Nav>
|
</Nav>
|
||||||
|
|
||||||
{#await invoke('get_config') then config}
|
<div class="max-w-lg mx-auto my-1.5 p-4 space-y-16">
|
||||||
<div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16">
|
<SettingsGroup name="General">
|
||||||
<SettingsGroup name="General">
|
<ToggleSetting title="Start on login" bind:value={config.start_on_login}>
|
||||||
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
|
<svelte:fragment slot="description">
|
||||||
|
Start Creddy when you log in to your computer.
|
||||||
|
</svelte:fragment>
|
||||||
|
</ToggleSetting>
|
||||||
|
|
||||||
|
<ToggleSetting title="Start minimized" bind:value={config.start_minimized}>
|
||||||
|
<svelte:fragment slot="description">
|
||||||
|
Minimize to the system tray at startup.
|
||||||
|
</svelte:fragment>
|
||||||
|
</ToggleSetting>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<ToggleSetting title="Lock when idle" bind:value={config.auto_lock}>
|
||||||
|
<svelte:fragment slot="description">
|
||||||
|
Automatically lock Creddy after a period of inactivity.
|
||||||
|
</svelte:fragment>
|
||||||
|
</ToggleSetting>
|
||||||
|
|
||||||
|
{#if config.auto_lock}
|
||||||
|
<TimeSetting title="Idle timeout" bind:seconds={config.lock_after.secs}>
|
||||||
<svelte:fragment slot="description">
|
<svelte:fragment slot="description">
|
||||||
Start Creddy when you log in to your computer.
|
How long to wait before automatically locking.
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</ToggleSetting>
|
</TimeSetting>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}>
|
<Setting title="Update credentials">
|
||||||
<svelte:fragment slot="description">
|
<Link slot="input" target="EnterCredentials">
|
||||||
Minimize to the system tray at startup.
|
<button class="btn btn-sm btn-primary">Update</button>
|
||||||
</svelte:fragment>
|
</Link>
|
||||||
</ToggleSetting>
|
<svelte:fragment slot="description">
|
||||||
|
Update or re-enter your encrypted credentials.
|
||||||
|
</svelte:fragment>
|
||||||
|
</Setting>
|
||||||
|
|
||||||
<NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}>
|
<FileSetting
|
||||||
<svelte:fragment slot="description">
|
title="Terminal emulator"
|
||||||
How long to wait after a request is approved/denied before minimizing
|
bind:value={config.terminal.exec}
|
||||||
the window to tray. Only applicable if the window was minimized
|
|
||||||
to tray before the request was received.
|
>
|
||||||
</svelte:fragment>
|
<svelte:fragment slot="description">
|
||||||
</NumericSetting>
|
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>
|
||||||
|
|
||||||
<NumericSetting
|
<SettingsGroup name="Hotkeys">
|
||||||
title="Listen port"
|
<div class="space-y-4">
|
||||||
bind:value={$appState.config.listen_port}
|
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
|
||||||
min={osType === 'Windows_NT' ? 1 : 0}
|
|
||||||
on:update={save}
|
|
||||||
>
|
|
||||||
<svelte:fragment slot="description">
|
|
||||||
Listen for credentials requests on this port.
|
|
||||||
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
|
|
||||||
</svelte:fragment>
|
|
||||||
</NumericSetting>
|
|
||||||
|
|
||||||
<Setting title="Update credentials">
|
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
|
||||||
<Link slot="input" target="EnterCredentials">
|
<Keybind description="Show Creddy" bind:value={config.hotkeys.show_window} />
|
||||||
<button class="btn btn-sm btn-primary">Update</button>
|
<Keybind description="Launch terminal" bind:value={config.hotkeys.launch_terminal} />
|
||||||
</Link>
|
|
||||||
<svelte:fragment slot="description">
|
|
||||||
Update or re-enter your encrypted credentials.
|
|
||||||
</svelte:fragment>
|
|
||||||
</Setting>
|
|
||||||
|
|
||||||
<FileSetting
|
|
||||||
title="Terminal emulator"
|
|
||||||
bind:value={$appState.config.terminal.exec}
|
|
||||||
on:update={save}
|
|
||||||
>
|
|
||||||
<svelte:fragment slot="description">
|
|
||||||
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
|
|
||||||
</svelte:fragment>
|
|
||||||
</FileSetting>
|
|
||||||
</SettingsGroup>
|
|
||||||
|
|
||||||
<SettingsGroup name="Hotkeys">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
|
|
||||||
<Keybind description="Show Creddy" value={$appState.config.hotkeys.show_window} on:update={save} />
|
|
||||||
<Keybind description="Launch terminal" value={$appState.config.hotkeys.launch_terminal} on:update={save} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SettingsGroup>
|
</div>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
</div>
|
<p class="text-sm text-right">
|
||||||
{/await}
|
Creddy {$appState.appVersion}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
|
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
|
||||||
@ -116,4 +122,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { draw, fade } from 'svelte/transition';
|
import { draw, fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { appState, completeRequest } from '../lib/state.js';
|
import { appState, cleanupRequest } from '../lib/state.js';
|
||||||
|
|
||||||
let success = false;
|
let success = false;
|
||||||
let error = null;
|
let error = null;
|
||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
completeRequest,
|
cleanupRequest,
|
||||||
// Extra 50ms so the window can finish disappearing before the redraw
|
// Extra 50ms so the window can finish disappearing before the redraw
|
||||||
Math.min(5000, $appState.config.rehide_ms + 50),
|
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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@ -33,6 +33,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
|
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
|
||||||
{$appState.currentRequest.approval}!
|
{$appState.currentRequest.response.approval}!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { invoke } from '@tauri-apps/api/tauri';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { emit } from '@tauri-apps/api/event';
|
import { emit } from '@tauri-apps/api/event';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@ -55,7 +55,14 @@
|
|||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
emit('credentials-event', 'unlock-canceled');
|
emit('credentials-event', 'unlock-canceled');
|
||||||
navigate('Home');
|
if ($appState.currentRequest !== null) {
|
||||||
|
// dirty hack to prevent spurious error when returning to approve screen
|
||||||
|
delete $appState.currentRequest.response;
|
||||||
|
navigate('Approve');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
navigate('Home');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
16
todo.md
16
todo.md
@ -1,16 +0,0 @@
|
|||||||
## Definitely
|
|
||||||
|
|
||||||
* Switch to "process" provider for AWS credentials (much less hacky)
|
|
||||||
* Session timeout (plain duration, or activity-based?)
|
|
||||||
* Fix rehide behavior when new request comes in while old one is still being resolved
|
|
||||||
* Additional hotkey configuration (approve/deny at the very least)
|
|
||||||
* Logging
|
|
||||||
* Icon
|
|
||||||
* SSH key handling
|
|
||||||
|
|
||||||
## Maybe
|
|
||||||
|
|
||||||
* Flatten error type hierarchy
|
|
||||||
* Rehide after terminal launch from locked
|
|
||||||
* Generalize Request across both credentials and terminal launch?
|
|
||||||
* Make hotkey configuration a little more tolerant of slight mistiming
|
|
Reference in New Issue
Block a user