Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions android/src/main/java/SharePlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +42,7 @@ class ShareOptions {
var title: String? = null
var url: String? = null
var files: List<SharedFile>? = null
var anchor: Anchor? = null
}

@TauriPlugin
Expand Down
2 changes: 1 addition & 1 deletion api-iife.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions guest-js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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 {
Expand Down Expand Up @@ -240,5 +252,9 @@ export async function share(data: ShareData): Promise<void> {
);
}

if (data.anchor) {
payload.anchor = data.anchor;
}

await invoke("plugin:vnidrop-share|share", { options: payload });
}
18 changes: 17 additions & 1 deletion ios/Sources/ShareCore/ShareModels.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
}
35 changes: 30 additions & 5 deletions ios/Sources/SharePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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)
}

/**
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions ios/test-support/tauri-api/Sources/Tauri/Tauri.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import WebKit

public typealias JsonObject = [String: Any?]

Expand All @@ -8,6 +9,8 @@ open class Plugin: NSObject {
public required override init() {
super.init()
}

@objc open func load(webview: WKWebView) {}
}

open class PluginManager: NSObject {
Expand Down
14 changes: 14 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +75,8 @@ pub struct ShareOptions {
pub url: Option<String>,
/// A list of files to share, each represented by a `SharedFile` struct.
pub files: Option<Vec<SharedFile>>,
/// Optional source rectangle used to anchor the share popover on iPadOS and macOS.
pub anchor: Option<ShareAnchor>,
}

impl ShareOptions {
Expand Down Expand Up @@ -232,6 +244,7 @@ mod tests {
title: None,
url: url.map(ToString::to_string),
files,
anchor: None,
}
}

Expand Down Expand Up @@ -301,6 +314,7 @@ mod tests {
title: None,
url: None,
files: None,
anchor: None,
}
.validate()
.is_err());
Expand Down
35 changes: 25 additions & 10 deletions src/platform/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,18 +202,33 @@ pub fn share<R: Runtime>(
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,
);
Expand Down
20 changes: 20 additions & 0 deletions tests/js/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
6 changes: 6 additions & 0 deletions tests/model_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
Expand All @@ -47,6 +49,7 @@ fn public_model_validation_enforces_limits() {
title: None,
url: None,
files: None,
anchor: None,
}
.validate()
.is_err());
Expand All @@ -57,6 +60,7 @@ fn public_model_validation_enforces_limits() {
title: None,
url: Some(oversized_url),
files: None,
anchor: None,
}
.validate()
.is_err());
Expand All @@ -73,6 +77,7 @@ fn public_model_validation_enforces_limits() {
title: None,
url: None,
files: Some(files),
anchor: None,
}
.validate()
.is_err());
Expand All @@ -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());
Expand Down
Loading