diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..5b109c79 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +MACOSX_DEPLOYMENT_TARGET = "13.0" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3c32cd0d..541ab9ab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -239,3 +239,57 @@ jobs: with: name: ios-coverage path: ios/coverage.json + + test-macos: + name: macOS Unit Tests + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin + + - name: Build macOS bindings + run: cargo build --target aarch64-apple-darwin + + - name: Run macOS unit tests + run: | + cd macos + xcodebuild test \ + -scheme tauri-plugin-notifications \ + -destination 'platform=macOS' \ + -enableCodeCoverage YES \ + -resultBundlePath TestResults.xcresult + + - name: Generate coverage report + run: | + cd macos + xcrun xccov view --report --json TestResults.xcresult > coverage.json + + - name: Upload coverage to Codecov + if: ${{ github.actor != 'dependabot[bot]' }} + uses: codecov/codecov-action@v5 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: ./macos/coverage.json + flags: macos + name: macos-coverage + + - name: Upload test results + uses: actions/upload-artifact@v6 + if: always() + with: + name: macos-test-results + path: macos/TestResults.xcresult + + - name: Upload coverage reports + uses: actions/upload-artifact@v6 + if: always() + with: + name: macos-coverage + path: macos/coverage.json diff --git a/.gitignore b/.gitignore index 95ec3ab0..5f6905df 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ node_modules/ dist-js dist coverage + +# Firebase config (users should add their own) +google-services.json diff --git a/.prettierignore b/.prettierignore index de4c568d..b0234f99 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ node_modules schema.json target **/gen/* +**/.svelte-kit/ diff --git a/Cargo.toml b/Cargo.toml index 2144ce41..f0fe47fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,9 @@ license = "MIT" repository = "https://github.com/Choochmeque/tauri-plugin-notifications" [features] -default = [] +default = ["notify-rust"] push-notifications = [] +notify-rust = ["dep:notify-rust"] [dependencies] tauri = { version = "2.8.2" } @@ -28,12 +29,18 @@ url = { version = "2", features = ["serde"] } [target.'cfg(target_os = "ios")'.dependencies] tauri = { version = "2.8.2", features = ["wry"] } +[target.'cfg(target_os = "macos")'.dependencies] +swift-bridge = { version = "0.1", features = ["async"], git = "https://github.com/chinedufn/swift-bridge", rev = "82b0885" } + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] -notify-rust = "4.11" +notify-rust = { version = "4.11", optional = true } [build-dependencies] tauri-plugin = { version = "2.4.0", features = ["build"] } +[target.'cfg(target_os = "macos")'.build-dependencies] +swift-bridge-build = { version = "0.1", git = "https://github.com/chinedufn/swift-bridge", rev = "82b0885" } + [dev-dependencies] color-backtrace = "0.7" ctor = "0.6" diff --git a/android/src/androidTest/java/app/tauri/notification/NotificationStorageInstrumentedTest.kt b/android/src/androidTest/java/app/tauri/notification/NotificationStorageInstrumentedTest.kt index 3278710a..63b7dfee 100644 --- a/android/src/androidTest/java/app/tauri/notification/NotificationStorageInstrumentedTest.kt +++ b/android/src/androidTest/java/app/tauri/notification/NotificationStorageInstrumentedTest.kt @@ -168,13 +168,13 @@ class NotificationStorageInstrumentedTest { val retrieved = storage.getActionGroup("message-actions") - // Note: There's a bug in NotificationStorage.writeActionGroup where it uses - // type.id instead of loop index, causing actions to overwrite each other. - // This test verifies the actual (buggy) behavior. assertEquals(2, retrieved.size) - // Actions will be empty because write uses wrong key - assertTrue(retrieved[0]?.id.isNullOrEmpty()) - assertTrue(retrieved[1]?.id.isNullOrEmpty()) + assertEquals("reply", retrieved[0]?.id) + assertEquals("Reply", retrieved[0]?.title) + assertEquals(true, retrieved[0]?.input) + assertEquals("dismiss", retrieved[1]?.id) + assertEquals("Dismiss", retrieved[1]?.title) + assertEquals(false, retrieved[1]?.input) } @Test @@ -260,13 +260,17 @@ class NotificationStorageInstrumentedTest { val retrieved1 = storage.getActionGroup("group1") assertEquals(1, retrieved1.size) - // Bug in writeActionGroup causes empty action ids - assertTrue(retrieved1[0]?.id.isNullOrEmpty()) + assertEquals("action1", retrieved1[0]?.id) + assertEquals("Action 1", retrieved1[0]?.title) + assertEquals(false, retrieved1[0]?.input) val retrieved2 = storage.getActionGroup("group2") assertEquals(2, retrieved2.size) - // Bug in writeActionGroup causes empty action ids - assertTrue(retrieved2[0]?.id.isNullOrEmpty()) - assertTrue(retrieved2[1]?.id.isNullOrEmpty()) + assertEquals("action2", retrieved2[0]?.id) + assertEquals("Action 2", retrieved2[0]?.title) + assertEquals(true, retrieved2[0]?.input) + assertEquals("action3", retrieved2[1]?.id) + assertEquals("Action 3", retrieved2[1]?.title) + assertEquals(false, retrieved2[1]?.input) } } diff --git a/android/src/main/java/app/tauri/notification/Notification.kt b/android/src/main/java/app/tauri/notification/Notification.kt index bf10f3dc..204d5558 100644 --- a/android/src/main/java/app/tauri/notification/Notification.kt +++ b/android/src/main/java/app/tauri/notification/Notification.kt @@ -8,11 +8,11 @@ import android.content.ContentResolver import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.os.Build +import android.service.notification.StatusBarNotification +import androidx.annotation.RequiresApi import app.tauri.annotation.InvokeArg -import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject -import org.json.JSONException -import org.json.JSONObject @InvokeArg class Notification { @@ -38,6 +38,7 @@ class Notification { var sourceJson: String? = null var visibility: Int? = null var number: Int? = null + var silent: Boolean? = null fun getSound(context: Context, defaultSound: Int): String? { var soundPath: String? = null @@ -84,12 +85,77 @@ class Notification { fun buildNotificationPendingList(notifications: List): List { val pendingNotifications = mutableListOf() for (notification in notifications) { - val pendingNotification = PendingNotification(notification.id, notification.title, notification.body, notification.schedule, notification.extra) + val pendingNotification = PendingNotification( + id = notification.id, + title = notification.title, + body = notification.body, + schedule = notification.schedule, + extra = notification.extra + ) pendingNotifications.add(pendingNotification) } return pendingNotifications } + + @RequiresApi(Build.VERSION_CODES.M) + fun buildNotificationActiveList(statusBarNotifications: Array): List { + val activeNotifications = mutableListOf() + for (statusBarNotification in statusBarNotifications) { + val notification = statusBarNotification.notification + val data = mutableMapOf() + if (notification != null) { + for (key in notification.extras.keySet()) { + notification.extras.getString(key)?.let { value -> + data[key] = value + } + } + } + + val activeNotification = ActiveNotificationInfo( + id = statusBarNotification.id, + tag = statusBarNotification.tag, + title = notification?.extras?.getCharSequence(android.app.Notification.EXTRA_TITLE)?.toString(), + body = notification?.extras?.getCharSequence(android.app.Notification.EXTRA_TEXT)?.toString(), + group = notification?.group, + groupSummary = notification?.let { 0 != it.flags and android.app.Notification.FLAG_GROUP_SUMMARY } ?: false, + data = data, + extra = emptyMap(), + attachments = emptyList(), + actionTypeId = null, + schedule = null, + sound = null + ) + activeNotifications.add(activeNotification) + } + return activeNotifications + } } } -class PendingNotification(val id: Int, val title: String?, val body: String?, val schedule: NotificationSchedule?, val extra: JSObject?) \ No newline at end of file +class PendingNotification( + val id: Int, + val title: String?, + val body: String?, + val schedule: NotificationSchedule?, + val extra: JSObject? +) + +class ActiveNotificationInfo( + val id: Int, + val tag: String?, + val title: String?, + val body: String?, + val group: String?, + val groupSummary: Boolean, + val data: Map, + val extra: Map, + val attachments: List, + val actionTypeId: String?, + val schedule: NotificationSchedule?, + val sound: String? +) + +class AttachmentInfo( + val id: String, + val url: String +) \ No newline at end of file diff --git a/android/src/main/java/app/tauri/notification/NotificationAttachment.kt b/android/src/main/java/app/tauri/notification/NotificationAttachment.kt index 56a13818..f53f2df6 100644 --- a/android/src/main/java/app/tauri/notification/NotificationAttachment.kt +++ b/android/src/main/java/app/tauri/notification/NotificationAttachment.kt @@ -4,11 +4,14 @@ package app.tauri.notification +import app.tauri.Logger import app.tauri.plugin.JSObject import org.json.JSONArray import org.json.JSONException import org.json.JSONObject +private const val ATTACHMENT_TAG = "NotificationAttachment" + class NotificationAttachment { var id: String? = null var url: String? = null @@ -20,7 +23,8 @@ class NotificationAttachment { var attachments: JSONArray? = null try { attachments = notification.getJSONArray("attachments") - } catch (_: Exception) { + } catch (e: Exception) { + Logger.debug(Logger.tags(ATTACHMENT_TAG), "No attachments found in notification: ${e.message}") } if (attachments != null) { for (i in 0 until attachments.length()) { @@ -29,18 +33,21 @@ class NotificationAttachment { try { jsonObject = attachments.getJSONObject(i) } catch (e: JSONException) { + Logger.error(Logger.tags(ATTACHMENT_TAG), "Failed to get attachment object at index $i: ${e.message}", e) } if (jsonObject != null) { var jsObject: JSObject? = null try { jsObject = JSObject.fromJSONObject(jsonObject) - } catch (_: JSONException) { + } catch (e: JSONException) { + Logger.error(Logger.tags(ATTACHMENT_TAG), "Failed to convert attachment JSON object: ${e.message}", e) } newAttachment.id = jsObject!!.getString("id") newAttachment.url = jsObject.getString("url") try { newAttachment.options = jsObject.getJSONObject("options") - } catch (_: JSONException) { + } catch (e: JSONException) { + Logger.debug(Logger.tags(ATTACHMENT_TAG), "No options found for attachment: ${e.message}") } attachmentsList.add(newAttachment) } diff --git a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt index c3c9a8d2..0e6a17cf 100644 --- a/android/src/main/java/app/tauri/notification/NotificationPlugin.kt +++ b/android/src/main/java/app/tauri/notification/NotificationPlugin.kt @@ -61,6 +61,11 @@ class RegisterActionTypesArgs { lateinit var types: List } +@InvokeArg +class SetClickListenerActiveArgs { + var active: Boolean = false +} + @InvokeArg class ActiveNotification { var id: Int = 0 @@ -87,6 +92,10 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { private var pendingTokenInvoke: Invoke? = null private var cachedToken: String? = null + // Click listener tracking for cold-start support + private var hasClickedListener = false + private var pendingNotificationClick: JSObject? = null + companion object { var instance: NotificationPlugin? = null @@ -126,18 +135,81 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { } fun onIntent(intent: Intent) { - if (Intent.ACTION_MAIN != intent.action) { - return + Logger.debug(Logger.tags(TAG), "onIntent called - action: ${intent.action}, extras: ${intent.extras?.keySet()}") + + // Handle local notification click (requires ACTION_MAIN) + if (Intent.ACTION_MAIN == intent.action) { + val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) + if (dataJson != null) { + trigger("actionPerformed", dataJson) + triggerNotificationClicked( + intent.getIntExtra(NOTIFICATION_INTENT_KEY, -1), + extractLocalNotificationData(intent) + ) + return + } } - val dataJson = manager.handleNotificationActionPerformed(intent, notificationStorage) - if (dataJson != null) { - trigger("actionPerformed", dataJson) + + // Handle push notification click (Firebase background notification) + // Firebase may use different actions, so check for push data regardless of action + val pushData = extractPushNotificationData(intent) + if (pushData != null) { + Logger.debug(Logger.tags(TAG), "Push notification clicked with data: $pushData") + triggerNotificationClicked(-1, pushData) + } + } + + private fun extractLocalNotificationData(intent: Intent): JSObject? { + val notificationJson = intent.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY) ?: return null + return try { + val notification = JSObject(notificationJson) + if (notification.has("extra")) notification.getJSObject("extra") else null + } catch (e: Exception) { + Logger.error(Logger.tags(TAG), "Failed to extract local notification data: ${e.message}", e) + null + } + } + + private fun extractPushNotificationData(intent: Intent): JSObject? { + val extras = intent.extras ?: return null + // Skip if no extras or if it's a regular app launch + if (extras.isEmpty) return null + + Logger.debug(Logger.tags(TAG), "extractPushNotificationData - all extras: ${extras.keySet().map { "$it=${extras.getString(it)}" }}") + + // Filter out system/internal keys, keep user data + val data = JSObject() + for (key in extras.keySet()) { + // Skip Android/Firebase internal keys + if (key.startsWith("android.") || key.startsWith("google.") || + key.startsWith("gcm.") || key == "from" || key == "collapse_key") continue + extras.getString(key)?.let { data.put(key, it) } + } + Logger.debug(Logger.tags(TAG), "extractPushNotificationData - filtered data length: ${data.length()}") + return if (data.length() > 0) data else null + } + + private fun triggerNotificationClicked(id: Int, data: JSObject?) { + val clickedData = JSObject() + clickedData.put("id", id) + if (data != null) { + clickedData.put("data", data) + } + + Logger.debug(Logger.tags(TAG), "triggerNotificationClicked - id: $id, hasClickedListener: $hasClickedListener, data: $data") + + if (hasClickedListener) { + trigger("notificationClicked", clickedData) + } else { + Logger.debug(Logger.tags(TAG), "No click listener, storing as pending") + pendingNotificationClick = clickedData } } @Command fun show(invoke: Invoke) { val notification = invoke.parseArgs(Notification::class.java) + notification.sourceJson = invoke.getRawArgs() val id = manager.schedule(notification) if (notification.schedule != null) { @@ -150,6 +222,10 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun batch(invoke: Invoke) { val args = invoke.parseArgs(BatchArgs::class.java) + val mapper = jsonMapper() + for (notification in args.notifications) { + notification.sourceJson = mapper.writeValueAsString(notification) + } val ids = manager.schedule(args.notifications) notificationStorage.appendNotifications(args.notifications) @@ -164,6 +240,13 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { invoke.resolve() } + @Command + fun cancelAll(invoke: Invoke) { + val ids = notificationStorage.getSavedNotificationIds().mapNotNull { it.toIntOrNull() } + manager.cancel(ids) + invoke.resolve() + } + @Command fun removeActive(invoke: Invoke) { val args = invoke.parseArgs(RemoveActiveArgs::class.java) @@ -185,7 +268,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @Command fun getPending(invoke: Invoke) { - val notifications= notificationStorage.getSavedNotifications() + val notifications = notificationStorage.getSavedNotifications() val result = Notification.buildNotificationPendingList(notifications) invoke.resolveObject(result) } @@ -200,33 +283,12 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { @SuppressLint("ObsoleteSdkInt") @Command fun getActive(invoke: Invoke) { - val notifications = JSArray() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val activeNotifications = notificationManager.activeNotifications - for (activeNotification in activeNotifications) { - val jsNotification = JSObject() - jsNotification.put("id", activeNotification.id) - jsNotification.put("tag", activeNotification.tag) - val notification = activeNotification.notification - if (notification != null) { - jsNotification.put("title", notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE)) - jsNotification.put("body", notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)) - jsNotification.put("group", notification.group) - jsNotification.put( - "groupSummary", - 0 != notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY - ) - val extras = JSObject() - for (key in notification.extras.keySet()) { - extras.put(key!!, notification.extras.getString(key)) - } - jsNotification.put("data", extras) - } - notifications.put(jsNotification) - } + val result = Notification.buildNotificationActiveList(notificationManager.activeNotifications) + invoke.resolveObject(result) + } else { + invoke.resolveObject(emptyList()) } - - invoke.resolveObject(notifications) } @Command @@ -450,4 +512,18 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) { "denied" } } + + @Command + fun setClickListenerActive(invoke: Invoke) { + val args = invoke.parseArgs(SetClickListenerActiveArgs::class.java) + hasClickedListener = args.active + + // If listener just became active and we have pending click, trigger it + if (args.active && pendingNotificationClick != null) { + trigger("notificationClicked", pendingNotificationClick!!) + pendingNotificationClick = null + } + + invoke.resolve() + } } diff --git a/android/src/main/java/app/tauri/notification/NotificationSchedule.kt b/android/src/main/java/app/tauri/notification/NotificationSchedule.kt index 7b0d5112..9285b4c3 100644 --- a/android/src/main/java/app/tauri/notification/NotificationSchedule.kt +++ b/android/src/main/java/app/tauri/notification/NotificationSchedule.kt @@ -96,7 +96,6 @@ sealed class NotificationSchedule { is At -> allowWhileIdle is Interval -> allowWhileIdle is Every -> allowWhileIdle - else -> false } } diff --git a/android/src/main/java/app/tauri/notification/NotificationStorage.kt b/android/src/main/java/app/tauri/notification/NotificationStorage.kt index bceb985d..c9984155 100644 --- a/android/src/main/java/app/tauri/notification/NotificationStorage.kt +++ b/android/src/main/java/app/tauri/notification/NotificationStorage.kt @@ -6,10 +6,10 @@ package app.tauri.notification import android.content.Context import android.content.SharedPreferences +import app.tauri.Logger import com.fasterxml.jackson.databind.ObjectMapper -import org.json.JSONException -import java.lang.Exception +private const val STORAGE_TAG = "NotificationStorage" // Key for private preferences private const val NOTIFICATION_STORE_ID = "NOTIFICATION_STORE" // Key used to save action types @@ -17,58 +17,70 @@ private const val ACTION_TYPES_ID = "ACTION_TYPE_STORE" class NotificationStorage(private val context: Context, private val jsonMapper: ObjectMapper) { fun appendNotifications(localNotifications: List) { + Logger.debug(Logger.tags(STORAGE_TAG), "Appending ${localNotifications.size} notifications to storage") val storage = getStorage(NOTIFICATION_STORE_ID) val editor = storage.edit() + var savedCount = 0 for (request in localNotifications) { if (request.schedule != null) { val key: String = request.id.toString() - editor.putString(key, request.sourceJson.toString()) + val jsonValue = request.sourceJson + Logger.debug(Logger.tags(STORAGE_TAG), "Saving notification $key, sourceJson is null: ${request.sourceJson == null}, value: ${jsonValue?.take(100)}") + editor.putString(key, jsonValue) + savedCount++ + } else { + Logger.debug(Logger.tags(STORAGE_TAG), "Skipping notification ${request.id} - no schedule") } } editor.apply() + Logger.debug(Logger.tags(STORAGE_TAG), "Actually saved $savedCount scheduled notifications") } fun getSavedNotificationIds(): List { val storage = getStorage(NOTIFICATION_STORE_ID) val all = storage.all - return if (all != null) { - ArrayList(all.keys) - } else ArrayList() + val ids = if (all != null) ArrayList(all.keys) else ArrayList() + Logger.debug(Logger.tags(STORAGE_TAG), "Retrieved ${ids.size} saved notification IDs") + return ids } fun getSavedNotifications(): List { val storage = getStorage(NOTIFICATION_STORE_ID) val all = storage.all - if (all != null) { - val notifications = ArrayList() - for (key in all.keys) { - val notificationString = all[key] as String? - try { - val notification = jsonMapper.readValue(notificationString, Notification::class.java) - notifications.add(notification) - } catch (_: Exception) { } - } - return notifications - } - return ArrayList() + Logger.debug(Logger.tags(STORAGE_TAG), "Storage keys: ${all?.keys}") + val notifications = all?.keys?.mapNotNull { key -> + val value = all[key] + Logger.debug(Logger.tags(STORAGE_TAG), "Key $key, value type: ${value?.javaClass?.name}, value: ${value.toString().take(100)}") + val json = value as? String + parseNotification(json) + } ?: emptyList() + Logger.debug(Logger.tags(STORAGE_TAG), "Retrieved ${notifications.size} saved notifications") + return notifications } fun getSavedNotification(key: String): Notification? { val storage = getStorage(NOTIFICATION_STORE_ID) val notificationString = try { storage.getString(key, null) - } catch (ex: ClassCastException) { + } catch (e: ClassCastException) { + Logger.error(Logger.tags(STORAGE_TAG), "Failed to get notification string for key $key: ${e.message}", e) return null - } ?: return null + } + return parseNotification(notificationString) + } + private fun parseNotification(json: String?): Notification? { + if (json == null) return null return try { - jsonMapper.readValue(notificationString, Notification::class.java) - } catch (ex: JSONException) { + jsonMapper.readValue(json, Notification::class.java) + } catch (e: Exception) { + Logger.error(Logger.tags(STORAGE_TAG), "Failed to parse notification: ${e.message}", e) null } } fun deleteNotification(id: String?) { + Logger.debug(Logger.tags(STORAGE_TAG), "Deleting notification with id: $id") val editor = getStorage(NOTIFICATION_STORE_ID).edit() editor.remove(id) editor.apply() @@ -80,27 +92,29 @@ class NotificationStorage(private val context: Context, private val jsonMapper: fun writeActionGroup(actions: List) { for (type in actions) { - val i = type.id val editor = getStorage(ACTION_TYPES_ID + type.id).edit() editor.clear() editor.putInt("count", type.actions.size) - for (action in type.actions) { - editor.putString("id$i", action.id) - editor.putString("title$i", action.title) - editor.putBoolean("input$i", action.input ?: false) + for ((index, action) in type.actions.withIndex()) { + editor.putString("id$index", action.id) + editor.putString("title$index", action.title) + editor.putBoolean("input$index", action.input ?: false) } editor.apply() + Logger.debug(Logger.tags(STORAGE_TAG), "Saved action group ${type.id} with ${type.actions.size} actions") } } fun getActionGroup(forId: String): Array { val storage = getStorage(ACTION_TYPES_ID + forId) val count = storage.getInt("count", 0) + Logger.debug(Logger.tags(STORAGE_TAG), "Getting action group $forId, count: $count") val actions: Array = arrayOfNulls(count) for (i in 0 until count) { val id = storage.getString("id$i", "") val title = storage.getString("title$i", "") val input = storage.getBoolean("input$i", false) + Logger.debug(Logger.tags(STORAGE_TAG), "Action $i: id=$id, title=$title, input=$input") val action = NotificationAction() action.id = id ?: "" diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index ea398ad8..97b74047 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -81,7 +81,8 @@ class TauriNotificationManager( if (notificationJsonString != null) { request = JSObject(notificationJsonString) } - } catch (_: JSONException) { + } catch (e: JSONException) { + Logger.error(Logger.tags(TAG), "Failed to parse notification JSON: ${e.message}", e) } dataJson.put("notification", request) return dataJson @@ -219,7 +220,8 @@ class TauriNotificationManager( notificationManager.notify(notification.id, buildNotification) try { NotificationPlugin.triggerNotification(notification) - } catch (_: JSONException) { + } catch (e: JSONException) { + Logger.error(Logger.tags(TAG), "Failed to trigger notification event: ${e.message}", e) } } } @@ -498,11 +500,15 @@ class TimedNotificationPublisher : BroadcastReceiver() { } val storage = NotificationStorage(context, ObjectMapper()) + // Check if notification still exists in storage (might have been cancelled) val savedNotification = storage.getSavedNotification(id.toString()) - if (savedNotification != null) { - NotificationPlugin.triggerNotification(savedNotification) + if (savedNotification == null) { + // Notification was cancelled, don't show or reschedule + Logger.debug(Logger.tags(TAG), "Notification $id was cancelled, skipping") + return } + NotificationPlugin.triggerNotification(savedNotification) notificationManager.notify(id, notification) if (!rescheduleNotificationIfNeeded(context, intent, id)) { storage.deleteNotification(id.toString()) @@ -582,8 +588,8 @@ class LocalNotificationRestoreReceiver : BroadcastReceiver() { var config: PluginConfig? = null try { config = PluginManager.loadConfig(context, "notification", PluginConfig::class.java) - } catch (ex: Exception) { - ex.printStackTrace() + } catch (e: Exception) { + Logger.error(Logger.tags(TAG), "Failed to load notification plugin config: ${e.message}", e) } val notificationManager = TauriNotificationManager(storage, null, context, config) notificationManager.schedule(notifications) diff --git a/android/src/test/java/app/tauri/notification/NotificationAttachmentTest.kt b/android/src/test/java/app/tauri/notification/NotificationAttachmentTest.kt index a16e71b5..d5fcb030 100644 --- a/android/src/test/java/app/tauri/notification/NotificationAttachmentTest.kt +++ b/android/src/test/java/app/tauri/notification/NotificationAttachmentTest.kt @@ -9,7 +9,10 @@ import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.* import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class NotificationAttachmentTest { @Test diff --git a/android/src/test/java/app/tauri/notification/NotificationStorageTest.kt b/android/src/test/java/app/tauri/notification/NotificationStorageTest.kt index db1fd328..0a545117 100644 --- a/android/src/test/java/app/tauri/notification/NotificationStorageTest.kt +++ b/android/src/test/java/app/tauri/notification/NotificationStorageTest.kt @@ -11,7 +11,10 @@ import io.mockk.* import org.junit.Assert.* import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class NotificationStorageTest { private lateinit var mockContext: Context diff --git a/android/src/test/java/app/tauri/notification/NotificationTest.kt b/android/src/test/java/app/tauri/notification/NotificationTest.kt index c0f6052c..a0d8abc6 100644 --- a/android/src/test/java/app/tauri/notification/NotificationTest.kt +++ b/android/src/test/java/app/tauri/notification/NotificationTest.kt @@ -13,7 +13,10 @@ import io.mockk.* import org.junit.Assert.* import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class NotificationTest { private lateinit var mockContext: Context diff --git a/build.rs b/build.rs index 50fc8cc1..179435a3 100644 --- a/build.rs +++ b/build.rs @@ -2,15 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +#[cfg(target_os = "macos")] +use std::{path::PathBuf, process::Command}; + const COMMANDS: &[&str] = &[ + "register_listener", + "remove_listener", "notify", "request_permission", "is_permission_granted", "register_for_push_notifications", "unregister_for_push_notifications", "register_action_types", - "register_listener", "cancel", + "cancel_all", "get_pending", "remove_active", "get_active", @@ -38,13 +43,20 @@ fn main() { .expect("Failed to write build.properties"); } - // Generate marker file for iOS Swift build + // Generate marker file for iOS/macOS Swift build // Package.swift reads this file to conditionally enable ENABLE_PUSH_NOTIFICATIONS let ios_marker_path = std::path::Path::new("ios/.push-notifications-enabled"); + let macos_marker_path = std::path::Path::new("macos/.push-notifications-enabled"); if enable_push { std::fs::write(ios_marker_path, "").expect("Failed to write iOS push marker file"); - } else if ios_marker_path.exists() { - std::fs::remove_file(ios_marker_path).ok(); + std::fs::write(macos_marker_path, "").expect("Failed to write macOS push marker file"); + } else { + if ios_marker_path.exists() { + std::fs::remove_file(ios_marker_path).ok(); + } + if macos_marker_path.exists() { + std::fs::remove_file(macos_marker_path).ok(); + } } let result = tauri_plugin::Builder::new(COMMANDS) @@ -60,4 +72,105 @@ fn main() { { result.expect("Failed to build Tauri plugin"); } + + #[cfg(target_os = "macos")] + { + // Only run macOS-specific build steps when building for macOS + if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "macos" { + let bridges = vec!["src/macos.rs"]; + for path in &bridges { + println!("cargo:rerun-if-changed={path}"); + } + + println!("cargo:rerun-if-changed=macos/Sources/NotificationPlugin.swift"); + + swift_bridge_build::parse_bridges(bridges) + .write_all_concatenated(swift_bridge_out_dir(), env!("CARGO_PKG_NAME")); + + compile_swift(); + + println!("cargo:rustc-link-lib=static=tauri-plugin-notifications"); + println!( + "cargo:rustc-link-search={}", + swift_library_static_lib_dir() + .to_str() + .expect("Swift library path must be valid UTF-8") + ); + } + } +} + +#[cfg(target_os = "macos")] +fn compile_swift() { + let swift_package_dir = manifest_dir().join("macos"); + + let mut cmd = Command::new("swift"); + + cmd.current_dir(swift_package_dir).arg("build").args([ + "-Xswiftc", + "-import-objc-header", + "-Xswiftc", + swift_source_dir() + .join("bridging-header.h") + .to_str() + .expect("Bridging header path must be valid UTF-8"), + ]); + + if is_release_build() { + cmd.args(["-c", "release"]); + } + + let exit_status = cmd + .spawn() + .expect("Failed to spawn swift build command") + .wait_with_output() + .expect("Failed to wait for swift build output"); + + if !exit_status.status.success() { + panic!( + r#" +Stderr: {} +Stdout: {} +"#, + String::from_utf8(exit_status.stderr).expect("Stderr must be valid UTF-8"), + String::from_utf8(exit_status.stdout).expect("Stdout must be valid UTF-8"), + ) + } +} + +#[cfg(target_os = "macos")] +fn swift_bridge_out_dir() -> PathBuf { + generated_code_dir() +} + +#[cfg(target_os = "macos")] +fn manifest_dir() -> PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set"); + PathBuf::from(manifest_dir) +} + +#[cfg(target_os = "macos")] +fn is_release_build() -> bool { + std::env::var("PROFILE").expect("PROFILE must be set") == "release" +} + +#[cfg(target_os = "macos")] +fn swift_source_dir() -> PathBuf { + manifest_dir().join("macos/Sources") +} + +#[cfg(target_os = "macos")] +fn generated_code_dir() -> PathBuf { + swift_source_dir().join("generated") +} + +#[cfg(target_os = "macos")] +fn swift_library_static_lib_dir() -> PathBuf { + let debug_or_release = if is_release_build() { + "release" + } else { + "debug" + }; + + manifest_dir().join(format!("macos/.build/{debug_or_release}")) } diff --git a/examples/notifications-demo/src-tauri/Cargo.lock b/examples/notifications-demo/src-tauri/Cargo.lock index fa16f29f..c2b65606 100644 --- a/examples/notifications-demo/src-tauri/Cargo.lock +++ b/examples/notifications-demo/src-tauri/Cargo.lock @@ -3431,6 +3431,49 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "swift-bridge" +version = "0.1.58" +source = "git+https://github.com/chinedufn/swift-bridge?rev=82b0885#82b08851a9a6ebeed8fd6084f1a41176d904fae5" +dependencies = [ + "once_cell", + "swift-bridge-build", + "swift-bridge-macro", + "tokio", +] + +[[package]] +name = "swift-bridge-build" +version = "0.1.58" +source = "git+https://github.com/chinedufn/swift-bridge?rev=82b0885#82b08851a9a6ebeed8fd6084f1a41176d904fae5" +dependencies = [ + "proc-macro2", + "swift-bridge-ir", + "syn 1.0.109", + "tempfile", +] + +[[package]] +name = "swift-bridge-ir" +version = "0.1.58" +source = "git+https://github.com/chinedufn/swift-bridge?rev=82b0885#82b08851a9a6ebeed8fd6084f1a41176d904fae5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "swift-bridge-macro" +version = "0.1.58" +source = "git+https://github.com/chinedufn/swift-bridge?rev=82b0885#82b08851a9a6ebeed8fd6084f1a41176d904fae5" +dependencies = [ + "proc-macro2", + "quote", + "swift-bridge-ir", + "syn 1.0.109", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -3695,6 +3738,8 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "swift-bridge", + "swift-bridge-build", "tauri", "tauri-plugin", "thiserror 2.0.17", diff --git a/examples/notifications-demo/src-tauri/gen/android/app/build.gradle.kts b/examples/notifications-demo/src-tauri/gen/android/app/build.gradle.kts index 324110dd..666e8979 100644 --- a/examples/notifications-demo/src-tauri/gen/android/app/build.gradle.kts +++ b/examples/notifications-demo/src-tauri/gen/android/app/build.gradle.kts @@ -1,8 +1,10 @@ import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("com.google.gms.google-services") id("rust") } @@ -30,7 +32,8 @@ android { isDebuggable = true isJniDebuggable = true isMinifyEnabled = false - packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + packaging { + jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") jniLibs.keepDebugSymbols.add("*/x86/*.so") jniLibs.keepDebugSymbols.add("*/x86_64/*.so") @@ -45,8 +48,10 @@ android { ) } } - kotlinOptions { - jvmTarget = "1.8" + kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_1_8 + } } buildFeatures { buildConfig = true diff --git a/examples/notifications-demo/src-tauri/gen/android/build.gradle.kts b/examples/notifications-demo/src-tauri/gen/android/build.gradle.kts index 588ffc7f..38e9b6ea 100644 --- a/examples/notifications-demo/src-tauri/gen/android/build.gradle.kts +++ b/examples/notifications-demo/src-tauri/gen/android/build.gradle.kts @@ -6,6 +6,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:8.13.2") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0") + classpath("com.google.gms:google-services:4.4.2") } } diff --git a/examples/notifications-demo/src-tauri/tauri.conf.json b/examples/notifications-demo/src-tauri/tauri.conf.json index e2032b74..a16899b2 100644 --- a/examples/notifications-demo/src-tauri/tauri.conf.json +++ b/examples/notifications-demo/src-tauri/tauri.conf.json @@ -30,6 +30,12 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "iOS": { + "minimumSystemVersion": "15.0" + }, + "macOS": { + "minimumSystemVersion": "13.0" + } } } diff --git a/examples/notifications-demo/src/routes/+page.svelte b/examples/notifications-demo/src/routes/+page.svelte index 7a762916..f124016f 100644 --- a/examples/notifications-demo/src/routes/+page.svelte +++ b/examples/notifications-demo/src/routes/+page.svelte @@ -52,7 +52,7 @@ let newChannelName = $state("App Notifications"); let newChannelDescription = $state("General app notifications"); - // Push notifications (mobile only) + // Push notifications let pushToken = $state(null); let pushRegistered = $state(false); @@ -76,7 +76,7 @@ pendingNotifications = await pending(); addLog(`Loaded ${pendingNotifications.length} pending notifications`); } catch (error) { - addLog(`Error loading pending: ${error}`); + addLog(`Error loading pending: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -88,7 +88,7 @@ activeNotifications = await active(); addLog(`Loaded ${activeNotifications.length} active notifications`); } catch (error) { - addLog(`Error loading active: ${error}`); + addLog(`Error loading active: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -100,7 +100,7 @@ notificationChannels = await channels(); addLog(`Loaded ${notificationChannels.length} channels`); } catch (error) { - addLog(`Error loading channels: ${error}`); + addLog(`Error loading channels: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -117,7 +117,7 @@ permissionStatus = permissionGranted ? "Granted" : "Not Granted"; addLog(`Permission status: ${permissionStatus}`); } catch (error) { - addLog(`Error checking permission: ${error}`); + addLog(`Error checking permission: ${error instanceof Error ? error.message : JSON.stringify(error)}`); permissionStatus = "Error"; } } @@ -132,12 +132,12 @@ permissionStatus = result; addLog(`Permission request result: ${result}`); } catch (error) { - addLog(`Error requesting permission: ${error}`); + addLog(`Error requesting permission: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } // ============================================================================ - // PUSH NOTIFICATIONS (Mobile Only) + // PUSH NOTIFICATIONS // ============================================================================ /** @@ -152,7 +152,6 @@ addLog(`📱 Push token: ${token.substring(0, 20)}...`); } catch (error) { addLog(`❌ Error registering for push: ${error}`); - addLog(`Note: Push notifications are only available on iOS and Android`); } } @@ -166,7 +165,7 @@ pushRegistered = false; addLog(`❌ Unregistered from push notifications`); } catch (error) { - addLog(`Error unregistering from push: ${error}`); + addLog(`Error unregistering from push: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -183,7 +182,7 @@ await navigator.clipboard.writeText(pushToken); addLog(`📋 Push token copied to clipboard`); } catch (error) { - addLog(`Error copying to clipboard: ${error}`); + addLog(`Error copying to clipboard: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -207,7 +206,7 @@ }); addLog(`Sent notification: "${basicTitle}"`); } catch (error) { - addLog(`Error sending notification: ${error}`); + addLog(`Error sending notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -229,7 +228,7 @@ }); addLog(`Sent notification with ID: ${notificationId}`); } catch (error) { - addLog(`Error sending notification: ${error}`); + addLog(`Error sending notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -260,7 +259,7 @@ ); await refreshPending(); } catch (error) { - addLog(`Error scheduling notification: ${error}`); + addLog(`Error scheduling notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -282,7 +281,7 @@ addLog("Scheduled recurring notification (every minute)"); await refreshPending(); } catch (error) { - addLog(`Error scheduling recurring notification: ${error}`); + addLog(`Error scheduling recurring notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -334,7 +333,7 @@ }); addLog("Sent notification with action buttons"); } catch (error) { - addLog(`Error sending interactive notification: ${error}`); + addLog(`Error sending interactive notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -363,7 +362,7 @@ }); addLog("Sent notification with large body text"); } catch (error) { - addLog(`Error sending large body notification: ${error}`); + addLog(`Error sending large body notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -391,7 +390,7 @@ }); addLog("Sent inbox-style notification"); } catch (error) { - addLog(`Error sending inbox notification: ${error}`); + addLog(`Error sending inbox notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -416,7 +415,7 @@ addLog(`Created channel: ${newChannelName} (${newChannelId})`); await refreshChannels(); } catch (error) { - addLog(`Error creating channel: ${error}`); + addLog(`Error creating channel: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -443,7 +442,7 @@ }); addLog(`Sent notification to channel: ${channelId}`); } catch (error) { - addLog(`Error sending to channel: ${error}`); + addLog(`Error sending to channel: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -456,7 +455,7 @@ addLog(`Deleted channel: ${channelId}`); await refreshChannels(); } catch (error) { - addLog(`Error deleting channel: ${error}`); + addLog(`Error deleting channel: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -473,7 +472,7 @@ addLog(`Cancelled pending notification: ${id}`); await refreshPending(); } catch (error) { - addLog(`Error cancelling notification: ${error}`); + addLog(`Error cancelling notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -486,7 +485,7 @@ addLog("Cancelled all pending notifications"); await refreshPending(); } catch (error) { - addLog(`Error cancelling all: ${error}`); + addLog(`Error cancelling all: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -499,7 +498,7 @@ addLog(`Removed active notification: ${id}`); await refreshActive(); } catch (error) { - addLog(`Error removing active notification: ${error}`); + addLog(`Error removing active notification: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -512,7 +511,7 @@ addLog("Removed all active notifications"); await refreshActive(); } catch (error) { - addLog(`Error removing all active: ${error}`); + addLog(`Error removing all active: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } } @@ -541,7 +540,7 @@ // Register event listeners try { - // Listen for incoming notifications (push notifications on mobile) + // Listen for incoming notifications const unlistenReceived = await onNotificationReceived((notification) => { addLog( `📨 Notification received: ${notification.title || "No title"}`, @@ -550,8 +549,8 @@ }); // Listen for action button clicks - const unlistenAction = await onAction((notification) => { - addLog(`🔘 Action performed on: ${notification.title || "No title"}`); + const unlistenAction = await onAction((data) => { + addLog(`🔘 Action performed - actionId: ${data.actionId}, title: ${data.notification?.title || "No title"}, input: ${data.inputValue || "none"}`); }); // Listen for notification clicks/taps @@ -572,7 +571,7 @@ unlistenClicked.unregister(); }; } catch (error) { - addLog(`Error setting up event listeners: ${error}`); + addLog(`Error setting up event listeners: ${error instanceof Error ? error.message : JSON.stringify(error)}`); } }); @@ -616,11 +615,10 @@
-

📱 2. Push Notifications (Mobile Only)

+

📱 2. Push Notifications

Register for push notifications to receive remote notifications via FCM - (Android) or APNs (iOS). This feature is only available on mobile - platforms. + (Android) or APNs (iOS/macOS).

@@ -666,9 +664,9 @@ Note: Push notifications require additional setup:
  • Android: Configure Firebase Cloud Messaging
  • -
  • iOS: Configure Apple Push Notification service
  • +
  • iOS/macOS: Configure Apple Push Notification service
  • - Desktop: Push notifications are not yet supported + Linux/Windows: Push notifications are not supported
diff --git a/guest-js/index.test.ts b/guest-js/index.test.ts index cb7f16f5..60518bfa 100644 --- a/guest-js/index.test.ts +++ b/guest-js/index.test.ts @@ -708,7 +708,9 @@ describe("Notification Functions", () => { await cancelAll(); - expect(mockInvoke).toHaveBeenCalledWith("plugin:notifications|cancel"); + expect(mockInvoke).toHaveBeenCalledWith( + "plugin:notifications|cancel_all", + ); }); }); @@ -810,7 +812,7 @@ describe("Notification Functions", () => { expect(mockInvoke).toHaveBeenCalledWith( "plugin:notifications|create_channel", - channel, + { channel }, ); }); @@ -833,7 +835,7 @@ describe("Notification Functions", () => { expect(mockInvoke).toHaveBeenCalledWith( "plugin:notifications|create_channel", - channel, + { channel }, ); }); }); @@ -872,7 +874,7 @@ describe("Notification Functions", () => { const result = await channels(); expect(mockInvoke).toHaveBeenCalledWith( - "plugin:notifications|listChannels", + "plugin:notifications|list_channels", ); expect(result).toEqual(mockChannels); }); diff --git a/guest-js/index.ts b/guest-js/index.ts index 11da7413..a7c17a90 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -560,7 +560,7 @@ async function cancel(notifications: number[]): Promise { * @returns A promise indicating the success or failure of the operation. */ async function cancelAll(): Promise { - await invoke("plugin:notifications|cancel"); + await invoke("plugin:notifications|cancel_all"); } /** @@ -629,7 +629,7 @@ async function removeAllActive(): Promise { * @returns A promise indicating the success or failure of the operation. */ async function createChannel(channel: Channel): Promise { - await invoke("plugin:notifications|create_channel", { ...channel }); + await invoke("plugin:notifications|create_channel", { channel }); } /** @@ -659,7 +659,7 @@ async function removeChannel(id: string): Promise { * @returns A promise resolving to the list of notification channels. */ async function channels(): Promise { - return await invoke("plugin:notifications|listChannels"); + return await invoke("plugin:notifications|list_channels"); } /** diff --git a/ios/Sources/NotificationHandler.swift b/ios/Sources/NotificationHandler.swift index e33e3841..53f1b1ab 100644 --- a/ios/Sources/NotificationHandler.swift +++ b/ios/Sources/NotificationHandler.swift @@ -167,11 +167,16 @@ public class NotificationHandler: NSObject, NotificationHandlerProtocol { ) } - func toPendingNotification(_ request: UNNotificationRequest) -> PendingNotification { + func toPendingNotification(_ request: UNNotificationRequest) -> PendingNotification? { + guard let notification = notificationsMap[request.identifier], + let schedule = notification.schedule else { + return nil + } return PendingNotification( id: Int(request.identifier) ?? -1, title: request.content.title, - body: request.content.body + body: request.content.body, + schedule: schedule ) } } @@ -180,6 +185,7 @@ struct PendingNotification: Encodable { let id: Int let title: String let body: String + let schedule: NotificationSchedule } struct ActiveNotification: Encodable { diff --git a/ios/Sources/NotificationPlugin.swift b/ios/Sources/NotificationPlugin.swift index c2469976..6d126482 100644 --- a/ios/Sources/NotificationPlugin.swift +++ b/ios/Sources/NotificationPlugin.swift @@ -22,7 +22,7 @@ enum ShowNotificationError: LocalizedError { } } -enum ScheduleEveryKind: String, Decodable { +enum ScheduleEveryKind: String, Codable { case year case month case twoWeeks @@ -33,7 +33,7 @@ enum ScheduleEveryKind: String, Decodable { case second } -struct ScheduleInterval: Decodable { +struct ScheduleInterval: Codable { var year: Int? var month: Int? var day: Int? @@ -43,7 +43,7 @@ struct ScheduleInterval: Decodable { var second: Int? } -enum NotificationSchedule: Decodable { +enum NotificationSchedule: Codable { case at(date: String, repeating: Bool) case interval(interval: ScheduleInterval) case every(interval: ScheduleEveryKind, count: Int) @@ -162,10 +162,10 @@ class NotificationPlugin: Plugin { let notificationManager = NotificationManager() #if ENABLE_PUSH_NOTIFICATIONS - // Completion handler for push token registration - private var pushTokenCompletion: ((Result) -> Void)? - private let pushTokenTimeout: TimeInterval = 10.0 - private var pushTokenTimer: Timer? + // Completion handler for push token registration + private var pushTokenCompletion: ((Result) -> Void)? + private let pushTokenTimeout: TimeInterval = 10.0 + private var pushTokenTimer: Timer? #endif override init() { @@ -178,11 +178,11 @@ class NotificationPlugin: Plugin { super.load(webview: webview) #if ENABLE_PUSH_NOTIFICATIONS - // Store reference to this plugin for event triggering - AppDelegateSwizzler.plugin = self + // Store reference to this plugin for event triggering + AppDelegateSwizzler.plugin = self - // swizzle UIApplicationDelegate push methods - AppDelegateSwizzler.swizzlePushCallbacks() + // swizzle UIApplicationDelegate push methods + AppDelegateSwizzler.swizzlePushCallbacks() #endif } @@ -221,91 +221,93 @@ class NotificationPlugin: Plugin { @objc public func registerForPushNotifications(_ invoke: Invoke) { #if ENABLE_PUSH_NOTIFICATIONS - // First request notification permissions - notificationHandler.requestPermissions { [weak self] granted, error in - guard error == nil else { - invoke.reject(error!.localizedDescription) - return - } + // First request notification permissions + notificationHandler.requestPermissions { [weak self] granted, error in + guard error == nil else { + invoke.reject(error!.localizedDescription) + return + } - self?.registerForPushNotifications { result in - switch result { - case .success(let token): - invoke.resolve(["deviceToken": token]) - case .failure(let error): - invoke.reject(error.localizedDescription) + self?.registerForPushNotifications { result in + switch result { + case .success(let token): + invoke.resolve(["deviceToken": token]) + case .failure(let error): + invoke.reject(error.localizedDescription) + } } } - } #else - invoke.reject("Push notifications are disabled in this build") + invoke.reject("Push notifications are disabled in this build") #endif } @objc public func unregisterForPushNotifications(_ invoke: Invoke) { #if ENABLE_PUSH_NOTIFICATIONS - DispatchQueue.main.async { - UIApplication.shared.unregisterForRemoteNotifications() - invoke.resolve() - } + DispatchQueue.main.async { + UIApplication.shared.unregisterForRemoteNotifications() + invoke.resolve() + } #else - invoke.reject("Push notifications are disabled in this build") + invoke.reject("Push notifications are disabled in this build") #endif } #if ENABLE_PUSH_NOTIFICATIONS - private func registerForPushNotifications(completion: @escaping (Result) -> Void) { - // Store completion for later - self.pushTokenCompletion = completion - - // Set up timeout - self.pushTokenTimer?.invalidate() - self.pushTokenTimer = Timer.scheduledTimer(withTimeInterval: pushTokenTimeout, repeats: false) { [weak self] _ in - self?.handlePushTokenTimeout() - } + private func registerForPushNotifications(completion: @escaping (Result) -> Void) + { + // Store completion for later + self.pushTokenCompletion = completion + + // Set up timeout + self.pushTokenTimer?.invalidate() + self.pushTokenTimer = Timer.scheduledTimer(withTimeInterval: pushTokenTimeout, repeats: false) + { [weak self] _ in + self?.handlePushTokenTimeout() + } - // Register for remote notifications - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() + // Register for remote notifications + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } } - } - private func handlePushTokenTimeout() { - pushTokenTimer?.invalidate() - pushTokenTimer = nil - - if let completion = pushTokenCompletion { - pushTokenCompletion = nil - let error = NSError( - domain: "NotificationPlugin", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Timeout waiting for device token"] - ) - completion(.failure(error)) + private func handlePushTokenTimeout() { + pushTokenTimer?.invalidate() + pushTokenTimer = nil + + if let completion = pushTokenCompletion { + pushTokenCompletion = nil + let error = NSError( + domain: "NotificationPlugin", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Timeout waiting for device token"] + ) + completion(.failure(error)) + } } - } - // Called by AppDelegateSwizzler when token is received - func handlePushTokenReceived(_ token: String) { - pushTokenTimer?.invalidate() - pushTokenTimer = nil + // Called by AppDelegateSwizzler when token is received + func handlePushTokenReceived(_ token: String) { + pushTokenTimer?.invalidate() + pushTokenTimer = nil - if let completion = pushTokenCompletion { - pushTokenCompletion = nil - completion(.success(token)) + if let completion = pushTokenCompletion { + pushTokenCompletion = nil + completion(.success(token)) + } } - } - // Called by AppDelegateSwizzler when registration fails - func handlePushTokenError(_ error: Error) { - pushTokenTimer?.invalidate() - pushTokenTimer = nil + // Called by AppDelegateSwizzler when registration fails + func handlePushTokenError(_ error: Error) { + pushTokenTimer?.invalidate() + pushTokenTimer = nil - if let completion = pushTokenCompletion { - pushTokenCompletion = nil - completion(.failure(error)) + if let completion = pushTokenCompletion { + pushTokenCompletion = nil + completion(.failure(error)) + } } - } #endif @objc public override func checkPermissions(_ invoke: Invoke) { @@ -336,6 +338,11 @@ class NotificationPlugin: Plugin { invoke.resolve() } + @objc func cancelAll(_ invoke: Invoke) { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + invoke.resolve() + } + @objc func getPending(_ invoke: Invoke) { UNUserNotificationCenter.current().getPendingNotificationRequests(completionHandler: { (notifications) in @@ -379,18 +386,6 @@ class NotificationPlugin: Plugin { }) } - @objc func createChannel(_ invoke: Invoke) { - invoke.reject("not implemented") - } - - @objc func deleteChannel(_ invoke: Invoke) { - invoke.reject("not implemented") - } - - @objc func listChannels(_ invoke: Invoke) { - invoke.reject("not implemented") - } - @objc func setClickListenerActive(_ invoke: Invoke) { do { let args = try invoke.parseArgs(SetClickListenerActiveArgs.self) @@ -400,7 +395,6 @@ class NotificationPlugin: Plugin { invoke.reject(error.localizedDescription) } } - } @_cdecl("init_plugin_notification") diff --git a/ios/Tests/PluginTests/PluginTests.swift b/ios/Tests/PluginTests/PluginTests.swift index 5453b38c..722d0ece 100644 --- a/ios/Tests/PluginTests/PluginTests.swift +++ b/ios/Tests/PluginTests/PluginTests.swift @@ -505,7 +505,8 @@ final class NotificationTests: XCTestCase { // MARK: - Data Structure Tests func testPendingNotificationEncoding() throws { - let pending = PendingNotification(id: 1, title: "Test", body: "Body") + let schedule = NotificationSchedule.every(interval: .day, count: 1) + let pending = PendingNotification(id: 1, title: "Test", body: "Body", schedule: schedule) let encoder = JSONEncoder() let data = try encoder.encode(pending) @@ -579,7 +580,7 @@ final class NotificationTests: XCTestCase { // MARK: - NotificationHandler Tests - func testNotificationHandlerToPendingNotification() { + func testNotificationHandlerToPendingNotificationReturnsNilForUnknown() { let handler = NotificationHandler() let content = UNMutableNotificationContent() content.title = "Test Title" @@ -591,11 +592,47 @@ final class NotificationTests: XCTestCase { trigger: nil ) + // Should return nil since no notification was saved with this identifier + let pending = handler.toPendingNotification(request) + XCTAssertNil(pending) + } + + func testNotificationHandlerToPendingNotificationWithSavedNotification() { + let handler = NotificationHandler() + let schedule = NotificationSchedule.every(interval: .day, count: 1) + let notification = Notification( + id: 123, + title: "Test Title", + body: "Test Body", + extra: nil, + schedule: schedule, + attachments: nil, + sound: nil, + group: nil, + actionTypeId: nil, + summary: nil, + silent: nil + ) + + // Save the notification first + handler.saveNotification("123", notification) + + let content = UNMutableNotificationContent() + content.title = "Test Title" + content.body = "Test Body" + + let request = UNNotificationRequest( + identifier: "123", + content: content, + trigger: nil + ) + let pending = handler.toPendingNotification(request) - XCTAssertEqual(pending.id, 123) - XCTAssertEqual(pending.title, "Test Title") - XCTAssertEqual(pending.body, "Test Body") + XCTAssertNotNil(pending) + XCTAssertEqual(pending?.id, 123) + XCTAssertEqual(pending?.title, "Test Title") + XCTAssertEqual(pending?.body, "Test Body") } // MARK: - makeAttachments Tests diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 00000000..e786f25f --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,14 @@ +.DS_Store +/.build +/.swiftpm +/Packages +/*.xcodeproj +/*.xcresult +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +Package.resolved +generated/ +.push-notifications-enabled diff --git a/macos/Package.swift b/macos/Package.swift new file mode 100644 index 00000000..a861c687 --- /dev/null +++ b/macos/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version:5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription +import Foundation + +// Check if push notifications feature is enabled via marker file from Rust build +let enablePushNotifications = FileManager.default.fileExists( + atPath: URL(fileURLWithPath: #file).deletingLastPathComponent() + .appendingPathComponent(".push-notifications-enabled").path +) + +var swiftSettings: [SwiftSetting] = [ + .unsafeFlags([ + "-import-objc-header", "\(Context.packageDirectory)/Sources/bridging-header.h", + "-disable-bridging-pch" + ]) +] +if enablePushNotifications { + swiftSettings.append(.define("ENABLE_PUSH_NOTIFICATIONS")) +} + +let package = Package( + name: "tauri-plugin-notifications", + platforms: [ + .macOS(.v13), + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "tauri-plugin-notifications", + type: .static, + targets: ["tauri-plugin-notifications"]), + ], + dependencies: [ ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "tauri-plugin-notifications", + dependencies: [ ], + path: "Sources", + swiftSettings: swiftSettings, + linkerSettings: [ + .linkedFramework("UserNotifications") + ] + ), + .testTarget( + name: "PluginTests", + dependencies: ["tauri-plugin-notifications"], + swiftSettings: swiftSettings + ), + ] +) diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 00000000..1cf8c044 --- /dev/null +++ b/macos/README.md @@ -0,0 +1,3 @@ +# Tauri Plugin notifications + +A description of this package. diff --git a/macos/Sources/AppDelegateSwizzler.swift b/macos/Sources/AppDelegateSwizzler.swift new file mode 100644 index 00000000..b6d02f51 --- /dev/null +++ b/macos/Sources/AppDelegateSwizzler.swift @@ -0,0 +1,91 @@ +import AppKit +import ObjectiveC.runtime + +#if ENABLE_PUSH_NOTIFICATIONS + +enum AppDelegateSwizzler { + static weak var plugin: NotificationPlugin? + + static func swizzlePushCallbacks() { + guard let delegate = NSApplication.shared.delegate else { return } + + // didRegisterForRemoteNotificationsWithDeviceToken + swizzle( + type(of: delegate), + #selector(NSApplicationDelegate.application(_:didRegisterForRemoteNotificationsWithDeviceToken:)), + #selector(PushForwarder.ta_application(_:didRegisterForRemoteNotificationsWithDeviceToken:)) + ) + + // didFailToRegisterForRemoteNotificationsWithError + swizzle( + type(of: delegate), + #selector(NSApplicationDelegate.application(_:didFailToRegisterForRemoteNotificationsWithError:)), + #selector(PushForwarder.ta_application(_:didFailToRegisterForRemoteNotificationsWithError:)) + ) + + // didReceiveRemoteNotification + swizzle( + type(of: delegate), + #selector(NSApplicationDelegate.application(_:didReceiveRemoteNotification:)), + #selector(PushForwarder.ta_application(_:didReceiveRemoteNotification:)) + ) + } + + private static func swizzle(_ cls: AnyClass, _ original: Selector, _ replacement: Selector) { + guard + let swizzledMethod = class_getInstanceMethod(PushForwarder.self, replacement) + else { return } + + if let originalMethod = class_getInstanceMethod(cls, original) { + method_exchangeImplementations(originalMethod, swizzledMethod) + } else { + class_addMethod( + cls, + original, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + } + } +} + +// Extension to hold swizzled implementations (silences "nearly matches" warnings) +final class PushForwarder: NSObject { + @objc func ta_application(_ application: NSApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let hex = deviceToken.map { String(format: "%02x", $0) }.joined() + + AppDelegateSwizzler.plugin?.handlePushTokenReceived(hex) + try? AppDelegateSwizzler.plugin?.trigger("push-token", data: ["token": hex]) + + if responds(to: #selector(ta_application(_:didRegisterForRemoteNotificationsWithDeviceToken:))) { + self.ta_application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + } + } + + @objc func ta_application(_ application: NSApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + AppDelegateSwizzler.plugin?.handlePushTokenError(error) + try? AppDelegateSwizzler.plugin?.trigger("push-error", data: ["message": error.localizedDescription]) + + if responds(to: #selector(ta_application(_:didFailToRegisterForRemoteNotificationsWithError:))) { + self.ta_application(application, didFailToRegisterForRemoteNotificationsWithError: error) + } + } + + @objc func ta_application(_ application: NSApplication, + didReceiveRemoteNotification userInfo: [String: Any]) { + // Convert to [String: String] for Encodable + var stringDict: [String: String] = [:] + for (key, value) in userInfo { + stringDict[key] = String(describing: value) + } + try? AppDelegateSwizzler.plugin?.trigger("push-message", data: stringDict) + + if responds(to: #selector(ta_application(_:didReceiveRemoteNotification:))) { + self.ta_application(application, didReceiveRemoteNotification: userInfo) + } + } +} + +#endif diff --git a/macos/Sources/Notification.swift b/macos/Sources/Notification.swift new file mode 100644 index 00000000..d65be19e --- /dev/null +++ b/macos/Sources/Notification.swift @@ -0,0 +1,234 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import UserNotifications + +enum NotificationError: LocalizedError { + case triggerRepeatIntervalTooShort + case attachmentFileNotFound(path: String) + case attachmentUnableToCreate(String) + case pastScheduledTime + case invalidDate(String) + + var errorDescription: String? { + switch self { + case .triggerRepeatIntervalTooShort: + return "Schedule interval too short, must be a least 1 minute" + case .attachmentFileNotFound(let path): + return "Unable to find file \(path) for attachment" + case .attachmentUnableToCreate(let error): + return "Failed to create attachment: \(error)" + case .pastScheduledTime: + return "Scheduled time must be *after* current time" + case .invalidDate(let date): + return "Could not parse date \(date)" + } + } +} + +func makeNotificationContent(_ notification: Notification) throws -> UNNotificationContent { + let content = UNMutableNotificationContent() + content.title = NSString.localizedUserNotificationString( + forKey: notification.title, arguments: nil) + if let body = notification.body { + content.body = NSString.localizedUserNotificationString( + forKey: body, + arguments: nil) + } + + var userInfo: [AnyHashable: Any] = [:] + + if let extra = notification.extra { + userInfo["__EXTRA__"] = extra + } + + content.userInfo = userInfo + + if let actionTypeId = notification.actionTypeId { + content.categoryIdentifier = actionTypeId + } + + if let threadIdentifier = notification.group { + content.threadIdentifier = threadIdentifier + } + + if let summaryArgument = notification.summary { + content.summaryArgument = summaryArgument + } + + if let sound = notification.sound { + content.sound = UNNotificationSound(named: UNNotificationSoundName(sound)) + } + + if let attachments = notification.attachments { + content.attachments = try makeAttachments(attachments) + } + + return content +} + +func makeAttachments(_ attachments: [NotificationAttachment]) throws -> [UNNotificationAttachment] { + var createdAttachments = [UNNotificationAttachment]() + + for attachment in attachments { + + guard let urlObject = makeAttachmentUrl(attachment.url) else { + throw NotificationError.attachmentFileNotFound(path: attachment.url) + } + + let options = attachment.options != nil ? makeAttachmentOptions(attachment.options!) : nil + + do { + let newAttachment = try UNNotificationAttachment( + identifier: attachment.id, url: urlObject, options: options) + createdAttachments.append(newAttachment) + } catch { + throw NotificationError.attachmentUnableToCreate(error.localizedDescription) + } + } + + return createdAttachments +} + +func makeAttachmentUrl(_ path: String) -> URL? { + return URL(string: path) +} + +func makeAttachmentOptions(_ options: NotificationAttachmentOptions) -> [AnyHashable: Any] { + var opts: [AnyHashable: Any] = [:] + + if let value = options.iosUNNotificationAttachmentOptionsTypeHintKey { + opts[UNNotificationAttachmentOptionsTypeHintKey] = value + } + if let value = options.iosUNNotificationAttachmentOptionsThumbnailHiddenKey { + opts[UNNotificationAttachmentOptionsThumbnailHiddenKey] = value + } + if let value = options.iosUNNotificationAttachmentOptionsThumbnailClippingRectKey { + opts[UNNotificationAttachmentOptionsThumbnailClippingRectKey] = value + } + if let value = options + .iosUNNotificationAttachmentOptionsThumbnailTimeKey + + { + opts[UNNotificationAttachmentOptionsThumbnailTimeKey] = value + } + return opts +} + +func handleScheduledNotification(_ schedule: NotificationSchedule) throws + -> UNNotificationTrigger? +{ + switch schedule { + case .at(let date, let repeating): + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + + if let at = dateFormatter.date(from: date) { + let dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: at) + + if dateInfo.date! < Date() { + // TODO: + //Logger.debug("Scheduled time is in the past: \(dateInfo.date!) < \(Date())") + throw NotificationError.pastScheduledTime + } + + let dateInterval = DateInterval(start: Date(), end: dateInfo.date!) + + // Notifications that repeat have to be at least a minute between each other + if repeating && dateInterval.duration < 60 { + throw NotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger( + timeInterval: dateInterval.duration, repeats: repeating) + + } else { + throw NotificationError.invalidDate(date) + } + case .interval(let interval): + let dateComponents = getDateComponents(interval) + return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + case .every(let interval, let count): + if let repeatDateInterval = getRepeatDateInterval(interval, count) { + // Notifications that repeat have to be at least a minute between each other + if repeatDateInterval.duration < 60 { + throw NotificationError.triggerRepeatIntervalTooShort + } + + return UNTimeIntervalNotificationTrigger( + timeInterval: repeatDateInterval.duration, repeats: true) + } + } + + return nil +} + +/// Given our schedule format, return a DateComponents object +/// that only contains the components passed in. + +func getDateComponents(_ at: ScheduleInterval) -> DateComponents { + // var dateInfo = Calendar.current.dateComponents(in: TimeZone.current, from: Date()) + // dateInfo.calendar = Calendar.current + var dateInfo = DateComponents() + + if let year = at.year { + dateInfo.year = year + } + if let month = at.month { + dateInfo.month = month + } + if let day = at.day { + dateInfo.day = day + } + if let hour = at.hour { + dateInfo.hour = hour + } + if let minute = at.minute { + dateInfo.minute = minute + } + if let second = at.second { + dateInfo.second = second + } + if let weekday = at.weekday { + dateInfo.weekday = weekday + } + return dateInfo +} + +/// Compute the difference between the string representation of a date +/// interval and today. For example, if every is "month", then we +/// return the interval between today and a month from today. + +func getRepeatDateInterval(_ every: ScheduleEveryKind, _ count: Int) -> DateInterval? { + let cal = Calendar.current + let now = Date() + switch every { + case .year: + let newDate = cal.date(byAdding: .year, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .month: + let newDate = cal.date(byAdding: .month, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .twoWeeks: + let newDate = cal.date(byAdding: .weekOfYear, value: 2 * count, to: now)! + return DateInterval(start: now, end: newDate) + case .week: + let newDate = cal.date(byAdding: .weekOfYear, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .day: + let newDate = cal.date(byAdding: .day, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .hour: + let newDate = cal.date(byAdding: .hour, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .minute: + let newDate = cal.date(byAdding: .minute, value: count, to: now)! + return DateInterval(start: now, end: newDate) + case .second: + let newDate = cal.date(byAdding: .second, value: count, to: now)! + return DateInterval(start: now, end: newDate) + } +} diff --git a/macos/Sources/NotificationCategory.swift b/macos/Sources/NotificationCategory.swift new file mode 100644 index 00000000..246ae698 --- /dev/null +++ b/macos/Sources/NotificationCategory.swift @@ -0,0 +1,92 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import UserNotifications + +internal func makeCategories(_ actionTypes: [ActionType]) { + var createdCategories = [UNNotificationCategory]() + + let generalCategory = UNNotificationCategory( + identifier: "GENERAL", + actions: [], + intentIdentifiers: [], + options: .customDismissAction) + + createdCategories.append(generalCategory) + for type in actionTypes { + let newActions = makeActions(type.actions) + + // Create the custom actions for the TIMER_EXPIRED category. + var newCategory: UNNotificationCategory? + + newCategory = UNNotificationCategory( + identifier: type.id, + actions: newActions, + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: type.hiddenBodyPlaceholder ?? "", + options: makeCategoryOptions(type)) + + createdCategories.append(newCategory!) + } + + let center = UNUserNotificationCenter.current() + center.setNotificationCategories(Set(createdCategories)) +} + +func makeActions(_ actions: [Action]) -> [UNNotificationAction] { + var createdActions = [UNNotificationAction]() + + for action in actions { + var newAction: UNNotificationAction + if action.input ?? false { + if action.inputButtonTitle != nil { + newAction = UNTextInputNotificationAction( + identifier: action.id, + title: action.title, + options: makeActionOptions(action), + textInputButtonTitle: action.inputButtonTitle ?? "", + textInputPlaceholder: action.inputPlaceholder ?? "") + } else { + newAction = UNTextInputNotificationAction( + identifier: action.id, title: action.title, options: makeActionOptions(action)) + } + } else { + // Create the custom actions for the TIMER_EXPIRED category. + newAction = UNNotificationAction( + identifier: action.id, + title: action.title, + options: makeActionOptions(action)) + } + createdActions.append(newAction) + } + + return createdActions +} + +func makeActionOptions(_ action: Action) -> UNNotificationActionOptions { + if action.foreground ?? false { + return .foreground + } + if action.destructive ?? false { + return .destructive + } + if action.requiresAuthentication ?? false { + return .authenticationRequired + } + return UNNotificationActionOptions(rawValue: 0) +} + +func makeCategoryOptions(_ type: ActionType) -> UNNotificationCategoryOptions { + if type.customDismissAction ?? false { + return .customDismissAction + } + if type.hiddenPreviewsShowTitle ?? false { + return .hiddenPreviewsShowTitle + } + if type.hiddenPreviewsShowSubtitle ?? false { + return .hiddenPreviewsShowSubtitle + } + + return UNNotificationCategoryOptions(rawValue: 0) +} diff --git a/macos/Sources/NotificationHandler.swift b/macos/Sources/NotificationHandler.swift new file mode 100644 index 00000000..2195c1cf --- /dev/null +++ b/macos/Sources/NotificationHandler.swift @@ -0,0 +1,210 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import UserNotifications + +public class NotificationHandler: NSObject, NotificationHandlerProtocol { + + weak var plugin: NotificationPlugin? + + private var notificationsMap = [String: Notification]() + private var hasClickedListener = false + private var pendingNotificationClick: NotificationClickedData? = nil + + internal func saveNotification(_ key: String, _ notification: Notification) { + notificationsMap.updateValue(notification, forKey: key) + } + + func setClickListenerActive(_ active: Bool) { + hasClickedListener = active + + if active, let pending = pendingNotificationClick { + pendingNotificationClick = nil + try? self.plugin?.trigger("notificationClicked", data: pending) + } + } + + public func requestPermissions() async throws -> Bool { + let center = UNUserNotificationCenter.current() + return try await center.requestAuthorization(options: [.badge, .alert, .sound]) + } + + public func checkPermissions() async -> UNNotificationSettings { + let center = UNUserNotificationCenter.current() + return await center.notificationSettings() + } + + public func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions { + // Trigger notification event for both local and push notifications + if let notificationData = toActiveNotification(notification.request) { + try? self.plugin?.trigger("notification", data: notificationData) + } else { + let notificationData = toReceivedNotification(notification.request) + try? self.plugin?.trigger("notification", data: notificationData) + } + + // For push notifications in foreground, don't show system notification + // (only trigger event so developer can handle it) + let isPushNotification = notification.request.trigger?.isKind(of: UNPushNotificationTrigger.self) == true + if isPushNotification { + return UNNotificationPresentationOptions.init(rawValue: 0) + } + + // For local notifications, check if silent + if let options: Notification = notificationsMap[notification.request.identifier] { + if options.silent ?? false { + return UNNotificationPresentationOptions.init(rawValue: 0) + } + } + + return [ + .badge, + .sound, + .alert, + ] + } + + /// Convert notification request to ReceivedNotification (for push notifications not in map) + private func toReceivedNotification(_ request: UNNotificationRequest) -> ReceivedNotificationData { + let content = request.content + var extra: [String: String]? = nil + + if !content.userInfo.isEmpty { + extra = [:] + for (key, value) in content.userInfo { + if let keyStr = key as? String, let valStr = value as? String { + extra?[keyStr] = valStr + } + } + if extra?.isEmpty == true { + extra = nil + } + } + + return ReceivedNotificationData( + id: Int(request.identifier) ?? -1, + title: content.title, + body: content.body, + extra: extra + ) + } + + public func didReceive(response: UNNotificationResponse) { + let originalNotificationRequest = response.notification.request + let actionId = response.actionIdentifier + + var actionIdValue: String + // We turn the two default actions (open/dismiss) into generic strings + if actionId == UNNotificationDefaultActionIdentifier { + actionIdValue = "tap" + } else if actionId == UNNotificationDismissActionIdentifier { + actionIdValue = "dismiss" + } else { + actionIdValue = actionId + } + + var inputValue: String? = nil + // If the type of action was for an input type, get the value + if let inputType = response as? UNTextInputNotificationResponse { + inputValue = inputType.userText + } + + // Only trigger actionPerformed for local notifications (those in our map) + if let activeNotification = toActiveNotification(originalNotificationRequest) { + try? self.plugin?.trigger( + "actionPerformed", + data: ReceivedNotification( + actionId: actionIdValue, + inputValue: inputValue, + notification: activeNotification + )) + } + + // Handle notificationClicked for both local and push notifications + let id = Int(originalNotificationRequest.identifier) ?? -1 + let userInfo = originalNotificationRequest.content.userInfo + var dataDict: [String: String]? = nil + if !userInfo.isEmpty { + dataDict = [:] + for (key, value) in userInfo { + if let keyStr = key as? String, let valStr = value as? String { + dataDict?[keyStr] = valStr + } + } + if dataDict?.isEmpty == true { + dataDict = nil + } + } + + let clickedData = NotificationClickedData(id: id, data: dataDict) + + if hasClickedListener { + // Listener exists, trigger directly + try? self.plugin?.trigger("notificationClicked", data: clickedData) + } else { + // No listener (cold-start), store for later + pendingNotificationClick = clickedData + } + } + + func toActiveNotification(_ request: UNNotificationRequest) -> ActiveNotification? { + guard let notificationRequest = notificationsMap[request.identifier] else { + return nil + } + return ActiveNotification( + id: Int(request.identifier) ?? -1, + title: request.content.title, + body: request.content.body, + sound: notificationRequest.sound ?? "", + actionTypeId: request.content.categoryIdentifier, + attachments: notificationRequest.attachments + ) + } + + func toPendingNotification(_ request: UNNotificationRequest) -> PendingNotification? { + guard let notification = notificationsMap[request.identifier] else { + return nil + } + return PendingNotification( + id: Int(request.identifier) ?? -1, + title: request.content.title, + body: request.content.body, + schedule: notification.schedule! + ) + } +} + +struct PendingNotification: Encodable { + let id: Int + let title: String + let body: String + let schedule: NotificationSchedule +} + +struct ActiveNotification: Encodable { + let id: Int + let title: String + let body: String + let sound: String + let actionTypeId: String + let attachments: [NotificationAttachment]? +} + +struct ReceivedNotification: Encodable { + let actionId: String + let inputValue: String? + let notification: ActiveNotification +} + +struct NotificationClickedData: Encodable { + let id: Int + let data: [String: String]? +} + +struct ReceivedNotificationData: Encodable { + let id: Int + let title: String + let body: String + let extra: [String: String]? +} diff --git a/macos/Sources/NotificationManager.swift b/macos/Sources/NotificationManager.swift new file mode 100644 index 00000000..e82c14d1 --- /dev/null +++ b/macos/Sources/NotificationManager.swift @@ -0,0 +1,41 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +import Foundation +import UserNotifications + +@objc public protocol NotificationHandlerProtocol { + func willPresent(notification: UNNotification) -> UNNotificationPresentationOptions + func didReceive(response: UNNotificationResponse) +} + +@objc public class NotificationManager: NSObject, UNUserNotificationCenterDelegate { + public weak var notificationHandler: NotificationHandlerProtocol? + + override init() { + super.init() + let center = UNUserNotificationCenter.current() + center.delegate = self + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Call willPresent for both local and push notifications + let presentationOptions = notificationHandler?.willPresent(notification: notification) + completionHandler(presentationOptions ?? []) + } + + public func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + notificationHandler?.didReceive(response: response) + + completionHandler() + } +} diff --git a/macos/Sources/NotificationPlugin.swift b/macos/Sources/NotificationPlugin.swift new file mode 100644 index 00000000..fe7bee9a --- /dev/null +++ b/macos/Sources/NotificationPlugin.swift @@ -0,0 +1,392 @@ +import AppKit +import UserNotifications + +extension FFIResult: Error {} + +typealias JsonObject = [String: Any] + +enum ScheduleEveryKind: String, Codable { + case year + case month + case twoWeeks + case week + case day + case hour + case minute + case second +} + +struct ScheduleInterval: Codable { + var year: Int? + var month: Int? + var day: Int? + var weekday: Int? + var hour: Int? + var minute: Int? + var second: Int? +} + +enum NotificationSchedule: Codable { + case at(date: String, repeating: Bool) + case interval(interval: ScheduleInterval) + case every(interval: ScheduleEveryKind, count: Int) +} + +struct NotificationAttachmentOptions: Codable { + let iosUNNotificationAttachmentOptionsTypeHintKey: String? + let iosUNNotificationAttachmentOptionsThumbnailHiddenKey: String? + let iosUNNotificationAttachmentOptionsThumbnailClippingRectKey: String? + let iosUNNotificationAttachmentOptionsThumbnailTimeKey: String? +} + +struct NotificationAttachment: Codable { + let id: String + let url: String + let options: NotificationAttachmentOptions? +} + +struct Notification: Decodable { + let id: Int + var title: String + var body: String? + var extra: [String: String]? + var schedule: NotificationSchedule? + var attachments: [NotificationAttachment]? + var sound: String? + var group: String? + var actionTypeId: String? + var summary: String? + var silent: Bool? +} + +struct CancelArgs: Decodable { + let notifications: [Int] +} + +struct Action: Decodable { + let id: String + let title: String + var requiresAuthentication: Bool? + var foreground: Bool? + var destructive: Bool? + var input: Bool? + var inputButtonTitle: String? + var inputPlaceholder: String? +} + +struct ActionType: Decodable { + let id: String + let actions: [Action] + var hiddenPreviewsBodyPlaceholder: String? + var customDismissAction: Bool? + var allowInCarPlay: Bool? + var hiddenPreviewsShowTitle: Bool? + var hiddenPreviewsShowSubtitle: Bool? + var hiddenBodyPlaceholder: String? +} + +struct RegisterActionTypesArgs: Decodable { + let types: [ActionType] +} + +struct SetClickListenerActiveArgs: Decodable { + let active: Bool +} + +struct RemoveActiveNotification: Decodable { + let id: Int +} + +struct RemoveActiveArgs: Decodable { + let notifications: [RemoveActiveNotification] +} + +extension RustString { + func decode(_ type: T.Type) throws(FFIResult) -> T { + guard let data = self.toString().data(using: .utf8) else { + throw FFIResult.Err(RustString("Invalid UTF-8 string")) + } + do { + return try JSONDecoder().decode(type, from: data) + } catch { + throw FFIResult.Err(RustString("Failed to decode JSON: \(error.localizedDescription)")) + } + } +} + +extension Encodable { + func toJSONString() throws(FFIResult) -> String { + do { + let jsonData = try JSONEncoder().encode(self) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw FFIResult.Err(RustString("Failed to encode to JSON string")) + } + return jsonString + } catch let error as FFIResult { + throw error + } catch { + throw FFIResult.Err(RustString("Failed to encode to JSON: \(error.localizedDescription)")) + } + } +} + +func showNotification(notification: Notification) async throws(FFIResult) -> UNNotificationRequest { + var content: UNNotificationContent + do { + content = try makeNotificationContent(notification) + } catch { + throw FFIResult.Err(RustString(error.localizedDescription)) + } + + var trigger: UNNotificationTrigger? + + do { + if let schedule = notification.schedule { + try trigger = handleScheduledNotification(schedule) + } + } catch { + throw FFIResult.Err(RustString(error.localizedDescription)) + } + + // Schedule the request. + let request = UNNotificationRequest( + identifier: "\(notification.id)", content: content, trigger: trigger + ) + + let center = UNUserNotificationCenter.current() + do { + try await center.add(request) + } catch { + throw FFIResult.Err(RustString(error.localizedDescription)) + } + + return request +} + +class NotificationPlugin { + let notificationHandler = NotificationHandler() + let notificationManager = NotificationManager() + + #if ENABLE_PUSH_NOTIFICATIONS + // Completion handler for push token registration + private var pushTokenCompletion: ((Result) -> Void)? + private let pushTokenTimeout: TimeInterval = 10.0 + private var pushTokenTimer: Timer? + #endif + + init() { + #if ENABLE_PUSH_NOTIFICATIONS + // Store reference to this plugin for event triggering + AppDelegateSwizzler.plugin = self + + // swizzle UIApplicationDelegate push methods + AppDelegateSwizzler.swizzlePushCallbacks() + #endif + + notificationHandler.plugin = self + notificationManager.notificationHandler = notificationHandler + } + + public func show(args: RustString) async throws(FFIResult) -> Int32 { + let notification = try args.decode(Notification.self) + + let request = try await showNotification(notification: notification) + notificationHandler.saveNotification(request.identifier, notification) + return Int32(request.identifier) ?? -1 + } + + public func requestPermissions() async throws(FFIResult) -> String { + do { + let granted = try await notificationHandler.requestPermissions() + let permissionState = granted ? "granted" : "denied" + return "{\"permissionState\":\"\(permissionState)\"}" + } catch { + throw FFIResult.Err(RustString(error.localizedDescription)) + } + } + + public func registerForPushNotifications() async throws(FFIResult) -> String { + #if ENABLE_PUSH_NOTIFICATIONS + // First request notification permissions + let granted: Bool + do { + granted = try await notificationHandler.requestPermissions() + } catch { + throw FFIResult.Err(RustString("Failed to request notification permissions: \(error.localizedDescription)")) + } + + guard granted else { + throw FFIResult.Err(RustString("Notification permissions not granted")) + } + + // Register and wait for token + do { + let token = try await withCheckedThrowingContinuation { continuation in + self.registerForPushNotificationsWithCompletion { result in + continuation.resume(with: result) + } + } + return "{\"deviceToken\":\"\(token)\"}" + } catch { + throw FFIResult.Err(RustString(error.localizedDescription)) + } + #else + throw FFIResult.Err(RustString("Push notifications are disabled in this build")) + #endif + } + + public func unregisterForPushNotifications() throws(FFIResult) { + #if ENABLE_PUSH_NOTIFICATIONS + DispatchQueue.main.async { + NSApplication.shared.unregisterForRemoteNotifications() + } + #else + throw FFIResult.Err(RustString("Push notifications are disabled in this build")) + #endif + } + + public func checkPermissions() async throws(FFIResult) -> String { + let settings = await notificationHandler.checkPermissions() + let permission: String + + switch settings.authorizationStatus { + case .authorized, .ephemeral, .provisional: + permission = "granted" + case .denied: + permission = "denied" + case .notDetermined: + permission = "prompt" + @unknown default: + permission = "prompt" + } + + return "{\"permissionState\":\"\(permission)\"}" + } + + public func cancel(args: RustString) throws(FFIResult) { + let args = try args.decode(CancelArgs.self) + + UNUserNotificationCenter.current().removePendingNotificationRequests( + withIdentifiers: args.notifications.map { String($0) } + ) + } + + public func cancelAll() throws(FFIResult) { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + } + + public func getPending() async throws(FFIResult) -> String { + let notifications = await UNUserNotificationCenter.current().pendingNotificationRequests() + + let ret = notifications.compactMap({ [weak self] (notification) -> PendingNotification? in + return self?.notificationHandler.toPendingNotification(notification) + }) + + return try ret.toJSONString() + } + + public func registerActionTypes(args: RustString) throws(FFIResult) { + let args = try args.decode(RegisterActionTypesArgs.self) + makeCategories(args.types) + } + + public func removeActive(args: RustString) throws(FFIResult) { + let args = try args.decode(RemoveActiveArgs.self) + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: args.notifications.map { String($0.id) }) + } + + public func removeAllActive() throws(FFIResult) { + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + DispatchQueue.main.async { + NSApp.dockTile.badgeLabel = nil + } + } + + public func getActive() async throws(FFIResult) -> String { + let notifications = await UNUserNotificationCenter.current().deliveredNotifications() + + let ret = notifications.compactMap({ (notification) -> ActiveNotification? in + return self.notificationHandler.toActiveNotification(notification.request) + }) + + return try ret.toJSONString() + } + + public func setClickListenerActive(args: RustString) throws(FFIResult) { + let args = try args.decode(SetClickListenerActiveArgs.self) + notificationHandler.setClickListenerActive(args.active) + } + + #if ENABLE_PUSH_NOTIFICATIONS + private func registerForPushNotificationsWithCompletion(_ completion: @escaping (Result) -> Void) + { + // Store completion for later + self.pushTokenCompletion = completion + + // Set up timeout + self.pushTokenTimer?.invalidate() + self.pushTokenTimer = Timer.scheduledTimer(withTimeInterval: pushTokenTimeout, repeats: false) + { [weak self] _ in + self?.handlePushTokenTimeout() + } + + // Register for remote notifications + DispatchQueue.main.async { + NSApplication.shared.registerForRemoteNotifications() + } + } + + private func handlePushTokenTimeout() { + pushTokenTimer?.invalidate() + pushTokenTimer = nil + + if let completion = pushTokenCompletion { + pushTokenCompletion = nil + let error = NSError( + domain: "NotificationPlugin", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Timeout waiting for device token"] + ) + completion(.failure(error)) + } + } + + // Called by AppDelegateSwizzler when token is received + func handlePushTokenReceived(_ token: String) { + pushTokenTimer?.invalidate() + pushTokenTimer = nil + + if let completion = pushTokenCompletion { + pushTokenCompletion = nil + completion(.success(token)) + } + } + + // Called by AppDelegateSwizzler when registration fails + func handlePushTokenError(_ error: Error) { + pushTokenTimer?.invalidate() + pushTokenTimer = nil + + if let completion = pushTokenCompletion { + pushTokenCompletion = nil + completion(.failure(error)) + } + } + #endif + + public func trigger(_ event: String, data: T) throws { + let jsonData = try JSONEncoder().encode(data) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw NSError( + domain: "NotificationPlugin", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode data to JSON string"]) + } + try bridgeTrigger(RustString(event), RustString(jsonString)) + } +} + +// Initialize the plugin +func initPlugin() -> NotificationPlugin { + return NotificationPlugin() +} diff --git a/macos/Sources/bridging-header.h b/macos/Sources/bridging-header.h new file mode 100644 index 00000000..8882bf37 --- /dev/null +++ b/macos/Sources/bridging-header.h @@ -0,0 +1,9 @@ +#ifndef BRIDGING_HEADER_H +#define BRIDGING_HEADER_H + +#ifdef __OBJC__ +#import "./generated/SwiftBridgeCore.h" +#import "./generated/tauri-plugin-notifications/tauri-plugin-notifications.h" +#endif + +#endif // BRIDGING_HEADER_H \ No newline at end of file diff --git a/macos/Tests/PluginTests/MockFFI.swift b/macos/Tests/PluginTests/MockFFI.swift new file mode 100644 index 00000000..dfbd3d3c --- /dev/null +++ b/macos/Tests/PluginTests/MockFFI.swift @@ -0,0 +1,810 @@ +import Foundation +@testable import tauri_plugin_notifications + +// MARK: - Mock Storage + +private var mockStringStorage: [UnsafeMutableRawPointer: String] = [:] +private var nextMockPtr: UInt = 1 + +private func allocateMockPtr() -> UnsafeMutableRawPointer { + let ptr = UnsafeMutableRawPointer(bitPattern: nextMockPtr)! + nextMockPtr += 1 + return ptr +} + +// MARK: - RustString Mock FFI + +@_cdecl("__swift_bridge__$RustString$new") +func mock_RustString_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockStringStorage[ptr] = "" + return ptr +} + +@_cdecl("__swift_bridge__$RustString$new_with_str") +func mock_RustString_new_with_str(_ str: RustStr) -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + let swiftString = str.toString() + mockStringStorage[ptr] = swiftString + return ptr +} + +@_cdecl("__swift_bridge__$RustString$_free") +func mock_RustString_free(_ ptr: UnsafeMutableRawPointer) { + mockStringStorage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$RustString$len") +func mock_RustString_len(_ ptr: UnsafeMutableRawPointer) -> UInt { + guard let str = mockStringStorage[ptr] else { return 0 } + return UInt(str.utf8.count) +} + +@_cdecl("__swift_bridge__$RustString$as_str") +func mock_RustString_as_str(_ ptr: UnsafeMutableRawPointer) -> RustStr { + guard let str = mockStringStorage[ptr] else { + return RustStr(start: nil, len: 0) + } + let utf8 = Array(str.utf8) + let buffer = UnsafeMutablePointer.allocate(capacity: utf8.count) + buffer.initialize(from: utf8, count: utf8.count) + return RustStr(start: buffer, len: UInt(utf8.count)) +} + +@_cdecl("__swift_bridge__$RustString$trim") +func mock_RustString_trim(_ ptr: UnsafeMutableRawPointer) -> RustStr { + guard let str = mockStringStorage[ptr] else { + return RustStr(start: nil, len: 0) + } + let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines) + let utf8 = Array(trimmed.utf8) + let buffer = UnsafeMutablePointer.allocate(capacity: utf8.count) + buffer.initialize(from: utf8, count: utf8.count) + return RustStr(start: buffer, len: UInt(utf8.count)) +} + +@_cdecl("__swift_bridge__$RustStr$partial_eq") +func mock_RustStr_partial_eq(_ lhs: RustStr, _ rhs: RustStr) -> Bool { + return lhs.toString() == rhs.toString() +} + +// MARK: - RustVec Mock FFI + +private var mockVecStringStorage: [UnsafeMutableRawPointer: [UnsafeMutableRawPointer]] = [:] + +@_cdecl("__swift_bridge__$Vec_RustString$new") +func mock_Vec_RustString_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecStringStorage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_RustString$drop") +func mock_Vec_RustString_drop(_ ptr: UnsafeMutableRawPointer) { + mockVecStringStorage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_RustString$push") +func mock_Vec_RustString_push(_ vecPtr: UnsafeMutableRawPointer, _ itemPtr: UnsafeMutableRawPointer) { + mockVecStringStorage[vecPtr]?.append(itemPtr) +} + +@_cdecl("__swift_bridge__$Vec_RustString$pop") +func mock_Vec_RustString_pop(_ vecPtr: UnsafeMutableRawPointer) -> UnsafeMutableRawPointer? { + return mockVecStringStorage[vecPtr]?.popLast() +} + +@_cdecl("__swift_bridge__$Vec_RustString$get") +func mock_Vec_RustString_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> UnsafeMutableRawPointer? { + guard let vec = mockVecStringStorage[vecPtr], index < vec.count else { return nil } + return vec[Int(index)] +} + +@_cdecl("__swift_bridge__$Vec_RustString$get_mut") +func mock_Vec_RustString_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> UnsafeMutableRawPointer? { + return mock_Vec_RustString_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_RustString$len") +func mock_Vec_RustString_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecStringStorage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_RustString$as_ptr") +func mock_Vec_RustString_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafeRawPointer? { + guard let vec = mockVecStringStorage[vecPtr], !vec.isEmpty else { return nil } + return UnsafeRawPointer(vec.withUnsafeBufferPointer { $0.baseAddress }) +} + +// MARK: - Null pointer helper + +@_cdecl("__swift_bridge__null_pointer") +func mock_null_pointer() -> UnsafeMutableRawPointer? { + return nil +} + +// MARK: - Boxed FnOnce helpers + +@_cdecl("__swift_bridge__$call_boxed_fn_once_no_args_no_return") +func mock_call_boxed_fn_once(_ ptr: UnsafeMutableRawPointer) { + // No-op for tests +} + +@_cdecl("__swift_bridge__$free_boxed_fn_once_no_args_no_return") +func mock_free_boxed_fn_once(_ ptr: UnsafeMutableRawPointer) { + // No-op for tests +} + +// MARK: - Generic Vec Storage for primitive types + +private var mockVecU8Storage: [UnsafeMutableRawPointer: [UInt8]] = [:] +private var mockVecU16Storage: [UnsafeMutableRawPointer: [UInt16]] = [:] +private var mockVecU32Storage: [UnsafeMutableRawPointer: [UInt32]] = [:] +private var mockVecU64Storage: [UnsafeMutableRawPointer: [UInt64]] = [:] +private var mockVecUsizeStorage: [UnsafeMutableRawPointer: [UInt]] = [:] +private var mockVecI8Storage: [UnsafeMutableRawPointer: [Int8]] = [:] +private var mockVecI16Storage: [UnsafeMutableRawPointer: [Int16]] = [:] +private var mockVecI32Storage: [UnsafeMutableRawPointer: [Int32]] = [:] +private var mockVecI64Storage: [UnsafeMutableRawPointer: [Int64]] = [:] +private var mockVecIsizeStorage: [UnsafeMutableRawPointer: [Int]] = [:] +private var mockVecBoolStorage: [UnsafeMutableRawPointer: [Bool]] = [:] +private var mockVecF32Storage: [UnsafeMutableRawPointer: [Float]] = [:] +private var mockVecF64Storage: [UnsafeMutableRawPointer: [Double]] = [:] + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_u8$new") +func mock_Vec_u8_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecU8Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_u8$_free") +func mock_Vec_u8_free(_ ptr: UnsafeMutableRawPointer) { + mockVecU8Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_u8$push") +func mock_Vec_u8_push(_ vecPtr: UnsafeMutableRawPointer, _ val: UInt8) { + mockVecU8Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_u8$pop") +func mock_Vec_u8_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionU8 { + if let val = mockVecU8Storage[vecPtr]?.popLast() { + return __private__OptionU8(val: val, is_some: true) + } + return __private__OptionU8(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_u8$get") +func mock_Vec_u8_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionU8 { + if let vec = mockVecU8Storage[vecPtr], index < vec.count { + return __private__OptionU8(val: vec[Int(index)], is_some: true) + } + return __private__OptionU8(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_u8$get_mut") +func mock_Vec_u8_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionU8 { + return mock_Vec_u8_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_u8$len") +func mock_Vec_u8_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecU8Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_u8$as_ptr") +func mock_Vec_u8_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecU8Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_u16$new") +func mock_Vec_u16_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecU16Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_u16$_free") +func mock_Vec_u16_free(_ ptr: UnsafeMutableRawPointer) { + mockVecU16Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_u16$push") +func mock_Vec_u16_push(_ vecPtr: UnsafeMutableRawPointer, _ val: UInt16) { + mockVecU16Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_u16$pop") +func mock_Vec_u16_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionU16 { + if let val = mockVecU16Storage[vecPtr]?.popLast() { + return __private__OptionU16(val: val, is_some: true) + } + return __private__OptionU16(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_u16$get") +func mock_Vec_u16_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionU16 { + if let vec = mockVecU16Storage[vecPtr], index < vec.count { + return __private__OptionU16(val: vec[Int(index)], is_some: true) + } + return __private__OptionU16(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_u16$get_mut") +func mock_Vec_u16_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionU16 { + return mock_Vec_u16_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_u16$len") +func mock_Vec_u16_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecU16Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_u16$as_ptr") +func mock_Vec_u16_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecU16Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_u32$new") +func mock_Vec_u32_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecU32Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_u32$_free") +func mock_Vec_u32_free(_ ptr: UnsafeMutableRawPointer) { + mockVecU32Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_u32$push") +func mock_Vec_u32_push(_ vecPtr: UnsafeMutableRawPointer, _ val: UInt32) { + mockVecU32Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_u32$pop") +func mock_Vec_u32_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionU32 { + if let val = mockVecU32Storage[vecPtr]?.popLast() { + return __private__OptionU32(val: val, is_some: true) + } + return __private__OptionU32(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_u32$get") +func mock_Vec_u32_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionU32 { + if let vec = mockVecU32Storage[vecPtr], index < vec.count { + return __private__OptionU32(val: vec[Int(index)], is_some: true) + } + return __private__OptionU32(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_u32$get_mut") +func mock_Vec_u32_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionU32 { + return mock_Vec_u32_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_u32$len") +func mock_Vec_u32_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecU32Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_u32$as_ptr") +func mock_Vec_u32_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecU32Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_u64$new") +func mock_Vec_u64_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecU64Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_u64$_free") +func mock_Vec_u64_free(_ ptr: UnsafeMutableRawPointer) { + mockVecU64Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_u64$push") +func mock_Vec_u64_push(_ vecPtr: UnsafeMutableRawPointer, _ val: UInt64) { + mockVecU64Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_u64$pop") +func mock_Vec_u64_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionU64 { + if let val = mockVecU64Storage[vecPtr]?.popLast() { + return __private__OptionU64(val: val, is_some: true) + } + return __private__OptionU64(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_u64$get") +func mock_Vec_u64_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionU64 { + if let vec = mockVecU64Storage[vecPtr], index < vec.count { + return __private__OptionU64(val: vec[Int(index)], is_some: true) + } + return __private__OptionU64(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_u64$get_mut") +func mock_Vec_u64_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionU64 { + return mock_Vec_u64_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_u64$len") +func mock_Vec_u64_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecU64Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_u64$as_ptr") +func mock_Vec_u64_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecU64Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_usize$new") +func mock_Vec_usize_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecUsizeStorage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_usize$_free") +func mock_Vec_usize_free(_ ptr: UnsafeMutableRawPointer) { + mockVecUsizeStorage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_usize$push") +func mock_Vec_usize_push(_ vecPtr: UnsafeMutableRawPointer, _ val: UInt) { + mockVecUsizeStorage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_usize$pop") +func mock_Vec_usize_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionUsize { + if let val = mockVecUsizeStorage[vecPtr]?.popLast() { + return __private__OptionUsize(val: val, is_some: true) + } + return __private__OptionUsize(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_usize$get") +func mock_Vec_usize_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionUsize { + if let vec = mockVecUsizeStorage[vecPtr], index < vec.count { + return __private__OptionUsize(val: vec[Int(index)], is_some: true) + } + return __private__OptionUsize(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_usize$get_mut") +func mock_Vec_usize_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionUsize { + return mock_Vec_usize_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_usize$len") +func mock_Vec_usize_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecUsizeStorage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_usize$as_ptr") +func mock_Vec_usize_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecUsizeStorage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_i8$new") +func mock_Vec_i8_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecI8Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_i8$_free") +func mock_Vec_i8_free(_ ptr: UnsafeMutableRawPointer) { + mockVecI8Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_i8$push") +func mock_Vec_i8_push(_ vecPtr: UnsafeMutableRawPointer, _ val: Int8) { + mockVecI8Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_i8$pop") +func mock_Vec_i8_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionI8 { + if let val = mockVecI8Storage[vecPtr]?.popLast() { + return __private__OptionI8(val: val, is_some: true) + } + return __private__OptionI8(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_i8$get") +func mock_Vec_i8_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionI8 { + if let vec = mockVecI8Storage[vecPtr], index < vec.count { + return __private__OptionI8(val: vec[Int(index)], is_some: true) + } + return __private__OptionI8(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_i8$get_mut") +func mock_Vec_i8_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionI8 { + return mock_Vec_i8_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_i8$len") +func mock_Vec_i8_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecI8Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_i8$as_ptr") +func mock_Vec_i8_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecI8Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_i16$new") +func mock_Vec_i16_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecI16Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_i16$_free") +func mock_Vec_i16_free(_ ptr: UnsafeMutableRawPointer) { + mockVecI16Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_i16$push") +func mock_Vec_i16_push(_ vecPtr: UnsafeMutableRawPointer, _ val: Int16) { + mockVecI16Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_i16$pop") +func mock_Vec_i16_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionI16 { + if let val = mockVecI16Storage[vecPtr]?.popLast() { + return __private__OptionI16(val: val, is_some: true) + } + return __private__OptionI16(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_i16$get") +func mock_Vec_i16_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionI16 { + if let vec = mockVecI16Storage[vecPtr], index < vec.count { + return __private__OptionI16(val: vec[Int(index)], is_some: true) + } + return __private__OptionI16(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_i16$get_mut") +func mock_Vec_i16_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionI16 { + return mock_Vec_i16_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_i16$len") +func mock_Vec_i16_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecI16Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_i16$as_ptr") +func mock_Vec_i16_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecI16Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_i32$new") +func mock_Vec_i32_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecI32Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_i32$_free") +func mock_Vec_i32_free(_ ptr: UnsafeMutableRawPointer) { + mockVecI32Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_i32$push") +func mock_Vec_i32_push(_ vecPtr: UnsafeMutableRawPointer, _ val: Int32) { + mockVecI32Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_i32$pop") +func mock_Vec_i32_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionI32 { + if let val = mockVecI32Storage[vecPtr]?.popLast() { + return __private__OptionI32(val: val, is_some: true) + } + return __private__OptionI32(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_i32$get") +func mock_Vec_i32_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionI32 { + if let vec = mockVecI32Storage[vecPtr], index < vec.count { + return __private__OptionI32(val: vec[Int(index)], is_some: true) + } + return __private__OptionI32(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_i32$get_mut") +func mock_Vec_i32_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionI32 { + return mock_Vec_i32_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_i32$len") +func mock_Vec_i32_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecI32Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_i32$as_ptr") +func mock_Vec_i32_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecI32Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_i64$new") +func mock_Vec_i64_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecI64Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_i64$_free") +func mock_Vec_i64_free(_ ptr: UnsafeMutableRawPointer) { + mockVecI64Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_i64$push") +func mock_Vec_i64_push(_ vecPtr: UnsafeMutableRawPointer, _ val: Int64) { + mockVecI64Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_i64$pop") +func mock_Vec_i64_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionI64 { + if let val = mockVecI64Storage[vecPtr]?.popLast() { + return __private__OptionI64(val: val, is_some: true) + } + return __private__OptionI64(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_i64$get") +func mock_Vec_i64_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionI64 { + if let vec = mockVecI64Storage[vecPtr], index < vec.count { + return __private__OptionI64(val: vec[Int(index)], is_some: true) + } + return __private__OptionI64(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_i64$get_mut") +func mock_Vec_i64_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionI64 { + return mock_Vec_i64_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_i64$len") +func mock_Vec_i64_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecI64Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_i64$as_ptr") +func mock_Vec_i64_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecI64Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_isize$new") +func mock_Vec_isize_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecIsizeStorage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_isize$_free") +func mock_Vec_isize_free(_ ptr: UnsafeMutableRawPointer) { + mockVecIsizeStorage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_isize$push") +func mock_Vec_isize_push(_ vecPtr: UnsafeMutableRawPointer, _ val: Int) { + mockVecIsizeStorage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_isize$pop") +func mock_Vec_isize_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionIsize { + if let val = mockVecIsizeStorage[vecPtr]?.popLast() { + return __private__OptionIsize(val: val, is_some: true) + } + return __private__OptionIsize(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_isize$get") +func mock_Vec_isize_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionIsize { + if let vec = mockVecIsizeStorage[vecPtr], index < vec.count { + return __private__OptionIsize(val: vec[Int(index)], is_some: true) + } + return __private__OptionIsize(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_isize$get_mut") +func mock_Vec_isize_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionIsize { + return mock_Vec_isize_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_isize$len") +func mock_Vec_isize_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecIsizeStorage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_isize$as_ptr") +func mock_Vec_isize_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecIsizeStorage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_bool$new") +func mock_Vec_bool_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecBoolStorage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_bool$_free") +func mock_Vec_bool_free(_ ptr: UnsafeMutableRawPointer) { + mockVecBoolStorage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_bool$push") +func mock_Vec_bool_push(_ vecPtr: UnsafeMutableRawPointer, _ val: Bool) { + mockVecBoolStorage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_bool$pop") +func mock_Vec_bool_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionBool { + if let val = mockVecBoolStorage[vecPtr]?.popLast() { + return __private__OptionBool(val: val, is_some: true) + } + return __private__OptionBool(val: false, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_bool$get") +func mock_Vec_bool_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionBool { + if let vec = mockVecBoolStorage[vecPtr], index < vec.count { + return __private__OptionBool(val: vec[Int(index)], is_some: true) + } + return __private__OptionBool(val: false, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_bool$get_mut") +func mock_Vec_bool_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionBool { + return mock_Vec_bool_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_bool$len") +func mock_Vec_bool_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecBoolStorage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_bool$as_ptr") +func mock_Vec_bool_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecBoolStorage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_f32$new") +func mock_Vec_f32_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecF32Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_f32$_free") +func mock_Vec_f32_free(_ ptr: UnsafeMutableRawPointer) { + mockVecF32Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_f32$push") +func mock_Vec_f32_push(_ vecPtr: UnsafeMutableRawPointer, _ val: Float) { + mockVecF32Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_f32$pop") +func mock_Vec_f32_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionF32 { + if let val = mockVecF32Storage[vecPtr]?.popLast() { + return __private__OptionF32(val: val, is_some: true) + } + return __private__OptionF32(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_f32$get") +func mock_Vec_f32_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionF32 { + if let vec = mockVecF32Storage[vecPtr], index < vec.count { + return __private__OptionF32(val: vec[Int(index)], is_some: true) + } + return __private__OptionF32(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_f32$get_mut") +func mock_Vec_f32_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionF32 { + return mock_Vec_f32_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_f32$len") +func mock_Vec_f32_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecF32Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_f32$as_ptr") +func mock_Vec_f32_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecF32Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Vec + +@_cdecl("__swift_bridge__$Vec_f64$new") +func mock_Vec_f64_new() -> UnsafeMutableRawPointer { + let ptr = allocateMockPtr() + mockVecF64Storage[ptr] = [] + return ptr +} + +@_cdecl("__swift_bridge__$Vec_f64$_free") +func mock_Vec_f64_free(_ ptr: UnsafeMutableRawPointer) { + mockVecF64Storage.removeValue(forKey: ptr) +} + +@_cdecl("__swift_bridge__$Vec_f64$push") +func mock_Vec_f64_push(_ vecPtr: UnsafeMutableRawPointer, _ val: Double) { + mockVecF64Storage[vecPtr]?.append(val) +} + +@_cdecl("__swift_bridge__$Vec_f64$pop") +func mock_Vec_f64_pop(_ vecPtr: UnsafeMutableRawPointer) -> __private__OptionF64 { + if let val = mockVecF64Storage[vecPtr]?.popLast() { + return __private__OptionF64(val: val, is_some: true) + } + return __private__OptionF64(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_f64$get") +func mock_Vec_f64_get(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionF64 { + if let vec = mockVecF64Storage[vecPtr], index < vec.count { + return __private__OptionF64(val: vec[Int(index)], is_some: true) + } + return __private__OptionF64(val: 0, is_some: false) +} + +@_cdecl("__swift_bridge__$Vec_f64$get_mut") +func mock_Vec_f64_get_mut(_ vecPtr: UnsafeMutableRawPointer, _ index: UInt) -> __private__OptionF64 { + return mock_Vec_f64_get(vecPtr, index) +} + +@_cdecl("__swift_bridge__$Vec_f64$len") +func mock_Vec_f64_len(_ vecPtr: UnsafeMutableRawPointer) -> UInt { + return UInt(mockVecF64Storage[vecPtr]?.count ?? 0) +} + +@_cdecl("__swift_bridge__$Vec_f64$as_ptr") +func mock_Vec_f64_as_ptr(_ vecPtr: UnsafeMutableRawPointer) -> UnsafePointer? { + return mockVecF64Storage[vecPtr]?.withUnsafeBufferPointer { $0.baseAddress } +} + +// MARK: - Bridge Trigger Mock (Rust FFI callback for notifications) + +@_cdecl("__swift_bridge__$bridge_trigger") +func mock_bridge_trigger(_ event: UnsafeMutableRawPointer, _ payload: UnsafeMutableRawPointer) -> __swift_bridge__$ResultVoidAndFFIResult { + // No-op stub for tests - return success + return __swift_bridge__$ResultVoidAndFFIResult(tag: __swift_bridge__$ResultVoidAndFFIResult$ResultOk, payload: __swift_bridge__$ResultVoidAndFFIResult$Fields()) +} diff --git a/macos/Tests/PluginTests/PluginTests.swift b/macos/Tests/PluginTests/PluginTests.swift new file mode 100644 index 00000000..f48564d2 --- /dev/null +++ b/macos/Tests/PluginTests/PluginTests.swift @@ -0,0 +1,1349 @@ +import XCTest +import UserNotifications +@testable import tauri_plugin_notifications + +// MARK: - Test Helpers + +/// Parses a JSON string into a dictionary. +private func parseJSON(_ jsonString: String) -> JsonObject? { + guard let data = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? JsonObject else { + return nil + } + return json +} + +/// Type alias to avoid conflicts with Foundation.Notification +typealias PluginNotification = tauri_plugin_notifications.Notification + +/// Creates a minimal Notification for testing. +private func makeTestNotification( + id: Int = 1, + title: String = "Test Title", + body: String? = "Test Body", + schedule: NotificationSchedule? = nil, + attachments: [NotificationAttachment]? = nil, + sound: String? = nil, + group: String? = nil, + actionTypeId: String? = nil, + extra: [String: String]? = nil, + silent: Bool? = nil +) -> PluginNotification { + return PluginNotification( + id: id, + title: title, + body: body, + extra: extra, + schedule: schedule, + attachments: attachments, + sound: sound, + group: group, + actionTypeId: actionTypeId, + summary: nil, + silent: silent + ) +} + +// MARK: - ScheduleEveryKind Tests + +final class ScheduleEveryKindTests: XCTestCase { + + func testScheduleEveryKindRawValues() { + XCTAssertEqual(ScheduleEveryKind.year.rawValue, "year") + XCTAssertEqual(ScheduleEveryKind.month.rawValue, "month") + XCTAssertEqual(ScheduleEveryKind.twoWeeks.rawValue, "twoWeeks") + XCTAssertEqual(ScheduleEveryKind.week.rawValue, "week") + XCTAssertEqual(ScheduleEveryKind.day.rawValue, "day") + XCTAssertEqual(ScheduleEveryKind.hour.rawValue, "hour") + XCTAssertEqual(ScheduleEveryKind.minute.rawValue, "minute") + XCTAssertEqual(ScheduleEveryKind.second.rawValue, "second") + } + + func testScheduleEveryKindFromRawValue() { + XCTAssertEqual(ScheduleEveryKind(rawValue: "year"), .year) + XCTAssertEqual(ScheduleEveryKind(rawValue: "month"), .month) + XCTAssertEqual(ScheduleEveryKind(rawValue: "twoWeeks"), .twoWeeks) + XCTAssertEqual(ScheduleEveryKind(rawValue: "week"), .week) + XCTAssertEqual(ScheduleEveryKind(rawValue: "day"), .day) + XCTAssertEqual(ScheduleEveryKind(rawValue: "hour"), .hour) + XCTAssertEqual(ScheduleEveryKind(rawValue: "minute"), .minute) + XCTAssertEqual(ScheduleEveryKind(rawValue: "second"), .second) + XCTAssertNil(ScheduleEveryKind(rawValue: "invalid")) + } + + func testScheduleEveryKindEncodable() throws { + let encoder = JSONEncoder() + + let yearData = try encoder.encode(ScheduleEveryKind.year) + XCTAssertEqual(String(data: yearData, encoding: .utf8), "\"year\"") + + let monthData = try encoder.encode(ScheduleEveryKind.month) + XCTAssertEqual(String(data: monthData, encoding: .utf8), "\"month\"") + } + + func testScheduleEveryKindDecodable() throws { + let decoder = JSONDecoder() + + let yearData = "\"year\"".data(using: .utf8)! + let year = try decoder.decode(ScheduleEveryKind.self, from: yearData) + XCTAssertEqual(year, .year) + + let weekData = "\"week\"".data(using: .utf8)! + let week = try decoder.decode(ScheduleEveryKind.self, from: weekData) + XCTAssertEqual(week, .week) + } +} + +// MARK: - ScheduleInterval Tests + +final class ScheduleIntervalTests: XCTestCase { + + func testScheduleIntervalDecoding() throws { + let json = """ + {"year": 2024, "month": 12, "day": 25, "hour": 10, "minute": 30, "second": 0} + """ + let data = json.data(using: .utf8)! + let interval = try JSONDecoder().decode(ScheduleInterval.self, from: data) + + XCTAssertEqual(interval.year, 2024) + XCTAssertEqual(interval.month, 12) + XCTAssertEqual(interval.day, 25) + XCTAssertEqual(interval.hour, 10) + XCTAssertEqual(interval.minute, 30) + XCTAssertEqual(interval.second, 0) + XCTAssertNil(interval.weekday) + } + + func testScheduleIntervalPartialDecoding() throws { + let json = """ + {"hour": 9, "minute": 0} + """ + let data = json.data(using: .utf8)! + let interval = try JSONDecoder().decode(ScheduleInterval.self, from: data) + + XCTAssertNil(interval.year) + XCTAssertNil(interval.month) + XCTAssertNil(interval.day) + XCTAssertEqual(interval.hour, 9) + XCTAssertEqual(interval.minute, 0) + XCTAssertNil(interval.second) + XCTAssertNil(interval.weekday) + } + + func testScheduleIntervalWithWeekday() throws { + let json = """ + {"weekday": 2, "hour": 14} + """ + let data = json.data(using: .utf8)! + let interval = try JSONDecoder().decode(ScheduleInterval.self, from: data) + + XCTAssertEqual(interval.weekday, 2) + XCTAssertEqual(interval.hour, 14) + } +} + +// MARK: - NotificationSchedule Tests + +final class NotificationScheduleTests: XCTestCase { + + func testNotificationScheduleAtDecoding() throws { + let json = """ + {"at": {"date": "2024-12-25T10:00:00.000Z", "repeating": false}} + """ + let data = json.data(using: .utf8)! + let schedule = try JSONDecoder().decode(NotificationSchedule.self, from: data) + + if case .at(let date, let repeating) = schedule { + XCTAssertEqual(date, "2024-12-25T10:00:00.000Z") + XCTAssertFalse(repeating) + } else { + XCTFail("Expected .at schedule") + } + } + + func testNotificationScheduleIntervalDecoding() throws { + let json = """ + {"interval": {"interval": {"hour": 9, "minute": 0}}} + """ + let data = json.data(using: .utf8)! + let schedule = try JSONDecoder().decode(NotificationSchedule.self, from: data) + + if case .interval(let interval) = schedule { + XCTAssertEqual(interval.hour, 9) + XCTAssertEqual(interval.minute, 0) + } else { + XCTFail("Expected .interval schedule") + } + } + + func testNotificationScheduleEveryDecoding() throws { + let json = """ + {"every": {"interval": "hour", "count": 2}} + """ + let data = json.data(using: .utf8)! + let schedule = try JSONDecoder().decode(NotificationSchedule.self, from: data) + + if case .every(let interval, let count) = schedule { + XCTAssertEqual(interval, .hour) + XCTAssertEqual(count, 2) + } else { + XCTFail("Expected .every schedule") + } + } +} + +// MARK: - Notification Tests + +final class NotificationTests: XCTestCase { + + func testNotificationMinimalDecoding() throws { + let json = """ + {"id": 123, "title": "Hello"} + """ + let data = json.data(using: .utf8)! + let notification = try JSONDecoder().decode(PluginNotification.self, from: data) + + XCTAssertEqual(notification.id, 123) + XCTAssertEqual(notification.title, "Hello") + XCTAssertNil(notification.body) + XCTAssertNil(notification.extra) + XCTAssertNil(notification.schedule) + XCTAssertNil(notification.attachments) + XCTAssertNil(notification.sound) + XCTAssertNil(notification.group) + XCTAssertNil(notification.actionTypeId) + XCTAssertNil(notification.silent) + } + + func testNotificationFullDecoding() throws { + let json = """ + { + "id": 456, + "title": "Reminder", + "body": "Don't forget!", + "extra": {"key1": "value1", "key2": "value2"}, + "sound": "notification.wav", + "group": "reminders", + "actionTypeId": "reminder_actions", + "summary": "A reminder", + "silent": false + } + """ + let data = json.data(using: .utf8)! + let notification = try JSONDecoder().decode(PluginNotification.self, from: data) + + XCTAssertEqual(notification.id, 456) + XCTAssertEqual(notification.title, "Reminder") + XCTAssertEqual(notification.body, "Don't forget!") + XCTAssertEqual(notification.extra?["key1"], "value1") + XCTAssertEqual(notification.extra?["key2"], "value2") + XCTAssertEqual(notification.sound, "notification.wav") + XCTAssertEqual(notification.group, "reminders") + XCTAssertEqual(notification.actionTypeId, "reminder_actions") + XCTAssertEqual(notification.summary, "A reminder") + XCTAssertEqual(notification.silent, false) + } + + func testNotificationWithSchedule() throws { + let json = """ + { + "id": 1, + "title": "Scheduled", + "schedule": {"every": {"interval": "day", "count": 1}} + } + """ + let data = json.data(using: .utf8)! + let notification = try JSONDecoder().decode(PluginNotification.self, from: data) + + XCTAssertNotNil(notification.schedule) + if case .every(let interval, let count) = notification.schedule! { + XCTAssertEqual(interval, .day) + XCTAssertEqual(count, 1) + } else { + XCTFail("Expected .every schedule") + } + } + + func testNotificationWithAttachments() throws { + let json = """ + { + "id": 1, + "title": "With Image", + "attachments": [ + {"id": "img1", "url": "file:///path/to/image.png"} + ] + } + """ + let data = json.data(using: .utf8)! + let notification = try JSONDecoder().decode(PluginNotification.self, from: data) + + XCTAssertEqual(notification.attachments?.count, 1) + XCTAssertEqual(notification.attachments?[0].id, "img1") + XCTAssertEqual(notification.attachments?[0].url, "file:///path/to/image.png") + } +} + +// MARK: - Action Tests + +final class ActionTests: XCTestCase { + + func testActionMinimalDecoding() throws { + let json = """ + {"id": "action1", "title": "Accept"} + """ + let data = json.data(using: .utf8)! + let action = try JSONDecoder().decode(Action.self, from: data) + + XCTAssertEqual(action.id, "action1") + XCTAssertEqual(action.title, "Accept") + XCTAssertNil(action.requiresAuthentication) + XCTAssertNil(action.foreground) + XCTAssertNil(action.destructive) + XCTAssertNil(action.input) + } + + func testActionFullDecoding() throws { + let json = """ + { + "id": "reply", + "title": "Reply", + "requiresAuthentication": true, + "foreground": true, + "destructive": false, + "input": true, + "inputButtonTitle": "Send", + "inputPlaceholder": "Type your reply..." + } + """ + let data = json.data(using: .utf8)! + let action = try JSONDecoder().decode(Action.self, from: data) + + XCTAssertEqual(action.id, "reply") + XCTAssertEqual(action.title, "Reply") + XCTAssertEqual(action.requiresAuthentication, true) + XCTAssertEqual(action.foreground, true) + XCTAssertEqual(action.destructive, false) + XCTAssertEqual(action.input, true) + XCTAssertEqual(action.inputButtonTitle, "Send") + XCTAssertEqual(action.inputPlaceholder, "Type your reply...") + } + + func testDestructiveAction() throws { + let json = """ + {"id": "delete", "title": "Delete", "destructive": true} + """ + let data = json.data(using: .utf8)! + let action = try JSONDecoder().decode(Action.self, from: data) + + XCTAssertEqual(action.destructive, true) + } +} + +// MARK: - ActionType Tests + +final class ActionTypeTests: XCTestCase { + + func testActionTypeDecoding() throws { + let json = """ + { + "id": "message_actions", + "actions": [ + {"id": "reply", "title": "Reply"}, + {"id": "delete", "title": "Delete", "destructive": true} + ] + } + """ + let data = json.data(using: .utf8)! + let actionType = try JSONDecoder().decode(ActionType.self, from: data) + + XCTAssertEqual(actionType.id, "message_actions") + XCTAssertEqual(actionType.actions.count, 2) + XCTAssertEqual(actionType.actions[0].id, "reply") + XCTAssertEqual(actionType.actions[1].destructive, true) + } + + func testActionTypeWithOptions() throws { + let json = """ + { + "id": "secure_actions", + "actions": [{"id": "confirm", "title": "Confirm"}], + "customDismissAction": true, + "hiddenPreviewsShowTitle": true + } + """ + let data = json.data(using: .utf8)! + let actionType = try JSONDecoder().decode(ActionType.self, from: data) + + XCTAssertEqual(actionType.customDismissAction, true) + XCTAssertEqual(actionType.hiddenPreviewsShowTitle, true) + } +} + +// MARK: - CancelArgs Tests + +final class CancelArgsTests: XCTestCase { + + func testCancelArgsDecoding() throws { + let json = """ + {"notifications": [1, 2, 3, 4, 5]} + """ + let data = json.data(using: .utf8)! + let args = try JSONDecoder().decode(CancelArgs.self, from: data) + + XCTAssertEqual(args.notifications, [1, 2, 3, 4, 5]) + } + + func testCancelArgsEmptyArray() throws { + let json = """ + {"notifications": []} + """ + let data = json.data(using: .utf8)! + let args = try JSONDecoder().decode(CancelArgs.self, from: data) + + XCTAssertTrue(args.notifications.isEmpty) + } +} + +// MARK: - RemoveActiveArgs Tests + +final class RemoveActiveArgsTests: XCTestCase { + + func testRemoveActiveArgsDecoding() throws { + let json = """ + {"notifications": [{"id": 1}, {"id": 2}, {"id": 3}]} + """ + let data = json.data(using: .utf8)! + let args = try JSONDecoder().decode(RemoveActiveArgs.self, from: data) + + XCTAssertEqual(args.notifications.count, 3) + XCTAssertEqual(args.notifications[0].id, 1) + XCTAssertEqual(args.notifications[1].id, 2) + XCTAssertEqual(args.notifications[2].id, 3) + } +} + +// MARK: - SetClickListenerActiveArgs Tests + +final class SetClickListenerActiveArgsTests: XCTestCase { + + func testSetClickListenerActiveArgsTrue() throws { + let json = """ + {"active": true} + """ + let data = json.data(using: .utf8)! + let args = try JSONDecoder().decode(SetClickListenerActiveArgs.self, from: data) + + XCTAssertTrue(args.active) + } + + func testSetClickListenerActiveArgsFalse() throws { + let json = """ + {"active": false} + """ + let data = json.data(using: .utf8)! + let args = try JSONDecoder().decode(SetClickListenerActiveArgs.self, from: data) + + XCTAssertFalse(args.active) + } +} + +// MARK: - Helper Function Tests + +final class HelperFunctionTests: XCTestCase { + + // MARK: - getDateComponents Tests + + func testGetDateComponentsFull() { + let interval = ScheduleInterval( + year: 2024, + month: 12, + day: 25, + weekday: nil, + hour: 10, + minute: 30, + second: 0 + ) + + let components = getDateComponents(interval) + + XCTAssertEqual(components.year, 2024) + XCTAssertEqual(components.month, 12) + XCTAssertEqual(components.day, 25) + XCTAssertEqual(components.hour, 10) + XCTAssertEqual(components.minute, 30) + XCTAssertEqual(components.second, 0) + XCTAssertNil(components.weekday) + } + + func testGetDateComponentsPartial() { + let interval = ScheduleInterval( + year: nil, + month: nil, + day: nil, + weekday: 2, + hour: 9, + minute: 0, + second: nil + ) + + let components = getDateComponents(interval) + + XCTAssertNil(components.year) + XCTAssertNil(components.month) + XCTAssertNil(components.day) + XCTAssertEqual(components.weekday, 2) + XCTAssertEqual(components.hour, 9) + XCTAssertEqual(components.minute, 0) + XCTAssertNil(components.second) + } + + // MARK: - getRepeatDateInterval Tests + + func testGetRepeatDateIntervalYear() { + let interval = getRepeatDateInterval(.year, 1) + + XCTAssertNotNil(interval) + // A year should be roughly 365 days + let days = interval!.duration / (24 * 60 * 60) + XCTAssertTrue(days >= 365 && days <= 366) + } + + func testGetRepeatDateIntervalMonth() { + let interval = getRepeatDateInterval(.month, 1) + + XCTAssertNotNil(interval) + // A month is roughly 28-31 days + let days = interval!.duration / (24 * 60 * 60) + XCTAssertTrue(days >= 28 && days <= 31) + } + + func testGetRepeatDateIntervalTwoWeeks() { + let interval = getRepeatDateInterval(.twoWeeks, 1) + + XCTAssertNotNil(interval) + let days = interval!.duration / (24 * 60 * 60) + XCTAssertEqual(days, 14, accuracy: 0.01) + } + + func testGetRepeatDateIntervalWeek() { + let interval = getRepeatDateInterval(.week, 1) + + XCTAssertNotNil(interval) + let days = interval!.duration / (24 * 60 * 60) + XCTAssertEqual(days, 7, accuracy: 0.01) + } + + func testGetRepeatDateIntervalDay() { + let interval = getRepeatDateInterval(.day, 1) + + XCTAssertNotNil(interval) + let hours = interval!.duration / (60 * 60) + XCTAssertEqual(hours, 24, accuracy: 0.01) + } + + func testGetRepeatDateIntervalHour() { + let interval = getRepeatDateInterval(.hour, 1) + + XCTAssertNotNil(interval) + let minutes = interval!.duration / 60 + XCTAssertEqual(minutes, 60, accuracy: 0.01) + } + + func testGetRepeatDateIntervalMinute() { + let interval = getRepeatDateInterval(.minute, 1) + + XCTAssertNotNil(interval) + XCTAssertEqual(interval!.duration, 60, accuracy: 0.01) + } + + func testGetRepeatDateIntervalSecond() { + let interval = getRepeatDateInterval(.second, 30) + + XCTAssertNotNil(interval) + XCTAssertEqual(interval!.duration, 30, accuracy: 0.01) + } + + func testGetRepeatDateIntervalWithCount() { + let interval = getRepeatDateInterval(.hour, 3) + + XCTAssertNotNil(interval) + let hours = interval!.duration / (60 * 60) + XCTAssertEqual(hours, 3, accuracy: 0.01) + } + + // MARK: - makeActionOptions Tests + + func testMakeActionOptionsForeground() { + let action = Action( + id: "test", + title: "Test", + requiresAuthentication: nil, + foreground: true, + destructive: nil, + input: nil, + inputButtonTitle: nil, + inputPlaceholder: nil + ) + + let options = makeActionOptions(action) + XCTAssertEqual(options, .foreground) + } + + func testMakeActionOptionsDestructive() { + let action = Action( + id: "test", + title: "Test", + requiresAuthentication: nil, + foreground: nil, + destructive: true, + input: nil, + inputButtonTitle: nil, + inputPlaceholder: nil + ) + + let options = makeActionOptions(action) + XCTAssertEqual(options, .destructive) + } + + func testMakeActionOptionsAuthRequired() { + let action = Action( + id: "test", + title: "Test", + requiresAuthentication: true, + foreground: nil, + destructive: nil, + input: nil, + inputButtonTitle: nil, + inputPlaceholder: nil + ) + + let options = makeActionOptions(action) + XCTAssertEqual(options, .authenticationRequired) + } + + func testMakeActionOptionsNone() { + let action = Action( + id: "test", + title: "Test", + requiresAuthentication: nil, + foreground: nil, + destructive: nil, + input: nil, + inputButtonTitle: nil, + inputPlaceholder: nil + ) + + let options = makeActionOptions(action) + XCTAssertEqual(options.rawValue, 0) + } + + // MARK: - makeCategoryOptions Tests + + func testMakeCategoryOptionsCustomDismiss() { + let actionType = ActionType( + id: "test", + actions: [], + hiddenPreviewsBodyPlaceholder: nil, + customDismissAction: true, + allowInCarPlay: nil, + hiddenPreviewsShowTitle: nil, + hiddenPreviewsShowSubtitle: nil, + hiddenBodyPlaceholder: nil + ) + + let options = makeCategoryOptions(actionType) + XCTAssertEqual(options, .customDismissAction) + } + + func testMakeCategoryOptionsShowTitle() { + let actionType = ActionType( + id: "test", + actions: [], + hiddenPreviewsBodyPlaceholder: nil, + customDismissAction: nil, + allowInCarPlay: nil, + hiddenPreviewsShowTitle: true, + hiddenPreviewsShowSubtitle: nil, + hiddenBodyPlaceholder: nil + ) + + let options = makeCategoryOptions(actionType) + XCTAssertEqual(options, .hiddenPreviewsShowTitle) + } + + func testMakeCategoryOptionsShowSubtitle() { + let actionType = ActionType( + id: "test", + actions: [], + hiddenPreviewsBodyPlaceholder: nil, + customDismissAction: nil, + allowInCarPlay: nil, + hiddenPreviewsShowTitle: nil, + hiddenPreviewsShowSubtitle: true, + hiddenBodyPlaceholder: nil + ) + + let options = makeCategoryOptions(actionType) + XCTAssertEqual(options, .hiddenPreviewsShowSubtitle) + } + + func testMakeCategoryOptionsNone() { + let actionType = ActionType( + id: "test", + actions: [], + hiddenPreviewsBodyPlaceholder: nil, + customDismissAction: nil, + allowInCarPlay: nil, + hiddenPreviewsShowTitle: nil, + hiddenPreviewsShowSubtitle: nil, + hiddenBodyPlaceholder: nil + ) + + let options = makeCategoryOptions(actionType) + XCTAssertEqual(options.rawValue, 0) + } + + // MARK: - makeActions Tests + + func testMakeActionsBasic() { + let actions = [ + Action( + id: "accept", + title: "Accept", + requiresAuthentication: nil, + foreground: true, + destructive: nil, + input: nil, + inputButtonTitle: nil, + inputPlaceholder: nil + ), + Action( + id: "decline", + title: "Decline", + requiresAuthentication: nil, + foreground: nil, + destructive: true, + input: nil, + inputButtonTitle: nil, + inputPlaceholder: nil + ) + ] + + let unActions = makeActions(actions) + + XCTAssertEqual(unActions.count, 2) + XCTAssertEqual(unActions[0].identifier, "accept") + XCTAssertEqual(unActions[0].title, "Accept") + XCTAssertEqual(unActions[1].identifier, "decline") + XCTAssertEqual(unActions[1].title, "Decline") + } + + func testMakeActionsWithInput() { + let actions = [ + Action( + id: "reply", + title: "Reply", + requiresAuthentication: nil, + foreground: nil, + destructive: nil, + input: true, + inputButtonTitle: "Send", + inputPlaceholder: "Type here..." + ) + ] + + let unActions = makeActions(actions) + + XCTAssertEqual(unActions.count, 1) + XCTAssertTrue(unActions[0] is UNTextInputNotificationAction) + + let textAction = unActions[0] as! UNTextInputNotificationAction + XCTAssertEqual(textAction.identifier, "reply") + XCTAssertEqual(textAction.textInputButtonTitle, "Send") + XCTAssertEqual(textAction.textInputPlaceholder, "Type here...") + } + + func testMakeActionsEmptyArray() { + let actions: [Action] = [] + let unActions = makeActions(actions) + XCTAssertTrue(unActions.isEmpty) + } + + // MARK: - makeAttachmentUrl Tests + + func testMakeAttachmentUrlValid() { + let url = makeAttachmentUrl("file:///path/to/image.png") + XCTAssertNotNil(url) + XCTAssertEqual(url?.scheme, "file") + } + + func testMakeAttachmentUrlHttp() { + let url = makeAttachmentUrl("https://example.com/image.png") + XCTAssertNotNil(url) + XCTAssertEqual(url?.scheme, "https") + } + + // MARK: - makeAttachmentOptions Tests + + func testMakeAttachmentOptionsWithTypeHint() { + let options = NotificationAttachmentOptions( + iosUNNotificationAttachmentOptionsTypeHintKey: "public.png", + iosUNNotificationAttachmentOptionsThumbnailHiddenKey: nil, + iosUNNotificationAttachmentOptionsThumbnailClippingRectKey: nil, + iosUNNotificationAttachmentOptionsThumbnailTimeKey: nil + ) + + let result = makeAttachmentOptions(options) + XCTAssertEqual(result[UNNotificationAttachmentOptionsTypeHintKey] as? String, "public.png") + } + + func testMakeAttachmentOptionsEmpty() { + let options = NotificationAttachmentOptions( + iosUNNotificationAttachmentOptionsTypeHintKey: nil, + iosUNNotificationAttachmentOptionsThumbnailHiddenKey: nil, + iosUNNotificationAttachmentOptionsThumbnailClippingRectKey: nil, + iosUNNotificationAttachmentOptionsThumbnailTimeKey: nil + ) + + let result = makeAttachmentOptions(options) + XCTAssertTrue(result.isEmpty) + } +} + +// MARK: - NotificationContent Tests + +final class NotificationContentTests: XCTestCase { + + func testMakeNotificationContentBasic() throws { + let notification = makeTestNotification(title: "Test Title", body: "Test Body") + + let content = try makeNotificationContent(notification) + + XCTAssertEqual(content.title, "Test Title") + XCTAssertEqual(content.body, "Test Body") + } + + func testMakeNotificationContentWithExtra() throws { + let notification = makeTestNotification( + extra: ["key1": "value1", "key2": "value2"] + ) + + let content = try makeNotificationContent(notification) + + let extra = content.userInfo["__EXTRA__"] as? [String: String] + XCTAssertNotNil(extra) + XCTAssertEqual(extra?["key1"], "value1") + XCTAssertEqual(extra?["key2"], "value2") + } + + func testMakeNotificationContentWithActionType() throws { + let notification = makeTestNotification(actionTypeId: "message_actions") + + let content = try makeNotificationContent(notification) + + XCTAssertEqual(content.categoryIdentifier, "message_actions") + } + + func testMakeNotificationContentWithGroup() throws { + let notification = makeTestNotification(group: "messages") + + let content = try makeNotificationContent(notification) + + XCTAssertEqual(content.threadIdentifier, "messages") + } + + func testMakeNotificationContentWithSound() throws { + let notification = makeTestNotification(sound: "custom_sound.wav") + + let content = try makeNotificationContent(notification) + + XCTAssertNotNil(content.sound) + } + + func testMakeNotificationContentWithoutBody() throws { + let notification = makeTestNotification(body: nil) + + let content = try makeNotificationContent(notification) + + XCTAssertEqual(content.title, "Test Title") + XCTAssertEqual(content.body, "") + } +} + +// MARK: - handleScheduledNotification Tests + +final class ScheduleHandlerTests: XCTestCase { + + func testHandleScheduledNotificationEveryHour() throws { + let schedule = NotificationSchedule.every(interval: .hour, count: 2) + + let trigger = try handleScheduledNotification(schedule) + + XCTAssertNotNil(trigger) + XCTAssertTrue(trigger is UNTimeIntervalNotificationTrigger) + + let timeTrigger = trigger as! UNTimeIntervalNotificationTrigger + XCTAssertTrue(timeTrigger.repeats) + // 2 hours = 7200 seconds + XCTAssertEqual(timeTrigger.timeInterval, 7200, accuracy: 1) + } + + func testHandleScheduledNotificationEveryDay() throws { + let schedule = NotificationSchedule.every(interval: .day, count: 1) + + let trigger = try handleScheduledNotification(schedule) + + XCTAssertNotNil(trigger) + XCTAssertTrue(trigger is UNTimeIntervalNotificationTrigger) + + let timeTrigger = trigger as! UNTimeIntervalNotificationTrigger + XCTAssertTrue(timeTrigger.repeats) + // 1 day = 86400 seconds + XCTAssertEqual(timeTrigger.timeInterval, 86400, accuracy: 1) + } + + func testHandleScheduledNotificationInterval() throws { + let interval = ScheduleInterval( + year: nil, + month: nil, + day: nil, + weekday: nil, + hour: 9, + minute: 30, + second: nil + ) + let schedule = NotificationSchedule.interval(interval: interval) + + let trigger = try handleScheduledNotification(schedule) + + XCTAssertNotNil(trigger) + XCTAssertTrue(trigger is UNCalendarNotificationTrigger) + + let calendarTrigger = trigger as! UNCalendarNotificationTrigger + XCTAssertTrue(calendarTrigger.repeats) + XCTAssertEqual(calendarTrigger.dateComponents.hour, 9) + XCTAssertEqual(calendarTrigger.dateComponents.minute, 30) + } + + func testHandleScheduledNotificationTooShortInterval() throws { + // 30 seconds is less than 60 seconds minimum + let schedule = NotificationSchedule.every(interval: .second, count: 30) + + XCTAssertThrowsError(try handleScheduledNotification(schedule)) { error in + XCTAssertTrue(error is NotificationError) + if case NotificationError.triggerRepeatIntervalTooShort = error { + // Expected + } else { + XCTFail("Expected triggerRepeatIntervalTooShort error") + } + } + } + + func testHandleScheduledNotificationAtFutureDate() throws { + let futureDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())! + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + let dateString = dateFormatter.string(from: futureDate) + + let schedule = NotificationSchedule.at(date: dateString, repeating: false) + + let trigger = try handleScheduledNotification(schedule) + + XCTAssertNotNil(trigger) + XCTAssertTrue(trigger is UNTimeIntervalNotificationTrigger) + + let timeTrigger = trigger as! UNTimeIntervalNotificationTrigger + XCTAssertFalse(timeTrigger.repeats) + // Should be roughly 3600 seconds (1 hour) + XCTAssertTrue(timeTrigger.timeInterval > 3500 && timeTrigger.timeInterval < 3700) + } + + func testHandleScheduledNotificationAtPastDate() throws { + let pastDate = Calendar.current.date(byAdding: .hour, value: -1, to: Date())! + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + let dateString = dateFormatter.string(from: pastDate) + + let schedule = NotificationSchedule.at(date: dateString, repeating: false) + + XCTAssertThrowsError(try handleScheduledNotification(schedule)) { error in + XCTAssertTrue(error is NotificationError) + if case NotificationError.pastScheduledTime = error { + // Expected + } else { + XCTFail("Expected pastScheduledTime error") + } + } + } + + func testHandleScheduledNotificationInvalidDate() throws { + let schedule = NotificationSchedule.at(date: "invalid-date", repeating: false) + + XCTAssertThrowsError(try handleScheduledNotification(schedule)) { error in + XCTAssertTrue(error is NotificationError) + if case NotificationError.invalidDate(_) = error { + // Expected + } else { + XCTFail("Expected invalidDate error") + } + } + } + + func testHandleScheduledNotificationRepeatingTooShort() throws { + // Create a date just 30 seconds in the future with repeating + let futureDate = Calendar.current.date(byAdding: .second, value: 30, to: Date())! + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + let dateString = dateFormatter.string(from: futureDate) + + let schedule = NotificationSchedule.at(date: dateString, repeating: true) + + XCTAssertThrowsError(try handleScheduledNotification(schedule)) { error in + XCTAssertTrue(error is NotificationError) + if case NotificationError.triggerRepeatIntervalTooShort = error { + // Expected + } else { + XCTFail("Expected triggerRepeatIntervalTooShort error") + } + } + } +} + +// MARK: - NotificationError Tests + +final class NotificationErrorTests: XCTestCase { + + func testErrorDescriptions() { + XCTAssertEqual( + NotificationError.triggerRepeatIntervalTooShort.errorDescription, + "Schedule interval too short, must be a least 1 minute" + ) + + XCTAssertEqual( + NotificationError.attachmentFileNotFound(path: "/path/to/file").errorDescription, + "Unable to find file /path/to/file for attachment" + ) + + XCTAssertEqual( + NotificationError.attachmentUnableToCreate("some error").errorDescription, + "Failed to create attachment: some error" + ) + + XCTAssertEqual( + NotificationError.pastScheduledTime.errorDescription, + "Scheduled time must be *after* current time" + ) + + XCTAssertEqual( + NotificationError.invalidDate("bad-date").errorDescription, + "Could not parse date bad-date" + ) + } +} + +// MARK: - Plugin Initialization Tests +// Note: Some plugin tests require a full application environment with notification center access. +// Tests that crash in pure unit test environments are skipped. + +final class PluginTests: XCTestCase { + var plugin: NotificationPlugin? + + override func setUp() { + super.setUp() + // Plugin initialization may fail in pure unit test environment + // We'll handle this gracefully + } + + override func tearDown() { + plugin = nil + super.tearDown() + } + + private func getPlugin() throws -> NotificationPlugin { + if plugin == nil { + plugin = initPlugin() + } + return try XCTUnwrap(plugin) + } + + func testPluginInitialization() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Plugin initialization requires a full application environment") + } + + func testCheckPermissionsReturnsValidJSON() async throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testCancelWithValidArgs() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testCancelWithEmptyArray() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testCancelAllDoesNotThrow() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testRegisterActionTypesWithValidArgs() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testRegisterActionTypesWithEmptyTypes() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testRemoveActiveWithValidArgs() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testRemoveAllActiveDoesNotThrow() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testSetClickListenerActiveTrue() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testSetClickListenerActiveFalse() throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testGetPendingReturnsValidJSON() async throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } + + func testGetActiveReturnsValidJSON() async throws { + // Skip in environments where notification center isn't available + throw XCTSkip("Requires notification center access") + } +} + +// MARK: - RustString Extension Tests + +final class RustStringExtensionTests: XCTestCase { + + func testDecodeValidJSON() throws { + let rustString = RustString("{\"notifications\": [1, 2, 3]}") + let result = try rustString.decode(CancelArgs.self) + XCTAssertEqual(result.notifications, [1, 2, 3]) + } + + func testDecodeInvalidJSON() { + let rustString = RustString("invalid json") + + do { + _ = try rustString.decode(CancelArgs.self) + XCTFail("Should have thrown an error") + } catch { + // Expected + XCTAssertTrue(error is FFIResult) + } + } + + func testDecodeComplexType() throws { + let json = """ + { + "types": [ + { + "id": "msg", + "actions": [ + {"id": "reply", "title": "Reply", "input": true} + ] + } + ] + } + """ + let rustString = RustString(json) + let result = try rustString.decode(RegisterActionTypesArgs.self) + + XCTAssertEqual(result.types.count, 1) + XCTAssertEqual(result.types[0].id, "msg") + XCTAssertEqual(result.types[0].actions[0].input, true) + } +} + +// MARK: - Encodable Extension Tests + +final class EncodableExtensionTests: XCTestCase { + + func testActiveNotificationToJSON() throws { + let notification = ActiveNotification( + id: 123, + title: "Test", + body: "Body", + sound: "default", + actionTypeId: "actions", + attachments: nil + ) + + let jsonString = try notification.toJSONString() + let data = try XCTUnwrap(jsonString.data(using: .utf8)) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(json["id"] as? Int, 123) + XCTAssertEqual(json["title"] as? String, "Test") + XCTAssertEqual(json["body"] as? String, "Body") + XCTAssertEqual(json["sound"] as? String, "default") + XCTAssertEqual(json["actionTypeId"] as? String, "actions") + } + + func testPendingNotificationToJSON() throws { + let notification = PendingNotification( + id: 456, + title: "Pending", + body: "Pending body", + schedule: .every(interval: .hour, count: 1) + ) + + let jsonString = try notification.toJSONString() + let data = try XCTUnwrap(jsonString.data(using: .utf8)) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(json["id"] as? Int, 456) + XCTAssertEqual(json["title"] as? String, "Pending") + } + + func testNotificationClickedDataToJSON() throws { + let clickedData = NotificationClickedData( + id: 789, + data: ["action": "tap", "source": "notification"] + ) + + let jsonString = try clickedData.toJSONString() + let data = try XCTUnwrap(jsonString.data(using: .utf8)) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(json["id"] as? Int, 789) + let dataDict = json["data"] as? [String: String] + XCTAssertEqual(dataDict?["action"], "tap") + XCTAssertEqual(dataDict?["source"], "notification") + } + + func testReceivedNotificationDataToJSON() throws { + let receivedData = ReceivedNotificationData( + id: 101, + title: "Push", + body: "Push body", + extra: ["key": "value"] + ) + + let jsonString = try receivedData.toJSONString() + let data = try XCTUnwrap(jsonString.data(using: .utf8)) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + XCTAssertEqual(json["id"] as? Int, 101) + XCTAssertEqual(json["title"] as? String, "Push") + XCTAssertEqual(json["body"] as? String, "Push body") + let extra = json["extra"] as? [String: String] + XCTAssertEqual(extra?["key"], "value") + } + + func testArrayToJSON() throws { + let notifications = [ + ActiveNotification(id: 1, title: "First", body: "Body 1", sound: "", actionTypeId: "", attachments: nil), + ActiveNotification(id: 2, title: "Second", body: "Body 2", sound: "", actionTypeId: "", attachments: nil) + ] + + let jsonString = try notifications.toJSONString() + let data = try XCTUnwrap(jsonString.data(using: .utf8)) + let json = try JSONSerialization.jsonObject(with: data) as! [[String: Any]] + + XCTAssertEqual(json.count, 2) + XCTAssertEqual(json[0]["id"] as? Int, 1) + XCTAssertEqual(json[1]["id"] as? Int, 2) + } +} + +// MARK: - NotificationHandler Tests + +final class NotificationHandlerTests: XCTestCase { + var handler: NotificationHandler! + + override func setUp() { + super.setUp() + handler = NotificationHandler() + } + + override func tearDown() { + handler = nil + super.tearDown() + } + + func testSaveAndRetrieveNotification() { + let notification = makeTestNotification(id: 123, title: "Saved", body: "Body") + handler.saveNotification("123", notification) + + // Create a mock request to test retrieval + let content = UNMutableNotificationContent() + content.title = "Saved" + content.body = "Body" + let request = UNNotificationRequest(identifier: "123", content: content, trigger: nil) + + let activeNotification = handler.toActiveNotification(request) + XCTAssertNotNil(activeNotification) + XCTAssertEqual(activeNotification?.id, 123) + XCTAssertEqual(activeNotification?.title, "Saved") + } + + func testToActiveNotificationReturnsNilForUnknown() { + let content = UNMutableNotificationContent() + content.title = "Unknown" + let request = UNNotificationRequest(identifier: "unknown_id", content: content, trigger: nil) + + let activeNotification = handler.toActiveNotification(request) + XCTAssertNil(activeNotification) + } + + func testToPendingNotificationReturnsNilForUnknown() { + let content = UNMutableNotificationContent() + content.title = "Unknown" + let request = UNNotificationRequest(identifier: "unknown_id", content: content, trigger: nil) + + let pendingNotification = handler.toPendingNotification(request) + XCTAssertNil(pendingNotification) + } + + func testToPendingNotificationWithSchedule() { + let schedule = NotificationSchedule.every(interval: .hour, count: 1) + let notification = makeTestNotification(id: 456, title: "Scheduled", schedule: schedule) + handler.saveNotification("456", notification) + + let content = UNMutableNotificationContent() + content.title = "Scheduled" + let request = UNNotificationRequest(identifier: "456", content: content, trigger: nil) + + let pendingNotification = handler.toPendingNotification(request) + XCTAssertNotNil(pendingNotification) + XCTAssertEqual(pendingNotification?.id, 456) + XCTAssertEqual(pendingNotification?.title, "Scheduled") + } + + func testSetClickListenerActive() { + // Initially false + handler.setClickListenerActive(true) + // Should not crash and store the state + + handler.setClickListenerActive(false) + // Should not crash + } +} diff --git a/permissions/autogenerated/commands/cancel_all.toml b/permissions/autogenerated/commands/cancel_all.toml new file mode 100644 index 00000000..4282d003 --- /dev/null +++ b/permissions/autogenerated/commands/cancel_all.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-cancel-all" +description = "Enables the cancel_all command without any pre-configured scope." +commands.allow = ["cancel_all"] + +[[permission]] +identifier = "deny-cancel-all" +description = "Denies the cancel_all command without any pre-configured scope." +commands.deny = ["cancel_all"] diff --git a/permissions/autogenerated/commands/remove_listener.toml b/permissions/autogenerated/commands/remove_listener.toml new file mode 100644 index 00000000..9f315e51 --- /dev/null +++ b/permissions/autogenerated/commands/remove_listener.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-remove-listener" +description = "Enables the remove_listener command without any pre-configured scope." +commands.allow = ["remove_listener"] + +[[permission]] +identifier = "deny-remove-listener" +description = "Denies the remove_listener command without any pre-configured scope." +commands.deny = ["remove_listener"] diff --git a/permissions/autogenerated/reference.md b/permissions/autogenerated/reference.md index c966a934..a7cd453a 100644 --- a/permissions/autogenerated/reference.md +++ b/permissions/autogenerated/reference.md @@ -16,7 +16,9 @@ It allows all notification related features. - `allow-notify` - `allow-register-action-types` - `allow-register-listener` +- `allow-remove-listener` - `allow-cancel` +- `allow-cancel-all` - `allow-get-pending` - `allow-remove-active` - `allow-get-active` @@ -93,6 +95,32 @@ Denies the cancel command without any pre-configured scope. +`notifications:allow-cancel-all` + + + + +Enables the cancel_all command without any pre-configured scope. + + + + + + + +`notifications:deny-cancel-all` + + + + +Denies the cancel_all command without any pre-configured scope. + + + + + + + `notifications:allow-check-permissions` @@ -431,6 +459,32 @@ Denies the remove_active command without any pre-configured scope. +`notifications:allow-remove-listener` + + + + +Enables the remove_listener command without any pre-configured scope. + + + + + + + +`notifications:deny-remove-listener` + + + + +Denies the remove_listener command without any pre-configured scope. + + + + + + + `notifications:allow-request-permission` diff --git a/permissions/default.toml b/permissions/default.toml index a9dbe04f..c0a38055 100644 --- a/permissions/default.toml +++ b/permissions/default.toml @@ -18,7 +18,9 @@ permissions = [ "allow-notify", "allow-register-action-types", "allow-register-listener", + "allow-remove-listener", "allow-cancel", + "allow-cancel-all", "allow-get-pending", "allow-remove-active", "allow-get-active", diff --git a/permissions/schemas/schema.json b/permissions/schemas/schema.json index c2d0da04..016923d9 100644 --- a/permissions/schemas/schema.json +++ b/permissions/schemas/schema.json @@ -318,6 +318,18 @@ "const": "deny-cancel", "markdownDescription": "Denies the cancel command without any pre-configured scope." }, + { + "description": "Enables the cancel_all command without any pre-configured scope.", + "type": "string", + "const": "allow-cancel-all", + "markdownDescription": "Enables the cancel_all command without any pre-configured scope." + }, + { + "description": "Denies the cancel_all command without any pre-configured scope.", + "type": "string", + "const": "deny-cancel-all", + "markdownDescription": "Denies the cancel_all command without any pre-configured scope." + }, { "description": "Enables the check_permissions command without any pre-configured scope.", "type": "string", @@ -474,6 +486,18 @@ "const": "deny-remove-active", "markdownDescription": "Denies the remove_active command without any pre-configured scope." }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, { "description": "Enables the request_permission command without any pre-configured scope.", "type": "string", @@ -523,10 +547,10 @@ "markdownDescription": "Denies the unregister_for_push_notifications command without any pre-configured scope." }, { - "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`", + "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`", "type": "string", "const": "default", - "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`" + "markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-register-for-push-notifications`\n- `allow-unregister-for-push-notifications`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-cancel`\n- `allow-cancel-all`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`\n- `allow-set-click-listener-active`" } ] } diff --git a/src/commands.rs b/src/commands.rs index 9308f35d..ee3d7ee0 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2,16 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +use serde::Deserialize; use tauri::{command, plugin::PermissionState, AppHandle, Runtime, State}; use crate::{NotificationData, Notifications, Result}; +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub(crate) struct NotificationIdentifier { + pub id: i32, + #[allow(dead_code)] + pub tag: Option, +} + #[command] pub(crate) async fn is_permission_granted( _app: AppHandle, notification: State<'_, Notifications>, ) -> Result> { - let state = notification.permission_state()?; + let state = notification.permission_state().await?; match state { PermissionState::Granted => Ok(Some(true)), PermissionState::Denied => Ok(Some(false)), @@ -24,41 +33,23 @@ pub(crate) async fn request_permission( _app: AppHandle, notification: State<'_, Notifications>, ) -> Result { - notification.request_permission() + notification.request_permission().await } #[command] pub(crate) async fn register_for_push_notifications( _app: AppHandle, - _notification: State<'_, Notifications>, + notification: State<'_, Notifications>, ) -> Result { - #[cfg(feature = "push-notifications")] - { - _notification.register_for_push_notifications() - } - #[cfg(not(feature = "push-notifications"))] - { - Err(crate::Error::Io(std::io::Error::other( - "Push notifications feature is not enabled", - ))) - } + notification.register_for_push_notifications().await } #[command] pub(crate) async fn unregister_for_push_notifications( _app: AppHandle, - _notification: State<'_, Notifications>, + notification: State<'_, Notifications>, ) -> Result<()> { - #[cfg(feature = "push-notifications")] - { - _notification.unregister_for_push_notifications() - } - #[cfg(not(feature = "push-notifications"))] - { - Err(crate::Error::Io(std::io::Error::other( - "Push notifications feature is not enabled", - ))) - } + notification.unregister_for_push_notifications() } #[command] @@ -69,5 +60,92 @@ pub(crate) async fn notify( ) -> Result<()> { let mut builder = notification.builder(); builder.data = options; - builder.show() + builder.show().await +} + +#[command] +pub(crate) async fn register_action_types( + _app: AppHandle, + notification: State<'_, Notifications>, + types: Vec, +) -> Result<()> { + notification.register_action_types(types) +} + +#[command] +pub(crate) async fn get_pending( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result> { + notification.pending().await +} + +#[command] +pub(crate) async fn get_active( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result> { + notification.active().await +} + +#[command] +pub(crate) fn set_click_listener_active( + _app: AppHandle, + notification: State<'_, Notifications>, + active: bool, +) -> Result<()> { + notification.set_click_listener_active(active) +} + +#[command] +pub(crate) fn remove_active( + _app: AppHandle, + notification: State<'_, Notifications>, + notifications: Vec, +) -> Result<()> { + let ids: Vec = notifications.into_iter().map(|n| n.id).collect(); + notification.remove_active(ids) +} + +#[command] +pub(crate) fn cancel( + _app: AppHandle, + notification: State<'_, Notifications>, + notifications: Vec, +) -> Result<()> { + notification.cancel(notifications) +} + +#[command] +pub(crate) fn cancel_all( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result<()> { + notification.cancel_all() +} + +#[command] +pub(crate) fn create_channel( + _app: AppHandle, + notification: State<'_, Notifications>, + channel: crate::Channel, +) -> Result<()> { + notification.create_channel(channel) +} + +#[command] +pub(crate) fn delete_channel( + _app: AppHandle, + notification: State<'_, Notifications>, + id: String, +) -> Result<()> { + notification.delete_channel(id) +} + +#[command] +pub(crate) fn list_channels( + _app: AppHandle, + notification: State<'_, Notifications>, +) -> Result> { + notification.list_channels() } diff --git a/src/desktop.rs b/src/desktop.rs index d5aaa275..bdfbbbc6 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -23,7 +23,7 @@ pub fn init( pub struct Notifications(AppHandle); impl crate::NotificationsBuilder { - pub fn show(self) -> crate::Result<()> { + pub async fn show(self) -> crate::Result<()> { let mut notification = imp::Notification::new(self.app.config().identifier.clone()); if let Some(title) = self @@ -51,11 +51,11 @@ impl Notifications { NotificationsBuilder::new(self.0.clone()) } - pub fn request_permission(&self) -> crate::Result { + pub async fn request_permission(&self) -> crate::Result { Ok(PermissionState::Granted) } - pub fn register_for_push_notifications(&self) -> crate::Result { + pub async fn register_for_push_notifications(&self) -> crate::Result { Err(crate::Error::Io(std::io::Error::other( "Push notifications are not supported on desktop platforms", ))) @@ -67,9 +67,69 @@ impl Notifications { ))) } - pub fn permission_state(&self) -> crate::Result { + pub async fn permission_state(&self) -> crate::Result { Ok(PermissionState::Granted) } + + pub async fn pending(&self) -> crate::Result> { + Err(crate::Error::Io(std::io::Error::other( + "Pending notifications are not supported with notify-rust", + ))) + } + + pub async fn active(&self) -> crate::Result> { + Err(crate::Error::Io(std::io::Error::other( + "Active notifications are not supported with notify-rust", + ))) + } + + pub fn set_click_listener_active(&self, _active: bool) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Click listeners are not supported with notify-rust", + ))) + } + + pub fn remove_active(&self, _ids: Vec) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Removing active notifications is not supported with notify-rust", + ))) + } + + pub fn cancel(&self, _notifications: Vec) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Canceling notifications is not supported with notify-rust", + ))) + } + + pub fn cancel_all(&self) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Canceling notifications is not supported with notify-rust", + ))) + } + + pub fn register_action_types(&self, _types: Vec) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Action types are not supported with notify-rust", + ))) + } + + pub fn create_channel(&self, _channel: crate::Channel) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Notification channels are not supported with notify-rust", + ))) + } + + pub fn delete_channel(&self, _id: impl Into) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Notification channels are not supported with notify-rust", + ))) + } + + pub fn list_channels(&self) -> crate::Result> { + Err(crate::Error::Io(std::io::Error::other( + "Notification channels are not supported with notify-rust", + ))) + } } mod imp { diff --git a/src/error.rs b/src/error.rs index 3c04607d..70d77064 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,6 +6,50 @@ use serde::{ser::Serializer, Serialize}; pub type Result = std::result::Result; +/// Replica of the tauri::plugin::mobile::ErrorResponse for desktop platforms. +#[cfg(desktop)] +#[derive(Debug, thiserror::Error, Clone, serde::Deserialize)] +pub struct ErrorResponse { + /// Error code. + pub code: Option, + /// Error message. + pub message: Option, + /// Optional error data. + #[serde(flatten)] + pub data: T, +} + +#[cfg(desktop)] +impl std::fmt::Display for ErrorResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(code) = &self.code { + write!(f, "[{code}]")?; + if self.message.is_some() { + write!(f, " - ")?; + } + } + if let Some(message) = &self.message { + write!(f, "{message}")?; + } + Ok(()) + } +} + +/// Replica of the tauri::plugin::mobile::PluginInvokeError for desktop platforms. +#[cfg(desktop)] +#[derive(Debug, thiserror::Error)] +pub enum PluginInvokeError { + /// Error returned from direct desktop plugin. + #[error(transparent)] + InvokeRejected(#[from] ErrorResponse), + /// Failed to deserialize response. + #[error("failed to deserialize response: {0}")] + CannotDeserializeResponse(serde_json::Error), + /// Failed to serialize request payload. + #[error("failed to serialize payload: {0}")] + CannotSerializePayload(serde_json::Error), +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] @@ -13,6 +57,9 @@ pub enum Error { #[cfg(mobile)] #[error(transparent)] PluginInvoke(#[from] tauri::plugin::mobile::PluginInvokeError), + #[cfg(desktop)] + #[error(transparent)] + PluginInvoke(#[from] crate::error::PluginInvokeError), } impl Serialize for Error { diff --git a/src/lib.rs b/src/lib.rs index 331fdcbe..120a2c94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,19 +17,25 @@ use tauri::{ pub use models::*; pub use tauri::plugin::PermissionState; -#[cfg(desktop)] +#[cfg(all(desktop, feature = "notify-rust"))] mod desktop; +#[cfg(all(target_os = "macos", not(feature = "notify-rust")))] +mod macos; #[cfg(mobile)] mod mobile; mod commands; mod error; +#[cfg(desktop)] +mod listeners; mod models; pub use error::{Error, Result}; -#[cfg(desktop)] +#[cfg(all(desktop, feature = "notify-rust"))] pub use desktop::Notifications; +#[cfg(all(target_os = "macos", not(feature = "notify-rust")))] +pub use macos::Notifications; #[cfg(mobile)] pub use mobile::Notifications; @@ -37,14 +43,17 @@ pub use mobile::Notifications; #[derive(Debug)] pub struct NotificationsBuilder { #[cfg(desktop)] + #[allow(dead_code)] app: AppHandle, + #[cfg(all(target_os = "macos", not(feature = "notify-rust")))] + plugin: std::sync::Arc, #[cfg(mobile)] handle: PluginHandle, pub(crate) data: NotificationData, } impl NotificationsBuilder { - #[cfg(desktop)] + #[cfg(all(desktop, feature = "notify-rust"))] fn new(app: AppHandle) -> Self { Self { app, @@ -52,6 +61,15 @@ impl NotificationsBuilder { } } + #[cfg(all(target_os = "macos", not(feature = "notify-rust")))] + fn new(app: AppHandle, plugin: std::sync::Arc) -> Self { + Self { + app, + plugin, + data: Default::default(), + } + } + #[cfg(mobile)] fn new(handle: PluginHandle) -> Self { Self { @@ -222,12 +240,30 @@ pub fn init() -> TauriPlugin { commands::register_for_push_notifications, commands::unregister_for_push_notifications, commands::is_permission_granted, + commands::register_action_types, + commands::get_pending, + commands::get_active, + commands::set_click_listener_active, + commands::remove_active, + commands::cancel, + commands::cancel_all, + commands::create_channel, + commands::delete_channel, + commands::list_channels, + #[cfg(desktop)] + listeners::register_listener, + #[cfg(desktop)] + listeners::remove_listener, ]) .setup(|app, api| { + #[cfg(desktop)] + listeners::init(); #[cfg(mobile)] let notification = mobile::init(app, api)?; - #[cfg(desktop)] + #[cfg(all(desktop, feature = "notify-rust"))] let notification = desktop::init(app, api)?; + #[cfg(all(target_os = "macos", not(feature = "notify-rust")))] + let notification = macos::init(app, api)?; app.manage(notification); Ok(()) }) diff --git a/src/listeners.rs b/src/listeners.rs new file mode 100644 index 00000000..b0d4fcfc --- /dev/null +++ b/src/listeners.rs @@ -0,0 +1,103 @@ +//! Shared listener management for desktop platforms. +//! +//! This is a replication of Tauri's plugin listener implementation which is +//! currently only available for mobile plugins. Once Tauri adds desktop support +//! for plugin listeners, this module can be removed. +//! +//! Provides channel-based event delivery for notification events such as +//! notification received, action performed, and notification clicked. + +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +use crate::error::{ErrorResponse, PluginInvokeError}; + +type ChannelMap = HashMap>; +type ListenerMap = HashMap; + +static LISTENERS: OnceLock> = OnceLock::new(); + +/// Initialize the listeners registry. Call this during plugin init. +pub fn init() { + let _ = LISTENERS.get_or_init(|| RwLock::new(HashMap::new())); +} + +/// Trigger an event to all registered listeners for the given event name. +/// +/// Called by platform-specific code when notification events occur. +#[allow(dead_code)] +pub fn trigger(event: &str, payload: String) -> crate::Result<()> { + let listeners = LISTENERS.get().ok_or_else(|| { + crate::Error::from(PluginInvokeError::InvokeRejected(ErrorResponse { + code: None, + message: Some("Listeners not initialized".to_string()), + data: (), + })) + })?; + + let guard = listeners.read().map_err(|e| { + crate::Error::from(PluginInvokeError::InvokeRejected(ErrorResponse { + code: None, + message: Some(format!("Failed to acquire read lock: {e}")), + data: (), + })) + })?; + + if let Some(channels) = guard.get(event) { + let value: serde_json::Value = serde_json::from_str(&payload).map_err(|e| { + crate::Error::from(PluginInvokeError::InvokeRejected(ErrorResponse { + code: None, + message: Some(format!("Failed to parse payload JSON: {e}")), + data: (), + })) + })?; + for channel in channels.values() { + let _ = channel.send(value.clone()); + } + } + Ok(()) +} + +/// Register a channel to receive events for the given event name. +#[tauri::command] +pub(crate) fn register_listener( + event: String, + handler: tauri::ipc::Channel, +) -> crate::Result<()> { + let listeners = LISTENERS.get_or_init(|| RwLock::new(HashMap::new())); + let mut guard = listeners.write().map_err(|e| { + crate::Error::from(PluginInvokeError::InvokeRejected(ErrorResponse { + code: None, + message: Some(format!("Failed to acquire write lock: {e}")), + data: (), + })) + })?; + guard + .entry(event) + .or_default() + .insert(handler.id(), handler); + Ok(()) +} + +/// Remove a previously registered listener by event name and channel ID. +#[tauri::command] +pub(crate) fn remove_listener(event: String, channel_id: u32) -> crate::Result<()> { + let listeners = LISTENERS.get().ok_or_else(|| { + crate::Error::from(PluginInvokeError::InvokeRejected(ErrorResponse { + code: None, + message: Some("Listeners not initialized".to_string()), + data: (), + })) + })?; + let mut guard = listeners.write().map_err(|e| { + crate::Error::from(PluginInvokeError::InvokeRejected(ErrorResponse { + code: None, + message: Some(format!("Failed to acquire write lock: {e}")), + data: (), + })) + })?; + if let Some(channels) = guard.get_mut(&event) { + channels.remove(&channel_id); + } + Ok(()) +} diff --git a/src/macos.rs b/src/macos.rs new file mode 100644 index 00000000..8e9b849e --- /dev/null +++ b/src/macos.rs @@ -0,0 +1,341 @@ +use serde::de::DeserializeOwned; +use tauri::{ + plugin::{PermissionState, PluginApi}, + AppHandle, Runtime, +}; + +use crate::models::*; + +use std::{collections::HashMap, sync::Arc}; + +pub use ffi::NotificationPlugin; + +/// Validation checks for macOS notifications functionality. +/// +/// UserNotifications requires the app to run from a signed .app bundle. +/// During development with `tauri dev`, the binary runs +/// directly without a bundle, causing UserNotifications calls to fail silently or crash. +mod validation { + /// Ensures the app is running from a .app bundle. + pub fn require_bundle() -> crate::Result<()> { + std::env::current_exe() + .ok() + .and_then(|exe| { + let macos = exe.parent()?; + let contents = macos.parent()?; + let bundle = contents.parent()?; + (macos.ends_with("MacOS") + && contents.ends_with("Contents") + && bundle.to_string_lossy().ends_with(".app")) + .then_some(()) + }) + .ok_or_else(|| { + crate::error::PluginInvokeError::InvokeRejected(crate::error::ErrorResponse { + code: None, + message: Some("Notifications plugin requires the app to run from a .app bundle. You can enable notify-rust feature for development.".to_string()), + data: (), + }) + .into() + }) + } +} + +#[swift_bridge::bridge] +mod ffi { + pub enum FFIResult { + Err(String), // error message from Swift + } + + extern "Rust" { + #[swift_bridge(swift_name = "bridgeTrigger")] + fn bridge_trigger(event: String, payload: String) -> Result<(), FFIResult>; + } + + extern "Swift" { + #[swift_bridge(Sendable)] + type NotificationPlugin; + #[swift_bridge(init, swift_name = "initPlugin")] + fn init_plugin() -> NotificationPlugin; + + async fn show(&self, args: String) -> Result; + + async fn requestPermissions(&self) -> Result; + async fn registerForPushNotifications(&self) -> Result; + fn unregisterForPushNotifications(&self) -> Result<(), FFIResult>; + async fn checkPermissions(&self) -> Result; + fn cancel(&self, args: String) -> Result<(), FFIResult>; + fn cancelAll(&self) -> Result<(), FFIResult>; + async fn getPending(&self) -> Result; + fn registerActionTypes(&self, args: String) -> Result<(), FFIResult>; + fn removeActive(&self, args: String) -> Result<(), FFIResult>; + fn removeAllActive(&self) -> Result<(), FFIResult>; + async fn getActive(&self) -> Result; + fn setClickListenerActive(&self, args: String) -> Result<(), FFIResult>; + } +} + +impl std::fmt::Debug for ffi::NotificationPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NotificationPlugin").finish() + } +} + +/// Extension trait for parsing FFI responses from Swift into typed Rust results. +trait ParseFfiResponse { + /// Deserializes a JSON response into the target type, converting FFI errors + /// into plugin errors. + fn parse(self) -> crate::Result; +} + +impl ParseFfiResponse for Result { + fn parse(self) -> crate::Result { + match self { + Ok(json) => serde_json::from_str(&json) + .map_err(|e| crate::error::PluginInvokeError::CannotDeserializeResponse(e).into()), + Err(ffi::FFIResult::Err(msg)) => Err(crate::error::PluginInvokeError::InvokeRejected( + crate::error::ErrorResponse { + code: None, + message: Some(msg), + data: (), + }, + ) + .into()), + } + } +} + +trait ParseFfiVoidResponse { + fn parse_void(self) -> crate::Result<()>; +} + +impl ParseFfiVoidResponse for Result<(), ffi::FFIResult> { + fn parse_void(self) -> crate::Result<()> { + match self { + Ok(()) => Ok(()), + Err(ffi::FFIResult::Err(msg)) => Err(crate::error::PluginInvokeError::InvokeRejected( + crate::error::ErrorResponse { + code: None, + message: Some(msg), + data: (), + }, + ) + .into()), + } + } +} + +impl ParseFfiVoidResponse for Result { + fn parse_void(self) -> crate::Result<()> { + match self { + Ok(_) => Ok(()), + Err(ffi::FFIResult::Err(msg)) => Err(crate::error::PluginInvokeError::InvokeRejected( + crate::error::ErrorResponse { + code: None, + message: Some(msg), + data: (), + }, + ) + .into()), + } + } +} + +/// Called by Swift via FFI when transaction updates occur. +fn bridge_trigger(event: String, payload: String) -> Result<(), ffi::FFIResult> { + crate::listeners::trigger(&event, payload) + .map_err(|e| ffi::FFIResult::Err(format!("Failed to trigger event '{event}': {e}"))) +} + +pub fn init( + app: &AppHandle, + _api: PluginApi, +) -> crate::Result> { + validation::require_bundle()?; + + Ok(Notifications { + app: app.clone(), + plugin: Arc::new(ffi::NotificationPlugin::init_plugin()), + }) +} + +impl crate::NotificationsBuilder { + pub async fn show(self) -> crate::Result<()> { + validation::require_bundle()?; + + self.plugin + .show( + serde_json::to_string(&self.data) + .map_err(|e| crate::error::PluginInvokeError::CannotSerializePayload(e))?, + ) + .await + .parse_void() + } +} + +pub struct Notifications { + app: AppHandle, + plugin: Arc, +} + +impl Notifications { + pub fn builder(&self) -> crate::NotificationsBuilder { + crate::NotificationsBuilder::new(self.app.clone(), self.plugin.clone()) + } + + pub async fn request_permission(&self) -> crate::Result { + validation::require_bundle()?; + + let response: crate::PermissionResponse = self.plugin.requestPermissions().await.parse()?; + Ok(response.permission_state) + } + + pub async fn register_for_push_notifications(&self) -> crate::Result { + validation::require_bundle()?; + + #[cfg(feature = "push-notifications")] + { + let response: crate::PushNotificationResponse = + self.plugin.registerForPushNotifications().await.parse()?; + Ok(response.device_token) + } + #[cfg(not(feature = "push-notifications"))] + { + Err(crate::Error::Io(std::io::Error::other( + "Push notifications feature is not enabled", + ))) + } + } + + pub fn unregister_for_push_notifications(&self) -> crate::Result<()> { + validation::require_bundle()?; + + #[cfg(feature = "push-notifications")] + { + self.plugin.unregisterForPushNotifications().parse_void() + } + #[cfg(not(feature = "push-notifications"))] + { + Err(crate::Error::Io(std::io::Error::other( + "Push notifications feature is not enabled", + ))) + } + } + + pub async fn permission_state(&self) -> crate::Result { + validation::require_bundle()?; + + let response: crate::PermissionResponse = self.plugin.checkPermissions().await.parse()?; + Ok(response.permission_state) + } + + pub fn register_action_types(&self, types: Vec) -> crate::Result<()> { + validation::require_bundle()?; + + let mut args = HashMap::new(); + args.insert("types", types); + self.plugin + .registerActionTypes( + serde_json::to_string(&args) + .map_err(|e| crate::error::PluginInvokeError::CannotSerializePayload(e))?, + ) + .parse_void() + } + + pub fn remove_active(&self, notifications: Vec) -> crate::Result<()> { + validation::require_bundle()?; + + let mut args = HashMap::new(); + args.insert( + "notifications", + notifications + .into_iter() + .map(|id| { + let mut notification = HashMap::new(); + notification.insert("id", id); + notification + }) + .collect::>>(), + ); + self.plugin + .removeActive( + serde_json::to_string(&args) + .map_err(|e| crate::error::PluginInvokeError::CannotSerializePayload(e))?, + ) + .parse_void() + } + + pub async fn active(&self) -> crate::Result> { + validation::require_bundle()?; + + self.plugin.getActive().await.parse() + } + + pub fn remove_all_active(&self) -> crate::Result<()> { + validation::require_bundle()?; + + self.plugin.removeAllActive().parse_void() + } + + pub async fn pending(&self) -> crate::Result> { + validation::require_bundle()?; + + self.plugin.getPending().await.parse() + } + + /// Cancel pending notifications. + pub fn cancel(&self, notifications: Vec) -> crate::Result<()> { + validation::require_bundle()?; + + let mut args = HashMap::new(); + args.insert("notifications", notifications); + self.plugin + .cancel( + serde_json::to_string(&args) + .map_err(|e| crate::error::PluginInvokeError::CannotSerializePayload(e))?, + ) + .parse_void() + } + + /// Cancel all pending notifications. + pub fn cancel_all(&self) -> crate::Result<()> { + validation::require_bundle()?; + + self.plugin.cancelAll().parse_void() + } + + /// Set click listener active state. + /// Used internally to track if JS listener is registered. + pub fn set_click_listener_active(&self, active: bool) -> crate::Result<()> { + validation::require_bundle()?; + + let mut args = HashMap::new(); + args.insert("active", active); + self.plugin + .setClickListenerActive( + serde_json::to_string(&args) + .map_err(|e| crate::error::PluginInvokeError::CannotSerializePayload(e))?, + ) + .parse_void() + } + + /// Create a notification channel (not supported on macOS). + pub fn create_channel(&self, _channel: crate::Channel) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Notification channels are not supported on macOS", + ))) + } + + /// Delete a notification channel (not supported on macOS). + pub fn delete_channel(&self, _id: impl Into) -> crate::Result<()> { + Err(crate::Error::Io(std::io::Error::other( + "Notification channels are not supported on macOS", + ))) + } + + /// List notification channels (not supported on macOS). + pub fn list_channels(&self) -> crate::Result> { + Err(crate::Error::Io(std::io::Error::other( + "Notification channels are not supported on macOS", + ))) + } +} diff --git a/src/mobile.rs b/src/mobile.rs index 889da4cf..d952ffe1 100644 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use serde::{de::DeserializeOwned, Deserialize}; +use serde::de::DeserializeOwned; use tauri::{ plugin::{PermissionState, PluginApi, PluginHandle}, AppHandle, Runtime, @@ -31,9 +31,10 @@ pub fn init( } impl crate::NotificationsBuilder { - pub fn show(self) -> crate::Result<()> { + pub async fn show(self) -> crate::Result<()> { self.handle - .run_mobile_plugin::("show", self.data) + .run_mobile_plugin_async::("show", self.data) + .await .map(|_| ()) .map_err(Into::into) } @@ -49,18 +50,23 @@ impl Notifications { crate::NotificationsBuilder::new(self.0.clone()) } - pub fn request_permission(&self) -> crate::Result { + pub async fn request_permission(&self) -> crate::Result { self.0 - .run_mobile_plugin::("requestPermissions", ()) + .run_mobile_plugin_async::("requestPermissions", ()) + .await .map(|r| r.permission_state) .map_err(Into::into) } - pub fn register_for_push_notifications(&self) -> crate::Result { + pub async fn register_for_push_notifications(&self) -> crate::Result { #[cfg(feature = "push-notifications")] { self.0 - .run_mobile_plugin::("registerForPushNotifications", ()) + .run_mobile_plugin_async::( + "registerForPushNotifications", + (), + ) + .await .map(|r| r.device_token) .map_err(Into::into) } @@ -87,9 +93,10 @@ impl Notifications { } } - pub fn permission_state(&self) -> crate::Result { + pub async fn permission_state(&self) -> crate::Result { self.0 - .run_mobile_plugin::("checkPermissions", ()) + .run_mobile_plugin_async::("checkPermissions", ()) + .await .map(|r| r.permission_state) .map_err(Into::into) } @@ -120,9 +127,10 @@ impl Notifications { .map_err(Into::into) } - pub fn active(&self) -> crate::Result> { + pub async fn active(&self) -> crate::Result> { self.0 - .run_mobile_plugin("getActive", ()) + .run_mobile_plugin_async("getActive", ()) + .await .map_err(Into::into) } @@ -132,9 +140,10 @@ impl Notifications { .map_err(Into::into) } - pub fn pending(&self) -> crate::Result> { + pub async fn pending(&self) -> crate::Result> { self.0 - .run_mobile_plugin("getPending", ()) + .run_mobile_plugin_async("getPending", ()) + .await .map_err(Into::into) } @@ -147,35 +156,54 @@ impl Notifications { /// Cancel all pending notifications. pub fn cancel_all(&self) -> crate::Result<()> { - self.0.run_mobile_plugin("cancel", ()).map_err(Into::into) + self.0 + .run_mobile_plugin("cancelAll", ()) + .map_err(Into::into) } - #[cfg(target_os = "android")] + #[allow(unused_variables)] pub fn create_channel(&self, channel: Channel) -> crate::Result<()> { - self.0 + #[cfg(target_os = "android")] + return self + .0 .run_mobile_plugin("createChannel", channel) - .map_err(Into::into) + .map_err(Into::into); + #[cfg(target_os = "ios")] + return Err(crate::Error::Io(std::io::Error::other( + "Channels are not supported on iOS", + ))); } - #[cfg(target_os = "android")] + #[allow(unused_variables)] pub fn delete_channel(&self, id: impl Into) -> crate::Result<()> { - let mut args = HashMap::new(); - args.insert("id", id.into()); - self.0 - .run_mobile_plugin("deleteChannel", args) - .map_err(Into::into) + #[cfg(target_os = "android")] + { + let mut args = HashMap::new(); + args.insert("id", id.into()); + self.0 + .run_mobile_plugin("deleteChannel", args) + .map_err(Into::into) + } + #[cfg(target_os = "ios")] + return Err(crate::Error::Io(std::io::Error::other( + "Channels are not supported on iOS", + ))); } - #[cfg(target_os = "android")] pub fn list_channels(&self) -> crate::Result> { - self.0 + #[cfg(target_os = "android")] + return self + .0 .run_mobile_plugin("listChannels", ()) - .map_err(Into::into) + .map_err(Into::into); + #[cfg(target_os = "ios")] + return Err(crate::Error::Io(std::io::Error::other( + "Channels are not supported on iOS", + ))); } /// Set click listener active state. /// Used internally to track if JS listener is registered. - #[cfg(target_os = "ios")] pub fn set_click_listener_active(&self, active: bool) -> crate::Result<()> { let mut args = HashMap::new(); args.insert("active", active); @@ -184,17 +212,3 @@ impl Notifications { .map_err(Into::into) } } - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PermissionResponse { - permission_state: PermissionState, -} - -#[cfg(feature = "push-notifications")] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PushNotificationResponse { - #[allow(dead_code)] - device_token: String, -} diff --git a/src/models.rs b/src/models.rs index 1db93fda..f89e9903 100644 --- a/src/models.rs +++ b/src/models.rs @@ -5,9 +5,23 @@ use std::{collections::HashMap, fmt::Display}; use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; +use tauri::plugin::PermissionState; use url::Url; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionResponse { + pub permission_state: PermissionState, +} + +#[cfg(feature = "push-notifications")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PushNotificationResponse { + pub device_token: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Attachment { @@ -209,7 +223,7 @@ impl Default for NotificationData { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PendingNotification { id: i32, @@ -236,7 +250,7 @@ impl PendingNotification { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ActiveNotification { id: i32, @@ -307,37 +321,41 @@ impl ActiveNotification { } } -#[cfg(mobile)] -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ActionType { id: String, actions: Vec, hidden_previews_body_placeholder: Option, + #[serde(default)] custom_dismiss_action: bool, + #[serde(default)] allow_in_car_play: bool, + #[serde(default)] hidden_previews_show_title: bool, + #[serde(default)] hidden_previews_show_subtitle: bool, } -#[cfg(mobile)] -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Action { id: String, title: String, + #[serde(default)] requires_authentication: bool, + #[serde(default)] foreground: bool, + #[serde(default)] destructive: bool, + #[serde(default)] input: bool, input_button_title: Option, input_placeholder: Option, } -#[cfg(target_os = "android")] pub use android::*; -#[cfg(target_os = "android")] mod android { use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; @@ -368,10 +386,10 @@ mod android { name: String, description: Option, sound: Option, - lights: bool, + lights: Option, light_color: Option, - vibration: bool, - importance: Importance, + vibration: Option, + importance: Option, visibility: Option, } @@ -385,9 +403,9 @@ mod android { name: name.into(), description: None, sound: None, - lights: false, + lights: Some(false), light_color: None, - vibration: false, + vibration: Some(false), importance: Default::default(), visibility: None, }) @@ -410,7 +428,7 @@ mod android { } pub fn lights(&self) -> bool { - self.lights + self.lights.unwrap_or(false) } pub fn light_color(&self) -> Option<&str> { @@ -418,11 +436,11 @@ mod android { } pub fn vibration(&self) -> bool { - self.vibration + self.vibration.unwrap_or(false) } pub fn importance(&self) -> Importance { - self.importance + self.importance.unwrap_or_default() } pub fn visibility(&self) -> Option { @@ -442,7 +460,7 @@ mod android { } pub fn lights(mut self, lights: bool) -> Self { - self.0.lights = lights; + self.0.lights = Some(lights); self } @@ -452,12 +470,12 @@ mod android { } pub fn vibration(mut self, vibration: bool) -> Self { - self.0.vibration = vibration; + self.0.vibration = Some(vibration); self } pub fn importance(mut self, importance: Importance) -> Self { - self.0.importance = importance; + self.0.importance = Some(importance); self }