From 198abe3c2cae06dacab860b3a93f715dcf529a95 Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Mon, 23 Oct 2023 14:09:42 -0300 Subject: [PATCH] feat(plugins): typed invoke arguments for mobile plugins (#8076) --- .changes/android-plugin-get-config-typed.md | 5 + .changes/mobile-plugin-resolve-object.md | 5 + .changes/mobile-plugin-typed-invoke-args.md | 5 + .changes/update-mobile-template.md | 6 + core/tauri/Cargo.toml | 2 +- core/tauri/mobile/android/build.gradle.kts | 1 + core/tauri/mobile/android/proguard-rules.pro | 4 + .../main/java/app/tauri/PermissionHelper.kt | 12 +- .../java/app/tauri/annotation/InvokeArg.kt | 9 + .../src/main/java/app/tauri/plugin/Channel.kt | 28 +- .../src/main/java/app/tauri/plugin/Invoke.kt | 182 ++----------- .../src/main/java/app/tauri/plugin/Plugin.kt | 142 +++++----- .../java/app/tauri/plugin/PluginHandle.kt | 5 +- .../java/app/tauri/plugin/PluginManager.kt | 55 +++- .../ios-api/Sources/Tauri/Channel.swift | 62 ++++- .../mobile/ios-api/Sources/Tauri/Invoke.swift | 92 ++++--- .../ios-api/Sources/Tauri/JSTypes.swift | 242 +----------------- .../ios-api/Sources/Tauri/Plugin/Plugin.swift | 58 +++-- .../mobile/ios-api/Sources/Tauri/Tauri.swift | 34 +-- core/tauri/src/ios.rs | 136 +--------- core/tauri/src/jni_helpers.rs | 98 ------- core/tauri/src/lib.rs | 2 - core/tauri/src/plugin/mobile.rs | 26 +- core/tauri/src/protocol/tauri.rs | 2 +- examples/api/src-tauri/Cargo.lock | 94 ------- examples/api/src-tauri/Cargo.toml | 1 - examples/api/src-tauri/src/lib.rs | 5 - .../java/com/plugin/sample/ExamplePlugin.kt | 16 +- .../ios/Sources/ExamplePlugin.swift | 13 +- .../android/src/main/java/ExamplePlugin.kt | 11 +- .../plugin/ios/Sources/ExamplePlugin.swift | 18 +- 31 files changed, 434 insertions(+), 937 deletions(-) create mode 100644 .changes/android-plugin-get-config-typed.md create mode 100644 .changes/mobile-plugin-resolve-object.md create mode 100644 .changes/mobile-plugin-typed-invoke-args.md create mode 100644 .changes/update-mobile-template.md create mode 100644 core/tauri/mobile/android/src/main/java/app/tauri/annotation/InvokeArg.kt delete mode 100644 core/tauri/src/jni_helpers.rs diff --git a/.changes/android-plugin-get-config-typed.md b/.changes/android-plugin-get-config-typed.md new file mode 100644 index 000000000..53be05bb1 --- /dev/null +++ b/.changes/android-plugin-get-config-typed.md @@ -0,0 +1,5 @@ +--- +"tauri": patch:breaking +--- + +The Android `PluginManager.loadConfig` now takes a third parameter to define the class type of the config object. diff --git a/.changes/mobile-plugin-resolve-object.md b/.changes/mobile-plugin-resolve-object.md new file mode 100644 index 000000000..4819e4d69 --- /dev/null +++ b/.changes/mobile-plugin-resolve-object.md @@ -0,0 +1,5 @@ +--- +"tauri": patch:enhance +--- + +Mobile plugins can now resolve using an arbitrary object instead of using the `JSObject` class via `Invoke.resolve` on iOS and `Invoke.resolveObject` on Android. diff --git a/.changes/mobile-plugin-typed-invoke-args.md b/.changes/mobile-plugin-typed-invoke-args.md new file mode 100644 index 000000000..d98d78794 --- /dev/null +++ b/.changes/mobile-plugin-typed-invoke-args.md @@ -0,0 +1,5 @@ +--- +"tauri": patch:breaking +--- + +Mobile plugins now have access to a parser for the invoke arguments instead of relying on the `Invoke#get${TYPE}` methods. diff --git a/.changes/update-mobile-template.md b/.changes/update-mobile-template.md new file mode 100644 index 000000000..cf2197968 --- /dev/null +++ b/.changes/update-mobile-template.md @@ -0,0 +1,6 @@ +--- +"tauri-cli": patch:breaking +"@tauri-apps/cli": patch:breaking +--- + +Updated the mobile plugin templates following the tauri v2.0.0-alpha.17 changes. diff --git a/core/tauri/Cargo.toml b/core/tauri/Cargo.toml index 7387e8d07..6bee84a33 100644 --- a/core/tauri/Cargo.toml +++ b/core/tauri/Cargo.toml @@ -42,7 +42,7 @@ development = [ "quickcheck_macros" ] [dependencies] serde_json = { version = "1.0", features = [ "raw_value" ] } -serde = { version = "1.0", features = [ "derive" ] } +serde = { version = "1.0", features = [ "derive", "rc" ] } tokio = { version = "1", features = [ "rt", "rt-multi-thread", "sync", "fs", "io-util" ] } futures-util = "0.3" uuid = { version = "1", features = [ "v4" ], optional = true } diff --git a/core/tauri/mobile/android/build.gradle.kts b/core/tauri/mobile/android/build.gradle.kts index d23ad0fb1..36cc3f1a0 100644 --- a/core/tauri/mobile/android/build.gradle.kts +++ b/core/tauri/mobile/android/build.gradle.kts @@ -38,6 +38,7 @@ 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") diff --git a/core/tauri/mobile/android/proguard-rules.pro b/core/tauri/mobile/android/proguard-rules.pro index fed38c703..a461a5556 100644 --- a/core/tauri/mobile/android/proguard-rules.pro +++ b/core/tauri/mobile/android/proguard-rules.pro @@ -23,3 +23,7 @@ @app.tauri.annotation.Permission ; public (...); } + +-keep @app.tauri.annotation.InvokeArg public class * { + *; +} diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/PermissionHelper.kt b/core/tauri/mobile/android/src/main/java/app/tauri/PermissionHelper.kt index e84ae9b31..7d560fbac 100644 --- a/core/tauri/mobile/android/src/main/java/app/tauri/PermissionHelper.kt +++ b/core/tauri/mobile/android/src/main/java/app/tauri/PermissionHelper.kt @@ -95,10 +95,10 @@ object PermissionHelper { * @param neededPermissions The permissions needed. * @return The permissions not present in AndroidManifest.xml */ - fun getUndefinedPermissions(context: Context, neededPermissions: Array): Array { - val undefinedPermissions = ArrayList() + fun getUndefinedPermissions(context: Context, neededPermissions: Array): Array { + val undefinedPermissions = ArrayList() val requestedPermissions = getManifestPermissions(context) - if (requestedPermissions != null && requestedPermissions.isNotEmpty()) { + if (!requestedPermissions.isNullOrEmpty()) { val requestedPermissionsList = listOf(*requestedPermissions) val requestedPermissionsArrayList = ArrayList(requestedPermissionsList) for (permission in neededPermissions) { @@ -106,10 +106,8 @@ object PermissionHelper { undefinedPermissions.add(permission) } } - var undefinedPermissionArray = arrayOfNulls(undefinedPermissions.size) - undefinedPermissionArray = undefinedPermissions.toArray(undefinedPermissionArray) - return undefinedPermissionArray + return undefinedPermissions.toTypedArray() } - return neededPermissions as Array + return neededPermissions } } diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/annotation/InvokeArg.kt b/core/tauri/mobile/android/src/main/java/app/tauri/annotation/InvokeArg.kt new file mode 100644 index 000000000..ffef326d0 --- /dev/null +++ b/core/tauri/mobile/android/src/main/java/app/tauri/annotation/InvokeArg.kt @@ -0,0 +1,9 @@ +// Copyright 2019-2023 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 diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Channel.kt b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Channel.kt index eb7afa51a..34eba4ca5 100644 --- a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Channel.kt +++ b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Channel.kt @@ -4,8 +4,30 @@ package app.tauri.plugin -class Channel(val id: Long, private val handler: (data: JSObject) -> Unit) { - fun send(data: JSObject) { - handler(data) +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() { + 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)) } } diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Invoke.kt b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Invoke.kt index 3f0ea1214..4ff17d535 100644 --- a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Invoke.kt +++ b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Invoke.kt @@ -5,42 +5,54 @@ package app.tauri.plugin import app.tauri.Logger - -const val CHANNEL_PREFIX = "__CHANNEL__:" +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: PluginResult?) -> Unit, - private val sendChannelData: (channelId: Long, data: PluginResult) -> Unit, - val data: JSObject) { + private val sendResponse: (callback: Long, data: String) -> Unit, + private val argsJson: String, + private val jsonMapper: ObjectMapper +) { + fun parseArgs(cls: Class): T { + return jsonMapper.readValue(argsJson, cls) + } + + fun parseArgs(ref: TypeReference): T { + return jsonMapper.readValue(argsJson, ref) + } fun resolve(data: JSObject?) { - val result = PluginResult(data) - sendResponse(callback, result) + sendResponse(callback, PluginResult(data).toString()) + } + + fun resolveObject(data: Any) { + sendResponse(callback, jsonMapper.writeValueAsString(data)) } fun resolve() { - sendResponse(callback, null) + 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) } - try { - errorResult.put("message", msg) + + errorResult.put("message", msg) + if (code != null) { errorResult.put("code", code) - if (null != data) { - errorResult.put("data", data) - } - } catch (jsonEx: Exception) { - Logger.error(Logger.tags("Plugin"), jsonEx.message!!, jsonEx) } - sendResponse(error, errorResult) + if (data != null) { + errorResult.put("data", data) + } + + sendResponse(error, errorResult.toString()) } fun reject(msg: String?, ex: Exception?, data: JSObject?) { @@ -70,142 +82,4 @@ class Invoke( fun reject(msg: String?) { reject(msg, null, null, null) } - - fun getString(name: String): String? { - return getStringInternal(name, null) - } - - fun getString(name: String, defaultValue: String): String { - return getStringInternal(name, defaultValue)!! - } - - private fun getStringInternal(name: String, defaultValue: String?): String? { - val value = data.opt(name) ?: return defaultValue - return if (value is String) { - value - } else defaultValue - } - - fun getInt(name: String): Int? { - return getIntInternal(name, null) - } - - fun getInt(name: String, defaultValue: Int): Int { - return getIntInternal(name, defaultValue)!! - } - - private fun getIntInternal(name: String, defaultValue: Int?): Int? { - val value = data.opt(name) ?: return defaultValue - return if (value is Int) { - value - } else defaultValue - } - - fun getLong(name: String): Long? { - return getLongInternal(name, null) - } - - fun getLong(name: String, defaultValue: Long): Long { - return getLongInternal(name, defaultValue)!! - } - - private fun getLongInternal(name: String, defaultValue: Long?): Long? { - val value = data.opt(name) ?: return defaultValue - return if (value is Long) { - value - } else defaultValue - } - - fun getFloat(name: String): Float? { - return getFloatInternal(name, null) - } - - fun getFloat(name: String, defaultValue: Float): Float { - return getFloatInternal(name, defaultValue)!! - } - - private fun getFloatInternal(name: String, defaultValue: Float?): Float? { - val value = data.opt(name) ?: return defaultValue - if (value is Float) { - return value - } - if (value is Double) { - return value.toFloat() - } - return if (value is Int) { - value.toFloat() - } else defaultValue - } - - fun getDouble(name: String): Double? { - return getDoubleInternal(name, null) - } - - fun getDouble(name: String, defaultValue: Double): Double { - return getDoubleInternal(name, defaultValue)!! - } - - private fun getDoubleInternal(name: String, defaultValue: Double?): Double? { - val value = data.opt(name) ?: return defaultValue - if (value is Double) { - return value - } - if (value is Float) { - return value.toDouble() - } - return if (value is Int) { - value.toDouble() - } else defaultValue - } - - fun getBoolean(name: String): Boolean? { - return getBooleanInternal(name, null) - } - - fun getBoolean(name: String, defaultValue: Boolean): Boolean { - return getBooleanInternal(name, defaultValue)!! - } - - private fun getBooleanInternal(name: String, defaultValue: Boolean?): Boolean? { - val value = data.opt(name) ?: return defaultValue - return if (value is Boolean) { - value - } else defaultValue - } - - fun getObject(name: String): JSObject? { - return getObjectInternal(name, null) - } - - fun getObject(name: String, defaultValue: JSObject): JSObject { - return getObjectInternal(name, defaultValue)!! - } - - private fun getObjectInternal(name: String, defaultValue: JSObject?): JSObject? { - val value = data.opt(name) ?: return defaultValue - return if (value is JSObject) value else defaultValue - } - - fun getArray(name: String): JSArray? { - return getArrayInternal(name, null) - } - - fun getArray(name: String, defaultValue: JSArray): JSArray { - return getArrayInternal(name, defaultValue)!! - } - - private fun getArrayInternal(name: String, defaultValue: JSArray?): JSArray? { - val value = data.opt(name) ?: return defaultValue - return if (value is JSArray) value else defaultValue - } - - fun hasOption(name: String): Boolean { - return data.has(name) - } - - fun getChannel(name: String): Channel? { - val channelDef = getString(name, "") - val callback = channelDef.substring(CHANNEL_PREFIX.length).toLongOrNull() ?: return null - return Channel(callback) { res -> sendChannelData(callback, PluginResult(res)) } - } } diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Plugin.kt b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Plugin.kt index 5f1ee212f..aa5c573b6 100644 --- a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Plugin.kt +++ b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/Plugin.kt @@ -16,20 +16,42 @@ 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 org.json.JSONException 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? = null +} + abstract class Plugin(private val activity: Activity) { var handle: PluginHandle? = null private val listeners: MutableMap> = mutableMapOf() open fun load(webView: WebView) {} - fun getConfig(): JSObject { - return handle!!.config + fun jsonMapper(): ObjectMapper { + return handle!!.jsonMapper + } + + fun getConfig(cls: Class): T { + return jsonMapper().readValue(handle!!.config, cls) } /** @@ -88,20 +110,25 @@ abstract class Plugin(private val activity: Activity) { } } + fun triggerObject(event: String, payload: Any) { + val eventListeners = listeners[event] + if (!eventListeners.isNullOrEmpty()) { + val listeners = CopyOnWriteArrayList(eventListeners) + for (channel in listeners) { + channel.sendObject(payload) + } + } + } + @Command open fun registerListener(invoke: Invoke) { - val event = invoke.getString("event") - val channel = invoke.getChannel("handler") + val args = invoke.parseArgs(RegisterListenerArgs::class.java) - if (event == null || channel == null) { - invoke.reject("`event` or `handler` not provided") + val eventListeners = listeners[args.event] + if (eventListeners.isNullOrEmpty()) { + listeners[args.event] = mutableListOf(args.handler) } else { - val eventListeners = listeners[event] - if (eventListeners.isNullOrEmpty()) { - listeners[event] = mutableListOf(channel) - } else { - eventListeners.add(channel) - } + eventListeners.add(args.handler) } invoke.resolve() @@ -109,18 +136,13 @@ abstract class Plugin(private val activity: Activity) { @Command open fun removeListener(invoke: Invoke) { - val event = invoke.getString("event") - val channelId = invoke.getLong("channelId") + val args = invoke.parseArgs(RemoveListenerArgs::class.java) - if (event == null || channelId == null) { - invoke.reject("`event` or `channelId` not provided") - } else { - val eventListeners = listeners[event] - if (!eventListeners.isNullOrEmpty()) { - val c = eventListeners.find { c -> c.id == channelId } - if (c != null) { - eventListeners.remove(c) - } + val eventListeners = listeners[args.event] + if (!eventListeners.isNullOrEmpty()) { + val c = eventListeners.find { c -> c.id == args.channelId } + if (c != null) { + eventListeners.remove(c) } } @@ -165,26 +187,32 @@ abstract class Plugin(private val activity: Activity) { var permAliases: Array? = null val autoGrantPerms: MutableSet = HashSet() - // If call was made with a list of specific permission aliases to request, save them - // to be requested - val providedPerms: JSArray = invoke.getArray("permissions", JSArray()) - var providedPermsList: List? = null - try { - providedPermsList = providedPerms.toList() - } catch (ignore: JSONException) { - // do nothing - } + val args = invoke.parseArgs(RequestPermissionsArgs::class.java) + + args.permissions?.let { + val aliasSet: MutableSet = 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 = HashSet() - // If call was made without any custom permissions, request all from plugin annotation - val aliasSet: MutableSet = HashSet() - if (providedPermsList.isNullOrEmpty()) { 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.isEmpty()) { + if (perm.alias.isNotEmpty()) { autoGrantPerms.add(perm.alias) } } else { @@ -192,31 +220,23 @@ abstract class Plugin(private val activity: Activity) { } } permAliases = aliasSet.toTypedArray() - } else { - for (perm in annotation.permissions) { - if (providedPermsList.contains(perm.alias)) { - aliasSet.add(perm.alias) - } - } - if (aliasSet.isEmpty()) { - invoke.reject("No valid permission alias was requested of this plugin.") - } else { - permAliases = aliasSet.toTypedArray() - } } - if (!permAliases.isNullOrEmpty()) { + + permAliases?.let { // request permissions using provided aliases or all defined on the plugin - requestPermissionForAliases(permAliases, invoke, "checkPermissions") - } else 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()) + 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() } - invoke.resolve(permissionsResults) - } else { - // no permissions are defined on the plugin, resolve undefined - invoke.resolve() } } } @@ -288,15 +308,15 @@ abstract class Plugin(private val activity: Activity) { * [PermissionCallback] annotation. * * @param alias an alias defined on the plugin - * @param invoke the invoke involved in originating the request + * @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, - call: Invoke, + invoke: Invoke, callbackName: String ) { - requestPermissionForAliases(arrayOf(alias), call, callbackName) + requestPermissionForAliases(arrayOf(alias), invoke, callbackName) } /** diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginHandle.kt b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginHandle.kt index 08f4a0862..30d1d77c9 100644 --- a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginHandle.kt +++ b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginHandle.kt @@ -15,11 +15,10 @@ 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 -import java.util.Arrays - -class PluginHandle(private val manager: PluginManager, val name: String, val instance: Plugin, val config: JSObject) { +class PluginHandle(private val manager: PluginManager, val name: String, val instance: Plugin, val config: String, val jsonMapper: ObjectMapper) { private val commands: HashMap = HashMap() private val permissionCallbackMethods: HashMap = HashMap() private val startActivityCallbackMethods: HashMap = HashMap() diff --git a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginManager.kt b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginManager.kt index 62ddc8d19..74a21ed82 100644 --- a/core/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginManager.kt +++ b/core/tauri/mobile/android/src/main/java/app/tauri/plugin/PluginManager.kt @@ -11,9 +11,15 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher 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.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 { @@ -29,6 +35,7 @@ class PluginManager(val activity: AppCompatActivity) { private val requestPermissionsLauncher: ActivityResultLauncher> private var requestPermissionsCallback: RequestPermissionsCallback? = null private var startActivityForResultCallback: ActivityResultCallback? = null + private var jsonMapper: ObjectMapper init { startActivityForResultLauncher = @@ -46,6 +53,16 @@ class PluginManager(val activity: AppCompatActivity) { requestPermissionsCallback!!.onResult(result) } } + + jsonMapper = ObjectMapper() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + + val channelDeserializer = ChannelDeserializer({ channelId, payload -> + sendChannelData(channelId, payload) + }, jsonMapper) + jsonMapper + .registerModule(SimpleModule().addDeserializer(Channel::class.java, channelDeserializer)) } fun onNewIntent(intent: Intent) { @@ -77,8 +94,8 @@ class PluginManager(val activity: AppCompatActivity) { } @JniMethod - fun load(webView: WebView?, name: String, plugin: Plugin, config: JSObject) { - val handle = PluginHandle(this, name, plugin, config) + 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) @@ -86,21 +103,19 @@ class PluginManager(val activity: AppCompatActivity) { } @JniMethod - fun runCommand(id: Int, pluginId: String, command: String, data: JSObject) { + 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: PluginResult? = null - var error: PluginResult? = null + var success: String? = null + var error: String? = null if (fn == successId) { success = result } else { error = result } - handlePluginResponse(id, success?.toString(), error?.toString()) - }, { channelId, payload -> - sendChannelData(channelId, payload.toString()) - }, data) + handlePluginResponse(id, success, error) + }, data, jsonMapper) dispatchPluginMessage(invoke, pluginId) } @@ -119,19 +134,31 @@ class PluginManager(val activity: AppCompatActivity) { plugins[pluginId]?.invoke(invoke) } } catch (e: Exception) { - invoke.reject(if (e.message?.isEmpty() != false) { e.toString() } else { e.message }) + 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 loadConfig(context: Context, plugin: String): JSObject { + fun loadConfig(context: Context, plugin: String, cls: Class): T { val tauriConfigJson = FsUtils.readAsset(context.assets, "tauri.conf.json") - val tauriConfig = JSObject(tauriConfigJson) - val plugins = tauriConfig.getJSObject("plugins", JSObject()) - return plugins.getJSObject(plugin, JSObject()) + 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 +} diff --git a/core/tauri/mobile/ios-api/Sources/Tauri/Channel.swift b/core/tauri/mobile/ios-api/Sources/Tauri/Channel.swift index f3d03ba66..798104c7a 100644 --- a/core/tauri/mobile/ios-api/Sources/Tauri/Channel.swift +++ b/core/tauri/mobile/ios-api/Sources/Tauri/Channel.swift @@ -2,16 +2,64 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -public class Channel { - public let id: UInt64 - let handler: (JsonValue) -> Void +import Foundation - public init(id: UInt64, handler: @escaping (JsonValue) -> Void) { - self.id = id +let CHANNEL_PREFIX = "__CHANNEL__:" +let channelDataKey = CodingUserInfoKey(rawValue: "sendChannelData")! + +public class Channel: Decodable { + public let id: UInt64 + let handler: (String) -> Void + + public required init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let channelDef = try container.decode(String.self) + + let components = channelDef.components(separatedBy: CHANNEL_PREFIX) + if components.count < 2 { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid channel definition from \(channelDef)" + ) + + } + guard let channelId = UInt64(components[1]) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid channel ID from \(channelDef)" + ) + } + + guard let handler = decoder.userInfo[channelDataKey] as? (String) -> Void else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "missing userInfo for Channel handler. This is a Tauri issue" + ) + } + + self.id = channelId self.handler = handler } - public func send(_ data: JsonObject) { - handler(.dictionary(data)) + func serialize(_ data: JsonValue) -> String { + do { + return try data.jsonRepresentation() ?? "\"Failed to serialize payload\"" + } catch { + return "\"\(error)\"" + } } + + public func send(_ data: JsonObject) { + send(.dictionary(data)) + } + + public func send(_ data: JsonValue) { + handler(serialize(data)) + } + + public func send(_ data: T) throws { + let json = try JSONEncoder().encode(data) + handler(String(decoding: json, as: UTF8.self)) + } + } diff --git a/core/tauri/mobile/ios-api/Sources/Tauri/Invoke.swift b/core/tauri/mobile/ios-api/Sources/Tauri/Invoke.swift index f7490ae35..72f124d48 100644 --- a/core/tauri/mobile/ios-api/Sources/Tauri/Invoke.swift +++ b/core/tauri/mobile/ios-api/Sources/Tauri/Invoke.swift @@ -5,37 +5,42 @@ import Foundation import UIKit -let CHANNEL_PREFIX = "__CHANNEL__:" - -@objc public class Invoke: NSObject, JSValueContainer, BridgedJSValueContainer { - public var dictionaryRepresentation: NSDictionary { - return data as NSDictionary - } - - public static var jsDateFormatter: ISO8601DateFormatter = { - return ISO8601DateFormatter() - }() - - public var command: String - var callback: UInt64 - var error: UInt64 - public var data: JSObject - var sendResponse: (UInt64, JsonValue?) -> Void - var sendChannelData: (UInt64, JsonValue) -> Void +@objc public class Invoke: NSObject { + public let command: String + let callback: UInt64 + let error: UInt64 + let data: String + let sendResponse: (UInt64, String?) -> Void + let sendChannelData: (UInt64, String) -> Void public init( command: String, callback: UInt64, error: UInt64, - sendResponse: @escaping (UInt64, JsonValue?) -> Void, - sendChannelData: @escaping (UInt64, JsonValue) -> Void, data: JSObject? + sendResponse: @escaping (UInt64, String?) -> Void, + sendChannelData: @escaping (UInt64, String) -> Void, data: String ) { self.command = command self.callback = callback self.error = error - self.data = data ?? [:] + self.data = data self.sendResponse = sendResponse self.sendChannelData = sendChannelData } + public func parseArgs(_ type: T.Type) throws -> T { + let jsonData = self.data.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.userInfo[channelDataKey] = sendChannelData + return try decoder.decode(type, from: jsonData) + } + + func serialize(_ data: JsonValue) -> String { + do { + return try data.jsonRepresentation() ?? "\"Failed to serialize payload\"" + } catch { + return "\"\(error)\"" + } + } + public func resolve() { sendResponse(callback, nil) } @@ -45,15 +50,33 @@ let CHANNEL_PREFIX = "__CHANNEL__:" } public func resolve(_ data: JsonValue) { - sendResponse(callback, data) + sendResponse(callback, serialize(data)) + } + + public func resolve(_ data: T) { + do { + let json = try JSONEncoder().encode(data) + sendResponse(callback, String(decoding: json, as: UTF8.self)) + } catch { + sendResponse(self.error, "\"\(error)\"") + } } public func reject( - _ message: String, _ code: String? = nil, _ error: Error? = nil, _ data: JsonValue? = nil + _ message: String, code: String? = nil, error: Error? = nil, data: JsonValue? = nil ) { let payload: NSMutableDictionary = [ - "message": message, "code": code ?? "", "error": error ?? "", + "message": message ] + + if let code = code { + payload["code"] = code + } + + if let error = error { + payload["error"] = error + } + if let data = data { switch data { case .dictionary(let dict): @@ -62,7 +85,8 @@ let CHANNEL_PREFIX = "__CHANNEL__:" } } } - sendResponse(self.error, .dictionary(payload as! JsonObject)) + + sendResponse(self.error, serialize(.dictionary(payload as! JsonObject))) } public func unimplemented() { @@ -70,7 +94,7 @@ let CHANNEL_PREFIX = "__CHANNEL__:" } public func unimplemented(_ message: String) { - sendResponse(error, .dictionary(["message": message])) + reject(message) } public func unavailable() { @@ -78,22 +102,6 @@ let CHANNEL_PREFIX = "__CHANNEL__:" } public func unavailable(_ message: String) { - sendResponse(error, .dictionary(["message": message])) - } - - public func getChannel(_ key: String) -> Channel? { - let channelDef = getString(key, "") - let components = channelDef.components(separatedBy: CHANNEL_PREFIX) - if components.count < 2 { - return nil - } - guard let channelId = UInt64(components[1]) else { - return nil - } - return Channel( - id: channelId, - handler: { (res: JsonValue) -> Void in - self.sendChannelData(channelId, res) - }) + reject(message) } } diff --git a/core/tauri/mobile/ios-api/Sources/Tauri/JSTypes.swift b/core/tauri/mobile/ios-api/Sources/Tauri/JSTypes.swift index d1c3fb07b..505342dba 100644 --- a/core/tauri/mobile/ios-api/Sources/Tauri/JSTypes.swift +++ b/core/tauri/mobile/ios-api/Sources/Tauri/JSTypes.swift @@ -5,238 +5,18 @@ import Foundation // declare our empty protocol, and conformance, for typing -public protocol JSValue { } -extension String: JSValue { } -extension Bool: JSValue { } -extension Int: JSValue { } -extension Float: JSValue { } -extension Double: JSValue { } -extension NSNumber: JSValue { } -extension NSNull: JSValue { } -extension Array: JSValue { } -extension Date: JSValue { } -extension Dictionary: JSValue where Key == String, Value == JSValue { } +public protocol JSValue {} +extension String: JSValue {} +extension Bool: JSValue {} +extension Int: JSValue {} +extension Float: JSValue {} +extension Double: JSValue {} +extension NSNumber: JSValue {} +extension NSNull: JSValue {} +extension Array: JSValue {} +extension Date: JSValue {} +extension Dictionary: JSValue where Key == String, Value == JSValue {} // convenience aliases public typealias JSObject = [String: JSValue] public typealias JSArray = [JSValue] - -// string types -public protocol JSStringContainer { - func getString(_ key: String, _ defaultValue: String) -> String - func getString(_ key: String) -> String? -} - -extension JSStringContainer { - public func getString(_ key: String, _ defaultValue: String) -> String { - return getString(key) ?? defaultValue - } -} - -// boolean types -public protocol JSBoolContainer { - func getBool(_ key: String, _ defaultValue: Bool) -> Bool - func getBool(_ key: String) -> Bool? -} - -extension JSBoolContainer { - public func getBool(_ key: String, _ defaultValue: Bool) -> Bool { - return getBool(key) ?? defaultValue - } -} - -// integer types -public protocol JSIntContainer { - func getInt(_ key: String, _ defaultValue: Int) -> Int - func getInt(_ key: String) -> Int? -} - -extension JSIntContainer { - public func getInt(_ key: String, _ defaultValue: Int) -> Int { - return getInt(key) ?? defaultValue - } -} - -// float types -public protocol JSFloatContainer { - func getFloat(_ key: String, _ defaultValue: Float) -> Float - func getFloat(_ key: String) -> Float? -} - -extension JSFloatContainer { - public func getFloat(_ key: String, _ defaultValue: Float) -> Float { - return getFloat(key) ?? defaultValue - } -} - -// double types -public protocol JSDoubleContainer { - func getDouble(_ key: String, _ defaultValue: Double) -> Double - func getDouble(_ key: String) -> Double? -} - -extension JSDoubleContainer { - public func getDouble(_ key: String, _ defaultValue: Double) -> Double { - return getDouble(key) ?? defaultValue - } -} - -// date types -public protocol JSDateContainer { - func getDate(_ key: String, _ defaultValue: Date) -> Date - func getDate(_ key: String) -> Date? -} - -extension JSDateContainer { - public func getDate(_ key: String, _ defaultValue: Date) -> Date { - return getDate(key) ?? defaultValue - } -} - -// array types -public protocol JSArrayContainer { - func getArray(_ key: String, _ defaultValue: JSArray) -> JSArray - func getArray(_ key: String, _ ofType: T.Type) -> [T]? - func getArray(_ key: String) -> JSArray? -} - -extension JSArrayContainer { - public func getArray(_ key: String, _ defaultValue: JSArray) -> JSArray { - return getArray(key) ?? defaultValue - } - - public func getArray(_ key: String, _ ofType: T.Type) -> [T]? { - return getArray(key) as? [T] - } -} - -// dictionary types -public protocol JSObjectContainer { - func getObject(_ key: String, _ defaultValue: JSObject) -> JSObject - func getObject(_ key: String) -> JSObject? -} - -extension JSObjectContainer { - public func getObject(_ key: String, _ defaultValue: JSObject) -> JSObject { - return getObject(key) ?? defaultValue - } -} - -public protocol JSValueContainer: JSStringContainer, JSBoolContainer, JSIntContainer, JSFloatContainer, - JSDoubleContainer, JSDateContainer, JSArrayContainer, JSObjectContainer { - static var jsDateFormatter: ISO8601DateFormatter { get } - var data: JSObject { get } -} - -extension JSValueContainer { - public func getValue(_ key: String) -> JSValue? { - return data[key] - } - - public func getString(_ key: String) -> String? { - return data[key] as? String - } - - public func getBool(_ key: String) -> Bool? { - return data[key] as? Bool - } - - public func getInt(_ key: String) -> Int? { - return data[key] as? Int - } - - public func getFloat(_ key: String) -> Float? { - if let floatValue = data[key] as? Float { - return floatValue - } else if let doubleValue = data[key] as? Double { - return Float(doubleValue) - } - return nil - } - - public func getDouble(_ key: String) -> Double? { - return data[key] as? Double - } - - public func getDate(_ key: String) -> Date? { - if let isoString = data[key] as? String { - return Self.jsDateFormatter.date(from: isoString) - } - return data[key] as? Date - } - - public func getArray(_ key: String) -> JSArray? { - return data[key] as? JSArray - } - - public func getObject(_ key: String) -> JSObject? { - return data[key] as? JSObject - } -} - -@objc protocol BridgedJSValueContainer: NSObjectProtocol { - static var jsDateFormatter: ISO8601DateFormatter { get } - var dictionaryRepresentation: NSDictionary { get } -} - -/* - Simply casting objects from foundation class clusters (such as __NSArrayM) - doesn't work with the JSValue protocol and will always fail. So we need to - recursively and explicitly convert each value in the dictionary. - */ -public enum JSTypes { } -extension JSTypes { - public static func coerceDictionaryToJSObject(_ dictionary: NSDictionary?, formattingDatesAsStrings: Bool = false) -> JSObject? { - return coerceToJSValue(dictionary, formattingDates: formattingDatesAsStrings) as? JSObject - } - - public static func coerceDictionaryToJSObject(_ dictionary: [AnyHashable: Any]?, formattingDatesAsStrings: Bool = false) -> JSObject? { - return coerceToJSValue(dictionary, formattingDates: formattingDatesAsStrings) as? JSObject - } - - public static func coerceArrayToJSArray(_ array: [Any]?, formattingDatesAsStrings: Bool = false) -> JSArray? { - return array?.compactMap { coerceToJSValue($0, formattingDates: formattingDatesAsStrings) } - } -} - -private let dateStringFormatter = ISO8601DateFormatter() - -// We need a large switch statement because we have a lot of types. -// swiftlint:disable:next cyclomatic_complexity -private func coerceToJSValue(_ value: Any?, formattingDates: Bool) -> JSValue? { - guard let value = value else { - return nil - } - switch value { - case let stringValue as String: - return stringValue - case let numberValue as NSNumber: - return numberValue - case let boolValue as Bool: - return boolValue - case let intValue as Int: - return intValue - case let floatValue as Float: - return floatValue - case let doubleValue as Double: - return doubleValue - case let dateValue as Date: - if formattingDates { - return dateStringFormatter.string(from: dateValue) - } - return dateValue - case let nullValue as NSNull: - return nullValue - case let arrayValue as NSArray: - return arrayValue.compactMap { coerceToJSValue($0, formattingDates: formattingDates) } - case let dictionaryValue as NSDictionary: - let keys = dictionaryValue.allKeys.compactMap { $0 as? String } - var result: JSObject = [:] - for key in keys { - result[key] = coerceToJSValue(dictionaryValue[key], formattingDates: formattingDates) - } - return result - default: - return nil - } -} diff --git a/core/tauri/mobile/ios-api/Sources/Tauri/Plugin/Plugin.swift b/core/tauri/mobile/ios-api/Sources/Tauri/Plugin/Plugin.swift index 50179e2da..c90715b58 100644 --- a/core/tauri/mobile/ios-api/Sources/Tauri/Plugin/Plugin.swift +++ b/core/tauri/mobile/ios-api/Sources/Tauri/Plugin/Plugin.swift @@ -5,15 +5,31 @@ import WebKit import os.log +struct RegisterListenerArgs: Decodable { + let event: String + let handler: Channel +} + +struct RemoveListenerArgs: Decodable { + let event: String + let channelId: UInt64 +} + open class Plugin: NSObject { public let manager: PluginManager = PluginManager.shared - public var config: JSObject = [:] + var config: String = "{}" private var listeners = [String: [Channel]]() - internal func setConfig(_ config: JSObject) { + internal func setConfig(_ config: String) { self.config = config } + public func parseConfig(_ type: T.Type) throws -> T { + let jsonData = self.config.data(using: .utf8)! + let decoder = JSONDecoder() + return try decoder.decode(type, from: jsonData) + } + @objc open func load(webview: WKWebView) {} @objc open func checkPermissions(_ invoke: Invoke) { @@ -32,38 +48,32 @@ open class Plugin: NSObject { } } - @objc func registerListener(_ invoke: Invoke) { - guard let event = invoke.getString("event") else { - invoke.reject("`event` not provided") - return - } - guard let channel = invoke.getChannel("handler") else { - invoke.reject("`handler` not provided") - return + public func trigger(_ event: String, data: T) throws { + if let eventListeners = listeners[event] { + for channel in eventListeners { + try channel.send(data) + } } + } - if var eventListeners = listeners[event] { - eventListeners.append(channel) + @objc func registerListener(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(RegisterListenerArgs.self) + + if var eventListeners = listeners[args.event] { + eventListeners.append(args.handler) } else { - listeners[event] = [channel] + listeners[args.event] = [args.handler] } invoke.resolve() } - @objc func removeListener(_ invoke: Invoke) { - guard let event = invoke.getString("event") else { - invoke.reject("`event` not provided") - return - } + @objc func removeListener(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(RemoveListenerArgs.self) - if let eventListeners = listeners[event] { - guard let channelId = invoke.getInt("channelId") else { - invoke.reject("`channelId` not provided") - return - } + if let eventListeners = listeners[args.event] { - listeners[event] = eventListeners.filter { $0.id != channelId } + listeners[args.event] = eventListeners.filter { $0.id != args.channelId } } invoke.resolve() diff --git a/core/tauri/mobile/ios-api/Sources/Tauri/Tauri.swift b/core/tauri/mobile/ios-api/Sources/Tauri/Tauri.swift index 80a80fab7..d130015df 100644 --- a/core/tauri/mobile/ios-api/Sources/Tauri/Tauri.swift +++ b/core/tauri/mobile/ios-api/Sources/Tauri/Tauri.swift @@ -46,7 +46,7 @@ public class PluginManager { } } - func load(name: String, plugin: P, config: JSObject, webview: WKWebView?) { + func load(name: String, plugin: P, config: String, webview: WKWebView?) { plugin.setConfig(config) let handle = PluginHandle(plugin: plugin) if let webview = webview { @@ -95,11 +95,11 @@ extension PluginManager: NSCopying { } @_cdecl("register_plugin") -func registerPlugin(name: SRString, plugin: NSObject, config: NSDictionary?, webview: WKWebView?) { +func registerPlugin(name: SRString, plugin: NSObject, config: SRString, webview: WKWebView?) { PluginManager.shared.load( name: name.toString(), plugin: plugin as! Plugin, - config: JSTypes.coerceDictionaryToJSObject(config ?? [:], formattingDatesAsStrings: true)!, + config: config.toString(), webview: webview ) } @@ -115,34 +115,20 @@ func runCommand( id: Int, name: SRString, command: SRString, - data: NSDictionary, - callback: @escaping @convention(c) (Int, Bool, UnsafePointer?) -> Void, + data: SRString, + callback: @escaping @convention(c) (Int, Bool, UnsafePointer) -> Void, sendChannelData: @escaping @convention(c) (UInt64, UnsafePointer) -> Void ) { let callbackId: UInt64 = 0 let errorId: UInt64 = 1 let invoke = Invoke( command: command.toString(), callback: callbackId, error: errorId, - sendResponse: { (fn: UInt64, payload: JsonValue?) -> Void in + sendResponse: { (fn: UInt64, payload: String?) -> Void in let success = fn == callbackId - var payloadJson: String = "" - do { - try payloadJson = - payload == nil ? "null" : payload!.jsonRepresentation() ?? "`Failed to serialize payload`" - } catch { - payloadJson = "`\(error)`" - } - callback(id, success, payloadJson.cString(using: String.Encoding.utf8)) + callback(id, success, payload ?? "null") }, - sendChannelData: { (id: UInt64, payload: JsonValue) -> Void in - var payloadJson: String = "" - do { - try payloadJson = - payload.jsonRepresentation() ?? "`Failed to serialize payload`" - } catch { - payloadJson = "`\(error)`" - } - sendChannelData(id, payloadJson) - }, data: JSTypes.coerceDictionaryToJSObject(data, formattingDatesAsStrings: true)) + sendChannelData: { (id: UInt64, payload: String) -> Void in + sendChannelData(id, payload) + }, data: data.toString()) PluginManager.shared.invoke(name: name.toString(), invoke: invoke) } diff --git a/core/tauri/src/ios.rs b/core/tauri/src/ios.rs index 16bb3c6e5..a46bf1741 100644 --- a/core/tauri/src/ios.rs +++ b/core/tauri/src/ios.rs @@ -2,9 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use cocoa::base::{id, nil, NO, YES}; -use objc::*; -use serde_json::Value as JsonValue; use swift_rs::{swift, SRString, SwiftArg}; use std::{ @@ -38,143 +35,14 @@ swift!(pub fn run_plugin_command( id: i32, name: &SRString, method: &SRString, - data: *const c_void, + data: &SRString, callback: PluginMessageCallback, send_channel_data_callback: ChannelSendDataCallback )); swift!(pub fn register_plugin( name: &SRString, plugin: *const c_void, - config: *const c_void, + config: &SRString, webview: *const c_void )); swift!(pub fn on_webview_created(webview: *const c_void, controller: *const c_void)); - -pub fn json_to_dictionary(json: &JsonValue) -> id { - if let serde_json::Value::Object(map) = json { - unsafe { - let dictionary: id = msg_send![class!(NSMutableDictionary), alloc]; - let data: id = msg_send![dictionary, init]; - for (key, value) in map { - add_json_entry_to_dictionary(data, key, value); - } - data - } - } else { - nil - } -} - -const UTF8_ENCODING: usize = 4; - -struct NSString(id); - -impl NSString { - fn new(s: &str) -> Self { - // Safety: objc runtime calls are unsafe - NSString(unsafe { - let ns_string: id = msg_send![class!(NSString), alloc]; - let ns_string: id = msg_send![ns_string, - initWithBytes:s.as_ptr() - length:s.len() - encoding:UTF8_ENCODING]; - - // The thing is allocated in rust, the thing must be set to autorelease in rust to relinquish control - // or it can not be released correctly in OC runtime - let _: () = msg_send![ns_string, autorelease]; - - ns_string - }) - } -} - -unsafe fn add_json_value_to_array(array: id, value: &JsonValue) { - match value { - JsonValue::Null => { - let null: id = msg_send![class!(NSNull), null]; - let () = msg_send![array, addObject: null]; - } - JsonValue::Bool(val) => { - let value = if *val { YES } else { NO }; - let v: id = msg_send![class!(NSNumber), numberWithBool: value]; - let () = msg_send![array, addObject: v]; - } - JsonValue::Number(val) => { - let number: id = if let Some(v) = val.as_i64() { - msg_send![class!(NSNumber), numberWithInteger: v] - } else if let Some(v) = val.as_u64() { - msg_send![class!(NSNumber), numberWithUnsignedLongLong: v] - } else if let Some(v) = val.as_f64() { - msg_send![class!(NSNumber), numberWithDouble: v] - } else { - unreachable!() - }; - let () = msg_send![array, addObject: number]; - } - JsonValue::String(val) => { - let () = msg_send![array, addObject: NSString::new(val)]; - } - JsonValue::Array(val) => { - let nsarray: id = msg_send![class!(NSMutableArray), alloc]; - let inner_array: id = msg_send![nsarray, init]; - for value in val { - add_json_value_to_array(inner_array, value); - } - let () = msg_send![array, addObject: inner_array]; - } - JsonValue::Object(val) => { - let dictionary: id = msg_send![class!(NSMutableDictionary), alloc]; - let data: id = msg_send![dictionary, init]; - for (key, value) in val { - add_json_entry_to_dictionary(data, key, value); - } - let () = msg_send![array, addObject: data]; - } - } -} - -unsafe fn add_json_entry_to_dictionary(data: id, key: &str, value: &JsonValue) { - let key = NSString::new(key); - match value { - JsonValue::Null => { - let null: id = msg_send![class!(NSNull), null]; - let () = msg_send![data, setObject:null forKey: key]; - } - JsonValue::Bool(val) => { - let flag = if *val { YES } else { NO }; - let value: id = msg_send![class!(NSNumber), numberWithBool: flag]; - let () = msg_send![data, setObject:value forKey: key]; - } - JsonValue::Number(val) => { - let number: id = if let Some(v) = val.as_i64() { - msg_send![class!(NSNumber), numberWithInteger: v] - } else if let Some(v) = val.as_u64() { - msg_send![class!(NSNumber), numberWithUnsignedLongLong: v] - } else if let Some(v) = val.as_f64() { - msg_send![class!(NSNumber), numberWithDouble: v] - } else { - unreachable!() - }; - let () = msg_send![data, setObject:number forKey: key]; - } - JsonValue::String(val) => { - let () = msg_send![data, setObject:NSString::new(val) forKey: key]; - } - JsonValue::Array(val) => { - let nsarray: id = msg_send![class!(NSMutableArray), alloc]; - let array: id = msg_send![nsarray, init]; - for value in val { - add_json_value_to_array(array, value); - } - let () = msg_send![data, setObject:array forKey: key]; - } - JsonValue::Object(val) => { - let dictionary: id = msg_send![class!(NSMutableDictionary), alloc]; - let inner_data: id = msg_send![dictionary, init]; - for (key, value) in val { - add_json_entry_to_dictionary(inner_data, key, value); - } - let () = msg_send![data, setObject:inner_data forKey: key]; - } - } -} diff --git a/core/tauri/src/jni_helpers.rs b/core/tauri/src/jni_helpers.rs deleted file mode 100644 index 4f72bface..000000000 --- a/core/tauri/src/jni_helpers.rs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2019-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use crate::Runtime; -use jni::{ - errors::Error as JniError, - objects::{JObject, JValueOwned}, - JNIEnv, -}; -use serde_json::Value as JsonValue; -use tauri_runtime::RuntimeHandle; - -fn json_to_java<'a, R: Runtime>( - env: &mut JNIEnv<'a>, - activity: &JObject<'_>, - runtime_handle: &R::Handle, - json: &JsonValue, -) -> Result<(&'static str, JValueOwned<'a>), JniError> { - let (class, v) = match json { - JsonValue::Null => ("Ljava/lang/Object;", JObject::null().into()), - JsonValue::Bool(val) => ("Z", (*val).into()), - JsonValue::Number(val) => { - if let Some(v) = val.as_i64() { - ("J", v.into()) - } else if let Some(v) = val.as_f64() { - ("D", v.into()) - } else { - ("Ljava/lang/Object;", JObject::null().into()) - } - } - JsonValue::String(val) => ( - "Ljava/lang/Object;", - JObject::from(env.new_string(val)?).into(), - ), - JsonValue::Array(val) => { - let js_array_class = runtime_handle.find_class(env, activity, "app/tauri/plugin/JSArray")?; - let data = env.new_object(js_array_class, "()V", &[])?; - - for v in val { - let (signature, val) = json_to_java::(env, activity, runtime_handle, v)?; - env.call_method( - &data, - "put", - format!("({signature})Lorg/json/JSONArray;"), - &[val.borrow()], - )?; - } - - ("Ljava/lang/Object;", data.into()) - } - JsonValue::Object(val) => { - let data = { - let js_object_class = - runtime_handle.find_class(env, activity, "app/tauri/plugin/JSObject")?; - env.new_object(js_object_class, "()V", &[])? - }; - - for (key, value) in val { - let (signature, val) = json_to_java::(env, activity, runtime_handle, value)?; - let key = env.new_string(key)?; - env.call_method( - &data, - "put", - format!("(Ljava/lang/String;{signature})Lapp/tauri/plugin/JSObject;"), - &[(&key).into(), val.borrow()], - )?; - } - - ("Ljava/lang/Object;", data.into()) - } - }; - Ok((class, v)) -} - -pub fn to_jsobject<'a, R: Runtime>( - env: &mut JNIEnv<'a>, - activity: &JObject<'_>, - runtime_handle: &R::Handle, - json: &JsonValue, -) -> Result, JniError> { - if let JsonValue::Object(_) = json { - json_to_java::(env, activity, runtime_handle, json).map(|(_class, data)| data) - } else { - Ok(empty_object::(env, activity, runtime_handle)?.into()) - } -} - -fn empty_object<'a, R: Runtime>( - env: &mut JNIEnv<'a>, - activity: &JObject<'_>, - runtime_handle: &R::Handle, -) -> Result, JniError> { - // currently the Kotlin lib cannot handle nulls or raw values, it must be an object - let js_object_class = runtime_handle.find_class(env, activity, "app/tauri/plugin/JSObject")?; - let data = env.new_object(js_object_class, "()V", &[])?; - Ok(data) -} diff --git a/core/tauri/src/lib.rs b/core/tauri/src/lib.rs index 2e1a1c25a..61a6b2fe6 100644 --- a/core/tauri/src/lib.rs +++ b/core/tauri/src/lib.rs @@ -88,8 +88,6 @@ pub mod window; use tauri_runtime as runtime; #[cfg(target_os = "ios")] mod ios; -#[cfg(target_os = "android")] -mod jni_helpers; #[cfg(desktop)] pub mod menu; /// Path APIs. diff --git a/core/tauri/src/plugin/mobile.rs b/core/tauri/src/plugin/mobile.rs index a708f509a..15f49aa79 100644 --- a/core/tauri/src/plugin/mobile.rs +++ b/core/tauri/src/plugin/mobile.rs @@ -168,7 +168,7 @@ impl PluginApi { crate::ios::register_plugin( &name.into(), init_fn(), - crate::ios::json_to_dictionary(&config) as _, + &serde_json::to_string(&config).unwrap().as_str().into(), w.inner() as _, ) }; @@ -181,7 +181,10 @@ impl PluginApi { crate::ios::register_plugin( &self.name.into(), init_fn(), - crate::ios::json_to_dictionary(&self.raw_config) as _, + &serde_json::to_string(&self.raw_config) + .unwrap() + .as_str() + .into(), std::ptr::null(), ) }; @@ -230,17 +233,16 @@ impl PluginApi { .l()?; let plugin_name = env.new_string(plugin_name)?; - let config = - crate::jni_helpers::to_jsobject::(env, activity, &runtime_handle, plugin_config)?; + let config = env.new_string(&serde_json::to_string(plugin_config).unwrap())?; env.call_method( plugin_manager, "load", - "(Landroid/webkit/WebView;Ljava/lang/String;Lapp/tauri/plugin/Plugin;Lapp/tauri/plugin/JSObject;)V", + "(Landroid/webkit/WebView;Ljava/lang/String;Lapp/tauri/plugin/Plugin;Ljava/lang/String;)V", &[ webview.into(), (&plugin_name).into(), (&plugin).into(), - config.borrow() + (&config).into(), ], )?; @@ -381,7 +383,7 @@ pub(crate) fn run_command, F: FnOnce(PluginResponse) + id, &name.into(), &command.as_ref().into(), - crate::ios::json_to_dictionary(&payload) as _, + &serde_json::to_string(&payload).unwrap().as_str().into(), crate::ios::PluginMessageCallback(plugin_command_response_handler), crate::ios::ChannelSendDataCallback(send_channel_data_handler), ); @@ -409,13 +411,12 @@ pub(crate) fn run_command< plugin: &str, command: String, payload: &serde_json::Value, - runtime_handle: R::Handle, env: &mut JNIEnv<'_>, activity: &JObject<'_>, ) -> Result<(), JniError> { let plugin = env.new_string(plugin)?; let command = env.new_string(&command)?; - let data = crate::jni_helpers::to_jsobject::(env, activity, &runtime_handle, payload)?; + let data = env.new_string(&serde_json::to_string(payload).unwrap())?; let plugin_manager = env .call_method( activity, @@ -428,12 +429,12 @@ pub(crate) fn run_command< env.call_method( plugin_manager, "runCommand", - "(ILjava/lang/String;Ljava/lang/String;Lapp/tauri/plugin/JSObject;)V", + "(ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", &[ id.into(), (&plugin).into(), (&command).into(), - data.borrow(), + (&data).into(), ], )?; @@ -449,7 +450,6 @@ pub(crate) fn run_command< let id: i32 = PENDING_PLUGIN_CALLS_ID.fetch_add(1, Ordering::Relaxed); let plugin_name = name.to_string(); let command = command.as_ref().to_string(); - let handle_ = handle.clone(); PENDING_PLUGIN_CALLS .get_or_init(Default::default) @@ -458,7 +458,7 @@ pub(crate) fn run_command< .insert(id, Box::new(handler.clone())); handle.run_on_android_context(move |env, activity, _webview| { - if let Err(e) = run::(id, &plugin_name, command, &payload, handle_, env, activity) { + if let Err(e) = run::(id, &plugin_name, command, &payload, env, activity) { handler(Err(e.to_string().into())); } }); diff --git a/core/tauri/src/protocol/tauri.rs b/core/tauri/src/protocol/tauri.rs index bd2ba5d11..0b67c111c 100644 --- a/core/tauri/src/protocol/tauri.rs +++ b/core/tauri/src/protocol/tauri.rs @@ -27,7 +27,7 @@ struct CachedResponse { } pub fn get( - manager: &WindowManager, + #[allow(unused_variables)] manager: &WindowManager, window_origin: &str, web_resource_request_handler: Option>, ) -> UriSchemeProtocolHandler { diff --git a/examples/api/src-tauri/Cargo.lock b/examples/api/src-tauri/Cargo.lock index e62cececc..62a76a73e 100644 --- a/examples/api/src-tauri/Cargo.lock +++ b/examples/api/src-tauri/Cargo.lock @@ -82,24 +82,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" -[[package]] -name = "android_log-sys" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e" - -[[package]] -name = "android_logger" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8619b80c242aa7bd638b5c7ddd952addeecb71f69c75e33f1d47b2804f8f883a" -dependencies = [ - "android_log-sys", - "env_logger", - "log", - "once_cell", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -173,7 +155,6 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-cli", - "tauri-plugin-log", "tauri-plugin-sample", "tiny_http", "window-shadows", @@ -451,16 +432,6 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" -[[package]] -name = "byte-unit" -version = "4.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da78b32057b8fdfc352504708feeba7216dcd65a2c9ab02978cbd288d1279b6c" -dependencies = [ - "serde", - "utf8-width", -] - [[package]] name = "bytemuck" version = "1.14.0" @@ -1021,16 +992,6 @@ dependencies = [ "syn 2.0.38", ] -[[package]] -name = "env_logger" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" -dependencies = [ - "log", - "regex", -] - [[package]] name = "equivalent" version = "1.0.1" @@ -1088,15 +1049,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "fern" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" -dependencies = [ - "log", -] - [[package]] name = "field-offset" version = "0.3.6" @@ -2018,9 +1970,6 @@ name = "log" version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" -dependencies = [ - "value-bag", -] [[package]] name = "loom" @@ -2275,15 +2224,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "num_threads" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" -dependencies = [ - "libc", -] - [[package]] name = "objc" version = "0.2.7" @@ -3579,26 +3519,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "tauri-plugin-log" -version = "0.0.0" -source = "git+https://github.com/tauri-apps/plugins-workspace?branch=next#4a10f218f0e1fdd66a549dc0bf16be3efb17ea49" -dependencies = [ - "android_logger", - "byte-unit", - "cocoa 0.24.1", - "fern", - "log", - "objc", - "serde", - "serde_json", - "serde_repr", - "swift-rs", - "tauri", - "tauri-build", - "time", -] - [[package]] name = "tauri-plugin-sample" version = "0.1.0" @@ -3753,8 +3673,6 @@ checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" dependencies = [ "deranged", "itoa 1.0.9", - "libc", - "num_threads", "powerfmt", "serde", "time-core", @@ -4065,12 +3983,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-width" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1" - [[package]] name = "utf8parse" version = "0.2.1" @@ -4092,12 +4004,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" -[[package]] -name = "value-bag" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a72e1902dde2bd6441347de2b70b7f5d59bf157c6c62f0c44572607a1d55bbe" - [[package]] name = "version-compare" version = "0.1.1" diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index 728eee79a..a8c625a38 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -18,7 +18,6 @@ serde_json = "1.0" serde = { version = "1.0", features = [ "derive" ] } tiny_http = "0.11" log = "0.4" -tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "next" } tauri-plugin-sample = { path = "./tauri-plugin-sample/" } [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 9a06be515..9c08b55ec 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -43,11 +43,6 @@ pub fn run_app) + Send + 'static>( ) { #[allow(unused_mut)] let mut builder = builder - .plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - ) .plugin(tauri_plugin_sample::init()) .setup(move |app| { #[cfg(desktop)] diff --git a/examples/api/src-tauri/tauri-plugin-sample/android/src/main/java/com/plugin/sample/ExamplePlugin.kt b/examples/api/src-tauri/tauri-plugin-sample/android/src/main/java/com/plugin/sample/ExamplePlugin.kt index 69f11898e..e61e08209 100644 --- a/examples/api/src-tauri/tauri-plugin-sample/android/src/main/java/com/plugin/sample/ExamplePlugin.kt +++ b/examples/api/src-tauri/tauri-plugin-sample/android/src/main/java/com/plugin/sample/ExamplePlugin.kt @@ -6,25 +6,33 @@ package com.plugin.sample import android.app.Activity import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Channel import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import app.tauri.plugin.Invoke +@InvokeArg +class PingArgs { + var value: String? = null + var onEvent: Channel? = null +} + @TauriPlugin class ExamplePlugin(private val activity: Activity): Plugin(activity) { private val implementation = Example() @Command fun ping(invoke: Invoke) { - val onEvent = invoke.getChannel("onEvent") + val args = invoke.parseArgs(PingArgs::class.java) + val event = JSObject() event.put("kind", "ping") - onEvent?.send(event) + args.onEvent?.send(event) - val value = invoke.getString("value") ?: "" val ret = JSObject() - ret.put("value", implementation.pong(value)) + ret.put("value", implementation.pong(args.value ?: "default value :(")) invoke.resolve(ret) } } diff --git a/examples/api/src-tauri/tauri-plugin-sample/ios/Sources/ExamplePlugin.swift b/examples/api/src-tauri/tauri-plugin-sample/ios/Sources/ExamplePlugin.swift index 9774ba895..b68793867 100644 --- a/examples/api/src-tauri/tauri-plugin-sample/ios/Sources/ExamplePlugin.swift +++ b/examples/api/src-tauri/tauri-plugin-sample/ios/Sources/ExamplePlugin.swift @@ -7,13 +7,16 @@ import Tauri import UIKit import WebKit +class PingArgs: Decodable { + let value: String? + let onEvent: Channel? +} + class ExamplePlugin: Plugin { @objc public func ping(_ invoke: Invoke) throws { - let onEvent = invoke.getChannel("onEvent") - onEvent?.send(["kind": "ping"]) - - let value = invoke.getString("value") - invoke.resolve(["value": value as Any]) + let args = try invoke.parseArgs(PingArgs.self) + args.onEvent?.send(["kind": "ping"]) + invoke.resolve(["value": args.value ?? ""]) } } diff --git a/tooling/cli/templates/plugin/android/src/main/java/ExamplePlugin.kt b/tooling/cli/templates/plugin/android/src/main/java/ExamplePlugin.kt index 81ab5be9b..68a36ca01 100644 --- a/tooling/cli/templates/plugin/android/src/main/java/ExamplePlugin.kt +++ b/tooling/cli/templates/plugin/android/src/main/java/ExamplePlugin.kt @@ -2,20 +2,27 @@ package {{android_package_id}} import android.app.Activity import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import app.tauri.plugin.Invoke +@InvokeArg +class PingArgs { + var value: String? = null +} + @TauriPlugin class ExamplePlugin(private val activity: Activity): Plugin(activity) { private val implementation = Example() @Command fun ping(invoke: Invoke) { - val value = invoke.getString("value") ?: "" + val args = invoke.parseArgs(PingArgs::class.java) + val ret = JSObject() - ret.put("value", implementation.pong(value)) + ret.put("value", implementation.pong(args.value ?: "default value :(")) invoke.resolve(ret) } } diff --git a/tooling/cli/templates/plugin/ios/Sources/ExamplePlugin.swift b/tooling/cli/templates/plugin/ios/Sources/ExamplePlugin.swift index 3923f5129..526d73063 100644 --- a/tooling/cli/templates/plugin/ios/Sources/ExamplePlugin.swift +++ b/tooling/cli/templates/plugin/ios/Sources/ExamplePlugin.swift @@ -1,16 +1,20 @@ +import SwiftRs +import Tauri import UIKit import WebKit -import Tauri -import SwiftRs + +class PingArgs: Decodable { + let value: String? +} class ExamplePlugin: Plugin { - @objc public func ping(_ invoke: Invoke) throws { - let value = invoke.getString("value") - invoke.resolve(["value": value as Any]) - } + @objc public func ping(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(PingArgs.self) + invoke.resolve(["value": args.value ?? ""]) + } } @_cdecl("init_plugin_{{ plugin_name_snake_case }}") func initPlugin() -> Plugin { - return ExamplePlugin() + return ExamplePlugin() }