host: gallery folders and the opt-in clipboard history
media: gallery settings (absolute folders, deduplicated), listing and resolving images only directly inside owner-chosen folders (links must stay inside), data URLs, the bounded clipboard history with its index and image files, PNG encoding. media::clipboard (tauri feature): reading the OS clipboard, the Windows change counter, skipping copies marked private by password managers, and the opt-in polling loop (records from the next copy after opting in; releases the clipboard while off). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc44136866
commit
40ae81afbe
5 changed files with 721 additions and 1 deletions
108
crates/waybetter-host/src/media/clipboard.rs
Normal file
108
crates/waybetter-host/src/media/clipboard.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
//! The OS clipboard for the opt-in clipboard history (`tauri` feature): reading text or an image,
|
||||
//! skipping copies that password managers and similar apps mark as private, and the polling loop.
|
||||
//! Nothing is read while the owner's opt-in is off, and the OS clipboard handle is released then.
|
||||
|
||||
use super::{content_hash, MAX_CLIP_TEXT_BYTES};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Raw RGBA larger than this (e.g. a 4K screenshot is ~33 MB) is skipped.
|
||||
pub const MAX_CLIP_RGBA_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// A new copy: text, or an image as raw RGBA (`hash`, width, height, pixels).
|
||||
pub enum Seen {
|
||||
Text(String, String),
|
||||
Image(String, usize, usize, Vec<u8>),
|
||||
}
|
||||
|
||||
impl Seen {
|
||||
pub fn hash(&self) -> &str {
|
||||
match self {
|
||||
Seen::Text(hash, _) | Seen::Image(hash, ..) => hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read(clipboard: &mut arboard::Clipboard) -> Option<Seen> {
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.trim().is_empty() && text.len() <= MAX_CLIP_TEXT_BYTES {
|
||||
return Some(Seen::Text(content_hash("text", text.as_bytes()), text));
|
||||
}
|
||||
}
|
||||
let image = clipboard.get_image().ok()?;
|
||||
if image.bytes.is_empty() || image.bytes.len() > MAX_CLIP_RGBA_BYTES {
|
||||
return None;
|
||||
}
|
||||
Some(Seen::Image(content_hash("image", &image.bytes), image.width, image.height, image.bytes.into_owned()))
|
||||
}
|
||||
|
||||
/// Windows clipboard change counter: lets the poll skip reading (and hashing a possibly large
|
||||
/// image) every time when nothing was copied. `None` elsewhere, so every poll reads.
|
||||
#[cfg(windows)]
|
||||
fn change_marker() -> Option<u32> {
|
||||
clipboard_win::raw::seq_num().map(|n| n.get())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn change_marker() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Password managers and other apps mark secret copies with these registered formats; clipboard
|
||||
/// monitors (like Windows' own clipboard history) must not record them.
|
||||
#[cfg(windows)]
|
||||
pub fn marked_private() -> bool {
|
||||
["ExcludeClipboardContentFromMonitorProcessing", "Clipboard Viewer Ignore"].iter().any(|name| {
|
||||
clipboard_win::raw::register_format(name).is_some_and(|format| clipboard_win::raw::is_format_avail(format.get()))
|
||||
})
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
pub fn marked_private() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Polls the clipboard every `poll` forever (run it on its own thread). `enabled` is the owner's
|
||||
/// opt-in, asked every round; `on_copy` gets each new copy. Recording starts with the next copy
|
||||
/// after the opt-in, never with whatever was on the clipboard before, and a private copy is never
|
||||
/// read.
|
||||
pub fn watch(poll: Duration, enabled: impl Fn() -> bool, mut on_copy: impl FnMut(Seen)) {
|
||||
let mut clipboard: Option<arboard::Clipboard> = None;
|
||||
let mut last: Option<String> = None;
|
||||
let mut last_marker: Option<u32> = None;
|
||||
let mut was_enabled = false;
|
||||
loop {
|
||||
std::thread::sleep(poll);
|
||||
if !enabled() {
|
||||
// Release the OS clipboard handle entirely while the feature is off.
|
||||
clipboard = None;
|
||||
was_enabled = false;
|
||||
last_marker = None;
|
||||
continue;
|
||||
}
|
||||
let marker = change_marker();
|
||||
if marker.is_some() && marker == last_marker {
|
||||
continue;
|
||||
}
|
||||
last_marker = marker;
|
||||
if marked_private() {
|
||||
// Never read a secret copy; also arm the opt-in so it is not recorded later.
|
||||
was_enabled = true;
|
||||
continue;
|
||||
}
|
||||
if clipboard.is_none() {
|
||||
clipboard = arboard::Clipboard::new().ok();
|
||||
}
|
||||
let Some(board) = clipboard.as_mut() else { continue };
|
||||
let Some(seen) = read(board) else { continue };
|
||||
let hash = seen.hash().to_string();
|
||||
// Opt-in starts recording from the next copy, not whatever was on the clipboard before.
|
||||
if !was_enabled {
|
||||
was_enabled = true;
|
||||
last = Some(hash);
|
||||
continue;
|
||||
}
|
||||
if last.as_deref() == Some(hash.as_str()) {
|
||||
continue;
|
||||
}
|
||||
last = Some(hash);
|
||||
on_copy(seen);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue