diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index cfb7de3..b60ecfc 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -24,6 +24,10 @@ rusqlite = { version = "0.32", features = ["bundled"] } uuid = { version = "1", features = ["v4"] } # RFC3339 timestamps for created_at/updated_at/remind_at (Date.parse-able on the JS side). chrono = { version = "0.4", default-features = false, features = ["clock"] } +# Startup + operation logging to stdout AND a persistent file, so portability +# issues are diagnosable from any environment. `log` is the facade the code uses. +tauri-plugin-log = "2" +log = "0.4" # Tauri's default release profile: smaller, faster shipped binaries. [profile.release] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8af6499..cfec12a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -11,21 +11,41 @@ mod local; #[cfg_attr(mobile, tauri::mobile_entry_point)] 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(), + ) .setup(|app| { use tauri::Manager; + log_environment(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)?; - app.manage(local::open(&dir.join("thoughtsync.db"))?); + 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)); + app.manage(db); Ok(()) }) .invoke_handler(tauri::generate_handler![ + log_event, integration::integration_status, integration::integrate_desktop, integration::unintegrate_desktop, @@ -70,6 +90,59 @@ pub fn run() { .expect("error while running the ThoughtSync desktop app"); } +/// 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 diff --git a/desktop/src-tauri/src/local/mod.rs b/desktop/src-tauri/src/local/mod.rs index b4232a6..1ece189 100644 --- a/desktop/src-tauri/src/local/mod.rs +++ b/desktop/src-tauri/src/local/mod.rs @@ -24,3 +24,17 @@ pub fn open(path: &Path) -> rusqlite::Result { schema::migrate(&conn)?; Ok(Db(Mutex::new(conn))) } + +/// A one-line count summary of the store, for the startup log. +pub fn summary(db: &Db) -> String { + let conn = match db.0.lock() { + Ok(c) => c, + Err(_) => return "counts unavailable (lock poisoned)".to_string(), + }; + let count = |sql: &str| conn.query_row(sql, [], |r| r.get::<_, i64>(0)).unwrap_or(-1); + format!( + "{} notes, {} labels", + count("SELECT COUNT(*) FROM notes"), + count("SELECT COUNT(*) FROM labels"), + ) +} diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts index b3cff5c..3d8a33b 100644 --- a/frontend/src/desktop/bridge.ts +++ b/frontend/src/desktop/bridge.ts @@ -27,7 +27,18 @@ export function isDesktop(): boolean { export function invoke(cmd: string, args?: Record): Promise { const tauri = window.__TAURI__; if (!tauri) return Promise.reject(new Error("Not running in the desktop app.")); - return tauri.core.invoke(cmd, args); + return tauri.core.invoke(cmd, args).catch((err: unknown) => { + // Every failed command names itself in the log — so a broken "basic function" + // in some environment is diagnosable. Skip log_event to avoid recursion. + if (cmd !== "log_event") logEvent("error", `invoke '${cmd}' failed: ${String(err)}`); + throw err; + }); +} + +/** Route a frontend message into the desktop log (stdout + file). No-op on web. */ +export function logEvent(level: "info" | "warn" | "error" | "debug", message: string): void { + if (!isDesktop()) return; + void invoke("log_event", { level, message }).catch(() => {}); } // Desktop (Linux AppImage) self-integration: add/remove an applications-menu entry. diff --git a/frontend/src/main.ts b/frontend/src/main.ts index ce512d1..28f3479 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,6 +2,7 @@ import { createApp } from "vue"; import { createPinia } from "pinia"; import App from "./App.vue"; import router from "./router"; +import { isDesktop, logEvent } from "./desktop/bridge"; import "./style.css"; const app = createApp(App); @@ -9,6 +10,10 @@ app.use(createPinia()); // before router: the nav guard reads the session store app.use(router); app.mount("#app"); +// Boot line (desktop only): which data source, and the WebKit user-agent — the +// renderer identity that matters most when a build behaves differently per machine. +logEvent("info", `boot: source=${isDesktop() ? "local (offline core)" : "rest"} ua=${navigator.userAgent}`); + // Register the service worker so the app is installable (PWA). It's a // progressive enhancement — installability only, not offline-first (see // public/sw.js) — so failures are non-fatal and intentionally ignored. diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6cb8f1a..aae7126 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,6 +1,11 @@ import { createRouter, createWebHistory } from "vue-router"; import { useSessionStore } from "../stores/session"; import { useConfigStore } from "../stores/config"; +import { logEvent } from "../desktop/bridge"; + +// One-time boot diagnostic: the first navigation is where config + session resolve, +// so it's the moment that tells us whether the app got past its startup gate. +let bootLogged = false; const router = createRouter({ history: createWebHistory(), @@ -56,6 +61,13 @@ router.beforeEach(async (to) => { if (!session.loaded) { await session.fetchMe(); } + if (!bootLogged) { + bootLogged = true; + logEvent( + "info", + `first route: config(site=${config.siteName}) session(user=${session.user?.email ?? "none"}) -> ${String(to.name ?? to.path)}`, + ); + } if (to.meta.requiresAuth && !session.user) { return { name: "login", query: to.fullPath !== "/" ? { redirect: to.fullPath } : undefined }; }