托盘
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user