M10.6: HTTP transport for the handshake (task 1995)
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:
@@ -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]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user