Files
maCopy/src-tauri/src/commands.rs
T
ilia e9461ed4f3
CI / skip-ci-check (pull_request) Successful in 4s
CI / secret-scan (pull_request) Successful in 3s
CI / node-ci (pull_request) Successful in 17s
Add copy-only, color swatches, DB speed pragmas, and living docs
- 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
2026-07-14 21:58:31 -04:00

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(())
}