diff --git a/README.md b/README.md index 1bd917e..ef1f4e1 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ The frontend API is designed to closely resemble the Web Share API, making it in 2. **Sharing Content** - Use the `share()` function with a `ShareData` object to trigger the native dialog. The files field requires an array of `File` objects, which the plugin automatically handles by converting them to Base64 and managing their lifecycle in the backend. At least one of `text`, `url`, or a non-empty `files` array must be provided. + Use the `share()` function with a `ShareData` object to trigger the native dialog. The `files` field requires an array of `File` objects, which the plugin automatically handles by converting them to Base64 and managing their lifecycle in the backend. For content that already exists on disk, use `filePaths` to share the file directly while preserving its original filename. On Android, paths inside a configured `FileProvider` root are shared as-is; paths outside a declared root are copied to the plugin's cache share directory and cleaned up automatically. At least one of `text`, `url`, a non-empty `files` array, or a non-empty `filePaths` array must be provided. Note: on Android and Windows, the promise resolves when the app regains focus after the share UI closes (best-effort). On macOS, the share delegate is used to resolve when the share completes. On iOS, the promise is resolved using the native completion handler (`UIActivityViewController.completionWithItemsHandler`), which provides accurate resolution when sharing completes. We may expose a configuration option in the future to let developers choose the resolution behavior (immediate vs. on-focus vs. delayed). ```ts @@ -108,6 +108,16 @@ The frontend API is designed to closely resemble the Web Share API, making it in console.log("File shared successfully."); } } + + // Share existing files on disk by path (preserves original filename) + async function shareByPath() { + if (await canShare()) { + await share({ + title: "Saved Report", + filePaths: ["/Users/me/report.zip"], + }); + } + } ``` 3. Manual Cleanup @@ -153,6 +163,26 @@ and that the active capability includes the plugin permission: After changing plugin permissions or native Android code, fully rebuild and reinstall the Android app. A stale installed APK can keep showing this error even after the source code is corrected. +#### FileProvider and `filePaths` + +The plugin registers a `FileProvider` with authority `.fileprovider`. By default it only exposes the plugin's own cache directory (`cache/shares/`) for temporary files. + +If you want `filePaths` to share files directly from your app's directories (without making a temporary copy), add those directories to your app's `file_paths.xml` resource. In a default Tauri v2 project this is `src-tauri/gen/android/app/src/main/res/xml/file_paths.xml`; the `xml` directory and file may need to be created, and the exact path changes if you have customized `TAURI_ANDROID_PROJECT_PATH`. The plugin will try to use the original file first and only copy it if the path is not under a declared `FileProvider` root. + +```xml + + + + + + + +``` + +Make sure your custom `file_paths.xml` still contains a `cache-path` entry (for example `path="."` or `path="shares/"`) so that files created from the `files` array and fallback copies remain accessible. + +If a `filePath` is outside any declared root, the plugin copies it to its cache share directory, shares the copy, and cleans it up automatically. + 2. **Using the `ShareExt` Trait** The `ShareExt` trait is provided for a more idiomatic way to access the plugin's functionalities directly from an `AppHandle` or `Window`. @@ -169,6 +199,7 @@ After changing plugin permissions or native Android code, fully rebuild and rein title: Some("Rust Share".to_string()), url: None, files: None, + file_paths: None, anchor: None, }; diff --git a/android/src/main/java/SharePlugin.kt b/android/src/main/java/SharePlugin.kt index f561ffb..32b9369 100644 --- a/android/src/main/java/SharePlugin.kt +++ b/android/src/main/java/SharePlugin.kt @@ -19,6 +19,7 @@ import app.tauri.plugin.Invoke import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import java.io.IOException +import java.net.URLConnection import java.util.UUID @InvokeArg @@ -42,6 +43,8 @@ class ShareOptions { var title: String? = null var url: String? = null var files: List? = null + /** A list of local file paths to share directly from disk, preserving the original filename. */ + var filePaths: List? = null var anchor: Anchor? = null } @@ -77,7 +80,8 @@ class SharePlugin(private val activity: Activity): Plugin(activity) { val args = invoke.parseArgs(ShareOptions::class.java) ShareValidation.validateShareOptions(args) val fileUris = ArrayList() - var determinedMimeType = "text/plain" + val mimeTypes = ArrayList() + val authority = "${activity.packageName}.fileprovider" args.files?.let { if (it.isNotEmpty()) { @@ -90,16 +94,50 @@ class SharePlugin(private val activity: Activity): Plugin(activity) { } filesForShare.add(tempFile) - val authority = "${activity.packageName}.fileprovider" fileUris.add( FileProvider.getUriForFile(activity, authority, tempFile) ) + mimeTypes.add(file.mimeType) } + } + } + + args.filePaths?.let { + if (it.isNotEmpty()) { + for (path in it) { + val file = File(path) + if (!file.exists() || !file.isFile) { + throw SecurityException("File does not exist or is not a regular file: $path") + } + + // Prefer sharing directly through the caller's FileProvider. + // If the file is outside a declared root, copy it to the plugin's + // cache share directory and share from there. + val shareFile = try { + FileProvider.getUriForFile(activity, authority, file) + file + } catch (e: IllegalArgumentException) { + val safeFile = copyToSafeShareDir(file) + filesForShare.add(safeFile) + safeFile + } - determinedMimeType = determineMimeType(it) + fileUris.add( + FileProvider.getUriForFile(activity, authority, shareFile) + ) + mimeTypes.add( + URLConnection.guessContentTypeFromName(shareFile.name) + ?: "application/octet-stream" + ) + } } } + var determinedMimeType = "text/plain" + if (mimeTypes.isNotEmpty()) { + determinedMimeType = determineMimeType(mimeTypes) + } + if (fileUris.isEmpty() && args.text.isNullOrEmpty() && args.url.isNullOrEmpty()) { invoke.reject("No content provided to share.") return @@ -183,15 +221,15 @@ class SharePlugin(private val activity: Activity): Plugin(activity) { } } - private fun determineMimeType(files: List): String { - if (files.isEmpty()) return "*/*" - val firstMimeType = files.first().mimeType + private fun determineMimeType(mimeTypes: List): String { + if (mimeTypes.isEmpty()) return "*/*" + val firstMimeType = mimeTypes.first() val firstGeneralType = firstMimeType.substringBefore('/') - - val allSame = files.all { it.mimeType == firstMimeType } + + val allSame = mimeTypes.all { it == firstMimeType } if (allSame) return firstMimeType - val allSameGeneral = files.all { it.mimeType.startsWith(firstGeneralType) } + val allSameGeneral = mimeTypes.all { it.startsWith(firstGeneralType) } if (allSameGeneral) return "$firstGeneralType/*" return "*/*" @@ -280,4 +318,34 @@ class SharePlugin(private val activity: Activity): Plugin(activity) { return intendedFile } + + /** + * Copies an existing file into the dedicated share directory in the app's cache. + * A unique subdirectory is used so the original filename is preserved while + * avoiding name collisions, and the app's FileProvider (which only exposes + * cache/shares/) can grant URIs for the copy. + */ + @Throws(IOException::class, SecurityException::class) + private fun copyToSafeShareDir(source: File): File { + val safeDir = getSafeShareDir() + val safeDirCanonicalPath = safeDir.canonicalPath + + // Use a unique subdirectory for each copied file to avoid collisions + // and to keep the original filename intact for target apps. + val uniqueDir = File(safeDir, UUID.randomUUID().toString()) + if (!uniqueDir.mkdirs()) { + throw IOException("Failed to create share subdirectory.") + } + + val dest = File(uniqueDir, source.name) + + // CRITICAL: Path Traversal Check + // Ensure the final resolved path is still inside our secure directory. + if (!dest.canonicalPath.startsWith(safeDirCanonicalPath + File.separator)) { + throw SecurityException("Path Traversal Attack Detected. Malicious filename: '${source.name}'") + } + + source.copyTo(dest, overwrite = true) + return dest + } } diff --git a/android/src/main/java/ShareValidation.kt b/android/src/main/java/ShareValidation.kt index ae840a7..9272a1a 100644 --- a/android/src/main/java/ShareValidation.kt +++ b/android/src/main/java/ShareValidation.kt @@ -11,6 +11,7 @@ object ShareValidation { const val MAX_URL_BYTES = 4096 const val MAX_FILE_NAME_BYTES = 255 const val MAX_MIME_TYPE_BYTES = 255 + const val MAX_FILE_PATH_BYTES = 4096 @Throws(SecurityException::class) fun validateShareOptions(args: ShareOptions) { @@ -24,8 +25,10 @@ object ShareValidation { } } - val files = args.files ?: return - if (files.size > MAX_FILES) { + val files = args.files ?: emptyList() + val filePaths = args.filePaths ?: emptyList() + val fileCount = files.size + filePaths.size + if (fileCount > MAX_FILES) { throw SecurityException("Too many files provided. Maximum is $MAX_FILES.") } @@ -43,6 +46,10 @@ object ShareValidation { throw SecurityException("Total shared file size exceeds the maximum of $MAX_TOTAL_FILE_BYTES bytes.") } } + + for (path in filePaths) { + validateStringLength("file path", path, MAX_FILE_PATH_BYTES) + } } @Throws(SecurityException::class) diff --git a/android/src/test/java/ShareValidationUnitTest.kt b/android/src/test/java/ShareValidationUnitTest.kt index da36ee3..3574949 100644 --- a/android/src/test/java/ShareValidationUnitTest.kt +++ b/android/src/test/java/ShareValidationUnitTest.kt @@ -51,6 +51,13 @@ class ShareValidationUnitTest { ShareValidation.validateShareOptions(options(files = files)) } + val paths = (0..ShareValidation.MAX_FILES).map { + "/data/file-$it" + } + assertThrowsSecurity { + ShareValidation.validateShareOptions(options(filePaths = paths)) + } + assertThrowsSecurity { ShareValidation.validateShareOptions(options(text = "a".repeat(ShareValidation.MAX_TEXT_BYTES + 1))) } @@ -60,6 +67,12 @@ class ShareValidationUnitTest { options(files = listOf(file("a".repeat(ShareValidation.MAX_FILE_NAME_BYTES + 1), "aGVsbG8="))) ) } + + assertThrowsSecurity { + ShareValidation.validateShareOptions( + options(filePaths = listOf("/path/".repeat(ShareValidation.MAX_FILE_PATH_BYTES))) + ) + } } private fun options( @@ -67,12 +80,14 @@ class ShareValidationUnitTest { title: String? = null, url: String? = null, files: List? = null, + filePaths: List? = null, ): ShareOptions { return ShareOptions().apply { this.text = text this.title = title this.url = url this.files = files + this.filePaths = filePaths } } diff --git a/api-iife.js b/api-iife.js index 545c3ba..f19c160 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"})))),e.anchor&&(r.anchor=e.anchor),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||e.filePaths&&e.filePaths.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.")}const t=(e.files?.length??0)+(e.filePaths?.length??0);if(0!==t){if(t>16)throw new TypeError("Too many files provided. Maximum is 16.");if(e.files&&e.files.length>0){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.")}if(e.filePaths&&e.filePaths.length>0){if(e.filePaths.find(e=>o(e)>4096))throw new TypeError("file path exceeds the maximum length of 4096 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.filePaths&&e.filePaths.length>0&&(r.filePaths=e.filePaths),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 7c091ad..cfa64c5 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -8,6 +8,7 @@ const MAX_TITLE_BYTES = 1024; const MAX_URL_BYTES = 4096; const MAX_FILE_NAME_BYTES = 255; const MAX_MIME_TYPE_BYTES = 255; +const MAX_FILE_PATH_BYTES = 4096; /** * Represents the content to be shared, similar to the Web Share API's ShareData dictionary. @@ -32,6 +33,12 @@ export interface ShareRect { export interface ShareData { /** Optional array of File objects to share (e.g., images, PDFs). */ files?: File[]; + /** + * Optional array of local file paths to share. The file is shared directly + * from disk, preserving its original filename, instead of being copied from + * base64 content. Mutually exclusive with `files` in most cases. + */ + filePaths?: string[]; /** Optional text content to be shared. */ text?: string; /** Optional title describing the shared content. */ @@ -46,7 +53,12 @@ export interface ShareData { } function hasShareableContent(data: ShareData): boolean { - return Boolean(data.text || data.url || (data.files && data.files.length > 0)); + return Boolean( + data.text || + data.url || + (data.files && data.files.length > 0) || + (data.filePaths && data.filePaths.length > 0) + ); } function byteLength(value: string): number { @@ -96,43 +108,59 @@ function validateShareData(data: ShareData): void { } } - if (!data.files || data.files.length === 0) { + const fileCount = (data.files?.length ?? 0) + (data.filePaths?.length ?? 0); + if (fileCount === 0) { return; } - if (data.files.length > MAX_FILES) { + if (fileCount > MAX_FILES) { throw new TypeError(`Too many files provided. Maximum is ${MAX_FILES}.`); } - const totalBytes = data.files.reduce((total, file) => total + file.size, 0); - if (totalBytes > MAX_TOTAL_FILE_BYTES) { - throw new TypeError( - `Total shared file size exceeds the maximum of ${MAX_TOTAL_FILE_BYTES} bytes.` - ); - } + if (data.files && data.files.length > 0) { + const totalBytes = data.files.reduce((total, file) => total + file.size, 0); + if (totalBytes > MAX_TOTAL_FILE_BYTES) { + throw new TypeError( + `Total shared file size exceeds the maximum of ${MAX_TOTAL_FILE_BYTES} bytes.` + ); + } - const oversizedFile = data.files.find((file) => file.size > MAX_FILE_BYTES); - if (oversizedFile) { - throw new TypeError( - `File '${oversizedFile.name}' exceeds the maximum size of ${MAX_FILE_BYTES} bytes.` - ); - } + const oversizedFile = data.files.find((file) => file.size > MAX_FILE_BYTES); + if (oversizedFile) { + throw new TypeError( + `File '${oversizedFile.name}' exceeds the maximum size of ${MAX_FILE_BYTES} bytes.` + ); + } - const oversizedName = data.files.find((file) => byteLength(file.name) > MAX_FILE_NAME_BYTES); - if (oversizedName) { - throw new TypeError( - `File name exceeds the maximum length of ${MAX_FILE_NAME_BYTES} bytes.` + const oversizedName = data.files.find( + (file) => byteLength(file.name) > MAX_FILE_NAME_BYTES ); + if (oversizedName) { + throw new TypeError( + `File name exceeds the maximum length of ${MAX_FILE_NAME_BYTES} bytes.` + ); + } + + const oversizedMimeType = data.files.find((file) => { + const type = file.type || "application/octet-stream"; + return byteLength(type) > MAX_MIME_TYPE_BYTES; + }); + if (oversizedMimeType) { + throw new TypeError( + `mime type exceeds the maximum length of ${MAX_MIME_TYPE_BYTES} bytes.` + ); + } } - const oversizedMimeType = data.files.find((file) => { - const type = file.type || "application/octet-stream"; - return byteLength(type) > MAX_MIME_TYPE_BYTES; - }); - if (oversizedMimeType) { - throw new TypeError( - `mime type exceeds the maximum length of ${MAX_MIME_TYPE_BYTES} bytes.` + if (data.filePaths && data.filePaths.length > 0) { + const oversizedPath = data.filePaths.find( + (path) => byteLength(path) > MAX_FILE_PATH_BYTES ); + if (oversizedPath) { + throw new TypeError( + `file path exceeds the maximum length of ${MAX_FILE_PATH_BYTES} bytes.` + ); + } } } @@ -252,6 +280,10 @@ export async function share(data: ShareData): Promise { ); } + if (data.filePaths && data.filePaths.length > 0) { + payload.filePaths = data.filePaths; + } + if (data.anchor) { payload.anchor = data.anchor; } diff --git a/ios/Sources/ShareCore/ShareModels.swift b/ios/Sources/ShareCore/ShareModels.swift index 37cc4b7..1644016 100644 --- a/ios/Sources/ShareCore/ShareModels.swift +++ b/ios/Sources/ShareCore/ShareModels.swift @@ -29,13 +29,15 @@ public struct ShareOptions: Decodable { public var title: String? public var url: String? public var files: [SharedFile]? + public var filePaths: [String]? public var anchor: ShareAnchor? - public init(text: String? = nil, title: String? = nil, url: String? = nil, files: [SharedFile]? = nil, anchor: ShareAnchor? = nil) { + public init(text: String? = nil, title: String? = nil, url: String? = nil, files: [SharedFile]? = nil, filePaths: [String]? = nil, anchor: ShareAnchor? = nil) { self.text = text self.title = title self.url = url self.files = files + self.filePaths = filePaths self.anchor = anchor } } diff --git a/ios/Sources/ShareCore/ShareValidation.swift b/ios/Sources/ShareCore/ShareValidation.swift index 4f46957..c014917 100644 --- a/ios/Sources/ShareCore/ShareValidation.swift +++ b/ios/Sources/ShareCore/ShareValidation.swift @@ -8,6 +8,7 @@ public let maxTitleBytes = 1024 public let maxURLBytes = 4096 public let maxFileNameBytes = 255 public let maxMimeTypeBytes = 255 +public let maxFilePathBytes = 4096 public func validateShareOptions(_ args: ShareOptions) -> String? { if let error = validateStringLength("text", args.text, maxBytes: maxTextBytes) { @@ -26,32 +27,39 @@ public func validateShareOptions(_ args: ShareOptions) -> String? { } } - guard let files = args.files else { - return nil - } - - if files.count > maxFiles { + let fileCount = (args.files ?? []).count + (args.filePaths ?? []).count + if fileCount > maxFiles { return "Too many files provided. Maximum is \(maxFiles)." } var totalEstimatedBytes = 0 - for file in files { - if let error = validateStringLength("file name", file.name, maxBytes: maxFileNameBytes) { - return error - } - if let error = validateStringLength("mime type", file.mimeType, maxBytes: maxMimeTypeBytes) { - return error - } + if let files = args.files { + for file in files { + if let error = validateStringLength("file name", file.name, maxBytes: maxFileNameBytes) { + return error + } + if let error = validateStringLength("mime type", file.mimeType, maxBytes: maxMimeTypeBytes) { + return error + } - guard let estimatedBytes = estimateBase64DecodedSize(file.data) else { - return "Invalid Base64 data." - } - if estimatedBytes > maxFileBytes { - return "File '\(file.name)' exceeds the maximum size of \(maxFileBytes) bytes." + guard let estimatedBytes = estimateBase64DecodedSize(file.data) else { + return "Invalid Base64 data." + } + if estimatedBytes > maxFileBytes { + return "File '\(file.name)' exceeds the maximum size of \(maxFileBytes) bytes." + } + totalEstimatedBytes += estimatedBytes + if totalEstimatedBytes > maxTotalFileBytes { + return "Total shared file size exceeds the maximum of \(maxTotalFileBytes) bytes." + } } - totalEstimatedBytes += estimatedBytes - if totalEstimatedBytes > maxTotalFileBytes { - return "Total shared file size exceeds the maximum of \(maxTotalFileBytes) bytes." + } + + if let filePaths = args.filePaths { + for path in filePaths { + if let error = validateStringLength("file path", path, maxBytes: maxFilePathBytes) { + return error + } } } diff --git a/ios/Sources/SharePlugin.swift b/ios/Sources/SharePlugin.swift index dba9fdb..16b94e5 100644 --- a/ios/Sources/SharePlugin.swift +++ b/ios/Sources/SharePlugin.swift @@ -83,7 +83,7 @@ public class SharePlugin: Plugin { invoke.reject("File '\(file.name)' exceeds the maximum size of \(maxFileBytes) bytes.") return } - + do { let tempFileURL = try createSafeTempFile(for: file.name) try decodedData.write(to: tempFileURL, options:.atomic) @@ -98,6 +98,15 @@ public class SharePlugin: Plugin { } } + // Share local files directly from their original paths. This preserves the + // original filename and avoids creating temporary copies. + if let filePaths = args.filePaths { + for path in filePaths { + let fileURL = URL(fileURLWithPath: path) + activityItems.append(fileURL) + } + } + if activityItems.isEmpty { _ = resetShareState() invoke.reject("No content provided to share.") diff --git a/src/models.rs b/src/models.rs index cf68df7..eb27276 100644 --- a/src/models.rs +++ b/src/models.rs @@ -10,6 +10,7 @@ pub const MAX_TITLE_BYTES: usize = 1024; pub const MAX_URL_BYTES: usize = 4096; pub const MAX_FILE_NAME_BYTES: usize = 255; pub const MAX_MIME_TYPE_BYTES: usize = 255; +pub const MAX_FILE_PATH_BYTES: usize = 4096; /// Represents a rectangle in web-viewport coordinates used to anchor a share popover. #[derive(Debug, Deserialize, Serialize, Clone, Copy)] @@ -75,6 +76,9 @@ pub struct ShareOptions { pub url: Option, /// A list of files to share, each represented by a `SharedFile` struct. pub files: Option>, + /// A list of local file paths to share directly from disk, preserving the + /// original filename instead of creating a temporary copy from Base64 data. + pub file_paths: Option>, /// Optional source rectangle used to anchor the share popover on iPadOS and macOS. pub anchor: Option, } @@ -85,6 +89,10 @@ impl ShareOptions { self.text.as_ref().is_some_and(|value| !value.is_empty()) || self.url.as_ref().is_some_and(|value| !value.is_empty()) || self.files.as_ref().is_some_and(|files| !files.is_empty()) + || self + .file_paths + .as_ref() + .is_some_and(|paths| !paths.is_empty()) } /// Combines text and URL for platforms that expose one plain-text field. @@ -109,13 +117,15 @@ impl ShareOptions { validate_web_url(url)?; } - if let Some(files) = self.files.as_ref() { - if files.len() > MAX_FILES { - return Err(Error::InvalidArgs(format!( - "Too many files provided. Maximum is {MAX_FILES}." - ))); - } + let file_count = self.files.as_ref().map(|f| f.len()).unwrap_or(0) + + self.file_paths.as_ref().map(|p| p.len()).unwrap_or(0); + if file_count > MAX_FILES { + return Err(Error::InvalidArgs(format!( + "Too many files provided. Maximum is {MAX_FILES}." + ))); + } + if let Some(files) = self.files.as_ref() { let mut total_estimated_bytes = 0usize; for file in files { validate_optional_string("file name", Some(&file.name), MAX_FILE_NAME_BYTES)?; @@ -143,6 +153,12 @@ impl ShareOptions { } } + if let Some(file_paths) = self.file_paths.as_ref() { + for path in file_paths { + validate_optional_string("file path", Some(path), MAX_FILE_PATH_BYTES)?; + } + } + Ok(()) } } @@ -244,6 +260,7 @@ mod tests { title: None, url: url.map(ToString::to_string), files, + file_paths: None, anchor: None, } } @@ -314,6 +331,7 @@ mod tests { title: None, url: None, files: None, + file_paths: None, anchor: None, } .validate() diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 7ae38ab..045db7b 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -33,6 +33,7 @@ thread_local! { #[derive(Default)] struct ShareDelegateIvars { completion: RefCell>>>, + subject: RefCell>>, } define_class!( @@ -48,8 +49,11 @@ define_class!( fn sharing_service_picker_delegate_for_sharing_service( &self, _picker: &NSSharingServicePicker, - _service: &NSSharingService, + service: &NSSharingService, ) -> Option>> { + if let Some(subject) = self.ivars().subject.borrow().as_ref() { + service.setSubject(Some(&**subject)); + } Some(ProtocolObject::from_retained(self.retain())) } @@ -90,9 +94,14 @@ define_class!( ); impl SharePickerDelegate { - fn new(mtm: MainThreadMarker, completion: mpsc::Sender>) -> Retained { + fn new( + mtm: MainThreadMarker, + completion: mpsc::Sender>, + subject: Option>, + ) -> Retained { let ivars = ShareDelegateIvars { completion: RefCell::new(Some(completion)), + subject: RefCell::new(subject), }; let this = Self::alloc(mtm).set_ivars(ivars); unsafe { msg_send![super(this), init] } @@ -149,9 +158,31 @@ pub fn share( let temp_file_manager_clone = managed_files.clone(); - if let Some(combined_text) = options.combined_text() { - items_to_share - .push(unsafe { Retained::cast_unchecked(NSString::from_str(&combined_text)) }); + let has_files = options.files.as_ref().is_some_and(|f| !f.is_empty()) + || options.file_paths.as_ref().is_some_and(|p| !p.is_empty()); + + let subject = if has_files { + options + .title + .as_ref() + .or(options.text.as_ref()) + .map(|s| NSString::from_str(s)) + } else { + None + }; + + if !has_files { + if let Some(url) = options.url.as_deref().filter(|value| !value.is_empty()) { + if let Some(url_obj) = unsafe { NSURL::URLWithString(&NSString::from_str(url)) } { + items_to_share.push(unsafe { Retained::cast_unchecked(url_obj) }); + } + } + + if let Some(text) = options.text.as_deref().filter(|value| !value.is_empty()) { + items_to_share.push(unsafe { + Retained::cast_unchecked(NSString::from_str(text)) + }); + } } if let Some(files) = options.files { @@ -172,11 +203,20 @@ pub fn share( Ok(()) }) { - eprintln!("Failed to add file to managed list: {}", e); + log::error!("Failed to add file to managed list: {}", e); } } } + // Share local files directly from their original paths. This preserves the + // original filename and avoids creating temporary copies. + if let Some(file_paths) = options.file_paths { + for path in file_paths { + let url = NSURL::fileURLWithPath(&NSString::from_str(&path)); + items_to_share.push(unsafe { Retained::cast_unchecked(url) }); + } + } + if items_to_share.is_empty() { return Err(Error::InvalidArgs( "No content provided to share.".to_string(), @@ -197,7 +237,7 @@ pub fn share( }; let mtm = MainThreadMarker::new().expect("Main thread marker"); - let delegate = SharePickerDelegate::new(mtm, completion_tx); + let delegate = SharePickerDelegate::new(mtm, completion_tx, subject); ACTIVE_DELEGATES.with(|delegates| delegates.borrow_mut().push(delegate.retain())); unsafe { picker.setDelegate(Some(ProtocolObject::from_ref(&*delegate))) }; diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 8451a7e..91b0e3a 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -2,6 +2,7 @@ use super::focus; use crate::state::PluginTempFileManager; use crate::{CanShareResult, Error, ShareOptions, SharedFile, MAX_FILE_BYTES}; use base64::{engine::general_purpose, Engine as _}; +use log::error; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use std::cell::RefCell; use std::fs::OpenOptions; @@ -9,18 +10,16 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::mpsc; use tauri::{Runtime, State, Window}; -use windows::ApplicationModel::DataTransfer::{DataRequestedEventArgs, DataTransferManager}; -use windows::Foundation::Uri; -use windows::Storage::IStorageItem; -use windows::{ - core::{Interface, HSTRING}, - Foundation::TypedEventHandler, - Storage::StorageFile, - Win32::{ - Foundation::HWND, - System::WinRT::{RoInitialize, RO_INIT_SINGLETHREADED}, - UI::Shell::IDataTransferManagerInterop, - }, +use windows::core::{Interface, HSTRING}; +use windows::ApplicationModel::DataTransfer::{ + DataPackageOperation, DataRequestDeferral, DataRequestedEventArgs, DataTransferManager, +}; +use windows::Foundation::{TypedEventHandler, Uri}; +use windows::Storage::{IStorageItem, StorageFile}; +use windows::Win32::{ + Foundation::HWND, + System::WinRT::{RoInitialize, RO_INIT_SINGLETHREADED}, + UI::Shell::IDataTransferManagerInterop, }; use windows_collections::IIterable; @@ -38,6 +37,23 @@ impl From for Error { } } +/// Ensures a `DataRequestDeferral` is always completed when the handler scope ends. +struct DeferralGuard(Option); + +impl DeferralGuard { + fn new(deferral: DataRequestDeferral) -> Self { + Self(Some(deferral)) + } +} + +impl Drop for DeferralGuard { + fn drop(&mut self) { + if let Some(deferral) = self.0.take() { + let _ = deferral.Complete(); + } + } +} + pub fn cleanup() -> Result<(), Error> { let temp_dir = get_plugin_temp_dir()?; if temp_dir.exists() { @@ -61,26 +77,45 @@ pub fn share( let win_clone = window.clone(); let managed_files_arc = state.inner().managed_files.clone(); + let tx_handler = tx.clone(); + + let options = options; if let Err(e) = window.run_on_main_thread(move || { - let options_arc = std::sync::Arc::new(options.clone()); + let tx_for_handler = tx_handler; let result = (|| -> Result<(), Error> { initialize_winrt_thread()?; let hwnd = get_hwnd(&win_clone)?; let (dtm, interop) = get_data_transfer_manager(hwnd)?; let data_requested_handler = TypedEventHandler::new({ - let options_clone = options_arc.clone(); + let options_clone = std::sync::Arc::new(options.clone()); let managed_files_arc_clone_for_handler = managed_files_arc.clone(); move |_, args: windows::core::Ref<'_, DataRequestedEventArgs>| -> windows::core::Result<()> { - if let Some(request_args) = (*args).as_ref() { + let handler_result = (|| -> Result<(), Error> { + let request_args = (*args).as_ref().ok_or_else(|| Error::NativeApi("Missing DataRequestedEventArgs".to_string()))?; let request = request_args.Request()?; let data = request.Data()?; let properties = data.Properties()?; - if let Some(title) = &options_clone.title { - properties.SetTitle(&HSTRING::from(title))?; - } + let title = options_clone.title.clone().unwrap_or_else(|| { + options_clone + .file_paths + .as_ref() + .and_then(|paths| paths.first()) + .and_then(|path| Path::new(path).file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .or_else(|| { + options_clone + .files + .as_ref() + .and_then(|files| files.first()) + .map(|file| file.name.clone()) + }) + .unwrap_or_else(|| "Shared content".to_string()) + }); + + properties.SetTitle(&HSTRING::from(&title))?; if let (Some(t), Some(u)) = (&options_clone.text, &options_clone.url) { // Set the plain text content. @@ -97,7 +132,7 @@ pub fn share( // If the URL string cannot be parsed into a valid Uri object, // a warning is logged. In such cases, the URL might still be // valuable as part of the plain text. - eprintln!("Warning: Could not parse URL '{}' for DataPackage::SetWebLink. Setting as part of text.", u); + error!("Warning: Could not parse URL '{}' for DataPackage::SetWebLink. Setting as part of text.", u); // Optionally, if it's critical for the URL to be present in some form, // even if not semantically, it could be appended to the plain text. let combined_text_fallback = format!("{}\n{}", t, u); @@ -107,7 +142,7 @@ pub fn share( } // If only text is provided, simply set the plain text content. else if let Some(t) = &options_clone.text { - if!t.is_empty() { + if !t.is_empty() { data.SetText(&HSTRING::from(t))?; } } @@ -118,76 +153,82 @@ pub fn share( } else { // If URL parsing fails, fall back to setting it as plain text. // This ensures the URL string is still transferred, even without its semantic type. - eprintln!("Warning: Could not parse URL '{}' for DataPackage::SetWebLink. Setting as plain text.", u); + error!("Warning: Could not parse URL '{}' for DataPackage::SetWebLink. Setting as plain text.", u); data.SetText(&HSTRING::from(u))?; } } - if let Some(files) = &options_clone.files { + if options_clone.files.is_some() || options_clone.file_paths.is_some() { let deferral = request.GetDeferral()?; - let data_clone = data.clone(); - - tauri::async_runtime::spawn({ - let files = files.clone(); - let managed_files_arc_for_async = managed_files_arc_clone_for_handler.clone(); - async move { - let mut storage_items: Vec = Vec::new(); - - for file in files { - match create_temp_file_for_data(&file) { - Ok(path_buf) => { - let path_str = path_buf.to_string_lossy().to_string(); - if let Err(e) = managed_files_arc_for_async.lock().map_err(|e| format!("Failed to lock mutex: {}", e)).and_then(|mut files| { - files.push(path_buf.clone()); - Ok(()) - }) { - eprintln!("Failed to update temp file manager: {}", e); - } - - match StorageFile::GetFileFromPathAsync(&HSTRING::from(path_str)) { - Ok(op) => match op.get() { - Ok(storage_file) => { - if let Ok(item) = storage_file.cast() { - storage_items.push(item); - } - }, - Err(e) => eprintln!("Failed to get storage file: {}", e), - }, - Err(e) => eprintln!("Failed to get file from path: {}", e), - } - }, - Err(e) => eprintln!("Failed to create temp file: {}", e), - } - } + let _guard = DeferralGuard::new(deferral); + let mut storage_items: Vec> = Vec::new(); + + if let Some(files) = &options_clone.files { + let temp_dir = get_plugin_temp_dir()?; + for file in files { + let path_buf = create_temp_file_for_data(file, &temp_dir)?; + let path_str = path_buf.to_string_lossy().to_string(); + let mut files = managed_files_arc_clone_for_handler + .lock() + .map_err(|e| Error::NativeApi(format!("Failed to lock temp file manager: {}", e)))?; + files.push(path_buf); + + let storage_file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_str)) + .map_err(|e| Error::NativeApi(format!("GetFileFromPathAsync failed for temp file: {}", e)))? + .get() + .map_err(|e| Error::NativeApi(format!("GetFileFromPathAsync result failed for temp file: {}", e)))?; + let item: IStorageItem = storage_file.cast() + .map_err(|e| Error::NativeApi(format!("Failed to cast temp StorageFile to IStorageItem: {}", e)))?; + storage_items.push(Some(item)); + } + } - if !storage_items.is_empty() { - let options_items = storage_items.into_iter().map(Some).collect::>(); - let iterable_items: Result, _> = options_items.try_into(); - - match iterable_items { - Ok(items) => { - if let Err(e) = data_clone.SetStorageItemsReadOnly(&items) { - println!("Failed to set storage items on data package: {}", e); - } - }, - Err(e) => { - println!("Failed to convert Vec to IIterable: {}", e); - } - } + if let Some(file_paths) = &options_clone.file_paths { + for path in file_paths { + if path.is_empty() { + continue; } - deferral.Complete()?; - Ok::<(), windows::core::Error>(()) + + // Normalize any forward slashes to Windows backslashes. The + // frontend may pass absolute paths with either separator, but + // WinRT's StorageFile API is stricter about backslash separators. + let normalized_path = path.replace('/', "\\"); + + let storage_file = StorageFile::GetFileFromPathAsync(&HSTRING::from(normalized_path)) + .map_err(|e| Error::NativeApi(format!("GetFileFromPathAsync failed for '{}': {}", path, e)))? + .get() + .map_err(|e| Error::NativeApi(format!("GetFileFromPathAsync result failed for '{}': {}", path, e)))?; + let item: IStorageItem = storage_file.cast() + .map_err(|e| Error::NativeApi(format!("Failed to cast StorageFile to IStorageItem for '{}': {}", path, e)))?; + storage_items.push(Some(item)); } + } - }); - } + if !storage_items.is_empty() { + let iterable_items: IIterable = storage_items.try_into() + .map_err(|e| Error::NativeApi(format!("Failed to convert storage items to IIterable: {}", e)))?; - SHARE_STATE.with(|state| { - if let Some((manager, token)) = state.borrow_mut().take() { - let _ = manager.RemoveDataRequested(token); + data.SetRequestedOperation(DataPackageOperation::Copy) + .map_err(|e| Error::NativeApi(format!("Failed to set requested operation: {}", e)))?; + data.SetStorageItems(&iterable_items, true) + .map_err(|e| Error::NativeApi(format!("Failed to set storage items on DataPackage: {}", e)))?; } - }); + } + + Ok(()) + })(); + + if let Err(e) = &handler_result { + error!("[share] DataRequested handler failed: {}", e); } + let _ = tx_for_handler.send(handler_result); + + SHARE_STATE.with(|state| { + if let Some((manager, token)) = state.borrow_mut().take() { + let _ = manager.RemoveDataRequested(token); + } + }); + Ok(()) } }); @@ -198,23 +239,26 @@ pub fn share( *state.borrow_mut() = Some((dtm, token)); }); - // Best-effort note: ShowShareUIForWindow doesn't provide a reliable completion callback - // for desktop apps. Consider making resolution behavior configurable for end developers - // (immediate vs. on-focus vs. delayed). unsafe { interop.ShowShareUIForWindow(hwnd) }?; Ok(()) })(); - tx.send(result).ok(); + + if let Err(err) = result { + let _ = tx.send(Err(err)); + } }) { focus_wait.cancel(); return Err(e.into()); } - let share_result = match rx.recv() { + let share_result = match rx.recv_timeout(std::time::Duration::from_secs(30)) { Ok(result) => result, Err(err) => { focus_wait.cancel(); - return Err(err.into()); + return Err(Error::NativeApi(format!( + "Share timed out waiting for DataRequested event: {}", + err + ))); } }; if let Err(err) = share_result { @@ -268,8 +312,8 @@ fn get_plugin_temp_dir() -> Result { Ok(dir) } -/// Creates a secure temporary file from Base64 data. -fn create_temp_file_for_data(file: &SharedFile) -> Result { +/// Creates a secure temporary file from Base64 data inside `temp_dir`. +fn create_temp_file_for_data(file: &SharedFile, temp_dir: &Path) -> Result { let decoded_bytes = general_purpose::STANDARD .decode(&file.data) .map_err(|_| Error::InvalidArgs("Invalid Base64 data provided".to_string()))?; @@ -288,7 +332,6 @@ fn create_temp_file_for_data(file: &SharedFile) -> Result { .to_str() .ok_or_else(|| Error::InvalidArgs("File name contains invalid UTF-8".to_string()))?; - let temp_dir = get_plugin_temp_dir()?; let temp_path = temp_dir.join(format!("{}-{}", uuid::Uuid::new_v4(), sanitized_name)); let mut file_handle = OpenOptions::new() @@ -303,3 +346,67 @@ fn create_temp_file_for_data(file: &SharedFile) -> Result { Ok(temp_path) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn sample_shared_file() -> SharedFile { + SharedFile { + data: general_purpose::STANDARD.encode(b"hello world"), + name: "hello.txt".to_string(), + mime_type: "text/plain".to_string(), + } + } + + #[test] + fn create_temp_file_for_data_creates_expected_file() { + let temp_dir = TempDir::new().unwrap(); + let file = sample_shared_file(); + + let path = create_temp_file_for_data(&file, temp_dir.path()).unwrap(); + + assert_eq!(path.parent().unwrap(), temp_dir.path()); + let name = path.file_name().unwrap().to_string_lossy(); + assert!(name.ends_with("-hello.txt"), "unexpected file name: {name}"); + assert_eq!(fs::read(&path).unwrap(), b"hello world"); + } + + #[test] + fn create_temp_file_for_data_rejects_invalid_base64() { + let temp_dir = TempDir::new().unwrap(); + let file = SharedFile { + data: "not-valid-base64!!!".to_string(), + name: "bad.txt".to_string(), + mime_type: "text/plain".to_string(), + }; + + let err = create_temp_file_for_data(&file, temp_dir.path()).unwrap_err(); + + assert!( + matches!(err, Error::InvalidArgs(_)), + "unexpected error: {err}" + ); + } + + #[test] + fn create_temp_file_for_data_sanitizes_path_traversal_in_name() { + let temp_dir = TempDir::new().unwrap(); + let file = SharedFile { + data: general_purpose::STANDARD.encode(b"payload"), + name: "../etc/secret.txt".to_string(), + mime_type: "text/plain".to_string(), + }; + + let path = create_temp_file_for_data(&file, temp_dir.path()).unwrap(); + + assert_eq!(path.parent().unwrap(), temp_dir.path()); + let name = path.file_name().unwrap().to_string_lossy(); + assert!( + name.ends_with("-secret.txt"), + "unexpected file name: {name}" + ); + } +} diff --git a/src/state.rs b/src/state.rs index dbf6578..2d03704 100644 --- a/src/state.rs +++ b/src/state.rs @@ -27,7 +27,7 @@ impl PluginTempFileManager { let mut files = match self.managed_files.lock() { Ok(guard) => guard, Err(poisoned) => { - eprintln!("Mutex was poisoned during cleanup: {:?}", poisoned); + log::error!("Mutex was poisoned during cleanup: {:?}", poisoned); poisoned.into_inner() } }; @@ -38,7 +38,7 @@ impl PluginTempFileManager { } } if !errors.is_empty() { - eprintln!("Errors during cleanup: {:?}", errors); + log::error!("Errors during cleanup: {:?}", errors); } } } diff --git a/tests/model_contracts.rs b/tests/model_contracts.rs index 71481be..2b48a71 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(), }]), + file_paths: None, anchor: None, }; @@ -34,6 +35,7 @@ fn public_model_validation_rejects_non_web_url_schemes() { title: None, url: Some(url.to_string()), files: None, + file_paths: None, anchor: None, } .validate() @@ -49,6 +51,7 @@ fn public_model_validation_enforces_limits() { title: None, url: None, files: None, + file_paths: None, anchor: None, } .validate() @@ -60,6 +63,7 @@ fn public_model_validation_enforces_limits() { title: None, url: Some(oversized_url), files: None, + file_paths: None, anchor: None, } .validate() @@ -77,6 +81,7 @@ fn public_model_validation_enforces_limits() { title: None, url: None, files: Some(files), + file_paths: None, anchor: None, } .validate() @@ -91,6 +96,7 @@ fn public_model_validation_enforces_limits() { name: "a".repeat(MAX_FILE_NAME_BYTES + 1), mime_type: "text/plain".to_string(), }]), + file_paths: None, anchor: None, } .validate()