Files
thoughtsync/desktop/src-tauri/src/commands/sync.rs
T
bvandeusenandClaude Opus 5 0a7480cf9b
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
core: extract the store and sync engine into a shared crate (M12 step 1)
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>
2026-08-17 23:12:26 -04:00

191 lines
7.6 KiB
Rust

//! 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())
}