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.
This commit is contained in:
ByteAtATime 2025-06-23 18:12:52 -07:00
parent 418c23cb0a
commit 9c1ee6efe4
No known key found for this signature in database
9 changed files with 358 additions and 101 deletions

View file

@ -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<typeof SidecarMessageWithPluginsSchema>;

View file

@ -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<T>(type: string, payload: object = {}): Promise<T> {
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<FileSystemItem[]> {
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<FileSystemItem[]>('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<string> {
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<string>('get-selected-text');
}
export async function open(target: string, application?: Application | string): Promise<void> {
@ -137,3 +100,25 @@ export async function open(target: string, application?: Application | string):
}
});
}
export async function getApplications(path?: fs.PathLike): Promise<Application[]> {
const pathString = path ? path.toString() : undefined;
return sendSystemRequest<Application[]>('get-applications', { path: pathString });
}
export async function getDefaultApplication(path: fs.PathLike): Promise<Application> {
return sendSystemRequest<Application>('get-default-application', { path: path.toString() });
}
export async function getFrontmostApplication(): Promise<Application> {
return sendSystemRequest<Application>('get-frontmost-application');
}
export async function showInFinder(path: fs.PathLike): Promise<void> {
return sendSystemRequest<void>('show-in-finder', { path: path.toString() });
}
export async function trash(path: fs.PathLike | fs.PathLike[]): Promise<void> {
const paths = (Array.isArray(path) ? path : [path]).map((p) => p.toString());
return sendSystemRequest<void>('trash', { paths });
}

View file

@ -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: <T>(
key: string,

View file

@ -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;
}
});
});

25
src-tauri/Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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();

168
src-tauri/src/system.rs Normal file
View file

@ -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<String>,
}
#[tauri::command]
pub fn trash(paths: Vec<String>) -> 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<String>) -> Result<Vec<Application>, 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<Application, String> {
Err(format!("get_default_application for '{}' is not yet implemented for this platform.", path))
}
#[tauri::command]
pub fn get_frontmost_application() -> Result<Application, String> {
#[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())
}
}

View file

@ -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;