This commit is contained in:
zhongluofeng
2026-07-30 09:09:29 +08:00
parent 74902c4cec
commit f452322aad
32 changed files with 4586 additions and 115 deletions
+205
View File
@@ -0,0 +1,205 @@
//! Tauri 命令:剪贴板模块
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use serde::Serialize;
use tauri::{AppHandle, State};
use super::manager::{ClipboardManager, ClipboardSettings};
use super::reader::dib_to_png;
use super::storage::{ClipboardItem, ClipboardItemDetail};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardStatus {
pub running: bool,
pub count: i64,
}
fn img_to_base64(b: &[u8]) -> Option<String> {
dib_to_png(b).map(|png| STANDARD.encode(&png))
}
/// 历史查询结果(含总数,用于分页)
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryPage {
pub items: Vec<ClipboardItem>,
pub total: i64,
}
#[tauri::command]
pub async fn clipboard_get_history(
limit: Option<i64>,
offset: Option<i64>,
kind: Option<String>,
manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> {
let storage = manager.storage();
let kind = kind.unwrap_or_else(|| "all".into());
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
let items = storage.get_history(limit, offset, &kind);
let total = storage.count_kind(&kind);
Ok(HistoryPage { items, total })
}
#[tauri::command]
pub async fn clipboard_get_pinned(
manager: State<'_, ClipboardManager>,
) -> Result<Vec<ClipboardItem>, String> {
Ok(manager.storage().get_pinned())
}
#[tauri::command]
pub async fn clipboard_search(
query: String,
limit: Option<i64>,
offset: Option<i64>,
manager: State<'_, ClipboardManager>,
) -> Result<HistoryPage, String> {
let storage = manager.storage();
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
let items = storage.search(&query, limit, offset);
let total = storage.count_search(&query);
Ok(HistoryPage { items, total })
}
#[tauri::command]
pub async fn clipboard_get_item(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<Option<ClipboardItemDetail>, String> {
Ok(manager.storage().get_detail(id, img_to_base64))
}
#[tauri::command]
pub async fn clipboard_set_pinned(
id: i64,
pinned: bool,
manager: State<'_, ClipboardManager>,
) -> Result<bool, String> {
Ok(manager.storage().set_pinned(id, pinned))
}
#[tauri::command]
pub async fn clipboard_delete(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<bool, String> {
Ok(manager.storage().delete(id))
}
#[tauri::command]
pub async fn clipboard_clear(manager: State<'_, ClipboardManager>) -> Result<bool, String> {
Ok(manager.storage().clear_non_pinned())
}
#[tauri::command]
pub async fn clipboard_copy_back(
id: i64,
manager: State<'_, ClipboardManager>,
) -> Result<(), String> {
manager.copy_back(id)
}
#[tauri::command]
pub async fn clipboard_count(manager: State<'_, ClipboardManager>) -> Result<i64, String> {
Ok(manager.storage().count())
}
#[tauri::command]
pub async fn clipboard_get_settings(
manager: State<'_, ClipboardManager>,
) -> Result<ClipboardSettings, String> {
Ok(manager.get_settings())
}
#[tauri::command]
pub async fn clipboard_save_settings(
settings: ClipboardSettings,
app: AppHandle,
manager: State<'_, ClipboardManager>,
) -> Result<(), String> {
let prev_enabled = manager.get_settings().enabled;
let prev_shortcut = manager.get_settings().shortcut.clone();
manager.save_settings(settings.clone());
// 监听开关变化时联动启停
if settings.enabled && !prev_enabled {
manager.start(&app);
} else if !settings.enabled && prev_enabled {
manager.stop();
}
// 快捷键变化时重新注册
if settings.shortcut != prev_shortcut {
super::popup::register_shortcut(&app, &settings.shortcut)?;
}
Ok(())
}
#[tauri::command]
pub async fn clipboard_status(
manager: State<'_, ClipboardManager>,
) -> Result<ClipboardStatus, String> {
Ok(ClipboardStatus {
running: manager.is_running(),
count: manager.storage().count(),
})
}
#[tauri::command]
pub async fn clipboard_start(app: AppHandle, manager: State<'_, ClipboardManager>) -> Result<(), String> {
manager.start(&app);
Ok(())
}
#[tauri::command]
pub async fn clipboard_stop(manager: State<'_, ClipboardManager>) -> Result<(), String> {
manager.stop();
Ok(())
}
/// 注册(或切换)快捷弹窗全局快捷键
#[tauri::command]
pub async fn clipboard_register_shortcut(
shortcut: String,
app: AppHandle,
) -> Result<(), String> {
super::popup::register_shortcut(&app, &shortcut)
}
/// 注销快捷弹窗全局快捷键
#[tauri::command]
pub async fn clipboard_unregister_shortcut(app: AppHandle) -> Result<(), String> {
super::popup::unregister_shortcut(&app);
Ok(())
}
/// 手动触发显示快捷弹窗(供 UI 按钮调用)
#[tauri::command]
pub async fn clipboard_show_popup(app: AppHandle) -> Result<(), String> {
super::popup::show_popup(&app);
Ok(())
}
/// 隐藏快捷弹窗
#[tauri::command]
pub async fn clipboard_hide_popup(app: AppHandle) -> Result<(), String> {
super::popup::hide_popup(&app);
Ok(())
}
/// 显示已创建的弹窗窗口(前端 onMounted 后调用)
#[tauri::command]
pub async fn clipboard_show_window(app: AppHandle) -> Result<(), String> {
super::popup::show_window(&app);
Ok(())
}
/// 隐藏弹窗并模拟 Ctrl+V 粘贴到原窗口
#[tauri::command]
pub async fn clipboard_paste_to_target(app: AppHandle) -> Result<(), String> {
super::popup::paste_to_target(&app);
Ok(())
}
+187
View File
@@ -0,0 +1,187 @@
//! ClipboardManager:管理监听线程生命周期、设置持久化、copy_back。
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use serde::{Deserialize, Serialize};
use super::monitor::start_monitor;
use super::reader::{write_dib, write_files, write_text};
use super::storage::Storage;
/// 剪贴板设置(持久化到 clipboard/settings.json
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ClipboardSettings {
/// 监听是否启用
pub enabled: bool,
/// 非固定历史最大条数
pub max_items: i64,
/// 图片大小上限(KB),0 表示不限
pub max_image_kb: u64,
pub record_text: bool,
pub record_image: bool,
pub record_files: bool,
/// 去重(相同内容更新时间而非新增)
pub dedup: bool,
/// 快捷弹窗全局快捷键(如 "Alt+V",空字符串表示禁用)
#[serde(default = "default_shortcut")]
pub shortcut: String,
}
fn default_shortcut() -> String {
"Alt+V".to_string()
}
impl Default for ClipboardSettings {
fn default() -> Self {
Self {
enabled: true,
max_items: 500,
max_image_kb: 5120,
record_text: true,
record_image: true,
record_files: true,
dedup: true,
shortcut: default_shortcut(),
}
}
}
pub struct ClipboardManager {
storage: Arc<Storage>,
settings: Arc<Mutex<ClipboardSettings>>,
suppress: Arc<AtomicBool>,
monitor_stop: Arc<AtomicBool>,
monitor_handle: Mutex<Option<JoinHandle<()>>>,
settings_path: PathBuf,
}
impl ClipboardManager {
pub fn new(app_data_dir: PathBuf) -> Self {
let clip_dir = app_data_dir.join("clipboard");
let storage = match Storage::new(&clip_dir) {
Ok(s) => Arc::new(s),
Err(e) => {
eprintln!("[clipboard] 磁盘存储初始化失败,回退内存: {}", e);
Arc::new(Storage::new_in_memory())
}
};
let settings_path = clip_dir.join("settings.json");
let settings = Arc::new(Mutex::new(load_settings(&settings_path)));
let suppress = Arc::new(AtomicBool::new(false));
let monitor_stop = Arc::new(AtomicBool::new(true));
Self {
storage,
settings,
suppress,
monitor_stop,
monitor_handle: Mutex::new(None),
settings_path,
}
}
/// 启动监听(若已运行则跳过)
pub fn start(&self, app: &tauri::AppHandle) {
let mut handle = self.monitor_handle.lock().unwrap();
if handle.is_some() {
return;
}
self.monitor_stop.store(false, Ordering::SeqCst);
let h = start_monitor(
self.storage.clone(),
app.clone(),
self.settings.clone(),
self.suppress.clone(),
self.monitor_stop.clone(),
);
*handle = Some(h);
eprintln!("[clipboard] 监听已启动");
}
/// 停止监听
pub fn stop(&self) {
self.monitor_stop.store(true, Ordering::SeqCst);
if let Some(h) = self.monitor_handle.lock().unwrap().take() {
// 不阻塞等待;轮询线程最迟 800ms 后退出
let _ = h.join();
}
eprintln!("[clipboard] 监听已停止");
}
pub fn is_running(&self) -> bool {
self.monitor_handle.lock().unwrap().is_some()
}
pub fn get_settings(&self) -> ClipboardSettings {
self.settings.lock().unwrap().clone()
}
pub fn save_settings(&self, s: ClipboardSettings) {
{
*self.settings.lock().unwrap() = s.clone();
}
save_settings(&self.settings_path, &s);
}
/// 将某条历史写回剪贴板。写回前置 suppress 标志以避免再次记录。
pub fn copy_back(&self, id: i64) -> Result<(), String> {
let (kind, content, blob) = self
.storage
.get_raw_for_copy(id)
.ok_or_else(|| "条目不存在".to_string())?;
self.suppress.store(true, Ordering::SeqCst);
let ok = match kind.as_str() {
"text" => content.as_deref().map(write_text).unwrap_or(false),
"image" => blob.as_deref().map(write_dib).unwrap_or(false),
"files" => {
let paths: Vec<String> = content
.as_deref()
.and_then(|c| serde_json::from_str(c).ok())
.unwrap_or_default();
if paths.is_empty() {
false
} else {
write_files(&paths)
}
}
_ => false,
};
if ok {
Ok(())
} else {
// 写入失败也清除 suppress,避免误吞下次复制
self.suppress.store(false, Ordering::SeqCst);
Err("写回剪贴板失败".into())
}
}
pub fn storage(&self) -> &Arc<Storage> {
&self.storage
}
}
impl Drop for ClipboardManager {
fn drop(&mut self) {
self.stop();
}
}
fn load_settings(path: &Path) -> ClipboardSettings {
match fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => ClipboardSettings::default(),
}
}
fn save_settings(path: &Path, s: &ClipboardSettings) {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(s) {
let _ = fs::write(path, json);
}
}
+17
View File
@@ -0,0 +1,17 @@
//! 剪贴板增强模块:监听、SQLite 持久化、多格式预览、固定、写回、快捷弹窗。
pub mod commands;
pub mod manager;
pub mod monitor;
pub mod popup;
pub mod reader;
pub mod storage;
pub use commands::{
clipboard_clear, clipboard_copy_back, clipboard_count, clipboard_delete, clipboard_get_history,
clipboard_get_item, clipboard_get_pinned, clipboard_get_settings, clipboard_hide_popup,
clipboard_paste_to_target, clipboard_register_shortcut, clipboard_save_settings, clipboard_search,
clipboard_set_pinned, clipboard_show_popup, clipboard_show_window, clipboard_start,
clipboard_status, clipboard_stop, clipboard_unregister_shortcut,
};
pub use manager::ClipboardManager;
+169
View File
@@ -0,0 +1,169 @@
//! 剪贴板监听线程:基于 GetClipboardSequenceNumber 轮询
//!
//! 选用轮询而非 AddClipboardFormatListener 消息窗口:实现更简单、无需消息循环,
//! 800ms 间隔对剪贴板场景延迟可接受,且 GetClipboardSequenceNumber 不需要 OpenClipboard,开销极小。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use super::reader::{read_clipboard, ClipData};
use super::storage::{NewItem, Storage};
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
/// 启动监听线程,返回 JoinHandle。
pub fn start_monitor(
storage: Arc<Storage>,
app: AppHandle,
settings: Arc<Mutex<super::manager::ClipboardSettings>>,
suppress: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
) -> thread::JoinHandle<()> {
thread::spawn(move || loop {
if stop.load(Ordering::SeqCst) {
break;
}
thread::sleep(Duration::from_millis(800));
if stop.load(Ordering::SeqCst) {
break;
}
let seq = unsafe { GetClipboardSequenceNumber() };
// 首次记录基准;后续对比变化
let last = LAST_SEQ.with(|c| {
let prev = c.get().unwrap_or(seq);
c.set(Some(seq));
prev
});
if seq == last {
continue;
}
// 序列号变化,处理一次
if suppress.swap(false, Ordering::SeqCst) {
// 由本应用 copy_back 触发,跳过记录
continue;
}
let (rec_text, rec_image, rec_files, max_items, max_image_kb, dedup) = {
let s = settings.lock().unwrap();
(
s.record_text,
s.record_image,
s.record_files,
s.max_items,
s.max_image_kb,
s.dedup,
)
};
let data = match read_clipboard() {
Some(d) => d,
None => continue,
};
let item = match &data {
ClipData::Text(t) => {
if !rec_text {
continue;
}
build_text_item(t)
}
ClipData::Image { dib, width, height } => {
if !rec_image {
continue;
}
if max_image_kb > 0 && (dib.len() as u64 / 1024) > max_image_kb {
continue;
}
build_image_item(dib, *width, *height)
}
ClipData::Files(fs) => {
if !rec_files {
continue;
}
build_files_item(fs)
}
};
if let Some(_id) = storage.insert_or_touch(item, dedup) {
storage.prune_to_max(max_items);
let _ = app.emit("clipboard-changed", ());
}
})
}
thread_local! {
static LAST_SEQ: std::cell::Cell<Option<u32>> = std::cell::Cell::new(None);
}
fn build_text_item(t: &str) -> NewItem {
NewItem {
kind: "text".into(),
content: Some(t.to_string()),
blob: None,
preview: make_preview(t, 200),
size: t.len() as i64,
hash: hash_str(t),
}
}
fn build_image_item(dib: &[u8], w: u32, h: u32) -> NewItem {
NewItem {
kind: "image".into(),
content: None,
blob: Some(dib.to_vec()),
preview: format!("图片 {}×{}", w, h),
size: dib.len() as i64,
hash: hash_bytes(dib),
}
}
fn build_files_item(files: &[String]) -> NewItem {
let content = serde_json::to_string(files).unwrap_or_default();
let preview = if files.len() == 1 {
make_preview(&files[0], 200)
} else {
format!(
"{} 个文件 · {}",
files.len(),
make_preview(files.first().map(|s| s.as_str()).unwrap_or(""), 60)
)
};
let hash = hash_str(&content);
NewItem {
kind: "files".into(),
content: Some(content),
blob: None,
preview,
size: files.iter().map(|f| f.len()).sum::<usize>() as i64,
hash,
}
}
fn make_preview(s: &str, max_chars: usize) -> String {
let single: String = s
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let trimmed = single.trim().to_string();
if trimmed.chars().count() <= max_chars {
trimmed
} else {
let truncated: String = trimmed.chars().take(max_chars).collect();
format!("{}", truncated)
}
}
fn hash_str(s: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut h = DefaultHasher::new();
h.write(s.as_bytes());
format!("{:016x}", h.finish())
}
fn hash_bytes(b: &[u8]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut h = DefaultHasher::new();
h.write(b);
format!("{:016x}", h.finish())
}
+254
View File
@@ -0,0 +1,254 @@
//! 剪贴板快捷弹窗:在鼠标位置显示历史记录弹窗。
//!
//! 流程:
//! 1. 全局快捷键触发 → `show_popup` 在鼠标位置创建(隐藏)或显示窗口
//! 2. 前端 Vue 挂载完成、主题应用后调用 `clipboard_show_window` 显示窗口
//! 3. 前端监听 `clipboard-popup-show` 事件刷新数据
//! 4. 用户选择 → 前端调用 `clipboard_copy_back` 写回剪贴板
//! 5. 前端调用 `clipboard_paste_to_target` → 隐藏窗口 → 延迟模拟 Ctrl+V 粘贴
//! 6. 窗口失焦自动隐藏
use std::sync::Mutex;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, Emitter};
use tauri::window::{Effect, EffectsBuilder};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
/// 弹窗窗口标签
pub const POPUP_LABEL: &str = "clipboard-popup";
/// 当前注册的快捷键(用于注销旧快捷键)
static CURRENT_SHORTCUT: Mutex<Option<String>> = Mutex::new(None);
/// 解析快捷键字符串为 Shortcut(格式如 "Alt+V"、"Ctrl+Shift+V"
/// 失败返回 None。
pub fn parse_shortcut(s: &str) -> Option<Shortcut> {
s.trim().parse::<Shortcut>().ok()
}
/// 注册全局快捷键。重复调用会先注销旧快捷键。
/// 传入空字符串则仅注销不注册。
pub fn register_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), String> {
// 先注销旧快捷键
unregister_shortcut(app);
if shortcut_str.trim().is_empty() {
return Ok(());
}
let shortcut = parse_shortcut(shortcut_str)
.ok_or_else(|| format!("无效的快捷键: {}", shortcut_str))?;
let app_handle = app.clone();
app.global_shortcut()
.on_shortcut(shortcut, move |_app, _shortcut, event| {
// 仅在按下时触发(松开不触发)
if event.state == ShortcutState::Pressed {
show_popup(&app_handle);
}
})
.map_err(|e| format!("注册快捷键失败: {}", e))?;
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = Some(shortcut_str.to_string());
}
eprintln!("[clipboard] 已注册快捷键: {}", shortcut_str);
Ok(())
}
/// 注销当前快捷键
pub fn unregister_shortcut(app: &AppHandle) {
if let Ok(cur) = CURRENT_SHORTCUT.lock() {
if let Some(ref s) = *cur {
if let Some(shortcut) = parse_shortcut(s) {
let _ = app.global_shortcut().unregister(shortcut);
}
}
}
if let Ok(mut cur) = CURRENT_SHORTCUT.lock() {
*cur = None;
}
}
/// 在鼠标当前位置显示弹窗。
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
/// 窗口已存在则移动到鼠标位置并显示。
pub fn show_popup(app: &AppHandle) {
// 获取鼠标位置(屏幕坐标)
let (mx, my) = match get_cursor_pos() {
Some(p) => p,
None => return,
};
// 窗口尺寸(逻辑像素)
let w = 380.0_f64;
let h = 460.0_f64;
// 获取屏幕工作区以避免弹出位置超出屏幕
let (screen_w, screen_h) = get_work_area().unwrap_or((1920.0, 1080.0));
// 计算窗口位置:鼠标位置作为左上角,超出屏幕则调整
let x = (mx as f64).min(screen_w - w).max(0.0);
let y = (my as f64).min(screen_h - h).max(0.0);
// 窗口已存在:移动 + 显示 + 请求焦点
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.set_position(tauri::Position::Logical(tauri::LogicalPosition {
x,
y,
}));
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("clipboard-popup-show", ());
return;
}
// 首次创建窗口:先隐藏,等前端挂载完成后再显示
// 这样确保主题效果(dark class、mica)在窗口可见前已应用
let win = match WebviewWindowBuilder::new(
app,
POPUP_LABEL,
WebviewUrl::App("index.html#clipboard-popup".into()),
)
.title("剪贴板")
.inner_size(w, h)
.position(x, y)
.decorations(false)
.transparent(true)
.shadow(true)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false) // 关键:先隐藏,前端 onMounted 后调用 show_window
.focused(true)
.effects(EffectsBuilder::new().effects(vec![Effect::Mica]).build())
.build()
{
Ok(w) => w,
Err(e) => {
eprintln!("[clipboard] 创建弹窗失败: {}", e);
return;
}
};
// 监听窗口失焦:自动隐藏
let app_handle = app.clone();
let win_handle = win.clone();
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
let _ = win_handle.hide();
let _ = app_handle.emit("clipboard-popup-hide", ());
}
});
eprintln!("[clipboard] 弹窗窗口已创建(隐藏状态)");
}
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
pub fn show_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.show();
let _ = win.set_focus();
// 通知前端刷新数据
let _ = app.emit("clipboard-popup-show", ());
}
}
/// 隐藏弹窗(不销毁,保留复用)
pub fn hide_popup(app: &AppHandle) {
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
let _ = win.hide();
}
}
/// 隐藏弹窗后延迟模拟 Ctrl+V 粘贴到之前聚焦的窗口。
/// 延迟 150ms 确保焦点切换回原窗口后再发送按键。
pub fn paste_to_target(app: &AppHandle) {
// 先隐藏弹窗,让焦点回到原窗口
hide_popup(app);
// 在独立线程中延迟模拟 Ctrl+V
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
simulate_paste();
});
}
/// 使用 Win32 SendInput 模拟 Ctrl+V 按键
#[cfg(windows)]
fn simulate_paste() {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP,
VK_CONTROL,
};
const VK_V: u16 = 0x56;
// 安全初始化为零值,避免未定义字段
let mut inputs: [INPUT; 4] = unsafe { std::mem::zeroed() };
// Ctrl down
inputs[0].r#type = INPUT_KEYBOARD;
inputs[0].Anonymous.ki = KEYBDINPUT { wVk: VK_CONTROL, wScan: 0, dwFlags: 0, time: 0, dwExtraInfo: 0 };
// V down
inputs[1].r#type = INPUT_KEYBOARD;
inputs[1].Anonymous.ki = KEYBDINPUT { wVk: VK_V, wScan: 0, dwFlags: 0, time: 0, dwExtraInfo: 0 };
// V up
inputs[2].r#type = INPUT_KEYBOARD;
inputs[2].Anonymous.ki = KEYBDINPUT { wVk: VK_V, wScan: 0, dwFlags: KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0 };
// Ctrl up
inputs[3].r#type = INPUT_KEYBOARD;
inputs[3].Anonymous.ki = KEYBDINPUT { wVk: VK_CONTROL, wScan: 0, dwFlags: KEYEVENTF_KEYUP, time: 0, dwExtraInfo: 0 };
unsafe {
SendInput(4, inputs.as_ptr(), std::mem::size_of::<INPUT>() as i32);
}
}
#[cfg(not(windows))]
fn simulate_paste() {
// 非 Windows 平台暂不支持自动粘贴
}
// ===== Win32 API:获取鼠标位置和工作区 =====
#[cfg(windows)]
mod win_api {
use windows_sys::Win32::Foundation::POINT;
use windows_sys::Win32::UI::WindowsAndMessaging::{GetCursorPos, SystemParametersInfoW, SPI_GETWORKAREA};
/// 获取鼠标位置(屏幕坐标,逻辑像素)
pub fn get_cursor_pos() -> Option<(i32, i32)> {
let mut pt = POINT { x: 0, y: 0 };
unsafe {
if GetCursorPos(&mut pt) != 0 {
Some((pt.x, pt.y))
} else {
None
}
}
}
/// 获取屏幕工作区(排除任务栏,逻辑像素)
pub fn get_work_area() -> Option<(f64, f64)> {
use windows_sys::Win32::Foundation::RECT;
let mut rect = RECT { left: 0, top: 0, right: 0, bottom: 0 };
unsafe {
if SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut rect as *mut _ as *mut _, 0) != 0 {
Some(((rect.right - rect.left) as f64, (rect.bottom - rect.top) as f64))
} else {
None
}
}
}
}
#[cfg(not(windows))]
mod win_api {
pub fn get_cursor_pos() -> Option<(i32, i32)> { None }
pub fn get_work_area() -> Option<(f64, f64)> { None }
}
pub use win_api::{get_cursor_pos, get_work_area};
+280
View File
@@ -0,0 +1,280 @@
//! Win32 剪贴板读取与写回
//!
//! 读取优先级:文本 > 图片 > 文件。写回支持三种格式。
//! 句柄类型说明(windows-sys 0.52):
//! - HWND / HANDLE = isize
//! - HGLOBAL / HLOCAL = *mut c_void
//! - HDROP = isize(与 HANDLE 同)
use windows_sys::Win32::System::DataExchange::{
CloseClipboard, EmptyClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard,
SetClipboardData,
};
use windows_sys::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, GMEM_MOVEABLE, GMEM_ZEROINIT};
use windows_sys::Win32::System::Ole::{CF_DIB, CF_HDROP, CF_UNICODETEXT};
use windows_sys::Win32::UI::Shell::DragQueryFileW;
/// 剪贴板当前内容
pub enum ClipData {
Text(String),
Image { dib: Vec<u8>, width: u32, height: u32 },
Files(Vec<String>),
}
/// 读取剪贴板(需在 monitor 线程或命令线程调用)。OpenClipboard 失败返回 None。
pub fn read_clipboard() -> Option<ClipData> {
unsafe {
if OpenClipboard(0) == 0 {
return None;
}
let result = if IsClipboardFormatAvailable(CF_UNICODETEXT as u32) != 0 {
read_text()
} else if IsClipboardFormatAvailable(CF_DIB as u32) != 0 {
read_image()
} else if IsClipboardFormatAvailable(CF_HDROP as u32) != 0 {
read_files()
} else {
None
};
CloseClipboard();
result
}
}
unsafe fn read_text() -> Option<ClipData> {
let h = GetClipboardData(CF_UNICODETEXT as u32); // HANDLE = isize
if h == 0 {
return None;
}
let hglob = h as *mut core::ffi::c_void; // 实际是 HGLOBAL
let ptr = GlobalLock(hglob) as *const u16;
if ptr.is_null() {
return None;
}
let mut len = 0usize;
while *ptr.add(len) != 0 {
len += 1;
}
let slice = core::slice::from_raw_parts(ptr, len);
let s = String::from_utf16_lossy(slice);
GlobalUnlock(hglob);
if s.is_empty() {
None
} else {
Some(ClipData::Text(s))
}
}
unsafe fn read_image() -> Option<ClipData> {
let h = GetClipboardData(CF_DIB as u32);
if h == 0 {
return None;
}
let hglob = h as *mut core::ffi::c_void;
let ptr = GlobalLock(hglob) as *const u8;
if ptr.is_null() {
return None;
}
let size = GlobalSize(hglob);
let slice = core::slice::from_raw_parts(ptr, size);
let dib = slice.to_vec();
GlobalUnlock(hglob);
let (w, hgt) = dib_info(&dib).unwrap_or((0, 0));
Some(ClipData::Image {
dib,
width: w,
height: hgt,
})
}
unsafe fn read_files() -> Option<ClipData> {
let h = GetClipboardData(CF_HDROP as u32); // HANDLE = isize
if h == 0 {
return None;
}
let hdrop = h; // HDROP = isize
let count = DragQueryFileW(hdrop, 0xFFFFFFFF, core::ptr::null_mut(), 0);
let mut files = Vec::with_capacity(count as usize);
for i in 0..count {
let len = DragQueryFileW(hdrop, i, core::ptr::null_mut(), 0);
if len == 0 {
continue;
}
let mut buf = vec![0u16; (len as usize) + 1];
let got = DragQueryFileW(hdrop, i, buf.as_mut_ptr(), buf.len() as u32);
let s = String::from_utf16_lossy(&buf[..got as usize]);
files.push(s);
}
// 剪贴板拥有句柄,不调用 DragFinish
if files.is_empty() {
None
} else {
Some(ClipData::Files(files))
}
}
// ===== 写回剪贴板(copy_back =====
/// 写回文本。失败返回 false。写回会清空其他格式。
pub fn write_text(s: &str) -> bool {
unsafe {
if OpenClipboard(0) == 0 {
return false;
}
let r = write_text_inner(s);
CloseClipboard();
r
}
}
unsafe fn write_text_inner(s: &str) -> bool {
if EmptyClipboard() == 0 {
return false;
}
let mut utf16: Vec<u16> = s.encode_utf16().collect();
utf16.push(0); // null terminator
let byte_len = utf16.len() * 2;
let hglob = GlobalAlloc(GMEM_MOVEABLE, byte_len);
if hglob.is_null() {
return false;
}
let ptr = GlobalLock(hglob) as *mut u16;
if ptr.is_null() {
return false;
}
core::ptr::copy_nonoverlapping(utf16.as_ptr(), ptr, utf16.len());
GlobalUnlock(hglob);
SetClipboardData(CF_UNICODETEXT as u32, hglob as isize) != 0
}
/// 写回图片(CF_DIB 原始字节)。
pub fn write_dib(dib: &[u8]) -> bool {
unsafe {
if OpenClipboard(0) == 0 {
return false;
}
let r = (|| {
if EmptyClipboard() == 0 {
return false;
}
let hglob = GlobalAlloc(GMEM_MOVEABLE, dib.len());
if hglob.is_null() {
return false;
}
let ptr = GlobalLock(hglob) as *mut u8;
if ptr.is_null() {
return false;
}
core::ptr::copy_nonoverlapping(dib.as_ptr(), ptr, dib.len());
GlobalUnlock(hglob);
SetClipboardData(CF_DIB as u32, hglob as isize) != 0
})();
CloseClipboard();
r
}
}
/// 写回文件列表(CF_HDROP)。
pub fn write_files(paths: &[String]) -> bool {
unsafe {
if OpenClipboard(0) == 0 {
return false;
}
let r = (|| {
if EmptyClipboard() == 0 {
return false;
}
// DROPFILES 头部 20 字节:pFiles(u32) + pt.x(i32) + pt.y(i32) + fNC(i32) + fWide(i32)
const DF_SIZE: usize = 20;
let mut wide: Vec<u16> = Vec::new();
for p in paths {
wide.extend(p.encode_utf16());
wide.push(0); // 每条字符串 null 结尾
}
wide.push(0); // 末尾额外 null 表示列表结束
let total = DF_SIZE + wide.len() * 2;
let hglob = GlobalAlloc(GMEM_MOVEABLE | GMEM_ZEROINIT, total);
if hglob.is_null() {
return false;
}
let ptr = GlobalLock(hglob) as *mut u8;
if ptr.is_null() {
return false;
}
let offset = DF_SIZE as u32;
let off_bytes = offset.to_ne_bytes();
core::ptr::copy_nonoverlapping(off_bytes.as_ptr(), ptr, 4); // pFiles
let fwide: u32 = 1;
let fwide_bytes = fwide.to_ne_bytes();
core::ptr::copy_nonoverlapping(fwide_bytes.as_ptr(), ptr.add(16), 4); // fWide = 1
let str_ptr = ptr.add(DF_SIZE) as *mut u16;
core::ptr::copy_nonoverlapping(wide.as_ptr(), str_ptr, wide.len());
GlobalUnlock(hglob);
SetClipboardData(CF_HDROP as u32, hglob as isize) != 0
})();
CloseClipboard();
r
}
}
// ===== DIB → PNG(用于预览) =====
/// 从 DIB 字节解析宽高
pub fn dib_info(dib: &[u8]) -> Option<(u32, u32)> {
if dib.len() < 40 {
return None;
}
let width = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]);
let height = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
if width <= 0 {
return None;
}
Some((width as u32, height.abs() as u32))
}
/// 将 CF_DIB 字节转换为 PNG 字节(仅支持 24/32bpp BI_RGB)。
pub fn dib_to_png(dib: &[u8]) -> Option<Vec<u8>> {
use image::codecs::png::PngEncoder;
use image::ImageEncoder;
if dib.len() < 40 {
return None;
}
let header_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]);
let width = i32::from_le_bytes([dib[4], dib[5], dib[6], dib[7]]);
let height_raw = i32::from_le_bytes([dib[8], dib[9], dib[10], dib[11]]);
let bpp = u16::from_le_bytes([dib[14], dib[15]]);
let compression = u32::from_le_bytes([dib[16], dib[17], dib[18], dib[19]]);
if width <= 0 || compression != 0 {
return None;
}
if bpp != 24 && bpp != 32 {
return None;
}
let height = height_raw.abs();
let top_down = height_raw < 0;
let pixel_offset = header_size as usize; // 24/32bpp 无调色板
let row_size = ((bpp as u32 * width as u32 + 31) / 32) * 4;
let bytes_per_pixel = (bpp / 8) as usize;
let needed = pixel_offset + (row_size as usize) * (height as usize);
if dib.len() < needed {
return None;
}
let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
for y in 0..height {
let src_row = if top_down { y } else { height - 1 - y };
let src_off = pixel_offset + (src_row as usize) * (row_size as usize);
for x in 0..width {
let sp = src_off + (x as usize) * bytes_per_pixel;
let dp = (y as usize) * (width as usize) * 4 + (x as usize) * 4;
rgba[dp] = dib[sp + 2]; // R
rgba[dp + 1] = dib[sp + 1]; // G
rgba[dp + 2] = dib[sp]; // B
rgba[dp + 3] = 255; // A
}
}
let mut buf = Vec::new();
let enc = PngEncoder::new(&mut buf);
enc.write_image(&rgba, width as u32, height as u32, image::ExtendedColorType::Rgba8)
.ok()?;
Some(buf)
}
+375
View File
@@ -0,0 +1,375 @@
//! SQLite 持久化:剪贴板历史记录
//!
//! 表结构见 `init_db`。所有方法线程安全(内部 Mutex 包裹 Connection)。
use rusqlite::{params, Connection, OptionalExtension};
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
/// 列表项(不含大字段,用于历史/搜索结果)
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardItem {
pub id: i64,
pub kind: String,
pub preview: String,
pub size: i64,
pub pinned: bool,
pub pinned_order: Option<i64>,
pub created_at: i64,
}
/// 详情(含文本内容或图片 base64)
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardItemDetail {
#[serde(flatten)]
pub item: ClipboardItem,
/// 文本内容 / 文件列表 JSON
pub content: Option<String>,
/// 图片 PNG base64(仅 image 类型)
pub image_base64: Option<String>,
}
/// 新捕获的剪贴板数据
pub struct NewItem {
pub kind: String,
pub content: Option<String>,
pub blob: Option<Vec<u8>>,
pub preview: String,
pub size: i64,
pub hash: String,
}
pub struct Storage {
conn: Mutex<Connection>,
}
impl Storage {
pub fn new(data_dir: &PathBuf) -> std::io::Result<Self> {
fs::create_dir_all(data_dir)?;
let db_path = data_dir.join("history.db");
let conn = Connection::open(&db_path)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
conn.busy_timeout(std::time::Duration::from_secs(3)).ok();
Self::init_db(&conn);
Ok(Self {
conn: Mutex::new(conn),
})
}
/// 内存数据库回退(磁盘初始化失败时使用,数据不持久化)
pub fn new_in_memory() -> Self {
let conn = Connection::open_in_memory().expect("open_in_memory");
Self::init_db(&conn);
Self {
conn: Mutex::new(conn),
}
}
fn init_db(conn: &Connection) {
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS clipboard_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
content TEXT,
blob BLOB,
preview TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
hash TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
pinned_order INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_history(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pinned ON clipboard_history(pinned, pinned_order);
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_history(hash);
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_history(kind);",
);
}
/// 插入新条目;若 dedup 为 true 且 hash 已存在则仅更新 created_at,返回条目 id。
/// 返回 None 表示未插入(内容为空)。
pub fn insert_or_touch(&self, item: NewItem, dedup: bool) -> Option<i64> {
if item.preview.is_empty() && item.content.is_none() && item.blob.is_none() {
return None;
}
let conn = self.conn.lock().ok()?;
if dedup {
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM clipboard_history WHERE hash = ?1",
params![item.hash],
|r| r.get(0),
)
.optional()
.ok()
.flatten();
if let Some(id) = existing {
// 更新时间戳到当前(置顶到列表最前)
let now = now_ms();
let _ = conn.execute(
"UPDATE clipboard_history SET created_at = ?1 WHERE id = ?2",
params![now, id],
);
return Some(id);
}
}
let now = now_ms();
let res = conn.execute(
"INSERT INTO clipboard_history
(kind, content, blob, preview, size, hash, pinned, pinned_order, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, NULL, ?7)",
params![
item.kind,
item.content,
item.blob.as_deref(),
item.preview,
item.size,
item.hash,
now,
],
);
if res.is_err() {
return None;
}
Some(conn.last_insert_rowid())
}
/// 获取非固定的历史记录(按时间倒序)
pub fn get_history(&self, limit: i64, offset: i64, kind: &str) -> Vec<ClipboardItem> {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return vec![],
};
let sql = if kind == "all" {
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
FROM clipboard_history WHERE pinned = 0
ORDER BY created_at DESC LIMIT ?1 OFFSET ?2"
} else {
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
FROM clipboard_history WHERE pinned = 0 AND kind = ?1
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
};
let mut stmt = match conn.prepare(sql) {
Ok(s) => s,
Err(_) => return vec![],
};
let rows = if kind == "all" {
stmt.query_map(params![limit, offset], row_to_item)
} else {
stmt.query_map(params![kind, limit, offset], row_to_item)
};
rows.map(|r| r.filter_map(|i| i.ok()).collect())
.unwrap_or_default()
}
/// 获取固定条目(按 pinned_order,未设置则按时间倒序)
pub fn get_pinned(&self) -> Vec<ClipboardItem> {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return vec![],
};
let mut stmt = match conn.prepare(
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
FROM clipboard_history WHERE pinned = 1
ORDER BY pinned_order ASC NULLS LAST, created_at DESC",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map([], row_to_item)
.map(|r| r.filter_map(|i| i.ok()).collect())
.unwrap_or_default()
}
/// 搜索(跨固定/非固定,按时间倒序)
pub fn search(&self, query: &str, limit: i64, offset: i64) -> Vec<ClipboardItem> {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return vec![],
};
let like = format!("%{}%", query);
let mut stmt = match conn.prepare(
"SELECT id, kind, preview, size, pinned, pinned_order, created_at
FROM clipboard_history
WHERE pinned = 0 AND (preview LIKE ?1 OR content LIKE ?1)
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map(params![like, limit, offset], row_to_item)
.map(|r| r.filter_map(|i| i.ok()).collect())
.unwrap_or_default()
}
/// 获取详情(含文本/图片预览 base64)
pub fn get_detail(&self, id: i64, image_to_base64: impl Fn(&[u8]) -> Option<String>) -> Option<ClipboardItemDetail> {
let conn = self.conn.lock().ok()?;
let row = conn
.query_row(
"SELECT id, kind, preview, size, pinned, pinned_order, created_at, content, blob
FROM clipboard_history WHERE id = ?1",
params![id],
|r| {
let kind: String = r.get(1)?;
let content: Option<String> = r.get(7)?;
let blob: Option<Vec<u8>> = r.get(8)?;
Ok((
ClipboardItem {
id: r.get(0)?,
kind,
preview: r.get(2)?,
size: r.get(3)?,
pinned: r.get::<_, i64>(4)? != 0,
pinned_order: r.get(5)?,
created_at: r.get(6)?,
},
content,
blob,
))
},
)
.ok()?;
let (item, content, blob) = row;
let image_base64 = if item.kind == "image" {
blob.as_deref().and_then(|b| image_to_base64(b))
} else {
None
};
Some(ClipboardItemDetail {
item,
content,
image_base64,
})
}
/// 获取原始字段供 copy_back 写回(避免 base64 转换开销)
pub fn get_raw_for_copy(&self, id: i64) -> Option<(String, Option<String>, Option<Vec<u8>>)> {
let conn = self.conn.lock().ok()?;
conn.query_row(
"SELECT kind, content, blob FROM clipboard_history WHERE id = ?1",
params![id],
|r| Ok((r.get::<_, String>(0)?, r.get(1)?, r.get(2)?)),
)
.ok()
}
/// 切换固定状态。设为固定时分配一个递减的 pinned_order(靠前)。
pub fn set_pinned(&self, id: i64, pinned: bool) -> bool {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return false,
};
if pinned {
// 取当前最小 pinned_order,再 -1 使其排到最前
let min_order: Option<i64> = conn
.query_row("SELECT MIN(pinned_order) FROM clipboard_history WHERE pinned = 1", [], |r| r.get(0))
.ok()
.flatten();
let new_order = min_order.unwrap_or(0) - 1;
conn.execute(
"UPDATE clipboard_history SET pinned = 1, pinned_order = ?1 WHERE id = ?2",
params![new_order, id],
)
.is_ok()
} else {
conn.execute(
"UPDATE clipboard_history SET pinned = 0, pinned_order = NULL WHERE id = ?1",
params![id],
)
.is_ok()
}
}
pub fn delete(&self, id: i64) -> bool {
self.conn
.lock()
.ok()
.map(|c| c.execute("DELETE FROM clipboard_history WHERE id = ?1", params![id]).is_ok())
.unwrap_or(false)
}
/// 清空所有非固定条目
pub fn clear_non_pinned(&self) -> bool {
self.conn
.lock()
.ok()
.map(|c| c.execute("DELETE FROM clipboard_history WHERE pinned = 0", []).is_ok())
.unwrap_or(false)
}
/// 超过 max 时自动清理最旧的非固定条目
pub fn prune_to_max(&self, max_items: i64) {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return,
};
let _ = conn.execute(
"DELETE FROM clipboard_history
WHERE pinned = 0 AND id IN (
SELECT id FROM clipboard_history
WHERE pinned = 0
ORDER BY created_at DESC
LIMIT -1 OFFSET ?1
)",
params![max_items],
);
}
pub fn count(&self) -> i64 {
self.count_kind("all")
}
/// 按类型统计非固定条目总数(kind="all" 表示全部)
pub fn count_kind(&self, kind: &str) -> i64 {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return 0,
};
let sql = if kind == "all" {
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0"
} else {
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND kind = ?1"
};
if kind == "all" {
conn.query_row(sql, [], |r| r.get(0)).ok().unwrap_or(0)
} else {
conn.query_row(sql, params![kind], |r| r.get(0)).ok().unwrap_or(0)
}
}
/// 按搜索关键词统计匹配的非固定条目总数
pub fn count_search(&self, query: &str) -> i64 {
let conn = match self.conn.lock() {
Ok(c) => c,
Err(_) => return 0,
};
let pattern = format!("%{}%", query);
conn.query_row(
"SELECT COUNT(*) FROM clipboard_history WHERE pinned = 0 AND preview LIKE ?1",
params![pattern],
|r| r.get(0),
)
.ok()
.unwrap_or(0)
}
}
fn row_to_item(r: &rusqlite::Row<'_>) -> rusqlite::Result<ClipboardItem> {
Ok(ClipboardItem {
id: r.get(0)?,
kind: r.get(1)?,
preview: r.get(2)?,
size: r.get(3)?,
pinned: r.get::<_, i64>(4)? != 0,
pinned_order: r.get(5)?,
created_at: r.get(6)?,
})
}
pub fn now_ms() -> i64 {
chrono::Local::now().timestamp_millis()
}