CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m12s
Trash had no end. A note sat in /trash until someone emptied it by hand, and its attachment BYTES sat on disk the whole time — the pile-up the operator asked about. Nothing purged; there was no scheduler at all. Retention is server-owned: `trash_retention_days` (default 30, 0 = keep forever) in the settings registry, so it lands in admin Settings with no migration and takes effect without a restart. A background sweep started in before_serving does the work. Clients learn about a purge the way they learn about any deletion — as a tombstone on the delta feed. An auto-purge nobody can see coming is data loss on a timer, so the window is now visible: /api/config publishes it, notes carry `deleted_at`, Trash leads with the policy, and each card counts down. The countdown rounds DOWN — saying "1 day left" for a note with ten minutes on the clock is the one error here that actually costs someone a note. Three things this turned up on the way: - `DELETE /api/notes/<id>` hard-deleted the row, leaving no tombstone at all. A permanent delete in the web UI never reached a linked device, which would keep its copy forever and push it back on the next edit. It now purges through the same path as everything else. - The purge left `note_revisions` and `note_link_previews` behind. A revision holds the full body, so the text of a "permanently deleted" note was still sitting in the database. - `deleted_at` now SURVIVES a purge instead of being cleared. It's still true, and it means every query that says "not trashed" excludes tombstones for free — without it a content-less row reads as a perfectly normal active note and shows up on the board as a blank card. Desktop keeps its own clock only when there's nobody else to keep one: the sweep runs at startup on an UNLINKED device and refuses otherwise. A linked client that expired notes on its own schedule could destroy something the server was deliberately keeping, then push that delete upstream. Local policy must never outrank the server's — so it also adopts the server's window for the countdown rather than showing its offline default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
167 lines
6.6 KiB
Rust
167 lines
6.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 crate::local::Db;
|
|
use crate::sync::blobs::BlobStore;
|
|
use crate::sync::client::{self, Identity, ProbeResult};
|
|
use crate::sync::compat::Compatibility;
|
|
use crate::sync::engine;
|
|
use crate::sync::push;
|
|
use crate::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,
|
|
})
|
|
}
|
|
|
|
/// Stop syncing and forget the server.
|
|
///
|
|
/// Local only: the device token remains valid on the SERVER until revoked there
|
|
/// (Account → Linked devices). We can't reliably revoke it from here — a pasted
|
|
/// token arrives without its device id — so the UI must say so rather than imply a
|
|
/// remote revoke that didn't happen. Tracked for follow-up.
|
|
#[tauri::command]
|
|
pub fn sync_unlink(db: State<'_, Db>) -> Result<state::Status, String> {
|
|
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");
|
|
state::status(&conn).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[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())
|
|
}
|