Add tauri-plugin-credentials for cross-platform credential storage
Create a Tauri v2 plugin that uses EncryptedSharedPreferences (Android Keystore) on Android and the system keychain (keyring crate) on desktop. This replaces the direct onyx-core keyring calls in the Tauri app, which failed on Android because keyring-storage was feature-gated to desktop only. - New plugin crate at apps/tauri/tauri-plugin-credentials/ with Kotlin Android code and Rust desktop fallback - Update all Tauri credential commands to use the plugin API - Add security-crypto dependency for Android and ProGuard rule for Tink - Remove onyx-core/keyring-storage dependency from Tauri app features
This commit is contained in:
parent
44ac2bc5b3
commit
192b449e87
14
apps/tauri/src-tauri/Cargo.lock
generated
14
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -2402,7 +2402,6 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"chrono",
|
||||
"directories",
|
||||
"keyring",
|
||||
"log",
|
||||
"quick-xml 0.36.2",
|
||||
"reqwest 0.12.28",
|
||||
|
|
@ -2428,6 +2427,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-credentials",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-os",
|
||||
"tokio",
|
||||
|
|
@ -4123,6 +4123,18 @@ dependencies = [
|
|||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-credentials"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"keyring",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ tauri-plugin-os = "2"
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
onyx-core = { path = "../../../crates/onyx-core", default-features = false }
|
||||
tauri-plugin-credentials = { path = "../tauri-plugin-credentials", default-features = false }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
|
|
@ -35,5 +36,5 @@ notify-debouncer-mini = { version = "0.5", optional = true }
|
|||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
desktop = ["notify", "notify-debouncer-mini", "onyx-core/keyring-storage"]
|
||||
desktop = ["notify", "notify-debouncer-mini", "tauri-plugin-credentials/desktop"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
|
|
|||
19
apps/tauri/tauri-plugin-credentials/Cargo.toml
Normal file
19
apps/tauri/tauri-plugin-credentials/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "tauri-plugin-credentials"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
links = "tauri-plugin-credentials"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-plugin = { version = "2", features = ["build"] }
|
||||
|
||||
[dependencies]
|
||||
tauri = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
|
||||
|
||||
[features]
|
||||
default = ["desktop"]
|
||||
desktop = ["keyring"]
|
||||
1
apps/tauri/tauri-plugin-credentials/android/.tauri/tauri-api/.gitignore
vendored
Normal file
1
apps/tauri/tauri-plugin-credentials/android/.tauri/tauri-api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.tauri"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("proguard-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("androidx.core:core-ktx:1.7.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.0")
|
||||
implementation("com.google.android.material:material:1.7.0")
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
}
|
||||
41
apps/tauri/tauri-plugin-credentials/android/.tauri/tauri-api/proguard-rules.pro
vendored
Normal file
41
apps/tauri/tauri-plugin-credentials/android/.tauri/tauri-api/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-keep class app.tauri.** {
|
||||
@app.tauri.JniMethod public <methods>;
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
-keep class app.tauri.plugin.JSArray {
|
||||
public <init>(...);
|
||||
}
|
||||
|
||||
-keepclassmembers class org.json.JSONArray {
|
||||
public put(...);
|
||||
}
|
||||
|
||||
-keep class app.tauri.plugin.JSObject {
|
||||
public <init>(...);
|
||||
public put(...);
|
||||
}
|
||||
|
||||
-keep @app.tauri.annotation.TauriPlugin public class * {
|
||||
@app.tauri.annotation.Command public <methods>;
|
||||
@app.tauri.annotation.PermissionCallback <methods>;
|
||||
@app.tauri.annotation.ActivityCallback <methods>;
|
||||
@app.tauri.annotation.Permission <methods>;
|
||||
public <init>(...);
|
||||
}
|
||||
|
||||
-keep @app.tauri.annotation.InvokeArg public class * {
|
||||
*;
|
||||
}
|
||||
|
||||
-keep @com.fasterxml.jackson.databind.annotation.JsonDeserialize public class * {
|
||||
*;
|
||||
}
|
||||
|
||||
-keep @com.fasterxml.jackson.databind.annotation.JsonSerialize public class * {
|
||||
*;
|
||||
}
|
||||
|
||||
-keep class * extends com.fasterxml.jackson.databind.JsonDeserializer { *; }
|
||||
|
||||
-keep class * extends com.fasterxml.jackson.databind.JsonSerializer { *; }
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("app.tauri.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import android.app.Activity
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSObject
|
||||
|
||||
@TauriPlugin
|
||||
class AppPlugin(private val activity: Activity): Plugin(activity) {
|
||||
private val BACK_BUTTON_EVENT = "back-button"
|
||||
|
||||
private var webView: WebView? = null
|
||||
|
||||
override fun load(webView: WebView) {
|
||||
this.webView = webView
|
||||
}
|
||||
|
||||
init {
|
||||
val callback = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (!hasListener(BACK_BUTTON_EVENT)) {
|
||||
if (this@AppPlugin.webView?.canGoBack() == true) {
|
||||
this@AppPlugin.webView!!.goBack()
|
||||
} else {
|
||||
this.isEnabled = false
|
||||
this@AppPlugin.activity.onBackPressed()
|
||||
this.isEnabled = true
|
||||
}
|
||||
} else {
|
||||
val data = JSObject().apply {
|
||||
put("canGoBack", this@AppPlugin.webView?.canGoBack() ?: false)
|
||||
}
|
||||
trigger(BACK_BUTTON_EVENT, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
(activity as AppCompatActivity).onBackPressedDispatcher.addCallback(activity, callback)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun exit(invoke: Invoke) {
|
||||
invoke.resolve()
|
||||
activity.finish()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.min
|
||||
|
||||
internal class FsUtils {
|
||||
companion object {
|
||||
fun readAsset(assetManager: AssetManager, fileName: String): String {
|
||||
assetManager.open(fileName).bufferedReader().use {
|
||||
return it.readText()
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileUrlForUri(context: Context, uri: Uri): String? {
|
||||
// DocumentProvider
|
||||
if (DocumentsContract.isDocumentUri(context, uri)) {
|
||||
// ExternalStorageProvider
|
||||
if (isExternalStorageDocument(uri)) {
|
||||
val docId: String = DocumentsContract.getDocumentId(uri)
|
||||
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }
|
||||
.toTypedArray()
|
||||
val type = split[0]
|
||||
if ("primary".equals(type, ignoreCase = true)) {
|
||||
return legacyPrimaryPath(split[1])
|
||||
} else {
|
||||
val splitIndex = docId.indexOf(':', 1)
|
||||
val tag = docId.substring(0, splitIndex)
|
||||
val path = docId.substring(splitIndex + 1)
|
||||
val nonPrimaryVolume = getPathToNonPrimaryVolume(context, tag)
|
||||
if (nonPrimaryVolume != null) {
|
||||
val result = "$nonPrimaryVolume/$path"
|
||||
val file = File(result)
|
||||
return if (file.exists() && file.canRead()) {
|
||||
result
|
||||
} else null
|
||||
}
|
||||
}
|
||||
} else if (isDownloadsDocument(uri)) {
|
||||
val id: String = DocumentsContract.getDocumentId(uri)
|
||||
val contentUri: Uri = ContentUris.withAppendedId(
|
||||
Uri.parse("content://downloads/public_downloads"),
|
||||
java.lang.Long.valueOf(id)
|
||||
)
|
||||
return getDataColumn(context, contentUri, null, null)
|
||||
} else if (isMediaDocument(uri)) {
|
||||
val docId: String = DocumentsContract.getDocumentId(uri)
|
||||
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }
|
||||
.toTypedArray()
|
||||
val type = split[0]
|
||||
var contentUri: Uri? = null
|
||||
when (type) {
|
||||
"image" -> {
|
||||
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
"video" -> {
|
||||
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
"audio" -> {
|
||||
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
}
|
||||
val selection = "_id=?"
|
||||
val selectionArgs = arrayOf(split[1])
|
||||
if (contentUri != null) {
|
||||
return getDataColumn(context, contentUri, selection, selectionArgs)
|
||||
}
|
||||
}
|
||||
} else if ("content".equals(uri.scheme, ignoreCase = true)) {
|
||||
// Return the remote address
|
||||
return if (isGooglePhotosUri(uri)) uri.lastPathSegment else getDataColumn(
|
||||
context,
|
||||
uri,
|
||||
null,
|
||||
null
|
||||
)
|
||||
} else if ("file".equals(uri.scheme, ignoreCase = true)) {
|
||||
return uri.path
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of the data column for this Uri. This is useful for
|
||||
* MediaStore Uris, and other file-based ContentProviders.
|
||||
*
|
||||
* @param context The context.
|
||||
* @param uri The Uri to query.
|
||||
* @param selection (Optional) Filter used in the query.
|
||||
* @param selectionArgs (Optional) Selection arguments used in the query.
|
||||
* @return The value of the _data column, which is typically a file path.
|
||||
*/
|
||||
private fun getDataColumn(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?
|
||||
): String? {
|
||||
var path: String? = null
|
||||
var cursor: Cursor? = null
|
||||
val column = "_data"
|
||||
val projection = arrayOf(column)
|
||||
try {
|
||||
cursor = context.contentResolver.query(uri, projection, selection, selectionArgs, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
val index = cursor.getColumnIndexOrThrow(column)
|
||||
path = cursor.getString(index)
|
||||
}
|
||||
} catch (ex: IllegalArgumentException) {
|
||||
return getCopyFilePath(uri, context)
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
return path ?: getCopyFilePath(uri, context)
|
||||
}
|
||||
|
||||
private fun getCopyFilePath(uri: Uri, context: Context): String? {
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)!!
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
val name = cursor.getString(nameIndex)
|
||||
val file = File(context.filesDir, name)
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val outputStream = FileOutputStream(file)
|
||||
var read: Int
|
||||
val maxBufferSize = 1024 * 1024
|
||||
val bufferSize = min(inputStream!!.available(), maxBufferSize)
|
||||
val buffers = ByteArray(bufferSize)
|
||||
while (inputStream.read(buffers).also { read = it } != -1) {
|
||||
outputStream.write(buffers, 0, read)
|
||||
}
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
} finally {
|
||||
cursor.close()
|
||||
}
|
||||
return file.path
|
||||
}
|
||||
|
||||
private fun legacyPrimaryPath(pathPart: String): String {
|
||||
return Environment.getExternalStorageDirectory().toString() + "/" + pathPart
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uri The Uri to check.
|
||||
* @return Whether the Uri authority is ExternalStorageProvider.
|
||||
*/
|
||||
private fun isExternalStorageDocument(uri: Uri): Boolean {
|
||||
return "com.android.externalstorage.documents" == uri.authority
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uri The Uri to check.
|
||||
* @return Whether the Uri authority is DownloadsProvider.
|
||||
*/
|
||||
private fun isDownloadsDocument(uri: Uri): Boolean {
|
||||
return "com.android.providers.downloads.documents" == uri.authority
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uri The Uri to check.
|
||||
* @return Whether the Uri authority is MediaProvider.
|
||||
*/
|
||||
private fun isMediaDocument(uri: Uri): Boolean {
|
||||
return "com.android.providers.media.documents" == uri.authority
|
||||
}
|
||||
|
||||
/**
|
||||
* @param uri The Uri to check.
|
||||
* @return Whether the Uri authority is Google Photos.
|
||||
*/
|
||||
private fun isGooglePhotosUri(uri: Uri): Boolean {
|
||||
return "com.google.android.apps.photos.content" == uri.authority
|
||||
}
|
||||
|
||||
private fun getPathToNonPrimaryVolume(context: Context, tag: String): String? {
|
||||
val volumes = context.externalCacheDirs
|
||||
if (volumes != null) {
|
||||
for (volume in volumes) {
|
||||
if (volume != null) {
|
||||
val path = volume.absolutePath
|
||||
val index = path.indexOf(tag)
|
||||
if (index != -1) {
|
||||
return path.substring(0, index) + tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
internal annotation class JniMethod
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/Logger.java
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
class Logger {
|
||||
companion object {
|
||||
private const val LOG_TAG_CORE = "Tauri"
|
||||
|
||||
fun tags(vararg subtags: String): String {
|
||||
return if (subtags.isNotEmpty()) {
|
||||
LOG_TAG_CORE + "/" + TextUtils.join("/", subtags)
|
||||
} else LOG_TAG_CORE
|
||||
}
|
||||
|
||||
fun verbose(message: String) {
|
||||
verbose(LOG_TAG_CORE, message)
|
||||
}
|
||||
|
||||
fun verbose(tag: String, message: String) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.v(tag, message)
|
||||
}
|
||||
|
||||
fun debug(message: String) {
|
||||
debug(LOG_TAG_CORE, message)
|
||||
}
|
||||
|
||||
fun debug(tag: String, message: String) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.d(tag, message)
|
||||
}
|
||||
|
||||
fun info(message: String) {
|
||||
info(LOG_TAG_CORE, message)
|
||||
}
|
||||
|
||||
fun info(tag: String, message: String) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.i(tag, message)
|
||||
}
|
||||
|
||||
fun warn(message: String) {
|
||||
warn(LOG_TAG_CORE, message)
|
||||
}
|
||||
|
||||
fun warn(tag: String, message: String) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.w(tag, message)
|
||||
}
|
||||
|
||||
fun error(message: String) {
|
||||
error(LOG_TAG_CORE, message, null)
|
||||
}
|
||||
|
||||
fun error(message: String, e: Throwable?) {
|
||||
error(LOG_TAG_CORE, message, e)
|
||||
}
|
||||
|
||||
fun error(tag: String, message: String, e: Throwable?) {
|
||||
if (!shouldLog()) {
|
||||
return
|
||||
}
|
||||
Log.e(tag, message, e)
|
||||
}
|
||||
|
||||
private fun shouldLog(): Boolean {
|
||||
return BuildConfig.DEBUG
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import android.app.Activity
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.OpenableColumns
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Plugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSObject
|
||||
|
||||
const val TAURI_ASSETS_DIRECTORY_URI = "asset://localhost/"
|
||||
|
||||
@InvokeArg
|
||||
class GetFileNameFromUriArgs {
|
||||
lateinit var uri: String
|
||||
}
|
||||
|
||||
@TauriPlugin
|
||||
class PathPlugin(private val activity: Activity): Plugin(activity) {
|
||||
private fun resolvePath(invoke: Invoke, path: String?) {
|
||||
val obj = JSObject()
|
||||
obj.put("path", path)
|
||||
invoke.resolve(obj)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getFileNameFromUri(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(GetFileNameFromUriArgs::class.java)
|
||||
val name = getRealNameFromURI(activity, Uri.parse(args.uri))
|
||||
val res = JSObject()
|
||||
res.put("name", name)
|
||||
invoke.resolve(res)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getAudioDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_MUSIC)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getExternalCacheDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.externalCacheDir?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getConfigDir(invoke: Invoke) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
resolvePath(invoke, activity.dataDir.absolutePath)
|
||||
} else {
|
||||
resolvePath(invoke, activity.applicationInfo.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getDataDir(invoke: Invoke) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
resolvePath(invoke, activity.dataDir.absolutePath)
|
||||
} else {
|
||||
resolvePath(invoke, activity.applicationInfo.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getDocumentDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getDownloadDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getPictureDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getPublicDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.getExternalFilesDir(Environment.DIRECTORY_DCIM)?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getVideoDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.externalCacheDir?.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getResourcesDir(invoke: Invoke) {
|
||||
resolvePath(invoke, TAURI_ASSETS_DIRECTORY_URI)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getCacheDir(invoke: Invoke) {
|
||||
resolvePath(invoke, activity.cacheDir.absolutePath)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun getHomeDir(invoke: Invoke) {
|
||||
resolvePath(invoke, Environment.getExternalStorageDirectory().absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRealNameFromURI(activity: Activity, contentUri: Uri): String? {
|
||||
var cursor: Cursor? = null
|
||||
try {
|
||||
val projection = arrayOf(OpenableColumns.DISPLAY_NAME)
|
||||
cursor = activity.contentResolver.query(contentUri, projection, null, null, null)
|
||||
|
||||
cursor?.let {
|
||||
val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (it.moveToFirst()) {
|
||||
return it.getString(columnIndex)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.error("failed to get real name from URI $e")
|
||||
} finally {
|
||||
cursor?.close()
|
||||
}
|
||||
|
||||
return null // Return null if no file name could be resolved
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/PermissionHelper.java
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import java.util.ArrayList;
|
||||
|
||||
object PermissionHelper {
|
||||
/**
|
||||
* Checks if a list of given permissions are all granted by the user
|
||||
*
|
||||
* @param permissions Permissions to check.
|
||||
* @return True if all permissions are granted, false if at least one is not.
|
||||
*/
|
||||
fun hasPermissions(context: Context?, permissions: Array<String>): Boolean {
|
||||
for (perm in permissions) {
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
context!!,
|
||||
perm
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given permission has been defined in the AndroidManifest.xml
|
||||
*
|
||||
* @param permission A permission to check.
|
||||
* @return True if the permission has been defined in the Manifest, false if not.
|
||||
*/
|
||||
fun hasDefinedPermission(context: Context, permission: String): Boolean {
|
||||
var hasPermission = false
|
||||
val requestedPermissions = getManifestPermissions(context)
|
||||
if (requestedPermissions != null && requestedPermissions.isNotEmpty()) {
|
||||
val requestedPermissionsList = listOf(*requestedPermissions)
|
||||
val requestedPermissionsArrayList = ArrayList(requestedPermissionsList)
|
||||
if (requestedPermissionsArrayList.contains(permission)) {
|
||||
hasPermission = true
|
||||
}
|
||||
}
|
||||
return hasPermission
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether all of the given permissions have been defined in the AndroidManifest.xml
|
||||
* @param context the app context
|
||||
* @param permissions a list of permissions
|
||||
* @return true only if all permissions are defined in the AndroidManifest.xml
|
||||
*/
|
||||
fun hasDefinedPermissions(context: Context, permissions: Array<String>): Boolean {
|
||||
for (permission in permissions) {
|
||||
if (!hasDefinedPermission(context, permission)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions defined in AndroidManifest.xml
|
||||
*
|
||||
* @return The permissions defined in AndroidManifest.xml
|
||||
*/
|
||||
private fun getManifestPermissions(context: Context): Array<String>? {
|
||||
var requestedPermissions: Array<String>? = null
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
pm.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
|
||||
}
|
||||
if (packageInfo != null) {
|
||||
requestedPermissions = packageInfo.requestedPermissions
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return requestedPermissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml
|
||||
*
|
||||
* @param neededPermissions The permissions needed.
|
||||
* @return The permissions not present in AndroidManifest.xml
|
||||
*/
|
||||
fun getUndefinedPermissions(context: Context, neededPermissions: Array<String>): Array<String> {
|
||||
val undefinedPermissions = ArrayList<String>()
|
||||
val requestedPermissions = getManifestPermissions(context)
|
||||
if (!requestedPermissions.isNullOrEmpty()) {
|
||||
val requestedPermissionsList = listOf(*requestedPermissions)
|
||||
val requestedPermissionsArrayList = ArrayList(requestedPermissionsList)
|
||||
for (permission in neededPermissions) {
|
||||
if (!requestedPermissionsArrayList.contains(permission)) {
|
||||
undefinedPermissions.add(permission)
|
||||
}
|
||||
}
|
||||
return undefinedPermissions.toTypedArray()
|
||||
}
|
||||
return neededPermissions
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import java.util.*
|
||||
|
||||
enum class PermissionState(private val state: String) {
|
||||
GRANTED("granted"), DENIED("denied"), PROMPT("prompt"), PROMPT_WITH_RATIONALE("prompt-with-rationale");
|
||||
|
||||
override fun toString(): String {
|
||||
return state
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun byState(state: String): PermissionState {
|
||||
return valueOf(state.uppercase(Locale.ROOT).replace('-', '_'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class ActivityCallback
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
annotation class InvokeArg
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Permission(
|
||||
/**
|
||||
* An array of Android permission strings.
|
||||
* Eg: {Manifest.permission.ACCESS_COARSE_LOCATION}
|
||||
* or {"android.permission.ACCESS_COARSE_LOCATION"}
|
||||
*/
|
||||
val strings: Array<String> = [],
|
||||
/**
|
||||
* An optional name to use instead of the Android permission string.
|
||||
*/
|
||||
val alias: String = ""
|
||||
)
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
annotation class PermissionCallback
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Command
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.annotation
|
||||
|
||||
import app.tauri.annotation.Permission
|
||||
|
||||
/**
|
||||
* Base annotation for all Plugins
|
||||
*/
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class TauriPlugin(
|
||||
/**
|
||||
* Permissions this plugin needs, in order to make permission requests
|
||||
* easy if the plugin only needs basic permission prompting
|
||||
*/
|
||||
val permissions: Array<Permission> = []
|
||||
)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
|
||||
const val CHANNEL_PREFIX = "__CHANNEL__:"
|
||||
|
||||
internal class ChannelDeserializer(val sendChannelData: (channelId: Long, data: String) -> Unit, private val objectMapper: ObjectMapper): JsonDeserializer<Channel>() {
|
||||
override fun deserialize(
|
||||
jsonParser: JsonParser?,
|
||||
deserializationContext: DeserializationContext
|
||||
): Channel {
|
||||
val channelDef = deserializationContext.readValue(jsonParser, String::class.java)
|
||||
val callback = channelDef.substring(CHANNEL_PREFIX.length).toLongOrNull() ?: throw Error("unexpected channel value $channelDef")
|
||||
return Channel(callback, { res -> sendChannelData(callback, res) }, objectMapper)
|
||||
}
|
||||
}
|
||||
|
||||
class Channel(val id: Long, private val handler: (data: String) -> Unit, private val objectMapper: ObjectMapper) {
|
||||
fun send(data: JSObject) {
|
||||
handler(PluginResult(data).toString())
|
||||
}
|
||||
|
||||
fun sendObject(data: Any) {
|
||||
handler(objectMapper.writeValueAsString(data))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
internal class InvalidCommandException : Exception {
|
||||
constructor(s: String?) : super(s) {}
|
||||
constructor(t: Throwable?) : super(t) {}
|
||||
constructor(s: String?, t: Throwable?) : super(s, t) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import app.tauri.Logger
|
||||
import com.fasterxml.jackson.core.type.TypeReference
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
|
||||
class Invoke(
|
||||
val id: Long,
|
||||
val command: String,
|
||||
val callback: Long,
|
||||
val error: Long,
|
||||
private val sendResponse: (callback: Long, data: String) -> Unit,
|
||||
private val argsJson: String,
|
||||
private val jsonMapper: ObjectMapper
|
||||
) {
|
||||
fun getRawArgs(): String {
|
||||
return argsJson
|
||||
}
|
||||
|
||||
fun getArgs(): JSObject {
|
||||
return JSObject(argsJson)
|
||||
}
|
||||
|
||||
fun<T> parseArgs(cls: Class<T>): T {
|
||||
return jsonMapper.readValue(argsJson, cls)
|
||||
}
|
||||
|
||||
fun<T> parseArgs(ref: TypeReference<T>): T {
|
||||
return jsonMapper.readValue(argsJson, ref)
|
||||
}
|
||||
|
||||
fun resolve(data: JSObject?) {
|
||||
sendResponse(callback, PluginResult(data).toString())
|
||||
}
|
||||
|
||||
fun resolveObject(data: Any) {
|
||||
sendResponse(callback, jsonMapper.writeValueAsString(data))
|
||||
}
|
||||
|
||||
fun resolve() {
|
||||
sendResponse(callback, "null")
|
||||
}
|
||||
|
||||
fun reject(msg: String?, code: String?, ex: Exception?, data: JSObject?) {
|
||||
val errorResult = PluginResult()
|
||||
|
||||
if (ex != null) {
|
||||
Logger.error(Logger.tags("Plugin"), msg!!, ex)
|
||||
}
|
||||
|
||||
errorResult.put("message", msg)
|
||||
if (code != null) {
|
||||
errorResult.put("code", code)
|
||||
}
|
||||
if (data != null) {
|
||||
errorResult.put("data", data)
|
||||
}
|
||||
|
||||
sendResponse(error, errorResult.toString())
|
||||
}
|
||||
|
||||
fun reject(msg: String?, ex: Exception?, data: JSObject?) {
|
||||
reject(msg, null, ex, data)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, code: String?, data: JSObject?) {
|
||||
reject(msg, code, null, data)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, code: String?, ex: Exception?) {
|
||||
reject(msg, code, ex, null)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, data: JSObject?) {
|
||||
reject(msg, null, null, data)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, ex: Exception?) {
|
||||
reject(msg, null, ex, null)
|
||||
}
|
||||
|
||||
fun reject(msg: String?, code: String?) {
|
||||
reject(msg, code, null, null)
|
||||
}
|
||||
|
||||
fun reject(msg: String?) {
|
||||
reject(msg, null, null, null)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
|
||||
class JSArray : JSONArray {
|
||||
constructor() : super() {}
|
||||
constructor(json: String?) : super(json) {}
|
||||
constructor(copyFrom: Collection<*>?) : super(copyFrom) {}
|
||||
constructor(array: Any?) : super(array) {}
|
||||
|
||||
@Suppress("UNCHECKED_CAST", "ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE")
|
||||
@Throws(JSONException::class)
|
||||
fun <E> toList(): List<E> {
|
||||
val items: MutableList<E> = ArrayList()
|
||||
var o: Any? = null
|
||||
for (i in 0 until this.length()) {
|
||||
this.get(i).also { o = it }
|
||||
try {
|
||||
items.add(this.get(i) as E)
|
||||
} catch (ex: Exception) {
|
||||
throw JSONException("Not all items are instances of the given type")
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new JSArray without throwing a error
|
||||
*/
|
||||
fun from(array: Any?): JSArray? {
|
||||
try {
|
||||
return JSArray(array)
|
||||
} catch (ex: JSONException) {
|
||||
//
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
class JSObject : JSONObject {
|
||||
constructor() : super()
|
||||
constructor(json: String) : super(json)
|
||||
constructor(obj: JSONObject, names: Array<String>) : super(obj, names)
|
||||
|
||||
override fun getString(key: String): String {
|
||||
return getString(key, "")!!
|
||||
}
|
||||
|
||||
fun getString(key: String, defaultValue: String?): String? {
|
||||
try {
|
||||
if (!super.isNull(key)) {
|
||||
return super.getString(key)
|
||||
}
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun getInteger(key: String): Int? {
|
||||
return getIntegerInternal(key, null)
|
||||
}
|
||||
|
||||
fun getInteger(key: String, defaultValue: Int): Int {
|
||||
return getIntegerInternal(key, defaultValue)!!
|
||||
}
|
||||
|
||||
private fun getIntegerInternal(key: String, defaultValue: Int?): Int? {
|
||||
try {
|
||||
return super.getInt(key)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String): Boolean {
|
||||
return getBooleanInternal(key, false)!!
|
||||
}
|
||||
|
||||
fun getBoolean(key: String, defaultValue: Boolean?): Boolean {
|
||||
return getBooleanInternal(key, defaultValue)!!
|
||||
}
|
||||
|
||||
private fun getBooleanInternal(key: String, defaultValue: Boolean?): Boolean? {
|
||||
try {
|
||||
return super.getBoolean(key)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
fun getJSObject(name: String): JSObject? {
|
||||
try {
|
||||
return getJSObjectInternal(name, null)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getJSObject(name: String, defaultValue: JSObject): JSObject {
|
||||
return getJSObjectInternal(name, defaultValue)!!
|
||||
}
|
||||
|
||||
private fun getJSObjectInternal(name: String, defaultValue: JSObject?): JSObject? {
|
||||
try {
|
||||
val obj = get(name)
|
||||
if (obj is JSONObject) {
|
||||
val keysIter = obj.keys()
|
||||
val keys: MutableList<String> = ArrayList()
|
||||
while (keysIter.hasNext()) {
|
||||
keys.add(keysIter.next())
|
||||
}
|
||||
return JSObject(obj, keys.toTypedArray())
|
||||
}
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Boolean): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Int): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Long): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Double): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun put(key: String, value: Any?): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun put(key: String, value: String?): JSObject {
|
||||
try {
|
||||
super.put(key, value)
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a pathetic JSONObject into a JSObject
|
||||
* @param obj
|
||||
*/
|
||||
@Throws(JSONException::class)
|
||||
fun fromJSONObject(obj: JSONObject): JSObject {
|
||||
val keysIter = obj.keys()
|
||||
val keys: MutableList<String> = ArrayList()
|
||||
while (keysIter.hasNext()) {
|
||||
keys.add(keysIter.next())
|
||||
}
|
||||
return JSObject(obj, keys.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,490 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.Configuration
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.core.app.ActivityCompat
|
||||
import app.tauri.FsUtils
|
||||
import app.tauri.Logger
|
||||
import app.tauri.PermissionHelper
|
||||
import app.tauri.PermissionState
|
||||
import app.tauri.annotation.ActivityCallback
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.PermissionCallback
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import java.util.*
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
@InvokeArg
|
||||
internal class RegisterListenerArgs {
|
||||
lateinit var event: String
|
||||
lateinit var handler: Channel
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
internal class RemoveListenerArgs {
|
||||
lateinit var event: String
|
||||
var channelId: Long = 0
|
||||
}
|
||||
|
||||
@InvokeArg internal class RequestPermissionsArgs {
|
||||
var permissions: List<String>? = null
|
||||
}
|
||||
|
||||
abstract class Plugin(private val activity: Activity) {
|
||||
var handle: PluginHandle? = null
|
||||
private val listeners: MutableMap<String, MutableList<Channel>> = mutableMapOf()
|
||||
|
||||
open fun load(webView: WebView) {}
|
||||
|
||||
fun jsonMapper(): ObjectMapper {
|
||||
return handle!!.jsonMapper
|
||||
}
|
||||
|
||||
fun<T> getConfig(cls: Class<T>): T {
|
||||
return jsonMapper().readValue(handle!!.config, cls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new intent being received by the application
|
||||
*/
|
||||
open fun onNewIntent(intent: Intent) {}
|
||||
|
||||
|
||||
/**
|
||||
* This event is called just before another activity comes into the foreground.
|
||||
*/
|
||||
open fun onPause() {}
|
||||
|
||||
/**
|
||||
* This event is called when the user returns to the activity. It is also called on cold starts.
|
||||
*/
|
||||
open fun onResume() {}
|
||||
|
||||
/**
|
||||
* This event is called after onStop() when the current activity is being re-displayed to the user (the user has navigated back to it).
|
||||
* It will be followed by onStart() and then onResume().
|
||||
*/
|
||||
open fun onRestart() {}
|
||||
|
||||
/**
|
||||
* This event is called when the app is no longer visible to the user.
|
||||
* You will next receive either onRestart(), onDestroy(), or nothing, depending on later user activity.
|
||||
*/
|
||||
open fun onStop() {}
|
||||
|
||||
/**
|
||||
* This event is called before the activity is destroyed.
|
||||
*/
|
||||
open fun onDestroy() {}
|
||||
|
||||
/**
|
||||
* This event is called when a configuration change occurs but the app does not recreate the activity.
|
||||
*/
|
||||
open fun onConfigurationChanged(newConfig: Configuration) {}
|
||||
|
||||
/**
|
||||
* Start activity for result with the provided Intent and resolve calling the provided callback method name.
|
||||
*
|
||||
* If there is no registered activity callback for the method name passed in, the call will
|
||||
* be rejected. Make sure a valid activity result callback method is registered using the
|
||||
* [ActivityCallback] annotation.
|
||||
*
|
||||
* @param invoke the invoke object
|
||||
* @param intent the intent used to start an activity
|
||||
* @param callbackName the name of the callback to run when the launched activity is finished
|
||||
*/
|
||||
fun startActivityForResult(invoke: Invoke, intent: Intent, callbackName: String) {
|
||||
handle!!.startActivityForResult(invoke, intent, callbackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like startActivityForResult() but taking an IntentSender to describe the activity to be started.
|
||||
*
|
||||
* If there is no registered activity callback for the method name passed in, the call will
|
||||
* be rejected. Make sure a valid activity result callback method is registered using the
|
||||
* [ActivityCallback] annotation.
|
||||
*
|
||||
* @param invoke the invoke object
|
||||
* @param intentSender the intent used to start an activity
|
||||
* @param callbackName the name of the callback to run when the launched activity is finished
|
||||
*/
|
||||
fun startIntentSenderForResult(invoke: Invoke, intentSender: IntentSenderRequest, callbackName: String) {
|
||||
handle!!.startIntentSenderForResult(invoke, intentSender, callbackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin log tags.
|
||||
* @param subTags
|
||||
*/
|
||||
protected fun getLogTag(vararg subTags: String): String {
|
||||
return Logger.tags(*subTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a log tag with the plugin's class name as subTag.
|
||||
*/
|
||||
protected fun getLogTag(): String {
|
||||
return Logger.tags(this.javaClass.simpleName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an URI to an URL that can be loaded by the webview.
|
||||
*/
|
||||
fun assetUrl(u: Uri): String {
|
||||
var path = FsUtils.getFileUrlForUri(activity, u)
|
||||
if (path?.startsWith("file://") == true) {
|
||||
path = path.replace("file://", "")
|
||||
}
|
||||
return "asset://localhost$path"
|
||||
}
|
||||
|
||||
fun trigger(event: String, payload: JSObject) {
|
||||
val eventListeners = listeners[event]
|
||||
if (!eventListeners.isNullOrEmpty()) {
|
||||
val listeners = CopyOnWriteArrayList(eventListeners)
|
||||
for (channel in listeners) {
|
||||
channel.send(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerObject(event: String, payload: Any) {
|
||||
val eventListeners = listeners[event]
|
||||
if (!eventListeners.isNullOrEmpty()) {
|
||||
val listeners = CopyOnWriteArrayList(eventListeners)
|
||||
for (channel in listeners) {
|
||||
channel.sendObject(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasListener(event: String): Boolean {
|
||||
return !listeners[event].isNullOrEmpty()
|
||||
}
|
||||
|
||||
@Command
|
||||
open fun registerListener(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(RegisterListenerArgs::class.java)
|
||||
|
||||
val eventListeners = listeners[args.event]
|
||||
if (eventListeners.isNullOrEmpty()) {
|
||||
listeners[args.event] = mutableListOf(args.handler)
|
||||
} else {
|
||||
eventListeners.add(args.handler)
|
||||
}
|
||||
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@Command
|
||||
open fun removeListener(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(RemoveListenerArgs::class.java)
|
||||
|
||||
val eventListeners = listeners[args.event]
|
||||
if (!eventListeners.isNullOrEmpty()) {
|
||||
val c = eventListeners.find { c -> c.id == args.channelId }
|
||||
if (c != null) {
|
||||
eventListeners.remove(c)
|
||||
}
|
||||
}
|
||||
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported plugin method for checking the granted status for each permission
|
||||
* declared on the plugin. This plugin call responds with a mapping of permissions to
|
||||
* the associated granted status.
|
||||
*/
|
||||
@Command
|
||||
@PermissionCallback
|
||||
open fun checkPermissions(invoke: Invoke) {
|
||||
val permissionsResult: Map<String, PermissionState?> = getPermissionStates()
|
||||
if (permissionsResult.isEmpty()) {
|
||||
// if no permissions are defined on the plugin, resolve undefined
|
||||
invoke.resolve()
|
||||
} else {
|
||||
val permissionsResultJSON = JSObject()
|
||||
for ((key, value) in permissionsResult) {
|
||||
permissionsResultJSON.put(key, value)
|
||||
}
|
||||
invoke.resolve(permissionsResultJSON)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported plugin method to request all permissions for this plugin.
|
||||
* To manually request permissions within a plugin use:
|
||||
* [.requestAllPermissions], or
|
||||
* [.requestPermissionForAlias], or
|
||||
* [.requestPermissionForAliases]
|
||||
*
|
||||
* @param invoke
|
||||
*/
|
||||
@Command
|
||||
open fun requestPermissions(invoke: Invoke) {
|
||||
val annotation = handle?.annotation
|
||||
if (annotation != null) {
|
||||
// handle permission requests for plugins defined with @TauriPlugin
|
||||
var permAliases: Array<String>? = null
|
||||
val autoGrantPerms: MutableSet<String> = HashSet()
|
||||
|
||||
val args = invoke.parseArgs(RequestPermissionsArgs::class.java)
|
||||
|
||||
args.permissions?.let {
|
||||
val aliasSet: MutableSet<String> = HashSet()
|
||||
|
||||
for (perm in annotation.permissions) {
|
||||
if (it.contains(perm.alias)) {
|
||||
aliasSet.add(perm.alias)
|
||||
}
|
||||
}
|
||||
if (aliasSet.isEmpty()) {
|
||||
invoke.reject("No valid permission alias was requested of this plugin.")
|
||||
return
|
||||
} else {
|
||||
permAliases = aliasSet.toTypedArray()
|
||||
}
|
||||
} ?: run {
|
||||
val aliasSet: MutableSet<String> = HashSet()
|
||||
|
||||
for (perm in annotation.permissions) {
|
||||
// If a permission is defined with no permission strings, separate it for auto-granting.
|
||||
// Otherwise, the alias is added to the list to be requested.
|
||||
if (perm.strings.isEmpty() || perm.strings.size == 1 && perm.strings[0]
|
||||
.isEmpty()
|
||||
) {
|
||||
if (perm.alias.isNotEmpty()) {
|
||||
autoGrantPerms.add(perm.alias)
|
||||
}
|
||||
} else {
|
||||
aliasSet.add(perm.alias)
|
||||
}
|
||||
}
|
||||
permAliases = aliasSet.toTypedArray()
|
||||
}
|
||||
|
||||
permAliases?.let {
|
||||
// request permissions using provided aliases or all defined on the plugin
|
||||
requestPermissionForAliases(it, invoke, "checkPermissions")
|
||||
} ?: run {
|
||||
if (autoGrantPerms.isNotEmpty()) {
|
||||
// if the plugin only has auto-grant permissions, return all as GRANTED
|
||||
val permissionsResults = JSObject()
|
||||
for (perm in autoGrantPerms) {
|
||||
permissionsResults.put(perm, PermissionState.GRANTED.toString())
|
||||
}
|
||||
invoke.resolve(permissionsResults)
|
||||
} else {
|
||||
// no permissions are defined on the plugin, resolve undefined
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given permission alias is correctly declared in AndroidManifest.xml
|
||||
* @param alias a permission alias defined on the plugin
|
||||
* @return true only if all permissions associated with the given alias are declared in the manifest
|
||||
*/
|
||||
fun isPermissionDeclared(alias: String): Boolean {
|
||||
val annotation = handle?.annotation
|
||||
if (annotation != null) {
|
||||
for (perm in annotation.permissions) {
|
||||
if (alias.equals(perm.alias, ignoreCase = true)) {
|
||||
var result = true
|
||||
for (permString in perm.strings) {
|
||||
result = result && PermissionHelper.hasDefinedPermission(activity, permString)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.error(
|
||||
String.format(
|
||||
"isPermissionDeclared: No alias defined for %s " + "or missing @TauriPlugin annotation.",
|
||||
alias
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
private fun permissionActivityResult(
|
||||
invoke: Invoke,
|
||||
permissionStrings: Array<String>,
|
||||
callbackName: String
|
||||
) {
|
||||
handle!!.requestPermissions(invoke, permissionStrings, callbackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request all of the specified permissions in the TauriPlugin annotation (if any)
|
||||
*
|
||||
* If there is no registered permission callback for the Invoke passed in, the call will
|
||||
* be rejected. Make sure a valid permission callback method is registered using the
|
||||
* [PermissionCallback] annotation.
|
||||
*
|
||||
* @param invoke
|
||||
* @param callbackName the name of the callback to run when the permission request is complete
|
||||
*/
|
||||
protected fun requestAllPermissions(
|
||||
invoke: Invoke,
|
||||
callbackName: String
|
||||
) {
|
||||
val annotation = handle!!.annotation
|
||||
if (annotation != null) {
|
||||
val perms: HashSet<String> = HashSet()
|
||||
for (perm in annotation.permissions) {
|
||||
perms.addAll(perm.strings)
|
||||
}
|
||||
permissionActivityResult(invoke, perms.toArray(arrayOfNulls<String>(0)), callbackName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permissions using an alias defined on the plugin.
|
||||
*
|
||||
* If there is no registered permission callback for the Invoke passed in, the call will
|
||||
* be rejected. Make sure a valid permission callback method is registered using the
|
||||
* [PermissionCallback] annotation.
|
||||
*
|
||||
* @param alias an alias defined on the plugin
|
||||
* @param invoke the invoke involved in originating the request
|
||||
* @param callbackName the name of the callback to run when the permission request is complete
|
||||
*/
|
||||
protected fun requestPermissionForAlias(
|
||||
alias: String,
|
||||
invoke: Invoke,
|
||||
callbackName: String
|
||||
) {
|
||||
requestPermissionForAliases(arrayOf(alias), invoke, callbackName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permissions using aliases defined on the plugin.
|
||||
*
|
||||
* If there is no registered permission callback for the Invoke passed in, the call will
|
||||
* be rejected. Make sure a valid permission callback method is registered using the
|
||||
* [PermissionCallback] annotation.
|
||||
*
|
||||
* @param aliases a set of aliases defined on the plugin
|
||||
* @param invoke the invoke involved in originating the request
|
||||
* @param callbackName the name of the callback to run when the permission request is complete
|
||||
*/
|
||||
fun requestPermissionForAliases(
|
||||
aliases: Array<String>,
|
||||
invoke: Invoke,
|
||||
callbackName: String
|
||||
) {
|
||||
if (aliases.isEmpty()) {
|
||||
Logger.error("No permission alias was provided")
|
||||
return
|
||||
}
|
||||
val permissions = getPermissionStringsForAliases(aliases)
|
||||
if (permissions.isNotEmpty()) {
|
||||
permissionActivityResult(invoke, permissions, callbackName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Android permission strings defined on the [TauriPlugin] annotation with
|
||||
* the provided aliases.
|
||||
*
|
||||
* @param aliases aliases for permissions defined on the plugin
|
||||
* @return Android permission strings associated with the provided aliases, if exists
|
||||
*/
|
||||
private fun getPermissionStringsForAliases(aliases: Array<String>): Array<String> {
|
||||
val annotation = handle?.annotation
|
||||
val perms: HashSet<String> = HashSet()
|
||||
if (annotation != null) {
|
||||
for (perm in annotation.permissions) {
|
||||
if (aliases.contains(perm.alias)) {
|
||||
perms.addAll(perm.strings)
|
||||
}
|
||||
}
|
||||
}
|
||||
return perms.toArray(arrayOfNulls(0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permission state for the provided permission alias.
|
||||
*
|
||||
* @param alias the permission alias to get
|
||||
* @return the state of the provided permission alias or null
|
||||
*/
|
||||
fun getPermissionState(alias: String): PermissionState? {
|
||||
return getPermissionStates()[alias]
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check all permissions defined on a plugin and see the state of each.
|
||||
*
|
||||
* @return A mapping of permission aliases to the associated granted status.
|
||||
*/
|
||||
open fun getPermissionStates(): Map<String, PermissionState> {
|
||||
val permissionsResults: MutableMap<String, PermissionState> = HashMap()
|
||||
val annotation = handle?.annotation
|
||||
if (annotation != null) {
|
||||
for (perm in annotation.permissions) {
|
||||
// If a permission is defined with no permission constants, return GRANTED for it.
|
||||
// Otherwise, get its true state.
|
||||
if (perm.strings.isEmpty() || perm.strings.size == 1 && perm.strings[0]
|
||||
.isEmpty()
|
||||
) {
|
||||
val key = perm.alias
|
||||
if (key.isNotEmpty()) {
|
||||
val existingResult = permissionsResults[key]
|
||||
|
||||
// auto set permission state to GRANTED if the alias is empty.
|
||||
if (existingResult == null) {
|
||||
permissionsResults[key] = PermissionState.GRANTED
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (permString in perm.strings) {
|
||||
val key = perm.alias.ifEmpty { permString }
|
||||
var permissionStatus: PermissionState
|
||||
if (ActivityCompat.checkSelfPermission(
|
||||
activity,
|
||||
permString
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionStatus = PermissionState.GRANTED
|
||||
} else {
|
||||
permissionStatus = PermissionState.PROMPT
|
||||
|
||||
// Check if there is a cached permission state for the "Never ask again" state
|
||||
val prefs =
|
||||
activity.getSharedPreferences("PluginPermStates", Activity.MODE_PRIVATE)
|
||||
val state = prefs.getString(permString, null)
|
||||
if (state != null) {
|
||||
permissionStatus = PermissionState.byState(state)
|
||||
}
|
||||
}
|
||||
val existingResult = permissionsResults[key]
|
||||
|
||||
// multiple permissions with the same alias must all be true, otherwise all false.
|
||||
if (existingResult == null || existingResult === PermissionState.GRANTED) {
|
||||
permissionsResults[key] = permissionStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissionsResults
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.core.app.ActivityCompat
|
||||
import app.tauri.PermissionHelper
|
||||
import app.tauri.PermissionState
|
||||
import app.tauri.annotation.ActivityCallback
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.PermissionCallback
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import java.lang.reflect.Method
|
||||
|
||||
class PluginHandle(private val manager: PluginManager, val name: String, val instance: Plugin, val config: String, val jsonMapper: ObjectMapper) {
|
||||
private val commands: HashMap<String, CommandData> = HashMap()
|
||||
private val permissionCallbackMethods: HashMap<String, Method> = HashMap()
|
||||
private val startActivityCallbackMethods: HashMap<String, Method> = HashMap()
|
||||
var annotation: TauriPlugin?
|
||||
var loaded = false
|
||||
|
||||
init {
|
||||
indexMethods()
|
||||
instance.handle = this
|
||||
annotation = instance.javaClass.getAnnotation(TauriPlugin::class.java)
|
||||
}
|
||||
|
||||
fun load(webView: WebView) {
|
||||
instance.load(webView)
|
||||
loaded = true
|
||||
}
|
||||
|
||||
fun startActivityForResult(invoke: Invoke, intent: Intent, callbackName: String) {
|
||||
manager.startActivityForResult(intent) { result ->
|
||||
val method = startActivityCallbackMethods[callbackName]
|
||||
if (method != null) {
|
||||
method.isAccessible = true
|
||||
method(instance, invoke, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startIntentSenderForResult(invoke: Invoke, intentSender: IntentSenderRequest, callbackName: String) {
|
||||
manager.startIntentSenderForResult(intentSender) { result ->
|
||||
val method = startActivityCallbackMethods[callbackName]
|
||||
if (method != null) {
|
||||
method.isAccessible = true
|
||||
method(instance, invoke, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPermissions(
|
||||
invoke: Invoke,
|
||||
permissions: Array<String>,
|
||||
callbackName: String
|
||||
) {
|
||||
manager.requestPermissions(permissions) { result ->
|
||||
if (validatePermissions(invoke, result)) {
|
||||
val method = permissionCallbackMethods[callbackName]
|
||||
if (method != null) {
|
||||
method.isAccessible = true
|
||||
method(instance, invoke)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves permission states and rejects if permissions were not correctly defined in
|
||||
* the AndroidManifest.xml file.
|
||||
*
|
||||
* @param permissions
|
||||
* @return true if permissions were saved and defined correctly, false if not
|
||||
*/
|
||||
private fun validatePermissions(
|
||||
invoke: Invoke,
|
||||
permissions: Map<String, Boolean>
|
||||
): Boolean {
|
||||
val activity = manager.activity
|
||||
val prefs =
|
||||
activity.getSharedPreferences("PluginPermStates", Activity.MODE_PRIVATE)
|
||||
for ((permString, isGranted) in permissions) {
|
||||
if (isGranted) {
|
||||
// Permission granted. If previously denied, remove cached state
|
||||
val state = prefs.getString(permString, null)
|
||||
if (state != null) {
|
||||
val editor: SharedPreferences.Editor = prefs.edit()
|
||||
editor.remove(permString)
|
||||
editor.apply()
|
||||
}
|
||||
} else {
|
||||
val editor: SharedPreferences.Editor = prefs.edit()
|
||||
if (ActivityCompat.shouldShowRequestPermissionRationale(
|
||||
activity,
|
||||
permString
|
||||
)
|
||||
) {
|
||||
// Permission denied, can prompt again with rationale
|
||||
editor.putString(permString, PermissionState.PROMPT_WITH_RATIONALE.toString())
|
||||
} else {
|
||||
// Permission denied permanently, store this state for future reference
|
||||
editor.putString(permString, PermissionState.DENIED.toString())
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
}
|
||||
val permStrings = permissions.keys.toTypedArray()
|
||||
if (!PermissionHelper.hasDefinedPermissions(activity, permStrings)) {
|
||||
val builder = StringBuilder()
|
||||
builder.append("Missing the following permissions in AndroidManifest.xml:\n")
|
||||
val missing = PermissionHelper.getUndefinedPermissions(activity, permStrings)
|
||||
for (perm in missing) {
|
||||
builder.append(
|
||||
"""
|
||||
$perm
|
||||
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
invoke.reject(builder.toString())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Throws(
|
||||
InvalidCommandException::class,
|
||||
IllegalAccessException::class
|
||||
)
|
||||
fun invoke(invoke: Invoke) {
|
||||
val methodMeta = commands[invoke.command]
|
||||
?: throw InvalidCommandException("No command " + invoke.command + " found for plugin " + instance.javaClass.name)
|
||||
methodMeta.method.invoke(instance, invoke)
|
||||
}
|
||||
|
||||
private fun indexMethods() {
|
||||
val methods = mutableListOf<Method>()
|
||||
var pluginCursor: Class<*> = instance.javaClass
|
||||
while (pluginCursor.name != Any::class.java.name) {
|
||||
methods.addAll(listOf(*pluginCursor.declaredMethods))
|
||||
pluginCursor = pluginCursor.superclass
|
||||
}
|
||||
|
||||
for (method in methods) {
|
||||
if (method.isAnnotationPresent(Command::class.java)) {
|
||||
val command = method.getAnnotation(Command::class.java) ?: continue
|
||||
val methodMeta = CommandData(method, command)
|
||||
commands[method.name] = methodMeta
|
||||
}
|
||||
|
||||
if (method.isAnnotationPresent(ActivityCallback::class.java)) {
|
||||
startActivityCallbackMethods[method.name] = method
|
||||
}
|
||||
|
||||
if (method.isAnnotationPresent(PermissionCallback::class.java)) {
|
||||
permissionCallbackMethods[method.name] = method
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.res.Configuration
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.FsUtils
|
||||
import app.tauri.JniMethod
|
||||
import app.tauri.Logger
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
|
||||
class PluginManager(val activity: AppCompatActivity) {
|
||||
fun interface RequestPermissionsCallback {
|
||||
fun onResult(permissions: Map<String, Boolean>)
|
||||
}
|
||||
|
||||
fun interface ActivityResultCallback {
|
||||
fun onResult(result: ActivityResult)
|
||||
}
|
||||
|
||||
private val plugins: HashMap<String, PluginHandle> = HashMap()
|
||||
private val startActivityForResultLauncher: ActivityResultLauncher<Intent>
|
||||
private val startIntentSenderForResultLauncher: ActivityResultLauncher<IntentSenderRequest>
|
||||
private val requestPermissionsLauncher: ActivityResultLauncher<Array<String>>
|
||||
private var requestPermissionsCallback: RequestPermissionsCallback? = null
|
||||
private var startActivityForResultCallback: ActivityResultCallback? = null
|
||||
private var startIntentSenderForResultCallback: ActivityResultCallback? = null
|
||||
private var jsonMapper: ObjectMapper
|
||||
|
||||
init {
|
||||
startActivityForResultLauncher =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (startActivityForResultCallback != null) {
|
||||
startActivityForResultCallback!!.onResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
startIntentSenderForResultLauncher =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()
|
||||
) { result ->
|
||||
if (startIntentSenderForResultCallback != null) {
|
||||
startIntentSenderForResultCallback!!.onResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
requestPermissionsLauncher =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { result ->
|
||||
if (requestPermissionsCallback != null) {
|
||||
requestPermissionsCallback!!.onResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
jsonMapper = ObjectMapper()
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
|
||||
.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
|
||||
|
||||
val channelDeserializer = ChannelDeserializer({ channelId, payload ->
|
||||
sendChannelData(channelId, payload)
|
||||
}, jsonMapper)
|
||||
jsonMapper
|
||||
.registerModule(SimpleModule().addDeserializer(Channel::class.java, channelDeserializer))
|
||||
}
|
||||
|
||||
fun onNewIntent(intent: Intent) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onNewIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPause() {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onPause()
|
||||
}
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onResume()
|
||||
}
|
||||
}
|
||||
|
||||
fun onRestart() {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onRestart()
|
||||
}
|
||||
}
|
||||
|
||||
fun onStop() {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onStop()
|
||||
}
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun onConfigurationChanged(newConfig: Configuration) {
|
||||
for (plugin in plugins.values) {
|
||||
plugin.instance.onConfigurationChanged(newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
fun startActivityForResult(intent: Intent, callback: ActivityResultCallback) {
|
||||
startActivityForResultCallback = callback
|
||||
startActivityForResultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
fun startIntentSenderForResult(intent: IntentSenderRequest, callback: ActivityResultCallback) {
|
||||
startIntentSenderForResultCallback = callback
|
||||
startIntentSenderForResultLauncher.launch(intent)
|
||||
}
|
||||
|
||||
fun requestPermissions(
|
||||
permissionStrings: Array<String>,
|
||||
callback: RequestPermissionsCallback
|
||||
) {
|
||||
requestPermissionsCallback = callback
|
||||
requestPermissionsLauncher.launch(permissionStrings)
|
||||
}
|
||||
|
||||
@JniMethod
|
||||
fun onWebViewCreated(webView: WebView) {
|
||||
for ((_, plugin) in plugins) {
|
||||
if (!plugin.loaded) {
|
||||
plugin.load(webView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JniMethod
|
||||
fun load(webView: WebView?, name: String, plugin: Plugin, config: String) {
|
||||
val handle = PluginHandle(this, name, plugin, config, jsonMapper)
|
||||
plugins[name] = handle
|
||||
if (webView != null) {
|
||||
plugin.load(webView)
|
||||
}
|
||||
}
|
||||
|
||||
@JniMethod
|
||||
fun runCommand(id: Int, pluginId: String, command: String, data: String) {
|
||||
val successId = 0L
|
||||
val errorId = 1L
|
||||
val invoke = Invoke(id.toLong(), command, successId, errorId, { fn, result ->
|
||||
var success: String? = null
|
||||
var error: String? = null
|
||||
if (fn == successId) {
|
||||
success = result
|
||||
} else {
|
||||
error = result
|
||||
}
|
||||
handlePluginResponse(id, success, error)
|
||||
}, data, jsonMapper)
|
||||
|
||||
dispatchPluginMessage(invoke, pluginId)
|
||||
}
|
||||
|
||||
private fun dispatchPluginMessage(invoke: Invoke, pluginId: String) {
|
||||
Logger.verbose(
|
||||
Logger.tags("Plugin"),
|
||||
"Tauri plugin: pluginId: $pluginId, command: ${invoke.command}"
|
||||
)
|
||||
|
||||
try {
|
||||
val plugin = plugins[pluginId]
|
||||
if (plugin == null) {
|
||||
invoke.reject("Plugin $pluginId not initialized")
|
||||
} else {
|
||||
plugins[pluginId]?.invoke(invoke)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
var exception: Throwable = e
|
||||
if (exception.message?.isEmpty() != false) {
|
||||
if (e is InvocationTargetException) {
|
||||
exception = e.targetException
|
||||
}
|
||||
}
|
||||
invoke.reject(if (exception.message?.isEmpty() != false) { exception.toString() } else { exception.message })
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun<T> loadConfig(context: Context, plugin: String, cls: Class<T>): T {
|
||||
val tauriConfigJson = FsUtils.readAsset(context.assets, "tauri.conf.json")
|
||||
val mapper = ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
val config = mapper.readValue(tauriConfigJson, Config::class.java)
|
||||
return mapper.readValue(config.plugins[plugin].toString(), cls)
|
||||
}
|
||||
}
|
||||
|
||||
private external fun handlePluginResponse(id: Int, success: String?, error: String?)
|
||||
private external fun sendChannelData(id: Long, data: String)
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
internal class Config {
|
||||
lateinit var plugins: Map<String, JsonNode>
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import app.tauri.annotation.Command
|
||||
import java.lang.reflect.Method
|
||||
|
||||
class CommandData(
|
||||
val method: Method, methodDecorator: Command
|
||||
) {
|
||||
|
||||
// The name of the method
|
||||
val name: String = method.name
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri.plugin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import app.tauri.Logger
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class PluginResult @JvmOverloads constructor(json: JSObject? = JSObject()) {
|
||||
private val json: JSObject
|
||||
|
||||
init {
|
||||
this.json = json ?: JSObject()
|
||||
}
|
||||
|
||||
fun put(name: String, value: Boolean): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
fun put(name: String, value: Double): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
fun put(name: String, value: Int): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
fun put(name: String, value: Long): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as an ISO string
|
||||
*/
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
fun put(name: String, value: Date): PluginResult {
|
||||
val tz: TimeZone = TimeZone.getTimeZone("UTC")
|
||||
val df: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'")
|
||||
df.timeZone = tz
|
||||
return jsonPut(name, df.format(value))
|
||||
}
|
||||
|
||||
fun put(name: String, value: Any?): PluginResult {
|
||||
return jsonPut(name, value)
|
||||
}
|
||||
|
||||
fun put(name: String, value: PluginResult): PluginResult {
|
||||
return jsonPut(name, value.json)
|
||||
}
|
||||
|
||||
private fun jsonPut(name: String, value: Any?): PluginResult {
|
||||
try {
|
||||
json.put(name, value)
|
||||
} catch (ex: Exception) {
|
||||
Logger.error(Logger.tags("Plugin"), "", ex)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return json.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package app.tauri
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
33
apps/tauri/tauri-plugin-credentials/android/build.gradle.kts
Normal file
33
apps/tauri/tauri-plugin-credentials/android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.tauri.credentials"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.security:security-crypto:1.0.0")
|
||||
implementation(project(":tauri-android"))
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package app.tauri.credentials
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
|
||||
@InvokeArg
|
||||
class StoreArgs {
|
||||
lateinit var domain: String
|
||||
lateinit var username: String
|
||||
lateinit var password: String
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
class DomainArgs {
|
||||
lateinit var domain: String
|
||||
}
|
||||
|
||||
/// Credential storage plugin using Android EncryptedSharedPreferences (backed by Android Keystore).
|
||||
@TauriPlugin
|
||||
class CredentialPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
|
||||
private fun getPrefs(): SharedPreferences {
|
||||
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
||||
return EncryptedSharedPreferences.create(
|
||||
"onyx_credentials",
|
||||
masterKeyAlias,
|
||||
activity,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun store(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(StoreArgs::class.java)
|
||||
try {
|
||||
getPrefs().edit()
|
||||
.putString("${args.domain}::username", args.username)
|
||||
.putString("${args.domain}::${args.username}::password", args.password)
|
||||
.apply()
|
||||
invoke.resolve()
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Failed to store credentials: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun load(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(DomainArgs::class.java)
|
||||
try {
|
||||
val prefs = getPrefs()
|
||||
val username = prefs.getString("${args.domain}::username", null)
|
||||
if (username == null) {
|
||||
invoke.reject("No credentials found for '${args.domain}'. Run setup or configure environment variables.")
|
||||
return
|
||||
}
|
||||
val password = prefs.getString("${args.domain}::${username}::password", null)
|
||||
if (password == null) {
|
||||
invoke.reject("No password found for '${args.domain}' user '$username'")
|
||||
return
|
||||
}
|
||||
val result = JSObject()
|
||||
result.put("username", username)
|
||||
result.put("password", password)
|
||||
invoke.resolve(result)
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Failed to load credentials: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun delete(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(DomainArgs::class.java)
|
||||
try {
|
||||
val prefs = getPrefs()
|
||||
val username = prefs.getString("${args.domain}::username", null)
|
||||
val editor = prefs.edit().remove("${args.domain}::username")
|
||||
if (username != null) {
|
||||
editor.remove("${args.domain}::${username}::password")
|
||||
}
|
||||
editor.apply()
|
||||
invoke.resolve()
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Failed to delete credentials: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/tauri/tauri-plugin-credentials/build.rs
Normal file
7
apps/tauri/tauri-plugin-credentials/build.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const COMMANDS: &[&str] = &["store", "load", "delete"];
|
||||
|
||||
fn main() {
|
||||
tauri_plugin::Builder::new(COMMANDS)
|
||||
.android_path("android")
|
||||
.build();
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-delete"
|
||||
description = "Enables the delete command without any pre-configured scope."
|
||||
commands.allow = ["delete"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-delete"
|
||||
description = "Denies the delete command without any pre-configured scope."
|
||||
commands.deny = ["delete"]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-load"
|
||||
description = "Enables the load command without any pre-configured scope."
|
||||
commands.allow = ["load"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-load"
|
||||
description = "Denies the load command without any pre-configured scope."
|
||||
commands.deny = ["load"]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
"$schema" = "../../schemas/schema.json"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-store"
|
||||
description = "Enables the store command without any pre-configured scope."
|
||||
commands.allow = ["store"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-store"
|
||||
description = "Denies the store command without any pre-configured scope."
|
||||
commands.deny = ["store"]
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
## Permission Table
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Identifier</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`credentials:allow-delete`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the delete command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`credentials:deny-delete`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the delete command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`credentials:allow-load`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the load command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`credentials:deny-load`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the load command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`credentials:allow-store`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the store command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`credentials:deny-store`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the store command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PermissionFile",
|
||||
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"default": {
|
||||
"description": "The default permission set for the plugin",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DefaultPermission"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"set": {
|
||||
"description": "A list of permissions sets defined",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionSet"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"description": "A list of inlined permissions",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Permission"
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"DefaultPermission": {
|
||||
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PermissionSet": {
|
||||
"description": "A set of direct permissions grouped together under a new name.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"description",
|
||||
"identifier",
|
||||
"permissions"
|
||||
],
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does.",
|
||||
"type": "string"
|
||||
},
|
||||
"permissions": {
|
||||
"description": "All permissions this set contains.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/PermissionKind"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Permission": {
|
||||
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"description": "The version of the permission.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"identifier": {
|
||||
"description": "A unique identifier for the permission.",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"commands": {
|
||||
"description": "Allowed or denied commands when using this permission.",
|
||||
"default": {
|
||||
"allow": [],
|
||||
"deny": []
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Commands"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": {
|
||||
"description": "Allowed or denied scoped when using this permission.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Scopes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"platforms": {
|
||||
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Target"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Commands": {
|
||||
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Allowed command.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Denied command, which takes priority.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scopes": {
|
||||
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allow": {
|
||||
"description": "Data that defines what is allowed by the scope.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
"deny": {
|
||||
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Value": {
|
||||
"description": "All supported ACL values.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents a null JSON value.",
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`bool`].",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"description": "Represents a valid ACL [`Number`].",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`String`].",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Represents a list of other [`Value`]s.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/Value"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Number": {
|
||||
"description": "A valid ACL number.",
|
||||
"anyOf": [
|
||||
{
|
||||
"description": "Represents an [`i64`].",
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
{
|
||||
"description": "Represents a [`f64`].",
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Target": {
|
||||
"description": "Platform target.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "MacOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"macOS"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Windows.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Linux.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Android.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "iOS.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iOS"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"PermissionKind": {
|
||||
"type": "string",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Enables the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-delete",
|
||||
"markdownDescription": "Enables the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-delete",
|
||||
"markdownDescription": "Denies the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-load",
|
||||
"markdownDescription": "Enables the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-load",
|
||||
"markdownDescription": "Denies the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-store",
|
||||
"markdownDescription": "Enables the store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-store",
|
||||
"markdownDescription": "Denies the store command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
211
apps/tauri/tauri-plugin-credentials/src/lib.rs
Normal file
211
apps/tauri/tauri-plugin-credentials/src/lib.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
};
|
||||
#[cfg(target_os = "android")]
|
||||
use tauri::plugin::PluginHandle;
|
||||
|
||||
const PLUGIN_IDENTIFIER: &str = "app.tauri.credentials";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StoreArgs {
|
||||
domain: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct DomainArgs {
|
||||
domain: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct LoadResult {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// Credential storage handle. Desktop uses the system keychain; Android uses EncryptedSharedPreferences.
|
||||
pub struct Credentials<R: Runtime> {
|
||||
#[cfg(target_os = "android")]
|
||||
_handle: PluginHandle<R>,
|
||||
#[cfg(not(target_os = "android"))]
|
||||
_phantom: std::marker::PhantomData<fn() -> R>,
|
||||
}
|
||||
|
||||
impl<R: Runtime> Credentials<R> {
|
||||
pub fn store(&self, domain: &str, username: &str, password: &str) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
self._handle
|
||||
.run_mobile_plugin::<()>(
|
||||
"store",
|
||||
StoreArgs {
|
||||
domain: domain.to_string(),
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
desktop_store(domain, username, password)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&self, domain: &str) -> Result<(String, String), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let result: LoadResult = self
|
||||
._handle
|
||||
.run_mobile_plugin("load", DomainArgs { domain: domain.to_string() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((result.username, result.password))
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
desktop_load(domain)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&self, domain: &str) -> Result<(), String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
self._handle
|
||||
.run_mobile_plugin::<()>("delete", DomainArgs { domain: domain.to_string() })
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
desktop_delete(domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desktop keyring implementation ──────────────────────────────────
|
||||
|
||||
#[cfg(all(not(target_os = "android"), feature = "desktop"))]
|
||||
fn desktop_store(domain: &str, username: &str, password: &str) -> Result<(), String> {
|
||||
let service = format!("com.onyx.webdav.{}", domain);
|
||||
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, username);
|
||||
|
||||
keyring::Entry::new(&service, "username")
|
||||
.map_err(|e| format!("Failed to create keyring entry: {}", e))?
|
||||
.set_password(username)
|
||||
.map_err(|e| format!("Failed to store username: {}", e))?;
|
||||
|
||||
keyring::Entry::new(&scoped_service, "password")
|
||||
.map_err(|e| format!("Failed to create keyring entry: {}", e))?
|
||||
.set_password(password)
|
||||
.map_err(|e| format!("Failed to store password: {}", e))?;
|
||||
|
||||
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
|
||||
let _ = legacy.delete_credential();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "android"), not(feature = "desktop")))]
|
||||
fn desktop_store(_domain: &str, _username: &str, _password: &str) -> Result<(), String> {
|
||||
Err("Credential storage not available on this platform".into())
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "android"), feature = "desktop"))]
|
||||
fn desktop_load(domain: &str) -> Result<(String, String), String> {
|
||||
let service = format!("com.onyx.webdav.{}", domain);
|
||||
|
||||
let username = keyring::Entry::new(&service, "username")
|
||||
.map_err(|e| format!("Failed to create keyring entry: {}", e))?
|
||||
.get_password()
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"No credentials found for '{}'. Run setup or configure environment variables.",
|
||||
domain
|
||||
)
|
||||
})?;
|
||||
|
||||
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, username);
|
||||
let password = keyring::Entry::new(&scoped_service, "password")
|
||||
.ok()
|
||||
.and_then(|e| e.get_password().ok())
|
||||
.or_else(|| {
|
||||
keyring::Entry::new(&service, "password")
|
||||
.ok()
|
||||
.and_then(|e| e.get_password().ok())
|
||||
})
|
||||
.ok_or_else(|| format!("No password found for '{}' user '{}'", domain, username))?;
|
||||
|
||||
// Auto-migrate legacy credentials to scoped format
|
||||
if keyring::Entry::new(&scoped_service, "password")
|
||||
.ok()
|
||||
.and_then(|e| e.get_password().ok())
|
||||
.is_none()
|
||||
{
|
||||
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
|
||||
let _ = entry.set_password(&password);
|
||||
}
|
||||
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
|
||||
let _ = legacy.delete_credential();
|
||||
}
|
||||
}
|
||||
|
||||
Ok((username, password))
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "android"), not(feature = "desktop")))]
|
||||
fn desktop_load(domain: &str) -> Result<(String, String), String> {
|
||||
Err(format!(
|
||||
"No credentials found for '{}'. Credential storage not available on this platform.",
|
||||
domain
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "android"), feature = "desktop"))]
|
||||
fn desktop_delete(domain: &str) -> Result<(), String> {
|
||||
let service = format!("com.onyx.webdav.{}", domain);
|
||||
let username = keyring::Entry::new(&service, "username")
|
||||
.ok()
|
||||
.and_then(|e| e.get_password().ok());
|
||||
|
||||
if let Some(user) = &username {
|
||||
let scoped = format!("com.onyx.webdav.{}::{}", domain, user);
|
||||
if let Ok(e) = keyring::Entry::new(&scoped, "password") {
|
||||
let _ = e.delete_credential();
|
||||
}
|
||||
}
|
||||
if let Ok(e) = keyring::Entry::new(&service, "password") {
|
||||
let _ = e.delete_credential();
|
||||
}
|
||||
if let Ok(e) = keyring::Entry::new(&service, "username") {
|
||||
let _ = e.delete_credential();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_os = "android"), not(feature = "desktop")))]
|
||||
fn desktop_delete(_domain: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Plugin init ─────────────────────────────────────────────────────
|
||||
|
||||
/// Initialize the credentials plugin. Call `.plugin(tauri_plugin_credentials::init())` on the Tauri builder.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("credentials")
|
||||
.setup(|app, api| {
|
||||
#[cfg(target_os = "android")]
|
||||
let credentials = Credentials {
|
||||
_handle: api.register_android_plugin(PLUGIN_IDENTIFIER, "CredentialPlugin")?,
|
||||
};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let credentials: Credentials<R> = Credentials {
|
||||
_phantom: std::marker::PhantomData,
|
||||
};
|
||||
let _ = api;
|
||||
app.manage(credentials);
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
Loading…
Reference in a new issue