CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m17s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 1m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 40s
Milestone 325 step 6 (Scribe #3254). The server publishes its AppImage in the updater's own format at /api/client/linux-appimage/update.json: the ordering key as `version`, the signature, and an absolute download URL built on the host that was asked, so the token the updater attaches goes nowhere else. Unsigned platforms and a server with no AppImage 404. The desktop's update source is now Fabled-Git (and its channel) or one server: - `read_source` is the one reader. The installer's `install-server` marker feeds the `update_server` pref once per new value, exactly as the channel marker feeds its pref; tauri.conf.json's endpoint is never consulted. - From a server, the check and the download carry the sync link's token when the app is linked to that same server. Without one the update shows and says to link rather than offering a button that 401s. - A server with no build says so. A 404 is "up to date" only on the forge, where it means an unpublished channel. - Sync → App updates offers the source once there is a server to offer (the chosen one, or the linked one), and only shows the channel for the forge. The trust anchor does not move: whatever the source, the updater verifies the AppImage against the public key built into the app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
18 KiB
Rust
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(crossover::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("blobs"))?;
|
|
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.0.lock() {
|
|
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);
|
|
}
|
|
}
|
|
}
|