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.
This commit is contained in:
@@ -20,7 +20,8 @@ type Res<T> = Result<T, String>;
|
||||
/// High-level status for the top bar / first-run gating.
|
||||
#[derive(Serialize)]
|
||||
pub struct Status {
|
||||
pub library_path: String,
|
||||
/// Where the library syncs: the account and server, or this device only.
|
||||
pub library_label: String,
|
||||
pub reference_path: String,
|
||||
pub reference_ready: bool,
|
||||
pub card_count: usize,
|
||||
@@ -28,13 +29,27 @@ pub struct Status {
|
||||
/// when "Update card data" last completed. `None` if unavailable.
|
||||
pub reference_updated_at: Option<i64>,
|
||||
/// Whether this is a mobile build. The UI uses it to hide affordances that
|
||||
/// only make sense with an arbitrary-path filesystem (choosing where
|
||||
/// library.db lives) — see `set_library_path`. Reported from the build
|
||||
/// only make sense with an arbitrary-path filesystem. Reported from the build
|
||||
/// target rather than sniffed from the user agent so it can't disagree with
|
||||
/// what the backend will actually allow.
|
||||
pub is_mobile: bool,
|
||||
}
|
||||
|
||||
/// Where the library syncs, for the top bar — never the internal working
|
||||
/// database, which is an implementation detail the user never sees.
|
||||
fn library_label(s: &AppState) -> String {
|
||||
match s.sync_account() {
|
||||
Some(a) if a.token.is_some() => {
|
||||
let host = url::Url::parse(&a.server_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(str::to_string))
|
||||
.unwrap_or_else(|| a.server_url.clone());
|
||||
format!("{} @ {host}", a.user.username)
|
||||
}
|
||||
_ => "This device only (not syncing)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_of(s: &AppState) -> Status {
|
||||
let reference_updated_at = std::fs::metadata(&s.reference_path)
|
||||
.and_then(|m| m.modified())
|
||||
@@ -42,11 +57,7 @@ fn status_of(s: &AppState) -> Status {
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64);
|
||||
Status {
|
||||
// Where the library document lives, not the internal working database —
|
||||
// that one is an implementation detail the user never sees. Always
|
||||
// populated: with nothing chosen it's the default in private storage,
|
||||
// so the path bar never goes blank.
|
||||
library_path: s.effective_document_path().display().to_string(),
|
||||
library_label: library_label(s),
|
||||
reference_path: s.reference_path.display().to_string(),
|
||||
reference_ready: s.catalog.is_some(),
|
||||
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),
|
||||
|
||||
@@ -33,26 +33,14 @@ pub(crate) fn logged<T>(what: &str, result: Result<T, String>) -> Result<T, Stri
|
||||
result
|
||||
}
|
||||
|
||||
/// Register the Kotlin-side `SyncPlugin` and stash its handle in app state, so
|
||||
/// the sync commands can call across to SAF. See `sync.rs` for why library sync
|
||||
/// goes through a Java plugin rather than plain `std::fs`.
|
||||
#[cfg(target_os = "android")]
|
||||
fn sync_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
tauri::plugin::Builder::new("mtgc-sync")
|
||||
.setup(|app, api| {
|
||||
let handle = api.register_android_plugin("com.garrettmills.mtgc", "SyncPlugin")?;
|
||||
app.manage(sync::SyncBridge(handle));
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init());
|
||||
// Android gets sync sign-ins back through the `mtgc://` scheme; desktop
|
||||
// uses a loopback port instead and needs no scheme registered.
|
||||
#[cfg(target_os = "android")]
|
||||
let builder = builder.plugin(sync_plugin());
|
||||
let builder = builder.plugin(tauri_plugin_deep_link::init());
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
@@ -63,6 +51,15 @@ pub fn run() {
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
mlog!("data dir: {}", data_dir.display());
|
||||
app.manage(Mutex::new(AppState::new(data_dir)));
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
app.deep_link().on_open_url(|event| {
|
||||
for url in event.urls() {
|
||||
sync::deliver_callback(url.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -71,13 +68,15 @@ pub fn run() {
|
||||
commands::cancel_update_card_data,
|
||||
commands::import_reference_db,
|
||||
sync::sync_status,
|
||||
sync::sync_pick_folder,
|
||||
sync::sync_set_document,
|
||||
sync::sync_list_documents,
|
||||
sync::sync_set_document_name,
|
||||
sync::sync_forget_document,
|
||||
sync::sync_sign_in,
|
||||
sync::sync_cancel_sign_in,
|
||||
sync::sync_sign_out,
|
||||
sync::sync_now,
|
||||
sync::convert_legacy_library,
|
||||
sync::sync_connect,
|
||||
sync::sync_devices,
|
||||
sync::sync_revoke_device,
|
||||
sync::import_library_file,
|
||||
sync::export_library_document,
|
||||
news::get_news,
|
||||
news::get_set_cards,
|
||||
commands::search_cards,
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths.
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths and
|
||||
//! the sync account.
|
||||
//!
|
||||
//! The user's library is a JSON *document* in a folder they sync. The SQLite
|
||||
//! database behind it is an internal working copy under the app data dir that
|
||||
//! they never see, name, or move — it can always be rebuilt by merging the
|
||||
//! document into an empty one (see [`mtgc_core::sync`]).
|
||||
//! The user's library lives in an internal SQLite working copy under the app
|
||||
//! data dir, which works fully offline. When the device is signed in to a sync
|
||||
//! server, that copy is reconciled with the server's whenever it can be reached
|
||||
//! (see `sync.rs` and [`mtgc_core::sync::remote`]).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use mtgc_core::sync::account::AccountUser;
|
||||
use mtgc_core::{Catalog, NameIndex};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Small app-shell preferences persisted across restarts, distinct from the
|
||||
/// user's library data.
|
||||
///
|
||||
/// Fields from older builds (`document_path`, `document_name`, from when the
|
||||
/// library was a file in a synced folder) are simply ignored when read.
|
||||
#[derive(Default, Serialize, Deserialize, Clone)]
|
||||
struct PersistedConfig {
|
||||
/// The library document the user chose. Desktop only — Android reaches its
|
||||
/// folder through a SAF grant held on the Java side rather than by path,
|
||||
/// so there is no path to persist there (see `sync.rs`).
|
||||
#[serde(default)]
|
||||
document_path: Option<PathBuf>,
|
||||
/// A pre-document `library.db` inherited from an older build, kept until
|
||||
/// it has been converted so the app can offer to do that once.
|
||||
#[serde(default)]
|
||||
legacy_library_path: Option<PathBuf>,
|
||||
/// Which file inside the granted folder holds the library. Android only:
|
||||
/// there the user grants a *folder* (SAF has no path to hand back), so the
|
||||
/// file within it has to be chosen separately. Defaults to "library.json"
|
||||
/// for a fresh setup, but must be settable — a library named anything else
|
||||
/// would otherwise never be found, and the phone would quietly create an
|
||||
/// empty second one beside it.
|
||||
/// The sync server this device is (or was last) signed in to.
|
||||
#[serde(default)]
|
||||
document_name: Option<String>,
|
||||
sync: Option<SyncAccount>,
|
||||
}
|
||||
|
||||
/// This device's sync server account.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct SyncAccount {
|
||||
pub server_url: String,
|
||||
/// This device's token. `None` once signed out, or once the server stopped
|
||||
/// accepting it; the rest is kept so signing back in can be offered with
|
||||
/// the address already filled in.
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
pub user: AccountUser,
|
||||
#[serde(default)]
|
||||
pub device_id: i64,
|
||||
/// Unix seconds of the last sync that completed.
|
||||
#[serde(default)]
|
||||
pub last_synced_at: Option<i64>,
|
||||
}
|
||||
|
||||
fn config_path(data_dir: &Path) -> PathBuf {
|
||||
@@ -42,11 +56,7 @@ fn load_config(data_dir: &Path) -> PersistedConfig {
|
||||
return PersistedConfig::default();
|
||||
};
|
||||
// Every field defaults, so an older or partial file parses fine and simply
|
||||
// leaves the unknown settings unset. Deliberately *not* keyed on which
|
||||
// fields happen to be populated: an Android install has neither a
|
||||
// `document_path` (its folder is a SAF grant) nor a legacy library, so a
|
||||
// "does this look empty?" test threw away its `document_name` on every
|
||||
// launch and silently reverted the chosen library file.
|
||||
// leaves the unknown settings unset.
|
||||
let mut cfg: PersistedConfig = serde_json::from_str(&text).unwrap_or_default();
|
||||
|
||||
// Builds before the document format stored `library_path` pointing at a
|
||||
@@ -66,19 +76,43 @@ fn load_config(data_dir: &Path) -> PersistedConfig {
|
||||
}
|
||||
|
||||
fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
|
||||
if let Ok(s) = serde_json::to_string_pretty(cfg) {
|
||||
let _ = std::fs::write(config_path(data_dir), s);
|
||||
let Ok(text) = serde_json::to_string_pretty(cfg) else {
|
||||
return;
|
||||
};
|
||||
// Written beside and renamed into place, so a crash mid-write can't leave
|
||||
// a truncated config that silently signs the device out.
|
||||
let path = config_path(data_dir);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Err(e) = write_private(&tmp, &text).and_then(|_| std::fs::rename(&tmp, &path)) {
|
||||
crate::mlog!("save_config: {e}");
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a file only this user can read: the config holds the device's sync
|
||||
/// token. (App data is already private on Android; this covers desktop.)
|
||||
fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(path)?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
|
||||
pub struct AppState {
|
||||
data_dir: PathBuf,
|
||||
/// Held whole so that writing one preference can't drop the others — the
|
||||
/// setters below persist this rather than reconstructing a fresh config.
|
||||
config: PersistedConfig,
|
||||
/// Internal SQLite working copy. Derived from the document; never chosen
|
||||
/// by, shown to, or moved by the user.
|
||||
/// Internal SQLite working copy: the library itself, on this device. Never
|
||||
/// chosen by, shown to, or moved by the user.
|
||||
pub working_db_path: PathBuf,
|
||||
pub reference_path: PathBuf,
|
||||
pub catalog: Option<Catalog>,
|
||||
@@ -86,10 +120,20 @@ pub struct AppState {
|
||||
/// Set while `update_card_data` is running; `cancel_update_card_data`
|
||||
/// flips it to abort the in-progress ingest.
|
||||
pub ingest_cancel: Option<mtgc_core::ingest::CancelToken>,
|
||||
/// Set while a sync is in flight. Sync fires automatically on launch and on
|
||||
/// every foreground/background transition, so overlapping calls are easy to
|
||||
/// provoke; a second one would merge the same document concurrently.
|
||||
/// Set while a sync is in flight. Sync fires automatically on launch, on
|
||||
/// every foreground/background transition, and on reconnecting, so
|
||||
/// overlapping calls are easy to provoke; a second one would only repeat
|
||||
/// the first one's work.
|
||||
pub sync_running: bool,
|
||||
/// Why the last sync failed, until one succeeds. Not persisted: after a
|
||||
/// restart the next sync says soon enough.
|
||||
pub sync_error: Option<String>,
|
||||
/// The first sync with this account found a library both here and on the
|
||||
/// server, and only the user can say whether to combine them or take the
|
||||
/// server's. Syncing waits until they do.
|
||||
pub sync_needs_choice: bool,
|
||||
/// Set while a sign-in waits on the browser; flipping it abandons the wait.
|
||||
pub sign_in_cancel: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -104,10 +148,13 @@ impl AppState {
|
||||
index: None,
|
||||
ingest_cancel: None,
|
||||
sync_running: false,
|
||||
sync_error: None,
|
||||
sync_needs_choice: false,
|
||||
sign_in_cancel: None,
|
||||
};
|
||||
crate::mlog!(
|
||||
"AppState::new: document={:?} working={} reference={} (exists={})",
|
||||
s.config.document_path,
|
||||
"AppState::new: sync={:?} working={} reference={} (exists={})",
|
||||
s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())),
|
||||
s.working_db_path.display(),
|
||||
s.reference_path.display(),
|
||||
s.reference_path.exists()
|
||||
@@ -123,48 +170,26 @@ impl AppState {
|
||||
s
|
||||
}
|
||||
|
||||
/// The library document the user explicitly chose, if they have.
|
||||
pub fn document_path(&self) -> Option<&Path> {
|
||||
self.config.document_path.as_deref()
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
/// Where the library document actually lives right now.
|
||||
///
|
||||
/// Falls back to app-private storage so there is *always* a document. The
|
||||
/// alternative — no document until the user picks one — meant edits piled
|
||||
/// up in the working copy with nowhere to be saved, and choosing a
|
||||
/// destination later would discard them. Picking a location is a
|
||||
/// preference, not a precondition for using the app.
|
||||
pub fn effective_document_path(&self) -> PathBuf {
|
||||
self.config
|
||||
.document_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_document_path())
|
||||
/// The sync account, signed in or not.
|
||||
pub fn sync_account(&self) -> Option<&SyncAccount> {
|
||||
self.config.sync.as_ref()
|
||||
}
|
||||
|
||||
pub fn default_document_path(&self) -> PathBuf {
|
||||
self.data_dir.join("library.json")
|
||||
}
|
||||
|
||||
/// Point at (or, with `None`, forget) the library document.
|
||||
pub fn set_document_path(&mut self, path: Option<PathBuf>) {
|
||||
self.config.document_path = path;
|
||||
pub fn set_sync_account(&mut self, account: Option<SyncAccount>) {
|
||||
self.config.sync = account;
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
|
||||
/// The library file within the granted folder.
|
||||
///
|
||||
/// Android-only: desktop names the whole path at once, so these are dead
|
||||
/// there rather than genuinely unused.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn document_name(&self) -> Option<&str> {
|
||||
self.config.document_name.as_deref()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn set_document_name(&mut self, name: Option<String>) {
|
||||
self.config.document_name = name;
|
||||
save_config(&self.data_dir, &self.config);
|
||||
/// Change the sync account in place and persist it. A no-op without one.
|
||||
pub fn update_sync_account(&mut self, f: impl FnOnce(&mut SyncAccount)) {
|
||||
if let Some(a) = self.config.sync.as_mut() {
|
||||
f(a);
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pre-document `library.db` still waiting to be converted.
|
||||
@@ -227,39 +252,63 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_name_alone_survives_a_reload() {
|
||||
// An Android install has no document_path (its folder is a SAF grant)
|
||||
// and no legacy library, so this config's *only* meaningful field is
|
||||
// the chosen library file. A loader that decided emptiness meant "must
|
||||
// be an old config" discarded it on every launch, silently reverting
|
||||
// the user's choice back to library.json.
|
||||
let dir = scratch("android-shape");
|
||||
std::fs::write(
|
||||
config_path(&dir),
|
||||
r#"{"document_path":null,"legacy_library_path":null,"document_name":"decks-v1.json"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = load_config(&dir);
|
||||
assert_eq!(cfg.document_name.as_deref(), Some("decks-v1.json"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
fn account() -> SyncAccount {
|
||||
SyncAccount {
|
||||
server_url: "https://mtgc.example".into(),
|
||||
token: Some("mtgc_secret".into()),
|
||||
user: AccountUser {
|
||||
id: "u1".into(),
|
||||
username: "alice".into(),
|
||||
display_name: "Alice".into(),
|
||||
email: "alice@example.com".into(),
|
||||
method: "local".into(),
|
||||
},
|
||||
device_id: 7,
|
||||
last_synced_at: Some(1_700_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_setting_round_trips() {
|
||||
let dir = scratch("round-trip");
|
||||
let cfg = PersistedConfig {
|
||||
document_path: Some(PathBuf::from("/tmp/lib.json")),
|
||||
legacy_library_path: Some(PathBuf::from("/tmp/old.db")),
|
||||
document_name: Some("mine.json".into()),
|
||||
sync: Some(account()),
|
||||
};
|
||||
save_config(&dir, &cfg);
|
||||
|
||||
let back = load_config(&dir);
|
||||
assert_eq!(back.document_path, cfg.document_path);
|
||||
assert_eq!(back.legacy_library_path, cfg.legacy_library_path);
|
||||
assert_eq!(back.document_name, cfg.document_name);
|
||||
assert_eq!(back.sync, cfg.sync);
|
||||
assert!(!dir.join("config.json.tmp").exists());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_config_holding_the_token_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = scratch("private");
|
||||
save_config(&dir, &PersistedConfig { legacy_library_path: None, sync: Some(account()) });
|
||||
let mode = std::fs::metadata(config_path(&dir)).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_sync_config_still_loads() {
|
||||
// What the previous, folder-based build wrote. Its settings no longer
|
||||
// mean anything, but the file must not fail to load over them.
|
||||
let dir = scratch("folder-era");
|
||||
std::fs::write(
|
||||
config_path(&dir),
|
||||
r#"{"document_path":"/home/u/Sync/library.json","legacy_library_path":"/tmp/old.db","document_name":"decks.json"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = load_config(&dir);
|
||||
assert_eq!(cfg.legacy_library_path, Some(PathBuf::from("/tmp/old.db")));
|
||||
assert!(cfg.sync.is_none());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -277,7 +326,6 @@ mod tests {
|
||||
cfg.legacy_library_path,
|
||||
Some(PathBuf::from("/home/u/Seafile/Decks-v1.db"))
|
||||
);
|
||||
assert!(cfg.document_path.is_none(), "a .db is never the document");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -293,9 +341,9 @@ mod tests {
|
||||
#[test]
|
||||
fn an_unreadable_or_corrupt_config_falls_back_to_defaults() {
|
||||
let dir = scratch("corrupt");
|
||||
assert!(load_config(&dir).document_name.is_none(), "missing file");
|
||||
assert!(load_config(&dir).sync.is_none(), "missing file");
|
||||
std::fs::write(config_path(&dir), "{not json").unwrap();
|
||||
assert!(load_config(&dir).document_name.is_none(), "truncated file");
|
||||
assert!(load_config(&dir).sync.is_none(), "truncated file");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user