Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 11s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 58s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m50s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 11m37s
ThoughtSync is renamed Inkwell ("Fabled Inkwell" in full; Scribe note 5071).
This is step 1 of milestone 481: every string a person reads in the running
apps. Identities installed clients depend on are deliberately untouched — the
Tauri productName (it derives the .deb Package: field), identifier and binary
name, applicationId, X-ThoughtSync-* headers, the export's app marker, env vars,
module and crate names.
- web: title, PWA manifest (name "Fabled Inkwell", short_name "Inkwell"),
offline page, icon labels, build labels, prompts, notification title
- server: site_name default, import error, link-preview User-Agent
- 0030: a stored site_name of exactly the old default follows the rename. The
Settings page saves every key, so most servers hold "ThoughtSync" without an
admin ever having chosen it; a name they typed is left alone
- desktop: window title, default device name, local-mode site name, log line
- android: app_name and the strings that name the app
- core: probe and compatibility messages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
460 lines
18 KiB
Rust
460 lines
18 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};
|
|
use std::sync::OnceLock;
|
|
|
|
/// The sync wire protocol this client speaks.
|
|
///
|
|
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
|
|
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
|
|
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
|
|
|
|
/// The oldest server protocol this client can drive — the symmetric half of the
|
|
/// server's `min_client_protocol_version`.
|
|
///
|
|
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
|
|
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
|
|
/// floors, on the rule that "dropping a field a client sends and expects back is
|
|
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
|
|
/// server gets `"default"` from serde's default and draws the colour it derives
|
|
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
|
|
/// client PUSHING `color` to a v4 server has the key ignored — the server reads its
|
|
/// payload key by key and never validates the shape. Neither direction errors, and
|
|
/// neither loses anything a person can see; `title` was the note's NAME, and this is a
|
|
/// field that no longer renders anywhere.
|
|
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
|
|
|
|
/// 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 Inkwell."
|
|
),
|
|
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 }
|
|
}
|
|
}
|
|
|
|
/// Who this client says it is, set once by the host application at startup.
|
|
///
|
|
/// THE CORE CANNOT KNOW THIS, and the value it used to invent was wrong twice. It
|
|
/// was `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and this crate is compiled into
|
|
/// the desktop app AND the Android app — so every phone in the field announced
|
|
/// itself as a desktop. The version was worse: `CARGO_PKG_VERSION` here is the
|
|
/// version of the CORE crate, a number no build stamps and no user has ever seen,
|
|
/// while the thing a reader of that header wants is the app's own build (note 3127
|
|
/// §5 — with no version tags, the artifact's self-report is the only answer to
|
|
/// "which build is this?").
|
|
///
|
|
/// So the host names itself. `OnceLock` because identity is fixed for the life of
|
|
/// the process and a second caller should be ignored rather than race the first.
|
|
static CLIENT_AGENT: OnceLock<String> = OnceLock::new();
|
|
|
|
/// Name this client for the servers it talks to — `("thoughtsync-android", "2026.08.31.1204")`.
|
|
///
|
|
/// Call once at startup, before any sync. Calling twice is not an error and the
|
|
/// first name wins; not calling it at all is visible in the header rather than
|
|
/// silently plausible.
|
|
pub fn set_client_agent(name: &str, version: &str) {
|
|
let _ = CLIENT_AGENT.set(format!("{name}/{version}"));
|
|
}
|
|
|
|
/// 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] {
|
|
// `unidentified/unknown`, never a plausible default. Nothing reads this header
|
|
// today, which is exactly why a wrong value could sit in it for months: the
|
|
// first person to look at a server log is the first person who could catch it,
|
|
// and only if what they see is obviously a host that never introduced itself.
|
|
let agent = CLIENT_AGENT
|
|
.get()
|
|
.cloned()
|
|
.unwrap_or_else(|| "thoughtsync-unidentified/unknown".to_string());
|
|
[
|
|
("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 an Inkwell 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("Inkwell".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("Inkwell".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.
|
|
// Versions come from the constants, not literals: this test is about unknown
|
|
// FIELDS, and pinning the numbers made it fail the moment the protocol moved
|
|
// to v2 — for a reason that has nothing to do with what it checks.
|
|
let body = format!(
|
|
r#"{{"site_name":"S","sync_protocol_version":{v},
|
|
"min_client_protocol_version":{v},
|
|
"sync_features":["notes","labels","attachments","tombstones","revisions"],
|
|
"some_future_field":{{"nested":true}}}}"#,
|
|
v = CLIENT_PROTOCOL_VERSION,
|
|
);
|
|
let info: ServerInfo = serde_json::from_str(&body).expect("unknown fields are ignored");
|
|
assert_eq!(evaluate(&info), Compatibility::Ok);
|
|
}
|
|
|
|
#[test]
|
|
fn client_headers_identify_app_and_protocol() {
|
|
// Sets the process-wide agent, which is why this test also owns the
|
|
// assertion about it: a second test calling `set_client_agent` would race
|
|
// this one for the OnceLock, and whichever lost would see the other's name.
|
|
// One test, both branches, in order.
|
|
assert!(
|
|
client_headers()[0]
|
|
.1
|
|
.starts_with("thoughtsync-unidentified/"),
|
|
"a host that never introduced itself must say so"
|
|
);
|
|
|
|
set_client_agent("thoughtsync-test", "2026.08.31.1204");
|
|
let headers = client_headers();
|
|
assert_eq!(headers[0].0, "X-ThoughtSync-Client");
|
|
assert_eq!(headers[0].1, "thoughtsync-test/2026.08.31.1204");
|
|
assert_eq!(headers[1].1, CLIENT_PROTOCOL_VERSION.to_string());
|
|
|
|
// First name wins — a second host cannot rename a running process.
|
|
set_client_agent("thoughtsync-impostor", "0");
|
|
assert_eq!(client_headers()[0].1, "thoughtsync-test/2026.08.31.1204");
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|