2026-10-16 21:44:55 -05:00
|
|
|
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths,
|
|
|
|
|
//! the device's libraries, and the sync account.
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
//!
|
2026-10-16 21:44:55 -05:00
|
|
|
//! Each library lives in an internal SQLite working copy under the app data
|
|
|
|
|
//! dir, which works fully offline. One of them is open at a time — the active
|
|
|
|
|
//! library — and every command works on that one. When the device is signed in to a sync
|
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
|
|
|
//! server, that copy is reconciled with the server's whenever it can be reached
|
|
|
|
|
//! (see `sync.rs` and [`mtgc_core::sync::remote`]).
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
2026-07-07 20:55:05 -05:00
|
|
|
use std::path::{Path, PathBuf};
|
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
|
|
|
use std::sync::atomic::AtomicBool;
|
|
|
|
|
use std::sync::Arc;
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
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
|
|
|
use mtgc_core::sync::account::AccountUser;
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
use mtgc_core::{Catalog, NameIndex};
|
2026-07-07 20:55:05 -05:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
/// Small app-shell preferences persisted across restarts, distinct from the
|
|
|
|
|
/// user's library data.
|
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
|
|
|
///
|
|
|
|
|
/// Fields from older builds (`document_path`, `document_name`, from when the
|
|
|
|
|
/// library was a file in a synced folder) are simply ignored when read.
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
#[derive(Default, Serialize, Deserialize, Clone)]
|
2026-07-07 20:55:05 -05:00
|
|
|
struct PersistedConfig {
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
/// 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>,
|
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
|
|
|
/// The sync server this device is (or was last) signed in to.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
sync: Option<SyncAccount>,
|
2026-10-16 21:44:55 -05:00
|
|
|
/// The libraries on this device, the default one first. Never empty once
|
|
|
|
|
/// loaded: a config from before named libraries gets the default.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
libraries: Vec<LocalLibrary>,
|
|
|
|
|
/// The id of the library that's open.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
active_library: String,
|
|
|
|
|
/// Server libraries removed from this device, which syncing therefore
|
|
|
|
|
/// doesn't download again.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
dismissed_remote: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The id of the library every device starts with. Its files stay where the
|
|
|
|
|
/// one library always lived, so upgrading moves nothing.
|
|
|
|
|
pub const DEFAULT_LIBRARY: &str = "default";
|
|
|
|
|
|
|
|
|
|
/// One of the libraries on this device.
|
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct LocalLibrary {
|
|
|
|
|
/// Local only: the folder it lives in. Never shown.
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub name: String,
|
|
|
|
|
/// The server's id for it, once linked to a library there.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub remote_id: Option<String>,
|
|
|
|
|
/// Whether it should be on the server. Off for a library kept on this
|
|
|
|
|
/// device only — a scratch library to try things in, say.
|
|
|
|
|
#[serde(default = "yes")]
|
|
|
|
|
pub sync: bool,
|
|
|
|
|
/// It was linked, and another device deleted it from the server. The copy
|
|
|
|
|
/// here stays until the user removes it or puts it back on the server.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub server_deleted: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn yes() -> bool {
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl LocalLibrary {
|
|
|
|
|
pub fn is_default(&self) -> bool {
|
|
|
|
|
self.id == DEFAULT_LIBRARY
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_library() -> LocalLibrary {
|
|
|
|
|
LocalLibrary {
|
|
|
|
|
id: DEFAULT_LIBRARY.into(),
|
|
|
|
|
name: "Main".into(),
|
|
|
|
|
remote_id: None,
|
|
|
|
|
sync: true,
|
|
|
|
|
server_deleted: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A fresh id for a library's folder.
|
|
|
|
|
fn new_library_id() -> String {
|
|
|
|
|
mtgc_core::sync::account::random_token()
|
|
|
|
|
.chars()
|
|
|
|
|
.filter(char::is_ascii_alphanumeric)
|
|
|
|
|
.take(16)
|
|
|
|
|
.collect()
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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,
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
#[serde(default)]
|
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
|
|
|
pub device_id: i64,
|
|
|
|
|
/// Unix seconds of the last sync that completed.
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub last_synced_at: Option<i64>,
|
2026-07-07 20:55:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn config_path(data_dir: &Path) -> PathBuf {
|
|
|
|
|
data_dir.join("config.json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_config(data_dir: &Path) -> PersistedConfig {
|
2026-10-16 21:44:55 -05:00
|
|
|
let mut cfg = read_config(data_dir);
|
|
|
|
|
// Before named libraries there was one, which is the default now.
|
|
|
|
|
if !cfg.libraries.iter().any(LocalLibrary::is_default) {
|
|
|
|
|
cfg.libraries.insert(0, default_library());
|
|
|
|
|
}
|
|
|
|
|
if !cfg.libraries.iter().any(|l| l.id == cfg.active_library) {
|
|
|
|
|
cfg.active_library = DEFAULT_LIBRARY.into();
|
|
|
|
|
}
|
|
|
|
|
cfg
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_config(data_dir: &Path) -> PersistedConfig {
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
let Ok(text) = std::fs::read_to_string(config_path(data_dir)) else {
|
|
|
|
|
return PersistedConfig::default();
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
};
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
// Every field defaults, so an older or partial file parses fine and simply
|
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
|
|
|
// leaves the unknown settings unset.
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
let mut cfg: PersistedConfig = serde_json::from_str(&text).unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
// Builds before the document format stored `library_path` pointing at a
|
|
|
|
|
// SQLite file the user managed themselves. Pick that up so the UI can offer
|
|
|
|
|
// to convert it once — but never open it as the working copy.
|
|
|
|
|
if cfg.legacy_library_path.is_none() {
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct OldConfig {
|
|
|
|
|
library_path: Option<PathBuf>,
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
}
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
cfg.legacy_library_path = serde_json::from_str::<OldConfig>(&text)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|c| c.library_path)
|
|
|
|
|
.filter(|p| p.extension().is_some_and(|e| e != "json"));
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
}
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
cfg
|
2026-07-07 20:55:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
|
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
|
|
|
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);
|
2026-07-07 20:55:05 -05:00
|
|
|
}
|
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
|
|
|
let mut f = opts.open(path)?;
|
|
|
|
|
f.write_all(text.as_bytes())?;
|
|
|
|
|
f.sync_all()
|
2026-07-07 20:55:05 -05:00
|
|
|
}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
|
2026-10-16 21:44:55 -05:00
|
|
|
/// The folder a library's files live in.
|
|
|
|
|
fn library_dir(data_dir: &Path, id: &str) -> PathBuf {
|
|
|
|
|
if id == DEFAULT_LIBRARY {
|
|
|
|
|
data_dir.to_path_buf()
|
|
|
|
|
} else {
|
|
|
|
|
data_dir.join("libraries").join(id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
|
|
|
|
|
pub struct AppState {
|
2026-07-07 20:55:05 -05:00
|
|
|
data_dir: PathBuf,
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
/// 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,
|
2026-10-16 21:44:55 -05:00
|
|
|
/// Internal SQLite working copy of the active library: the library itself,
|
|
|
|
|
/// on this device. Never chosen by, shown to, or moved by the user.
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
pub working_db_path: PathBuf,
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
pub reference_path: PathBuf,
|
|
|
|
|
pub catalog: Option<Catalog>,
|
|
|
|
|
pub index: Option<NameIndex>,
|
2026-07-07 20:22:31 -05:00
|
|
|
/// 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>,
|
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
|
|
|
/// 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.
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
pub sync_running: bool,
|
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
|
|
|
/// 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>>,
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AppState {
|
|
|
|
|
pub fn new(data_dir: PathBuf) -> Self {
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
let config = load_config(&data_dir);
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
let mut s = Self {
|
|
|
|
|
reference_path: data_dir.join("reference.db"),
|
2026-10-16 21:44:55 -05:00
|
|
|
working_db_path: library_dir(&data_dir, &config.active_library).join("working.db"),
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
config,
|
2026-07-07 20:55:05 -05:00
|
|
|
data_dir,
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
catalog: None,
|
|
|
|
|
index: None,
|
2026-07-07 20:22:31 -05:00
|
|
|
ingest_cancel: None,
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
sync_running: false,
|
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
|
|
|
sync_error: None,
|
|
|
|
|
sync_needs_choice: false,
|
|
|
|
|
sign_in_cancel: None,
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
};
|
2026-10-16 21:44:55 -05:00
|
|
|
// A library whose folder vanished would otherwise be recreated empty
|
|
|
|
|
// by opening it, which is fine — but its folder must exist first.
|
|
|
|
|
let _ = std::fs::create_dir_all(s.library_dir(&s.config.active_library.clone()));
|
2026-08-02 16:10:25 -05:00
|
|
|
crate::mlog!(
|
2026-10-16 21:44:55 -05:00
|
|
|
"AppState::new: sync={:?} library={} working={} reference={} (exists={})",
|
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
|
|
|
s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())),
|
2026-10-16 21:44:55 -05:00
|
|
|
s.config.active_library,
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
s.working_db_path.display(),
|
2026-08-02 16:10:25 -05:00
|
|
|
s.reference_path.display(),
|
|
|
|
|
s.reference_path.exists()
|
|
|
|
|
);
|
|
|
|
|
// Best-effort open; failures just leave the catalog unloaded (the UI
|
|
|
|
|
// falls back to "no card data yet" and offers Download/Import again).
|
|
|
|
|
// That fallback previously made a real open failure indistinguishable
|
|
|
|
|
// from a first run — log it so it shows up in `adb logcat -s
|
|
|
|
|
// RustStdoutStderr` instead of silently reappearing every launch.
|
|
|
|
|
if let Err(e) = s.reopen() {
|
|
|
|
|
crate::mlog!("AppState::new: reopen failed: {e}");
|
|
|
|
|
}
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
s
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// The sync account, signed in or not.
|
|
|
|
|
pub fn sync_account(&self) -> Option<&SyncAccount> {
|
|
|
|
|
self.config.sync.as_ref()
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
pub fn set_sync_account(&mut self, account: Option<SyncAccount>) {
|
|
|
|
|
self.config.sync = account;
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
save_config(&self.data_dir, &self.config);
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// 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);
|
|
|
|
|
}
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-10-16 21:44:55 -05:00
|
|
|
/// Forget every link to a server library. For signing in to a different
|
|
|
|
|
/// account or server, where the old ids mean nothing.
|
|
|
|
|
pub fn forget_remote_links(&mut self) {
|
|
|
|
|
for lib in &mut self.config.libraries {
|
|
|
|
|
lib.remote_id = None;
|
|
|
|
|
lib.server_deleted = false;
|
|
|
|
|
}
|
|
|
|
|
self.config.dismissed_remote.clear();
|
|
|
|
|
save_config(&self.data_dir, &self.config);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- libraries ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
pub fn libraries(&self) -> &[LocalLibrary] {
|
|
|
|
|
&self.config.libraries
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn library(&self, id: &str) -> Option<&LocalLibrary> {
|
|
|
|
|
self.config.libraries.iter().find(|l| l.id == id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn active_library(&self) -> &LocalLibrary {
|
|
|
|
|
self.library(&self.config.active_library).expect("load_config keeps the active library listed")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The folder a library's files live in.
|
|
|
|
|
pub fn library_dir(&self, id: &str) -> PathBuf {
|
|
|
|
|
library_dir(&self.data_dir, id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn working_path(&self, id: &str) -> PathBuf {
|
|
|
|
|
self.library_dir(id).join("working.db")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Where a library's sync merge base is kept. The default library keeps
|
|
|
|
|
/// the name it had before there were others.
|
|
|
|
|
pub fn base_path(&self, id: &str) -> PathBuf {
|
|
|
|
|
if id == DEFAULT_LIBRARY {
|
|
|
|
|
self.data_dir.join("sync-server-base.json")
|
|
|
|
|
} else {
|
|
|
|
|
self.library_dir(id).join("sync-base.json")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Server libraries removed from this device.
|
|
|
|
|
pub fn dismissed_remote(&self) -> &[String] {
|
|
|
|
|
&self.config.dismissed_remote
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Start a new, empty library. It isn't opened.
|
|
|
|
|
pub fn create_library(&mut self, name: &str, sync: bool, remote_id: Option<String>) -> Result<LocalLibrary, String> {
|
|
|
|
|
let name = self.check_name(name, None)?;
|
|
|
|
|
let lib = LocalLibrary { id: new_library_id(), name, remote_id, sync, server_deleted: false };
|
|
|
|
|
let dir = self.library_dir(&lib.id);
|
|
|
|
|
std::fs::create_dir_all(&dir).map_err(|e| format!("Couldn't create the library: {e}"))?;
|
|
|
|
|
// Created now, so the library exists on disk even before anything
|
|
|
|
|
// is added to it.
|
|
|
|
|
mtgc_core::db::open_library(&self.working_path(&lib.id)).map_err(|e| e.to_string())?;
|
|
|
|
|
if let Some(r) = &lib.remote_id {
|
|
|
|
|
self.config.dismissed_remote.retain(|d| d != r);
|
|
|
|
|
}
|
|
|
|
|
self.config.libraries.push(lib.clone());
|
|
|
|
|
save_config(&self.data_dir, &self.config);
|
|
|
|
|
Ok(lib)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A trimmed name, if no other library here already has it.
|
|
|
|
|
pub fn check_name(&self, name: &str, except: Option<&str>) -> Result<String, String> {
|
|
|
|
|
let name = name.trim();
|
|
|
|
|
if name.is_empty() {
|
|
|
|
|
return Err("A library needs a name.".into());
|
|
|
|
|
}
|
|
|
|
|
let taken = self
|
|
|
|
|
.config
|
|
|
|
|
.libraries
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|l| Some(l.id.as_str()) != except && l.name.eq_ignore_ascii_case(name));
|
|
|
|
|
if taken {
|
|
|
|
|
return Err(format!("There's already a library called {name}."));
|
|
|
|
|
}
|
|
|
|
|
Ok(name.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Change a library in place and persist it. A no-op for an unknown id.
|
|
|
|
|
pub fn update_library(&mut self, id: &str, f: impl FnOnce(&mut LocalLibrary)) {
|
|
|
|
|
if let Some(lib) = self.config.libraries.iter_mut().find(|l| l.id == id) {
|
|
|
|
|
f(lib);
|
|
|
|
|
save_config(&self.data_dir, &self.config);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Stop downloading a server library to this device.
|
|
|
|
|
pub fn dismiss_remote(&mut self, remote_id: &str) {
|
|
|
|
|
if !self.config.dismissed_remote.iter().any(|d| d == remote_id) {
|
|
|
|
|
self.config.dismissed_remote.push(remote_id.to_string());
|
|
|
|
|
save_config(&self.data_dir, &self.config);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn undismiss_remote(&mut self, remote_id: &str) {
|
|
|
|
|
self.config.dismissed_remote.retain(|d| d != remote_id);
|
|
|
|
|
save_config(&self.data_dir, &self.config);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Open another library.
|
|
|
|
|
pub fn switch_library(&mut self, id: &str) -> Result<(), String> {
|
|
|
|
|
if self.library(id).is_none() {
|
|
|
|
|
return Err("There's no such library on this device.".into());
|
|
|
|
|
}
|
|
|
|
|
if self.config.active_library == id {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
// A sync works on paths it took when it started.
|
|
|
|
|
if self.sync_running {
|
|
|
|
|
return Err("Wait for the sync to finish, then try again.".into());
|
|
|
|
|
}
|
|
|
|
|
let _ = std::fs::create_dir_all(self.library_dir(id));
|
|
|
|
|
self.config.active_library = id.to_string();
|
|
|
|
|
save_config(&self.data_dir, &self.config);
|
|
|
|
|
self.working_db_path = self.working_path(id);
|
|
|
|
|
self.reopen_catalog()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Delete a library and its files from this device. The active one is
|
|
|
|
|
/// left by switching to the default first; the default itself stays.
|
|
|
|
|
pub fn delete_library(&mut self, id: &str) -> Result<LocalLibrary, String> {
|
|
|
|
|
let lib = self.library(id).cloned().ok_or("There's no such library on this device.")?;
|
|
|
|
|
if lib.is_default() {
|
|
|
|
|
return Err("The main library can't be deleted.".into());
|
|
|
|
|
}
|
|
|
|
|
if self.sync_running {
|
|
|
|
|
return Err("Wait for the sync to finish, then try again.".into());
|
|
|
|
|
}
|
|
|
|
|
if self.config.active_library == id {
|
|
|
|
|
self.switch_library(DEFAULT_LIBRARY)?;
|
|
|
|
|
}
|
|
|
|
|
self.config.libraries.retain(|l| l.id != id);
|
|
|
|
|
save_config(&self.data_dir, &self.config);
|
|
|
|
|
let dir = self.library_dir(id);
|
|
|
|
|
if let Err(e) = std::fs::remove_dir_all(&dir) {
|
|
|
|
|
crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display());
|
|
|
|
|
}
|
|
|
|
|
Ok(lib)
|
|
|
|
|
}
|
|
|
|
|
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
/// A pre-document `library.db` still waiting to be converted.
|
|
|
|
|
pub fn legacy_library_path(&self) -> Option<&Path> {
|
|
|
|
|
self.config.legacy_library_path.as_deref()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Forget the legacy library, once it's been converted or dismissed.
|
|
|
|
|
pub fn clear_legacy_library(&mut self) {
|
|
|
|
|
self.config.legacy_library_path = None;
|
|
|
|
|
save_config(&self.data_dir, &self.config);
|
2026-07-07 20:55:05 -05:00
|
|
|
}
|
|
|
|
|
|
Add Android support via Tauri
The Rust core was already portable (caller-provided db paths, rustls rather
than openssl) and the shell already had the mobile entry point, so this is
mostly toolchain, provisioning, and platform-gating.
Toolchain:
- scripts/android-setup.sh installs what Android Studio doesn't: SDK
cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is
pinned rather than "latest" because sdkmanager's newest is often a beta
(30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers
Android Studio's bundled JDK 21, since Gradle rejects newer JDKs.
- Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is
hand-edited and Android permissions have no tauri.conf.json equivalent.
- vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server.
Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a
phone. Add import_reference_db to adopt a desktop-built file instead, validated
by db::inspect_reference_db first so picking the wrong file is a clean error
rather than a broken install. Installs via copy-then-rename so an interrupted
copy can't leave a partial db.
Ingest hardening (helps desktop too):
- Give ureq connect/read timeouts. Cancellation is only polled per parsed
record, so a stalled socket previously hung forever with no way to cancel.
- Drop the remove_file before rename; POSIX rename already replaces atomically,
and the delete-first window could leave no reference.db at all if the process
was killed -- much likelier on Android.
UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path
equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile)
flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard
doesn't break the shell.
Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds
(bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a
device.
2026-08-02 13:16:34 -05:00
|
|
|
/// Drop the open catalog (and its name index), releasing the handles on both
|
|
|
|
|
/// db files. Call before replacing `reference.db` on disk so nothing is
|
|
|
|
|
/// still reading the file being swapped out.
|
|
|
|
|
pub fn close(&mut self) {
|
|
|
|
|
self.catalog = None;
|
|
|
|
|
self.index = None;
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
/// (Re)open the catalog + rebuild the name index. Only opens when a reference
|
|
|
|
|
/// db exists — attaching a non-existent file would create an empty (tableless)
|
|
|
|
|
/// db and make every query fail.
|
|
|
|
|
pub fn reopen(&mut self) -> Result<(), String> {
|
|
|
|
|
if self.reference_path.exists() {
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
let catalog = Catalog::open(&self.working_db_path, &self.reference_path)
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
|
let index = catalog.build_name_index().map_err(|e| e.to_string())?;
|
|
|
|
|
self.index = Some(index);
|
|
|
|
|
self.catalog = Some(catalog);
|
|
|
|
|
} else {
|
|
|
|
|
self.catalog = None;
|
|
|
|
|
self.index = None;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 21:44:55 -05:00
|
|
|
/// Reopen just the catalog, on the active library's working copy. The name
|
|
|
|
|
/// index only reads card data, so it survives a switch of library.
|
|
|
|
|
fn reopen_catalog(&mut self) -> Result<(), String> {
|
|
|
|
|
if self.index.is_none() {
|
|
|
|
|
return self.reopen();
|
|
|
|
|
}
|
|
|
|
|
self.catalog = None;
|
|
|
|
|
if self.reference_path.exists() {
|
|
|
|
|
self.catalog = Some(
|
|
|
|
|
Catalog::open(&self.working_db_path, &self.reference_path).map_err(|e| e.to_string())?,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
Initial commit: MTG personal library catalog (Tauri v2 + React/TS)
Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
tile/list view toggle, hover card previews, deck format + commander support,
per-card location field, grouping by type/subtype/CMC/color, localStorage
persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 19:37:56 -05:00
|
|
|
pub fn catalog(&self) -> Result<&Catalog, String> {
|
|
|
|
|
self.catalog
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn index(&self) -> Result<&NameIndex, String> {
|
|
|
|
|
self.index
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
|
|
|
|
|
}
|
|
|
|
|
}
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn scratch(name: &str) -> PathBuf {
|
|
|
|
|
let dir = std::env::temp_dir().join(format!("mtgc-cfg-{name}-{}", std::process::id()));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
dir
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
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),
|
|
|
|
|
}
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_setting_round_trips() {
|
|
|
|
|
let dir = scratch("round-trip");
|
2026-10-16 21:44:55 -05:00
|
|
|
let scratch_lib = LocalLibrary {
|
|
|
|
|
id: "abc".into(),
|
|
|
|
|
name: "Scratch".into(),
|
|
|
|
|
remote_id: Some("r1".into()),
|
|
|
|
|
sync: false,
|
|
|
|
|
server_deleted: true,
|
|
|
|
|
};
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
let cfg = PersistedConfig {
|
|
|
|
|
legacy_library_path: Some(PathBuf::from("/tmp/old.db")),
|
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
|
|
|
sync: Some(account()),
|
2026-10-16 21:44:55 -05:00
|
|
|
libraries: vec![default_library(), scratch_lib],
|
|
|
|
|
active_library: "abc".into(),
|
|
|
|
|
dismissed_remote: vec!["r2".into()],
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
};
|
|
|
|
|
save_config(&dir, &cfg);
|
|
|
|
|
|
|
|
|
|
let back = load_config(&dir);
|
|
|
|
|
assert_eq!(back.legacy_library_path, cfg.legacy_library_path);
|
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
|
|
|
assert_eq!(back.sync, cfg.sync);
|
2026-10-16 21:44:55 -05:00
|
|
|
assert_eq!(back.libraries, cfg.libraries);
|
|
|
|
|
assert_eq!(back.active_library, "abc");
|
|
|
|
|
assert_eq!(back.dismissed_remote, cfg.dismissed_remote);
|
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
|
|
|
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");
|
2026-10-16 21:44:55 -05:00
|
|
|
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
|
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
|
|
|
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());
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_pre_document_config_offers_its_library_for_conversion() {
|
|
|
|
|
let dir = scratch("legacy");
|
|
|
|
|
std::fs::write(
|
|
|
|
|
config_path(&dir),
|
|
|
|
|
r#"{"library_path":"/home/u/Seafile/Decks-v1.db"}"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let cfg = load_config(&dir);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
cfg.legacy_library_path,
|
|
|
|
|
Some(PathBuf::from("/home/u/Seafile/Decks-v1.db"))
|
|
|
|
|
);
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_library_path_that_is_already_json_is_not_treated_as_legacy() {
|
|
|
|
|
let dir = scratch("json-path");
|
|
|
|
|
std::fs::write(config_path(&dir), r#"{"library_path":"/home/u/lib.json"}"#).unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(load_config(&dir).legacy_library_path.is_none());
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_unreadable_or_corrupt_config_falls_back_to_defaults() {
|
|
|
|
|
let dir = scratch("corrupt");
|
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
|
|
|
assert!(load_config(&dir).sync.is_none(), "missing file");
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
std::fs::write(config_path(&dir), "{not json").unwrap();
|
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
|
|
|
assert!(load_config(&dir).sync.is_none(), "truncated file");
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
2026-10-16 21:44:55 -05:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_config_from_before_named_libraries_has_just_the_default() {
|
|
|
|
|
let dir = scratch("pre-libraries");
|
|
|
|
|
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
|
|
|
|
|
let cfg = load_config(&dir);
|
|
|
|
|
assert_eq!(cfg.libraries, vec![default_library()]);
|
|
|
|
|
assert_eq!(cfg.active_library, DEFAULT_LIBRARY);
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_default_library_keeps_the_files_it_always_had() {
|
|
|
|
|
let dir = scratch("paths");
|
|
|
|
|
let s = AppState::new(dir.clone());
|
|
|
|
|
assert_eq!(s.working_db_path, dir.join("working.db"));
|
|
|
|
|
assert_eq!(s.base_path(DEFAULT_LIBRARY), dir.join("sync-server-base.json"));
|
|
|
|
|
assert_eq!(s.working_path("abc"), dir.join("libraries/abc/working.db"));
|
|
|
|
|
assert_eq!(s.base_path("abc"), dir.join("libraries/abc/sync-base.json"));
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn libraries_can_be_created_switched_to_and_deleted() {
|
|
|
|
|
let dir = scratch("libraries");
|
|
|
|
|
let mut s = AppState::new(dir.clone());
|
|
|
|
|
let lib = s.create_library(" Scratch ", false, None).unwrap();
|
|
|
|
|
assert_eq!(lib.name, "Scratch");
|
|
|
|
|
assert!(s.working_path(&lib.id).exists());
|
|
|
|
|
assert!(s.create_library("scratch", true, None).is_err(), "names are unique");
|
|
|
|
|
assert!(s.create_library(" ", true, None).is_err());
|
|
|
|
|
|
|
|
|
|
s.switch_library(&lib.id).unwrap();
|
|
|
|
|
assert_eq!(s.working_db_path, s.working_path(&lib.id));
|
|
|
|
|
// Remembered across restarts.
|
|
|
|
|
let mut s = AppState::new(dir.clone());
|
|
|
|
|
assert_eq!(s.active_library().id, lib.id);
|
|
|
|
|
|
|
|
|
|
s.sync_running = true;
|
|
|
|
|
assert!(s.switch_library(DEFAULT_LIBRARY).is_err(), "not mid-sync");
|
|
|
|
|
assert!(s.delete_library(&lib.id).is_err(), "not mid-sync");
|
|
|
|
|
s.sync_running = false;
|
|
|
|
|
|
|
|
|
|
assert!(s.delete_library(DEFAULT_LIBRARY).is_err());
|
|
|
|
|
s.delete_library(&lib.id).unwrap();
|
|
|
|
|
assert_eq!(s.active_library().id, DEFAULT_LIBRARY, "deleting the open one switches away");
|
|
|
|
|
assert!(!s.library_dir(&lib.id).exists());
|
|
|
|
|
assert_eq!(s.libraries().len(), 1);
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
}
|