diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b37b070..dfe1352 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,8 +8,8 @@ mod integration; mod local; -// `pub` (unlike the modules above) because most of it has no in-crate caller yet — -// the sync engine that will consume it is M10.7, and a private module's unreachable +// `pub` (unlike the modules above) because parts of it have no in-crate caller yet — +// the engine that will consume them is M10.7b/c, and a private module's unreachable // items read as dead code. pub mod sync; @@ -89,7 +89,10 @@ pub fn run() { local::commands::saved_filters_create, local::commands::saved_filters_remove, local::commands::saved_filters_rename, - sync::client::server_probe, + sync::commands::sync_probe, + sync::commands::sync_link, + sync::commands::sync_unlink, + sync::commands::sync_status, ]) .run(tauri::generate_context!()) .expect("error while running the ThoughtSync desktop app"); diff --git a/desktop/src-tauri/src/sync/client.rs b/desktop/src-tauri/src/sync/client.rs index fd94e58..b75fc6c 100644 --- a/desktop/src-tauri/src/sync/client.rs +++ b/desktop/src-tauri/src/sync/client.rs @@ -1,27 +1,29 @@ //! HTTP transport to a ThoughtSync server. //! -//! Today it performs exactly one call: the compatibility handshake (M10.6). The -//! sync engine (M10.7) grows push/pull on top of the same client, which is why the -//! auth headers, timeout and error vocabulary are established here rather than -//! inline in the probe. +//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The +//! engine that moves notes — push, pull, cursor — grows on top of the same client, +//! which is why the timeout, identity headers and error vocabulary live here rather +//! than inline at each call site. //! -//! Nothing here runs unless the user has linked a server — the app is local-first -//! and fully usable with no network at all. +//! Nothing here runs unless the user has linked a server; the app is local-first and +//! fully usable with no network at all. use std::time::Duration; -use serde::Serialize; +use reqwest::{RequestBuilder, StatusCode}; +use serde::{Deserialize, Serialize}; use super::compat::{self, Compatibility, ServerInfo}; -/// Handshake timeout. Short on purpose: a user is watching a "Connect" button while -/// this runs, and the most common mistake — a wrong host on a LAN — fails by -/// hanging rather than refusing, so an unbounded wait would just look frozen. -const PROBE_TIMEOUT: Duration = Duration::from_secs(10); +/// Timeout for the short request/response calls in this module. Kept tight because a +/// user is watching a button while they run, and the most common mistake — a wrong +/// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would +/// just look frozen. The sync engine's bulk transfers will need their own, longer one. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -/// What the link UI needs after a successful handshake: where we ended up (the -/// normalized URL, which may differ from what was typed), who answered, and whether -/// we can work with them. +/// What the link UI needs after a handshake: where we ended up (the normalized URL, +/// which may differ from what was typed), who answered, and whether we can work +/// with them. #[derive(Debug, Serialize)] pub struct ProbeResult { pub base_url: String, @@ -29,31 +31,62 @@ pub struct ProbeResult { pub compatibility: Compatibility, } -/// The public, unauthenticated endpoint carrying the handshake. -fn config_url(base_url: &str) -> String { - format!("{base_url}/api/config") +/// The account a device token belongs to. Surfaced after linking so the user can +/// confirm they linked the account they meant to — easy to get wrong on a server +/// hosting more than one. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Identity { + pub id: String, + pub email: String, + #[serde(default)] + pub display_name: String, +} + +#[derive(Deserialize)] +struct DeviceLoginResponse { + token: String, + user: Identity, +} + +fn http() -> Result { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|e| format!("Could not start the network client: {e}")) +} + +/// Attach the client-identity headers every request carries, plus a bearer token +/// when we hold one. +fn prepare(builder: RequestBuilder, token: Option<&str>) -> RequestBuilder { + let mut builder = builder; + for (name, value) in compat::client_headers() { + builder = builder.header(name, value); + } + match token { + Some(t) => builder.bearer_auth(t), + None => builder, + } +} + +fn unexpected_status(base_url: &str, status: StatusCode) -> String { + format!( + "{base_url} answered with HTTP {}. Check the address — a reverse proxy or a \ + different site may be answering there.", + status.as_u16() + ) } /// Ask a server who it is and whether we can sync with it. /// /// `Err` means we never got a usable answer (bad address, unreachable, not a -/// ThoughtSync server). A server that answers but is *incompatible* is `Ok` with a -/// verdict — that distinction matters, because the two need very different messages: -/// one is "check what you typed", the other is "update something". +/// ThoughtSync server). A server that answers but is *incompatible* comes back `Ok` +/// with a verdict — that distinction matters, because the two need very different +/// messages: one is "check what you typed", the other is "update something". pub async fn probe(raw_url: &str) -> Result { let base_url = compat::normalize_base_url(raw_url) .ok_or("Enter a server address, like https://notes.example.com")?; - let client = reqwest::Client::builder() - .timeout(PROBE_TIMEOUT) - .build() - .map_err(|e| format!("Could not start the network client: {e}"))?; - - let mut request = client.get(config_url(&base_url)); - for (name, value) in compat::client_headers() { - request = request.header(name, value); - } - + let request = prepare(http()?.get(config_url(&base_url)), None); let response = request .send() .await @@ -61,11 +94,7 @@ pub async fn probe(raw_url: &str) -> Result { let status = response.status(); if !status.is_success() { - return Err(format!( - "{base_url} answered with HTTP {}. Check the address — a reverse proxy or \ - a different site may be answering there.", - status.as_u16() - )); + return Err(unexpected_status(&base_url, status)); } // Something answered 200 that isn't a ThoughtSync server (a router login page, a @@ -86,6 +115,83 @@ pub async fn probe(raw_url: &str) -> Result { }) } +/// Exchange email + password for a device bearer token. +/// +/// The fresh-install path: it needs no existing session, which is what lets a brand +/// new desktop install link without visiting the web app first. +pub async fn device_login( + base_url: &str, + email: &str, + password: &str, + device_name: &str, +) -> Result<(String, Identity), String> { + let body = serde_json::json!({ + "email": email, + "password": password, + "name": device_name, + }); + let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body); + let response = request + .send() + .await + .map_err(|e| describe_transport_error(base_url, &e))?; + + let status = response.status(); + if status == StatusCode::UNAUTHORIZED { + return Err("That email and password didn't match an account on this server.".to_string()); + } + if !status.is_success() { + return Err(unexpected_status(base_url, status)); + } + + let parsed: DeviceLoginResponse = response + .json() + .await + .map_err(|_| format!("{base_url} signed us in but sent an unexpected reply."))?; + Ok((parsed.token, parsed.user)) +} + +/// Validate a token by asking whom it belongs to. +/// +/// Used when the user pastes a token issued from the web app. Storing it unverified +/// would turn a copy/paste slip into a failure that only surfaces at the next sync, +/// far from the thing that caused it. +pub async fn fetch_identity(base_url: &str, token: &str) -> Result { + let request = prepare(http()?.get(me_url(base_url)), Some(token)); + let response = request + .send() + .await + .map_err(|e| describe_transport_error(base_url, &e))?; + + let status = response.status(); + if status == StatusCode::UNAUTHORIZED { + let message = "That token isn't valid on this server — it may have been revoked. \ + Issue a new one from the web app under Account → Linked devices."; + return Err(message.to_string()); + } + if !status.is_success() { + return Err(unexpected_status(base_url, status)); + } + + response + .json() + .await + .map_err(|_| format!("{base_url} accepted the token but sent an unexpected reply.")) +} + +/// The public, unauthenticated endpoint carrying the handshake. +fn config_url(base_url: &str) -> String { + format!("{base_url}/api/config") +} + +fn device_login_url(base_url: &str) -> String { + format!("{base_url}/api/auth/device-login") +} + +fn me_url(base_url: &str) -> String { + format!("{base_url}/api/auth/me") +} + /// Turn a transport failure into something a person can act on. reqwest's own /// Display is accurate but reads like a stack trace. fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String { @@ -93,7 +199,7 @@ fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String { format!( "{base_url} didn't respond within {} seconds. It may be offline, or \ unreachable from this network.", - PROBE_TIMEOUT.as_secs() + REQUEST_TIMEOUT.as_secs() ) } else if err.is_connect() { format!( @@ -105,28 +211,30 @@ fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String { } } -/// Probe a server from the link/settings UI. -#[tauri::command] -pub async fn server_probe(url: String) -> Result { - probe(&url).await -} - #[cfg(test)] mod tests { use super::*; #[test] - fn config_url_joins_without_doubling_slashes() { - // normalize_base_url has already stripped any trailing slash, so a plain + fn urls_join_without_doubling_slashes() { + // normalize_base_url has already stripped any trailing slash, so plain // concatenation is correct — this pins that assumption. assert_eq!( config_url("https://notes.example.com"), "https://notes.example.com/api/config" ); + assert_eq!( + device_login_url("https://notes.example.com"), + "https://notes.example.com/api/auth/device-login" + ); + assert_eq!( + me_url("https://notes.example.com"), + "https://notes.example.com/api/auth/me" + ); } #[test] - fn config_url_preserves_a_port_and_subpath() { + fn urls_preserve_a_port_and_subpath() { assert_eq!( config_url("http://192.168.1.10:8000/thoughtsync"), "http://192.168.1.10:8000/thoughtsync/api/config" diff --git a/desktop/src-tauri/src/sync/commands.rs b/desktop/src-tauri/src/sync/commands.rs new file mode 100644 index 0000000..1d7a0c9 --- /dev/null +++ b/desktop/src-tauri/src/sync/commands.rs @@ -0,0 +1,124 @@ +//! 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::client::{self, Identity, ProbeResult}; +use crate::sync::compat::Compatibility; +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 { + 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, + #[serde(default)] + pub password: Option, + #[serde(default)] + pub token: Option, + /// How this device is labelled in the server's device list. + #[serde(default)] + pub name: Option, +} + +#[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) -> 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 { + // 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())?; + 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 { + 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 { + let conn = db.0.lock().map_err(|e| e.to_string())?; + state::status(&conn).map_err(|e| e.to_string()) +} diff --git a/desktop/src-tauri/src/sync/mod.rs b/desktop/src-tauri/src/sync/mod.rs index acd26c0..a7d403b 100644 --- a/desktop/src-tauri/src/sync/mod.rs +++ b/desktop/src-tauri/src/sync/mod.rs @@ -3,9 +3,16 @@ //! The app is local-first: `local` is the source of truth and everything works //! unlinked. Nothing in here runs until the user links a server. //! -//! `compat` owns the version/capability handshake (M10.6) that decides whether a -//! given server can be talked to at all. The engine that then moves notes — push, -//! pull, the revision cursor, last-write-wins — lands in M10.7 and consults it. +//! - `compat` — the version/capability handshake (M10.6): whether a given server can +//! be talked to at all. Pure decision logic, no I/O. +//! - `client` — HTTP transport: the handshake call and device-token auth. +//! - `state` — the persisted link record (server, token, change-feed cursor). +//! - `commands` — the Tauri surface the Settings UI drives. +//! +//! The engine that moves notes — push, pull, last-write-wins — lands in M10.7b/c and +//! consults `compat` before it does anything. pub mod client; +pub mod commands; pub mod compat; +pub mod state; diff --git a/desktop/src-tauri/src/sync/state.rs b/desktop/src-tauri/src/sync/state.rs new file mode 100644 index 0000000..c8ddf06 --- /dev/null +++ b/desktop/src-tauri/src/sync/state.rs @@ -0,0 +1,233 @@ +//! The link record: which server this app is paired with, the device token that +//! authenticates to it, and how far it has consumed that server's change feed. +//! +//! One row, enforced by `CHECK (id = 1)` and seeded during migration, so every +//! operation here is an UPDATE — there is no create-or-missing case to handle. +//! +//! The token lives in the app-data SQLite file rather than an OS keyring on purpose: +//! the `keyring` crate needs libsecret/DBus on Linux, which adds a C dependency to a +//! binary that has to cross-compile, and fails outright on headless or minimal-WM +//! setups. Protecting the database file is the portable trade. + +use rusqlite::{params, Connection}; +use serde::Serialize; + +/// The full link record, token included. Internal to the Rust side. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SyncState { + pub server_url: Option, + pub device_token: Option, + pub last_cursor: i64, +} + +impl SyncState { + /// Linked means BOTH a server and a credential for it. Either one alone is a + /// half-written link that nothing can act on, so it must not read as linked. + pub fn is_linked(&self) -> bool { + self.server_url.is_some() && self.device_token.is_some() + } +} + +/// What the UI is allowed to see. +/// +/// Deliberately has no `device_token` field: this crosses into the webview, and a +/// long-lived bearer token has no business being reachable from page scripts. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct Status { + pub linked: bool, + pub server_url: Option, + pub last_cursor: i64, +} + +impl From<&SyncState> for Status { + fn from(s: &SyncState) -> Self { + Status { + linked: s.is_linked(), + server_url: s.server_url.clone(), + last_cursor: s.last_cursor, + } + } +} + +/// Treat a blank string as absent, so a half-cleared row can't masquerade as linked. +fn present(value: Option) -> Option { + value.filter(|s| !s.trim().is_empty()) +} + +pub fn read(conn: &Connection) -> rusqlite::Result { + conn.query_row( + "SELECT server_url, device_token, last_cursor FROM sync_state WHERE id = 1", + [], + |row| { + let cursor: Option = row.get(2)?; + Ok(SyncState { + server_url: present(row.get(0)?), + device_token: present(row.get(1)?), + // Stored TEXT (schema) but used as an integer watermark. Absent or + // unparseable means "start from the beginning" — always the safe + // reading, because a redundant full sync costs time, never data, + // whereas a too-high cursor silently skips changes. + last_cursor: cursor.and_then(|c| c.trim().parse().ok()).unwrap_or(0), + }) + }, + ) +} + +/// Record a link. +/// +/// Resets the change-feed cursor whenever the server differs from the one previously +/// linked. A cursor is only meaningful against the server that issued it; carrying +/// one across would silently skip every change on the new server below that +/// watermark — data loss that looks like a successful sync. Re-linking the SAME +/// server (after a token refresh, say) keeps the cursor, so a routine re-auth doesn't +/// force a full re-download. +pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusqlite::Result<()> { + let keep_cursor = read(conn)?.server_url.as_deref() == Some(server_url); + conn.execute( + "UPDATE sync_state + SET server_url = ?1, + device_token = ?2, + last_cursor = CASE WHEN ?3 THEN last_cursor ELSE NULL END + WHERE id = 1", + params![server_url, device_token, keep_cursor], + )?; + Ok(()) +} + +/// Forget the server entirely. +/// +/// Clears the cursor as well as the credentials: a cursor left behind would, on the +/// next link, be interpreted against a server that never issued it. +pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + "UPDATE sync_state + SET server_url = NULL, device_token = NULL, last_cursor = NULL + WHERE id = 1", + [], + )?; + Ok(()) +} + +/// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only +/// after a page has been fully applied. +pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> { + conn.execute( + "UPDATE sync_state SET last_cursor = ?1 WHERE id = 1", + params![cursor.to_string()], + )?; + Ok(()) +} + +pub fn status(conn: &Connection) -> rusqlite::Result { + Ok(Status::from(&read(conn)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::local::schema; + + fn db() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory db"); + schema::migrate(&conn).expect("migrate"); + conn + } + + #[test] + fn fresh_store_is_unlinked() { + let conn = db(); + let state = read(&conn).expect("read"); + assert_eq!(state, SyncState::default()); + assert!(!state.is_linked()); + assert_eq!(state.last_cursor, 0); + } + + #[test] + fn link_round_trips() { + let conn = db(); + set_link(&conn, "https://notes.example.com", "tok-1").expect("link"); + let state = read(&conn).expect("read"); + assert!(state.is_linked()); + assert_eq!(state.server_url.as_deref(), Some("https://notes.example.com")); + assert_eq!(state.device_token.as_deref(), Some("tok-1")); + } + + #[test] + fn relinking_the_same_server_keeps_the_cursor() { + let conn = db(); + set_link(&conn, "https://a.example.com", "tok-1").expect("link"); + set_cursor(&conn, 4242).expect("cursor"); + // e.g. the token was revoked and the user re-authenticated. + set_link(&conn, "https://a.example.com", "tok-2").expect("relink"); + let state = read(&conn).expect("read"); + assert_eq!(state.last_cursor, 4242, "a re-auth shouldn't force a full re-sync"); + assert_eq!(state.device_token.as_deref(), Some("tok-2")); + } + + #[test] + fn linking_a_different_server_resets_the_cursor() { + let conn = db(); + set_link(&conn, "https://a.example.com", "tok-1").expect("link"); + set_cursor(&conn, 4242).expect("cursor"); + set_link(&conn, "https://b.example.com", "tok-2").expect("relink"); + assert_eq!( + read(&conn).expect("read").last_cursor, + 0, + "a cursor from another server would skip everything below it" + ); + } + + #[test] + fn unlink_clears_the_cursor_too() { + let conn = db(); + set_link(&conn, "https://a.example.com", "tok-1").expect("link"); + set_cursor(&conn, 99).expect("cursor"); + clear_link(&conn).expect("unlink"); + let state = read(&conn).expect("read"); + assert!(!state.is_linked()); + assert_eq!(state.last_cursor, 0); + assert!(state.server_url.is_none()); + assert!(state.device_token.is_none()); + } + + #[test] + fn half_written_link_is_not_linked() { + let conn = db(); + conn.execute( + "UPDATE sync_state SET server_url = 'https://a.example.com' WHERE id = 1", + [], + ) + .expect("partial write"); + assert!(!read(&conn).expect("read").is_linked()); + } + + #[test] + fn blank_strings_count_as_absent() { + let conn = db(); + conn.execute( + "UPDATE sync_state SET server_url = ' ', device_token = '' WHERE id = 1", + [], + ) + .expect("blank write"); + let state = read(&conn).expect("read"); + assert!(!state.is_linked()); + assert!(state.server_url.is_none()); + } + + #[test] + fn unparseable_cursor_falls_back_to_a_full_sync() { + let conn = db(); + conn.execute("UPDATE sync_state SET last_cursor = 'garbage' WHERE id = 1", []) + .expect("bad cursor"); + assert_eq!(read(&conn).expect("read").last_cursor, 0); + } + + #[test] + fn status_never_carries_the_token() { + let conn = db(); + set_link(&conn, "https://a.example.com", "super-secret").expect("link"); + let json = serde_json::to_string(&status(&conn).expect("status")).expect("serialize"); + assert!(!json.contains("super-secret"), "token leaked to the webview: {json}"); + assert!(json.contains("\"linked\":true"), "got {json}"); + } +}