Share a card's image from the preview

A share button beside the preview's close button sends the face being
shown at Scryfall's large size: on desktop the image is decoded and put on
the clipboard (tauri-plugin-clipboard-manager); on Android it's saved to
the cache and handed to the system share sheet by a small in-repo plugin
(SharePlugin.kt) through the app's FileProvider. The preview's corner
buttons are also a little larger.
This commit is contained in:
2026-10-24 15:20:27 -05:00
parent 7415289257
commit 226878fdd7
10 changed files with 597 additions and 5 deletions

335
Cargo.lock generated
View File

@@ -59,6 +59,27 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arboard"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "argon2"
version = "0.5.3"
@@ -431,6 +452,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.0"
@@ -581,6 +608,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clipboard-win"
version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -1013,13 +1049,19 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser",
"foldhash",
"foldhash 0.2.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1210,6 +1252,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "event-listener"
version = "5.4.2"
@@ -1248,6 +1296,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1289,6 +1343,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1305,6 +1365,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1541,6 +1607,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1739,6 +1815,17 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1754,6 +1841,15 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -2069,6 +2165,22 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2437,15 +2549,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "mtgc"
version = "0.1.0"
dependencies = [
"image",
"mtgc-core",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
@@ -2547,6 +2671,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2690,6 +2823,7 @@ dependencies = [
"block2",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
]
@@ -2949,6 +3083,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "p256"
version = "0.13.2"
@@ -3053,6 +3197,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.14.0",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -3304,6 +3459,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -4559,6 +4726,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4136fb69d967753d000423d7e5f863f89bf949efbdfbecb43a580426a01a0194"
dependencies = [
"arboard",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.10"
@@ -4816,6 +4998,20 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.53"
@@ -5180,6 +5376,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -5481,6 +5688,76 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.0",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
dependencies = [
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
dependencies = [
"bitflags 2.13.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
"quick-xml",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.103"
@@ -5611,6 +5888,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6096,6 +6379,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.18",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.3"
@@ -6167,6 +6468,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "yoke"
version = "0.8.3"
@@ -6337,6 +6655,21 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.13.1"

View File

@@ -51,3 +51,24 @@ pub(crate) fn get_json<T: serde::de::DeserializeOwned>(url: &str) -> Result<T> {
.call()?
.into_json()?)
}
/// Most bytes [`fetch_image`] will read. Scryfall's largest card scans are a
/// few hundred kilobytes; this only stops a bad URL streaming forever.
const MAX_IMAGE_BYTES: u64 = 20 << 20;
/// GET a card image (a Scryfall scan) as bytes, for sharing or copying it.
/// HTTPS only.
pub fn fetch_image(url: &str) -> Result<Vec<u8>> {
if !url.starts_with("https://") {
return Err(crate::error::CoreError::Other(format!("not an https image URL: {url}")));
}
let mut bytes = Vec::new();
agent()
.get(url)
.set("User-Agent", USER_AGENT)
.call()?
.into_reader()
.take(MAX_IMAGE_BYTES)
.read_to_end(&mut bytes)?;
Ok(bytes)
}

View File

@@ -28,6 +28,7 @@ use std::path::Path;
use rusqlite::Connection;
pub use error::{CoreError, Result};
pub use http::fetch_image;
pub use model::{
CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryCopy,
LibraryFacets, LibraryFilter, LibraryStats, PrintingRef,

View File

@@ -25,6 +25,11 @@ mtgc-core = { path = "../crates/mtgc-core" }
tauri-plugin-opener = "2.5.4"
url = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
# Copying a card image: the clipboard takes raw pixels, and Scryfall serves JPEGs.
tauri-plugin-clipboard-manager = "2"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
[target.'cfg(target_os = "android")'.dependencies]
# Delivers sync sign-ins back to the app through the `mtgc://` scheme.
tauri-plugin-deep-link = "2"

View File

@@ -0,0 +1,51 @@
package com.garrettmills.mtgc
import android.app.Activity
import android.content.ClipData
import android.content.Intent
import androidx.core.content.FileProvider
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.Plugin
import java.io.File
@InvokeArg
class ShareImageArgs {
/** A JPEG under the app's cache dir, which the FileProvider exposes. */
lateinit var path: String
var title: String? = null
}
/**
* The Android half of the app's `share` plugin (src-tauri/src/share.rs):
* opens the system share sheet for a card image the Rust side has saved.
*/
@TauriPlugin
class SharePlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun shareImage(invoke: Invoke) {
try {
val args = invoke.parseArgs(ShareImageArgs::class.java)
val uri = FileProvider.getUriForFile(
activity,
"${activity.packageName}.fileprovider",
File(args.path),
)
val send = Intent(Intent.ACTION_SEND).apply {
type = "image/jpeg"
putExtra(Intent.EXTRA_STREAM, uri)
args.title?.let { putExtra(Intent.EXTRA_TITLE, it) }
// ClipData lets the chooser show a preview of the image, and carries
// the read grant along with it.
clipData = ClipData.newRawUri(args.title, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
activity.startActivity(Intent.createChooser(send, args.title))
invoke.resolve()
} catch (ex: Exception) {
invoke.reject(ex.message)
}
}
}

View File

@@ -1,6 +1,7 @@
mod commands;
mod libraries;
mod news;
mod share;
mod state;
mod sync;
@@ -38,7 +39,10 @@ pub(crate) fn logged<T>(what: &str, result: Result<T, String>) -> Result<T, Stri
pub fn run() {
let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init());
.plugin(tauri_plugin_opener::init())
.plugin(share::init());
#[cfg(desktop)]
let builder = builder.plugin(tauri_plugin_clipboard_manager::init());
// Android gets sync sign-ins back through the `mtgc://` scheme; desktop
// uses a loopback port instead and needs no scheme registered.
#[cfg(target_os = "android")]
@@ -66,6 +70,7 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
commands::get_status,
share::share_card_image,
commands::update_card_data,
commands::cancel_update_card_data,
commands::import_reference_db,

92
src-tauri/src/share.rs Normal file
View File

@@ -0,0 +1,92 @@
//! Sharing a card's image: onto the clipboard on desktop, through the system
//! share sheet on Android.
//!
//! Android has no official Tauri share plugin, so this registers a small one
//! of our own whose Kotlin half (`SharePlugin.kt`, in the Android app module)
//! hands the image to `ACTION_SEND` via the app's FileProvider.
use tauri::AppHandle;
type Res<T> = Result<T, String>;
/// Scryfall serves each scan at several sizes under the same path; the
/// previews show "normal", but a shared or pasted image deserves "large".
fn large_variant(url: &str) -> String {
if url.starts_with("https://cards.scryfall.io/normal/") {
url.replacen("/normal/", "/large/", 1)
} else {
url.to_string()
}
}
/// Copy (desktop) or share (Android) the card image at `url`. Returns what
/// happened — "copied" or "shared" — for the UI to confirm.
#[tauri::command]
pub async fn share_card_image(app: AppHandle, url: String, name: String) -> Res<&'static str> {
crate::sync::blocking(move || {
let bytes = mtgc_core::fetch_image(&large_variant(&url)).map_err(|e| e.to_string())?;
deliver(&app, &bytes, &name)
})
.await
}
#[cfg(desktop)]
fn deliver(app: &AppHandle, bytes: &[u8], _name: &str) -> Res<&'static str> {
use tauri_plugin_clipboard_manager::ClipboardExt;
// The clipboard takes raw pixels, and Scryfall's scans are JPEGs.
let rgba = image::load_from_memory(bytes).map_err(|e| e.to_string())?.to_rgba8();
let (width, height) = rgba.dimensions();
app.clipboard()
.write_image(&tauri::image::Image::new(rgba.as_raw(), width, height))
.map_err(|e| e.to_string())?;
Ok("copied")
}
#[cfg(target_os = "android")]
fn deliver(app: &AppHandle, bytes: &[u8], name: &str) -> Res<&'static str> {
use tauri::Manager;
// The cache dir is what the FileProvider exposes (res/xml/file_paths.xml).
// Named after the card, since that's what the receiving app shows.
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("shared");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let file_name: String = name
.chars()
.map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' })
.collect();
let path = dir.join(format!("{}.jpg", file_name.trim()));
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
let plugin = app.state::<SharePlugin>();
plugin
.0
.run_mobile_plugin::<()>(
"shareImage",
serde_json::json!({ "path": path.to_string_lossy(), "title": name }),
)
.map_err(|e| e.to_string())?;
Ok("shared")
}
#[cfg(target_os = "ios")]
fn deliver(_app: &AppHandle, _bytes: &[u8], _name: &str) -> Res<&'static str> {
Err("Sharing isn't available on iOS yet.".into())
}
/// The Kotlin half's handle, for `run_mobile_plugin`.
#[cfg(target_os = "android")]
struct SharePlugin(tauri::plugin::PluginHandle<tauri::Wry>);
/// Registers the Android share plugin. A no-op plugin elsewhere.
pub fn init() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri::plugin::Builder::new("share")
.setup(|_app, _api| {
#[cfg(target_os = "android")]
{
use tauri::Manager;
let handle = _api.register_android_plugin("com.garrettmills.mtgc", "SharePlugin")?;
_app.manage(SharePlugin(handle));
}
Ok(())
})
.build()
}

View File

@@ -508,6 +508,9 @@ export const api = {
isCommander = false,
isMaybeboard = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
/** Copy (desktop) or share (Android) a card image; says which happened. */
shareCardImage: (url: string, name: string) =>
invoke<"copied" | "shared">("share_card_image", { url, name }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) =>
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),

View File

@@ -98,6 +98,15 @@ const ICON_UPLOAD = icon(
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
</>,
);
const ICON_SHARE = icon(
<>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" />
</>,
);
const ICON_CHECK = icon(<polyline points="20 6 9 17 4 12" />);
// A box (as on the "Not owned" filter) with a plus: add to the library.
const ICON_LIBRARY_ADD = icon(
<>
@@ -156,6 +165,8 @@ export const Icons = {
download: ICON_DOWNLOAD,
upload: ICON_UPLOAD,
libraryAdd: ICON_LIBRARY_ADD,
share: ICON_SHARE,
check: ICON_CHECK,
};
// --- Dialogs ------------------------------------------------------------
@@ -1294,6 +1305,46 @@ function PreviewPaneToggle({
);
}
// Android gets the system share sheet; desktop copies the image instead.
const CAN_SHARE_SHEET = /Android/i.test(navigator.userAgent);
/** Shares the card image being shown — copies it on desktop — and confirms
* with a brief check mark (or the error, in the tooltip). */
function ShareImageButton({ url, name, overlay }: { url: string; name: string; overlay?: boolean }) {
const [state, setState] = useState<"idle" | "busy" | "done" | { error: string }>("idle");
useEffect(() => {
if (state !== "done" && typeof state !== "object") return;
const t = setTimeout(() => setState("idle"), state === "done" ? 1500 : 4000);
return () => clearTimeout(t);
}, [state]);
const idleLabel = CAN_SHARE_SHEET ? "Share image" : "Copy image";
const label =
state === "done"
? CAN_SHARE_SHEET
? "Shared"
: "Copied"
: typeof state === "object"
? `Couldn't ${CAN_SHARE_SHEET ? "share" : "copy"} the image: ${state.error}`
: idleLabel;
return (
<IconButton
icon={state === "done" ? Icons.check : Icons.share}
label={label}
overlay={overlay}
disabled={state === "busy"}
onClick={async () => {
setState("busy");
try {
await api.shareCardImage(url, name);
setState("done");
} catch (err) {
setState({ error: String(err) });
}
}}
/>
);
}
function ClickPreviewModal({
state,
onClose,
@@ -1309,6 +1360,8 @@ function ClickPreviewModal({
const [art, setArt] = useState<CardArt | null>(null);
const card: PreviewCard = art ? { ...state.card, ...art } : state.card;
const hasBack = !!card.back_image_uri;
// What sharing sends: the face being looked at.
const shownImage = showBack && hasBack ? card.back_image_uri : card.image_uri;
const backdropRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const infoRef = useRef<HTMLDivElement>(null);
@@ -1423,6 +1476,9 @@ function ClickPreviewModal({
<span className="preview-modal-close">
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</span>
<span className="preview-modal-share">
<ShareImageButton url={shownImage} name={card.name} overlay />
</span>
{hasBack && (
<button className="preview-modal-flip" onClick={onFlip} title="Show the other side">
⟲ Flip
@@ -1433,7 +1489,10 @@ function ClickPreviewModal({
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
<span className="row" style={{ gap: 8 }}>
<ShareImageButton url={shownImage} name={card.name} overlay />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</span>
</div>
)}
</div>

View File

@@ -2220,6 +2220,7 @@ tbody tr:hover {
max-width: min(720px, 90vw);
}
.preview-modal-compact .preview-modal-close,
.preview-modal-compact .preview-modal-share,
.preview-modal-compact .preview-modal-flip,
.preview-modal-compact:not(.preview-modal-show-info) .preview-info,
.preview-modal-show-info .preview-modal-faces {
@@ -2227,8 +2228,20 @@ tbody tr:hover {
}
.preview-modal-close {
position: absolute;
top: -14px;
right: -14px;
top: -16px;
right: -16px;
}
/* The preview's corner buttons are its main controls, so a little larger
than the overlay buttons on tiles. */
.preview-modal-close .icon-btn,
.preview-modal-share .icon-btn {
width: 32px;
height: 32px;
}
.preview-modal-close .icon-btn svg,
.preview-modal-share .icon-btn svg {
width: 18px;
height: 18px;
}
.preview-modal-close .icon-btn {
box-shadow: var(--shadow-md);
@@ -2237,6 +2250,15 @@ tbody tr:hover {
background: var(--danger);
color: #fff;
}
/* Beside the close button, to its left: past its 32px, plus a 12px gap. */
.preview-modal-share {
position: absolute;
top: -16px;
right: 28px;
}
.preview-modal-share .icon-btn {
box-shadow: var(--shadow-md);
}
.preview-modal-flip {
position: absolute;
bottom: 14px;