M10.6: HTTP transport for the handshake (task 1995)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m4s

Adds the client's first outbound call: GET {server}/api/config, carrying
X-ThoughtSync-Client and X-ThoughtSync-Protocol, feeding compat::evaluate.

Deliberately its OWN commit. This introduces the first HTTP+TLS stack into a
crate that cross-compiles to Windows from Linux via cargo-xwin — the lane
that has already broken once on a transitive C dependency (libsqlite3-sys
needing llvm-lib). Landing it alone means a failure here has exactly one
possible cause, instead of surfacing mid-way through M10.7's much larger
change where it would be expensive to bisect.

TLS backend is native-tls, NOT rustls, and that is the whole point of the
choice: on x86_64-pc-windows-msvc native-tls resolves to `schannel`, which
is pure-Rust bindings to the OS TLS stack, so nothing C or assembly has to
cross-compile on the fragile lane. rustls would pull in ring/aws-lc-rs and
their assembler. On Linux native-tls uses OpenSSL, whose headers ci-tauri
already ships (libssl-dev, part of Tauri's own Linux prerequisites).

Verified from run 2884's log rather than assumed: tokio and http are already
in the Windows tree via tauri, but no HTTP client and no TLS stack were —
so this genuinely is new surface there, not a no-op.

probe() distinguishes "never got a usable answer" (Err) from "answered, but
we can't work with it" (Ok + verdict). Those need very different messages:
one is "check what you typed", the other is "update something". Transport
errors are translated out of reqwest's Display, which is accurate but reads
like a stack trace.

Still no UI — M10.7 owns the link/settings surface that calls server_probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
2026-07-25 22:47:10 -04:00
co-authored by Claude Opus 5
parent 4b4bfe67ad
commit 4eb92942d0
4 changed files with 150 additions and 2 deletions
+10
View File
@@ -28,6 +28,16 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
# issues are diagnosable from any environment. `log` is the facade the code uses. # issues are diagnosable from any environment. `log` is the facade the code uses.
tauri-plugin-log = "2" tauri-plugin-log = "2"
log = "0.4" log = "0.4"
# HTTP for the opt-in server handshake (M10.6) and, next, the sync engine (M10.7).
#
# native-tls, NOT rustls, deliberately: on x86_64-pc-windows-msvc native-tls
# resolves to `schannel` — pure-Rust bindings to the OS TLS stack — so nothing C or
# assembly has to cross-compile on the Windows lane, which is the fragile one (it
# builds on Linux via cargo-xwin, and a C dependency there is what broke it before).
# rustls would instead pull in ring/aws-lc-rs and their assembler. On Linux
# native-tls uses OpenSSL, whose headers (libssl-dev) ci-tauri already ships.
# default-features off drops http2/charset we don't need for a JSON API.
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
# Tauri's default release profile: smaller, faster shipped binaries. # Tauri's default release profile: smaller, faster shipped binaries.
[profile.release] [profile.release]
+4 -2
View File
@@ -8,8 +8,9 @@
mod integration; mod integration;
mod local; mod local;
// `pub` (unlike the modules above) because nothing calls into it yet — the sync // `pub` (unlike the modules above) because most of it has no in-crate caller yet —
// engine that will is M10.7. A private module's unused items read as dead code. // the sync engine that will consume it is M10.7, and a private module's unreachable
// items read as dead code.
pub mod sync; pub mod sync;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -88,6 +89,7 @@ pub fn run() {
local::commands::saved_filters_create, local::commands::saved_filters_create,
local::commands::saved_filters_remove, local::commands::saved_filters_remove,
local::commands::saved_filters_rename, local::commands::saved_filters_rename,
sync::client::server_probe,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running the ThoughtSync desktop app"); .expect("error while running the ThoughtSync desktop app");
+135
View File
@@ -0,0 +1,135 @@
//! 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.
//!
//! 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 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);
/// 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.
#[derive(Debug, Serialize)]
pub struct ProbeResult {
pub base_url: String,
pub server: ServerInfo,
pub compatibility: Compatibility,
}
/// The public, unauthenticated endpoint carrying the handshake.
fn config_url(base_url: &str) -> String {
format!("{base_url}/api/config")
}
/// 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".
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 response = request
.send()
.await
.map_err(|e| describe_transport_error(&base_url, &e))?;
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()
));
}
// Something answered 200 that isn't a ThoughtSync server (a router login page, a
// captive portal). Report the address, not the parse error, which would mean
// nothing to the person reading it.
let server: ServerInfo = response.json().await.map_err(|_| {
format!(
"{base_url} responded, but not with ThoughtSync's configuration. \
Is that the right address?"
)
})?;
let compatibility = compat::evaluate(&server);
Ok(ProbeResult {
base_url,
server,
compatibility,
})
}
/// 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 {
if err.is_timeout() {
format!(
"{base_url} didn't respond within {} seconds. It may be offline, or \
unreachable from this network.",
PROBE_TIMEOUT.as_secs()
)
} else if err.is_connect() {
format!(
"Couldn't reach {base_url}. Check the address and that the server is \
running. If it uses plain HTTP, include http:// explicitly."
)
} else {
format!("Couldn't reach {base_url}: {err}")
}
}
/// 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
// concatenation is correct — this pins that assumption.
assert_eq!(
config_url("https://notes.example.com"),
"https://notes.example.com/api/config"
);
}
#[test]
fn config_url_preserves_a_port_and_subpath() {
assert_eq!(
config_url("http://192.168.1.10:8000/thoughtsync"),
"http://192.168.1.10:8000/thoughtsync/api/config"
);
}
}
+1
View File
@@ -7,4 +7,5 @@
//! given server can be talked to at all. The engine that then moves notes — push, //! 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. //! pull, the revision cursor, last-write-wins — lands in M10.7 and consults it.
pub mod client;
pub mod compat; pub mod compat;