Present desktop OTP and sensitive outputs

This commit is contained in:
2026-08-10 20:22:59 +02:00
parent 95bb10b4a1
commit 4a54bf7b6a
13 changed files with 1881 additions and 39 deletions

View File

@@ -32,6 +32,13 @@ pub enum UiAction {
CopyEntry,
DeleteEntry,
ToggleReveal,
GenerateOtp,
CopyOtp,
ImportOtp,
ShowOtpUri,
CopyOtpUri,
ShowOtpQr,
RemoveOtp,
GitStatus,
GitPull,
GitPush,
@@ -72,6 +79,13 @@ impl UiAction {
Self::CopyEntry => "copy-entry",
Self::DeleteEntry => "delete-entry",
Self::ToggleReveal => "toggle-reveal",
Self::GenerateOtp => "generate-otp",
Self::CopyOtp => "copy-otp",
Self::ImportOtp => "import-otp",
Self::ShowOtpUri => "show-otp-uri",
Self::CopyOtpUri => "copy-otp-uri",
Self::ShowOtpQr => "show-otp-qr",
Self::RemoveOtp => "remove-otp",
Self::GitStatus => "git-status",
Self::GitPull => "git-pull",
Self::GitPush => "git-push",
@@ -135,6 +149,7 @@ pub struct ActionContext {
pub focused_field: bool,
pub focused_sensitive: bool,
pub focused_generatable: bool,
pub focused_otp: bool,
pub entry_path: bool,
pub selected_object: bool,
pub git_running: bool,
@@ -237,6 +252,23 @@ pub const ACTIONS: &[ActionSpec] = &[
"Reveal or Hide Field",
None,
),
spec(
UiAction::GenerateOtp,
MenuGroup::Entry,
"Generate OTP Code",
None,
),
spec(UiAction::CopyOtp, MenuGroup::Entry, "Copy OTP Code", None),
spec(
UiAction::ImportOtp,
MenuGroup::Entry,
"Import OTP URI or QR…",
None,
),
spec(UiAction::ShowOtpUri, MenuGroup::Entry, "Show OTP URI", None),
spec(UiAction::CopyOtpUri, MenuGroup::Entry, "Copy OTP URI", None),
spec(UiAction::ShowOtpQr, MenuGroup::Entry, "Show OTP QR", None),
spec(UiAction::RemoveOtp, MenuGroup::Entry, "Remove OTP", None),
spec(UiAction::GitStatus, MenuGroup::Tools, "Git Status…", None),
spec(UiAction::GitPull, MenuGroup::Tools, "Pull", None),
spec(UiAction::GitPush, MenuGroup::Tools, "Push", None),
@@ -378,6 +410,28 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
&& !context.switching_vault
&& context.focused_sensitive
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp => {
context.unlocked
&& context.document_open
&& !context.editing
&& context.focused_otp
&& !context.saving
&& !context.switching_vault
&& !context.modal_open
}
UiAction::ImportOtp => {
context.storage_ready
&& context.unlocked
&& !context.dirty
&& !context.saving
&& !context.switching_vault
&& !context.modal_open
}
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => {
context.storage_ready
&& !context.saving
@@ -497,6 +551,58 @@ pub fn disabled_reason(action: UiAction, context: ActionContext) -> Option<&'sta
UiAction::ToggleReveal if !context.document_open => "Open an entry first",
UiAction::ToggleReveal if !context.focused_sensitive => "Select a sensitive field first",
UiAction::ToggleReveal => "Wait for vault validation",
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
if !context.unlocked =>
{
"Unlock an entry first"
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
if !context.document_open =>
{
"Open an entry first"
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
if !context.focused_otp =>
{
"Select an OTP URI field first"
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
if context.editing =>
{
"Finish editing the entry first"
}
UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp => "Close the current screen first",
UiAction::ImportOtp if !context.storage_ready => "Shared configuration is unavailable",
UiAction::ImportOtp if !context.unlocked => "Unlock the password store first",
UiAction::ImportOtp if context.dirty => "Save or discard the current draft first",
UiAction::ImportOtp if context.saving => "Wait for the active save",
UiAction::ImportOtp if context.switching_vault => "Wait for vault validation",
UiAction::ImportOtp => "Close the current screen first",
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync
if !context.storage_ready =>
{
@@ -561,6 +667,13 @@ pub const fn aliases(action: UiAction) -> &'static [&'static str] {
UiAction::CopyEntry => &["duplicate entry", "copy folder", "pass cp"],
UiAction::DeleteEntry => &["remove", "rm", "delete folder"],
UiAction::ToggleReveal => &["show password", "hide password", "reveal field"],
UiAction::GenerateOtp => &["totp", "hotp", "one time password"],
UiAction::CopyOtp => &["copy totp", "copy hotp", "otp clipboard"],
UiAction::ImportOtp => &["add otp", "scan qr", "import otpauth"],
UiAction::ShowOtpUri => &["show otpauth", "otp secret"],
UiAction::CopyOtpUri => &["copy otpauth", "copy otp secret"],
UiAction::ShowOtpQr => &["otp qr", "export otp"],
UiAction::RemoveOtp => &["delete otp", "remove otpauth"],
UiAction::GitStatus => &["repository status", "git history"],
UiAction::GitPull => &["fetch", "download changes"],
UiAction::GitPush => &["upload changes"],
@@ -623,6 +736,7 @@ mod tests {
focused_field: true,
focused_sensitive: true,
focused_generatable: true,
focused_otp: true,
entry_path: true,
selected_object: true,
git_running: false,
@@ -672,6 +786,17 @@ mod tests {
assert!(enabled(UiAction::InitializeStore, viewing));
assert!(enabled(UiAction::NewFolder, viewing));
assert!(enabled(UiAction::NewEntry, viewing));
for action in [
UiAction::GenerateOtp,
UiAction::CopyOtp,
UiAction::ShowOtpUri,
UiAction::CopyOtpUri,
UiAction::ShowOtpQr,
UiAction::RemoveOtp,
] {
assert!(enabled(action, viewing), "{action:?}");
}
assert!(enabled(UiAction::ImportOtp, viewing));
let locked = ActionContext {
unlocked: false,
@@ -683,6 +808,13 @@ mod tests {
UiAction::CopyEditedField,
UiAction::ToggleReveal,
UiAction::GeneratePassword,
UiAction::GenerateOtp,
UiAction::CopyOtp,
UiAction::ImportOtp,
UiAction::ShowOtpUri,
UiAction::CopyOtpUri,
UiAction::ShowOtpQr,
UiAction::RemoveOtp,
UiAction::Lock,
] {
assert!(!enabled(action, locked));

View File

@@ -2,6 +2,8 @@
use std::path::PathBuf;
use ironstorage::repository::SecretBytes;
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
let mut dialog = rfd::AsyncFileDialog::new().set_title("Open Password Store");
@@ -14,6 +16,22 @@ pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, St
.map(|folder| folder.path().to_owned()))
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
let selected = rfd::AsyncFileDialog::new()
.set_title("Import OTP QR Image")
.add_filter("Image", &["png", "jpg", "jpeg", "gif"])
.pick_file()
.await;
selected
.map(|file| {
std::fs::read(file.path())
.map(SecretBytes::new)
.map_err(|error| error.to_string())
})
.transpose()
}
#[cfg(target_os = "linux")]
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
use ashpd::{
@@ -47,6 +65,37 @@ pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, St
.map(Some)
}
#[cfg(target_os = "linux")]
pub async fn pick_qr_image() -> Result<Option<SecretBytes>, String> {
use ashpd::{
PortalError,
desktop::{file_chooser::SelectedFiles, request::ResponseError},
};
let response = SelectedFiles::open_file()
.title("Import OTP QR Image")
.accept_label("Import")
.modal(true)
.multiple(false)
.send()
.await
.and_then(|request| request.response());
let selected = match response {
Ok(selected) => selected,
Err(ashpd::Error::Response(ResponseError::Cancelled))
| Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None),
Err(error) => return Err(error.to_string()),
};
let Some(uri) = selected.uris().first() else {
return Err("file portal returned no selection".to_owned());
};
let path = file_uri_path(uri.as_str())?;
std::fs::read(path)
.map(SecretBytes::new)
.map(Some)
.map_err(|error| error.to_string())
}
#[cfg(target_os = "linux")]
fn file_uri_path(uri: &str) -> Result<PathBuf, String> {
let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?;

File diff suppressed because it is too large Load Diff

View File

@@ -145,6 +145,13 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
| UiAction::CopyEntry
| UiAction::DeleteEntry
| UiAction::ToggleReveal
| UiAction::GenerateOtp
| UiAction::CopyOtp
| UiAction::ImportOtp
| UiAction::ShowOtpUri
| UiAction::CopyOtpUri
| UiAction::ShowOtpQr
| UiAction::RemoveOtp
| UiAction::GitStatus
| UiAction::GitPull
| UiAction::GitPush