M10.7a: link/unlink a server — device auth + sync_state (task 2104)
The pairing step. Nothing else in the sync arc can move until this works. sync/state.rs owns the link record in the sync_state row M10.4 already put in the local schema. Two safety properties are the reason it isn't just three setters: - Linking a DIFFERENT server resets the change-feed cursor. 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 wearing the costume of a successful sync. Re-linking the SAME server (a token refresh) keeps it, so a routine re-auth doesn't force a full re-download. - Unlink clears the cursor too, so a later link can't inherit a watermark from a server that never issued it. An unparseable or absent cursor reads as 0 (full sync). That direction is always safe: a redundant re-sync costs time, a too-high cursor costs notes. Likewise a half-written row (server but no token) reports NOT linked. state::Status deliberately has no device_token field — it crosses into the webview, and a long-lived bearer token has no business reachable from page scripts. A test asserts the token never appears in its serialization. Token lives in the app-data SQLite file, not an OS keyring: 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/minimal-WM setups — the same class of environment assumption behind the black-window bug. sync_link runs the M10.6 handshake FIRST and refuses an incompatible server before any credential is sent. Two credential paths, because neither covers everyone: device-login (a fresh install has no session to mint a token from) and a pasted token (some users would rather not type a password into a desktop app). A pasted token is verified against /api/auth/me before being stored — auth.py's login_required accepts bearer — since an unverified paste would turn a copy/paste slip into a failure surfacing at the next sync, far from its cause. The store lock is taken only after all network work: a std MutexGuard isn't Send so it cannot cross an await, and holding the store for a round-trip would freeze every note operation in the UI. Unlink is LOCAL only — the token stays valid server-side until revoked under Account -> Linked devices. A pasted token arrives without its device id, so a reliable remote revoke isn't possible from here; the UI must say so rather than imply a revoke that didn't happen. Follow-up filed. No UI yet — that's M10.7e. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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, String> {
|
||||
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<ProbeResult, String> {
|
||||
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<ProbeResult, String> {
|
||||
|
||||
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<ProbeResult, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Identity, String> {
|
||||
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<ProbeResult, String> {
|
||||
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"
|
||||
|
||||
@@ -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<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())?;
|
||||
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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
pub device_token: Option<String>,
|
||||
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<String>,
|
||||
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<String>) -> Option<String> {
|
||||
value.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
|
||||
conn.query_row(
|
||||
"SELECT server_url, device_token, last_cursor FROM sync_state WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let cursor: Option<String> = 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<Status> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user