- Copy only (right-click -> Copy): write to clipboard without auto-pasting or hiding the window - Color swatches: hex/rgb/hsl entries show a preview chip - SQLite WAL + synchronous=NORMAL so the UI can read while the clipboard poller writes - Memoize list rows (React.memo) so selection/focus changes don't re-render every row's preview - Add docs/GUIDE.md (living install + usage doc), docs/SHARING.md, docs/TESTING.md, docs/LAUNCH-AT-LOGIN.md, docs/PRODUCT.md, CHANGELOG.md, ROADMAP.md - Add homelab Gitea Actions CI (.gitea/workflows/ci.yml) + gitleaks allowlist - Bump version to 0.2.0
213 lines
6.5 KiB
Rust
213 lines
6.5 KiB
Rust
use crate::clipboard::LastHash;
|
|
use crate::db::{ClipboardEntry, Database, Settings};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use tauri::{Manager, State};
|
|
|
|
pub struct DbState(pub Arc<Database>);
|
|
pub struct PausedState(pub Arc<AtomicBool>);
|
|
pub struct LastHashState(pub LastHash);
|
|
|
|
#[tauri::command]
|
|
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
|
db.0.get_entries(limit.unwrap_or(crate::db::DISPLAY_LIMIT))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
|
if query.trim().is_empty() {
|
|
return get_entries(db, limit);
|
|
}
|
|
db.0.search_entries(&query, limit.unwrap_or(crate::db::DISPLAY_LIMIT))
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_entry(db: State<'_, DbState>, id: i64) -> Result<ClipboardEntry, String> {
|
|
db.0.get_entry(id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("entry {} not found", id))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn latest_entry_id(db: State<'_, DbState>) -> Result<Option<i64>, String> {
|
|
db.0.latest_entry_id().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_entry(db: State<'_, DbState>, id: i64) -> Result<(), String> {
|
|
db.0.delete_entry(id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn toggle_pin(db: State<'_, DbState>, id: i64) -> Result<bool, String> {
|
|
db.0.toggle_pin(id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn clear_all(db: State<'_, DbState>) -> Result<(), String> {
|
|
db.0.clear_all().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_settings(db: State<'_, DbState>) -> Result<Settings, String> {
|
|
db.0.get_settings().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_setting(db: State<'_, DbState>, key: String, value: String) -> Result<(), String> {
|
|
db.0.set_setting(&key, &value).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_paused(paused: State<'_, PausedState>) -> bool {
|
|
paused.0.load(Ordering::Relaxed)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_paused(paused: State<'_, PausedState>, value: bool) {
|
|
paused.0.store(value, Ordering::Relaxed);
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Result<(), String> {
|
|
db.0.set_setting("window_width", &width.to_string())
|
|
.map_err(|e| e.to_string())?;
|
|
db.0.set_setting("window_height", &height.to_string())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Validate, persist, and re-register the global toggle hotkey.
|
|
/// On registration failure the previous hotkey is restored.
|
|
#[tauri::command]
|
|
pub fn set_hotkey(
|
|
app: tauri::AppHandle,
|
|
db: State<'_, DbState>,
|
|
hotkey: String,
|
|
) -> Result<(), String> {
|
|
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
|
|
|
let new_shortcut = crate::parse_hotkey(&hotkey)
|
|
.ok_or_else(|| format!("Invalid hotkey: {}", hotkey))?;
|
|
|
|
let old = db.0.get_settings().map_err(|e| e.to_string())?.hotkey;
|
|
let gs = app.global_shortcut();
|
|
|
|
if let Some(old_shortcut) = crate::parse_hotkey(&old) {
|
|
let _ = gs.unregister(old_shortcut);
|
|
}
|
|
|
|
if let Err(e) = gs.register(new_shortcut) {
|
|
// Roll back so the app keeps a working hotkey
|
|
if let Some(old_shortcut) = crate::parse_hotkey(&old) {
|
|
let _ = gs.register(old_shortcut);
|
|
}
|
|
return Err(format!("Could not register '{}': {}", hotkey, e));
|
|
}
|
|
|
|
db.0.set_setting("hotkey", &hotkey).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Write content to the system clipboard and update the shared hash so the
|
|
/// polling thread doesn't re-insert it. Shared by copy-only and paste-and-refocus.
|
|
fn write_clipboard(
|
|
last_hash: &LastHash,
|
|
content: &str,
|
|
kind: &str,
|
|
) -> Result<(), String> {
|
|
{
|
|
let h = crate::clipboard::hash_content(content.as_bytes());
|
|
*last_hash.lock().unwrap() = h;
|
|
}
|
|
|
|
let mut clipboard = arboard::Clipboard::new()
|
|
.map_err(|e| format!("clipboard open failed: {}", e))?;
|
|
|
|
if kind == "image" && content.starts_with("data:image/") {
|
|
let (w, h, rgba) = crate::image_util::data_uri_to_rgba(content)?;
|
|
let img = crate::image_util::to_arboard_image(w, h, rgba);
|
|
clipboard
|
|
.set_image(img)
|
|
.map_err(|e| format!("clipboard image write failed: {}", e))?;
|
|
} else {
|
|
clipboard
|
|
.set_text(content)
|
|
.map_err(|e| format!("clipboard write failed: {}", e))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Copy content to the clipboard without pasting or hiding the window —
|
|
/// Ditto/Pastebot-style "copy only" for building up a paste elsewhere.
|
|
#[tauri::command]
|
|
pub fn copy_to_clipboard(
|
|
last_hash: State<'_, LastHashState>,
|
|
content: String,
|
|
content_type: Option<String>,
|
|
) -> Result<(), String> {
|
|
let kind = content_type.unwrap_or_else(|| {
|
|
if content.starts_with("data:image/") {
|
|
"image".into()
|
|
} else {
|
|
"text".into()
|
|
}
|
|
});
|
|
write_clipboard(&last_hash.0, &content, &kind)
|
|
}
|
|
|
|
/// Write content to the system clipboard, update the shared hash so the
|
|
/// polling thread doesn't re-insert it, hide the window, then simulate
|
|
/// Cmd+V via AppleScript to paste into the previously-focused app.
|
|
#[tauri::command]
|
|
pub async fn paste_and_refocus(
|
|
app: tauri::AppHandle,
|
|
last_hash: State<'_, LastHashState>,
|
|
content: String,
|
|
content_type: Option<String>,
|
|
) -> Result<(), String> {
|
|
let kind = content_type.unwrap_or_else(|| {
|
|
if content.starts_with("data:image/") {
|
|
"image".into()
|
|
} else {
|
|
"text".into()
|
|
}
|
|
});
|
|
|
|
write_clipboard(&last_hash.0, &content, &kind)?;
|
|
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
let _ = window.hide();
|
|
}
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let script = r#"
|
|
tell application "System Events"
|
|
set frontApp to name of first application process whose frontmost is true
|
|
end tell
|
|
tell application frontApp to activate
|
|
delay 0.08
|
|
tell application "System Events"
|
|
keystroke "v" using command down
|
|
end tell
|
|
"#;
|
|
let output = tokio::process::Command::new("osascript")
|
|
.arg("-e")
|
|
.arg(script)
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("osascript spawn failed: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::warn!("osascript paste failed: {}", stderr);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|