Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
806 lines
30 KiB
Rust
806 lines
30 KiB
Rust
//! Library sync with an mtgc sync server: signing in, and running the cycle.
|
|
//!
|
|
//! The reconciliation lives in [`mtgc_core::sync::remote`], as a cycle over a
|
|
//! [`Remote`](mtgc_core::sync::remote::Remote) that is tested without any
|
|
//! network. This module is the plumbing around it: where the merge base is
|
|
//! kept, holding the state lock while the working copy is rewritten (and only
|
|
//! then), and the platform-specific half of signing in.
|
|
//!
|
|
//! The library never depends on the server being reachable. The working copy
|
|
//! is the library on this device; every edit lands there, and a sync is just a
|
|
//! chance to exchange edits with the other devices. A failed sync loses
|
|
//! nothing and is retried on the next trigger.
|
|
//!
|
|
//! # Signing in
|
|
//!
|
|
//! The browser does the signing in, on the server's own pages, and hands back
|
|
//! a single-use code that this app redeems with its PKCE verifier. Getting the
|
|
//! code back differs by platform:
|
|
//!
|
|
//! - desktop listens on a one-shot loopback port (RFC 8252 §7.3);
|
|
//! - Android registers the `mtgc://` scheme, and the deep-link plugin delivers
|
|
//! the callback to [`deliver_callback`].
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use mtgc_core::sync::account::{self, AccountUser, DeviceInfo, Pkce};
|
|
use mtgc_core::sync::remote::{
|
|
first_sync_plan, sync_cycle, AccountBase, FirstSync, HttpRemote, SyncBase, UNAUTHORIZED,
|
|
};
|
|
use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport};
|
|
use serde::Serialize;
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
use crate::state::{AppState, SyncAccount};
|
|
|
|
/// How long to wait for the user to finish signing in in the browser.
|
|
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
|
|
|
fn app_state(app: &AppHandle) -> tauri::State<'_, Mutex<AppState>> {
|
|
app.state::<Mutex<AppState>>()
|
|
}
|
|
|
|
fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> {
|
|
// Every holder leaves the state consistent between statements, so a panic
|
|
// elsewhere is no reason to stop syncing.
|
|
app_state(app).inner().lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Run blocking work (network, SQLite) off the main thread. Tauri runs
|
|
/// non-async commands on the main thread, where a slow server would freeze
|
|
/// the whole UI.
|
|
async fn blocking<T: Send + 'static>(
|
|
f: impl FnOnce() -> Result<T, String> + Send + 'static,
|
|
) -> Result<T, String> {
|
|
tauri::async_runtime::spawn_blocking(f)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
}
|
|
|
|
// --- the merge base -----------------------------------------------------------
|
|
|
|
/// A new name rather than the folder-sync era's `sync-base.json`, whose
|
|
/// contents mean nothing to a server: an old file is simply never read.
|
|
fn base_path(data_dir: &Path) -> PathBuf {
|
|
data_dir.join("sync-server-base.json")
|
|
}
|
|
|
|
/// The stored base, if it belongs to this server and account. Missing,
|
|
/// unreadable, or someone else's all mean the same thing: no ancestor, which
|
|
/// makes the next sync a first sync.
|
|
fn read_base(data_dir: &Path, account: &SyncAccount) -> Option<SyncBase> {
|
|
let text = std::fs::read_to_string(base_path(data_dir)).ok()?;
|
|
serde_json::from_str::<AccountBase>(&text)
|
|
.ok()?
|
|
.for_account(&account.server_url, &account.user.id)
|
|
}
|
|
|
|
fn write_base(data_dir: &Path, account: &SyncAccount, base: &SyncBase) {
|
|
let stored = AccountBase {
|
|
server_url: account.server_url.clone(),
|
|
user_id: account.user.id.clone(),
|
|
base: base.clone(),
|
|
};
|
|
let path = base_path(data_dir);
|
|
let tmp = path.with_extension("json.tmp");
|
|
let result = serde_json::to_string(&stored)
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|t| std::fs::write(&tmp, t).map_err(|e| e.to_string()))
|
|
.and_then(|_| std::fs::rename(&tmp, &path).map_err(|e| e.to_string()));
|
|
if let Err(e) = result {
|
|
// Not fatal: without a base the next sync is a first sync, which can
|
|
// ask a question but never deletes anything.
|
|
crate::mlog!("sync: couldn't save the merge base: {e}");
|
|
}
|
|
}
|
|
|
|
/// The working copy as a document.
|
|
fn local_doc(working: &Path) -> Result<LibraryDoc, String> {
|
|
let conn = mtgc_core::db::open_library(working).map_err(|e| e.to_string())?;
|
|
core_sync::read_doc(&conn).map_err(|e| e.to_string())
|
|
}
|
|
|
|
// --- status -------------------------------------------------------------------
|
|
|
|
#[derive(Serialize, Default)]
|
|
pub struct SyncStatus {
|
|
pub signed_in: bool,
|
|
/// Kept after signing out, to offer signing back in.
|
|
pub server_url: Option<String>,
|
|
pub account: Option<AccountUser>,
|
|
pub last_synced_at: Option<i64>,
|
|
pub last_error: Option<String>,
|
|
/// The first sync is waiting on the user: see [`sync_connect`].
|
|
pub needs_choice: bool,
|
|
/// Edits made here that the server hasn't acknowledged yet.
|
|
pub pending_changes: bool,
|
|
/// A sign-in is waiting on the browser.
|
|
pub signing_in: bool,
|
|
/// A pre-document `library.db` waiting to be imported, if any.
|
|
pub legacy_library: Option<String>,
|
|
}
|
|
|
|
fn status(app: &AppHandle) -> SyncStatus {
|
|
let s = lock(app);
|
|
let account = s.sync_account().cloned();
|
|
let pending_changes = account
|
|
.as_ref()
|
|
.filter(|a| a.token.is_some())
|
|
.map(|a| match read_base(s.data_dir(), a) {
|
|
Some(base) => local_doc(&s.working_db_path).map(|d| d != base.doc).unwrap_or(false),
|
|
// Never synced with this account: anything here is unsent.
|
|
None => local_doc(&s.working_db_path).map(|d| !d.is_empty()).unwrap_or(false),
|
|
})
|
|
.unwrap_or(false);
|
|
SyncStatus {
|
|
signed_in: account.as_ref().is_some_and(|a| a.token.is_some()),
|
|
server_url: account.as_ref().map(|a| a.server_url.clone()),
|
|
last_synced_at: account.as_ref().and_then(|a| a.last_synced_at),
|
|
account: account.map(|a| a.user),
|
|
last_error: s.sync_error.clone(),
|
|
needs_choice: s.sync_needs_choice,
|
|
pending_changes,
|
|
signing_in: s.sign_in_cancel.is_some(),
|
|
legacy_library: s.legacy_library_path().map(|p| p.display().to_string()),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn sync_status(app: AppHandle) -> Result<SyncStatus, String> {
|
|
blocking(move || Ok(status(&app))).await
|
|
}
|
|
|
|
// --- signing in -------------------------------------------------------------------
|
|
|
|
/// A name for this device in the account's device list.
|
|
fn default_device_name() -> String {
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
"Android".to_string()
|
|
}
|
|
#[cfg(not(target_os = "android"))]
|
|
{
|
|
let host = std::env::var("COMPUTERNAME")
|
|
.ok()
|
|
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
|
|
.map(|h| h.trim().to_string())
|
|
.filter(|h| !h.is_empty());
|
|
match host {
|
|
Some(h) => h,
|
|
None => std::env::consts::OS.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sign in to a sync server through the browser, then sync.
|
|
///
|
|
/// Resolves once the user finishes (or abandons) signing in. Any first-sync
|
|
/// question is reported through [`SyncStatus::needs_choice`].
|
|
#[tauri::command]
|
|
pub async fn sync_sign_in(
|
|
app: AppHandle,
|
|
server_url: String,
|
|
device_name: Option<String>,
|
|
) -> Result<SyncStatus, String> {
|
|
let server_url = account::normalize_server_url(&server_url);
|
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
{
|
|
let mut s = lock(&app);
|
|
if s.sign_in_cancel.is_some() {
|
|
return Err("A sign-in is already in progress.".into());
|
|
}
|
|
s.sign_in_cancel = Some(cancel.clone());
|
|
}
|
|
|
|
let app2 = app.clone();
|
|
let result = blocking(move || {
|
|
let device_name = device_name
|
|
.map(|n| n.trim().to_string())
|
|
.filter(|n| !n.is_empty())
|
|
.unwrap_or_else(default_device_name);
|
|
sign_in_flow(&app2, &server_url, &device_name, &cancel)
|
|
})
|
|
.await;
|
|
lock(&app).sign_in_cancel = None;
|
|
|
|
let signed_in = crate::logged("sync_sign_in", result)?;
|
|
{
|
|
let mut s = lock(&app);
|
|
let previous = s.sync_account().cloned();
|
|
let switching = previous
|
|
.as_ref()
|
|
.is_some_and(|p| p.server_url != signed_in.0 || p.user.id != signed_in.1.user.id);
|
|
s.set_sync_account(Some(SyncAccount {
|
|
server_url: signed_in.0.clone(),
|
|
token: Some(signed_in.1.token),
|
|
user: signed_in.1.user,
|
|
device_id: signed_in.1.device_id,
|
|
last_synced_at: if switching { None } else { previous.and_then(|p| p.last_synced_at) },
|
|
}));
|
|
s.sync_error = None;
|
|
s.sync_needs_choice = false;
|
|
}
|
|
|
|
// Sync straight away so the library arrives (or the question is asked)
|
|
// without waiting for the next trigger. A failure here is recorded in the
|
|
// status rather than failing a sign-in that did succeed.
|
|
let _ = sync_now(app.clone()).await;
|
|
blocking(move || Ok(status(&app))).await
|
|
}
|
|
|
|
/// Abandon a sign-in that's waiting on the browser.
|
|
#[tauri::command]
|
|
pub fn sync_cancel_sign_in(app: AppHandle) {
|
|
if let Some(c) = lock(&app).sign_in_cancel.as_ref() {
|
|
c.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
/// Returns (server URL, the completed sign-in).
|
|
fn sign_in_flow(
|
|
app: &AppHandle,
|
|
server_url: &str,
|
|
device_name: &str,
|
|
cancel: &AtomicBool,
|
|
) -> Result<(String, account::SignedIn), String> {
|
|
let info = account::server_info(server_url).map_err(|e| e.to_string())?;
|
|
if !info.local_auth && info.oidc.is_none() {
|
|
return Err("That server has no way to sign in configured.".into());
|
|
}
|
|
|
|
let pkce = Pkce::new();
|
|
let state = account::random_token();
|
|
let callback = Callback::listen()?;
|
|
let url = account::start_url(server_url, &callback.redirect_uri(), &pkce, &state, device_name);
|
|
|
|
use tauri_plugin_opener::OpenerExt;
|
|
app.opener()
|
|
.open_url(&url, None::<&str>)
|
|
.map_err(|e| format!("Couldn't open the browser: {e}"))?;
|
|
|
|
let params = callback.wait(cancel, Instant::now() + SIGN_IN_TIMEOUT)?;
|
|
let get = |k: &str| params.iter().find(|(name, _)| name == k).map(|(_, v)| v.clone());
|
|
if get("state").as_deref() != Some(state.as_str()) {
|
|
return Err("The sign-in response didn't match this sign-in. Try again.".into());
|
|
}
|
|
if let Some(err) = get("error") {
|
|
return Err(match err.as_str() {
|
|
"access_denied" => "Sign-in was declined.".into(),
|
|
other => format!("Sign-in failed: {other}"),
|
|
});
|
|
}
|
|
let code = get("code").ok_or("The sign-in response had no code. Try again.")?;
|
|
let signed_in =
|
|
account::redeem_code(server_url, &code, &pkce.verifier).map_err(|e| e.to_string())?;
|
|
crate::mlog!(
|
|
"sync_sign_in: signed in to {server_url} as {} ({})",
|
|
signed_in.user.username,
|
|
signed_in.user.method
|
|
);
|
|
Ok((server_url.to_string(), signed_in))
|
|
}
|
|
|
|
type Params = Vec<(String, String)>;
|
|
|
|
/// Where the browser hands the sign-in back.
|
|
#[cfg(not(target_os = "android"))]
|
|
struct Callback {
|
|
listener: std::net::TcpListener,
|
|
}
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
impl Callback {
|
|
fn listen() -> Result<Self, String> {
|
|
// Port 0: any free port. The server accepts any loopback port, and a
|
|
// fixed one could already be taken.
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
|
.map_err(|e| format!("Couldn't open a port for the sign-in to return to: {e}"))?;
|
|
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
|
|
Ok(Self { listener })
|
|
}
|
|
|
|
fn redirect_uri(&self) -> String {
|
|
let port = self.listener.local_addr().map(|a| a.port()).unwrap_or(0);
|
|
format!("http://127.0.0.1:{port}/callback")
|
|
}
|
|
|
|
fn wait(self, cancel: &AtomicBool, deadline: Instant) -> Result<Params, String> {
|
|
loop {
|
|
if cancel.load(Ordering::SeqCst) {
|
|
return Err("Sign-in cancelled.".into());
|
|
}
|
|
if Instant::now() > deadline {
|
|
return Err("Sign-in timed out. Try again.".into());
|
|
}
|
|
match self.listener.accept() {
|
|
Ok((stream, _)) => {
|
|
// Browsers also ask for /favicon.ico and the like; answer
|
|
// and keep waiting for the real callback.
|
|
if let Some(params) = loopback::handle(stream) {
|
|
return Ok(params);
|
|
}
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
Err(e) => return Err(format!("Sign-in listener failed: {e}")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The desktop's one-request HTTP server, free-standing so it's testable.
|
|
#[cfg(not(target_os = "android"))]
|
|
mod loopback {
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::time::Duration;
|
|
|
|
use super::Params;
|
|
|
|
const DONE_PAGE: &str = "<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width'>\
|
|
<title>mtgc</title><body style='font:16px system-ui;display:grid;place-items:center;min-height:90vh'>\
|
|
<p>You're signed in. You can close this tab and return to mtgc.</p></body>";
|
|
|
|
/// Answer one connection. Returns the query parameters when it was the
|
|
/// sign-in callback.
|
|
pub fn handle(mut stream: TcpStream) -> Option<Params> {
|
|
let _ = stream.set_nonblocking(false);
|
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
|
|
let mut buf = Vec::new();
|
|
let mut chunk = [0u8; 2048];
|
|
// Only the request line matters; stop at the end of the headers.
|
|
while !buf.windows(4).any(|w| w == b"\r\n\r\n") && buf.len() < 16 * 1024 {
|
|
match stream.read(&mut chunk) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
|
}
|
|
}
|
|
let head = String::from_utf8_lossy(&buf);
|
|
let params = parse_request_line(head.lines().next().unwrap_or_default());
|
|
let (status, body) = match ¶ms {
|
|
Some(_) => ("200 OK", DONE_PAGE),
|
|
None => ("404 Not Found", ""),
|
|
};
|
|
let _ = write!(
|
|
stream,
|
|
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\
|
|
Cache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
params
|
|
}
|
|
|
|
/// `GET /callback?code=…&state=… HTTP/1.1` → the query parameters.
|
|
pub fn parse_request_line(line: &str) -> Option<Params> {
|
|
let mut parts = line.split_whitespace();
|
|
if parts.next()? != "GET" {
|
|
return None;
|
|
}
|
|
let target = parts.next()?;
|
|
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
|
if path != "/callback" {
|
|
return None;
|
|
}
|
|
Some(url::form_urlencoded::parse(query.as_bytes()).into_owned().collect())
|
|
}
|
|
}
|
|
|
|
/// Android: the server sends the browser to `mtgc://auth/callback`, which the
|
|
/// deep-link plugin hands to [`deliver_callback`].
|
|
#[cfg(target_os = "android")]
|
|
struct Callback {
|
|
rx: std::sync::mpsc::Receiver<String>,
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
static WAITING: Mutex<Option<std::sync::mpsc::Sender<String>>> = Mutex::new(None);
|
|
|
|
/// Called with every `mtgc://` URL the app is opened with.
|
|
#[cfg(target_os = "android")]
|
|
pub fn deliver_callback(url: &str) {
|
|
if !url.starts_with("mtgc://auth/callback") {
|
|
return;
|
|
}
|
|
match WAITING.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
|
Some(tx) => {
|
|
let _ = tx.send(url.to_string());
|
|
}
|
|
// The app was restarted while the browser was open, so the verifier
|
|
// this callback needs is gone. The user just signs in again.
|
|
None => crate::mlog!("sync: ignoring a sign-in callback nothing is waiting for"),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
impl Callback {
|
|
fn listen() -> Result<Self, String> {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
*WAITING.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
|
Ok(Self { rx })
|
|
}
|
|
|
|
fn redirect_uri(&self) -> String {
|
|
"mtgc://auth/callback".into()
|
|
}
|
|
|
|
fn wait(self, cancel: &AtomicBool, deadline: Instant) -> Result<Params, String> {
|
|
let result = loop {
|
|
if cancel.load(Ordering::SeqCst) {
|
|
break Err("Sign-in cancelled.".into());
|
|
}
|
|
if Instant::now() > deadline {
|
|
break Err("Sign-in timed out. Try again.".into());
|
|
}
|
|
match self.rx.recv_timeout(Duration::from_millis(250)) {
|
|
Ok(url) => {
|
|
let query = url.split_once('?').map(|(_, q)| q).unwrap_or_default();
|
|
break Ok(url::form_urlencoded::parse(query.as_bytes()).into_owned().collect());
|
|
}
|
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
|
Err(e) => break Err(e.to_string()),
|
|
}
|
|
};
|
|
*WAITING.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
result
|
|
}
|
|
}
|
|
|
|
/// Sign this device out. The library stays here, and keeps working offline.
|
|
#[tauri::command]
|
|
pub async fn sync_sign_out(app: AppHandle) -> Result<SyncStatus, String> {
|
|
blocking(move || {
|
|
let account = lock(&app).sync_account().cloned();
|
|
if let Some(a) = account {
|
|
if let Some(token) = &a.token {
|
|
// Best effort: offline, the token stays valid on the server
|
|
// until revoked from another device, but this one forgets it.
|
|
if let Err(e) = HttpRemote::new(&a.server_url, token).logout() {
|
|
crate::mlog!("sync_sign_out: server logout failed: {e}");
|
|
}
|
|
}
|
|
}
|
|
let mut s = lock(&app);
|
|
s.update_sync_account(|a| a.token = None);
|
|
s.sync_error = None;
|
|
s.sync_needs_choice = false;
|
|
drop(s);
|
|
Ok(status(&app))
|
|
})
|
|
.await
|
|
}
|
|
|
|
// --- syncing --------------------------------------------------------------------
|
|
|
|
/// Outcome of one sync.
|
|
#[derive(Serialize, Default)]
|
|
pub struct SyncReport {
|
|
/// Whether this device's library changed.
|
|
pub changed: bool,
|
|
pub published: bool,
|
|
/// The first sync is waiting on the user: see [`sync_connect`].
|
|
pub needs_choice: bool,
|
|
pub report: MergeReport,
|
|
}
|
|
|
|
/// Reconcile the working copy with the server.
|
|
///
|
|
/// `Ok(None)` when there is nothing to do — not signed in — or a sync was
|
|
/// already in flight and this call stood down. Sync runs automatically on
|
|
/// launch, on reconnecting, and on every foreground/background transition, so
|
|
/// overlap is routine rather than exceptional.
|
|
#[tauri::command]
|
|
pub async fn sync_now(app: AppHandle) -> Result<Option<SyncReport>, String> {
|
|
run_sync(app, None).await
|
|
}
|
|
|
|
/// Answer the first-sync question: combine this device's library with the
|
|
/// server's, or replace it with the server's.
|
|
#[tauri::command]
|
|
pub async fn sync_connect(app: AppHandle, choice: FirstSync) -> Result<Option<SyncReport>, String> {
|
|
run_sync(app, Some(choice)).await
|
|
}
|
|
|
|
async fn run_sync(app: AppHandle, choice: Option<FirstSync>) -> Result<Option<SyncReport>, String> {
|
|
{
|
|
let mut s = lock(&app);
|
|
if s.sync_running || s.sync_account().and_then(|a| a.token.as_ref()).is_none() {
|
|
return Ok(None);
|
|
}
|
|
s.sync_running = true;
|
|
}
|
|
let app2 = app.clone();
|
|
let result = blocking(move || sync_inner(&app2, choice)).await;
|
|
|
|
let mut s = lock(&app);
|
|
s.sync_running = false;
|
|
match &result {
|
|
Ok(r) => {
|
|
s.sync_error = None;
|
|
s.sync_needs_choice = r.needs_choice;
|
|
if !r.needs_choice {
|
|
s.update_sync_account(|a| a.last_synced_at = Some(unix_now()));
|
|
}
|
|
}
|
|
Err(e) if e == UNAUTHORIZED => {
|
|
// Revoked from another device, or the account was removed.
|
|
// Retrying can't help, so stop trying until the user signs in.
|
|
s.update_sync_account(|a| a.token = None);
|
|
s.sync_error = Some(e.clone());
|
|
}
|
|
Err(e) => s.sync_error = Some(e.clone()),
|
|
}
|
|
drop(s);
|
|
crate::logged("sync_now", result).map(Some)
|
|
}
|
|
|
|
fn sync_inner(app: &AppHandle, choice: Option<FirstSync>) -> Result<SyncReport, String> {
|
|
let (account, data_dir, working) = {
|
|
let s = lock(app);
|
|
let account = s.sync_account().cloned().ok_or("Not signed in.")?;
|
|
(account, s.data_dir().to_path_buf(), s.working_db_path.clone())
|
|
};
|
|
let token = account.token.as_deref().ok_or("Not signed in.")?;
|
|
let remote = HttpRemote::new(&account.server_url, token);
|
|
|
|
let mut base = match read_base(&data_dir, &account) {
|
|
Some(base) => base,
|
|
None => {
|
|
// Never synced with this account's library from here, so there is
|
|
// no ancestor to tell edits from deletions with.
|
|
use mtgc_core::sync::remote::Remote;
|
|
let theirs = remote.get().map_err(|e| e.to_string())?;
|
|
let ours = {
|
|
let _s = lock(app);
|
|
local_doc(&working)?
|
|
};
|
|
match first_sync_plan(&ours, &theirs.doc, choice) {
|
|
None => return Ok(SyncReport { needs_choice: true, ..Default::default() }),
|
|
// An empty ancestor: nothing on either side reads as deleted.
|
|
Some(FirstSync::Combine) => SyncBase::default(),
|
|
Some(FirstSync::UseServer) => {
|
|
let _s = lock(app);
|
|
let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?;
|
|
core_sync::apply_doc(&conn, &theirs.doc).map_err(|e| e.to_string())?;
|
|
SyncBase { version: theirs.version, doc: theirs.doc }
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// The state lock is held only while the working copy is read, merged and
|
|
// rewritten — every edit command takes the same lock, so none can land in
|
|
// between and be overwritten. The network calls happen without it.
|
|
let result = sync_cycle(&remote, &mut base, |base_doc, theirs| {
|
|
let _s = lock(app);
|
|
let conn = mtgc_core::db::open_library(&working)?;
|
|
core_sync::merge_into(&conn, base_doc, theirs)
|
|
});
|
|
// Saved whether or not the cycle finished: it only ever records what is
|
|
// known to be on the server, so a partial run's base is still sound.
|
|
write_base(&data_dir, &account, &base);
|
|
let cycle = result.map_err(|e| e.to_string())?;
|
|
|
|
crate::mlog!(
|
|
"sync_now: changed={} published={} version={} conflicts={}",
|
|
!cycle.report.is_empty(),
|
|
cycle.published,
|
|
cycle.version,
|
|
cycle.report.conflicts.len()
|
|
);
|
|
Ok(SyncReport {
|
|
changed: !cycle.report.is_empty(),
|
|
published: cycle.published,
|
|
needs_choice: false,
|
|
report: cycle.report,
|
|
})
|
|
}
|
|
|
|
fn unix_now() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
// --- devices ----------------------------------------------------------------------
|
|
|
|
fn signed_in_remote(app: &AppHandle) -> Result<HttpRemote, String> {
|
|
let s = lock(app);
|
|
let a = s.sync_account().ok_or("Not signed in.")?;
|
|
let token = a.token.as_deref().ok_or("Not signed in.")?;
|
|
Ok(HttpRemote::new(&a.server_url, token))
|
|
}
|
|
|
|
/// The account's signed-in devices.
|
|
#[tauri::command]
|
|
pub async fn sync_devices(app: AppHandle) -> Result<Vec<DeviceInfo>, String> {
|
|
blocking(move || {
|
|
let me = signed_in_remote(&app)?.me().map_err(|e| e.to_string())?;
|
|
Ok(me.devices)
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Sign out another of the account's devices — a lost phone, say.
|
|
#[tauri::command]
|
|
pub async fn sync_revoke_device(app: AppHandle, id: i64) -> Result<Vec<DeviceInfo>, String> {
|
|
blocking(move || {
|
|
let remote = signed_in_remote(&app)?;
|
|
remote.revoke_device(id).map_err(|e| e.to_string())?;
|
|
Ok(remote.me().map_err(|e| e.to_string())?.devices)
|
|
})
|
|
.await
|
|
}
|
|
|
|
// --- import and export ----------------------------------------------------------
|
|
|
|
/// What an import added.
|
|
#[derive(Serialize)]
|
|
pub struct ImportReport {
|
|
pub cards: usize,
|
|
pub decks: usize,
|
|
pub report: MergeReport,
|
|
}
|
|
|
|
/// Merge a library file into this device's library: a JSON library document
|
|
/// (a backup, or the file a folder-synced build kept), or a `library.db` from
|
|
/// before documents existed. Nothing already here is lost — it's merged with
|
|
/// an empty ancestor — and the next sync shares the result.
|
|
#[tauri::command]
|
|
pub async fn import_library_file(app: AppHandle, path: String) -> Result<ImportReport, String> {
|
|
let app2 = app.clone();
|
|
let report = blocking(move || {
|
|
let file = PathBuf::from(&path);
|
|
if !file.is_file() {
|
|
return Err(format!("{} isn't there.", file.display()));
|
|
}
|
|
let is_json = file.extension().is_some_and(|e| e.eq_ignore_ascii_case("json"));
|
|
let doc = if is_json {
|
|
let text = std::fs::read_to_string(&file).map_err(|e| e.to_string())?;
|
|
LibraryDoc::from_json(&text).map_err(|e| format!("Not a library file: {e}"))?
|
|
} else {
|
|
core_sync::convert_legacy_library(&file).map_err(|e| e.to_string())?
|
|
};
|
|
|
|
let mut s = lock(&app2);
|
|
let conn = mtgc_core::db::open_library(&s.working_db_path).map_err(|e| e.to_string())?;
|
|
let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?;
|
|
let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc);
|
|
core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?;
|
|
if s.legacy_library_path() == Some(file.as_path()) {
|
|
s.clear_legacy_library();
|
|
}
|
|
crate::mlog!(
|
|
"import_library_file: {} cards, {} decks from {}",
|
|
doc.cards.len(),
|
|
doc.decks.len(),
|
|
file.display()
|
|
);
|
|
Ok(ImportReport { cards: doc.cards.len(), decks: doc.decks.len(), report })
|
|
})
|
|
.await;
|
|
let report = crate::logged("import_library_file", report)?;
|
|
// Share it now rather than at the next trigger.
|
|
let _ = sync_now(app).await;
|
|
Ok(report)
|
|
}
|
|
|
|
/// Save the whole library as a JSON document — a backup that
|
|
/// [`import_library_file`] reads back.
|
|
#[tauri::command]
|
|
pub async fn export_library_document(app: AppHandle, path: String) -> Result<(), String> {
|
|
blocking(move || {
|
|
let doc = {
|
|
let s = lock(&app);
|
|
local_doc(&s.working_db_path)?
|
|
};
|
|
let text = doc.to_json().map_err(|e| e.to_string())?;
|
|
std::fs::write(&path, text).map_err(|e| format!("Couldn't save {path}: {e}"))
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn account(server: &str, user: &str) -> SyncAccount {
|
|
SyncAccount {
|
|
server_url: server.into(),
|
|
token: Some("t".into()),
|
|
user: AccountUser {
|
|
id: user.into(),
|
|
username: user.into(),
|
|
display_name: String::new(),
|
|
email: String::new(),
|
|
method: "local".into(),
|
|
},
|
|
device_id: 1,
|
|
last_synced_at: None,
|
|
}
|
|
}
|
|
|
|
fn scratch(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("mtgc-sync-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn a_base_is_only_used_with_the_account_it_came_from() {
|
|
let dir = scratch("base");
|
|
let mine = account("https://a.example", "u1");
|
|
let base = SyncBase { version: 4, ..Default::default() };
|
|
write_base(&dir, &mine, &base);
|
|
|
|
assert_eq!(read_base(&dir, &mine), Some(base));
|
|
assert_eq!(read_base(&dir, &account("https://a.example", "u2")), None);
|
|
assert_eq!(read_base(&dir, &account("https://b.example", "u1")), None);
|
|
assert!(!dir.join("sync-server-base.json.tmp").exists());
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_folder_sync_era_base_is_never_read() {
|
|
// That build's file held a bare document under a different name.
|
|
let dir = scratch("old-base");
|
|
std::fs::write(dir.join("sync-base.json"), r#"{"version":1,"cards":{},"decks":{}}"#).unwrap();
|
|
assert_eq!(read_base(&dir, &account("https://a.example", "u1")), None);
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
#[test]
|
|
fn the_loopback_listener_takes_only_the_callback() {
|
|
assert_eq!(
|
|
loopback::parse_request_line("GET /callback?code=abc%2B1&state=xyz HTTP/1.1"),
|
|
Some(vec![("code".into(), "abc+1".into()), ("state".into(), "xyz".into())])
|
|
);
|
|
assert_eq!(loopback::parse_request_line("GET /favicon.ico HTTP/1.1"), None);
|
|
assert_eq!(loopback::parse_request_line("POST /callback?code=a HTTP/1.1"), None);
|
|
assert_eq!(loopback::parse_request_line(""), None);
|
|
}
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
#[test]
|
|
fn the_loopback_listener_answers_a_real_browser_request() {
|
|
use std::io::{Read, Write};
|
|
let cb = Callback::listen().unwrap();
|
|
let addr = cb.redirect_uri().replace("http://", "").replace("/callback", "");
|
|
let cancel = AtomicBool::new(false);
|
|
let browser = std::thread::spawn(move || {
|
|
// A stray request first, as browsers make.
|
|
let mut s = std::net::TcpStream::connect(&addr).unwrap();
|
|
s.write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
|
let mut out = String::new();
|
|
s.read_to_string(&mut out).unwrap();
|
|
assert!(out.starts_with("HTTP/1.1 404"));
|
|
|
|
let mut s = std::net::TcpStream::connect(&addr).unwrap();
|
|
s.write_all(b"GET /callback?code=c&state=s HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
|
let mut out = String::new();
|
|
s.read_to_string(&mut out).unwrap();
|
|
assert!(out.starts_with("HTTP/1.1 200"));
|
|
assert!(out.contains("signed in"));
|
|
});
|
|
|
|
let params = cb.wait(&cancel, Instant::now() + Duration::from_secs(10)).unwrap();
|
|
browser.join().unwrap();
|
|
assert!(params.contains(&("code".into(), "c".into())));
|
|
}
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
#[test]
|
|
fn a_sign_in_can_be_abandoned() {
|
|
let cb = Callback::listen().unwrap();
|
|
let cancel = AtomicBool::new(true);
|
|
let err = cb.wait(&cancel, Instant::now() + Duration::from_secs(10)).unwrap_err();
|
|
assert!(err.contains("cancelled"));
|
|
}
|
|
}
|