diff --git a/.gitignore b/.gitignore index 2b11039..f0ae9cc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,15 @@ node_modules/ /dist/ -# Tauri generated -/src-tauri/gen/ +# Tauri generated. `gen/android` is deliberately tracked: it holds the Gradle +# project and AndroidManifest.xml, which we hand-edit (permissions have no +# tauri.conf.json equivalent) and must not lose to a re-init. +/src-tauri/gen/* +!/src-tauri/gen/android +/src-tauri/gen/android/.gradle/ +/src-tauri/gen/android/build/ +/src-tauri/gen/android/app/build/ +/src-tauri/gen/android/local.properties # Local databases (reference.db is rebuildable; library.db is your synced data) *.db diff --git a/README.md b/README.md index ef9e7f2..0da0b46 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,53 @@ npm run build # typecheck + build frontend cargo build # build everything incl. the desktop app ``` -## Mobile (future) +## Android -The core and frontend are platform-agnostic; `cargo tauri icon` already emitted -iOS/Android icon sets. Porting is adding `tauri ios` / `tauri android` targets — -only reference-data provisioning differs (download on-device). +The Rust core and frontend are platform-agnostic, so the Android build shares +all app logic with the desktop; only data provisioning differs. + +### One-time setup + +Install Android Studio and **launch it once** so its wizard downloads the SDK, +then: + +```bash +./scripts/android-setup.sh +``` + +That adds what Android Studio doesn't install by default: the SDK +command-line tools, a pinned stable **NDK**, and the Rust Android targets. It +prints the three environment variables to export (`ANDROID_HOME`, `NDK_HOME`, +`JAVA_HOME`). + +> **Why the NDK?** `mtgc-core` builds `rusqlite` with the `bundled` feature +> (compiles SQLite from C) and `rustls` pulls in `ring` (C/asm), so a C +> cross-toolchain is required. The bundled SQLite can't be swapped for +> Android's system libsqlite3 either — the card schema needs FTS5 with the +> `trigram` tokenizer, which platform SQLite often lacks. + +> **Why `JAVA_HOME`?** Gradle/AGP reject very new JDKs. The script prefers +> Android Studio's bundled JetBrains Runtime (JDK 21), which needs no extra +> install. + +### Build and run + +```bash +cargo tauri android init # once — scaffolds src-tauri/gen/android +cargo tauri android dev # run on an emulator or adb-connected device +cargo tauri android build --apk +``` + +`src-tauri/gen/android` is committed (unlike the rest of `gen/`) because +`AndroidManifest.xml` is hand-edited — Android permissions have no +`tauri.conf.json` equivalent. + +### Card data on a phone + +Downloading card data streams ~740MB of JSON to build a ~73MB database, which +is slow on mobile and may be killed if the app is backgrounded. Faster: copy +`reference.db` from a desktop install onto the device and use **Import card +data…**. The file is validated before it replaces anything. ## Branding diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 6ff210f..81355e1 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -76,6 +76,44 @@ pub fn open_reference_for_build(path: &Path) -> Result { Ok(conn) } +/// Sanity-check that `path` is a usable reference db and report how many oracle +/// cards it holds. +/// +/// Used before adopting an externally-supplied file — e.g. a `reference.db` +/// copied over from a desktop install, which is how mobile avoids a ~740MB +/// on-device ingest. Without this check, handing over the wrong file (or a +/// truncated copy) would be accepted silently and only surface later as every +/// query failing. Opened read-only so inspecting a file can't alter it. +pub fn inspect_reference_db(path: &Path) -> Result { + let conn = Connection::open_with_flags( + path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + )?; + // The tables every card query depends on. `oracle_names_fts` is included + // because it's an FTS5 virtual table: if the SQLite that opens this file + // lacks FTS5, it won't be listed and we want to fail here with a clear + // message rather than at first search. + for table in ["oracle_cards", "printings", "oracle_names_fts"] { + let found: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name = ?1", + [table], + |r| r.get(0), + )?; + if found == 0 { + return Err(crate::error::CoreError::Other(format!( + "not a valid mtgc card database (missing '{table}')" + ))); + } + } + let count: i64 = conn.query_row("SELECT COUNT(*) FROM oracle_cards", [], |r| r.get(0))?; + if count == 0 { + return Err(crate::error::CoreError::Other( + "card database is empty".into(), + )); + } + Ok(count) +} + /// Open the library db at `library_path`, run migrations, and ATTACH the /// reference db at `reference_path` under the [`REF_DB`] alias. pub fn open(library_path: &Path, reference_path: &Path) -> Result { @@ -166,4 +204,39 @@ mod tests { .unwrap(); assert_eq!(hit, 1); } + + #[test] + fn inspect_reference_db_accepts_a_real_db_and_rejects_bad_ones() { + let dir = tempfile::tempdir().unwrap(); + + // A schema-correct db with cards in it: reports the card count. + let good = dir.path().join("good.db"); + { + let conn = open_reference_for_build(&good).unwrap(); + conn.execute( + "INSERT INTO oracle_cards (oracle_id,name,name_lower) VALUES ('a','Sol Ring','sol ring')", + [], + ) + .unwrap(); + } + assert_eq!(inspect_reference_db(&good).unwrap(), 1); + + // Right schema, no cards — a truncated/aborted build, not usable. + let empty = dir.path().join("empty.db"); + open_reference_for_build(&empty).unwrap(); + assert!(inspect_reference_db(&empty).is_err()); + + // A valid SQLite file that isn't ours (e.g. the user picked library.db). + let wrong = dir.path().join("wrong.db"); + { + let conn = Connection::open(&wrong).unwrap(); + migrate_library(&conn).unwrap(); + } + assert!(inspect_reference_db(&wrong).is_err()); + + // Not a database at all. + let junk = dir.path().join("junk.db"); + std::fs::write(&junk, b"definitely not sqlite").unwrap(); + assert!(inspect_reference_db(&junk).is_err()); + } } diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index c354d2b..478a0d9 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -152,7 +152,11 @@ pub fn build_reference_db( let result = build_into(&tmp, cancel, &mut progress); match &result { Ok(_) => { - let _ = std::fs::remove_file(dest); + // Rename straight over `dest`: POSIX rename replaces the + // destination atomically, so there is never a moment with no + // reference.db. (Deleting `dest` first would open a window where a + // process kill — far likelier on Android, which reaps backgrounded + // apps — leaves the user with no card data at all.) std::fs::rename(&tmp, dest)?; } Err(_) => { @@ -331,8 +335,23 @@ struct BulkUris { default_size: u64, } +/// HTTP agent with timeouts. Without them a stalled connection hangs forever: +/// cancellation is only polled per *parsed record*, so if no bytes arrive, no +/// records parse and `CancelToken` is never consulted — the ingest wedges with +/// no way out. Mobile networks stall like this routinely. +/// +/// `timeout_read` is per read call, not for the whole transfer, so a +/// multi-hundred-megabyte download is fine as long as it keeps making progress. +fn agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(30)) + .timeout_read(std::time::Duration::from_secs(60)) + .build() +} + fn fetch_bulk_uris() -> Result { - let index: BulkIndex = ureq::get(BULK_INDEX_URL) + let index: BulkIndex = agent() + .get(BULK_INDEX_URL) .set("User-Agent", USER_AGENT) .set("Accept", "application/json") .call()? @@ -356,7 +375,7 @@ fn fetch_bulk_uris() -> Result { } fn http_reader(url: &str) -> Result { - let resp = ureq::get(url).set("User-Agent", USER_AGENT).call()?; + let resp = agent().get(url).set("User-Agent", USER_AGENT).call()?; Ok(BufReader::with_capacity(1 << 20, resp.into_reader())) } diff --git a/scripts/android-setup.sh b/scripts/android-setup.sh new file mode 100755 index 0000000..e6a07ef --- /dev/null +++ b/scripts/android-setup.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# android-setup.sh — one-time Android build environment setup for mtgc. +# +# Installs the pieces `cargo tauri android` needs that Android Studio does NOT +# install by default: the SDK command-line tools, the NDK (required to +# cross-compile the Rust core — see "Why the NDK is mandatory" below), and the +# Rust Android targets. +# +# Assumes the Android SDK itself already exists (Android Studio's first-run +# wizard creates it at ~/Android/Sdk). Set ANDROID_HOME to override. +# +# Safe to re-run: every step is skipped if already satisfied. +# +# Usage: +# ./scripts/android-setup.sh +# ANDROID_HOME=/opt/android-sdk ./scripts/android-setup.sh +# NDK_VERSION=27.2.12479018 ./scripts/android-setup.sh # pin instead of latest +# +# Why the NDK is mandatory (not just nice to have): mtgc-core builds rusqlite +# with the `bundled` feature, which compiles the SQLite C amalgamation, and +# rustls pulls in `ring` (C/asm). Both need a C cross-toolchain. The bundled +# SQLite can't be swapped for Android's system libsqlite3 either — the card +# schema uses FTS5 with the `trigram` tokenizer, which platform SQLite on many +# Android versions lacks entirely. +# +set -euo pipefail + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[err]\033[0m %s\n' "$*" >&2; } + +ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" +# Bump if Google publishes a newer bundle; the URL pattern is stable. +CMDLINE_TOOLS_BUILD="${CMDLINE_TOOLS_BUILD:-13114758}" +CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_BUILD}_latest.zip" + +# Pinned to a known-good *stable* NDK on purpose. sdkmanager also publishes beta +# and canary channels whose version numbers sort higher (e.g. 30.0.15729638 is +# r30-beta2), so "install the newest" silently lands you on a prerelease +# toolchain — not what you want compiling SQLite and ring. Override with +# NDK_VERSION=... if you need a different one. +DEFAULT_NDK_VERSION="28.2.13676358" + +RUST_TARGETS=( + aarch64-linux-android # 64-bit ARM — virtually all real devices + armv7-linux-androideabi # 32-bit ARM — older devices + i686-linux-android # 32-bit x86 emulator + x86_64-linux-android # 64-bit x86 emulator (the fast one on a desktop) +) + +# --- 1. Prerequisites ------------------------------------------------------ + +if ! command -v rustc >/dev/null 2>&1; then + err "Rust not found. Install from https://rustup.rs and re-run." + exit 1 +fi +info "Rust: $(rustc --version)" + +if cargo tauri --version >/dev/null 2>&1; then + info "Tauri CLI: $(cargo tauri --version 2>&1 | head -1)" +else + err "Tauri CLI not found. Run ./scripts/devel-setup.sh first." + exit 1 +fi + +# --- 2. A JDK the Android Gradle Plugin actually accepts ------------------- +# +# Fedora's default JDK is newer than AGP/Gradle supports (a JDK 25 default fails +# the Gradle build outright), so pin JAVA_HOME to 21 (or 17) for Android work. +# +# Android Studio's bundled JetBrains Runtime is preferred: it's the same JDK +# Studio itself runs Gradle with, so it's guaranteed AGP-compatible, and it +# needs no system package install. Note we test for bin/javac specifically — +# a JRE-only or leftover-empty /usr/lib/jvm directory must not count. + +find_jdk() { + # Explicit override wins. + if [ -n "${ANDROID_JAVA_HOME:-}" ] && [ -x "${ANDROID_JAVA_HOME}/bin/javac" ]; then + echo "$ANDROID_JAVA_HOME"; return 0 + fi + # Android Studio's bundled JBR (Toolbox or standalone install locations). + for d in \ + "$HOME/.local/share/JetBrains/Toolbox/apps/android-studio/jbr" \ + "$HOME/.local/share/JetBrains/Toolbox/apps/android-studio"/*/jbr \ + "$HOME/android-studio/jbr" \ + /opt/android-studio/jbr + do + [ -x "$d/bin/javac" ] && { echo "$d"; return 0; } + done + # Fall back to a system JDK of an accepted major version. + for v in 21 17; do + for d in "/usr/lib/jvm/java-${v}-openjdk" /usr/lib/jvm/java-${v}-openjdk-*; do + [ -x "$d/bin/javac" ] && { echo "$d"; return 0; } + done + done + return 1 +} + +if JDK_PATH="$(find_jdk)"; then + export JAVA_HOME="$JDK_PATH" + info "Android JDK: $JAVA_HOME ($("$JAVA_HOME/bin/java" -version 2>&1 | head -1))" +else + err "No JDK 21 or 17 found (Gradle rejects the newer system default)." + err "Easiest fix: install Android Studio (its bundled JBR is used automatically)," + err "or: sudo dnf install java-21-openjdk-devel" + err "or: ANDROID_JAVA_HOME=/path/to/jdk ./scripts/android-setup.sh" + exit 1 +fi + +# --- 3. Android SDK -------------------------------------------------------- + +if [ ! -d "$ANDROID_HOME" ]; then + err "No Android SDK at $ANDROID_HOME" + err "Install Android Studio and run it once (its setup wizard downloads the SDK)," + err "or set ANDROID_HOME to an existing SDK." + exit 1 +fi +info "Android SDK: $ANDROID_HOME" + +# --- 4. SDK command-line tools (provides sdkmanager) ----------------------- + +SDKMANAGER="$(find "$ANDROID_HOME/cmdline-tools" -name sdkmanager -type f 2>/dev/null | head -1 || true)" + +if [ -z "$SDKMANAGER" ]; then + info "cmdline-tools not present — installing (build $CMDLINE_TOOLS_BUILD)…" + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fL --progress-bar -o "$tmp/cmdline-tools.zip" "$CMDLINE_TOOLS_URL" + unzip -q "$tmp/cmdline-tools.zip" -d "$tmp" + # sdkmanager expects to live at cmdline-tools/latest/, not cmdline-tools/. + mkdir -p "$ANDROID_HOME/cmdline-tools" + rm -rf "$ANDROID_HOME/cmdline-tools/latest" + mv "$tmp/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" +fi +info "sdkmanager: $SDKMANAGER" + +sdk() { "$SDKMANAGER" --sdk_root="$ANDROID_HOME" "$@"; } + +# Accept licenses up front so the installs below don't block on a prompt. +info "Accepting SDK licenses…" +yes 2>/dev/null | sdk --licenses >/dev/null || true + +# --- 5. NDK ---------------------------------------------------------------- + +NDK_VERSION="${NDK_VERSION:-$DEFAULT_NDK_VERSION}" +NDK_DIR="$ANDROID_HOME/ndk/$NDK_VERSION" + +if [ -d "$NDK_DIR" ]; then + info "NDK already installed: $NDK_DIR" +else + info "Installing NDK $NDK_VERSION (large download)…" + sdk --install "ndk;$NDK_VERSION" +fi +[ -d "$NDK_DIR" ] || { err "NDK install did not produce $NDK_DIR"; exit 1; } + +# Surface the actual release name so a prerelease can't slip by unnoticed. +NDK_RELEASE="$(sed -n 's/^Pkg.Revision *= *//p' "$NDK_DIR/source.properties" 2>/dev/null)" +info "NDK: $NDK_DIR (revision ${NDK_RELEASE:-unknown})" +case "$NDK_RELEASE" in + *beta*|*rc*|*canary*) warn "This NDK is a prerelease — pin a stable NDK_VERSION if builds misbehave." ;; +esac + +# --- 6. Rust Android targets ---------------------------------------------- + +installed="$(rustup target list --installed)" +for t in "${RUST_TARGETS[@]}"; do + if grep -qx "$t" <<<"$installed"; then + info "rust target present: $t" + else + info "adding rust target: $t" + rustup target add "$t" + fi +done + +# --- 7. The environment the build needs ------------------------------------ + +cat < + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/MainActivity.kt new file mode 100644 index 0000000..29795c9 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/MainActivity.kt @@ -0,0 +1,11 @@ +package com.garrettmills.mtgc + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } +} diff --git a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..85d0c88 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..13dd214 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a888b33 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a2a838e Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3f8a57f Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..6a4945b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/values/colors.xml b/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/strings.xml b/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..9c26bf3 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + "mtgc" + "mtgc" + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/themes.xml b/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..6a4945b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..607240b --- /dev/null +++ b/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/src-tauri/gen/android/buildSrc/build.gradle.kts b/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..5c55bba --- /dev/null +++ b/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/BuildTask.kt new file mode 100644 index 0000000..a1b43c0 --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """cargo"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/RustPlugin.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/garrettmills/mtgc/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/gradle.properties b/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..2a7ec69 --- /dev/null +++ b/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/src-tauri/gen/android/gradlew b/src-tauri/gen/android/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/src-tauri/gen/android/gradlew.bat b/src-tauri/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src-tauri/gen/android/settings.gradle b/src-tauri/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fa74a0b..82f81f9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -27,6 +27,12 @@ pub struct Status { /// Unix timestamp (seconds) of reference.db's last modification — i.e. /// when "Update card data" last completed. `None` if unavailable. pub reference_updated_at: Option, + /// Whether this is a mobile build. The UI uses it to hide affordances that + /// only make sense with an arbitrary-path filesystem (choosing where + /// library.db lives) — see `set_library_path`. Reported from the build + /// target rather than sniffed from the user agent so it can't disagree with + /// what the backend will actually allow. + pub is_mobile: bool, } fn status_of(s: &AppState) -> Status { @@ -41,6 +47,7 @@ fn status_of(s: &AppState) -> Status { reference_ready: s.catalog.is_some(), card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0), reference_updated_at, + is_mobile: cfg!(mobile), } } @@ -89,6 +96,50 @@ pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res { Ok(status_of(&s)) } +/// Adopt an already-built `reference.db` from elsewhere on the device instead of +/// downloading and rebuilding it. +/// +/// This is the practical way to provision card data on mobile: the built db is +/// ~73MB, whereas `update_card_data` streams ~740MB of JSON and can take many +/// minutes — and Android may kill the app partway through. Copy the file from a +/// desktop install and point this at it. +#[tauri::command] +pub fn import_reference_db(db: Db, path: String) -> Res { + let src = PathBuf::from(&path); + let mut s = db.lock().unwrap(); + let dest = s.reference_path.clone(); + + if src == dest { + return Err("That file is already the active card database.".into()); + } + + // Validate before disturbing the working db, so picking the wrong file is a + // harmless error rather than a broken install. + mtgc_core::db::inspect_reference_db(&src).map_err(|e| e.to_string())?; + + // Release the handles on the file we're about to replace. + s.close(); + + // Copy beside the destination, then rename: a same-directory rename is + // atomic, so an interrupted copy can't leave a half-written reference.db. + let tmp = dest.with_extension("db.import"); + let install = || -> std::io::Result<()> { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(&src, &tmp)?; + std::fs::rename(&tmp, &dest) + }; + if let Err(e) = install() { + let _ = std::fs::remove_file(&tmp); + let _ = s.reopen(); // put the previous db back into service + return Err(format!("Couldn't install that card database: {e}")); + } + + s.reopen()?; + Ok(status_of(&s)) +} + /// Cancel an in-progress `update_card_data`, if one is running. No-op otherwise. #[tauri::command] pub fn cancel_update_card_data(db: Db) -> Res<()> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 61e5bb5..eeaef73 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ pub fn run() { commands::set_library_path, commands::update_card_data, commands::cancel_update_card_data, + commands::import_reference_db, commands::search_cards, commands::add_owned, commands::set_owned, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index fa4c24b..7ed6853 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -75,6 +75,14 @@ impl AppState { ); } + /// 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; + } + /// (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. diff --git a/src/App.tsx b/src/App.tsx index 552f004..f322126 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -291,6 +291,27 @@ export default function App() { } } + /** Adopt a reference.db built elsewhere (e.g. copied from a desktop install) + * instead of streaming ~740MB to rebuild it on this device. */ + async function importReferenceDb() { + const path = await open({ + title: "Import a card database (reference.db)", + filters: [{ name: "Card database", extensions: ["db", "sqlite"] }], + }); + if (typeof path !== "string") return; + try { + setStatus(await api.importReferenceDb(path)); + setProgress(null); + } catch (err) { + setProgress({ + stage: "error", + message: `Couldn't import that card database: ${String(err)}`, + overall_percent: 0, + stages: lastProgress.current?.stages ?? [], + }); + } + } + async function openLibrary() { const path = await open({ title: "Open an existing library.db", @@ -378,14 +399,21 @@ export default function App() { {status?.library_path ?? ""} - - + {/* Choosing where library.db lives is desktop-only: on Android the + live db must stay in app-private storage (SQLite can't open a + content:// URI), so these would only ever fail there. */} + {status && !status.is_mobile && ( + <> + + + + )} + ) : tab === "add" ? ( diff --git a/src/api.ts b/src/api.ts index 57a7a09..4771f8b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -102,6 +102,10 @@ export interface Status { reference_ready: boolean; card_count: number; reference_updated_at: number | null; // unix seconds + /** True on mobile builds. Used to hide affordances that depend on choosing an + * arbitrary filesystem path for library.db — Android scoped storage has no + * equivalent, and SQLite can't be backed by a content:// URI. */ + is_mobile: boolean; } export interface LibraryStats { @@ -180,6 +184,8 @@ export const api = { setLibraryPath: (path: string) => invoke("set_library_path", { path }), updateCardData: () => invoke("update_card_data"), cancelUpdateCardData: () => invoke("cancel_update_card_data"), + /** Adopt an already-built reference.db from `path` instead of downloading it. */ + importReferenceDb: (path: string) => invoke("import_reference_db", { path }), searchCards: (query: string, limit = 20) => invoke("search_cards", { query, limit }), diff --git a/src/styles.css b/src/styles.css index c5261d5..e72b88f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -317,7 +317,12 @@ input[type="checkbox"] { .app { display: flex; + /* `dvh` tracks the *dynamic* viewport, so an on-screen keyboard (or a mobile + browser's collapsing chrome) shrinks the shell instead of pushing content + out of an over-tall, unscrollable `100vh` box. Falls back for older + engines that don't know dvh. */ height: 100vh; + height: 100dvh; overflow: hidden; } diff --git a/vite.config.ts b/vite.config.ts index 5dc2a3e..ef2dd62 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +// On mobile, the dev server has to be reachable from the phone/emulator rather +// than only from localhost. `tauri android dev` sets TAURI_DEV_HOST to the +// address the device should dial; when it's absent (desktop) we keep the +// localhost-only default. +const devHost = process.env.TAURI_DEV_HOST; + // Vite config tuned for Tauri: fixed port, no clearing the terminal Tauri owns. export default defineConfig({ plugins: [react()], @@ -8,6 +14,10 @@ export default defineConfig({ server: { port: 1420, strictPort: true, + host: devHost || false, + // HMR must also point at the reachable address, or the device loads the app + // but can never connect back for updates. + hmr: devHost ? { protocol: "ws", host: devHost, port: 1421 } : undefined, watch: { // Don't watch the Rust side; Tauri handles that. ignored: ["**/src-tauri/**"],