new clipboard handler which can detect more types

This commit is contained in:
pandadev 2024-08-09 16:53:52 +02:00
parent b29fd82105
commit 7ab1d8936a
No known key found for this signature in database
GPG key ID: C39629DACB8E762F
6 changed files with 346 additions and 334 deletions

View file

@ -11,7 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0-rc.0", "@tauri-apps/api": "^2.0.0-rc.0",
"@tauri-apps/cli": "^2.0.0-rc.1", "@tauri-apps/cli": "^2.0.0-rc.3",
"@tauri-apps/plugin-autostart": "^2.0.0-rc.0", "@tauri-apps/plugin-autostart": "^2.0.0-rc.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0-rc.0", "@tauri-apps/plugin-clipboard-manager": "^2.0.0-rc.0",
"@tauri-apps/plugin-os": "^2.0.0-rc.0", "@tauri-apps/plugin-os": "^2.0.0-rc.0",
@ -21,6 +21,6 @@
"overlayscrollbars": "^2.10.0", "overlayscrollbars": "^2.10.0",
"overlayscrollbars-vue": "^0.5.9", "overlayscrollbars-vue": "^0.5.9",
"sass": "^1.77.8", "sass": "^1.77.8",
"vue": "3.4.35" "vue": "3.4.37"
} }
} }

473
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,12 +7,12 @@ edition = "2021"
rust-version = "1.70" rust-version = "1.70"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.0.0-rc.0", features = [] } tauri-build = { version = "2.0.0-rc.1", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0.0-rc.0", features = ["tray-icon", "image-png"] } tauri = { version = "2.0.0-rc.1", features = ["tray-icon", "image-png"] }
tauri-plugin-sql = {version = "2.0.0-rc.0", features = ["sqlite"] } tauri-plugin-sql = {version = "2.0.0-rc.0", features = ["sqlite"] }
tauri-plugin-clipboard-manager = "2.0.0-rc.0" tauri-plugin-clipboard = "2.1.6"
tauri-plugin-autostart = "2.0.0-rc.0" tauri-plugin-autostart = "2.0.0-rc.0"
tauri-plugin-os = "2.0.0-rc.0" tauri-plugin-os = "2.0.0-rc.0"
tauri-plugin-updater = "2.0.0-rc.0" tauri-plugin-updater = "2.0.0-rc.0"
@ -25,7 +25,6 @@ serde_json = "1.0"
rdev = "0.5.3" rdev = "0.5.3"
rand = "0.8" rand = "0.8"
base64 = "0.22.1" base64 = "0.22.1"
arboard = "3.4.0"
image = "0.25.1" image = "0.25.1"
reqwest = { version = "0.12.5", features = ["blocking"] } reqwest = { version = "0.12.5", features = ["blocking"] }
url = "2.5.2" url = "2.5.2"

View file

@ -28,8 +28,6 @@
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-is-focused", "core:window:allow-is-focused",
"core:window:allow-is-visible", "core:window:allow-is-visible"
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-write-image"
] ]
} }

View file

@ -1,24 +1,21 @@
use arboard::Clipboard;
use base64::engine::general_purpose::STANDARD;
use base64::Engine; use base64::Engine;
use image::io::Reader as ImageReader; use base64::engine::general_purpose::STANDARD;
use image::{DynamicImage, ImageBuffer, ImageFormat, Rgba}; use tauri::{AppHandle, Manager, Runtime, Emitter, Listener};
use lazy_static::lazy_static; use tauri_plugin_clipboard::Clipboard;
use rand::distributions::Alphanumeric; use tokio::runtime::Runtime as TokioRuntime;
use rand::{thread_rng, Rng};
use rdev::{simulate, EventType, Key};
use regex::Regex; use regex::Regex;
use reqwest::Client;
use sha2::{Sha256, Digest};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::fs; use std::{
use std::io::Cursor; fs,
use std::sync::Mutex; sync::Mutex,
use std::thread; thread,
use std::time::Duration; time::Duration,
use tauri::{AppHandle, Manager}; };
use tokio::runtime::Runtime; use rand::Rng;
use url::Url; use sha2::{Sha256, Digest};
use rdev::{simulate, Key, EventType};
use lazy_static::lazy_static;
use image::ImageFormat;
lazy_static! { lazy_static! {
static ref APP_DATA_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None); static ref APP_DATA_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
@ -61,82 +58,82 @@ pub fn get_image_path(app_handle: tauri::AppHandle, filename: String) -> String
image_path.to_str().unwrap_or("").to_string() image_path.to_str().unwrap_or("").to_string()
} }
pub fn setup(app_handle: tauri::AppHandle) { pub fn setup<R: Runtime>(app: &AppHandle<R>) {
let is_processing = std::sync::Arc::new(std::sync::Mutex::new(false)); let app = app.clone();
let runtime = TokioRuntime::new().expect("Failed to create Tokio runtime");
std::thread::spawn({ app.clone().listen("plugin:clipboard://clipboard-monitor/update", move |_event| {
let app_handle = app_handle.clone(); let app = app.clone();
let is_processing = std::sync::Arc::clone(&is_processing); runtime.block_on(async move {
move || { let clipboard = app.state::<Clipboard>();
let mut clipboard = Clipboard::new().unwrap(); let available_types = clipboard.available_types().unwrap();
let mut last_text = String::new();
loop { println!("Clipboard update detected");
let mut is_processing = is_processing.lock().unwrap();
if !*is_processing {
*is_processing = true;
let pool = app_handle.state::<SqlitePool>();
let rt = app_handle.state::<Runtime>();
if let Ok(content) = clipboard.get_text() { match get_pool(&app).await {
if content != last_text { Ok(pool) => {
last_text = content.clone(); if available_types.image {
rt.block_on(async { println!("Handling image change");
insert_content_if_not_exists(&app_handle, &pool, "text", content).await; if let Ok(image_data) = clipboard.read_image_base64() {
}); let base64_image = STANDARD.encode(&image_data);
insert_content_if_not_exists(app.clone(), pool.clone(), "image", base64_image).await;
} }
} let _ = app.emit("plugin:clipboard://image-changed", ());
} else if available_types.html {
if let Ok(image) = clipboard.get_image() { println!("Handling HTML change");
match process_clipboard_image(image) { if let Ok(html) = clipboard.read_html() {
Ok(png_image) => { insert_content_if_not_exists(app.clone(), pool.clone(), "html", html).await;
let base64_image = STANDARD.encode(&png_image);
rt.block_on(async {
insert_content_if_not_exists(&app_handle, &pool, "image", base64_image).await;
});
}
Err(e) => {
println!("Failed to process clipboard image: {}", e);
}
} }
let _ = app.emit("plugin:clipboard://html-changed", ());
} else if available_types.rtf {
println!("Handling RTF change");
if let Ok(rtf) = clipboard.read_rtf() {
insert_content_if_not_exists(app.clone(), pool.clone(), "rtf", rtf).await;
}
let _ = app.emit("plugin:clipboard://rtf-changed", ());
} else if available_types.files {
println!("Handling files change");
if let Ok(files) = clipboard.read_files() {
let files_str = files.join(", ");
insert_content_if_not_exists(app.clone(), pool.clone(), "files", files_str).await;
}
let _ = app.emit("plugin:clipboard://files-changed", ());
} else if available_types.text {
println!("Handling text change");
if let Ok(text) = clipboard.read_text() {
insert_content_if_not_exists(app.clone(), pool.clone(), "text", text).await;
}
let _ = app.emit("plugin:clipboard://text-changed", ());
} else {
println!("Unknown clipboard content type");
} }
*is_processing = false;
} }
thread::sleep(Duration::from_millis(100)); Err(e) => {
println!("Failed to get database pool: {}", e);
}
} }
} });
}); });
} }
fn process_clipboard_image( async fn get_pool<R: Runtime>(app_handle: &AppHandle<R>) -> Result<SqlitePool, Box<dyn std::error::Error + Send + Sync>> {
image_data: arboard::ImageData, let app_data_dir = app_handle.path().app_data_dir().expect("Failed to get app data directory");
) -> Result<Vec<u8>, Box<dyn std::error::Error>> { let db_path = app_data_dir.join("data.db");
let img = ImageBuffer::<Rgba<u8>, _>::from_raw( let database_url = format!("sqlite:{}", db_path.to_str().unwrap());
image_data.width as u32, SqlitePool::connect(&database_url).await.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
image_data.height as u32,
image_data.bytes.into_owned(),
)
.ok_or("Failed to create ImageBuffer")?;
let dynamic_image = DynamicImage::ImageRgba8(img);
let mut png_bytes: Vec<u8> = Vec::new();
dynamic_image.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)?;
Ok(png_bytes)
} }
async fn insert_content_if_not_exists(app_handle: &AppHandle, pool: &SqlitePool, content_type: &str, content: String) { async fn insert_content_if_not_exists<R: Runtime>(app_handle: AppHandle<R>, pool: SqlitePool, content_type: &str, content: String) {
let last_content: Option<String> = sqlx::query_scalar( let last_content: Option<String> = sqlx::query_scalar(
"SELECT content FROM history WHERE content_type = ? ORDER BY timestamp DESC LIMIT 1", "SELECT content FROM history WHERE content_type = ? ORDER BY timestamp DESC LIMIT 1",
) )
.bind(content_type) .bind(content_type)
.fetch_one(pool) .fetch_one(&pool)
.await .await
.unwrap_or(None); .unwrap_or(None);
let content = if content_type == "image" { let content = if content_type == "image" {
match save_image(app_handle, &content).await { match save_image(&app_handle, &content).await {
Ok(path) => path, Ok(path) => path,
Err(e) => { Err(e) => {
println!("Failed to save image: {}", e); println!("Failed to save image: {}", e);
@ -148,15 +145,15 @@ async fn insert_content_if_not_exists(app_handle: &AppHandle, pool: &SqlitePool,
}; };
if last_content.as_deref() != Some(&content) { if last_content.as_deref() != Some(&content) {
let id: String = thread_rng() let id: String = rand::thread_rng()
.sample_iter(&Alphanumeric) .sample_iter(&rand::distributions::Alphanumeric)
.take(16) .take(16)
.map(char::from) .map(char::from)
.collect(); .collect();
let url_regex = Regex::new(r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)$").unwrap(); let url_regex = Regex::new(r"^https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)$").unwrap();
let favicon_base64 = if content_type == "text" && url_regex.is_match(&content) { let favicon_base64 = if content_type == "text" && url_regex.is_match(&content) {
match Url::parse(&content) { match url::Url::parse(&content) {
Ok(url) => match fetch_favicon_as_base64(url).await { Ok(url) => match fetch_favicon_as_base64(url).await {
Ok(Some(favicon)) => Some(favicon), Ok(Some(favicon)) => Some(favicon),
Ok(None) => None, Ok(None) => None,
@ -181,12 +178,12 @@ async fn insert_content_if_not_exists(app_handle: &AppHandle, pool: &SqlitePool,
.bind(content_type) .bind(content_type)
.bind(&content) .bind(&content)
.bind(favicon_base64) .bind(favicon_base64)
.execute(pool) .execute(&pool)
.await; .await;
} }
} }
async fn save_image(app_handle: &AppHandle, base64_image: &str) -> Result<String, Box<dyn std::error::Error>> { async fn save_image<R: Runtime>(app_handle: &AppHandle<R>, base64_image: &str) -> Result<String, Box<dyn std::error::Error>> {
let image_data = STANDARD.decode(base64_image)?; let image_data = STANDARD.decode(base64_image)?;
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(&image_data); hasher.update(&image_data);
@ -205,20 +202,40 @@ async fn save_image(app_handle: &AppHandle, base64_image: &str) -> Result<String
Ok(path.to_str().unwrap().to_string()) Ok(path.to_str().unwrap().to_string())
} }
async fn fetch_favicon_as_base64(url: Url) -> Result<Option<String>, Box<dyn std::error::Error>> { async fn fetch_favicon_as_base64(url: url::Url) -> Result<Option<String>, Box<dyn std::error::Error>> {
let client = Client::new(); let client = reqwest::Client::new();
let favicon_url = format!("https://icon.horse/icon/{}", url.host_str().unwrap()); let favicon_url = format!("https://icon.horse/icon/{}", url.host_str().unwrap());
let response = client.get(&favicon_url).send().await?; let response = client.get(&favicon_url).send().await?;
if response.status().is_success() { if response.status().is_success() {
let bytes = response.bytes().await?; let bytes = response.bytes().await?;
let img = ImageReader::new(Cursor::new(bytes)) let img = image::load_from_memory(&bytes)?;
.with_guessed_format()?
.decode()?;
let mut png_bytes: Vec<u8> = Vec::new(); let mut png_bytes: Vec<u8> = Vec::new();
img.write_to(&mut Cursor::new(&mut png_bytes), ImageFormat::Png)?; img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ImageFormat::Png)?;
Ok(Some(STANDARD.encode(&png_bytes))) Ok(Some(STANDARD.encode(&png_bytes)))
} else { } else {
Ok(None) Ok(None)
} }
} }
#[tauri::command]
pub fn start_monitor(app_handle: AppHandle) -> Result<(), String> {
let clipboard = app_handle.state::<Clipboard>();
clipboard.start_monitor(app_handle.clone()).map_err(|e| e.to_string())?;
app_handle.emit("plugin:clipboard://clipboard-monitor/status", true).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn stop_monitor(app_handle: AppHandle) -> Result<(), String> {
let clipboard = app_handle.state::<Clipboard>();
clipboard.stop_monitor(app_handle.clone()).map_err(|e| e.to_string())?;
app_handle.emit("plugin:clipboard://clipboard-monitor/status", false).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn is_monitor_running(app_handle: AppHandle) -> bool {
let clipboard = app_handle.state::<Clipboard>();
clipboard.is_monitor_running()
}

View file

@ -45,7 +45,7 @@ pub fn center_window_on_current_monitor(window: &tauri::WebviewWindow) {
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_sql::Builder::default().build()) .plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@ -65,7 +65,8 @@ fn main() {
hotkeys::setup(app_handle.clone()); hotkeys::setup(app_handle.clone());
tray::setup(app)?; tray::setup(app)?;
database::setup(app)?; database::setup(app)?;
clipboard::setup(app_handle.clone()); clipboard::setup(app.handle());
let _ = clipboard::start_monitor(app_handle.clone());
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
center_window_on_current_monitor(&window); center_window_on_current_monitor(&window);