desktop: a global hotkey opens a small window to write in, now with its lockfile
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m22s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m22s
Restores42e06da, 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 from42e06da: 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
This commit is contained in:
Generated
+67
@@ -1286,6 +1286,16 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1405,6 +1415,24 @@ version = "0.3.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
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]]
|
[[package]]
|
||||||
name = "gobject-sys"
|
name = "gobject-sys"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
@@ -3947,6 +3975,21 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-log"
|
name = "tauri-plugin-log"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
@@ -4196,6 +4239,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"thoughtsync-core",
|
"thoughtsync-core",
|
||||||
@@ -5577,6 +5621,23 @@ dependencies = [
|
|||||||
"pkg-config",
|
"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]]
|
[[package]]
|
||||||
name = "xattr"
|
name = "xattr"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -5587,6 +5648,12 @@ dependencies = [
|
|||||||
"rustix",
|
"rustix",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xkeysym"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
@@ -64,3 +64,8 @@ tauri-plugin-log = "2"
|
|||||||
# the plugin declares android support level "none", which is why the Android client
|
# the plugin declares android support level "none", which is why the Android client
|
||||||
# gets a server-served update path instead (Scribe note 2725).
|
# gets a server-served update path instead (Scribe note 2725).
|
||||||
tauri-plugin-updater = "2"
|
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"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Core capability for the main ThoughtSync window.",
|
"description": "Core capability for the ThoughtSync windows: the board and the quick-capture window.",
|
||||||
"windows": ["main"],
|
"windows": ["main", "capture"],
|
||||||
"permissions": ["core:default"]
|
"permissions": ["core:default"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, String> {
|
||||||
|
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<CaptureShortcut, String> {
|
||||||
|
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::<Shortcut>()
|
||||||
|
.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<CaptureShortcut, String> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
//! remains here is the Tauri command surface (`commands`), desktop integration
|
//! remains here is the Tauri command surface (`commands`), desktop integration
|
||||||
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
|
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
|
||||||
|
|
||||||
|
mod capture;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod integration;
|
mod integration;
|
||||||
mod update;
|
mod update;
|
||||||
@@ -80,6 +81,10 @@ pub fn run() {
|
|||||||
// build without a signing key still starts normally and simply reports that
|
// build without a signing key still starts normally and simply reports that
|
||||||
// updates aren't configured.
|
// updates aren't configured.
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.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
|
// 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
|
// (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
|
// 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).
|
// in this directory saying which one the user picked (issue 2183).
|
||||||
update::adopt_installer_channel(&db, &dir);
|
update::adopt_installer_channel(&db, &dir);
|
||||||
sweep_local_trash(&db);
|
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);
|
app.manage(db);
|
||||||
// Attachment bytes live beside the database, filed by content hash, so a
|
// Attachment bytes live beside the database, filed by content hash, so a
|
||||||
// synced image is readable with no network (M10.7d).
|
// synced image is readable with no network (M10.7d).
|
||||||
@@ -176,6 +184,9 @@ pub fn run() {
|
|||||||
update::update_channel_set,
|
update::update_channel_set,
|
||||||
update::update_check,
|
update::update_check,
|
||||||
update::update_install,
|
update::update_install,
|
||||||
|
capture::capture_shortcut_get,
|
||||||
|
capture::capture_shortcut_set,
|
||||||
|
capture::capture_done,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running the ThoughtSync desktop app");
|
.expect("error while running the ThoughtSync desktop app");
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
|
|
||||||
interface TauriGlobal {
|
interface TauriGlobal {
|
||||||
core: { invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> };
|
core: { invoke: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> };
|
||||||
|
// 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: <T>(event: string, handler: (e: { payload: T }) => void) => Promise<() => void>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -192,3 +199,49 @@ export const updates = {
|
|||||||
*/
|
*/
|
||||||
install: () => invoke<void>("update_install"),
|
install: () => invoke<void>("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<CaptureShortcut>("capture_shortcut_get"),
|
||||||
|
/** Pass "" to turn quick capture off. Rejects if the system refuses it. */
|
||||||
|
setShortcut: (shortcut: string) => invoke<CaptureShortcut>("capture_shortcut_set", { shortcut }),
|
||||||
|
/** Hide the capture window; `saved` decides whether the board is told to reload. */
|
||||||
|
done: (saved: boolean) => invoke<void>("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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ const router = createRouter({
|
|||||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
{ 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",
|
path: "/settings",
|
||||||
name: "settings",
|
name: "settings",
|
||||||
@@ -89,6 +98,13 @@ router.beforeEach(async (to) => {
|
|||||||
if (to.meta.requiresDesktop && !isDesktop()) {
|
if (to.meta.requiresDesktop && !isDesktop()) {
|
||||||
return { name: "board" };
|
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
|
// Deliberately NOT applied to /login and /register: bouncing those on desktop
|
||||||
// would loop against the requiresAuth guard above the moment a session is
|
// 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
|
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import EmptyState from "../components/EmptyState.vue";
|
|||||||
import FilterBar from "../components/FilterBar.vue";
|
import FilterBar from "../components/FilterBar.vue";
|
||||||
import NoteGrid from "../components/NoteGrid.vue";
|
import NoteGrid from "../components/NoteGrid.vue";
|
||||||
import NoteEditor from "../components/NoteEditor.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 notes = useNotesStore();
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
@@ -227,8 +227,21 @@ onMounted(() => {
|
|||||||
.catch(() => {});
|
.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(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener("keydown", onBoardKey);
|
window.removeEventListener("keydown", onBoardKey);
|
||||||
|
stopCaptureListener?.();
|
||||||
ui.boardCardFocused = false;
|
ui.boardCardFocused = false;
|
||||||
});
|
});
|
||||||
watch([currentView, currentLabel, facetKey], reload);
|
watch([currentView, currentLabel, facetKey], reload);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// The quick-capture window: one field, and two ways out.
|
||||||
|
//
|
||||||
|
// This runs in a SECOND Tauri window, summoned by a global hotkey over whatever
|
||||||
|
// the person was doing. Everything here is shaped by that: no shell, no nav, no
|
||||||
|
// board — a window that arrives uninvited has to be finishable in one gesture and
|
||||||
|
// leave nothing behind if it isn't.
|
||||||
|
import { nextTick, onMounted, ref } from "vue";
|
||||||
|
import { repo } from "../adapters";
|
||||||
|
import { capture } from "../desktop/bridge";
|
||||||
|
|
||||||
|
const body = ref("");
|
||||||
|
const field = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Focused on arrival, and after a save. The whole feature is "press the keys and
|
||||||
|
// start typing" — a window that needs a click first has not saved anyone a step.
|
||||||
|
await nextTick();
|
||||||
|
field.value?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const content = body.value.trim();
|
||||||
|
// Nothing typed is not an error, it is a change of mind — the same reading the
|
||||||
|
// board takes of tapping + and walking away.
|
||||||
|
if (!content) {
|
||||||
|
void capture.done(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await repo.notes.create({ body: content });
|
||||||
|
body.value = "";
|
||||||
|
await capture.done(true);
|
||||||
|
} catch {
|
||||||
|
// The window STAYS OPEN on failure, holding the text. Hiding it would throw
|
||||||
|
// away the only copy of something the person just wrote, to report a problem
|
||||||
|
// they could otherwise retry their way out of.
|
||||||
|
error.value = "Couldn't save that. Your text is still here — try again.";
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
// The text is deliberately KEPT. The window is hidden rather than destroyed, so
|
||||||
|
// a capture interrupted by something more urgent is still there on the next
|
||||||
|
// press — which is the behaviour that makes it safe to press Escape.
|
||||||
|
void capture.done(false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex h-screen w-screen flex-col gap-2 bg-neutral-50 p-3 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref="field"
|
||||||
|
v-model="body"
|
||||||
|
class="min-h-0 flex-1 resize-none rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900"
|
||||||
|
placeholder="Write it down…"
|
||||||
|
aria-label="New note"
|
||||||
|
@keydown.esc.prevent="dismiss"
|
||||||
|
@keydown.enter.ctrl.prevent="save"
|
||||||
|
@keydown.enter.meta.prevent="save"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<!-- The shortcuts are written down rather than assumed: this window is seen
|
||||||
|
rarely and briefly, and it is the only place they are discoverable. -->
|
||||||
|
<p class="text-xs text-neutral-400">
|
||||||
|
<kbd>Ctrl</kbd>/<kbd>⌘</kbd> + <kbd>Enter</kbd> to save · <kbd>Esc</kbd> to dismiss
|
||||||
|
</p>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="dismiss">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? "Saving…" : "Save" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,8 +5,11 @@ import BaseButton from "../components/BaseButton.vue";
|
|||||||
import BaseInput from "../components/BaseInput.vue";
|
import BaseInput from "../components/BaseInput.vue";
|
||||||
import Icon from "../components/Icon.vue";
|
import Icon from "../components/Icon.vue";
|
||||||
import {
|
import {
|
||||||
|
SUGGESTED_CAPTURE_SHORTCUT,
|
||||||
|
capture as captureBridge,
|
||||||
sync as syncBridge,
|
sync as syncBridge,
|
||||||
updates as updateBridge,
|
updates as updateBridge,
|
||||||
|
type CaptureShortcut,
|
||||||
type Compatibility,
|
type Compatibility,
|
||||||
type ProbeResult,
|
type ProbeResult,
|
||||||
type RevokeOutcome,
|
type RevokeOutcome,
|
||||||
@@ -77,6 +80,31 @@ const checkedOnce = ref(false);
|
|||||||
|
|
||||||
const updateAvailable = computed(() => !!update.value?.available);
|
const updateAvailable = computed(() => !!update.value?.available);
|
||||||
|
|
||||||
|
// --- Quick capture -----------------------------------------------------------
|
||||||
|
// A desktop-local preference, so it lives here beside the update channel rather
|
||||||
|
// than in admin Settings: that screen is the SERVER's, and this is a property of
|
||||||
|
// this installation on this machine.
|
||||||
|
const shortcut = ref<CaptureShortcut>({ shortcut: "", registered: false });
|
||||||
|
const shortcutDraft = ref("");
|
||||||
|
const savingShortcut = ref(false);
|
||||||
|
const shortcutError = ref("");
|
||||||
|
|
||||||
|
async function saveShortcut(value: string) {
|
||||||
|
savingShortcut.value = true;
|
||||||
|
shortcutError.value = "";
|
||||||
|
try {
|
||||||
|
shortcut.value = await captureBridge.setShortcut(value);
|
||||||
|
shortcutDraft.value = shortcut.value.shortcut;
|
||||||
|
} catch (e) {
|
||||||
|
// The message comes from the core and names the actual reason — "something
|
||||||
|
// else is already using it" reads very differently from "that is not a
|
||||||
|
// shortcut this system understands", and both are things you can act on.
|
||||||
|
shortcutError.value = String((e as { message?: string }).message ?? e);
|
||||||
|
} finally {
|
||||||
|
savingShortcut.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkUpdates() {
|
async function checkUpdates() {
|
||||||
checking.value = true;
|
checking.value = true;
|
||||||
updateError.value = "";
|
updateError.value = "";
|
||||||
@@ -126,6 +154,13 @@ async function refresh() {
|
|||||||
// An older build without the update commands — leave the default showing
|
// An older build without the update commands — leave the default showing
|
||||||
// rather than blocking the whole Sync screen on it.
|
// rather than blocking the whole Sync screen on it.
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
shortcut.value = await captureBridge.shortcut();
|
||||||
|
shortcutDraft.value = shortcut.value.shortcut;
|
||||||
|
} catch {
|
||||||
|
// Older build without the capture commands. Same reading as the channel
|
||||||
|
// above — show the default rather than block the screen.
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
status.value = await syncBridge.status();
|
status.value = await syncBridge.status();
|
||||||
pending.value = await syncBridge.hasPending();
|
pending.value = await syncBridge.hasPending();
|
||||||
@@ -452,6 +487,65 @@ onMounted(refresh);
|
|||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Quick capture. Outside the linked/unlinked split for the same reason as
|
||||||
|
updates: a hotkey that writes to the local store needs no server. -->
|
||||||
|
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
||||||
|
<h2 class="text-sm font-semibold">Quick capture</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
A system-wide shortcut that opens a small window to write a note in, without
|
||||||
|
bringing this one forward.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-end gap-3">
|
||||||
|
<BaseInput
|
||||||
|
id="capture-shortcut"
|
||||||
|
v-model="shortcutDraft"
|
||||||
|
label="Shortcut"
|
||||||
|
:placeholder="SUGGESTED_CAPTURE_SHORTCUT"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<BaseButton :loading="savingShortcut" @click="saveShortcut(shortcutDraft)">Save</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-if="shortcut.shortcut"
|
||||||
|
variant="ghost"
|
||||||
|
:loading="savingShortcut"
|
||||||
|
@click="saveShortcut('')"
|
||||||
|
>
|
||||||
|
Turn off
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="shortcutError" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ shortcutError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Stored and LIVE are reported separately because they can disagree: a
|
||||||
|
combination another app grabbed first is saved here and does nothing when
|
||||||
|
pressed, and saying only "your shortcut is X" would be a lie with a
|
||||||
|
keystroke attached. -->
|
||||||
|
<p
|
||||||
|
v-else-if="shortcut.shortcut && !shortcut.registered"
|
||||||
|
class="mt-2 text-sm text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
{{ shortcut.shortcut }} is saved but isn't active — something else on this
|
||||||
|
system is holding it. Try a different combination.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="shortcut.registered" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Press {{ shortcut.shortcut }} anywhere to capture a note.
|
||||||
|
</p>
|
||||||
|
<p v-else class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
Off. There's no default on purpose — any combination picked for you is one
|
||||||
|
taken away from something else on your machine.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="underline hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||||
|
@click="saveShortcut(SUGGESTED_CAPTURE_SHORTCUT)"
|
||||||
|
>
|
||||||
|
Use {{ SUGGESTED_CAPTURE_SHORTCUT }}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
|
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
|
||||||
has never touched a server still updates itself. -->
|
has never touched a server still updates itself. -->
|
||||||
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
||||||
|
|||||||
Reference in New Issue
Block a user