From 9c1ee6efe426fe7b62892b6c4ea104c94d457f91 Mon Sep 17 00:00:00 2001 From: ByteAtATime Date: Mon, 23 Jun 2025 18:12:52 -0700 Subject: [PATCH] feat: implement system utilities API This commit introduces new system message schemas and corresponding commands for managing applications, including fetching applications, getting the default application, retrieving the frontmost application, showing an application in Finder, and trashing files. --- packages/protocol/src/index.ts | 51 +++++++++- sidecar/src/api/environment.ts | 133 ++++++++++++-------------- sidecar/src/api/index.ts | 17 +++- sidecar/src/index.ts | 36 +++---- src-tauri/Cargo.lock | 25 +++++ src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 10 +- src-tauri/src/system.rs | 168 +++++++++++++++++++++++++++++++++ src/lib/sidecar.svelte.ts | 18 ++++ 9 files changed, 358 insertions(+), 101 deletions(-) create mode 100644 src-tauri/src/system.rs diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 2f22e25..409fd6a 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -299,6 +299,50 @@ const OauthRemoveTokensMessageSchema = z.object({ payload: OauthRemoveTokensPayloadSchema }); +const SystemGetApplicationsPayloadSchema = z.object({ + requestId: z.string(), + path: z.string().optional() +}); +const SystemGetApplicationsMessageSchema = z.object({ + type: z.literal('system-get-applications'), + payload: SystemGetApplicationsPayloadSchema +}); + +const SystemGetDefaultApplicationPayloadSchema = z.object({ + requestId: z.string(), + path: z.string() +}); +const SystemGetDefaultApplicationMessageSchema = z.object({ + type: z.literal('system-get-default-application'), + payload: SystemGetDefaultApplicationPayloadSchema +}); + +const SystemGetFrontmostApplicationPayloadSchema = z.object({ + requestId: z.string() +}); +const SystemGetFrontmostApplicationMessageSchema = z.object({ + type: z.literal('system-get-frontmost-application'), + payload: SystemGetFrontmostApplicationPayloadSchema +}); + +const SystemShowInFinderPayloadSchema = z.object({ + requestId: z.string(), + path: z.string() +}); +const SystemShowInFinderMessageSchema = z.object({ + type: z.literal('system-show-in-finder'), + payload: SystemShowInFinderPayloadSchema +}); + +const SystemTrashPayloadSchema = z.object({ + requestId: z.string(), + paths: z.array(z.string()) +}); +const SystemTrashMessageSchema = z.object({ + type: z.literal('system-trash'), + payload: SystemTrashPayloadSchema +}); + export const SidecarMessageWithPluginsSchema = z.union([ BatchUpdateSchema, CommandSchema, @@ -319,6 +363,11 @@ export const SidecarMessageWithPluginsSchema = z.union([ OauthAuthorizeMessageSchema, OauthGetTokensMessageSchema, OauthSetTokensMessageSchema, - OauthRemoveTokensMessageSchema + OauthRemoveTokensMessageSchema, + SystemGetApplicationsMessageSchema, + SystemGetDefaultApplicationMessageSchema, + SystemGetFrontmostApplicationMessageSchema, + SystemShowInFinderMessageSchema, + SystemTrashMessageSchema ]); export type SidecarMessageWithPlugins = z.infer; diff --git a/sidecar/src/api/environment.ts b/sidecar/src/api/environment.ts index e9966ca..77a015a 100644 --- a/sidecar/src/api/environment.ts +++ b/sidecar/src/api/environment.ts @@ -4,6 +4,7 @@ import { writeOutput } from '../io'; import type { Application } from './types'; import { config } from '../config'; import { browserExtensionState } from '../state'; +import * as crypto from 'crypto'; const supportPath = config.supportDir; try { @@ -20,6 +21,40 @@ export interface FileSystemItem { export const BrowserExtension = { name: 'BrowserExtension' }; +const pendingSystemRequests = new Map< + string, + { resolve: (value: unknown) => void; reject: (reason?: unknown) => void } +>(); + +function sendSystemRequest(type: string, payload: object = {}): Promise { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + pendingSystemRequests.set(requestId, { resolve, reject }); + writeOutput({ + type: `system-${type}`, + payload: { requestId, ...payload } + }); + setTimeout(() => { + if (pendingSystemRequests.has(requestId)) { + pendingSystemRequests.delete(requestId); + reject(new Error(`Request for ${type} timed out`)); + } + }, 5000); // 5-second timeout + }); +} + +export function handleSystemResponse(requestId: string, result: unknown, error?: string) { + const promise = pendingSystemRequests.get(requestId); + if (promise) { + if (error) { + promise.reject(new Error(error)); + } else { + promise.resolve(result); + } + pendingSystemRequests.delete(requestId); + } +} + export const environment = { appearance: 'dark' as const, assetsPath: config.assetsDir, @@ -40,84 +75,12 @@ export const environment = { } }; -const pendingFinderItemsRequests = new Map< - string, - { resolve: (items: FileSystemItem[]) => void; reject: (error: Error) => void } ->(); - export async function getSelectedFinderItems(): Promise { - 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); - }); + return sendSystemRequest('get-selected-finder-items'); } -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< - string, - { resolve: (text: string) => void; reject: (error: Error) => void } ->(); - export async function getSelectedText(): Promise { - return new Promise((resolve, reject) => { - const requestId = Math.random().toString(36).substring(7); - - pendingTextRequests.set(requestId, { resolve, reject }); - - writeOutput({ - type: 'get-selected-text', - payload: { - requestId - } - }); - - setTimeout(() => { - if (pendingTextRequests.has(requestId)) { - pendingTextRequests.delete(requestId); - reject(new Error('Timeout: Could not get selected text')); - } - }, 1000); - }); -} - -export function handleSelectedTextResponse(requestId: string, text: string | null, error?: string) { - const pending = pendingTextRequests.get(requestId); - if (pending) { - pendingTextRequests.delete(requestId); - if (error) { - pending.reject(new Error(error)); - } else { - pending.resolve(text || ''); - } - } + return sendSystemRequest('get-selected-text'); } export async function open(target: string, application?: Application | string): Promise { @@ -137,3 +100,25 @@ export async function open(target: string, application?: Application | string): } }); } + +export async function getApplications(path?: fs.PathLike): Promise { + const pathString = path ? path.toString() : undefined; + return sendSystemRequest('get-applications', { path: pathString }); +} + +export async function getDefaultApplication(path: fs.PathLike): Promise { + return sendSystemRequest('get-default-application', { path: path.toString() }); +} + +export async function getFrontmostApplication(): Promise { + return sendSystemRequest('get-frontmost-application'); +} + +export async function showInFinder(path: fs.PathLike): Promise { + return sendSystemRequest('show-in-finder', { path: path.toString() }); +} + +export async function trash(path: fs.PathLike | fs.PathLike[]): Promise { + const paths = (Array.isArray(path) ? path : [path]).map((p) => p.toString()); + return sendSystemRequest('trash', { paths }); +} diff --git a/sidecar/src/api/index.ts b/sidecar/src/api/index.ts index c162f20..6d750e4 100644 --- a/sidecar/src/api/index.ts +++ b/sidecar/src/api/index.ts @@ -10,7 +10,17 @@ import { Grid } from './components/grid'; import { Form } from './components/form'; import { Action, ActionPanel } from './components/actions'; import { Detail } from './components/detail'; -import { environment, getSelectedFinderItems, getSelectedText, open } from './environment'; +import { + environment, + getSelectedFinderItems, + getSelectedText, + open, + getApplications, + getDefaultApplication, + getFrontmostApplication, + showInFinder, + trash +} from './environment'; import { preferencesStore } from '../preferences'; import { showToast } from './toast'; import { showHUD } from './hud'; @@ -56,6 +66,9 @@ export const getRaycastApi = () => { List, Clipboard, environment, + getApplications, + getDefaultApplication, + getFrontmostApplication, getPreferenceValues: () => { if (currentPluginName) { return preferencesStore.getPreferenceValues(currentPluginName, currentPluginPreferences); @@ -70,8 +83,10 @@ export const getRaycastApi = () => { getSelectedFinderItems, getSelectedText, open, + showInFinder, showToast, showHUD, + trash, useNavigation, usePersistentState: ( key: string, diff --git a/sidecar/src/index.ts b/sidecar/src/index.ts index 0c0c230..9d62b07 100644 --- a/sidecar/src/index.ts +++ b/sidecar/src/index.ts @@ -5,11 +5,7 @@ import { instances, navigationStack, toasts, browserExtensionState } from './sta import { batchedUpdates, updateContainer } from './reconciler'; import { preferencesStore } from './preferences'; import type { RaycastInstance } from './types'; -import { - handleGetSelectedFinderItemsResponse, - handleSelectedTextResponse, - type FileSystemItem -} from './api/environment'; +import { handleSystemResponse } from './api/environment'; import { handleBrowserExtensionResponse } from './api/browserExtension'; import { handleClipboardResponse } from './api/clipboard'; import { handleOAuthResponse, handleTokenResponse } from './api/oauth'; @@ -27,6 +23,16 @@ rl.on('line', (line) => { try { const command: { action: string; payload: unknown } = JSON.parse(line); + if (command.action.startsWith('system-') && command.action.endsWith('-response')) { + const { requestId, result, error } = command.payload as { + requestId: string; + result?: unknown; + error?: string; + }; + handleSystemResponse(requestId, result, error); + return; + } + switch (command.action) { case 'request-plugin-list': sendPluginList(); @@ -120,24 +126,6 @@ rl.on('line', (line) => { toast?.hide(); break; } - case 'selected-text-response': { - const { requestId, text, error } = command.payload as { - requestId: string; - text?: string | null; - error?: string; - }; - 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; - } case 'browser-extension-response': { const { requestId, result, error } = command.payload as { requestId: string; @@ -195,6 +183,8 @@ rl.on('line', (line) => { : { message: String(err) }; writeLog(`ERROR: ${error.message} \n ${error.stack ?? ''}`); writeOutput({ type: 'error', payload: error.message }); + + throw err; } }); }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1b2209f..fb95ed9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4357,6 +4357,7 @@ dependencies = [ "tauri-plugin-single-instance", "tokio", "tokio-tungstenite", + "trash", "url", "uuid", "zbus", @@ -6046,6 +6047,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trash" +version = "5.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22746c6b0c6d85d60a8f0d858f7057dfdf11297c132679f452ec908fba42b871" +dependencies = [ + "chrono", + "libc", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", +] + [[package]] name = "tray-icon" version = "0.20.1" @@ -6220,6 +6239,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9d4a3ce..61b631a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,7 @@ image = "0.25.6" regex = "1.11.1" rand = "0.9.1" tauri-plugin-http = "2" +trash = "5.2.2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-global-shortcut = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b87895..a9305cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod extensions; mod filesystem; mod oauth; mod quicklinks; +mod system; use crate::{app::App, cache::AppCache}; use browser_extension::WsState; @@ -191,7 +192,12 @@ pub fn run() { quicklinks::list_quicklinks, quicklinks::update_quicklink, quicklinks::delete_quicklink, - quicklinks::execute_quicklink + quicklinks::execute_quicklink, + system::get_applications, + system::get_default_application, + system::get_frontmost_application, + system::show_in_finder, + system::trash ]) .setup(|app| { let app_handle = app.handle().clone(); @@ -211,4 +217,4 @@ pub fn run() { }) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} +} \ No newline at end of file diff --git a/src-tauri/src/system.rs b/src-tauri/src/system.rs new file mode 100644 index 0000000..f2d13d4 --- /dev/null +++ b/src-tauri/src/system.rs @@ -0,0 +1,168 @@ +use std::process::Command; + +#[derive(serde::Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Application { + name: String, + path: String, + bundle_id: Option, +} + +#[tauri::command] +pub fn trash(paths: Vec) -> Result<(), String> { + trash::delete_all(paths).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn show_in_finder(path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + Command::new("explorer") + .args(["/select,", &path]) + .spawn() + .map_err(|e| e.to_string())?; + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .args(["-R", &path]) + .spawn() + .map_err(|e| e.to_string())?; + } + + #[cfg(target_os = "linux")] + { + let path = std::path::Path::new(&path); + let parent = path.parent().unwrap_or(path).as_os_str(); + Command::new("xdg-open") + .arg(parent) + .spawn() + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +#[tauri::command] +pub fn get_applications(_path: Option) -> Result, String> { + #[cfg(target_os = "macos")] + { + let script = r#" + set output to "" + set app_paths to paragraphs of (do shell script "mdfind 'kMDItemContentType == \"com.apple.application-bundle\"' -onlyin /Applications -onlyin /System/Applications -onlyin ~/Applications") + repeat with app_path in app_paths + if app_path is not "" then + try + set app_info to info for (app_path as POSIX file) + set app_name to name of app_info + set bundle_id to bundle identifier of app_info + set output to output & app_name & "%%" & app_path & "%%" & bundle_id & "\n" + on error + -- ignore apps we can't get info for + end try + end if + end repeat + return output + "#; + let output = Command::new("osascript") + .arg("-e") + .arg(script) + .output() + .map_err(|e| e.to_string())?; + + let result_str = String::from_utf8_lossy(&output.stdout); + let apps = result_str + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split("%%").collect(); + if parts.len() == 3 { + Some(Application { + name: parts[0].to_string(), + path: parts[1].to_string(), + bundle_id: Some(parts[2].to_string()), + }) + } else { + None + } + }) + .collect(); + Ok(apps) + } + + #[cfg(target_os = "linux")] + { + Ok(crate::get_installed_apps().into_iter().map(|app| Application { + name: app.name, + path: app.exec.unwrap_or_default(), + bundle_id: None, + }).collect()) + } + + #[cfg(target_os = "windows")] + { + use winreg::enums::*; + use winreg::RegKey; + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let uninstall = hklm.open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall").map_err(|e| e.to_string())?; + let mut apps = Vec::new(); + + for key in uninstall.enum_keys().filter_map(Result::ok) { + if let Ok(subkey) = uninstall.open_subkey(key) { + if let (Ok(name), Ok(path)) = (subkey.get_value("DisplayName"), subkey.get_value("InstallLocation")) { + let name_str: String = name; + let path_str: String = path; + if !name_str.is_empty() && !path_str.is_empty() { + apps.push(Application { name: name_str, path: path_str, bundle_id: None }); + } + } + } + } + Ok(apps) + } +} + + +#[tauri::command] +pub fn get_default_application(path: String) -> Result { + Err(format!("get_default_application for '{}' is not yet implemented for this platform.", path)) +} + + +#[tauri::command] +pub fn get_frontmost_application() -> Result { + #[cfg(target_os = "macos")] + { + let script = r#" + tell application "System Events" + set front_app to first application process whose frontmost is true + set app_path to (path of application file of front_app) + set app_name to (name of front_app) + set bundle_id to (bundle identifier of front_app) + return app_name & "%%" & app_path & "%%" & bundle_id + end tell + "#; + let output = Command::new("osascript") + .arg("-e") + .arg(script) + .output() + .map_err(|e| e.to_string())?; + + let result_str = String::from_utf8_lossy(&output.stdout); + let parts: Vec<&str> = result_str.trim().split("%%").collect(); + if parts.len() == 3 { + Ok(Application { + name: parts[0].to_string(), + path: parts[1].to_string(), + bundle_id: Some(parts[2].to_string()), + }) + } else { + Err("Could not determine frontmost application".to_string()) + } + } + + #[cfg(any(target_os = "linux", target_os = "windows"))] + { + Err("get_frontmost_application is not yet implemented for this platform.".to_string()) + } +} \ No newline at end of file diff --git a/src/lib/sidecar.svelte.ts b/src/lib/sidecar.svelte.ts index d8e48be..1ab5b3d 100644 --- a/src/lib/sidecar.svelte.ts +++ b/src/lib/sidecar.svelte.ts @@ -166,6 +166,24 @@ class SidecarService { return; } + if (typedMessage.type.startsWith('system-')) { + const { requestId, ...params } = typedMessage.payload as { + requestId: string; + [key: string]: unknown; + }; + const command = typedMessage.type.replace('system-', '').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.startsWith('clipboard-')) { const { requestId, ...params } = typedMessage.payload as { requestId: string;