feat: implement browser extension apis

This commit implements the full api for the Raycast browser extension. It introduces a WebSocket server in the Tauri backend to handle JSON-RPC communication with the companion browser extension. The sidecar now exposes the BrowserExtension module with getTabs and getContent methods, which proxy requests through the frontend to the new backend commands.
This commit is contained in:
ByteAtATime 2025-06-18 21:50:46 -07:00
parent 623c8094dd
commit f7939e5c9b
No known key found for this signature in database
11 changed files with 725 additions and 156 deletions

View file

@ -184,6 +184,16 @@ const GetSelectedFinderItemsMessageSchema = z.object({
payload: GetSelectedFinderItemsPayloadSchema
});
const BrowserExtensionRequestPayloadSchema = z.object({
requestId: z.string(),
method: z.string(),
params: z.unknown()
});
const BrowserExtensionRequestMessageSchema = z.object({
type: z.literal('browser-extension-request'),
payload: BrowserExtensionRequestPayloadSchema
});
export const SidecarMessageWithPluginsSchema = z.union([
BatchUpdateSchema,
CommandSchema,
@ -193,6 +203,7 @@ export const SidecarMessageWithPluginsSchema = z.union([
GoBackToPluginListSchema,
OpenMessageSchema,
GetSelectedTextMessageSchema,
GetSelectedFinderItemsMessageSchema
GetSelectedFinderItemsMessageSchema,
BrowserExtensionRequestMessageSchema
]);
export type SidecarMessageWithPlugins = z.infer<typeof SidecarMessageWithPluginsSchema>;

View file

@ -0,0 +1,82 @@
import { writeOutput } from '../io';
import * as crypto from 'crypto';
const pendingRequests = new Map<
string,
{ resolve: (value: any) => void; reject: (reason?: any) => void }
>();
export function handleBrowserExtensionResponse(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 sendRequest<T>(method: string, params: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID();
pendingRequests.set(requestId, { resolve, reject });
writeOutput({
type: 'browser-extension-request',
payload: { requestId, method, params }
});
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject(new Error(`Request for ${method} timed out`));
}
}, 5000);
});
}
type Tab = {
active: boolean;
id: number;
url: string;
favicon?: string;
title?: string;
};
export const BrowserExtensionAPI = {
async getTabs(): Promise<Tab[]> {
const result = await sendRequest<{ value: any[] }>('getTabs', {});
return result.value.map((tab) => ({
id: tab.tabId,
url: tab.url,
title: tab.title,
favicon: tab.favicon,
active: tab.active
}));
},
async getContent(options?: {
cssSelector?: string;
tabId?: number;
format?: 'html' | 'text' | 'markdown';
}): Promise<string> {
const format = options?.format ?? 'markdown';
if (options?.cssSelector && format === 'markdown') {
throw new Error('When using a CSS selector, the `format` option can not be `markdown`.');
}
const params: { field: string; selector?: string; tabId?: number } = {
field: format
};
if (options?.cssSelector) {
params.selector = options.cssSelector;
}
if (options?.tabId) {
params.tabId = options.tabId;
}
const result = await sendRequest<{ value: string }>('getTab', params);
return result.value;
}
};

View file

@ -3,6 +3,7 @@ import * as fs from 'fs';
import { writeOutput } from '../io';
import type { Application } from './types';
import { config } from '../config';
import { browserExtensionState } from '../state';
const supportPath = config.supportDir;
try {
@ -17,6 +18,8 @@ export interface FileSystemItem {
path: string;
}
export const BrowserExtension = { name: 'BrowserExtension' };
export const environment = {
appearance: 'dark' as const,
assetsPath: config.assetsDir,
@ -29,7 +32,10 @@ export const environment = {
raycastVersion: '1.0.0',
supportPath: supportPath,
textSize: 'medium' as const,
canAccess: (): boolean => {
canAccess: (feature: { name: string }): boolean => {
if (feature && feature.name === 'BrowserExtension') {
return browserExtensionState.isConnected;
}
return true;
}
};

View file

@ -13,6 +13,7 @@ import { Detail } from './components/detail';
import { environment, getSelectedFinderItems, getSelectedText, open } from './environment';
import { preferencesStore } from '../preferences';
import { showToast } from './toast';
import { BrowserExtensionAPI } from './browserExtension';
let currentPluginName: string | null = null;
let currentPluginPreferences: Array<{
@ -40,11 +41,15 @@ export const getRaycastApi = () => {
LocalStorage,
Color,
Cache,
Icon,
LaunchType,
getSelectedFinderItems,
getSelectedText,
showToast,
Toast,
Action,
ActionPanel,
Detail,
Form,
Grid,
List,
environment,
getPreferenceValues: () => {
if (currentPluginName) {
@ -57,6 +62,11 @@ export const getRaycastApi = () => {
defaultAction: 'copy'
};
},
getSelectedFinderItems,
getSelectedText,
open,
showToast,
useNavigation,
usePersistentState: <T>(
key: string,
initialValue: T
@ -64,14 +74,6 @@ export const getRaycastApi = () => {
const [state, setState] = React.useState(initialValue);
return [state, setState, false];
},
useNavigation,
List,
Grid,
Action,
ActionPanel,
Detail,
Form,
Icon,
open
BrowserExtension: BrowserExtensionAPI
};
};

View file

@ -1,7 +1,7 @@
import { createInterface } from 'readline';
import { writeLog, writeOutput } from './io';
import { runPlugin, sendPluginList } from './plugin';
import { instances, navigationStack, toasts } from './state';
import { instances, navigationStack, toasts, browserExtensionState } from './state';
import { batchedUpdates, updateContainer } from './reconciler';
import { preferencesStore } from './preferences';
import type { RaycastInstance } from './types';
@ -10,6 +10,7 @@ import {
handleSelectedTextResponse,
type FileSystemItem
} from './api/environment';
import { handleBrowserExtensionResponse } from './api/browserExtension';
process.on('unhandledRejection', (reason: unknown) => {
writeLog(`--- UNHANDLED PROMISE REJECTION ---`);
@ -135,6 +136,20 @@ rl.on('line', (line) => {
handleGetSelectedFinderItemsResponse(requestId, items ?? null, error);
break;
}
case 'browser-extension-response': {
const { requestId, result, error } = command.payload as {
requestId: string;
result?: any;
error?: string;
};
handleBrowserExtensionResponse(requestId, result, error);
break;
}
case 'browser-extension-connection-status': {
const { isConnected } = command.payload as { isConnected: boolean };
browserExtensionState.isConnected = isConnected;
break;
}
default:
writeLog(`Unknown command action: ${command.action}`);
}

View file

@ -5,6 +5,9 @@ import type React from 'react';
export const instances = new Map<number, AnyInstance>();
export const root: Container = { id: 'root', children: [] };
export const toasts = new Map<number, any>();
export const browserExtensionState = {
isConnected: false
};
let instanceCounter = 0;
export const getNextInstanceId = (): number => ++instanceCounter;

438
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -34,8 +34,11 @@ zbus = "5.7.1"
reqwest = "0.12.20"
zip = "4.1.0"
bytes = "1.10.1"
tokio-tungstenite = "^0.27"
futures-util = "^0.3.31"
tokio = { version = "^1.45.1", features = ["full"] }
uuid = { version = "^1.17.0", features = ["v4", "serde"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"

View file

@ -0,0 +1,161 @@
use futures_util::{stream::StreamExt, SinkExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager, State};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message;
#[derive(Serialize, Deserialize)]
struct JsonRpcRequest {
jsonrpc: String,
method: String,
params: serde_json::Value,
id: u64,
}
#[derive(Serialize, Deserialize, Debug)]
struct JsonRpcResponse {
jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
id: u64,
}
#[derive(Serialize, Deserialize, Debug)]
struct JsonRpcError {
code: i32,
message: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
enum IncomingMessage {
Request {
id: u64,
method: String,
params: Option<serde_json::Value>,
},
Response {
id: u64,
#[serde(default)]
result: serde_json::Value,
#[serde(default)]
error: serde_json::Value,
},
Notification {
method: String,
params: Option<serde_json::Value>,
},
}
pub struct WsState {
pub connection: Arc<Mutex<Option<tokio::sync::mpsc::Sender<String>>>>,
pub is_connected: Arc<Mutex<bool>>,
pub pending_requests: Arc<Mutex<HashMap<u64, oneshot::Sender<Result<serde_json::Value, String>>>>>,
pub request_id_counter: Arc<Mutex<u64>>,
}
impl Default for WsState {
fn default() -> Self {
Self {
connection: Arc::new(Mutex::new(None)),
is_connected: Arc::new(Mutex::new(false)),
pending_requests: Arc::new(Mutex::new(HashMap::new())),
request_id_counter: Arc::new(Mutex::new(0)),
}
}
}
async fn handle_connection(stream: TcpStream, app_handle: AppHandle) {
let state: State<WsState> = app_handle.state();
let ws_stream = match tokio_tungstenite::accept_async(stream).await {
Ok(ws) => ws,
Err(e) => {
eprintln!("WebSocket handshake error: {}", e);
return;
}
};
*state.is_connected.lock().unwrap() = true;
println!("Browser extension connected.");
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(100);
*state.connection.lock().unwrap() = Some(tx);
let sender_task = tokio::spawn(async move {
while let Some(msg_to_send) = rx.recv().await {
if ws_sender.send(Message::Binary(msg_to_send.into())).await.is_err() {
break;
}
}
});
let app_clone_for_receiver = app_handle.clone();
let receiver_task = tokio::spawn(async move {
while let Some(msg) = ws_receiver.next().await {
let msg = match msg {
Ok(m) => m,
Err(_) => break,
};
if let Message::Text(text) = msg {
match serde_json::from_str::<IncomingMessage>(&text) {
Ok(IncomingMessage::Request { id, method, .. }) => {
if method == "ping" {
let response = json!({
"jsonrpc": "2.0",
"result": null,
"id": id
});
let tx = app_clone_for_receiver.state::<WsState>().connection.lock().unwrap().clone();
if let Some(tx) = tx {
let _ = tx.send(response.to_string()).await;
}
}
}
Ok(IncomingMessage::Response { id, result, error }) => {
let sender = app_clone_for_receiver.state::<WsState>().pending_requests.lock().unwrap().remove(&id);
if let Some(sender) = sender {
if !error.is_null() {
let _ = sender.send(Err(error.to_string()));
} else {
let _ = sender.send(Ok(result));
}
}
}
Ok(IncomingMessage::Notification { method, params }) => {
println!("Received notification: {} with params {:?}", method, params);
}
Err(e) => {
eprintln!("Failed to parse message from browser extension: {}", e);
}
}
}
}
});
tokio::select! {
_ = sender_task => {},
_ = receiver_task => {},
}
*state.is_connected.lock().unwrap() = false;
*state.connection.lock().unwrap() = None;
println!("Browser extension disconnected.");
}
pub async fn run_server(app_handle: AppHandle) {
let addr = "127.0.0.1:7265";
let listener = TcpListener::bind(&addr).await.expect("Failed to bind");
println!("WebSocket server listening on ws://{}", addr);
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(handle_connection(stream, app_handle.clone()));
}
}

View file

@ -1,4 +1,5 @@
mod app;
mod browser_extension;
mod cache;
mod desktop;
mod error;
@ -6,6 +7,8 @@ mod error;
use crate::{app::App, cache::AppCache};
#[cfg(target_os = "linux")]
use arboard;
use browser_extension::WsState;
use serde_json::json;
use selection::get_text;
use std::fs;
use std::io::{self, Cursor};
@ -15,7 +18,7 @@ use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::Duration;
use tauri::Manager;
use tauri::{Manager, State};
#[cfg(target_os = "linux")]
use url::Url;
#[cfg(target_os = "linux")]
@ -165,23 +168,6 @@ fn get_selected_finder_items_windows() -> Result<Vec<FileSystemItem>, String> {
Ok(paths)
}
#[cfg(target_os = "linux")]
async fn get_selected_finder_items_linux() -> Result<Vec<FileSystemItem>, 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<Vec<FileSystemItem>, String> {
let connection = match zbus::Connection::session().await {
@ -281,6 +267,23 @@ fn get_from_clipboard() -> Result<Vec<FileSystemItem>, String> {
Ok(vec![])
}
#[cfg(target_os = "linux")]
async fn get_selected_finder_items_linux() -> Result<Vec<FileSystemItem>, 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())
}
#[tauri::command]
fn get_installed_apps() -> Vec<App> {
match AppCache::get_apps() {
@ -428,9 +431,61 @@ async fn install_extension(
Ok(())
}
#[tauri::command]
async fn browser_extension_check_connection(state: State<'_, WsState>) -> Result<bool, String> {
Ok(*state.is_connected.lock().unwrap())
}
#[tauri::command]
async fn browser_extension_request(
method: String,
params: serde_json::Value,
state: State<'_, WsState>,
) -> Result<serde_json::Value, String> {
let tx = {
let lock = state.connection.lock().unwrap();
lock.clone()
};
if let Some(tx) = tx {
let request_id = {
let mut counter = state.request_id_counter.lock().unwrap();
*counter += 1;
*counter
};
let request = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": request_id
});
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
state
.pending_requests
.lock()
.unwrap()
.insert(request_id, response_tx);
if tx.send(request.to_string()).await.is_err() {
return Err("Failed to send message to browser extension".into());
}
match tokio::time::timeout(Duration::from_secs(5), response_rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err("Request cancelled".into()),
Err(_) => Err("Request timed out".into()),
}
} else {
Err("Browser extension not connected".into())
}
}
#[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() {
@ -450,13 +505,18 @@ pub fn run() {
launch_app,
get_selected_text,
get_selected_finder_items,
install_extension
install_extension,
browser_extension_check_connection,
browser_extension_request
])
.setup(|app| {
use tauri_plugin_global_shortcut::{
Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState,
};
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(browser_extension::run_server(app_handle));
thread::spawn(|| {
thread::sleep(Duration::from_secs(60));
loop {
@ -498,4 +558,4 @@ pub fn run() {
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
}

View file

@ -10,6 +10,7 @@ class SidecarService {
#receiveBuffer = Buffer.alloc(0);
#unpackr = new Unpackr();
#onGoBackToPluginList: (() => void) | null = null;
#browserExtensionConnectionInterval: ReturnType<typeof setInterval> | null = null;
logs: string[] = $state([]);
@ -46,6 +47,16 @@ class SidecarService {
this.#log(`Sidecar spawned with PID: ${this.#sidecarChild.pid}`);
this.requestPluginList();
this.#browserExtensionConnectionInterval = setInterval(async () => {
try {
const isConnected = await invoke<boolean>('browser_extension_check_connection');
this.dispatchEvent('browser-extension-connection-status', { isConnected });
} catch (e) {
this.#log(`Error checking browser extension connection: ${e}`);
this.dispatchEvent('browser-extension-connection-status', { isConnected: false });
}
}, 5000);
} catch (e) {
this.#log(`ERROR starting sidecar: ${e}`);
console.error('Failed to start sidecar:', e);
@ -58,6 +69,10 @@ class SidecarService {
this.#sidecarChild.kill();
this.#sidecarChild = null;
}
if (this.#browserExtensionConnectionInterval) {
clearInterval(this.#browserExtensionConnectionInterval);
this.#browserExtensionConnectionInterval = null;
}
};
dispatchEvent = (action: string, payload?: object) => {
@ -193,6 +208,19 @@ class SidecarService {
return;
}
if (typedMessage.type === 'browser-extension-request') {
const { requestId, method, params } = typedMessage.payload;
invoke('browser_extension_request', { method, params })
.then((result) => {
this.dispatchEvent('browser-extension-response', { requestId, result });
})
.catch((error) => {
this.#log(`ERROR from browser extension request: ${error}`);
this.dispatchEvent('browser-extension-response', { requestId, error: String(error) });
});
return;
}
const commands = typedMessage.type === 'BATCH_UPDATE' ? typedMessage.payload : [typedMessage];
if (commands.length > 0) {
uiStore.applyCommands(commands);