- lib.rs MAIN_WINDOW: the "main" window label that the reminder worker, the capture window and the shell each wrote, 5 sites in all. - lib.rs now_ms(): the epoch-milliseconds clock that the reminder worker and autosync's cycle stamp each computed. LastCycle.at_ms becomes i64, the same number on the wire. - integration.rs applications_dir(): the XDG launcher directory that the entry path and the desktop-database refresh each built. rustfmt --check is clean in the CI image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
9.0 KiB
Rust
227 lines
9.0 KiB
Rust
//! 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.
|
|
//!
|
|
//! The suggestion the settings screen offers (`CommandOrControl+Shift+N`) lives in
|
|
//! the frontend, not here. It is a UI affordance — a starting point put in front of
|
|
//! someone — and this side accepts any combination the OS will take, so a constant
|
|
//! here would be a second copy of a string only the UI ever reads.
|
|
//!
|
|
//! ## 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 inkwell_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";
|
|
|
|
/// 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 = "inkwell://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.conn()?;
|
|
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.conn()?;
|
|
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(crate::MAIN_WINDOW) {
|
|
// 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(())
|
|
}
|