//! ThoughtSync 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 `thoughtsync-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 commands; mod integration; mod update; // The store and the sync engine live in the shared `thoughtsync-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 thoughtsync_core::{local, sync}; pub fn run() { use tauri_plugin_log::{Target, TargetKind}; #[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()) // 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.thoughtsync/thoughtsync.db), created on // first launch. This is what makes the app work with no server or login. let dir = app.path().app_data_dir()?; std::fs::create_dir_all(&dir)?; let db_path = dir.join("thoughtsync.db"); 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); sweep_local_trash(&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); 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_add_item, commands::local::notes_update_item, commands::local::notes_delete_item, 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::notes_search, 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::local::saved_filters_list, commands::local::saved_filters_create, commands::local::saved_filters_remove, commands::local::saved_filters_rename, commands::sync::sync_probe, commands::sync::sync_link, commands::sync::sync_unlink, commands::sync::sync_status, commands::sync::sync_now, commands::sync::sync_has_pending, update::update_channel_get, update::update_channel_set, update::update_check, update::update_install, ]) .run(tauri::generate_context!()) .expect("error while running the ThoughtSync 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}"), } } /// 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!( "ThoughtSync desktop v{} starting ({} {})", env!("CARGO_PKG_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); } } }