core: extract the store and sync engine into a shared crate (M12 step 1)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 48s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m50s
Desktop (Tauri) / Update manifest (push) Skipped

Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.

This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.

The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.

Two things a workspace changes that are easy to miss, both caught before pushing:

[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.

And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.

Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 23:12:26 -04:00
co-authored by Claude Opus 5
parent c28f2bc00e
commit 0a7480cf9b
75 changed files with 246 additions and 1346 deletions
+283
View File
@@ -0,0 +1,283 @@
//! Tauri command surface for the local store — the offline mirror of the REST API
//! the frontend's repository seam calls. Each command locks the shared connection and
//! delegates to `store`; errors are stringified for the JS boundary. adapters/local.ts
//! (M10.5) invokes these.
use serde_json::Value;
use tauri::State;
use thoughtsync_core::local::models::*;
use thoughtsync_core::local::retention;
use thoughtsync_core::local::store;
use thoughtsync_core::local::Db;
use thoughtsync_core::sync::state;
// A macro would hide the (very regular) locking; kept explicit so each command reads
// as an obvious lock -> delegate -> stringify.
#[tauri::command]
pub fn config_get(db: State<'_, Db>) -> PublicConfig {
// What the Trash view counts down against: the linked server's window if we know
// it, else this device's own. Reading it here rather than hard-coding the offline
// default is what keeps the deadline on screen equal to the one that will actually
// be enforced. A store error falls back to the default rather than failing the
// call — the app must still boot.
let fallback = retention::LOCAL_RETENTION_DAYS;
let retention_days = match db.0.lock() {
Ok(conn) => state::effective_retention_days(&conn, fallback).unwrap_or(fallback),
Err(_) => fallback,
};
// Offline defaults: no signups, no server-side URL unfurling (needs network).
PublicConfig {
site_name: "ThoughtSync".to_string(),
allow_registration: false,
version: env!("CARGO_PKG_VERSION").to_string(),
enable_url_unfurl: false,
trash_retention_days: retention_days.max(0) as u32,
}
}
#[tauri::command]
pub fn auth_me() -> User {
// The single synthetic local user, so the auth-gated router resolves with no login.
User {
id: "local".to_string(),
email: "local@thoughtsync.app".to_string(),
display_name: "You".to_string(),
email_verified: true,
is_admin: false,
}
}
#[tauri::command]
pub fn notes_list(query: ListQuery, db: State<'_, Db>) -> Result<Vec<Note>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::list_notes(&conn, &query).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_get(id: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::get_note(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_create(input: NoteCreateInput, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::create_note(&conn, &input).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_create_titled(title: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::create_titled(&conn, &title).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_update(id: String, changes: Value, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::update_note(&conn, &id, &changes).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_complete_reminder(id: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::complete_reminder(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_snooze_reminder(id: String, minutes: i64, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::snooze_reminder(&conn, &id, minutes).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_set_labels(
id: String,
label_ids: Vec<String>,
db: State<'_, Db>,
) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::set_labels(&conn, &id, &label_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_add_item(id: String, text: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::add_item(&conn, &id, &text).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_update_item(
id: String,
item_id: String,
changes: Value,
db: State<'_, Db>,
) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::update_item(&conn, &id, &item_id, &changes).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_delete_item(id: String, item_id: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::delete_item(&conn, &id, &item_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_delete_attachment(
id: String,
att_id: String,
db: State<'_, Db>,
) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::delete_attachment(&conn, &id, &att_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_delete_preview(
id: String,
preview_id: String,
db: State<'_, Db>,
) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::delete_preview(&conn, &id, &preview_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_reorder(ordered_ids: Vec<String>, db: State<'_, Db>) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::reorder(&conn, &ordered_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_trash(id: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::trash(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_restore(id: String, db: State<'_, Db>) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::restore(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_delete_forever(id: String, db: State<'_, Db>) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::delete_forever(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_revisions(id: String, db: State<'_, Db>) -> Result<Vec<NoteRevision>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::revisions(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_restore_revision(
id: String,
rev_id: String,
db: State<'_, Db>,
) -> Result<Note, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::restore_revision(&conn, &id, &rev_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_reminders(db: State<'_, Db>) -> Result<Vec<Note>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::reminders(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_titles(db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::titles(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_search(q: String, db: State<'_, Db>) -> Result<Vec<Note>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::search(&conn, &q).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_backlinks(id: String, db: State<'_, Db>) -> Result<Vec<Backlink>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::backlinks(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn notes_link_search(q: String, db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::link_search(&conn, &q).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::list_labels(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn labels_create(name: String, db: State<'_, Db>) -> Result<Label, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::create_label(&conn, &name).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn labels_rename(id: String, name: String, db: State<'_, Db>) -> Result<Label, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::rename_label(&conn, &id, &name).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn labels_set_color(id: String, color: String, db: State<'_, Db>) -> Result<Label, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::set_label_color(&conn, &id, &color).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn labels_remove(id: String, db: State<'_, Db>) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::remove_label(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn labels_merge(source_id: String, into: String, db: State<'_, Db>) -> Result<Label, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::merge_labels(&conn, &source_id, &into).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn saved_filters_list(db: State<'_, Db>) -> Result<Vec<SavedFilter>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::list_saved_filters(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn saved_filters_create(
name: String,
params: Value,
db: State<'_, Db>,
) -> Result<SavedFilter, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::create_saved_filter(&conn, &name, &params).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn saved_filters_remove(id: String, db: State<'_, Db>) -> Result<(), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::remove_saved_filter(&conn, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn saved_filters_rename(
id: String,
name: String,
db: State<'_, Db>,
) -> Result<SavedFilter, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
store::rename_saved_filter(&conn, &id, &name).map_err(|e| e.to_string())
}
+10
View File
@@ -0,0 +1,10 @@
//! The Tauri command surface — the desktop's adapter onto `thoughtsync-core`.
//!
//! This is the whole of what couples the store and sync engine to Tauri, and keeping
//! it in one place is deliberate: the Android client writes its own adapter (uniffi)
//! against the same core, so anything that leaks framework concerns back into the
//! core makes that second adapter harder. `frontend/src/adapters/local.ts` calls in
//! here over `invoke`.
pub mod local;
pub mod sync;
+190
View File
@@ -0,0 +1,190 @@
//! Tauri commands for pairing with a server (M10.7a).
//!
//! Linking is opt-in and reversible; the app is fully usable having never touched
//! any of this. The Settings UI (M10.7e) drives these.
use serde::{Deserialize, Serialize};
use tauri::State;
use thoughtsync_core::local::Db;
use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::client::{self, Identity, ProbeResult};
use thoughtsync_core::sync::compat::Compatibility;
use thoughtsync_core::sync::engine;
use thoughtsync_core::sync::push;
use thoughtsync_core::sync::state;
/// Ask a server who it is, without committing to anything. The UI calls this as the
/// user finishes typing an address, so they see what answered before handing over
/// credentials.
#[tauri::command]
pub async fn sync_probe(url: String) -> Result<ProbeResult, String> {
client::probe(&url).await
}
/// Either a password login or a token pasted from the web app. Both are offered
/// because neither covers everyone: a fresh install has no session to mint a token
/// from, while someone using a password manager or SSO may prefer not to type a
/// password into a desktop app at all.
#[derive(Deserialize)]
pub struct LinkInput {
pub url: String,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub token: Option<String>,
/// How this device is labelled in the server's device list.
#[serde(default)]
pub name: Option<String>,
}
#[derive(Serialize)]
pub struct LinkResult {
pub status: state::Status,
pub identity: Identity,
/// Carried through so the UI can warn about a `degraded` server right after
/// linking, instead of staying silent until a feature quietly does nothing.
pub compatibility: Compatibility,
}
/// A recognizable default, so a server's device list doesn't fill up with "Device".
fn default_device_name() -> String {
format!("ThoughtSync desktop ({})", std::env::consts::OS)
}
fn trimmed(value: &Option<String>) -> Option<&str> {
value.as_deref().map(str::trim).filter(|s| !s.is_empty())
}
#[tauri::command]
pub async fn sync_link(input: LinkInput, db: State<'_, Db>) -> Result<LinkResult, String> {
// 1. Handshake FIRST. Never hand credentials to a server we've established we
// can't sync with — and an incompatible server is exactly the case where a
// later failure would be hardest to attribute.
let probe = client::probe(&input.url).await?;
if let Compatibility::Incompatible { reason, .. } = &probe.compatibility {
return Err(reason.clone());
}
let base_url = probe.base_url;
// 2. Obtain a credential.
let (token, identity) = match trimmed(&input.token) {
Some(token) => {
// Verify before storing: an unverified paste turns a copy/paste slip
// into a failure that only surfaces at the next sync.
let identity = client::fetch_identity(&base_url, token).await?;
(token.to_string(), identity)
}
None => {
let (Some(email), Some(password)) = (trimmed(&input.email), trimmed(&input.password))
else {
return Err("Enter your email and password, or paste a device token.".to_string());
};
let name = trimmed(&input.name)
.map(str::to_string)
.unwrap_or_else(default_device_name);
client::device_login(&base_url, email, password, &name).await?
}
};
// 3. Persist. The lock is taken only now, for two reasons: a std MutexGuard
// isn't Send so it cannot be held across an await, and holding the store
// locked for a network round-trip would freeze every note operation in the UI.
let status = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
state::set_link(&conn, &base_url, &token).map_err(|e| e.to_string())?;
// Adopt the server's trash-retention window immediately, so the Trash view
// stops counting down against this device's offline default the moment it's
// no longer the policy in force.
if let Some(days) = probe.server.trash_retention_days {
state::set_server_retention(&conn, days as i64).map_err(|e| e.to_string())?;
}
state::status(&conn).map_err(|e| e.to_string())?
};
log::info!("linked to {} as {}", base_url, identity.email);
Ok(LinkResult {
status,
identity,
compatibility: probe.compatibility,
})
}
#[derive(Serialize)]
pub struct UnlinkResult {
pub status: state::Status,
/// What happened to the token on the SERVER — kept separate from `status`
/// because the local half always succeeds and the remote half may not.
pub revoked: client::RevokeOutcome,
}
/// Stop syncing, and retire this device's token on the server.
///
/// The local half is unconditional. Someone unlinking because the machine is being
/// sold or handed on must not be held to it by a server that's offline or gone — so
/// the revoke is attempted first, its outcome carried back for the UI to report
/// honestly, and the link cleared either way.
#[tauri::command]
pub async fn sync_unlink(db: State<'_, Db>) -> Result<UnlinkResult, String> {
// Read and release before the network call: a std MutexGuard isn't Send, and
// holding the store across a round-trip would freeze every note operation in
// the UI.
let link = {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let current = state::read(&conn).map_err(|e| e.to_string())?;
current.server_url.zip(current.device_token)
};
let revoked = match &link {
Some((base_url, token)) => client::revoke_self(base_url, token).await,
None => client::RevokeOutcome::Skipped,
};
let conn = db.0.lock().map_err(|e| e.to_string())?;
state::clear_link(&conn).map_err(|e| e.to_string())?;
log::info!("unlinked from server (server-side token: {revoked:?})");
Ok(UnlinkResult {
status: state::status(&conn).map_err(|e| e.to_string())?,
revoked,
})
}
#[tauri::command]
pub fn sync_status(db: State<'_, Db>) -> Result<state::Status, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
state::status(&conn).map_err(|e| e.to_string())
}
/// The server URL + token, or a plain "not linked" error. Every networked sync
/// command needs exactly this, and none of them may hold the lock past it.
fn credentials(db: &State<'_, Db>) -> Result<(String, String), String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let current = state::read(&conn).map_err(|e| e.to_string())?;
match (current.server_url, current.device_token) {
(Some(url), Some(token)) => Ok((url, token)),
_ => Err("This app isn't linked to a server yet.".to_string()),
}
}
/// Run one full sync: push local changes, then pull the server's.
///
/// The only sync entry point exposed to the UI, on purpose. Push and pull exist
/// separately inside the crate, but offering a bare "pull" would let the UI overwrite
/// unsent local edits — the ordering isn't a suggestion, it's what keeps them.
#[tauri::command]
pub async fn sync_now(
db: State<'_, Db>,
blobs: State<'_, BlobStore>,
) -> Result<engine::SyncOutcome, String> {
let (base_url, token) = credentials(&db)?;
engine::run_cycle(db.inner(), blobs.inner(), &base_url, &token).await
}
/// Whether anything is waiting to be sent. Lets the UI show an honest "unsynced
/// changes" state without running a sync to find out.
#[tauri::command]
pub fn sync_has_pending(db: State<'_, Db>) -> Result<bool, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
push::has_pending(&conn).map_err(|e| e.to_string())
}