use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
#[cfg(target_os = "macos")]
use arboard::SetExtApple;
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "android", target_os = "emscripten")),
))]
use arboard::SetExtLinux;
#[cfg(windows)]
use arboard::SetExtWindows;
use notify_rust::{Notification, Timeout};
use zeroize::Zeroizing;
use crate::cli::i18n;
const POLL_TICK: Duration = Duration::from_millis(100);
const MAX_DEADLINE: Duration = Duration::from_hours(24 * 365);
enum ClearOutcome {
Cleared,
Unchanged,
ForciblyCleared,
}
trait ClipboardBackend {
fn current_text(&mut self) -> anyhow::Result<String>;
fn clear(&mut self) -> anyhow::Result<()>;
}
impl ClipboardBackend for arboard::Clipboard {
fn current_text(&mut self) -> anyhow::Result<String> {
self.get().text().map_err(anyhow::Error::from)
}
fn clear(&mut self) -> anyhow::Result<()> {
Self::clear(self).map_err(anyhow::Error::from)
}
}
struct ClipboardGuard<C: ClipboardBackend> {
clipboard: C,
expected: Zeroizing<String>,
cleared: bool,
}
impl<C: ClipboardBackend> ClipboardGuard<C> {
fn clear_if_unchanged(&mut self) -> anyhow::Result<ClearOutcome> {
self.cleared = true;
match self.clipboard.current_text() {
Ok(current) if current.as_str() == self.expected.as_str() => {
self.clipboard.clear()?;
Ok(ClearOutcome::Cleared)
}
Ok(_) => Ok(ClearOutcome::Unchanged),
Err(error) => {
i18n::clipboard_read_for_compare_failed(&error);
self.clipboard.clear()?;
Ok(ClearOutcome::ForciblyCleared)
}
}
}
}
impl<C: ClipboardBackend> Drop for ClipboardGuard<C> {
fn drop(&mut self) {
if self.cleared {
return;
}
if let Err(error) = self.clear_if_unchanged() {
i18n::clipboard_drop_clear_failed(&error);
}
}
}
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
static HANDLER_INSTALLED: OnceLock<()> = OnceLock::new();
fn install_interrupt_handler() {
HANDLER_INSTALLED.get_or_init(|| {
if let Err(error) = ctrlc::set_handler(|| INTERRUPTED.store(true, Ordering::Relaxed)) {
i18n::clipboard_ctrlc_handler_install_failed(&error);
}
});
}
pub fn copy_text_to_clipboard(text: &str, duration: Duration, notify: bool) -> anyhow::Result<()> {
install_interrupt_handler();
INTERRUPTED.store(false, Ordering::Relaxed);
let mut clipboard = arboard::Clipboard::new()?;
clipboard.set().exclude_from_history().text(text)?;
let mut guard = ClipboardGuard {
clipboard,
expected: Zeroizing::new(text.to_owned()),
cleared: false,
};
let deadline = Instant::now() + duration.min(MAX_DEADLINE);
let cancelled = loop {
if INTERRUPTED.load(Ordering::Relaxed) {
break true;
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break false;
}
thread::sleep(remaining.min(POLL_TICK));
};
let outcome = guard.clear_if_unchanged();
drop(guard);
if let Err(error) = &outcome {
i18n::clipboard_clear_failed(error);
i18n::clipboard_manual_clear_required();
}
if notify {
let (body, timeout) = notification(&outcome, cancelled);
if let Err(error) = Notification::new()
.summary("pasejo")
.body(&body)
.timeout(timeout)
.show()
{
i18n::clipboard_notification_dispatch_failed(&error);
}
}
Ok(())
}
fn notification(outcome: &anyhow::Result<ClearOutcome>, cancelled: bool) -> (String, Timeout) {
match outcome {
Ok(ClearOutcome::Cleared) => (
i18n::clipboard_notification_cleared(cancelled),
Timeout::Default,
),
Ok(ClearOutcome::Unchanged) => (
i18n::clipboard_notification_unchanged(cancelled),
Timeout::Default,
),
Ok(ClearOutcome::ForciblyCleared) => (
i18n::clipboard_notification_forcibly_cleared(cancelled),
Timeout::Default,
),
Err(_) => (
i18n::clipboard_notification_failed(cancelled),
Timeout::Never,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn max_deadline_does_not_overflow_instant() {
let _ = Instant::now() + MAX_DEADLINE;
}
#[test]
fn duration_max_is_clamped_to_max_deadline() {
assert_eq!(Duration::MAX.min(MAX_DEADLINE), MAX_DEADLINE);
}
#[test]
fn small_duration_is_not_clamped() {
let small = Duration::from_secs(45);
assert_eq!(small.min(MAX_DEADLINE), small);
}
#[test]
fn cleared_body_uses_default_timeout() {
i18n::init_for_tests();
let (body, timeout) = notification(&Ok(ClearOutcome::Cleared), false);
assert_eq!(body, "Clipboard cleared");
assert!(matches!(timeout, Timeout::Default));
}
#[test]
fn cancelled_suffix_appended_to_cleared() {
i18n::init_for_tests();
let (body, _) = notification(&Ok(ClearOutcome::Cleared), true);
assert_eq!(body, "Clipboard cleared (cancelled)");
}
#[test]
fn cancelled_suffix_appended_to_unchanged() {
i18n::init_for_tests();
let (body, _) = notification(&Ok(ClearOutcome::Unchanged), true);
assert!(body.starts_with("Clipboard left untouched"));
assert!(body.ends_with(" (cancelled)"));
}
#[test]
fn cancelled_suffix_appended_to_forcibly_cleared() {
i18n::init_for_tests();
let (body, _) = notification(&Ok(ClearOutcome::ForciblyCleared), true);
assert!(body.contains("couldn't verify"));
assert!(body.ends_with(" (cancelled)"));
}
#[test]
fn cancelled_suffix_appended_to_failure() {
i18n::init_for_tests();
let (body, _) = notification(&Err(anyhow::anyhow!("boom")), true);
assert!(body.starts_with("Failed to clear clipboard!"));
assert!(body.ends_with(" (cancelled)"));
}
#[test]
fn no_cancelled_suffix_when_not_cancelled() {
for outcome in [
Ok(ClearOutcome::Cleared),
Ok(ClearOutcome::Unchanged),
Ok(ClearOutcome::ForciblyCleared),
] {
let (body, _) = notification(&outcome, false);
assert!(
!body.contains("(cancelled)"),
"unexpected suffix in {body:?}"
);
}
let (body, _) = notification(&Err(anyhow::anyhow!("boom")), false);
assert!(
!body.contains("(cancelled)"),
"unexpected suffix in {body:?}"
);
}
#[test]
fn failure_uses_never_timeout() {
let (_, timeout) = notification(&Err(anyhow::anyhow!("boom")), false);
assert!(matches!(timeout, Timeout::Never));
}
#[test]
fn success_variants_use_default_timeout() {
for outcome in [
Ok(ClearOutcome::Cleared),
Ok(ClearOutcome::Unchanged),
Ok(ClearOutcome::ForciblyCleared),
] {
let (_, timeout) = notification(&outcome, false);
assert!(matches!(timeout, Timeout::Default));
}
}
use std::cell::Cell;
use std::rc::Rc;
#[derive(Default)]
struct FakeStats {
get_calls: Cell<u32>,
clear_calls: Cell<u32>,
}
struct FakeClipboard {
text: String,
fail_get: bool,
fail_clear: bool,
stats: Rc<FakeStats>,
}
impl ClipboardBackend for FakeClipboard {
fn current_text(&mut self) -> anyhow::Result<String> {
self.stats.get_calls.set(self.stats.get_calls.get() + 1);
if self.fail_get {
anyhow::bail!("fake get failure");
}
Ok(self.text.clone())
}
fn clear(&mut self) -> anyhow::Result<()> {
self.stats.clear_calls.set(self.stats.clear_calls.get() + 1);
if self.fail_clear {
anyhow::bail!("fake clear failure");
}
self.text.clear();
Ok(())
}
}
fn guard_with(
text: &str,
expected: &str,
cleared: bool,
) -> (Rc<FakeStats>, ClipboardGuard<FakeClipboard>) {
let stats = Rc::new(FakeStats::default());
let guard = ClipboardGuard {
clipboard: FakeClipboard {
text: text.to_owned(),
fail_get: false,
fail_clear: false,
stats: Rc::clone(&stats),
},
expected: Zeroizing::new(expected.to_owned()),
cleared,
};
(stats, guard)
}
#[test]
fn drop_with_cleared_flag_set_does_not_touch_backend() {
let (stats, guard) = guard_with("secret", "secret", true);
drop(guard);
assert_eq!(stats.get_calls.get(), 0);
assert_eq!(stats.clear_calls.get(), 0);
}
#[test]
fn drop_clears_clipboard_when_text_still_matches() {
let (stats, guard) = guard_with("secret", "secret", false);
drop(guard);
assert_eq!(stats.get_calls.get(), 1);
assert_eq!(stats.clear_calls.get(), 1);
}
#[test]
fn drop_leaves_clipboard_untouched_when_user_copied_something_else() {
let (stats, guard) = guard_with("user-copied-this", "our-secret", false);
drop(guard);
assert_eq!(stats.get_calls.get(), 1);
assert_eq!(stats.clear_calls.get(), 0);
}
#[test]
fn drop_clears_defensively_when_compare_read_fails() {
let stats = Rc::new(FakeStats::default());
let guard = ClipboardGuard {
clipboard: FakeClipboard {
text: String::from("secret"),
fail_get: true,
fail_clear: false,
stats: Rc::clone(&stats),
},
expected: Zeroizing::new(String::from("secret")),
cleared: false,
};
drop(guard);
assert_eq!(stats.get_calls.get(), 1);
assert_eq!(stats.clear_calls.get(), 1);
}
#[test]
fn drop_swallows_clear_failure_without_panicking() {
let stats = Rc::new(FakeStats::default());
let guard = ClipboardGuard {
clipboard: FakeClipboard {
text: String::from("secret"),
fail_get: false,
fail_clear: true,
stats: Rc::clone(&stats),
},
expected: Zeroizing::new(String::from("secret")),
cleared: false,
};
drop(guard);
assert_eq!(stats.get_calls.get(), 1);
assert_eq!(stats.clear_calls.get(), 1);
}
#[test]
fn explicit_clear_propagates_clear_failure_to_caller() {
let stats = Rc::new(FakeStats::default());
let mut guard = ClipboardGuard {
clipboard: FakeClipboard {
text: String::from("secret"),
fail_get: false,
fail_clear: true,
stats: Rc::clone(&stats),
},
expected: Zeroizing::new(String::from("secret")),
cleared: false,
};
let outcome = guard.clear_if_unchanged();
assert!(outcome.is_err());
assert!(guard.cleared);
let calls_before_drop = stats.clear_calls.get();
drop(guard);
assert_eq!(stats.clear_calls.get(), calls_before_drop);
}
}