tauri-plugin-widgets 0.8.0

Tauri plugin for App Widgets on Android, iOS, and macOS (WidgetKit); Windows Widgets Board (Adaptive Cards) + desktop webview; Linux desktop webview with X11 DESKTOP pin.
Documentation
package git.s00d.widgets

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.AppWidgetId
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import org.json.JSONObject

private const val CONFIG_STATE_KEY_NAME = "__widget_config_state__"
private const val NONCE_STATE_KEY_NAME = "__widget_nonce_state__"
internal val CONFIG_STATE_KEY = stringPreferencesKey(CONFIG_STATE_KEY_NAME)
internal val NONCE_STATE_KEY = stringPreferencesKey(NONCE_STATE_KEY_NAME)

internal val ACTION_KEY = ActionParameters.Key<String>("action")
internal val PAYLOAD_KEY = ActionParameters.Key<String>("payload")
internal val URL_KEY = ActionParameters.Key<String>("url")
internal val SET_KEY = ActionParameters.Key<String>("set")

class WidgetActionCallback : ActionCallback {
    override suspend fun onAction(
        context: Context,
        glanceId: androidx.glance.GlanceId,
        parameters: ActionParameters,
    ) {
        val appWidgetId = (glanceId as? AppWidgetId)?.appWidgetId ?: -1
        val group = WidgetStoreKeys.resolveGroup(context, appWidgetId)
        val action = parameters[ACTION_KEY]
        val payload = parameters[PAYLOAD_KEY]
        val url = parameters[URL_KEY]
        val set = parameters[SET_KEY]
        val normalizedUrl = url?.trim().orEmpty().let {
            if (it.equals("null", ignoreCase = true) || it.equals("undefined", ignoreCase = true)) "" else it
        }
        val logicalWidgetId = WidgetStoreKeys.resolveWidgetId(context, appWidgetId)

        // Contract v3 `set`: write the placement's state in-process first, then the action.
        val stateChanged = !set.isNullOrBlank() && applySet(context, appWidgetId, set)

        // Action-driven buttons are the primary flow in widgets.
        // Only attempt URL open when no action is provided.
        if (!action.isNullOrBlank()) {
            // Always queue (one delivery path, nothing lost). A running app is woken to drain
            // it now; otherwise the host drains on Ready/Resumed.
            WidgetStoreKeys.enqueueAction(context, group, action, payload, logicalWidgetId)
            WidgetBridgePlugin.pluginInstance?.takeIf { it.isAlive() }?.notifyPendingActions(group)
        } else if (normalizedUrl.isNotBlank()) {
            val uri = Uri.parse(normalizedUrl)
            val intent = Intent(Intent.ACTION_VIEW, uri).apply {
                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            }
            val canHandle = intent.resolveActivity(context.packageManager) != null
            if (canHandle) {
                runCatching { context.startActivity(intent) }
                    .onFailure { e -> Log.e(TAG, "action open url failed=${e.message} url=$normalizedUrl", e) }
            } else {
                Log.w(TAG, "action open url skipped: no handler url=$normalizedUrl")
            }
        }

        if (stateChanged) {
            // A running session recomposes only on a Glance state change; WidgetRoot re-reads prefs.
            runCatching {
                updateAppWidgetState(context, glanceId) { it[NONCE_STATE_KEY] = System.currentTimeMillis().toString() }
            }.onFailure { e -> Log.e(TAG, "state nonce bump failed=${e.message}", e) }
        }
        runCatching { TauriGlanceWidget().update(context, glanceId) }
            .onFailure { e -> Log.e(TAG, "action-triggered update failed=${e.message}", e) }
    }

    /** Apply element `set` to the placement's stored state; false when nothing was written. */
    private fun applySet(context: Context, appWidgetId: Int, setRaw: String): Boolean {
        val set = Resolve.json(setRaw) as? JSONObject ?: return false
        val p = Placement.read(context, appWidgetId)
        val prefs = context.getSharedPreferences(p.group, Context.MODE_PRIVATE)
        val defaults = (Resolve.json(p.configRaw) as? JSONObject)?.opt("state")
        val current = WidgetStoreKeys.readState(prefs, p.widgetId, p.instance, defaults)
        WidgetStoreKeys.writeState(prefs, p.widgetId, p.instance, WidgetStoreKeys.applySet(current, set))
        val merged = WidgetStoreKeys.readState(prefs, p.widgetId, p.instance, defaults)
        WidgetBridgePlugin.pluginInstance?.takeIf { it.isAlive() }?.notifyStateChanged(p.widgetId, p.instance, merged)
        return true
    }
}