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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
.idea/
debug.log
package-lock.json
!examples/tauri-app/package-lock.json
.vscode/settings.json
yarn.lock

Expand All @@ -15,3 +16,10 @@ node_modules/

dist-js
dist

android/.gradle/
android/**/build/
examples/tauri-app/node_modules/
examples/tauri-app/dist/
examples/tauri-app/src-tauri/gen/
examples/tauri-app/src-tauri/target/
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tauri-plugin-vnidrop-share"
version = "0.2.2"
version = "1.0.0-rc"
description = "A Tauri plugin for sharing content via the system's share dialog."
license = "MIT"
authors = [ "Abass Hammed", "Vnidrop" ]
Expand Down
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The web's native [Web Share API](https://developer.mozilla.org/en-US/docs/Web/AP

For file sharing, the plugin intelligently manages the lifecycle of temporary files. It creates secure temporary files from Base64 data, ensuring they persist for the duration of the sharing operation, and automatically cleans them up when the application exits. On mobile platforms like Android and iOS, the native sharing APIs are directly invoked, and temporary files are managed and cleaned up within the native code. Android file cleanup is delayed briefly after the app resumes so receiving apps have time to open granted file URIs.

For release safety, share payloads are bounded on both the JavaScript and native sides. URLs must be well-formed `http://` or `https://` URLs; other schemes should be shared as plain text instead. A single share request may include up to 16 files, each file may be up to 50 MiB, and the total file payload may be up to 100 MiB. Text is limited to 64 KiB, titles to 1 KiB, URLs to 4 KiB, and file names/MIME types to 255 bytes.

## Installation

### Rust
Expand All @@ -18,7 +20,7 @@ Add the plugin to your `Cargo.toml`:

```sh
[dependencies]
tauri-plugin-vnidrop-share = "0.2.2"
tauri-plugin-vnidrop-share = "1.0.0-rc"
```

### Frontend
Expand Down Expand Up @@ -93,7 +95,7 @@ The frontend API is designed to closely resemble the Web Share API, making it in

3. Manual Cleanup

While the plugin automatically handles cleanup when the app exits, you can manually call `cleanup()` to remove temporary files after a share operation is complete to free up disk space. Avoid calling `cleanup()` while a share sheet is still open.
While the plugin automatically handles cleanup when the app exits, you can manually call `cleanup()` to remove temporary files after a share operation is complete to free up disk space. Avoid calling `cleanup()` while a share sheet is still open. The `cleanup` command is not included in the default permission set; enable `vnidrop-share:allow-cleanup` explicitly if your app calls it from the frontend.

```ts
import { cleanup } from "@vnidrop/tauri-plugin-share";
Expand All @@ -115,9 +117,25 @@ The frontend API is designed to closely resemble the Web Share API, making it in
.plugin(tauri_plugin_vnidrop_share::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
}
```

### Android troubleshooting

If Android reports `vnidrop-share.can_share not allowed. Plugin not found`, the APK does not have the share plugin registered in Tauri's runtime/ACL. Verify that your app calls:

```rs
.plugin(tauri_plugin_vnidrop_share::init())
```

and that the active capability includes the plugin permission:

```json
"vnidrop-share:default"
```

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.

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`.
Expand All @@ -141,3 +159,18 @@ The frontend API is designed to closely resemble the Web Share API, making it in
Ok(())
}
```

## Testing

The repository includes Rust, JavaScript, Android JVM, iOS Swift, and example-app checks:

```sh
bun run build
bun run test
bun run test:types
cargo test
cd android && gradle testDebugUnitTest
cd ios && VNIDROP_SHARE_USE_TAURI_STUB=1 swift test
cd examples/tauri-app && npm install && npm run build && npm audit
cd examples/tauri-app/src-tauri && cargo check
```
12 changes: 7 additions & 5 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,18 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "1.8"
}

kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}

dependencies {

implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
Expand Down
1 change: 1 addition & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
android.useAndroidX=true
9 changes: 6 additions & 3 deletions android/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ pluginManagement {
eachPlugin {
switch (requested.id.id) {
case "com.android.library":
useVersion("8.0.2")
useVersion("8.13.2")
break
case "org.jetbrains.kotlin.android":
useVersion("1.8.20")
useVersion("2.2.21")
break
}
}
Expand All @@ -28,4 +28,7 @@ dependencyResolutionManagement {
}

include ':tauri-android'
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
def generatedTauriApi = new File('./.tauri/tauri-api')
project(':tauri-android').projectDir = generatedTauriApi.exists()
? generatedTauriApi
: new File('./test-support/tauri-api')
8 changes: 5 additions & 3 deletions android/src/main/java/SharePlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,15 @@ class SharePlugin(private val activity: Activity): Plugin(activity) {
val filesForShare = ArrayList<File>()
try {
val args = invoke.parseArgs(ShareOptions::class.java)
ShareValidation.validateShareOptions(args)
val fileUris = ArrayList<Uri>()
var determinedMimeType = "text/plain"

args.files?.let {
if (it.isNotEmpty()) {
for (file in it) {
val decodedBytes = Base64.decode(file.data, Base64.DEFAULT)
ShareValidation.validateDecodedFileSize(file, decodedBytes.size)
val tempFile = createSafeFile(file.name)
FileOutputStream(tempFile).use { outputStream ->
outputStream.write(decodedBytes)
Expand Down Expand Up @@ -256,12 +258,12 @@ class SharePlugin(private val activity: Activity): Plugin(activity) {
// A robust approach is to allow only a whitelist of characters.
// Here, we also add a UUID to prevent name collisions.
val sanitizedBaseName = untrustedFileName.replace(Regex("[^a-zA-Z0-9._-]"), "")
val finalFileName = "${UUID.randomUUID()}-${sanitizedBaseName}"

if (finalFileName.isEmpty()) {
if (sanitizedBaseName.isEmpty()) {
throw SecurityException("Invalid filename: sanitized name is empty.")
}

val finalFileName = "${UUID.randomUUID()}-${sanitizedBaseName}"

val intendedFile = File(safeDir, finalFileName)

// CRITICAL: Path Traversal Check
Expand Down
97 changes: 97 additions & 0 deletions android/src/main/java/ShareValidation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package plugin.vnidrop.share

import java.net.URI

object ShareValidation {
const val MAX_FILES = 16
const val MAX_FILE_BYTES = 50 * 1024 * 1024
const val MAX_TOTAL_FILE_BYTES = 100 * 1024 * 1024
const val MAX_TEXT_BYTES = 64 * 1024
const val MAX_TITLE_BYTES = 1024
const val MAX_URL_BYTES = 4096
const val MAX_FILE_NAME_BYTES = 255
const val MAX_MIME_TYPE_BYTES = 255

@Throws(SecurityException::class)
fun validateShareOptions(args: ShareOptions) {
validateStringLength("text", args.text, MAX_TEXT_BYTES)
validateStringLength("title", args.title, MAX_TITLE_BYTES)
validateStringLength("url", args.url, MAX_URL_BYTES)

args.url?.let {
if (it.isNotEmpty()) {
validateWebUrl(it)
}
}

val files = args.files ?: return
if (files.size > MAX_FILES) {
throw SecurityException("Too many files provided. Maximum is $MAX_FILES.")
}

var totalEstimatedBytes = 0L
for (file in files) {
validateStringLength("file name", file.name, MAX_FILE_NAME_BYTES)
validateStringLength("mime type", file.mimeType, MAX_MIME_TYPE_BYTES)

val estimatedBytes = estimateBase64DecodedSize(file.data)
if (estimatedBytes > MAX_FILE_BYTES) {
throw SecurityException("File '${file.name}' exceeds the maximum size of $MAX_FILE_BYTES bytes.")
}
totalEstimatedBytes += estimatedBytes
if (totalEstimatedBytes > MAX_TOTAL_FILE_BYTES) {
throw SecurityException("Total shared file size exceeds the maximum of $MAX_TOTAL_FILE_BYTES bytes.")
}
}
}

@Throws(SecurityException::class)
fun validateDecodedFileSize(file: SharedFile, byteCount: Int) {
if (byteCount > MAX_FILE_BYTES) {
throw SecurityException("File '${file.name}' exceeds the maximum size of $MAX_FILE_BYTES bytes.")
}
}

@Throws(SecurityException::class)
fun estimateBase64DecodedSize(data: String): Long {
val normalized = data.filterNot { it.isWhitespace() }
if (normalized.isEmpty()) return 0
if (normalized.length % 4 != 0) {
throw SecurityException("Invalid Base64 data.")
}

val padding = normalized.takeLastWhile { it == '=' }.length
if (padding > 2) {
throw SecurityException("Invalid Base64 data.")
}

return (normalized.length / 4L) * 3L - padding
}

@Throws(SecurityException::class)
private fun validateStringLength(field: String, value: String?, maxBytes: Int) {
if (value != null && value.toByteArray(Charsets.UTF_8).size > maxBytes) {
throw SecurityException("$field exceeds the maximum length of $maxBytes bytes.")
}
}

@Throws(SecurityException::class)
private fun validateWebUrl(url: String) {
if (url.trim() != url || url.any { it.isWhitespace() || Character.isISOControl(it) }) {
throw SecurityException("Only well-formed http:// and https:// URLs can be shared as URLs.")
}

val uri = try {
URI(url)
} catch (_: Exception) {
null
}
val scheme = uri?.scheme?.lowercase()
if (scheme != "http" && scheme != "https") {
throw SecurityException("Only http:// and https:// URLs can be shared as URLs.")
}
if (uri.host.isNullOrEmpty()) {
throw SecurityException("Only http:// and https:// URLs can be shared as URLs.")
}
}
}
95 changes: 95 additions & 0 deletions android/src/test/java/ShareValidationUnitTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package plugin.vnidrop.share

import org.junit.Assert.assertEquals
import org.junit.Test

class ShareValidationUnitTest {
@Test
fun acceptsHttpAndHttpsUrls() {
ShareValidation.validateShareOptions(options(url = "https://example.com"))
ShareValidation.validateShareOptions(options(url = "http://example.com"))
}

@Test
fun rejectsNonWebUrlSchemes() {
listOf(
"file:///data/user/0/app/secret.db",
"content://provider/item",
"custom://value",
"https:///missing-host",
" https://example.com",
"https://example.com\nhttps://evil.example",
).forEach {
assertThrowsSecurity {
ShareValidation.validateShareOptions(options(url = it))
}
}
}

@Test
fun estimatesBase64DecodedSize() {
assertEquals(5, ShareValidation.estimateBase64DecodedSize("aGVsbG8="))
assertEquals(5, ShareValidation.estimateBase64DecodedSize("aGVs\nbG8="))
}

@Test
fun rejectsInvalidBase64Shape() {
assertThrowsSecurity {
ShareValidation.estimateBase64DecodedSize("abc")
}
assertThrowsSecurity {
ShareValidation.estimateBase64DecodedSize("abcd===")
}
}

@Test
fun rejectsTooManyFilesAndOversizedText() {
val files = (0..ShareValidation.MAX_FILES).map {
file("report-$it.txt", "aGVsbG8=")
}
assertThrowsSecurity {
ShareValidation.validateShareOptions(options(files = files))
}

assertThrowsSecurity {
ShareValidation.validateShareOptions(options(text = "a".repeat(ShareValidation.MAX_TEXT_BYTES + 1)))
}

assertThrowsSecurity {
ShareValidation.validateShareOptions(
options(files = listOf(file("a".repeat(ShareValidation.MAX_FILE_NAME_BYTES + 1), "aGVsbG8=")))
)
}
}

private fun options(
text: String? = null,
title: String? = null,
url: String? = null,
files: List<SharedFile>? = null,
): ShareOptions {
return ShareOptions().apply {
this.text = text
this.title = title
this.url = url
this.files = files
}
}

private fun file(name: String, data: String): SharedFile {
return SharedFile().apply {
this.name = name
this.data = data
this.mimeType = "text/plain"
}
}

private fun assertThrowsSecurity(block: () -> Unit) {
try {
block()
} catch (_: SecurityException) {
return
}
throw AssertionError("Expected SecurityException")
}
}
24 changes: 24 additions & 0 deletions android/test-support/tauri-api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}

android {
namespace = "app.tauri"
compileSdk = 36

defaultConfig {
minSdk = 21
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}

kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}
Loading
Loading