tsafe-tui 1.0.10

Terminal UI for tsafe secret vault — full-screen browser with keyboard navigation, history viewer, quick-unlock
Documentation
//! System clipboard backend for the TUI yank (`y`) hotkey.
//!
//! The trait exists so unit tests can inject a fake without depending on a
//! real X11/Wayland/macOS/Windows clipboard service. Auto-clear semantics
//! live on [`crate::app::App::maybe_expire_clipboard`].

use std::fmt;

/// Clipboard error surfaced to the user via the status bar and audit log.
///
/// The `String` body is the underlying backend error (arboard's `Display`).
/// On headless systems this typically reads "no display server available".
#[derive(Debug, Clone)]
pub enum ClipboardError {
    /// Backend could not be initialised (no display, no DBus, missing libs).
    Init(String),
    /// Backend was constructed but `set_text` / `get_text` failed.
    Op(String),
}

impl fmt::Display for ClipboardError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Init(m) => write!(f, "Clipboard unavailable: {m}"),
            Self::Op(m) => write!(f, "Clipboard error: {m}"),
        }
    }
}

impl std::error::Error for ClipboardError {}

/// Narrow trait so unit tests can inject a fake without a real clipboard service.
///
/// `Send` is required because [`crate::app::App`] holds the trait object and is
/// passed to render closures inside the event loop.
pub trait ClipboardBackend: Send {
    /// Place `text` on the system clipboard with no concealment hint. Used for
    /// the empty-string clear and other non-secret writes.
    fn set_text(&mut self, text: &str) -> Result<(), ClipboardError>;

    /// Place `text` on the system clipboard *and* request that conformant
    /// clipboard managers (Maccy, Pastebot, Klipper, etc.) skip archiving it.
    /// On macOS this sets `org.nspasteboard.ConcealedType` per the
    /// [nspasteboard.org](http://nspasteboard.org/) community standard. On
    /// Linux/Windows there is no equivalent convention, so this falls back to
    /// a normal write.
    ///
    /// Returns the [`ClipboardOutcome`] so the caller (and audit log) can
    /// distinguish "concealed" from "wrote but couldn't conceal" — never
    /// silently drop the difference.
    fn set_secret_text(&mut self, text: &str) -> Result<ClipboardOutcome, ClipboardError>;

    /// Read the current clipboard contents (used by auto-clear to compare).
    fn get_text(&mut self) -> Result<String, ClipboardError>;
}

/// Result of a [`ClipboardBackend::set_secret_text`] call. Carries forward the
/// honest answer to "did the OS-level concealment hint actually apply?" so the
/// audit log can record it (`AuditClipboardContext::excluded_from_history`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClipboardOutcome {
    /// `Some(true)` — concealed-type hint was set (currently macOS only).
    /// `Some(false)` — we tried but the platform path returned an error.
    /// `None` — the platform has no concealed-type convention to set.
    pub excluded_from_history: Option<bool>,
}

/// Production clipboard wrapping [`arboard::Clipboard`].
///
/// Initialisation is lazy so headless detection happens on the first `y` press
/// (fail-loud-per-attempt) rather than at TUI startup. On every call we ensure
/// an inner `arboard::Clipboard` is constructed; if construction errors we
/// surface [`ClipboardError::Init`] and reset so the next press retries.
#[derive(Default)]
pub struct SystemClipboard {
    inner: Option<arboard::Clipboard>,
}

impl SystemClipboard {
    pub fn new() -> Self {
        Self::default()
    }

    fn ensure(&mut self) -> Result<&mut arboard::Clipboard, ClipboardError> {
        match &mut self.inner {
            Some(c) => Ok(c),
            slot @ None => {
                let c =
                    arboard::Clipboard::new().map_err(|e| ClipboardError::Init(e.to_string()))?;
                Ok(slot.insert(c))
            }
        }
    }
}

impl ClipboardBackend for SystemClipboard {
    fn set_text(&mut self, text: &str) -> Result<(), ClipboardError> {
        let res = self
            .ensure()?
            .set_text(text)
            .map_err(|e| ClipboardError::Op(e.to_string()));
        if res.is_err() {
            // Drop the inner so the next attempt re-initialises (handles
            // mid-session display server restarts on Linux/Wayland).
            self.inner = None;
        }
        res
    }

    #[cfg(target_os = "macos")]
    fn set_secret_text(&mut self, text: &str) -> Result<ClipboardOutcome, ClipboardError> {
        use arboard::SetExtApple;
        let cb = self.ensure()?;
        let res = cb
            .set()
            .exclude_from_history()
            .text(text)
            .map_err(|e| ClipboardError::Op(e.to_string()));
        match res {
            Ok(()) => Ok(ClipboardOutcome {
                excluded_from_history: Some(true),
            }),
            Err(e) => {
                self.inner = None;
                Err(e)
            }
        }
    }

    #[cfg(not(target_os = "macos"))]
    fn set_secret_text(&mut self, text: &str) -> Result<ClipboardOutcome, ClipboardError> {
        // No equivalent of `org.nspasteboard.ConcealedType` on Linux/Windows
        // today. Write the value normally and surface `None` so the audit
        // entry honestly records "no concealment was attempted".
        self.set_text(text).map(|()| ClipboardOutcome {
            excluded_from_history: None,
        })
    }

    fn get_text(&mut self) -> Result<String, ClipboardError> {
        let res = self
            .ensure()?
            .get_text()
            .map_err(|e| ClipboardError::Op(e.to_string()));
        if res.is_err() {
            self.inner = None;
        }
        res
    }
}

/// In-memory fake exposed for integration tests in this crate. Cloneable so a
/// test can keep one handle to inspect state while injecting another into
/// [`crate::app::App`] via [`crate::app::App::set_clipboard_backend`].
///
/// Tracks whether the most recent write requested concealment — tests can
/// assert this via `last_was_secret`.
#[derive(Default, Clone)]
pub struct FakeClipboard {
    pub state: std::sync::Arc<std::sync::Mutex<Option<String>>>,
    pub last_was_secret: std::sync::Arc<std::sync::Mutex<bool>>,
}

impl ClipboardBackend for FakeClipboard {
    fn set_text(&mut self, text: &str) -> Result<(), ClipboardError> {
        *self.state.lock().unwrap() = Some(text.to_string());
        *self.last_was_secret.lock().unwrap() = false;
        Ok(())
    }

    fn set_secret_text(&mut self, text: &str) -> Result<ClipboardOutcome, ClipboardError> {
        *self.state.lock().unwrap() = Some(text.to_string());
        *self.last_was_secret.lock().unwrap() = true;
        // Mirror the macOS production path so test assertions match prod.
        Ok(ClipboardOutcome {
            excluded_from_history: Some(true),
        })
    }

    fn get_text(&mut self) -> Result<String, ClipboardError> {
        self.state
            .lock()
            .unwrap()
            .clone()
            .ok_or(ClipboardError::Op("empty".into()))
    }
}

/// Failing fake — every call returns [`ClipboardError::Init`]. Used to assert
/// the headless-detection path in unit tests without actually unsetting
/// `$DISPLAY` on the host.
#[derive(Default, Clone)]
pub struct UnavailableClipboard;

impl ClipboardBackend for UnavailableClipboard {
    fn set_text(&mut self, _text: &str) -> Result<(), ClipboardError> {
        Err(ClipboardError::Init("no display server (test)".into()))
    }
    fn set_secret_text(&mut self, _text: &str) -> Result<ClipboardOutcome, ClipboardError> {
        Err(ClipboardError::Init("no display server (test)".into()))
    }
    fn get_text(&mut self) -> Result<String, ClipboardError> {
        Err(ClipboardError::Init("no display server (test)".into()))
    }
}