core: extract the store and sync engine into a shared crate (M12 step 1)
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>
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
//! HTTP transport to a ThoughtSync server.
|
||||
//!
|
||||
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
|
||||
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
|
||||
//! which is why the timeout, identity headers and error vocabulary live here rather
|
||||
//! than inline at each call site.
|
||||
//!
|
||||
//! 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 reqwest::{RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::compat::{self, Compatibility, ServerInfo};
|
||||
use super::wire;
|
||||
|
||||
/// Timeout for the short request/response calls in this module. Kept tight because a
|
||||
/// user is watching a button while they run, 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. The sync engine's bulk transfers will need their own, longer one.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
|
||||
/// failing one at ten seconds would make a large store impossible to ever pull.
|
||||
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Shared by every call that presents a token, so a revoked one reads the same way
|
||||
/// wherever it surfaces.
|
||||
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
|
||||
revoked. Unlink and link again to issue a new one.";
|
||||
|
||||
/// What the link UI needs after a 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 account a device token belongs to. Surfaced after linking so the user can
|
||||
/// confirm they linked the account they meant to — easy to get wrong on a server
|
||||
/// hosting more than one.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Identity {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceLoginResponse {
|
||||
token: String,
|
||||
user: Identity,
|
||||
}
|
||||
|
||||
/// What became of this device's token on the SERVER when unlinking.
|
||||
///
|
||||
/// Not a bool, and not an error: unlinking must never be blocked by the network —
|
||||
/// wanting to stop syncing is a local decision — so the remote half reports back
|
||||
/// instead of failing the call, and each outcome needs different advice.
|
||||
///
|
||||
/// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum RevokeOutcome {
|
||||
/// The server confirmed it: this token authenticates nothing now.
|
||||
Revoked,
|
||||
/// This server has no self-revoke route — it predates one. The token is still
|
||||
/// live, and only the web app can retire it.
|
||||
Unsupported,
|
||||
/// We couldn't reach the server, or it refused. The token is still live.
|
||||
Failed { reason: String },
|
||||
/// Nothing to revoke; the app wasn't linked.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Retire the device token we authenticate with, server-side.
|
||||
///
|
||||
/// Identified by the token itself rather than a device id, because a token pasted
|
||||
/// from the web app never carried one — a route keyed on the id would work for
|
||||
/// exactly one of the two ways this app can be linked.
|
||||
pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome {
|
||||
let client = match http() {
|
||||
Ok(client) => client,
|
||||
Err(reason) => return RevokeOutcome::Failed { reason },
|
||||
};
|
||||
let request = prepare(client.delete(revoke_self_url(base_url)), Some(token));
|
||||
let response = match request.send().await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
return RevokeOutcome::Failed {
|
||||
reason: describe_transport_error(base_url, &e),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
// 401 counts as revoked: the token already authenticates nothing — retired by
|
||||
// another device, or purged server-side — which is the state we were asking for.
|
||||
if status.is_success() || status == StatusCode::UNAUTHORIZED {
|
||||
return RevokeOutcome::Revoked;
|
||||
}
|
||||
match status {
|
||||
// No such route: a server older than self-revoke. Any other shape of 404
|
||||
// (a proxy, a stale base URL) leaves the token live too, so the advice the
|
||||
// user needs is the same either way.
|
||||
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported,
|
||||
other => RevokeOutcome::Failed {
|
||||
reason: unexpected_status(base_url, other),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn http_with(timeout: Duration) -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Could not start the network client: {e}"))
|
||||
}
|
||||
|
||||
fn http() -> Result<reqwest::Client, String> {
|
||||
http_with(REQUEST_TIMEOUT)
|
||||
}
|
||||
|
||||
/// Attach the client-identity headers every request carries, plus a bearer token
|
||||
/// when we hold one.
|
||||
fn prepare(builder: RequestBuilder, token: Option<&str>) -> RequestBuilder {
|
||||
let mut builder = builder;
|
||||
for (name, value) in compat::client_headers() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
match token {
|
||||
Some(t) => builder.bearer_auth(t),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
|
||||
fn unexpected_status(base_url: &str, status: StatusCode) -> String {
|
||||
format!(
|
||||
"{base_url} answered with HTTP {}. Check the address — a reverse proxy or a \
|
||||
different site may be answering there.",
|
||||
status.as_u16()
|
||||
)
|
||||
}
|
||||
|
||||
/// 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* comes back `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 request = prepare(http()?.get(config_url(&base_url)), None);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(&base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(&base_url, status));
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exchange email + password for a device bearer token.
|
||||
///
|
||||
/// The fresh-install path: it needs no existing session, which is what lets a brand
|
||||
/// new desktop install link without visiting the web app first.
|
||||
pub async fn device_login(
|
||||
base_url: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
device_name: &str,
|
||||
) -> Result<(String, Identity), String> {
|
||||
let body = serde_json::json!({
|
||||
"email": email,
|
||||
"password": password,
|
||||
"name": device_name,
|
||||
});
|
||||
let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err("That email and password didn't match an account on this server.".to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
let parsed: DeviceLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| format!("{base_url} signed us in but sent an unexpected reply."))?;
|
||||
Ok((parsed.token, parsed.user))
|
||||
}
|
||||
|
||||
/// Validate a token by asking whom it belongs to.
|
||||
///
|
||||
/// Used when the user pastes a token issued from the web app. Storing it unverified
|
||||
/// would turn a copy/paste slip into a failure that only surfaces at the next sync,
|
||||
/// far from the thing that caused it.
|
||||
pub async fn fetch_identity(base_url: &str, token: &str) -> Result<Identity, String> {
|
||||
let request = prepare(http()?.get(me_url(base_url)), Some(token));
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
let message = "That token isn't valid on this server — it may have been revoked. \
|
||||
Issue a new one from the web app under Account → Linked devices.";
|
||||
return Err(message.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| format!("{base_url} accepted the token but sent an unexpected reply."))
|
||||
}
|
||||
|
||||
/// Fetch one page of the change feed, starting after `since`.
|
||||
///
|
||||
/// The caller loops until `has_more` is false (see `pull::run`); paging lives there
|
||||
/// rather than here so the transport stays a single request/response.
|
||||
pub async fn fetch_changes(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
since: i64,
|
||||
) -> Result<wire::ChangesPage, String> {
|
||||
let url = format!("{base_url}/api/sync/changes?since={since}");
|
||||
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't read the change feed from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Download one attachment's bytes.
|
||||
///
|
||||
/// Metadata already arrived on the delta feed; this is only the payload, fetched
|
||||
/// over the same route the web app uses (owner/shared scoped server-side).
|
||||
pub async fn fetch_attachment(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
note_id: &str,
|
||||
attachment_id: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}");
|
||||
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// Send a batch of changes and hand back the raw reply.
|
||||
///
|
||||
/// Returns text rather than parsed results so this module stays pure transport —
|
||||
/// `push::parse_results` owns the result shapes, and keeping them there is what lets
|
||||
/// the parsing be unit-tested without a server.
|
||||
pub async fn push_changes<T: Serialize>(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
changes: &[T],
|
||||
) -> Result<String, String> {
|
||||
let body = serde_json::json!({ "changes": changes });
|
||||
let url = format!("{base_url}/api/sync/push");
|
||||
let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).json(&body);
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| describe_transport_error(base_url, &e))?;
|
||||
|
||||
let status = response.status();
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return Err(TOKEN_REJECTED.to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(unexpected_status(base_url, status));
|
||||
}
|
||||
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
|
||||
}
|
||||
|
||||
/// The public, unauthenticated endpoint carrying the handshake.
|
||||
fn config_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/config")
|
||||
}
|
||||
|
||||
fn device_login_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/auth/device-login")
|
||||
}
|
||||
|
||||
fn me_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/auth/me")
|
||||
}
|
||||
|
||||
/// `self` rather than a device id: see `revoke_self`.
|
||||
fn revoke_self_url(base_url: &str) -> String {
|
||||
format!("{base_url}/api/auth/devices/self")
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
// No specific duration here: these calls run under two different budgets
|
||||
// (interactive vs bulk sync), and naming the wrong one is worse than naming
|
||||
// none.
|
||||
format!(
|
||||
"{base_url} didn't respond in time. It may be offline, or unreachable \
|
||||
from this network."
|
||||
)
|
||||
} 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}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn urls_join_without_doubling_slashes() {
|
||||
// normalize_base_url has already stripped any trailing slash, so plain
|
||||
// concatenation is correct — this pins that assumption.
|
||||
assert_eq!(
|
||||
config_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/config"
|
||||
);
|
||||
assert_eq!(
|
||||
device_login_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/auth/device-login"
|
||||
);
|
||||
assert_eq!(
|
||||
me_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/auth/me"
|
||||
);
|
||||
assert_eq!(
|
||||
revoke_self_url("https://notes.example.com"),
|
||||
"https://notes.example.com/api/auth/devices/self"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_outcome_serializes_tagged_for_the_frontend() {
|
||||
// The UI decides between "signed out on the server" and "still valid, go
|
||||
// revoke it" by reading this tag, so its shape is part of the contract.
|
||||
let json = serde_json::to_string(&RevokeOutcome::Failed {
|
||||
reason: "offline".into(),
|
||||
})
|
||||
.expect("outcome serializes");
|
||||
assert!(json.contains("\"status\":\"failed\""), "got {json}");
|
||||
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
|
||||
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_preserve_a_port_and_subpath() {
|
||||
assert_eq!(
|
||||
config_url("http://192.168.1.10:8000/thoughtsync"),
|
||||
"http://192.168.1.10:8000/thoughtsync/api/config"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user