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:
CRTkafa 2026-09-29 11:13:01 +07:00
parent dc44136866
commit 40ae81afbe
5 changed files with 721 additions and 1 deletions

View 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);
}
}