mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-06 02:47:54 +00:00
Add spotlight shortcut and menu bar controls
This commit is contained in:
parent
53c10e2126
commit
1b7f59f88b
6 changed files with 332 additions and 18 deletions
|
|
@ -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("+", " ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
62
apps/desktop/src-tauri/src/tray.rs
Normal file
62
apps/desktop/src-tauri/src/tray.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue