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,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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -23,6 +23,13 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"mobile": [
|
||||
{ "scheme": ["mtgc"], "host": "auth", "path": ["/callback"], "appLink": false }
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm"],
|
||||
|
||||
Reference in New Issue
Block a user