Add spotlight shortcut and menu bar controls

This commit is contained in:
Sreeram Sreedhar 2026-06-22 17:01:38 -07:00
parent 53c10e2126
commit 1b7f59f88b
6 changed files with 332 additions and 18 deletions

View file

@ -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<AppInfo | null>(null)
const [session, setSession] = useState<AuthSession | null>(null)
const [shortcut, setShortcut] = useState<SpotlightShortcut | null>(null)
const [shortcutError, setShortcutError] = useState<string | null>(null)
const [savingShortcut, setSavingShortcut] = useState<string | null>(null)
useEffect(() => {
invoke<AppInfo>("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 (
<div className="mx-auto w-full max-w-2xl p-8">
<h1 className="mb-6 font-semibold text-2xl">Settings</h1>
@ -62,6 +91,42 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle className="text-base">Spotlight</CardTitle>
<CardDescription>
Global shortcut for the floating memory search window.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<Row
label="Current"
value={formatShortcut(shortcut?.accelerator ?? "...")}
/>
<div className="grid gap-2 sm:grid-cols-3">
{SHORTCUT_OPTIONS.map((option) => {
const selected = shortcut?.accelerator === option.accelerator
return (
<Button
key={option.accelerator}
type="button"
variant={selected ? "default" : "outline"}
disabled={savingShortcut !== null}
onClick={() => updateShortcut(option.accelerator)}
>
{savingShortcut === option.accelerator
? "Saving..."
: option.label}
</Button>
)
})}
</div>
{shortcutError ? (
<p className="text-destructive text-sm">{shortcutError}</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">About</CardTitle>
@ -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 (
<div className="flex justify-between">
@ -97,3 +168,14 @@ function Row({ label, value }: { label: string; value: string }) {
</div>
)
}
function formatShortcut(value: string) {
return value
.replace("CommandOrControl", "⌘")
.replace("Command", "⌘")
.replace("Control", "⌃")
.replace("Option", "⌥")
.replace("Alt", "⌥")
.replace("Shift", "⇧")
.replaceAll("+", " ")
}

View file

@ -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<SpotlightShortcut>("spotlight_get_shortcut")
}
export function setSpotlightShortcut(accelerator: string) {
return invoke<SpotlightShortcut>("spotlight_set_shortcut", { accelerator })
}

View file

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

View file

@ -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::SpotlightShortcut, String> {
spotlight::get_shortcut(&state)
}
#[tauri::command]
fn spotlight_set_shortcut(
app: tauri::AppHandle,
state: tauri::State<'_, spotlight::SpotlightShortcutState>,
accelerator: String,
) -> Result<spotlight::SpotlightShortcut, String> {
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.

View file

@ -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<String>,
}
impl SpotlightShortcutState {
pub fn new(accelerator: String) -> Self {
Self {
accelerator: Mutex::new(accelerator),
}
}
}
pub fn create_window(app: &App) -> tauri::Result<WebviewWindow> {
@ -58,23 +84,62 @@ pub fn create_window(app: &App) -> tauri::Result<WebviewWindow> {
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<SpotlightShortcut, String> {
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<SpotlightShortcut, String> {
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<WebviewWindow> {
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<T>(app: &T, accelerator: &str) -> Result<(), String>
where
T: GlobalShortcutExt<tauri::Wry>,
{
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::<Shortcut>()
.map(|_| ())
.map_err(|error| format!("Invalid shortcut: {error}"))
}
fn read_shortcut(app: &AppHandle) -> Option<String> {
let path = shortcut_path(app).ok()?;
let contents = fs::read_to_string(path).ok()?;
let stored = serde_json::from_str::<StoredShortcut>(&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<std::path::PathBuf, String> {
Ok(app
.path()
.app_config_dir()
.map_err(|error| error.to_string())?
.join(SHORTCUT_FILE))
}

View file

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