diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 875e6d6..f7720e1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -64,8 +64,3 @@ 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 5f301f1..1116ed7 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 ThoughtSync windows: the board and the quick-capture window.", - "windows": ["main", "capture"], + "description": "Core capability for the main ThoughtSync window.", + "windows": ["main"], "permissions": ["core:default"] } diff --git a/desktop/src-tauri/src/capture.rs b/desktop/src-tauri/src/capture.rs deleted file mode 100644 index 933ec96..0000000 --- a/desktop/src-tauri/src/capture.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! 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 bb460b3..c187efc 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,7 +9,6 @@ //! 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; @@ -81,10 +80,6 @@ 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 @@ -123,9 +118,6 @@ 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). @@ -184,9 +176,6 @@ 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 2c4237a..00e22af 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -5,13 +5,6 @@ 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 { @@ -199,49 +192,3 @@ 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 cee5542..43a94f0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -24,15 +24,6 @@ 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", @@ -98,13 +89,6 @@ 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 00d06ca..aac62e9 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, onCaptured, sync as syncBridge } from "../desktop/bridge"; +import { isDesktop, sync as syncBridge } from "../desktop/bridge"; const notes = useNotesStore(); const config = useConfigStore(); @@ -227,21 +227,8 @@ 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 deleted file mode 100644 index 9bc53ae..0000000 --- a/frontend/src/views/CaptureView.vue +++ /dev/null @@ -1,87 +0,0 @@ - - -