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

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()
}