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
import { useEffect , useState } from "react" ;
import { open , save } from "@tauri-apps/plugin-dialog" ;
import { api , FirstSync , SyncDevice , SyncReport , SyncStatus } from "./api" ;
import { ConfirmDialog , formatRelativeTime } from "./components" ;
/** The server's answer when this device's token stops working. */
const SIGNED_OUT_BY_SERVER = "This device is no longer signed in to the sync server." ;
/** A one-line summary of a sync, for the panel's outcome line. */
export function describeSync ( report : SyncReport ) : string {
if ( report . needs_choice ) return "Choose how to combine the two libraries below." ;
const r = report . report ;
const parts : string [ ] = [ ] ;
if ( report . changed ) {
const bits = [
r . cards_changed && ` ${ r . cards_changed } card ${ r . cards_changed === 1 ? "" : "s" } ` ,
r . decks_changed && ` ${ r . decks_changed } deck ${ r . decks_changed === 1 ? "" : "s" } ` ,
r . decks_removed && ` ${ r . decks_removed } removed ` ,
] . filter ( Boolean ) ;
parts . push ( ` Brought in ${ bits . join ( ", " ) } from your other devices. ` ) ;
}
if ( report . published ) parts . push ( "Sent this device's changes." ) ;
if ( parts . length === 0 ) parts . push ( "Already up to date." ) ;
// Conflicts are the one thing worth spelling out — the user may want to
// correct a value that lost.
if ( r . conflicts . length > 0 ) {
parts . push ( ` Changed in two places, so the server's version was kept for: ${ r . conflicts . join ( "; " ) } . ` ) ;
}
return parts . join ( " " ) ;
}
/ * *
* Compact sync state for the top bar . Clicking it opens the Info tab , where
* the details and controls live .
* /
export function SyncIndicator ( {
sync ,
syncing ,
onClick ,
} : {
sync : SyncStatus | null ;
syncing : boolean ;
onClick : ( ) = > void ;
} ) {
if ( ! sync ) return null ;
let tone : "ok" | "warn" | "off" = "ok" ;
let label : string ;
if ( ! sync . signed_in ) {
tone = "off" ;
label = sync . last_error === SIGNED_OUT_BY_SERVER ? "Signed out" : "Not syncing" ;
} else if ( sync . needs_choice ) {
tone = "warn" ;
label = "Sync needs you" ;
} else if ( syncing ) {
label = "Syncing…" ;
} else if ( sync . last_error ) {
tone = "warn" ;
label = sync . pending_changes ? "Offline — changes will sync" : "Offline" ;
} else if ( sync . pending_changes ) {
tone = "warn" ;
label = "Not yet synced" ;
} else {
label = "Synced" ;
}
const title = sync . last_error
? ` Last sync failed: ${ sync . last_error } `
: sync . last_synced_at
? ` Last synced ${ formatRelativeTime ( sync . last_synced_at ) } `
: label ;
return (
< button className = { ` sync-indicator sync-indicator- ${ tone } ` } onClick = { onClick } title = { title } >
< span className = { ` status-dot ${ tone === "ok" ? "on" : tone === "warn" ? "warn" : "off" } ` } / >
{ label }
< / button >
) ;
}
/ * *
* The Info tab ' s library sync section : signing in to a sync server , the
* first - sync choice , the account ' s devices , and library backups .
* /
export function SyncPanel ( {
sync ,
setSync ,
syncing ,
runSync ,
onLibraryReplaced ,
} : {
sync : SyncStatus ;
setSync : ( s : SyncStatus ) = > void ;
syncing : boolean ;
/** Sync now; resolves with the report, or null when nothing ran. */
runSync : ( ) = > Promise < SyncReport | null > ;
/ * * T h e l i b r a r y m a y h a v e c h a n g e d w h o l e s a l e ( s i g n e d i n , i m p o r t e d , a d o p t e d t h e
* server ' s copy ) : every view should refetch . * /
onLibraryReplaced : ( ) = > void ;
} ) {
const [ serverUrl , setServerUrl ] = useState ( sync . server_url ? ? "" ) ;
const [ deviceName , setDeviceName ] = useState ( "" ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ outcome , setOutcome ] = useState < { ok : boolean ; message : string } | null > ( null ) ;
const [ devices , setDevices ] = useState < SyncDevice [ ] | null > ( null ) ;
const [ confirm , setConfirm ] = useState <
{ kind : "use_server" } | { kind : "revoke" ; device : SyncDevice } | null
> ( null ) ;
useEffect ( ( ) = > {
if ( ! serverUrl && sync . server_url ) setServerUrl ( sync . server_url ) ;
} , [ sync . server_url ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
// The device list is only worth a round trip while it's on screen.
useEffect ( ( ) = > {
if ( ! sync . signed_in ) {
setDevices ( null ) ;
return ;
}
api . syncDevices ( ) . then ( setDevices ) . catch ( ( ) = > setDevices ( null ) ) ;
} , [ sync . signed_in , sync . account ? . id ] ) ;
async function act ( work : ( ) = > Promise < string | null > ) {
setBusy ( true ) ;
setOutcome ( null ) ;
try {
const message = await work ( ) ;
if ( message ) setOutcome ( { ok : true , message } ) ;
} catch ( err ) {
// Backing out of a sign-in is a choice, not a failure worth flagging.
if ( ! String ( err ) . includes ( "Sign-in cancelled" ) ) {
setOutcome ( { ok : false , message : String ( err ) } ) ;
}
} finally {
setBusy ( false ) ;
api . syncStatus ( ) . then ( setSync ) . catch ( ( ) = > { } ) ;
}
}
const signIn = ( ) = >
act ( async ( ) = > {
if ( ! serverUrl . trim ( ) ) throw new Error ( "Enter your sync server's address." ) ;
// Shows "finish signing in in your browser" while this waits.
setSync ( { . . . sync , signing_in : true } ) ;
const s = await api . syncSignIn ( serverUrl . trim ( ) , deviceName . trim ( ) || undefined ) ;
setSync ( s ) ;
onLibraryReplaced ( ) ;
return s . needs_choice ? null : ` Signed in as ${ s . account ? . username } . Your library is syncing. ` ;
} ) ;
const cancelSignIn = ( ) = > api . syncCancelSignIn ( ) . catch ( ( ) = > { } ) ;
const signOut = ( ) = >
act ( async ( ) = > {
setSync ( await api . syncSignOut ( ) ) ;
onLibraryReplaced ( ) ;
return "Signed out. Your library stays on this device and keeps working offline." ;
} ) ;
const syncNow = ( ) = >
act ( async ( ) = > {
const r = await runSync ( ) ;
return r ? describeSync ( r ) : "A sync was already running." ;
} ) ;
const connect = ( choice : FirstSync ) = >
act ( async ( ) = > {
const r = await api . syncConnect ( choice ) ;
onLibraryReplaced ( ) ;
return r ? describeSync ( r ) : null ;
} ) ;
const revoke = ( device : SyncDevice ) = >
act ( async ( ) = > {
setDevices ( await api . syncRevokeDevice ( device . id ) ) ;
return ` Signed out ${ device . name } . ` ;
} ) ;
const exportLibrary = ( ) = >
act ( async ( ) = > {
const path = await save ( {
title : "Save a backup of your library" ,
defaultPath : ` mtgc-library- ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .json ` ,
filters : [ { name : "mtgc library" , extensions : [ "json" ] } ] ,
} ) ;
if ( typeof path !== "string" ) return null ;
await api . exportLibraryDocument ( path ) ;
return ` Saved to ${ path } . ` ;
} ) ;
const importLibrary = ( preset? : string ) = >
act ( async ( ) = > {
const path =
preset ? ?
( await open ( {
title : "Import a library file" ,
filters : [
{ name : "mtgc library" , extensions : [ "json" , "db" ] } ,
{ name : "All files" , extensions : [ "*" ] } ,
] ,
} ) ) ;
if ( typeof path !== "string" ) return null ;
const r = await api . importLibraryFile ( path ) ;
onLibraryReplaced ( ) ;
return ` Imported ${ r . cards } card ${ r . cards === 1 ? "" : "s" } and ${ r . decks } deck ${
r . decks === 1 ? "" : "s"
} , combined with what was already here . ` ;
} ) ;
const account = sync . account ;
return (
< >
2026-10-23 23:27:58 -05:00
< h2 style = { { margin : 0 } } > Sync < / h2 >
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 . signed_in ? (
< >
< div className = "hint" >
Your library is kept on this device and works offline . Sign in to a sync server to
share it with your other devices — edits made in two places are combined , not
overwritten .
< / div >
{ sync . last_error === SIGNED_OUT_BY_SERVER && (
< div className = "sync-bad" >
✕ This device was signed out by the server ( removed from another device , perhaps ) .
Sign in again to resume syncing .
< / div >
) }
{ sync . signing_in ? (
< >
< div className = "sync-ok" > Finish signing in in your browser … < / div >
< div className = "row" >
< button onClick = { cancelSignIn } > Cancel < / button >
< / div >
< / >
) : (
< >
< div className = "field" >
< label htmlFor = "sync-server" > Sync server < / label >
< input
id = "sync-server"
type = "url"
inputMode = "url"
autoCapitalize = "none"
autoCorrect = "off"
spellCheck = { false }
placeholder = "mtgc.example.com"
value = { serverUrl }
onChange = { ( e ) = > setServerUrl ( e . target . value ) }
onKeyDown = { ( e ) = > e . key === "Enter" && ! busy && signIn ( ) }
disabled = { busy }
/ >
< / div >
< div className = "field" >
< label htmlFor = "sync-device" > Name for this device ( optional ) < / label >
< input
id = "sync-device"
placeholder = "e.g. Desktop, Phone"
value = { deviceName }
onChange = { ( e ) = > setDeviceName ( e . target . value ) }
disabled = { busy }
/ >
< / div >
< div className = "row" >
< button className = "primary" onClick = { signIn } disabled = { busy || ! serverUrl . trim ( ) } >
Sign in …
< / button >
< / div >
< / >
) }
< / >
) : (
< >
< div className = "sync-folder" >
{ account ? . display_name && account . display_name !== account . username
? ` ${ account . display_name } ( ${ account . username } ) `
: account ? . username }
{ account ? . email && < span className = "hint" > · { account . email } < / span > }
< / div >
< div className = "hint" style = { { overflowWrap : "anywhere" } } >
{ sync . server_url }
{ account ? . method === "oidc" ? " · single sign-on" : "" }
< / div >
{ sync . needs_choice ? (
< div className = "sync-choice" >
< strong > This device and your account both have a library . < / strong >
< div className = "hint" >
Combining keeps everything from both . Using the server 's library replaces what' s on
this device .
< / div >
< div className = "row" style = { { flexWrap : "wrap" } } >
< button className = "primary" onClick = { ( ) = > connect ( "combine" ) } disabled = { busy } >
Combine them
< / button >
< button onClick = { ( ) = > setConfirm ( { kind : "use_server" } ) } disabled = { busy } >
Use the server ' s library
< / button >
< / div >
< / div >
) : (
< div className = { sync . last_error ? "sync-bad" : "sync-ok" } >
{ sync . last_error
? ` ✕ Couldn't sync: ${ sync . last_error } ${
sync . pending_changes ? " Your changes are safe here and will sync later." : ""
} `
: syncing
? "Syncing…"
: sync . pending_changes
? "Changes on this device haven't synced yet."
: sync . last_synced_at
? ` ✓ Synced ${ formatRelativeTime ( sync . last_synced_at ) } `
: "✓ Synced" }
< / div >
) }
< div className = "row" style = { { gap : "var(--space-3)" , flexWrap : "wrap" } } >
{ ! sync . needs_choice && (
< button className = "primary" onClick = { syncNow } disabled = { busy || syncing } >
{ busy || syncing ? "Syncing…" : "Sync now" }
< / button >
) }
< button onClick = { signOut } disabled = { busy } >
Sign out
< / button >
< / div >
< / >
) }
{ outcome && (
< div className = { outcome . ok ? "sync-ok" : "sync-bad" } >
{ outcome . ok ? "✓ " : "✕ " }
{ outcome . message }
< / div >
) }
{ sync . signed_in && devices && devices . length > 0 && (
< div className = "field" >
< label > Signed - in devices < / label >
< ul className = "sync-devices" >
{ devices . map ( ( d ) = > (
< li key = { d . id } >
< span className = "sync-device-name" >
{ d . name }
{ d . current && < span className = "badge" > This device < / span > }
< / span >
< span className = "hint" > seen { formatRelativeTime ( d . last_seen_at ) } < / span >
{ ! d . current && (
< button
className = "link-button"
onClick = { ( ) = > setConfirm ( { kind : "revoke" , device : d } ) }
disabled = { busy }
>
Sign out
< / button >
) }
< / li >
) ) }
< / ul >
< / div >
) }
< div className = "field" >
< label > Backup < / label >
< div className = "row" style = { { gap : "var(--space-3)" , flexWrap : "wrap" } } >
< button onClick = { exportLibrary } disabled = { busy } >
Export library …
< / button >
< button onClick = { ( ) = > importLibrary ( ) } disabled = { busy } >
Import library file …
< / button >
< / div >
< span className = "hint" >
Import combines a backup , or a library . json from an older version , with this one .
< / span >
< / div >
{ / * O n l y s h o w n w h i l e a n o l d l i b r a r y . d b i s s t i l l w a i t i n g ; t h e o f f e r
disappears for good once it ' s been imported . * / }
{ sync . legacy_library && (
< >
< div className = "hint" >
An older library from a previous version is still on this device . Import it to bring
your collection across .
< / div >
< div className = "sync-folder" > { sync . legacy_library } < / div >
< div className = "row" >
< button
className = "primary"
onClick = { ( ) = > importLibrary ( sync . legacy_library ! ) }
disabled = { busy }
>
Import old library
< / button >
< / div >
< / >
) }
{ confirm ? . kind === "use_server" && (
< ConfirmDialog
title = "Use the server's library?"
message = "This device's library will be replaced by the one on the server. Anything only on this device will be lost — export a backup first if you might want it."
confirmLabel = "Replace this device's library"
danger
onCancel = { ( ) = > setConfirm ( null ) }
onConfirm = { ( ) = > {
setConfirm ( null ) ;
connect ( "use_server" ) ;
} }
/ >
) }
{ confirm ? . kind === "revoke" && (
< ConfirmDialog
title = { ` Sign out ${ confirm . device . name } ? ` }
message = "It will stop syncing until someone signs in on it again. Its library stays on it."
confirmLabel = "Sign out device"
onCancel = { ( ) = > setConfirm ( null ) }
onConfirm = { ( ) = > {
const d = confirm . device ;
setConfirm ( null ) ;
revoke ( d ) ;
} }
/ >
) }
< / >
) ;
}