CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.
So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.
This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.
Two things deliberately kept, because they were serving recall and only
incidentally serving links:
- `/api/notes/titles` and the titles store. The command palette lists them so
you can jump to a note by name. `resolve()` — the name→note lookup that only
linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
filenames. What that name is FOR changed; that it exists did not.
`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.
Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
281 lines
13 KiB
Rust
281 lines
13 KiB
Rust
//! 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);
|
|
}
|
|
}
|
|
}
|