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:
2026-10-08 22:32:08 -05:00
parent df2cbb372a
commit de55fc634a
41 changed files with 6365 additions and 1662 deletions

View File

@@ -20,3 +20,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
mtgc-core = { path = "../crates/mtgc-core" }
tauri-plugin-opener = "2.5.4"
url = "2"
[target.'cfg(target_os = "android")'.dependencies]
# Delivers sync sign-ins back to the app through the `mtgc://` scheme.
tauri-plugin-deep-link = "2"

View File

@@ -2,9 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Note: library sync needs no storage permission. The user grants access
to one folder through the system folder picker (SAF), and that grant is
persisted across restarts — see SyncPlugin.kt. -->
<!-- Library sync talks to the user's sync server over the network, so it
needs no storage permission. Sign-in returns to the app through the
mtgc:// scheme; the deep-link plugin adds that intent filter to the
activity below when the app is built. -->
<!-- AndroidTV support -->

View File

@@ -1,295 +0,0 @@
package com.garrettmills.mtgc
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.provider.DocumentsContract
import androidx.activity.result.ActivityResult
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import java.io.File
@InvokeArg
class FileArgs {
/** File name inside the chosen folder, e.g. "library.json". */
lateinit var name: String
/** App-private staging path the bytes are copied to/from. */
lateinit var path: String
}
@InvokeArg
class NameArgs {
lateinit var name: String
}
/**
* Bridges the Storage Access Framework to Rust for library sync.
*
* The user picks one folder through the system picker; we take a *persistable*
* URI permission on it so the grant survives restarts. That folder is a real
* directory in shared storage (e.g. /storage/emulated/0/mtgc), so a sync tool
* like Syncthing can see it, but this app reaches it only through SAF and
* therefore needs no storage permission in the manifest.
*
* SAF hands back `content://` URIs, which SQLite cannot open — so nothing here
* exposes a URI to Rust. Instead every operation copies bytes between the
* chosen folder and an app-private staging path that Rust picks, and Rust only
* ever does plain file I/O on its own private storage. That is sufficient
* because the synced copy is never opened as a database; it is only ever
* written whole or read whole.
*/
@TauriPlugin
class SyncPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val PREFS = "mtgc-sync"
private const val KEY_TREE_URI = "tree-uri"
/** Generic type: a SQLite file has no useful registered MIME type. */
private const val MIME = "application/octet-stream"
private const val NO_FOLDER =
"No synced folder is set up yet — choose one first."
}
private fun prefs() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* The chosen folder, but only when the persisted grant is still held.
*
* A saved URI isn't proof of access: the user can revoke it in system
* settings, and clearing app data drops it. Checking the live grant list
* keeps us from failing later with an opaque SecurityException.
*/
private fun grantedTree(): Uri? {
val saved = prefs().getString(KEY_TREE_URI, null) ?: return null
val uri = Uri.parse(saved)
val held = activity.contentResolver.persistedUriPermissions.any {
it.uri == uri && it.isReadPermission && it.isWritePermission
}
return if (held) uri else null
}
/** Best-effort human-readable name for the folder, for display in the UI. */
private fun label(uri: Uri): String = try {
val docId = DocumentsContract.getTreeDocumentId(uri)
val parts = docId.split(":", limit = 2)
if (parts.size == 2 && parts[0].equals("primary", ignoreCase = true)) {
"Internal storage/${parts[1]}"
} else {
docId
}
} catch (_: Exception) {
uri.toString()
}
private fun statusObject(): JSObject {
val saved = prefs().getString(KEY_TREE_URI, null)
val granted = grantedTree()
return JSObject().apply {
// "chosen but not granted" is a distinct, recoverable state worth
// telling the user about — it means re-pick, not first-time setup.
put("chosen", saved != null)
put("granted", granted != null)
put("label", saved?.let { label(Uri.parse(it)) })
}
}
@Command
fun status(invoke: Invoke) {
invoke.resolve(statusObject())
}
@Command
fun pickFolder(invoke: Invoke) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
startActivityForResult(invoke, intent, "folderPicked")
}
@ActivityCallback
fun folderPicked(invoke: Invoke, result: ActivityResult) {
val uri = result.data?.data
if (result.resultCode != Activity.RESULT_OK || uri == null) {
// Backing out of the picker is a normal choice, not a failure.
invoke.resolve(statusObject().apply { put("cancelled", true) })
return
}
try {
// Without this the grant dies with the activity.
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: Exception) {
invoke.reject("Couldn't keep access to that folder: ${e.message}")
return
}
prefs().edit().putString(KEY_TREE_URI, uri.toString()).apply()
invoke.resolve(statusObject().apply { put("cancelled", false) })
}
@Command
fun forgetFolder(invoke: Invoke) {
prefs().getString(KEY_TREE_URI, null)?.let {
try {
activity.contentResolver.releasePersistableUriPermission(
Uri.parse(it),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (_: Exception) {
// Already gone; clearing our own record below is what matters.
}
}
prefs().edit().remove(KEY_TREE_URI).apply()
invoke.resolve(statusObject())
}
/** Locate a direct child of the chosen folder by display name. */
private fun findChild(tree: Uri, name: String): Uri? {
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
)
activity.contentResolver.query(children, projection, null, null, null)
?.use { c: Cursor ->
while (c.moveToNext()) {
if (c.getString(1) == name) {
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0))
}
}
}
return null
}
/**
* Names of the files directly inside the chosen folder. Rust uses this to
* discover the other devices' snapshots; which names matter is entirely its
* business, so everything is returned unfiltered.
*/
@Command
fun list(invoke: Invoke) {
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
try {
val names = JSArray()
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
)
val projection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
activity.contentResolver.query(children, projection, null, null, null)
?.use { c: Cursor ->
while (c.moveToNext()) {
c.getString(0)?.let { names.put(it) }
}
}
invoke.resolve(JSObject().apply { put("names", names) })
} catch (e: Exception) {
invoke.reject("Couldn't list the synced folder: ${e.message}")
}
}
/**
* Remove a file from the chosen folder.
*
* Used only to clear away conflict copies once their contents have been
* merged and republished — never to delete the library document itself.
*/
@Command
fun deleteFile(invoke: Invoke) {
val args = invoke.parseArgs(NameArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val doc = findChild(tree, args.name)
if (doc == null) {
invoke.resolve(JSObject().apply { put("removed", false) })
return
}
try {
val removed = DocumentsContract.deleteDocument(activity.contentResolver, doc)
invoke.resolve(JSObject().apply { put("removed", removed) })
} catch (e: Exception) {
invoke.reject("Couldn't remove ${args.name}: ${e.message}")
}
}
/** Copy `name` out of the chosen folder into the app-private `path`. */
@Command
fun readInto(invoke: Invoke) {
val args = invoke.parseArgs(FileArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val doc = findChild(tree, args.name)
if (doc == null) {
// A missing file is an expected state (nothing synced here yet), not an
// error — the caller decides what to do about it.
invoke.resolve(JSObject().apply {
put("found", false)
put("bytes", 0L)
})
return
}
try {
val dest = File(args.path)
dest.parentFile?.mkdirs()
val copied = activity.contentResolver.openInputStream(doc)!!.use { input ->
dest.outputStream().use { out -> input.copyTo(out) }
}
invoke.resolve(JSObject().apply {
put("found", true)
put("bytes", copied)
})
} catch (e: Exception) {
invoke.reject("Couldn't read ${args.name} from the synced folder: ${e.message}")
}
}
/** Copy the app-private `path` into the chosen folder as `name`. */
@Command
fun writeFrom(invoke: Invoke) {
val args = invoke.parseArgs(FileArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val src = File(args.path)
if (!src.isFile) return invoke.reject("Nothing to upload at ${args.path}")
try {
// Overwrite the existing document when there is one, rather than
// delete-and-recreate: a sync tool then sees one modified file instead
// of a removal followed by an unrelated addition.
val doc = findChild(tree, args.name)
?: DocumentsContract.createDocument(
activity.contentResolver,
DocumentsContract.buildDocumentUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
),
MIME,
args.name
)
?: return invoke.reject("Couldn't create ${args.name} in the synced folder")
// "wt" truncates first. Plain "w" leaves any trailing bytes of a longer
// previous file in place, which would silently corrupt a database that
// got smaller.
val copied = activity.contentResolver.openOutputStream(doc, "wt")!!.use { out ->
src.inputStream().use { input -> input.copyTo(out) }
}
invoke.resolve(JSObject().apply { put("bytes", copied) })
} catch (e: Exception) {
invoke.reject("Couldn't write ${args.name} to the synced folder: ${e.message}")
}
}
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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

View File

@@ -23,6 +23,13 @@
"csp": null
}
},
"plugins": {
"deep-link": {
"mobile": [
{ "scheme": ["mtgc"], "host": "auth", "path": ["/callback"], "appLink": false }
]
}
},
"bundle": {
"active": true,
"targets": ["deb", "rpm"],