CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 56s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m23s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m22s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m13s
Android / Build the server image (push) Successful in 1s
Family idea #5105, practice 13, as the operator chose on 2026-10-08. The check lives in the shared core, so the desktop and Android both get it. compat::cleartext_allowed decides from the address text alone, with no DNS lookup. It allows https:// always. It allows http:// to private, loopback, link-local and CGNAT IPs (CGNAT covers Tailscale), to fc00::/7, fe80::/10 and ::1, to single-label names, and to LAN suffixes (.local, .lan, .home.arpa, .ts.net and others). The refusal runs in two places: - probe, so linking stops before a password or token is sent; - the top of run_cycle, so a device linked before this change stops syncing with a message telling it to re-link, instead of sending its token on every cycle. The server is unchanged and never forces HTTPS (rule 94). Plain http:// on a LAN links and syncs as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.8 KiB
Rust
112 lines
4.8 KiB
Rust
//! The sync cycle (M10.7c).
|
|
//!
|
|
//! Deliberately the ONLY way the UI can sync. Push and pull are each usable on their
|
|
//! own inside this crate, but exposing them separately would let a caller pull
|
|
//! without pushing, which quietly overwrites unsent local edits.
|
|
|
|
use chrono::{SecondsFormat, Utc};
|
|
use serde::Serialize;
|
|
|
|
use super::blobs::BlobStore;
|
|
use super::pull;
|
|
use super::push;
|
|
use super::state;
|
|
use crate::local::Db;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SyncOutcome {
|
|
pub push: push::PushSummary,
|
|
pub pull: pull::PullSummary,
|
|
/// The state after the cycle, so the UI updates from one round-trip instead of
|
|
/// following every sync with a status call.
|
|
pub status: state::Status,
|
|
}
|
|
|
|
/// Push, then pull — in that order, always.
|
|
///
|
|
/// Pull writes the server's version straight over the local row, so anything not yet
|
|
/// sent would be lost to it. Pushing first is what puts the local edit in front of
|
|
/// the server's last-write-wins comparison, and it's the reason
|
|
/// `PullSummary::clobbered_dirty` should be zero on every healthy cycle.
|
|
///
|
|
/// A failed push aborts before the pull. Pulling anyway would take the exact rows we
|
|
/// just failed to save and overwrite them — turning a recoverable network error into
|
|
/// lost work.
|
|
pub async fn run_cycle(
|
|
db: &Db,
|
|
blobs: &BlobStore,
|
|
base_url: &str,
|
|
token: &str,
|
|
) -> Result<SyncOutcome, String> {
|
|
// A device linked to a public plain-http:// address before links were checked
|
|
// stops here, rather than sending its token in the clear on every cycle. Its
|
|
// sync error says to re-link over https:// (family idea #5105, practice 13).
|
|
super::compat::refuse_public_cleartext(base_url)?;
|
|
|
|
// What this server can do, asked before anything is sent: files attached here,
|
|
// and removed attachments and previews, go only to a server advertising
|
|
// `attachment_sync`. An older one keeps them queued on this device until it is
|
|
// updated, rather than refusing each one on every cycle. Best-effort — an
|
|
// unanswered probe reads as "not advertised", and the cycle carries on.
|
|
let server = super::client::probe(base_url).await.ok().map(|p| p.server);
|
|
let attachment_sync = server
|
|
.as_ref()
|
|
.is_some_and(|s| s.has_feature("attachment_sync"));
|
|
// Notes shared with this account come only from a server offering `shares`, and
|
|
// an unanswered probe reads as "not offered", like `attachment_sync` above.
|
|
let shares = server.as_ref().is_some_and(|s| s.has_feature("shares"));
|
|
// A recipient's own pin, archive and order on a shared note go only to a server
|
|
// that keeps them per person; an older one would apply them to nobody.
|
|
let accepts = push::Accepts {
|
|
attachment_sync,
|
|
shared_state: server
|
|
.as_ref()
|
|
.is_some_and(|s| s.has_feature("shared_state")),
|
|
};
|
|
|
|
let push = push::run(db, blobs, base_url, token, accepts).await?;
|
|
if shares {
|
|
// After the push, so nothing unsent is waiting when the full pull lands.
|
|
let level = if accepts.shared_state {
|
|
state::SHARED_STATE
|
|
} else {
|
|
state::SHARES
|
|
};
|
|
let conn = db.conn()?;
|
|
if state::begin_shares(&conn, level).map_err(|e| e.to_string())? {
|
|
log::info!("the server shares more of notes now; pulling everything once");
|
|
}
|
|
}
|
|
let pull = pull::run(db, blobs, base_url, token, shares).await?;
|
|
|
|
if pull.clobbered_dirty > 0 {
|
|
// Push ran first and reported success, so nothing should still have been
|
|
// dirty. Reaching here means something wrote to the store mid-cycle, or a
|
|
// change never got collected — worth a loud line either way.
|
|
log::warn!(
|
|
"sync cycle overwrote {} locally-edited note(s) despite pushing first",
|
|
pull.clobbered_dirty
|
|
);
|
|
}
|
|
|
|
// The same answer carries the trash-retention window the Trash view counts down
|
|
// against, which can change whenever an admin edits the setting. Best-effort on
|
|
// purpose: a config blip must not fail a cycle whose actual work succeeded, and
|
|
// the stored value simply stays as it was.
|
|
let retention = server.and_then(|s| s.trash_retention_days);
|
|
|
|
let status = {
|
|
let conn = db.conn()?;
|
|
if let Some(days) = retention {
|
|
state::set_server_retention(&conn, days as i64).map_err(|e| e.to_string())?;
|
|
}
|
|
// Stamped only here, after BOTH halves succeeded. A timestamp written after a
|
|
// partial cycle would tell the user they're up to date when they aren't.
|
|
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
|
state::mark_synced(&conn, &now).map_err(|e| e.to_string())?;
|
|
state::status(&conn).map_err(|e| e.to_string())?
|
|
};
|
|
|
|
Ok(SyncOutcome { push, pull, status })
|
|
}
|