From 6c0153be1e3b4c825bd57fd4230e986035416bc0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 1 Sep 2026 18:05:20 -0400 Subject: [PATCH] desktop: a global hotkey opens a small window to write in, now with its lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores 42e06da, which was reverted only because Cargo.lock had not been updated for the new crate and every cargo invocation in CI passes `--locked`. Both desktop jobs failed on that line before compiling anything, so nothing about the code had been judged. The lockfile was generated in CI's own `ci-tauri:1.97` image — one container, `cargo fetch`, nothing built. `cargo fetch` and NOT `generate-lockfile`: the latter re-resolves from scratch and would have churned versions across the whole workspace to add one dependency. The diff is 67 insertions, zero deletions, six packages — tauri-plugin-global-shortcut plus global-hotkey, x11rb, x11rb-protocol, xkeysym and gethostname. Nothing existing moved. The feature itself, unchanged from 42e06da: Press the combination anywhere and a small window arrives over whatever you were doing; type, Ctrl/Cmd+Enter, gone. The board never comes forward. There is no default shortcut on purpose — any default is a key combination taken away from something else on somebody's machine, silently, at install time. CommandOrControl+Shift+N is offered as a one-click suggestion. Stored and live are separate fields because they disagree: a combination another app holds is saved and does nothing when pressed, and a Wayland compositor may refuse global grabs outright. `capture_shortcut_set` registers before storing, so a refused combination is never written down as if it worked. The window hides rather than closes and keeps its text, so an interrupted capture is still there next press — which is what makes Escape safe. A failed save keeps it open too, rather than discarding the only copy of something just written in order to report a retryable problem. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c --- Cargo.lock | 67 ++++++ desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/capabilities/default.json | 4 +- desktop/src-tauri/src/capture.rs | 229 ++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 11 + frontend/src/desktop/bridge.ts | 53 +++++ frontend/src/router/index.ts | 16 ++ frontend/src/views/BoardView.vue | 15 +- frontend/src/views/CaptureView.vue | 87 ++++++++ frontend/src/views/SyncView.vue | 94 ++++++++ 10 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 desktop/src-tauri/src/capture.rs create mode 100644 frontend/src/views/CaptureView.vue diff --git a/Cargo.lock b/Cargo.lock index da578b5..b339ffc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1286,6 +1286,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1405,6 +1415,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.20", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -3947,6 +3975,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-plugin-log" version = "2.9.0" @@ -4196,6 +4239,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-updater", "thoughtsync-core", @@ -5577,6 +5621,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -5587,6 +5648,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f7720e1..875e6d6 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -64,3 +64,8 @@ tauri-plugin-log = "2" # the plugin declares android support level "none", which is why the Android client # gets a server-served update path instead (Scribe note 2725). tauri-plugin-updater = "2" + +# The system-wide quick-capture hotkey. Desktop only by nature — Android has no +# concept of a global shortcut, and its half of this feature is a share-sheet +# intent filter instead. +tauri-plugin-global-shortcut = "2" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 1116ed7..5f301f1 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Core capability for the main ThoughtSync window.", - "windows": ["main"], + "description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.", + "windows": ["main", "capture"], "permissions": ["core:default"] } diff --git a/desktop/src-tauri/src/capture.rs b/desktop/src-tauri/src/capture.rs new file mode 100644 index 0000000..933ec96 --- /dev/null +++ b/desktop/src-tauri/src/capture.rs @@ -0,0 +1,229 @@ +//! Quick capture: a system-wide hotkey that opens a small window to type into. +//! +//! The point is capture WITHOUT the app. Bringing the whole board forward to write +//! one line is the friction this removes, so the shortcut opens a small window of +//! its own rather than focusing `main` — and that window closes itself the moment +//! the note is saved. +//! +//! ## Why the shortcut is configurable, and why it starts unset +//! +//! A global shortcut is the one setting in this app that can collide with software +//! it knows nothing about. Whatever default is picked is a key combination taken +//! away from something on somebody's machine, silently, at install time. So there +//! is no default: the feature is off until someone chooses a combination, and +//! choosing one is how it turns on. [`SUGGESTED`] is offered by the UI as a +//! starting point, not applied on its behalf. +//! +//! ## Failure has to be visible +//! +//! Registering can fail — the combination may already be held by the window +//! manager or another app, and on Wayland a compositor may refuse global grabs +//! outright. A hotkey that quietly does nothing is worse than one that was never +//! offered, because there is nothing to look at and nothing to fix. So the stored +//! shortcut and the LIVE registration are reported separately: see +//! [`CaptureShortcut`]. + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; + +use thoughtsync_core::local::{store, Db}; + +const SHORTCUT_PREF: &str = "capture_shortcut"; + +/// The window the hotkey opens. Also the label the capability file grants to. +pub const CAPTURE_WINDOW: &str = "capture"; + +/// What the UI offers as a starting point. NOT applied automatically — see above. +/// +/// `CommandOrControl+Shift+N`: the Command/Control split is Tauri's own portable +/// spelling, and Shift+N is rare enough to be free on most desktops while still +/// meaning "new" to the person pressing it. +pub const SUGGESTED: &str = "CommandOrControl+Shift+N"; + +/// Emitted to the main window after a capture is saved, so the board reloads. +/// +/// The two windows hold separate copies of the frontend and therefore separate +/// Pinia stores; nothing in the capture window's store can reach the board's. The +/// note is already in SQLite by the time this fires — this only says "look again". +pub const CAPTURED_EVENT: &str = "thoughtsync://captured"; + +/// The stored shortcut and whether it is actually live. +/// +/// Two fields rather than one because they genuinely disagree: a combination can +/// be saved and refuse to register, and the person needs to be told which of those +/// they are looking at. `registered: false` with a non-empty `shortcut` is the +/// "something else already has this" case. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureShortcut { + /// The stored combination, or empty when quick capture is off. + pub shortcut: String, + /// Whether the OS accepted it. Always false when `shortcut` is empty. + pub registered: bool, +} + +fn stored(db: &Db) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + Ok(store::pref(&conn, SHORTCUT_PREF) + .map_err(|e| e.to_string())? + .unwrap_or_default()) +} + +/// Open (or focus) the capture window. +/// +/// Reused rather than recreated: holding one window and showing it is what makes +/// the second press feel instant, and it means a half-typed capture survives the +/// window being dismissed and reopened. +/// +/// `always_on_top` and `center` because this is summoned over whatever you were +/// doing — a capture window that opens behind the app you called it from has +/// failed at the only thing it does. +fn open_capture_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + return; + } + + // `index.html?capture=1` rather than a `/capture` path: the bundled assets are + // served as files, so a path with no file behind it is a 404 in the production + // build even though it routes fine under the dev server. A query string is + // carried through untouched and the router reads it on boot. + let built = WebviewWindowBuilder::new( + app, + CAPTURE_WINDOW, + WebviewUrl::App("index.html?capture=1".into()), + ) + .title("Quick capture") + .inner_size(520.0, 220.0) + .min_inner_size(360.0, 160.0) + .resizable(true) + .always_on_top(true) + .center() + .skip_taskbar(true) + .build(); + + match built { + Ok(window) => { + let _ = window.set_focus(); + } + // Never a panic and never fatal: failing to open a capture window must not + // take down an app whose board is working fine. + Err(e) => log::error!("could not open the capture window: {e}"), + } +} + +/// Register `shortcut`, replacing whatever was live. +/// +/// Unregisters everything first rather than tracking the previous binding: this +/// app owns exactly one global shortcut, so "all of ours" and "the old one" are +/// the same set, and keeping a copy of it is one more thing to get out of step. +fn register(app: &AppHandle, shortcut: &str) -> Result<(), String> { + let manager = app.global_shortcut(); + let _ = manager.unregister_all(); + if shortcut.is_empty() { + return Ok(()); + } + let parsed: Shortcut = shortcut + .parse() + .map_err(|_| format!("'{shortcut}' is not a shortcut this system understands."))?; + manager + .on_shortcut(parsed, |app, _shortcut, event| { + // Pressed only. Without this the window is opened on the press AND on + // the release, and the second one lands on the window the first opened. + if event.state == ShortcutState::Pressed { + open_capture_window(app); + } + }) + .map_err(|e| format!("Something else on this system is already using it ({e}).")) +} + +/// Restore the stored shortcut at startup. +/// +/// Best-effort by construction: a combination that worked when it was chosen can +/// be taken by something installed later, and the app must still open. The failure +/// is logged and the UI will show it as not registered when the settings screen is +/// next opened. +pub fn restore(app: &AppHandle, db: &Db) { + let shortcut = match stored(db) { + Ok(s) if !s.is_empty() => s, + Ok(_) => return, + Err(e) => { + log::warn!("could not read the capture shortcut: {e}"); + return; + } + }; + match register(app, &shortcut) { + Ok(()) => log::info!("quick capture is on: {shortcut}"), + Err(e) => log::warn!("quick capture shortcut '{shortcut}' did not register: {e}"), + } +} + +#[tauri::command] +pub fn capture_shortcut_get(app: AppHandle, db: State<'_, Db>) -> Result { + let shortcut = stored(&db)?; + // Asked of the manager rather than remembered from startup: the answer can + // have changed since, and a settings screen that reports a stale success is + // the exact thing this pair of fields exists to prevent. + let registered = !shortcut.is_empty() + && shortcut + .parse::() + .map(|s| app.global_shortcut().is_registered(s)) + .unwrap_or(false); + Ok(CaptureShortcut { + shortcut, + registered, + }) +} + +/// Store a shortcut and make it live, or clear it with an empty string. +/// +/// Registers BEFORE storing, so a combination the system refuses is not written +/// down as though it worked — the person would reopen the settings and find it +/// listed as their shortcut while nothing happened when they pressed it. +#[tauri::command] +pub fn capture_shortcut_set( + shortcut: String, + app: AppHandle, + db: State<'_, Db>, +) -> Result { + let wanted = shortcut.trim().to_string(); + register(&app, &wanted)?; + let conn = db.0.lock().map_err(|e| e.to_string())?; + store::set_pref(&conn, SHORTCUT_PREF, &wanted).map_err(|e| e.to_string())?; + log::info!( + "quick capture shortcut {}", + if wanted.is_empty() { + "cleared".to_string() + } else { + format!("set to {wanted}") + } + ); + Ok(CaptureShortcut { + shortcut: wanted.clone(), + registered: !wanted.is_empty(), + }) +} + +/// Hide the capture window and tell the board to reload. +/// +/// Hidden rather than closed so the next press has a window to show instead of one +/// to build. Called after a save and on Escape alike; `saved` is what decides +/// whether the board is told to look again. +#[tauri::command] +pub fn capture_done(saved: bool, app: AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window(CAPTURE_WINDOW) { + window.hide().map_err(|e| e.to_string())?; + } + if saved { + if let Some(main) = app.get_webview_window("main") { + // Failure here is cosmetic — the note is saved either way and the board + // will show it on its next load — so it is logged, not raised. + if let Err(e) = main.emit(CAPTURED_EVENT, ()) { + log::warn!("could not tell the board about a capture: {e}"); + } + } + } + Ok(()) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c187efc..bb460b3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ //! remains here is the Tauri command surface (`commands`), desktop integration //! (menu-entry install for the Linux AppImage), the in-app updater, and boot. +mod capture; mod commands; mod integration; mod update; @@ -80,6 +81,10 @@ pub fn run() { // build without a signing key still starts normally and simply reports that // updates aren't configured. .plugin(tauri_plugin_updater::Builder::new().build()) + // The quick-capture hotkey. Registering the combination itself happens in + // `setup`, once the store is open and can be asked which one to use — the + // plugin only has to exist before then. + .plugin(tauri_plugin_global_shortcut::Builder::new().build()) // Attachment bytes are served to the webview from the local blob store // (M10.7f). Registered on the BUILDER because a scheme has to exist before // the webview is created; the directory it reads from arrives later, in @@ -118,6 +123,9 @@ pub fn run() { // in this directory saying which one the user picked (issue 2183). update::adopt_installer_channel(&db, &dir); sweep_local_trash(&db); + // Before the store is handed to the app: `restore` needs to read the + // stored shortcut out of it, and after `manage` the Db has moved. + capture::restore(app.handle(), &db); app.manage(db); // Attachment bytes live beside the database, filed by content hash, so a // synced image is readable with no network (M10.7d). @@ -176,6 +184,9 @@ pub fn run() { update::update_channel_set, update::update_check, update::update_install, + capture::capture_shortcut_get, + capture::capture_shortcut_set, + capture::capture_done, ]) .run(tauri::generate_context!()) .expect("error while running the ThoughtSync desktop app"); diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index 00e22af..2c4237a 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -5,6 +5,13 @@ interface TauriGlobal { core: { invoke: (cmd: string, args?: Record) => Promise }; + // Also from `withGlobalTauri`. Needed because quick capture puts the app in TWO + // windows, each with its own Pinia stores — a note saved in one is invisible to + // the other until something says so, and an event is the only channel between + // them that does not involve polling SQLite. + event?: { + listen: (event: string, handler: (e: { payload: T }) => void) => Promise<() => void>; + }; } declare global { @@ -192,3 +199,49 @@ export const updates = { */ install: () => invoke("update_install"), }; + + +// --- Quick capture (#1899) --------------------------------------------------- + +/** + * The stored hotkey and whether the OS actually accepted it. + * + * They disagree more often than you would like: a combination can be saved and + * refuse to register because a window manager or another app already holds it, + * and on Wayland a compositor may refuse global grabs entirely. `registered: + * false` alongside a non-empty `shortcut` is precisely that case, and the UI has + * to say so — a hotkey that silently does nothing is worse than none, because + * there is nothing to look at and nothing to fix. + */ +export interface CaptureShortcut { + /** The stored combination, or "" when quick capture is off. */ + shortcut: string; + registered: boolean; +} + +/** Offered as a starting point, never applied on the user's behalf. */ +export const SUGGESTED_CAPTURE_SHORTCUT = "CommandOrControl+Shift+N"; + +/** Fired at the main window after a capture is saved. */ +const CAPTURED_EVENT = "thoughtsync://captured"; + +export const capture = { + shortcut: () => invoke("capture_shortcut_get"), + /** Pass "" to turn quick capture off. Rejects if the system refuses it. */ + setShortcut: (shortcut: string) => invoke("capture_shortcut_set", { shortcut }), + /** Hide the capture window; `saved` decides whether the board is told to reload. */ + done: (saved: boolean) => invoke("capture_done", { saved }), +}; + +/** + * Run `handler` whenever a note is captured in the other window. + * + * Returns an unlisten function, or a no-op on the web build and on any desktop + * runtime that does not expose the event API — the board simply keeps showing + * what it has until its next load, which is a stale list rather than a broken one. + */ +export async function onCaptured(handler: () => void): Promise<() => void> { + const events = window.__TAURI__?.event; + if (!events) return () => {}; + return events.listen(CAPTURED_EVENT, () => handler()); +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 43a94f0..cee5542 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -24,6 +24,15 @@ const router = createRouter({ { path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") }, ], }, + { + // The quick-capture window (#1899). Its own route because it is its own + // WINDOW — no shell, no nav, one field. Desktop only: there is no global + // hotkey in a browser tab and nothing to summon it. + path: "/capture", + name: "capture", + component: () => import("../views/CaptureView.vue"), + meta: { requiresAuth: true, requiresDesktop: true }, + }, { path: "/settings", name: "settings", @@ -89,6 +98,13 @@ router.beforeEach(async (to) => { if (to.meta.requiresDesktop && !isDesktop()) { return { name: "board" }; } + // The capture window is opened at `index.html?capture=1` rather than at + // `/capture`, because the bundled assets are served as files and a path with no + // file behind it 404s in the production build — it only routes under the dev + // server. A query string survives that, and this is where it becomes a route. + if (to.query.capture === "1" && to.name !== "capture") { + return { name: "capture" }; + } // Deliberately NOT applied to /login and /register: bouncing those on desktop // would loop against the requiresAuth guard above the moment a session is // missing. Nothing on the desktop navigates to them any more (AppShell's sign-out diff --git a/frontend/src/views/BoardView.vue b/frontend/src/views/BoardView.vue index aac62e9..00d06ca 100644 --- a/frontend/src/views/BoardView.vue +++ b/frontend/src/views/BoardView.vue @@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue"; import FilterBar from "../components/FilterBar.vue"; import NoteGrid from "../components/NoteGrid.vue"; import NoteEditor from "../components/NoteEditor.vue"; -import { isDesktop, sync as syncBridge } from "../desktop/bridge"; +import { isDesktop, onCaptured, sync as syncBridge } from "../desktop/bridge"; const notes = useNotesStore(); const config = useConfigStore(); @@ -227,8 +227,21 @@ onMounted(() => { .catch(() => {}); } }); +// A note written in the quick-capture window lands in the same SQLite file but a +// different Pinia store — this window has no way to know unless it is told. +// Registered as a promise because the listener is set up asynchronously, and +// unregistered on the way out so a board that has been navigated away from does +// not keep reloading itself. +let stopCaptureListener: (() => void) | null = null; +onMounted(() => { + void onCaptured(() => void reload()).then((stop) => { + stopCaptureListener = stop; + }); +}); + onBeforeUnmount(() => { window.removeEventListener("keydown", onBoardKey); + stopCaptureListener?.(); ui.boardCardFocused = false; }); watch([currentView, currentLabel, facetKey], reload); diff --git a/frontend/src/views/CaptureView.vue b/frontend/src/views/CaptureView.vue new file mode 100644 index 0000000..9bc53ae --- /dev/null +++ b/frontend/src/views/CaptureView.vue @@ -0,0 +1,87 @@ + + +