Files
mtgc/src-tauri/src/sync.rs
Garrett Mills de55fc634a Replace folder sync with an HTTP sync server, keeping the app offline-first
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.
2026-10-08 22:32:08 -05:00

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 &params {
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"));
}
}