From 1b7f59f88b3f8499533c798e18fb44f4bdadb755 Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Mon, 22 Jun 2026 17:01:38 -0700 Subject: [PATCH] Add spotlight shortcut and menu bar controls --- apps/desktop/app/(app)/settings/page.tsx | 82 +++++++++++ apps/desktop/lib/spotlight.ts | 12 ++ apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/lib.rs | 27 +++- apps/desktop/src-tauri/src/spotlight.rs | 165 ++++++++++++++++++++--- apps/desktop/src-tauri/src/tray.rs | 62 +++++++++ 6 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src-tauri/src/tray.rs diff --git a/apps/desktop/app/(app)/settings/page.tsx b/apps/desktop/app/(app)/settings/page.tsx index bd9b18c2..e7901a63 100644 --- a/apps/desktop/app/(app)/settings/page.tsx +++ b/apps/desktop/app/(app)/settings/page.tsx @@ -13,6 +13,11 @@ import Link from "next/link" import { useRouter } from "next/navigation" import { useEffect, useState } from "react" import { clearSession, getSession, type AuthSession } from "@/lib/auth" +import { + getSpotlightShortcut, + setSpotlightShortcut, + type SpotlightShortcut, +} from "@/lib/spotlight" type AppInfo = { name: string @@ -24,6 +29,9 @@ export default function SettingsPage() { const router = useRouter() const [info, setInfo] = useState(null) const [session, setSession] = useState(null) + const [shortcut, setShortcut] = useState(null) + const [shortcutError, setShortcutError] = useState(null) + const [savingShortcut, setSavingShortcut] = useState(null) useEffect(() => { invoke("app_info") @@ -32,6 +40,9 @@ export default function SettingsPage() { getSession() .then(setSession) .catch(() => setSession(null)) + getSpotlightShortcut() + .then(setShortcut) + .catch(() => setShortcut(null)) }, []) async function signOut() { @@ -39,6 +50,24 @@ export default function SettingsPage() { router.replace("/login") } + async function updateShortcut(accelerator: string) { + setShortcutError(null) + setSavingShortcut(accelerator) + try { + setShortcut(await setSpotlightShortcut(accelerator)) + } catch (err) { + setShortcutError( + err instanceof Error + ? err.message + : typeof err === "string" + ? err + : "Could not update shortcut", + ) + } finally { + setSavingShortcut(null) + } + } + return (

Settings

@@ -62,6 +91,42 @@ export default function SettingsPage() { + + + Spotlight + + Global shortcut for the floating memory search window. + + + + +
+ {SHORTCUT_OPTIONS.map((option) => { + const selected = shortcut?.accelerator === option.accelerator + return ( + + ) + })} +
+ {shortcutError ? ( +

{shortcutError}

+ ) : null} +
+
+ About @@ -89,6 +154,12 @@ export default function SettingsPage() { ) } +const SHORTCUT_OPTIONS = [ + { label: "⌘ ⇧ M", accelerator: "CommandOrControl+Shift+M" }, + { label: "⌘ ⇧ Space", accelerator: "CommandOrControl+Shift+Space" }, + { label: "⌘ ⌥ M", accelerator: "CommandOrControl+Option+M" }, +] as const + function Row({ label, value }: { label: string; value: string }) { return (
@@ -97,3 +168,14 @@ function Row({ label, value }: { label: string; value: string }) {
) } + +function formatShortcut(value: string) { + return value + .replace("CommandOrControl", "⌘") + .replace("Command", "⌘") + .replace("Control", "⌃") + .replace("Option", "⌥") + .replace("Alt", "⌥") + .replace("Shift", "⇧") + .replaceAll("+", " ") +} diff --git a/apps/desktop/lib/spotlight.ts b/apps/desktop/lib/spotlight.ts index 282658b4..7c1653d6 100644 --- a/apps/desktop/lib/spotlight.ts +++ b/apps/desktop/lib/spotlight.ts @@ -13,6 +13,10 @@ export type SpotlightMemory = { createdAt: string } +export type SpotlightShortcut = { + accelerator: string +} + export const OPEN_MEMORY_EVENT = "nav:open-memory" export const SPOTLIGHT_SHOWN_EVENT = "spotlight:shown" @@ -27,3 +31,11 @@ export function hideSpotlight() { export function openSpotlightResult(memory: SpotlightMemory) { return invoke("spotlight_open_result", { memory }) } + +export function getSpotlightShortcut() { + return invoke("spotlight_get_shortcut") +} + +export function setSpotlightShortcut(accelerator: string) { + return invoke("spotlight_set_shortcut", { accelerator }) +} diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1a29325c..a01b1f3c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -15,7 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["tray-icon"] } keyring = "3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0ed16fa9..c6d19239 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ mod auth; mod spotlight; +mod tray; use serde::Serialize; +use tauri::Manager; /// Identity of the native app, surfaced to the webview over IPC. /// `rename_all = "camelCase"` makes the JSON keys match the TypeScript `AppInfo`. @@ -62,13 +64,32 @@ fn spotlight_open_result( spotlight::open_result(&app, memory).map_err(|error| error.to_string()) } +#[tauri::command] +fn spotlight_get_shortcut( + state: tauri::State<'_, spotlight::SpotlightShortcutState>, +) -> Result { + spotlight::get_shortcut(&state) +} + +#[tauri::command] +fn spotlight_set_shortcut( + app: tauri::AppHandle, + state: tauri::State<'_, spotlight::SpotlightShortcutState>, + accelerator: String, +) -> Result { + spotlight::set_shortcut(&app, &state, accelerator) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .setup(|app| { spotlight::create_window(app)?; - spotlight::register_shortcut(app); + let accelerator = spotlight::load_shortcut(app.handle()); + app.manage(spotlight::SpotlightShortcutState::new(accelerator.clone())); + spotlight::register_shortcut(app, &accelerator); + tray::create(app)?; Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -79,7 +100,9 @@ pub fn run() { auth_whoami, spotlight_show, spotlight_hide, - spotlight_open_result + spotlight_open_result, + spotlight_get_shortcut, + spotlight_set_shortcut ]) // Bootstrap failure is unrecoverable (no window, no app), so we abort // loudly here. This is the one sanctioned `expect` — see roadmap quality bar. diff --git a/apps/desktop/src-tauri/src/spotlight.rs b/apps/desktop/src-tauri/src/spotlight.rs index 7f07217a..67c68392 100644 --- a/apps/desktop/src-tauri/src/spotlight.rs +++ b/apps/desktop/src-tauri/src/spotlight.rs @@ -1,14 +1,20 @@ +use std::{fs, sync::Mutex}; + use serde::{Deserialize, Serialize}; use tauri::{ App, AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, WindowEvent, }; -use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; +use tauri_plugin_global_shortcut::{ + GlobalShortcutExt, Shortcut, ShortcutEvent, ShortcutState, +}; const MAIN_LABEL: &str = "main"; const SPOTLIGHT_LABEL: &str = "spotlight"; const OPEN_MEMORY_EVENT: &str = "nav:open-memory"; const SHOWN_EVENT: &str = "spotlight:shown"; +const SHORTCUT_FILE: &str = "spotlight-shortcut.json"; +const DEFAULT_SHORTCUT: &str = "CommandOrControl+Shift+M"; #[derive(Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -24,8 +30,28 @@ pub struct SpotlightMemory { created_at: String, } -pub fn shortcut() -> Shortcut { - Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM) +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct StoredShortcut { + accelerator: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotlightShortcut { + accelerator: String, +} + +pub struct SpotlightShortcutState { + accelerator: Mutex, +} + +impl SpotlightShortcutState { + pub fn new(accelerator: String) -> Self { + Self { + accelerator: Mutex::new(accelerator), + } + } } pub fn create_window(app: &App) -> tauri::Result { @@ -58,23 +84,62 @@ pub fn create_window(app: &App) -> tauri::Result { Ok(window) } -pub fn register_shortcut(app: &App) { - let result = app - .global_shortcut() - .on_shortcut(shortcut(), |app, shortcut, event| { - if event.state == ShortcutState::Pressed && shortcut.matches( - Modifiers::SUPER | Modifiers::SHIFT, - Code::KeyM, - ) { - let _ = toggle(app); - } - }); +pub fn load_shortcut(app: &AppHandle) -> String { + read_shortcut(app).unwrap_or_else(|| DEFAULT_SHORTCUT.to_string()) +} - if let Err(error) = result { +pub fn register_shortcut(app: &App, accelerator: &str) { + if let Err(error) = register_accelerator(app, accelerator) { eprintln!("failed to register Supermemory spotlight shortcut: {error}"); } } +pub fn get_shortcut(state: &SpotlightShortcutState) -> Result { + Ok(SpotlightShortcut { + accelerator: state + .accelerator + .lock() + .map_err(|_| "Shortcut state lock poisoned".to_string())? + .clone(), + }) +} + +pub fn set_shortcut( + app: &AppHandle, + state: &SpotlightShortcutState, + accelerator: String, +) -> Result { + validate_accelerator(&accelerator)?; + + let mut current = state + .accelerator + .lock() + .map_err(|_| "Shortcut state lock poisoned".to_string())?; + + if *current == accelerator { + return Ok(SpotlightShortcut { + accelerator: current.clone(), + }); + } + + let previous = current.clone(); + unregister_accelerator_if_registered(app, &previous)?; + + if let Err(error) = register_accelerator(app, &accelerator) { + let _ = register_accelerator(app, &previous); + return Err(error); + } + + if let Err(error) = write_shortcut(app, &accelerator) { + let _ = unregister_accelerator(app, &accelerator); + let _ = register_accelerator(app, &previous); + return Err(error); + } + + *current = accelerator.clone(); + Ok(SpotlightShortcut { accelerator }) +} + pub fn toggle(app: &AppHandle) -> tauri::Result<()> { let window = spotlight_window(app)?; if window.is_visible()? { @@ -111,3 +176,73 @@ fn spotlight_window(app: &AppHandle) -> tauri::Result { app.get_webview_window(SPOTLIGHT_LABEL) .ok_or_else(|| tauri::Error::WindowNotFound) } + +fn shortcut_handler(app: &AppHandle, _shortcut: &Shortcut, event: ShortcutEvent) { + if event.state == ShortcutState::Pressed { + let _ = toggle(app); + } +} + +fn register_accelerator(app: &T, accelerator: &str) -> Result<(), String> +where + T: GlobalShortcutExt, +{ + validate_accelerator(accelerator)?; + app.global_shortcut() + .on_shortcut(accelerator, shortcut_handler) + .map_err(|error| error.to_string()) +} + +fn unregister_accelerator(app: &AppHandle, accelerator: &str) -> Result<(), String> { + validate_accelerator(accelerator)?; + app.global_shortcut() + .unregister(accelerator) + .map_err(|error| error.to_string()) +} + +fn unregister_accelerator_if_registered(app: &AppHandle, accelerator: &str) -> Result<(), String> { + validate_accelerator(accelerator)?; + if app.global_shortcut().is_registered(accelerator) { + unregister_accelerator(app, accelerator)?; + } + Ok(()) +} + +fn validate_accelerator(accelerator: &str) -> Result<(), String> { + accelerator + .parse::() + .map(|_| ()) + .map_err(|error| format!("Invalid shortcut: {error}")) +} + +fn read_shortcut(app: &AppHandle) -> Option { + let path = shortcut_path(app).ok()?; + let contents = fs::read_to_string(path).ok()?; + let stored = serde_json::from_str::(&contents).ok()?; + validate_accelerator(&stored.accelerator).ok()?; + Some(stored.accelerator) +} + +fn write_shortcut(app: &AppHandle, accelerator: &str) -> Result<(), String> { + let path = shortcut_path(app)?; + let parent = path + .parent() + .ok_or_else(|| "Could not resolve shortcut config directory".to_string())?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + + let payload = serde_json::to_vec_pretty(&StoredShortcut { + accelerator: accelerator.to_string(), + }) + .map_err(|error| error.to_string())?; + let temp_path = path.with_extension("json.tmp"); + fs::write(&temp_path, payload).map_err(|error| error.to_string())?; + fs::rename(temp_path, path).map_err(|error| error.to_string()) +} + +fn shortcut_path(app: &AppHandle) -> Result { + Ok(app + .path() + .app_config_dir() + .map_err(|error| error.to_string())? + .join(SHORTCUT_FILE)) +} diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs new file mode 100644 index 00000000..ed9ca3bf --- /dev/null +++ b/apps/desktop/src-tauri/src/tray.rs @@ -0,0 +1,62 @@ +use tauri::{ + menu::MenuBuilder, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + App, Manager, +}; + +use crate::spotlight; + +const MENU_OPEN_SPOTLIGHT: &str = "open-spotlight"; +const MENU_SHOW_APP: &str = "show-app"; +const MENU_QUIT: &str = "quit"; + +pub fn create(app: &App) -> tauri::Result<()> { + let menu = MenuBuilder::new(app) + .text(MENU_OPEN_SPOTLIGHT, "Open Spotlight") + .text(MENU_SHOW_APP, "Show Supermemory") + .separator() + .text(MENU_QUIT, "Quit") + .build()?; + let icon = app.default_window_icon().cloned(); + + let mut tray = TrayIconBuilder::with_id("supermemory") + .menu(&menu) + .show_menu_on_left_click(false) + .tooltip("Supermemory") + .icon_as_template(true) + .on_menu_event(|app, event| match event.id().as_ref() { + MENU_OPEN_SPOTLIGHT => { + let _ = spotlight::show(app); + } + MENU_SHOW_APP => { + let _ = show_main_window(app); + } + MENU_QUIT => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let _ = spotlight::show(tray.app_handle()); + } + }); + + if let Some(icon) = icon { + tray = tray.icon(icon); + } + + tray.build(app)?; + Ok(()) +} + +fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(main) = app.get_webview_window("main") { + main.show()?; + main.set_focus()?; + } + Ok(()) +}