diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b60ecfc..df86d99 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -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. tauri-plugin-log = "2" 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. [profile.release] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d15798a..b37b070 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,8 +8,9 @@ mod integration; mod local; -// `pub` (unlike the modules above) because nothing calls into it yet — the sync -// engine that will is M10.7. A private module's unused items read as dead code. +// `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 +// items read as dead code. pub mod sync; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -88,6 +89,7 @@ pub fn run() { local::commands::saved_filters_create, local::commands::saved_filters_remove, local::commands::saved_filters_rename, + sync::client::server_probe, ]) .run(tauri::generate_context!()) .expect("error while running the ThoughtSync desktop app"); diff --git a/desktop/src-tauri/src/sync/client.rs b/desktop/src-tauri/src/sync/client.rs new file mode 100644 index 0000000..fd94e58 --- /dev/null +++ b/desktop/src-tauri/src/sync/client.rs @@ -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 { + 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 { + 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" + ); + } +} diff --git a/desktop/src-tauri/src/sync/mod.rs b/desktop/src-tauri/src/sync/mod.rs index 62c37f3..acd26c0 100644 --- a/desktop/src-tauri/src/sync/mod.rs +++ b/desktop/src-tauri/src/sync/mod.rs @@ -7,4 +7,5 @@ //! 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. +pub mod client; pub mod compat;