diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index bf04f15..b192213 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -140,6 +140,14 @@ const GetSelectedTextMessageSchema = z.object({ payload: GetSelectedTextPayloadSchema }); +const GetSelectedFinderItemsPayloadSchema = z.object({ + requestId: z.string() +}); +const GetSelectedFinderItemsMessageSchema = z.object({ + type: z.literal('get-selected-finder-items'), + payload: GetSelectedFinderItemsPayloadSchema +}); + export const SidecarMessageWithPluginsSchema = z.union([ BatchUpdateSchema, CommandSchema, @@ -148,6 +156,7 @@ export const SidecarMessageWithPluginsSchema = z.union([ PreferenceValuesSchema, GoBackToPluginListSchema, OpenMessageSchema, - GetSelectedTextMessageSchema + GetSelectedTextMessageSchema, + GetSelectedFinderItemsMessageSchema ]); export type SidecarMessageWithPlugins = z.infer; diff --git a/sidecar/src/api/environment.ts b/sidecar/src/api/environment.ts index 30af419..adda7d9 100644 --- a/sidecar/src/api/environment.ts +++ b/sidecar/src/api/environment.ts @@ -33,8 +33,45 @@ export const environment = { } }; +const pendingFinderItemsRequests = new Map< + string, + { resolve: (items: FileSystemItem[]) => void; reject: (error: Error) => void } +>(); + export async function getSelectedFinderItems(): Promise { - return Promise.reject(new Error('Finder is not the frontmost application.')); + return new Promise((resolve, reject) => { + const requestId = Math.random().toString(36).substring(7); + + pendingFinderItemsRequests.set(requestId, { resolve, reject }); + + writeOutput({ + type: 'get-selected-finder-items', + payload: { requestId } + }); + + setTimeout(() => { + if (pendingFinderItemsRequests.has(requestId)) { + pendingFinderItemsRequests.delete(requestId); + reject(new Error('Timeout: Could not get selected finder items')); + } + }, 1000); + }); +} + +export function handleGetSelectedFinderItemsResponse( + requestId: string, + items: FileSystemItem[] | null, + error?: string +) { + const pending = pendingFinderItemsRequests.get(requestId); + if (pending) { + pendingFinderItemsRequests.delete(requestId); + if (error) { + pending.reject(new Error(error)); + } else { + pending.resolve(items || []); + } + } } const pendingTextRequests = new Map< diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index 855432c..e25314d 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -5,7 +5,11 @@ import { instances, navigationStack } from './state'; import { batchedUpdates, updateContainer } from './reconciler'; import { preferencesStore } from './preferences'; import type { RaycastInstance } from './types'; -import { handleSelectedTextResponse } from './api/environment'; +import { + handleGetSelectedFinderItemsResponse, + handleSelectedTextResponse, + type FileSystemItem +} from './api/environment'; process.on('unhandledRejection', (reason: unknown) => { writeLog(`--- UNHANDLED PROMISE REJECTION ---`); @@ -102,6 +106,15 @@ rl.on('line', (line) => { handleSelectedTextResponse(requestId, text ?? null, error); break; } + case 'selected-finder-items-response': { + const { requestId, items, error } = command.payload as { + requestId: string; + items?: FileSystemItem[] | null; + error?: string; + }; + handleGetSelectedFinderItemsResponse(requestId, items ?? null, error); + break; + } default: writeLog(`Unknown command action: ${command.action}`); } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3be57f1..fcf8680 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3323,6 +3323,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" name = "raycast-linux" version = "0.1.0" dependencies = [ + "arboard", "bincode", "freedesktop-file-parser", "rayon", @@ -3334,6 +3335,8 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-opener", "tauri-plugin-shell", + "url", + "zbus", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 50b8dfd..9394ee1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,4 +28,7 @@ freedesktop-file-parser = "0.2.0" bincode = { version = "2.0.1", features = ["serde"] } rayon = "1.10.0" selection = "1.2.0" +url = "2.5.4" +arboard = "3.5.0" +zbus = "5.7.1" diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index cbf8387..b76c1bd 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -4,7 +4,8 @@ use std::io; pub enum AppError { Io(io::Error), Serialization(String), - DirectoryNotFound + DirectoryNotFound, + CacheError(String), } impl From for AppError { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 41b9137..1f6c48a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,12 +3,274 @@ mod cache; mod desktop; mod error; -use crate::{ - app::App, - cache::AppCache, -}; +#[cfg(target_os = "linux")] +use arboard; +use crate::{app::App, cache::AppCache}; use selection::get_text; use std::{process::Command, thread, time::Duration}; +#[cfg(target_os = "linux")] +use std::path::Path; +#[cfg(target_os = "linux")] +use url::Url; +#[cfg(target_os = "linux")] +use zbus; + +#[derive(serde::Serialize, Clone, Debug)] +pub struct FileSystemItem { + path: String, +} + +#[tauri::command] +async fn get_selected_finder_items() -> Result, String> { + #[cfg(target_os = "macos")] + { + get_selected_finder_items_macos() + } + #[cfg(target_os = "windows")] + { + get_selected_finder_items_windows() + } + #[cfg(target_os = "linux")] + { + get_selected_finder_items_linux().await + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + Err("Unsupported operating system".to_string()) + } +} + +#[cfg(target_os = "macos")] +fn get_selected_finder_items_macos() -> Result, String> { + let script = r#" + tell application "Finder" + if not running then + return "" + end if + try + set theSelection to selection + if theSelection is {} then + return "" + end if + set thePaths to {} + repeat with i from 1 to count of theSelection + set end of thePaths to (POSIX path of (item i of theSelection as alias)) + end repeat + return thePaths + on error + return "" + end try + end tell + "#; + + let output = std::process::Command::new("osascript") + .arg("-l") + .arg("AppleScript") + .arg("-e") + .arg(script) + .output() + .map_err(|e| format!("Failed to execute osascript: {}", e))?; + + if !output.status.success() { + let error_message = String::from_utf8_lossy(&output.stderr); + if error_message.contains("Finder is not running") { + return Ok(vec![]); + } + return Err(format!("osascript failed with error: {}", error_message)); + } + + let result_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if result_str.is_empty() { + return Ok(vec![]); + } + + let paths: Vec = result_str + .split(", ") + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .map(|p| FileSystemItem { + path: p.to_string(), + }) + .collect(); + + Ok(paths) +} + +#[cfg(target_os = "windows")] +fn get_selected_finder_items_windows() -> Result, String> { + let script = r#" + Add-Type @" + using System; + using System.Runtime.InteropServices; + public class Win32 { + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + } +"@ + $foreground_hwnd = [Win32]::GetForegroundWindow() + $shell = New-Object -ComObject Shell.Application + $window = $shell.Windows() | Where-Object { $_.HWND -eq $foreground_hwnd } | Select-Object -First 1 + if ($window) { + if ($window.FullName -like "*\explorer.exe") { + $selection = $window.Document.SelectedItems() + if ($selection) { + $paths = $selection | ForEach-Object { $_.Path } + if ($paths) { + return $paths -join [System.Environment]::NewLine + } + } + } + } + return "" + "#; + + let output = std::process::Command::new("powershell") + .arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-Command") + .arg(script) + .output() + .map_err(|e| format!("Failed to execute powershell: {}", e))?; + + if !output.status.success() { + return Err(format!( + "powershell failed with error: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let result_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if result_str.is_empty() { + return Ok(vec![]); + } + + let paths: Vec = result_str + .lines() + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .map(|p| FileSystemItem { + path: p.to_string(), + }) + .collect(); + + Ok(paths) +} + +#[cfg(target_os = "linux")] +async fn get_selected_finder_items_linux() -> Result, String> { + if let Ok(paths) = get_from_file_manager().await { + if !paths.is_empty() { + return Ok(paths); + } + } + + if let Ok(paths) = get_from_clipboard() { + if !paths.is_empty() { + return Ok(paths); + } + } + + Err("Could not determine selected files. Please copy them to your clipboard.".to_string()) +} + +#[cfg(target_os = "linux")] +async fn get_from_file_manager() -> Result, String> { + let connection = match zbus::Connection::session().await { + Ok(c) => c, + Err(_) => return Ok(vec![]), + }; + + let proxy = match zbus::Proxy::new( + &connection, + "org.freedesktop.FileManager1", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1", + ) + .await + { + Ok(p) => p, + Err(_) => return Ok(vec![]), + }; + + let fm_service: String = proxy.destination().to_string(); + if fm_service.is_empty() { + return Ok(vec![]); + } + + let fm_name = fm_service.split('.').last().unwrap_or_default(); + let window_interface = format!("org.{}.Window", fm_name); + + if fm_name != "nautilus" && fm_name != "nemo" { + return Ok(vec![]); + } + + let response = match proxy.call_method("GetWindows", &()).await { + Ok(r) => r, + Err(_) => return Ok(vec![]), + }; + + let body = response.body(); + let windows: Vec = body.deserialize().unwrap_or_default(); + + for window_path in windows.iter().rev() { + let fm_service_ref = fm_service.as_str(); + let window_interface_ref = window_interface.as_str(); + let window_proxy = match zbus::Proxy::new( + &connection, + fm_service_ref, + window_path, + window_interface_ref, + ) + .await + { + Ok(p) => p, + Err(_) => continue, + }; + if let Ok(is_active) = window_proxy.get_property::("Active").await { + if is_active { + if let Ok(uris) = window_proxy.get_property::>("SelectedUris").await { + let paths = uris + .iter() + .filter_map(|uri_str| Url::parse(uri_str).ok()) + .filter_map(|url| url.to_file_path().ok()) + .map(|path_buf| FileSystemItem { + path: path_buf.to_string_lossy().into_owned(), + }) + .collect(); + return Ok(paths); + } + } + } + } + Ok(vec![]) +} + +#[cfg(target_os = "linux")] +fn get_from_clipboard() -> Result, String> { + let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; + if let Ok(text) = clipboard.get_text() { + let paths: Vec = text + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with("file://") { + Url::parse(trimmed).ok().and_then(|u| u.to_file_path().ok()) + } else { + Some(Path::new(trimmed).to_path_buf()) + } + }) + .filter(|p| p.exists()) + .map(|p| FileSystemItem { + path: p.to_string_lossy().to_string(), + }) + .collect(); + return Ok(paths); + } + Ok(vec![]) +} #[tauri::command] fn get_installed_apps() -> Vec { @@ -56,7 +318,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_installed_apps, launch_app, - get_selected_text + get_selected_text, + get_selected_finder_items ]) .setup(|_app| { thread::spawn(|| { diff --git a/src/lib/sidecar.svelte.ts b/src/lib/sidecar.svelte.ts index ecb8cea..0e380a3 100644 --- a/src/lib/sidecar.svelte.ts +++ b/src/lib/sidecar.svelte.ts @@ -169,6 +169,25 @@ class SidecarService { return; } + if (typedMessage.type === 'get-selected-finder-items') { + const { requestId } = typedMessage.payload; + invoke('get_selected_finder_items') + .then((items) => { + this.dispatchEvent('selected-finder-items-response', { + requestId, + items + }); + }) + .catch((error) => { + this.#log(`ERROR getting selected finder items: ${error}`); + this.dispatchEvent('selected-finder-items-response', { + requestId, + error: String(error) + }); + }); + return; + } + const commands = typedMessage.type === 'BATCH_UPDATE' ? typedMessage.payload : [typedMessage]; if (commands.length > 0) { uiStore.applyCommands(commands);