Files
inkwell/desktop/src-tauri/src/lib.rs
T
bvandeusenandClaude Opus 5.5 ee771e3734 Every lock of the store goes through Db::conn
Db::conn is documented as the one way to take the lock, yet five production and
test sites reached past it with db.0.lock(): the startup summary, the desktop's
trash sweep and config_get, and tests in sharing and update. All five now call
conn().

DRY pass #2, batch 2, F8 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:23:07 -04:00

373 lines
18 KiB
Rust

//! Inkwell desktop (Tauri v2).
//!
//! The window loads the shared Vue 3 frontend (`../../frontend`), which reaches the
//! store and sync engine through the `frontend/src/adapters/` seam (M10.3) over Tauri
//! `invoke`.
//!
//! This crate is the DESKTOP WRAPPER, not the core. The on-device SQLite store and
//! the sync engine live in `inkwell-core`, shared with the Android client; what
//! remains here is the Tauri command surface (`commands`), desktop integration
//! (menu-entry install for the Linux AppImage), the in-app updater, and boot.
mod autosync;
mod capture;
mod commands;
mod crossover;
mod integration;
mod reminders;
mod update;
/// The build a PERSON reads, baked in by the desktop lane at compile time.
///
/// Lives at the crate root because it has two readers — `config_get`, which puts it
/// in the UI, and `log_environment`, which puts it in the log — and this repo has
/// spent several issues on one fact held in two places (2181, 2182, 2183).
///
/// `option_env!`, not `env!`: a local `cargo tauri build` sets nothing, and this has
/// to keep compiling. `None` becomes "unknown" at each call site rather than a
/// plausible-looking default — note 3127 §5 makes this string the only answer to
/// "which build is this?" now that there are no version tags, so there is nothing
/// left to contradict it if it lies. An honest "I cannot say" is the only safe wrong
/// answer.
///
/// NOT `CARGO_PKG_VERSION`, which both readers used to use, and which was wrong on
/// every build ever shipped: `cargo tauri build --config '{"version": ...}'`
/// overrides `tauri.conf.json`, not Cargo's own metadata, so the literal `0.2.0` in
/// Cargo.toml is what reached the UI and the log regardless of what was built.
///
/// NOT the ordering key either. That value — `1.0.<minutes>`, which the override
/// above does set — is the opaque value Tauri's updater compares; it lands in bundle
/// filenames and `latest.json` and must never be shown to a person (#3144). Two
/// values, two audiences. `update.rs` deliberately still reads the key, through
/// `app.package_info().version`, because a comparator is exactly what it is.
const DISPLAY_VERSION: Option<&str> = option_env!("INKWELL_DISPLAY_VERSION");
/// The baked build, or the honest "I cannot say". The only way in — the const is
/// private so no caller can reach past the fallback.
pub(crate) fn display_version() -> &'static str {
DISPLAY_VERSION.unwrap_or("unknown")
}
// The store and the sync engine live in the shared `inkwell-core` crate, which
// the Android client binds through uniffi (Scribe note 2730). Aliased to their old
// names so every call site below reads exactly as it did when they were modules of
// this crate — the extraction changed where they live, not what they are.
use inkwell_core::{local, sync};
pub fn run() {
use tauri_plugin_log::{Target, TargetKind};
// Introduce ourselves to any server this app links to, BEFORE anything can sync.
// The core cannot work this out — it is compiled into the Android app too — so
// the header says "desktop" only because the desktop says so here, and carries
// the build a person can read rather than the core crate's own version.
sync::compat::set_client_agent("inkwell-desktop", display_version());
#[cfg(target_os = "linux")]
harden_linux_webkit_rendering();
tauri::Builder::default()
// Logging first, so startup diagnostics (and any setup error) are captured to
// stdout AND a persistent file from the very beginning — the basis for
// troubleshooting portability across environments.
.plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
.targets([
Target::new(TargetKind::Stdout),
Target::new(TargetKind::LogDir { file_name: None }),
])
.build(),
)
// In-app updates (M10.9). Registering the plugin is inert on its own — it
// reads its config only when `update_check`/`update_install` ask it to, so a
// 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())
// System notifications for due reminders, raised by the `reminders` worker.
.plugin(tauri_plugin_notification::init())
// 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
// `setup`, via `blobs::publish_root`.
.register_uri_scheme_protocol(sync::blobs::BLOB_SCHEME, |_ctx, request| {
let (status, content_type, body) =
sync::blobs::serve(request.uri().path(), request.uri().query());
tauri::http::Response::builder()
.status(status)
.header("Content-Type", content_type)
// The bytes are content-addressed: a given URL can never describe
// different bytes, so the webview may keep them indefinitely.
.header("Cache-Control", "public, max-age=31536000, immutable")
.body(body)
.unwrap_or_else(|_| {
tauri::http::Response::builder()
.status(500)
.body(Vec::new())
.expect("a bodiless 500 always builds")
})
})
.setup(|app| {
use tauri::Manager;
log_environment(app);
paint_window_before_the_webview_does(app);
// The on-device store lives in the platform app-data dir (e.g. Linux
// ~/.local/share/com.fabledsword.inkwell/inkwell.db), created on
// first launch. This is what makes the app work with no server or login.
let dir = app.path().app_data_dir()?;
// Before the store opens: an install from when this app was ThoughtSync
// has its notes in the old identifier's dir, beside this one.
if let Some(root) = dir.parent() {
let legacy = root.join(crossover::LEGACY_IDENTIFIER);
log_crossover(&legacy, crossover::adopt_legacy_data(&legacy, &dir));
}
std::fs::create_dir_all(&dir)?;
let db_path = dir.join(local::DB_FILE);
log::info!("opening local store: {}", db_path.display());
let db = local::open(&db_path)?;
log::info!("local store ready — {}", local::summary(&db));
// Before anything can ask what channel we're on: the installer left a note
// in this directory saying which one the user picked (issue 2183).
update::adopt_installer_channel(&db, &dir);
// And which server, when it installed from one (milestone 325 step 6).
update::adopt_installer_server(&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).
let blobs = sync::blobs::BlobStore::new(dir.join(local::BLOBS_DIR))?;
log::info!("attachment store ready: {}", blobs.root().display());
// Hand the directory to the URI-scheme handler registered below, which
// was built before this path could be resolved.
sync::blobs::publish_root(blobs.root().to_path_buf());
app.manage(blobs);
// Last: the worker reads the store and the blob store, both managed now.
// Its first cycle is the launch sync.
autosync::start(app.handle())?;
// Announces due reminders from the store, including while the window is
// minimised or covered.
reminders::start(app.handle())?;
// Coming back to the window is when someone is about to look, so it asks
// for a cycle. Rate-limited in autosync, because focus flaps constantly.
if let Some(window) = app.get_webview_window("main") {
let handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(true) = event {
handle
.state::<autosync::AutoSync>()
.kick(autosync::Trigger::Focus);
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
log_event,
integration::integration_status,
integration::integrate_desktop,
integration::unintegrate_desktop,
commands::local::config_get,
commands::local::auth_me,
commands::local::notes_list,
commands::local::notes_get,
commands::local::notes_create,
commands::local::notes_update,
commands::local::notes_complete_reminder,
commands::local::notes_snooze_reminder,
commands::local::notes_set_labels,
commands::local::notes_update_item,
commands::local::notes_add_attachment,
commands::local::notes_export,
commands::local::notes_import,
commands::local::notes_delete_attachment,
commands::local::notes_delete_preview,
commands::local::notes_reorder,
commands::local::notes_trash,
commands::local::notes_restore,
commands::local::notes_delete_forever,
commands::local::notes_revisions,
commands::local::notes_restore_revision,
commands::local::notes_reminders,
commands::local::notes_titles,
commands::local::labels_list,
commands::local::labels_create,
commands::local::labels_rename,
commands::local::labels_set_color,
commands::local::labels_remove,
commands::local::labels_merge,
commands::sync::sync_probe,
commands::sync::sync_link,
commands::sync::sync_unlink,
commands::sync::sync_status,
commands::sync::sync_now,
commands::sync::sync_last,
commands::sync::sync_has_pending,
commands::sync::shares_directory,
commands::sync::shares_list,
commands::sync::shares_share,
commands::sync::shares_unshare,
update::update_channel_get,
update::update_channel_set,
update::update_source_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 Inkwell desktop app");
}
/// Match the window's own background to the theme the UI is about to render in.
///
/// There is a gap between the window appearing and the webview painting its first
/// frame, and in it the platform's default background shows through — white. On a
/// dark-mode desktop that is the harshest thing the app does, and forcing WebKit's
/// software rendering (see `harden_linux_webkit_rendering`) makes the gap wider,
/// not narrower.
///
/// Done here rather than as `app.windows[].backgroundColor` in tauri.conf.json
/// because that config takes ONE static colour, and picking either one would fix
/// half of users while introducing the same flash for the other half. Reading the
/// live theme is the only version that is never a regression.
///
/// Best-effort throughout: a window that won't tell us its theme, or won't take a
/// colour, is a cosmetic loss and must never stop the app from opening.
fn paint_window_before_the_webview_does(app: &tauri::App) {
use tauri::Manager;
let Some(window) = app.get_webview_window("main") else {
return;
};
// Unknown theme reads as light, matching the platform default we'd get anyway.
let dark = matches!(window.theme(), Ok(tauri::Theme::Dark));
// The two values style.css actually paints: neutral-950 and neutral-50.
let color = if dark {
tauri::window::Color(10, 10, 10, 255)
} else {
tauri::window::Color(250, 250, 250, 255)
};
match window.set_background_color(Some(color)) {
Ok(()) => log::info!(
"window background set for the {} theme",
if dark { "dark" } else { "light" }
),
Err(e) => log::warn!("could not set the window background: {e}"),
}
}
/// Expire old trash at startup, on an unlinked device only (see `local::retention`).
///
/// At startup rather than on a timer: a desktop app isn't a server, and a sweep the
/// user is present for is one they can see the result of. A failure here is logged and
/// stepped over — housekeeping must never be the reason the app won't open.
fn sweep_local_trash(db: &local::Db) {
let conn = match db.conn() {
Ok(conn) => conn,
Err(_) => {
log::warn!("skipping the trash sweep: store lock poisoned");
return;
}
};
match local::retention::sweep_if_unlinked(&conn) {
Ok(Some(0)) | Ok(None) => {}
Ok(Some(n)) => log::info!("trash retention: purged {n} expired note(s)"),
Err(e) => log::warn!("trash sweep failed: {e}"),
}
}
/// Frontend logging bridge: routes boot milestones and errors from the webview into
/// the same stdout + file log as the Rust side (see frontend/src/desktop/bridge.ts).
#[tauri::command]
fn log_event(level: String, message: String) {
match level.as_str() {
"error" => log::error!(target: "frontend", "{message}"),
"warn" => log::warn!(target: "frontend", "{message}"),
"debug" => log::debug!(target: "frontend", "{message}"),
_ => log::info!(target: "frontend", "{message}"),
}
}
/// The startup log line for the ThoughtSync → Inkwell data crossover (crossover.rs).
fn log_crossover(legacy: &std::path::Path, result: std::io::Result<crossover::Outcome>) {
match result {
Ok(crossover::Outcome::Moved(n)) => {
log::info!("moved {n} entries from {}", legacy.display())
}
Ok(outcome) => log::debug!("ThoughtSync data crossover: {outcome:?}"),
Err(e) => log::warn!("could not move data from {}: {e}", legacy.display()),
}
}
/// Log the app version and the environment that determines whether the window
/// renders — the first thing to check when a build works on one machine but not
/// another.
fn log_environment(app: &tauri::App) {
use tauri::Manager;
log::info!(
"Inkwell desktop {} starting ({} {})",
display_version(),
std::env::consts::OS,
std::env::consts::ARCH,
);
match app.path().app_log_dir() {
Ok(d) => log::info!("log directory: {}", d.display()),
Err(e) => log::warn!("could not resolve log dir: {e}"),
}
#[cfg(target_os = "linux")]
log_linux_graphics_env();
}
/// The Linux display + graphics stack, and the WebKit render-hardening vars actually
/// in effect (set by harden_linux_webkit_rendering, which runs before the logger, so
/// we report the resulting environment rather than logging from inside it).
#[cfg(target_os = "linux")]
fn log_linux_graphics_env() {
let v = |k: &str| std::env::var(k).unwrap_or_else(|_| "(unset)".to_string());
log::info!(
"display: session_type={} desktop={} wayland={} x11={} gdk_backend={}",
v("XDG_SESSION_TYPE"),
v("XDG_CURRENT_DESKTOP"),
v("WAYLAND_DISPLAY"),
v("DISPLAY"),
v("GDK_BACKEND"),
);
log::info!(
"webkit hardening: dmabuf_disabled={} compositing_disabled={} nv_explicit_sync_disabled={}",
v("WEBKIT_DISABLE_DMABUF_RENDERER"),
v("WEBKIT_DISABLE_COMPOSITING_MODE"),
v("__NV_DISABLE_EXPLICIT_SYNC"),
);
}
/// WebKitGTK's GPU-accelerated rendering (the DMA-BUF renderer + EGL compositing) fails
/// to initialize on a wide range of Linux GPU/driver/Wayland setups — "Could not create
/// default EGL display: EGL_BAD_PARAMETER" → a black/blank window. This is a well-known
/// WebKitGTK issue that hits Tauri apps broadly, NOT app-specific. Tauri's guidance
/// (https://v2.tauri.app/develop/debug/linux-graphics/) is to force the software
/// fallbacks at startup, before the webview is created, so end users don't have to.
///
/// Applied on all Linux launches (this UI doesn't need GPU compositing, and the failure
/// spans AppImage, native, and dev builds), each var left overridable so a user can
/// re-enable acceleration by exporting it themselves before launch.
#[cfg(target_os = "linux")]
fn harden_linux_webkit_rendering() {
// Ordered per Tauri's escalation ladder; each set only if the user hasn't chosen.
for (key, value) in [
("__NV_DISABLE_EXPLICIT_SYNC", "1"),
("WEBKIT_DISABLE_DMABUF_RENDERER", "1"),
("WEBKIT_DISABLE_COMPOSITING_MODE", "1"),
] {
if std::env::var_os(key).is_none() {
std::env::set_var(key, value);
}
}
}