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:
335
Cargo.lock
generated
335
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
92
src-tauri/src/share.rs
Normal 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()
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user