feat: implement Clipboard API

This commit is contained in:
ByteAtATime 2025-06-19 11:23:05 -07:00
parent a598ccf50b
commit 3c3566136a
No known key found for this signature in database
9 changed files with 461 additions and 97 deletions

View file

@ -194,6 +194,61 @@ const BrowserExtensionRequestMessageSchema = z.object({
payload: BrowserExtensionRequestPayloadSchema
});
const ClipboardContentSchema = z.object({
text: z.string().optional(),
html: z.string().optional(),
file: z.string().optional()
});
const CopyOptionsSchema = z.object({
concealed: z.boolean().optional()
});
const ClipboardCopyPayloadSchema = z.object({
requestId: z.string(),
content: ClipboardContentSchema,
options: CopyOptionsSchema.optional()
});
const ClipboardCopyMessageSchema = z.object({
type: z.literal('clipboard-copy'),
payload: ClipboardCopyPayloadSchema
});
const ClipboardPastePayloadSchema = z.object({
requestId: z.string(),
content: ClipboardContentSchema
});
const ClipboardPasteMessageSchema = z.object({
type: z.literal('clipboard-paste'),
payload: ClipboardPastePayloadSchema
});
const ClipboardReadPayloadSchema = z.object({
requestId: z.string(),
offset: z.number().optional()
});
const ClipboardReadMessageSchema = z.object({
type: z.literal('clipboard-read'),
payload: ClipboardReadPayloadSchema
});
const ClipboardReadTextPayloadSchema = z.object({
requestId: z.string(),
offset: z.number().optional()
});
const ClipboardReadTextMessageSchema = z.object({
type: z.literal('clipboard-read-text'),
payload: ClipboardReadTextPayloadSchema
});
const ClipboardClearPayloadSchema = z.object({
requestId: z.string()
});
const ClipboardClearMessageSchema = z.object({
type: z.literal('clipboard-clear'),
payload: ClipboardClearPayloadSchema
});
export const SidecarMessageWithPluginsSchema = z.union([
BatchUpdateSchema,
CommandSchema,
@ -204,6 +259,11 @@ export const SidecarMessageWithPluginsSchema = z.union([
OpenMessageSchema,
GetSelectedTextMessageSchema,
GetSelectedFinderItemsMessageSchema,
BrowserExtensionRequestMessageSchema
BrowserExtensionRequestMessageSchema,
ClipboardCopyMessageSchema,
ClipboardPasteMessageSchema,
ClipboardReadMessageSchema,
ClipboardReadTextMessageSchema,
ClipboardClearMessageSchema
]);
export type SidecarMessageWithPlugins = z.infer<typeof SidecarMessageWithPluginsSchema>;

View file

@ -0,0 +1,79 @@
import { writeOutput } from '../io';
import * as crypto from 'crypto';
import type * as api from '@raycast/api';
type ClipboardContent = {
text?: string;
html?: string;
file?: string;
};
type ReadResult = {
text?: string;
html?: string;
file?: string;
};
const pendingRequests = new Map<
string,
{ resolve: (value: any) => void; reject: (reason?: any) => void }
>();
function sendRequest<T>(type: string, payload: object): Promise<T> {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID();
pendingRequests.set(requestId, { resolve, reject });
writeOutput({
type,
payload: { requestId, ...payload }
});
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error(`Request for ${type} timed out`));
}
}, 5000);
});
}
export function handleClipboardResponse(requestId: string, result: any, error?: string) {
const promise = pendingRequests.get(requestId);
if (promise) {
if (error) {
promise.reject(new Error(error));
} else {
promise.resolve(result);
}
pendingRequests.delete(requestId);
}
}
function normalizeContent(content: string | number | api.Clipboard.Content): ClipboardContent {
if (typeof content === 'string' || typeof content === 'number') {
return { text: String(content) };
}
return content;
}
export const Clipboard: typeof api.Clipboard = {
async copy(content, options) {
const normalized = normalizeContent(content);
return sendRequest<void>('clipboard-copy', { content: normalized, options });
},
async paste(content) {
const normalized = normalizeContent(content);
return sendRequest<void>('clipboard-paste', { content: normalized });
},
async clear() {
return sendRequest<void>('clipboard-clear', {});
},
async read(options) {
return sendRequest<ReadResult>('clipboard-read', { offset: options?.offset });
},
async readText(options) {
const result = await sendRequest<ReadResult>('clipboard-read-text', {
offset: options?.offset
});
return result.text;
}
};

View file

@ -14,6 +14,7 @@ import { environment, getSelectedFinderItems, getSelectedText, open } from './en
import { preferencesStore } from '../preferences';
import { showToast } from './toast';
import { BrowserExtensionAPI } from './browserExtension';
import { Clipboard } from './clipboard';
let currentPluginName: string | null = null;
let currentPluginPreferences: Array<{
@ -50,6 +51,7 @@ export const getRaycastApi = () => {
Form,
Grid,
List,
Clipboard,
environment,
getPreferenceValues: () => {
if (currentPluginName) {

View file

@ -11,6 +11,7 @@ import {
type FileSystemItem
} from './api/environment';
import { handleBrowserExtensionResponse } from './api/browserExtension';
import { handleClipboardResponse } from './api/clipboard';
process.on('unhandledRejection', (reason: unknown) => {
writeLog(`--- UNHANDLED PROMISE REJECTION ---`);
@ -150,6 +151,19 @@ rl.on('line', (line) => {
browserExtensionState.isConnected = isConnected;
break;
}
case 'clipboard-read-text-response':
case 'clipboard-read-response':
case 'clipboard-copy-response':
case 'clipboard-paste-response':
case 'clipboard-clear-response': {
const { requestId, result, error } = command.payload as {
requestId: string;
result?: any;
error?: string;
};
handleClipboardResponse(requestId, result, error);
break;
}
default:
writeLog(`Unknown command action: ${command.action}`);
}

72
src-tauri/Cargo.lock generated
View file

@ -785,6 +785,19 @@ dependencies = [
"libc",
]
[[package]]
name = "core-graphics"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.9.1",
"core-foundation 0.10.1",
"core-graphics-types 0.2.0",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
@ -1197,7 +1210,28 @@ dependencies = [
"log",
"objc2 0.5.2",
"windows 0.56.0",
"xkbcommon",
"xkbcommon 0.7.0",
"xkeysym",
]
[[package]]
name = "enigo"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71744ff36f35a4276e8827add8102d0e792378c574fd93cb4e1c8e0505f96b7c"
dependencies = [
"core-foundation 0.10.1",
"core-graphics 0.25.0",
"foreign-types-shared 0.3.1",
"libc",
"log",
"nom 8.0.0",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-foundation 0.3.1",
"windows 0.61.3",
"x11rb",
"xkbcommon 0.8.0",
"xkeysym",
]
@ -2583,6 +2617,15 @@ dependencies = [
"libc",
]
[[package]]
name = "memmap2"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
@ -2740,6 +2783,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@ -3630,6 +3682,7 @@ dependencies = [
"arboard",
"bincode",
"bytes",
"enigo 0.5.0",
"freedesktop-file-parser",
"futures-util",
"rayon",
@ -3991,7 +4044,7 @@ dependencies = [
"accessibility-sys-ng",
"arboard",
"core-foundation 0.9.4",
"enigo",
"enigo 0.2.1",
"log",
"windows 0.56.0",
"wl-clipboard-rs 0.8.1",
@ -5208,7 +5261,7 @@ checksum = "aac5e8971f245c3389a5a76e648bfc80803ae066a1243a75db0064d7c1129d63"
dependencies = [
"fnv",
"memchr",
"nom",
"nom 7.1.3",
"once_cell",
"petgraph",
]
@ -6435,7 +6488,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e"
dependencies = [
"libc",
"memmap2",
"memmap2 0.8.0",
"xkeysym",
]
[[package]]
name = "xkbcommon"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
dependencies = [
"libc",
"memmap2 0.9.5",
"xkeysym",
]

View file

@ -38,6 +38,7 @@ tokio-tungstenite = "^0.27"
futures-util = "^0.3.31"
tokio = { version = "^1.45.1", features = ["full"] }
uuid = { version = "^1.17.0", features = ["v4", "serde"] }
enigo = "0.5.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"

123
src-tauri/src/clipboard.rs Normal file
View file

@ -0,0 +1,123 @@
use enigo::{Enigo, Key, Keyboard, Settings};
use std::{thread, time::Duration};
use tauri_plugin_clipboard_manager::ClipboardExt;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ReadResult {
text: Option<String>,
html: Option<String>,
file: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardContent {
text: Option<String>,
html: Option<String>,
file: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Default, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CopyOptions {
concealed: Option<bool>,
}
#[tauri::command]
pub async fn clipboard_read_text(app: tauri::AppHandle) -> Result<ReadResult, String> {
let clipboard = app.clipboard();
let text = clipboard.read_text().ok();
Ok(ReadResult {
text,
html: None,
file: None
})
}
#[tauri::command]
pub async fn clipboard_read(app: tauri::AppHandle) -> Result<ReadResult, String> {
let clipboard = app.clipboard();
let text = clipboard.read_text().ok();
let html = None; // read_html is not supported by the plugin
let file = if let Some(ref text_content) = text {
if text_content.lines().count() == 1
&& (text_content.starts_with('/') || text_content.starts_with("file://"))
{
Some(text_content.clone())
} else {
None
}
} else {
None
};
Ok(ReadResult { text, html, file })
}
#[tauri::command]
pub async fn clipboard_copy(
app: tauri::AppHandle,
content: ClipboardContent,
_options: Option<CopyOptions>
) -> Result<(), String> {
let clipboard = app.clipboard();
if let Some(file_path) = &content.file {
clipboard
.write_text(file_path.clone())
.map_err(|e| e.to_string())?;
} else if let Some(html) = &content.html {
clipboard
.write_html(html.clone(), content.text)
.map_err(|e| e.to_string())?;
} else if let Some(text) = &content.text {
clipboard.write_text(text.clone()).map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn clipboard_paste(
app: tauri::AppHandle,
content: ClipboardContent
) -> Result<(), String> {
let clipboard = app.clipboard();
let original_text = clipboard.read_text().ok();
clipboard_copy(app.clone(), content, None).await?;
thread::sleep(Duration::from_millis(100));
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
{
enigo.key(Key::Meta, enigo::Direction::Press).ok();
enigo.key(Key::Unicode('v'), enigo::Direction::Click).ok();
enigo.key(Key::Meta, enigo::Direction::Release).ok();
}
#[cfg(not(target_os = "macos"))]
{
enigo.key(Key::Control, enigo::Direction::Press).ok();
enigo.key(Key::Unicode('v'), enigo::Direction::Click).ok();
enigo.key(Key::Control, enigo::Direction::Release).ok();
}
thread::sleep(Duration::from_millis(100));
if let Some(text) = original_text {
clipboard.write_text(text).map_err(|e| e.to_string())?;
} else {
clipboard.clear().map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn clipboard_clear(app: tauri::AppHandle) -> Result<(), String> {
app.clipboard().clear().map_err(|e| e.to_string())
}

View file

@ -1,6 +1,7 @@
mod app;
mod browser_extension;
mod cache;
mod clipboard;
mod desktop;
mod error;
mod extensions;
@ -16,122 +17,124 @@ use tauri::Manager;
#[tauri::command]
fn get_installed_apps() -> Vec<App> {
match AppCache::get_apps() {
Ok(apps) => apps,
Err(e) => {
eprintln!("Failed to get apps: {:?}", e);
Vec::new()
}
}
match AppCache::get_apps() {
Ok(apps) => apps,
Err(e) => {
eprintln!("Failed to get apps: {:?}", e);
Vec::new()
}
}
}
#[tauri::command]
fn launch_app(exec: String) -> Result<(), String> {
let exec_parts: Vec<&str> = exec.split_whitespace().collect();
if exec_parts.is_empty() {
return Err("Empty exec command".to_string());
}
let exec_parts: Vec<&str> = exec.split_whitespace().collect();
if exec_parts.is_empty() {
return Err("Empty exec command".to_string());
}
let mut command = Command::new(exec_parts[0]);
for arg in &exec_parts[1..] {
if !arg.starts_with('%') {
command.arg(arg);
}
}
let mut command = Command::new(exec_parts[0]);
for arg in &exec_parts[1..] {
if !arg.starts_with('%') {
command.arg(arg);
}
}
command
.spawn()
.map_err(|e| format!("Failed to launch app: {}", e))?;
command
.spawn()
.map_err(|e| format!("Failed to launch app: {}", e))?;
Ok(())
Ok(())
}
#[tauri::command]
fn get_selected_text() -> String {
get_text()
get_text()
}
fn setup_background_refresh() {
thread::spawn(|| {
thread::sleep(Duration::from_secs(60));
loop {
AppCache::refresh_background();
thread::sleep(Duration::from_secs(300));
}
});
thread::spawn(|| {
thread::sleep(Duration::from_secs(60));
loop {
AppCache::refresh_background();
thread::sleep(Duration::from_secs(300));
}
});
}
fn setup_global_shortcut(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
use tauri_plugin_global_shortcut::{
Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState,
};
use tauri_plugin_global_shortcut::{
Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState
};
let spotlight_shortcut = Shortcut::new(Some(Modifiers::ALT), Code::Space);
let handle = app.handle().clone();
let spotlight_shortcut = Shortcut::new(Some(Modifiers::ALT), Code::Space);
let handle = app.handle().clone();
println!("Spotlight shortcut: {:?}", spotlight_shortcut);
println!("Spotlight shortcut: {:?}", spotlight_shortcut);
app.handle().plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |_app, shortcut, event| {
println!("Shortcut: {:?}, Event: {:?}", shortcut, event);
if shortcut == &spotlight_shortcut
&& event.state() == ShortcutState::Pressed
{
let spotlight_window =
handle.get_webview_window("raycast-linux").unwrap();
println!("Spotlight window: {:?}", spotlight_window);
if spotlight_window.is_visible().unwrap_or(false) {
spotlight_window.hide().unwrap();
} else {
spotlight_window.show().unwrap();
spotlight_window.set_focus().unwrap();
}
}
})
.build(),
)?;
app.handle().plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |_app, shortcut, event| {
println!("Shortcut: {:?}, Event: {:?}", shortcut, event);
if shortcut == &spotlight_shortcut && event.state() == ShortcutState::Pressed {
let spotlight_window = handle.get_webview_window("raycast-linux").unwrap();
println!("Spotlight window: {:?}", spotlight_window);
if spotlight_window.is_visible().unwrap_or(false) {
spotlight_window.hide().unwrap();
} else {
spotlight_window.show().unwrap();
spotlight_window.set_focus().unwrap();
}
}
})
.build()
)?;
app.global_shortcut().register(spotlight_shortcut)?;
Ok(())
app.global_shortcut().register(spotlight_shortcut)?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(WsState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
if let Some(window) = app.get_webview_window("main") {
if let Ok(true) = window.is_visible() {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}))
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
get_installed_apps,
launch_app,
get_selected_text,
filesystem::get_selected_finder_items,
extensions::install_extension,
browser_extension::browser_extension_check_connection,
browser_extension::browser_extension_request
])
.setup(|app| {
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(browser_extension::run_server(app_handle));
tauri::Builder::default()
.manage(WsState::default())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
if let Ok(true) = window.is_visible() {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}))
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
get_installed_apps,
launch_app,
get_selected_text,
filesystem::get_selected_finder_items,
extensions::install_extension,
browser_extension::browser_extension_check_connection,
browser_extension::browser_extension_request,
clipboard::clipboard_read_text,
clipboard::clipboard_read,
clipboard::clipboard_copy,
clipboard::clipboard_paste,
clipboard::clipboard_clear
])
.setup(|app| {
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(browser_extension::run_server(app_handle));
setup_background_refresh();
setup_global_shortcut(app)?;
setup_background_refresh();
setup_global_shortcut(app)?;
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -128,7 +128,7 @@ class SidecarService {
}
};
#routeMessage = (message: unknown) => {
#routeMessage = async (message: unknown) => {
const result = SidecarMessageWithPluginsSchema.safeParse(message);
if (!result.success) {
@ -153,6 +153,24 @@ class SidecarService {
return;
}
if (typedMessage.type.startsWith('clipboard-')) {
const { requestId, ...params } = typedMessage.payload as {
requestId: string;
[key: string]: any;
};
const command = typedMessage.type.replace(/-/g, '_');
const responseType = `${typedMessage.type}-response`;
try {
const result = await invoke(command, params);
this.dispatchEvent(responseType, { requestId, result });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.#log(`ERROR from ${command}: ${errorMessage}`);
this.dispatchEvent(responseType, { requestId, error: errorMessage });
}
return;
}
if (typedMessage.type === 'plugin-list') {
uiStore.setPluginList(typedMessage.payload);
return;