Server -> local. sync/wire.rs mirrors the delta-feed JSON exactly as notes/serialize.py sends it; sync/pull.rs applies it. ATOMICITY IS THE POINT. The cursor is written in the SAME transaction as the page it describes. A cursor committed ahead of its data would skip those rows forever while reporting a clean sync — the worst kind of failure, because nothing looks wrong. A test forces a mid-page failure and asserts the cursor stayed put. Every degradation leans toward re-downloading rather than skipping: an unparseable cursor means full sync, wire fields are all defaulted so a newer server adding a field (or an older one omitting one) yields a partial note instead of a rejected page, and a page that fails rolls back whole. Labels are applied before notes so a membership never references a row that doesn't exist. A note also carries enough of its labels to materialize them, because notes and labels page from ONE shared sequence and a note can arrive referencing a label whose own delta landed in an earlier page. via_tag is applied verbatim rather than re-deriving #tags from the body. The server already reconciled them on save, and re-deriving would go through the local find-or-create path, which marks new labels dirty — pushing them straight back. Sync churn manufactured out of nothing. Duplicate-label merge, the subtle one: a label created offline can collide by name with one the server already had under a different id. Both sides enforce one label per name, so the server's row has to win — but simply deleting the local duplicate would CASCADE its note_labels away, stripping the label off notes this pull never mentions, with no later page to repair it. So we free the name, insert the server's row, re-point the memberships, then drop the husk. Tested. Children (items/attachments/previews/labels) are replaced wholesale rather than diffed: a delta carries the note's FULL state, so what arrived IS the complete set, and diffing could strand a row the server no longer has. The loop trusts the data over the flag — a server claiming has_more without advancing its cursor stops with an error instead of spinning forever. Pull can overwrite a row with unpushed local edits. The documented cycle is push-then-pull (M10.7c), so that should never happen; when it does it's counted as clobbered_dirty and logged rather than hidden. 17 tests, all against an in-memory database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
178 lines
7.4 KiB
Rust
178 lines
7.4 KiB
Rust
//! ThoughtSync desktop (Tauri v2).
|
|
//!
|
|
//! The window loads the shared Vue 3 frontend (`../../frontend`). The Rust core will
|
|
//! own the on-device SQLite store (M10.4) and the opt-in sync engine (M10.7), which
|
|
//! the frontend reaches through the `frontend/src/adapters/` seam (M10.3) over Tauri
|
|
//! `invoke`. Today it exposes desktop integration (menu-entry install for the Linux
|
|
//! AppImage) and boots the UI.
|
|
|
|
mod integration;
|
|
mod local;
|
|
// `pub` (unlike the modules above) because parts of it have no in-crate caller yet —
|
|
// the engine that will consume them is M10.7b/c, and a private module's unreachable
|
|
// items read as dead code.
|
|
pub mod sync;
|
|
|
|
#[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)?;
|
|
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,
|
|
local::commands::config_get,
|
|
local::commands::auth_me,
|
|
local::commands::notes_list,
|
|
local::commands::notes_get,
|
|
local::commands::notes_create,
|
|
local::commands::notes_create_titled,
|
|
local::commands::notes_update,
|
|
local::commands::notes_complete_reminder,
|
|
local::commands::notes_snooze_reminder,
|
|
local::commands::notes_set_labels,
|
|
local::commands::notes_add_item,
|
|
local::commands::notes_update_item,
|
|
local::commands::notes_delete_item,
|
|
local::commands::notes_delete_attachment,
|
|
local::commands::notes_delete_preview,
|
|
local::commands::notes_reorder,
|
|
local::commands::notes_trash,
|
|
local::commands::notes_restore,
|
|
local::commands::notes_delete_forever,
|
|
local::commands::notes_revisions,
|
|
local::commands::notes_restore_revision,
|
|
local::commands::notes_reminders,
|
|
local::commands::notes_titles,
|
|
local::commands::notes_search,
|
|
local::commands::notes_backlinks,
|
|
local::commands::notes_link_search,
|
|
local::commands::labels_list,
|
|
local::commands::labels_create,
|
|
local::commands::labels_rename,
|
|
local::commands::labels_set_color,
|
|
local::commands::labels_remove,
|
|
local::commands::labels_merge,
|
|
local::commands::saved_filters_list,
|
|
local::commands::saved_filters_create,
|
|
local::commands::saved_filters_remove,
|
|
local::commands::saved_filters_rename,
|
|
sync::commands::sync_probe,
|
|
sync::commands::sync_link,
|
|
sync::commands::sync_unlink,
|
|
sync::commands::sync_status,
|
|
sync::commands::sync_pull,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.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
|
|
/// 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);
|
|
}
|
|
}
|
|
}
|