Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.
The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.
Two of the values it was going to show were wrong, which is the part worth
knowing about.
The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).
The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.
Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.
Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.
build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
311 lines
14 KiB
Rust
311 lines
14 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 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!("THOUGHTSYNC_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 `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::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 {} 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);
|
|
}
|
|
}
|
|
}
|