Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bws-tui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions bws-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ anyhow = "1"
humantime = "2"
clap = { version = "4", features = ["derive"] }
arboard = "3"
base64 = "0.22"
6 changes: 3 additions & 3 deletions bws-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The official `bws` CLI already has the authentication and API access. What it do
- **Type to find.** Fuzzy filtering is always live; arrows always move.
- **Enter means actions, never exit.** Copy, Reveal, Edit, Delete, or Cancel appear as plain English.
- **Values stay out of lists and logs.** Reveal is deliberate; copy is explicit.
- **Clipboard copies expire.** The value clears after 30 seconds if it is still unchanged and `hush` remains open.
- **Native clipboard copies expire.** The value clears after 30 seconds if it is still unchanged and `hush` remains open. Headless terminal copies use OSC 52 and must be cleared manually.
- **Projects never drift.** The menu comes from `bws project list` every time.
- **No config file.** `bws` keeps its token/profile configuration; `hush` adds none.

Expand Down Expand Up @@ -101,7 +101,7 @@ cargo install --locked bws-tui
hush
```

Tested on macOS. The crate uses `arboard` for cross-platform clipboard access, but other platforms are not yet part of the release verification matrix.
On macOS and Linux desktops, `hush` uses the native clipboard through `arboard`. On headless Linux, it sends an OSC 52 clipboard write through the terminal. OSC 52 cannot confirm delivery or read back the clipboard, so `hush` does not automatically clear terminal copies. Clear them manually. Terminal copies are limited to 128 KiB of value text. Under tmux, enable `set-clipboard on` and ensure the outer terminal supports clipboard writes.

## The interface

Expand Down Expand Up @@ -198,7 +198,7 @@ That narrow boundary is the point. `hush` is an interface, not another secrets p
- **Masked entry:** TUI value input is never rendered as plaintext while typing.
- **No values in lists:** search rows contain only key and project name.
- **Sanitized failures:** failed `bws` operations report the operation and exit status, never the secret-bearing argv.
- **Conditional clipboard clear:** after 30 seconds, `hush` clears the clipboard only if it still contains the value it copied. Your newer clipboard content is left alone.
- **Conditional native clipboard clear:** after 30 seconds, `hush` clears a native clipboard only if it still contains the value it copied. Your newer clipboard content is left alone. OSC 52 copies are not auto-cleared because the terminal cannot report whether the clipboard changed.
- **Delete guard:** the TUI confirms deletion; scripts require `--yes`.
- **Scoped reads:** `get` and `exec` fetch single secrets by id; only `list`/`sync` refreshes see the full metadata set, and nothing persists values.
- **Names-only audit log:** every operation is recorded with timestamp, verb, and key names — never values.
Expand Down
35 changes: 4 additions & 31 deletions bws-tui/src/tui.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
mod actions;
mod chrome;
mod clipboard;
mod events;
mod forms;
mod render;
mod secrets;
mod terminal;

use crate::bws::{self, Project, Secret};
use anyhow::{Context, Result};
use arboard::Clipboard;
use secrecy::{ExposeSecret, SecretString};
use anyhow::Result;
use clipboard::*;
use secrecy::SecretString;
use std::time::{Duration, Instant};

const ACCENT: ratatui::style::Color = ratatui::style::Color::Cyan;
Expand Down Expand Up @@ -208,34 +209,6 @@ fn fuzzy_match(hay: &str, needle: &str) -> bool {
cur.is_none()
}

fn clipboard_holds_copied_value(current: &str, copied: &SecretString) -> bool {
current == copied.expose_secret()
}

fn clear_clipboard_value(copied: &SecretString) -> Result<bool> {
let mut clipboard = Clipboard::new().context("macOS clipboard is unavailable")?;
let current = clipboard
.get_text()
.context("failed to read the clipboard")?;
if !clipboard_holds_copied_value(&current, copied) {
return Ok(false);
}
clipboard
.set_text(String::new())
.context("failed to clear the clipboard")?;
Ok(true)
}

fn copy_with_autoclear(app: &mut App, value: String) -> Result<()> {
let mut clipboard = Clipboard::new().context("macOS clipboard is unavailable")?;
clipboard
.set_text(value.clone())
.context("failed to copy the secret value")?;
app.clipboard_value = Some(SecretString::from(value));
app.clipboard_clear_at = Some(Instant::now() + Duration::from_secs(30));
Ok(())
}

pub fn run() -> Result<()> {
let mut app = App::new(bws::list_projects()?);
terminal::run(&mut app)
Expand Down
16 changes: 11 additions & 5 deletions bws-tui/src/tui/actions.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
use super::*;
use std::io::Write;

pub(super) fn copy_action(app: &mut App) {
pub(super) fn copy_action(app: &mut App, output: &mut impl Write) {
if let Some(s) = app.selected_secret() {
let key = s.key.clone();
let value = s.value.clone();
match copy_with_autoclear(app, value) {
Ok(()) => app.set_ok(format!("✓ copied “{key}” — clears in 30s (keep app open)")),
match copy_value(app, value, output, should_try_native_clipboard()) {
Ok(CopyMethod::Native) => {
app.set_ok(format!("✓ copied “{key}” — clears in 30s (keep app open)"))
}
Ok(CopyMethod::Terminal) => app.set_ok(format!(
"sent “{key}” to terminal clipboard (OSC 52); clear manually"
)),
Err(error) => app.set_err(&error),
}
}
Expand All @@ -22,9 +28,9 @@ pub(super) fn edit_action(app: &mut App) {
}
}

pub(super) fn run_action(app: &mut App) {
pub(super) fn run_action(app: &mut App, output: &mut impl Write) {
match app.action_idx {
0 => copy_action(app),
0 => copy_action(app, output),
1 => app.revealed = true,
2 => edit_action(app),
3 => app.mode = Mode::ConfirmDelete,
Expand Down
96 changes: 96 additions & 0 deletions bws-tui/src/tui/clipboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use super::App;
use anyhow::{Context, Result};
use arboard::Clipboard;
use base64::{engine::general_purpose::STANDARD, Engine};
use secrecy::{ExposeSecret, SecretString};
use std::{
ffi::OsStr,
io::Write,
time::{Duration, Instant},
};
use zeroize::Zeroizing;

pub(super) fn clipboard_holds_copied_value(current: &str, copied: &SecretString) -> bool {
current == copied.expose_secret()
}

pub(super) fn clear_clipboard_value(copied: &SecretString) -> Result<bool> {
let mut clipboard = Clipboard::new().context("native clipboard is unavailable")?;
let current = clipboard
.get_text()
.context("failed to read the clipboard")?;
if !clipboard_holds_copied_value(&current, copied) {
return Ok(false);
}
clipboard
.set_text(String::new())
.context("failed to clear the clipboard")?;
Ok(true)
}

#[derive(Debug, PartialEq)]
pub(super) enum CopyMethod {
Native,
Terminal,
}

const MAX_OSC52_VALUE_BYTES: usize = 128 * 1024;

pub(super) fn has_display_server(display: Option<&OsStr>, wayland: Option<&OsStr>) -> bool {
display.is_some_and(|value| !value.is_empty()) || wayland.is_some_and(|value| !value.is_empty())
}

pub(super) fn should_try_native_clipboard() -> bool {
!cfg!(target_os = "linux")
|| has_display_server(
std::env::var_os("DISPLAY").as_deref(),
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
)
}

pub(super) fn write_osc52(value: &str, output: &mut impl Write) -> Result<()> {
anyhow::ensure!(
value.len() <= MAX_OSC52_VALUE_BYTES,
"terminal clipboard OSC 52 value exceeds 128 KiB"
);
let encoded = Zeroizing::new(STANDARD.encode(value));
output.write_all(b"\x1b]52;c;")?;
output.write_all(encoded.as_bytes())?;
output.write_all(b"\x07")?;
output
.flush()
.context("failed to flush terminal clipboard")?;
Ok(())
}

pub(super) fn copy_value(
app: &mut App,
value: String,
output: &mut impl Write,
try_native: bool,
) -> Result<CopyMethod> {
let native_error = if try_native {
match Clipboard::new()
.context("native clipboard is unavailable")
.and_then(|mut clipboard| {
clipboard
.set_text(value.clone())
.context("failed to copy using native clipboard")
}) {
Ok(()) => {
app.clipboard_value = Some(SecretString::from(value));
app.clipboard_clear_at = Some(Instant::now() + Duration::from_secs(30));
return Ok(CopyMethod::Native);
}
Err(error) => error,
}
} else {
anyhow::anyhow!("native clipboard unavailable: no display server")
};

write_osc52(&value, output).map_err(|error| {
anyhow::anyhow!("{native_error:#}; terminal clipboard OSC 52 failed: {error:#}")
})?;
// OSC 52 cannot read back the clipboard. Keep any earlier native clear pending.
Ok(CopyMethod::Terminal)
}
4 changes: 2 additions & 2 deletions bws-tui/src/tui/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ pub(super) fn event_loop(
app.action_idx = (app.action_idx + 1).min(ACTIONS.len() - 1);
}
}
KeyCode::Enter => run_action(app),
KeyCode::Char('c') => copy_action(app),
KeyCode::Enter => run_action(app, term.backend_mut()),
KeyCode::Char('c') => copy_action(app, term.backend_mut()),
KeyCode::Char('r') => app.revealed = !app.revealed,
KeyCode::Char('e') => edit_action(app),
KeyCode::Char('d') => app.mode = Mode::ConfirmDelete,
Expand Down
Loading
Loading