diff --git a/README.md b/README.md index 1e10727..1bd917e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,23 @@ The frontend API is designed to closely resemble the Web Share API, making it in } } + // Share with an anchor (iPadOS and macOS only) + async function shareFromButton(button: HTMLElement) { + const rect = button.getBoundingClientRect(); + if (await canShare()) { + await share({ + title: "My Project", + url: "https://github.com/vnidrop/plugin-share", + anchor: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }, + }); + } + } + // Share a file (e.g., an image) async function shareFile() { const fileInput = document.querySelector( @@ -152,6 +169,7 @@ After changing plugin permissions or native Android code, fully rebuild and rein title: Some("Rust Share".to_string()), url: None, files: None, + anchor: None, }; // Use the extension trait to access the plugin's API diff --git a/android/src/main/java/SharePlugin.kt b/android/src/main/java/SharePlugin.kt index 9f84db7..f561ffb 100644 --- a/android/src/main/java/SharePlugin.kt +++ b/android/src/main/java/SharePlugin.kt @@ -21,6 +21,14 @@ import app.tauri.plugin.Plugin import java.io.IOException import java.util.UUID +@InvokeArg +class Anchor { + var x: Double? = null + var y: Double? = null + var width: Double? = null + var height: Double? = null +} + @InvokeArg class SharedFile { lateinit var data: String @@ -34,6 +42,7 @@ class ShareOptions { var title: String? = null var url: String? = null var files: List? = null + var anchor: Anchor? = null } @TauriPlugin diff --git a/api-iife.js b/api-iife.js index 01e58b7..545c3ba 100644 --- a/api-iife.js +++ b/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_SHARE__=function(e){"use strict";async function t(e,t={},r){return window.__TAURI_INTERNALS__.invoke(e,t,r)}"function"==typeof SuppressedError&&SuppressedError;const r=52428800,n=104857600;function i(e){return Boolean(e.text||e.url||e.files&&e.files.length>0)}function o(e){return(new TextEncoder).encode(e).length}function s(e){if(e.text&&o(e.text)>65536)throw new TypeError("text exceeds the maximum length of 65536 bytes.");if(e.title&&o(e.title)>1024)throw new TypeError("title exceeds the maximum length of 1024 bytes.");if(void 0!==e.url){if(o(e.url)>4096)throw new TypeError("url exceeds the maximum length of 4096 bytes.");if(e.url.length>0&&!function(e){if(e.trim()!==e||/[\s\u0000-\u001f\u007f]/u.test(e))return!1;const t=e.indexOf("://");if(-1===t)return!1;const r=e.slice(t+3).split(/[/?#]/u,1)[0].split("@").pop()??"";if(!r||r.startsWith(":"))return!1;try{const t=new URL(e);return("http:"===t.protocol||"https:"===t.protocol)&&t.host.length>0}catch{return!1}}(e.url))throw new TypeError("Only http:// and https:// URLs can be shared as URLs.")}if(!e.files||0===e.files.length)return;if(e.files.length>16)throw new TypeError("Too many files provided. Maximum is 16.");if(e.files.reduce((e,t)=>e+t.size,0)>n)throw new TypeError("Total shared file size exceeds the maximum of 104857600 bytes.");const t=e.files.find(e=>e.size>r);if(t)throw new TypeError(`File '${t.name}' exceeds the maximum size of 52428800 bytes.`);if(e.files.find(e=>o(e.name)>255))throw new TypeError("File name exceeds the maximum length of 255 bytes.");if(e.files.find(e=>o(e.type||"application/octet-stream")>255))throw new TypeError("mime type exceeds the maximum length of 255 bytes.")}async function a(e){return new Promise((t,r)=>{const n=new FileReader;n.readAsDataURL(e),n.onload=()=>{const e=n.result.split(",")[1];t(e)},n.onerror=e=>r(e)})}return e.canShare=async function(e){if(e&&!i(e))return!1;if(e)try{s(e)}catch{return!1}const r=await t("plugin:vnidrop-share|can_share");return!0===r.value||"true"===r.value},e.cleanup=async function(){await t("plugin:vnidrop-share|cleanup")},e.share=async function(e){if(!i(e))throw new TypeError("No content provided to share.");s(e);const r={text:e.text,title:e.title,url:e.url};e.files&&e.files.length>0&&(r.files=await Promise.all(e.files.map(async e=>({data:await a(e),name:e.name,mimeType:e.type||"application/octet-stream"})))),await t("plugin:vnidrop-share|share",{options:r})},e}({});Object.defineProperty(window.__TAURI__,"share",{value:__TAURI_PLUGIN_SHARE__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_SHARE__=function(e){"use strict";async function t(e,t={},r){return window.__TAURI_INTERNALS__.invoke(e,t,r)}"function"==typeof SuppressedError&&SuppressedError;const r=52428800,n=104857600;function i(e){return Boolean(e.text||e.url||e.files&&e.files.length>0)}function o(e){return(new TextEncoder).encode(e).length}function s(e){if(e.text&&o(e.text)>65536)throw new TypeError("text exceeds the maximum length of 65536 bytes.");if(e.title&&o(e.title)>1024)throw new TypeError("title exceeds the maximum length of 1024 bytes.");if(void 0!==e.url){if(o(e.url)>4096)throw new TypeError("url exceeds the maximum length of 4096 bytes.");if(e.url.length>0&&!function(e){if(e.trim()!==e||/[\s\u0000-\u001f\u007f]/u.test(e))return!1;const t=e.indexOf("://");if(-1===t)return!1;const r=e.slice(t+3).split(/[/?#]/u,1)[0].split("@").pop()??"";if(!r||r.startsWith(":"))return!1;try{const t=new URL(e);return("http:"===t.protocol||"https:"===t.protocol)&&t.host.length>0}catch{return!1}}(e.url))throw new TypeError("Only http:// and https:// URLs can be shared as URLs.")}if(!e.files||0===e.files.length)return;if(e.files.length>16)throw new TypeError("Too many files provided. Maximum is 16.");if(e.files.reduce((e,t)=>e+t.size,0)>n)throw new TypeError("Total shared file size exceeds the maximum of 104857600 bytes.");const t=e.files.find(e=>e.size>r);if(t)throw new TypeError(`File '${t.name}' exceeds the maximum size of 52428800 bytes.`);if(e.files.find(e=>o(e.name)>255))throw new TypeError("File name exceeds the maximum length of 255 bytes.");if(e.files.find(e=>o(e.type||"application/octet-stream")>255))throw new TypeError("mime type exceeds the maximum length of 255 bytes.")}async function a(e){return new Promise((t,r)=>{const n=new FileReader;n.readAsDataURL(e),n.onload=()=>{const e=n.result.split(",")[1];t(e)},n.onerror=e=>r(e)})}return e.canShare=async function(e){if(e&&!i(e))return!1;if(e)try{s(e)}catch{return!1}const r=await t("plugin:vnidrop-share|can_share");return!0===r.value||"true"===r.value},e.cleanup=async function(){await t("plugin:vnidrop-share|cleanup")},e.share=async function(e){if(!i(e))throw new TypeError("No content provided to share.");s(e);const r={text:e.text,title:e.title,url:e.url};e.files&&e.files.length>0&&(r.files=await Promise.all(e.files.map(async e=>({data:await a(e),name:e.name,mimeType:e.type||"application/octet-stream"})))),e.anchor&&(r.anchor=e.anchor),await t("plugin:vnidrop-share|share",{options:r})},e}({});Object.defineProperty(window.__TAURI__,"share",{value:__TAURI_PLUGIN_SHARE__})} diff --git a/guest-js/index.ts b/guest-js/index.ts index 398afad..7c091ad 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -22,6 +22,13 @@ const MAX_MIME_TYPE_BYTES = 255; * }; * ``` */ +export interface ShareRect { + x: number; + y: number; + width: number; + height: number; +} + export interface ShareData { /** Optional array of File objects to share (e.g., images, PDFs). */ files?: File[]; @@ -31,6 +38,11 @@ export interface ShareData { title?: string; /** Optional URL to be shared. */ url?: string; + /** + * Optional source rectangle for iPadOS and macOS popovers, in web-viewport + * coordinates (pixels from the top-left). + */ + anchor?: ShareRect; } function hasShareableContent(data: ShareData): boolean { @@ -240,5 +252,9 @@ export async function share(data: ShareData): Promise { ); } + if (data.anchor) { + payload.anchor = data.anchor; + } + await invoke("plugin:vnidrop-share|share", { options: payload }); } diff --git a/ios/Sources/ShareCore/ShareModels.swift b/ios/Sources/ShareCore/ShareModels.swift index 588de8b..37cc4b7 100644 --- a/ios/Sources/ShareCore/ShareModels.swift +++ b/ios/Sources/ShareCore/ShareModels.swift @@ -1,3 +1,17 @@ +public struct ShareAnchor: Decodable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } +} + public struct SharedFile: Decodable { public let data: String public let name: String @@ -15,11 +29,13 @@ public struct ShareOptions: Decodable { public var title: String? public var url: String? public var files: [SharedFile]? + public var anchor: ShareAnchor? - public init(text: String? = nil, title: String? = nil, url: String? = nil, files: [SharedFile]? = nil) { + public init(text: String? = nil, title: String? = nil, url: String? = nil, files: [SharedFile]? = nil, anchor: ShareAnchor? = nil) { self.text = text self.title = title self.url = url self.files = files + self.anchor = anchor } } diff --git a/ios/Sources/SharePlugin.swift b/ios/Sources/SharePlugin.swift index 372130f..dba9fdb 100644 --- a/ios/Sources/SharePlugin.swift +++ b/ios/Sources/SharePlugin.swift @@ -11,9 +11,14 @@ func initPlugin() -> Plugin { public class SharePlugin: Plugin { + private var webview: WKWebView? private var temporaryFileURLs: [URL] = [] private var shareInProgress = false + open override func load(webview: WKWebView) { + self.webview = webview + } + @objc func canShare(_ invoke: Invoke) throws { // The native share sheet is always available on iOS. invoke.resolve(["value": true]) @@ -100,7 +105,7 @@ public class SharePlugin: Plugin { } temporaryFileURLs.append(contentsOf: createdFileURLs) - presentShareSheet(invoke: invoke, activityItems: activityItems) + presentShareSheet(invoke: invoke, activityItems: activityItems, anchor: args.anchor) } /** @@ -138,7 +143,7 @@ public class SharePlugin: Plugin { return safeDir.appendingPathComponent(finalFileName) } - private func presentShareSheet(invoke: Invoke, activityItems: [Any]) { + private func presentShareSheet(invoke: Invoke, activityItems: [Any], anchor: ShareAnchor? = nil) { DispatchQueue.main.async { guard let viewController = self.manager.viewController else { _ = self.resetShareState() @@ -154,6 +159,9 @@ public class SharePlugin: Plugin { } let activityViewController = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + if UIDevice.current.userInterfaceIdiom == .pad { + activityViewController.modalPresentationStyle = .popover + } activityViewController.completionWithItemsHandler = { _, _, _, error in DispatchQueue.main.async { @@ -170,9 +178,26 @@ public class SharePlugin: Plugin { // iPad presentation logic if let popoverController = activityViewController.popoverPresentationController { - popoverController.sourceView = viewController.view - popoverController.sourceRect = CGRect(x: viewController.view.bounds.midX, y: viewController.view.bounds.midY, width: 0, height: 0) - popoverController.permittedArrowDirections = [] + guard let sourceView = self.webview ?? viewController.view else { + let filesToClean = self.resetShareState() + self.cleanupTemporaryFiles(filesToClean) + invoke.reject("Could not find a source view for the share popover.") + return + } + popoverController.sourceView = sourceView + + if let anchor = anchor { + popoverController.sourceRect = CGRect( + x: anchor.x, + y: anchor.y, + width: anchor.width, + height: anchor.height + ) + popoverController.permittedArrowDirections = [.up, .down] + } else { + popoverController.sourceRect = CGRect(x: sourceView.bounds.midX, y: sourceView.bounds.midY, width: 0, height: 0) + popoverController.permittedArrowDirections = [] + } } viewController.present(activityViewController, animated: true, completion: nil) diff --git a/ios/test-support/tauri-api/Sources/Tauri/Tauri.swift b/ios/test-support/tauri-api/Sources/Tauri/Tauri.swift index adf89ad..50a7b26 100644 --- a/ios/test-support/tauri-api/Sources/Tauri/Tauri.swift +++ b/ios/test-support/tauri-api/Sources/Tauri/Tauri.swift @@ -1,4 +1,5 @@ import Foundation +import WebKit public typealias JsonObject = [String: Any?] @@ -8,6 +9,8 @@ open class Plugin: NSObject { public required override init() { super.init() } + + @objc open func load(webview: WKWebView) {} } open class PluginManager: NSObject { diff --git a/src/models.rs b/src/models.rs index 00b1034..cf68df7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -11,6 +11,16 @@ pub const MAX_URL_BYTES: usize = 4096; pub const MAX_FILE_NAME_BYTES: usize = 255; pub const MAX_MIME_TYPE_BYTES: usize = 255; +/// Represents a rectangle in web-viewport coordinates used to anchor a share popover. +#[derive(Debug, Deserialize, Serialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub struct ShareAnchor { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + /// Represents a file to be shared, including its content, name, and MIME type. /// /// The `data` field holds the Base64 encoded content of the file. This approach @@ -65,6 +75,8 @@ pub struct ShareOptions { pub url: Option, /// A list of files to share, each represented by a `SharedFile` struct. pub files: Option>, + /// Optional source rectangle used to anchor the share popover on iPadOS and macOS. + pub anchor: Option, } impl ShareOptions { @@ -232,6 +244,7 @@ mod tests { title: None, url: url.map(ToString::to_string), files, + anchor: None, } } @@ -301,6 +314,7 @@ mod tests { title: None, url: None, files: None, + anchor: None, } .validate() .is_err()); diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 4b2c197..7ae38ab 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -202,18 +202,33 @@ pub fn share( unsafe { picker.setDelegate(Some(ProtocolObject::from_ref(&*delegate))) }; let bounds = ns_view.bounds(); + // Web-viewport coordinates are top-left origin; AppKit bounds use a bottom-left origin. + // Flip the y-axis so the anchor rect describes the same visual area in NSView space. + let rect = options.anchor.map_or( + CGRect { + origin: CGPoint { + x: bounds.size.width / 2.0, + y: bounds.size.height / 2.0, + }, + size: CGSize { + width: 0.0, + height: 0.0, + }, + }, + |anchor| CGRect { + origin: CGPoint { + x: anchor.x, + y: bounds.size.height - anchor.y - anchor.height, + }, + size: CGSize { + width: anchor.width, + height: anchor.height, + }, + }, + ); unsafe { picker.showRelativeToRect_ofView_preferredEdge( - CGRect { - origin: CGPoint { - x: bounds.size.width / 2.0, - y: bounds.size.height / 2.0, - }, - size: CGSize { - width: 0.0, - height: 0.0, - }, - }, + rect, &ns_view, objc2_foundation::NSRectEdge::NSMinYEdge, ); diff --git a/tests/js/index.test.ts b/tests/js/index.test.ts index 985019f..8b6e68f 100644 --- a/tests/js/index.test.ts +++ b/tests/js/index.test.ts @@ -74,6 +74,26 @@ describe('share guest API', () => { }) }) + it('passes an anchor option to the native share command', async () => { + const { share } = await import('../../guest-js/index') + + await share({ + title: 'Greeting', + text: 'Share this', + url: 'https://example.com', + anchor: { x: 10, y: 20, width: 120, height: 44 }, + }) + + expect(tauriCoreMock.invoke).toHaveBeenCalledWith('plugin:vnidrop-share|share', { + options: { + title: 'Greeting', + text: 'Share this', + url: 'https://example.com', + anchor: { x: 10, y: 20, width: 120, height: 44 }, + }, + }) + }) + it('rejects non-web url schemes in the url field', async () => { const { share } = await import('../../guest-js/index') diff --git a/tests/model_contracts.rs b/tests/model_contracts.rs index 520080f..71481be 100644 --- a/tests/model_contracts.rs +++ b/tests/model_contracts.rs @@ -13,6 +13,7 @@ fn public_model_validation_accepts_local_file_bytes_without_url() { name: "hello.txt".to_string(), mime_type: "text/plain".to_string(), }]), + anchor: None, }; assert!(options.has_shareable_content()); @@ -33,6 +34,7 @@ fn public_model_validation_rejects_non_web_url_schemes() { title: None, url: Some(url.to_string()), files: None, + anchor: None, } .validate() .is_err()); @@ -47,6 +49,7 @@ fn public_model_validation_enforces_limits() { title: None, url: None, files: None, + anchor: None, } .validate() .is_err()); @@ -57,6 +60,7 @@ fn public_model_validation_enforces_limits() { title: None, url: Some(oversized_url), files: None, + anchor: None, } .validate() .is_err()); @@ -73,6 +77,7 @@ fn public_model_validation_enforces_limits() { title: None, url: None, files: Some(files), + anchor: None, } .validate() .is_err()); @@ -86,6 +91,7 @@ fn public_model_validation_enforces_limits() { name: "a".repeat(MAX_FILE_NAME_BYTES + 1), mime_type: "text/plain".to_string(), }]), + anchor: None, } .validate() .is_err());