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>
394 lines
14 KiB
Rust
394 lines
14 KiB
Rust
//! Client<->server compatibility handshake (M10.6).
|
|
//!
|
|
//! The desktop app is local-first: it never *needs* a server. When the user links
|
|
//! one, this module decides whether the two can actually talk — before a single
|
|
//! note moves. The sync engine (M10.7) consults it on link and on every sync.
|
|
//!
|
|
//! The contract is two integers per side, versioning the WIRE PROTOCOL separately
|
|
//! from either program's release version:
|
|
//!
|
|
//! | | this client | the server advertises |
|
|
//! |---|---|---|
|
|
//! | speaks | `CLIENT_PROTOCOL_VERSION` | `sync_protocol_version` |
|
|
//! | accepts down to | `MIN_SERVER_PROTOCOL_VERSION` | `min_client_protocol_version` |
|
|
//!
|
|
//! Each side declaring its own floor is what avoids app<->server lockstep: either
|
|
//! side can mark a change breaking without the other needing to ship in step. See
|
|
//! `docs/sync.md` for the policy that governs when those numbers move.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// The sync wire protocol this client speaks.
|
|
pub const CLIENT_PROTOCOL_VERSION: u32 = 1;
|
|
|
|
/// The oldest server protocol this client can drive — the symmetric half of the
|
|
/// server's `min_client_protocol_version`.
|
|
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 1;
|
|
|
|
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
|
|
/// link rather than degrading it.
|
|
pub const REQUIRED_FEATURES: &[&str] = &["notes", "labels"];
|
|
|
|
/// Capabilities whose absence costs a feature but not the link. Listing these
|
|
/// explicitly (rather than diffing against whatever the server happens to send) is
|
|
/// what lets the UI name exactly what the user will be missing.
|
|
pub const OPTIONAL_FEATURES: &[&str] = &["attachments", "tombstones", "revisions"];
|
|
|
|
/// The handshake fields of `GET /api/config`.
|
|
///
|
|
/// Every protocol field is optional because a server predating M10.6 simply won't
|
|
/// send them. That case has to read as "this server is too old to sync", not as a
|
|
/// parse failure — which would look to the user like they mistyped the URL.
|
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
|
pub struct ServerInfo {
|
|
#[serde(default)]
|
|
pub site_name: Option<String>,
|
|
/// The server's release version, for display only — never gate on it.
|
|
#[serde(default)]
|
|
pub version: Option<String>,
|
|
#[serde(default)]
|
|
pub sync_protocol_version: Option<u32>,
|
|
#[serde(default)]
|
|
pub min_client_protocol_version: Option<u32>,
|
|
#[serde(default)]
|
|
pub sync_features: Vec<String>,
|
|
/// How long the SERVER keeps a trashed note before purging it (0 = forever).
|
|
/// Once linked this is the window that actually applies, so the desktop's Trash
|
|
/// countdown has to come from here rather than from its own offline default.
|
|
#[serde(default)]
|
|
pub trash_retention_days: Option<u32>,
|
|
}
|
|
|
|
impl ServerInfo {
|
|
fn has_feature(&self, name: &str) -> bool {
|
|
self.sync_features.iter().any(|f| f.as_str() == name)
|
|
}
|
|
|
|
fn missing(&self, from: &[&str]) -> Vec<String> {
|
|
from.iter()
|
|
.copied()
|
|
.filter(|f| !self.has_feature(f))
|
|
.map(String::from)
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// The verdict the link/settings UI renders and the sync engine obeys.
|
|
///
|
|
/// Serialized tagged so the frontend can `switch` on `status` directly.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
#[serde(tag = "status", rename_all = "snake_case")]
|
|
pub enum Compatibility {
|
|
/// Full parity — sync everything.
|
|
Ok,
|
|
/// Safe to sync, but these named capabilities aren't available here.
|
|
Degraded { unavailable: Vec<String> },
|
|
/// Do not sync. `client_must_update` points the user at the side that can fix
|
|
/// it, so the message can be actionable instead of just "incompatible".
|
|
Incompatible {
|
|
reason: String,
|
|
client_must_update: bool,
|
|
},
|
|
}
|
|
|
|
fn incompatible(reason: &str, client_must_update: bool) -> Compatibility {
|
|
Compatibility::Incompatible {
|
|
reason: reason.to_string(),
|
|
client_must_update,
|
|
}
|
|
}
|
|
|
|
/// Decide whether this client can sync with the described server.
|
|
///
|
|
/// Pure: the transport fetches `ServerInfo`, this decides what it means. Keeping
|
|
/// the decision free of I/O is what makes every branch below unit-testable, which
|
|
/// matters because there is no Postgres/live-server lane in CI.
|
|
pub fn evaluate(info: &ServerInfo) -> Compatibility {
|
|
// Ordered most-fundamental first, so the user sees the root problem rather than
|
|
// a downstream symptom of it.
|
|
let Some(server_proto) = info.sync_protocol_version else {
|
|
return incompatible(
|
|
"This server doesn't support device sync — it predates the sync protocol. \
|
|
Update the server, then link again.",
|
|
false,
|
|
);
|
|
};
|
|
|
|
if server_proto < MIN_SERVER_PROTOCOL_VERSION {
|
|
return incompatible(
|
|
&format!(
|
|
"This server speaks sync protocol v{server_proto}, but this app needs \
|
|
at least v{MIN_SERVER_PROTOCOL_VERSION}. Update the server."
|
|
),
|
|
false,
|
|
);
|
|
}
|
|
|
|
// The server's floor is what hard-blocks an old client. Absent => no floor: a
|
|
// server that advertises a protocol but no minimum accepts anything.
|
|
let floor = info.min_client_protocol_version.unwrap_or(0);
|
|
if CLIENT_PROTOCOL_VERSION < floor {
|
|
return incompatible(
|
|
&format!(
|
|
"This server requires client protocol v{floor} or newer; this app \
|
|
speaks v{CLIENT_PROTOCOL_VERSION}. Update ThoughtSync."
|
|
),
|
|
true,
|
|
);
|
|
}
|
|
|
|
// A version match still isn't enough: a server can speak the protocol with a
|
|
// core capability compiled out or disabled.
|
|
let missing_required = info.missing(REQUIRED_FEATURES);
|
|
if !missing_required.is_empty() {
|
|
return incompatible(
|
|
&format!(
|
|
"This server is missing sync capabilities this app requires: {}.",
|
|
missing_required.join(", ")
|
|
),
|
|
false,
|
|
);
|
|
}
|
|
|
|
let unavailable = info.missing(OPTIONAL_FEATURES);
|
|
if unavailable.is_empty() {
|
|
Compatibility::Ok
|
|
} else {
|
|
Compatibility::Degraded { unavailable }
|
|
}
|
|
}
|
|
|
|
/// Headers this client puts on every request to a linked server, so the server can
|
|
/// log or gate on client identity without a separate handshake round-trip.
|
|
pub fn client_headers() -> [(&'static str, String); 2] {
|
|
let agent = format!("thoughtsync-desktop/{}", env!("CARGO_PKG_VERSION"));
|
|
[
|
|
("X-ThoughtSync-Client", agent),
|
|
(
|
|
"X-ThoughtSync-Protocol",
|
|
CLIENT_PROTOCOL_VERSION.to_string(),
|
|
),
|
|
]
|
|
}
|
|
|
|
/// Turn what a user typed into a base URL we can build request paths on, or `None`
|
|
/// if there's nothing usable in it.
|
|
///
|
|
/// A bare host gets **`https://`**, never `http://`. Silently downgrading would put
|
|
/// a long-lived device token on the wire in cleartext because someone omitted five
|
|
/// characters. Plain HTTP on a trusted LAN stays fully supported — the user just
|
|
/// has to type `http://` and thereby choose it.
|
|
pub fn normalize_base_url(raw: &str) -> Option<String> {
|
|
let trimmed = raw.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
// Resolve the scheme BEFORE touching trailing slashes — stripping them first
|
|
// turns a bare "https://" into "https:", which then reads as a hostname.
|
|
let with_scheme = match trimmed.split_once("://") {
|
|
Some((scheme, rest)) => {
|
|
// Anything that isn't HTTP(S) (ftp://, file://, a stray "foo://") can't
|
|
// be a ThoughtSync server; reject rather than fail confusingly later.
|
|
let scheme = scheme.to_ascii_lowercase();
|
|
if scheme != "http" && scheme != "https" {
|
|
return None;
|
|
}
|
|
format!("{scheme}://{rest}")
|
|
}
|
|
None => format!("https://{trimmed}"),
|
|
};
|
|
let (scheme, rest) = with_scheme.split_once("://")?;
|
|
let rest = rest.trim_end_matches('/');
|
|
// Reject a scheme with no authority ("https://", "http:///path").
|
|
if rest.split(['/', '?', '#']).next().unwrap_or("").is_empty() {
|
|
return None;
|
|
}
|
|
Some(format!("{scheme}://{rest}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A server matching this client exactly, which each test then degrades.
|
|
fn current_server() -> ServerInfo {
|
|
ServerInfo {
|
|
site_name: Some("ThoughtSync".into()),
|
|
version: Some("0.1.0".into()),
|
|
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
|
|
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
|
|
sync_features: REQUIRED_FEATURES
|
|
.iter()
|
|
.chain(OPTIONAL_FEATURES.iter())
|
|
.copied()
|
|
.map(String::from)
|
|
.collect(),
|
|
trash_retention_days: Some(30),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn current_server_is_fully_compatible() {
|
|
assert_eq!(evaluate(¤t_server()), Compatibility::Ok);
|
|
}
|
|
|
|
#[test]
|
|
fn server_without_protocol_fields_is_too_old() {
|
|
// A pre-M10.6 server: /api/config parses, but carries no protocol block.
|
|
let info = ServerInfo {
|
|
site_name: Some("ThoughtSync".into()),
|
|
version: Some("0.0.9".into()),
|
|
..Default::default()
|
|
};
|
|
match evaluate(&info) {
|
|
Compatibility::Incompatible {
|
|
client_must_update, ..
|
|
} => assert!(!client_must_update, "the SERVER is the old side here"),
|
|
other => panic!("expected incompatible, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn client_older_than_the_servers_floor_must_update() {
|
|
let info = ServerInfo {
|
|
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 5),
|
|
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 5),
|
|
..current_server()
|
|
};
|
|
match evaluate(&info) {
|
|
Compatibility::Incompatible {
|
|
client_must_update, ..
|
|
} => assert!(client_must_update),
|
|
other => panic!("expected incompatible, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn newer_server_within_our_floor_still_works() {
|
|
// The whole point of the two-number contract: a server can move ahead
|
|
// additively without locking out a client that predates the change.
|
|
let info = ServerInfo {
|
|
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 3),
|
|
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION),
|
|
..current_server()
|
|
};
|
|
assert_eq!(evaluate(&info), Compatibility::Ok);
|
|
}
|
|
|
|
#[test]
|
|
fn server_with_no_declared_floor_accepts_us() {
|
|
let info = ServerInfo {
|
|
min_client_protocol_version: None,
|
|
..current_server()
|
|
};
|
|
assert_eq!(evaluate(&info), Compatibility::Ok);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_optional_feature_degrades_rather_than_blocks() {
|
|
let info = ServerInfo {
|
|
sync_features: current_server()
|
|
.sync_features
|
|
.into_iter()
|
|
.filter(|f| f.as_str() != "attachments")
|
|
.collect(),
|
|
..current_server()
|
|
};
|
|
assert_eq!(
|
|
evaluate(&info),
|
|
Compatibility::Degraded {
|
|
unavailable: vec!["attachments".to_string()]
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_required_feature_blocks() {
|
|
let info = ServerInfo {
|
|
sync_features: vec!["labels".to_string()],
|
|
..current_server()
|
|
};
|
|
match evaluate(&info) {
|
|
Compatibility::Incompatible { reason, .. } => assert!(reason.contains("notes")),
|
|
other => panic!("expected incompatible, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn version_mismatch_outranks_a_missing_feature() {
|
|
// Both wrong → report the version, the root cause of the missing feature.
|
|
let info = ServerInfo {
|
|
sync_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 2),
|
|
min_client_protocol_version: Some(CLIENT_PROTOCOL_VERSION + 2),
|
|
sync_features: vec![],
|
|
..current_server()
|
|
};
|
|
match evaluate(&info) {
|
|
Compatibility::Incompatible {
|
|
client_must_update, ..
|
|
} => assert!(client_must_update),
|
|
other => panic!("expected incompatible, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn verdict_serializes_tagged_for_the_frontend() {
|
|
let verdict = Compatibility::Degraded {
|
|
unavailable: vec!["attachments".into()],
|
|
};
|
|
let json = serde_json::to_string(&verdict).expect("verdict serializes");
|
|
assert!(json.contains("\"status\":\"degraded\""), "got {json}");
|
|
}
|
|
|
|
#[test]
|
|
fn server_info_tolerates_unknown_and_absent_fields() {
|
|
// Forward compatibility: a NEWER server sending fields we've never heard of
|
|
// must not break the handshake.
|
|
let info: ServerInfo = serde_json::from_str(
|
|
r#"{"site_name":"S","sync_protocol_version":1,
|
|
"min_client_protocol_version":1,
|
|
"sync_features":["notes","labels","attachments","tombstones","revisions"],
|
|
"some_future_field":{"nested":true}}"#,
|
|
)
|
|
.expect("unknown fields are ignored");
|
|
assert_eq!(evaluate(&info), Compatibility::Ok);
|
|
}
|
|
|
|
#[test]
|
|
fn client_headers_identify_app_and_protocol() {
|
|
let headers = client_headers();
|
|
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
|
|
assert!(headers[0].1.starts_with("thoughtsync-desktop/"));
|
|
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
|
|
}
|
|
|
|
#[test]
|
|
fn base_url_defaults_to_https_and_trims() {
|
|
assert_eq!(
|
|
normalize_base_url(" notes.example.com/ "),
|
|
Some("https://notes.example.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
normalize_base_url("https://notes.example.com///"),
|
|
Some("https://notes.example.com".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn base_url_keeps_an_explicit_http_choice() {
|
|
// Plain HTTP on a LAN is supported — the user just has to ask for it.
|
|
assert_eq!(
|
|
normalize_base_url("http://192.168.1.10:8000"),
|
|
Some("http://192.168.1.10:8000".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn base_url_rejects_junk() {
|
|
assert_eq!(normalize_base_url(""), None);
|
|
assert_eq!(normalize_base_url(" "), None);
|
|
assert_eq!(normalize_base_url("https://"), None);
|
|
assert_eq!(normalize_base_url("ftp://files.example.com"), None);
|
|
}
|
|
}
|