tauri-plugin-widgets 0.4.3

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
import Tauri
import UIKit
import WebKit

#if canImport(WidgetKit)
import WidgetKit
#endif

struct SetItemsArgs: Decodable {
    let key: String
    let value: String
    let group: String
}

struct GetItemsArgs: Decodable {
    let key: String
    let group: String
}

struct SetRegisterWidgetArgs: Decodable {
    let widgets: [String]
}

struct ReloadTimelinesArgs: Decodable {
    let ofKind: String
}

struct SetWidgetConfigArgs: Decodable {
    let config: String
    let group: String
    let widgetId: String
}

struct GetWidgetConfigArgs: Decodable {
    let group: String
    let widgetId: String
}

struct GroupArgs: Decodable {
    let group: String
}

class WidgetPlugin: Plugin {
    private var registeredWidgets: [String] = []
    private let safeRegex = try! NSRegularExpression(pattern: "[^A-Za-z0-9._:-]", options: [])
    private let groupSafeRegex = try! NSRegularExpression(pattern: "[^A-Za-z0-9._-]", options: [])

    private let configPrefix = "config:"
    private let pendingActionsKey = "pending_actions"
    private let metaNonceKey = "__meta_nonce__"
    private let metaUpdatedAtKey = "__meta_updated_at__"

    private func sanitize(_ value: String, fallback: String = "_") -> String {
        let range = NSRange(location: 0, length: value.utf16.count)
        let cleaned = safeRegex.stringByReplacingMatches(in: value, options: [], range: range, withTemplate: "_")
        let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? fallback : trimmed
    }

    private func sanitizeGroup(_ value: String, fallback: String = "_") -> String {
        let range = NSRange(location: 0, length: value.utf16.count)
        let cleaned = groupSafeRegex.stringByReplacingMatches(in: value, options: [], range: range, withTemplate: "_")
        let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? fallback : trimmed
    }

    /// App Group must be explicit `group.*` — no silent substitution.
    private func resolveAppGroup(_ group: String) throws -> String {
        let clean = sanitizeGroup(group, fallback: "")
        guard clean.hasPrefix("group.") else {
            throw NSError(
                domain: "tauri-plugin-widgets",
                code: 1,
                userInfo: [NSLocalizedDescriptionKey: "group must be an App Group id starting with 'group.'"]
            )
        }
        return clean
    }

    private func dataFileURL(group: String) throws -> URL {
        let appGroup = try resolveAppGroup(group)
        guard let url = FileManager.default.containerURL(
            forSecurityApplicationGroupIdentifier: appGroup
        )?.appendingPathComponent("widget_data.json") else {
            throw NSError(
                domain: "tauri-plugin-widgets",
                code: 2,
                userInfo: [NSLocalizedDescriptionKey: "App Group container unavailable for \(appGroup). Enable App Groups."]
            )
        }
        return url
    }

    private func readDataMap(group: String) throws -> [String: String] {
        let url = try dataFileURL(group: group)
        guard let data = try? Data(contentsOf: url),
              let map = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
            return [:]
        }
        return map
    }

    private func touchMeta(_ map: inout [String: String]) {
        let next = (UInt64(map[metaNonceKey] ?? "0") ?? 0) &+ 1
        map[metaNonceKey] = String(next)
        map[metaUpdatedAtKey] = String(UInt64(Date().timeIntervalSince1970 * 1000))
    }

    private func writeDataMap(_ map: [String: String], group: String) throws {
        let url = try dataFileURL(group: group)
        let data = try JSONSerialization.data(withJSONObject: map, options: [.prettyPrinted, .sortedKeys])
        try data.write(to: url, options: .atomic)

        // Also mirror into App Group UserDefaults when suite is available.
        let appGroup = try resolveAppGroup(group)
        if let defaults = UserDefaults(suiteName: appGroup) {
            defaults.set(map, forKey: "widget_data")
            defaults.synchronize()
        }
    }

    private func configKey(_ widgetId: String) -> String {
        configPrefix + widgetId
    }

    @objc public override func load(webview: WKWebView) {}

    @objc func setItems(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(SetItemsArgs.self)
        let safeKey = sanitize(args.key)
        var map = try readDataMap(group: args.group)
        map[safeKey] = args.value
        touchMeta(&map)
        try writeDataMap(map, group: args.group)
        invoke.resolve(["results": true])
    }

    @objc func getItems(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(GetItemsArgs.self)
        let safeKey = sanitize(args.key)
        let map = try readDataMap(group: args.group)
        if let value = map[safeKey] {
            invoke.resolve(["results": value])
        } else {
            invoke.resolve(["results": NSNull()])
        }
    }

    @objc func setRegisterWidget(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(SetRegisterWidgetArgs.self)
        guard !args.widgets.isEmpty else {
            invoke.reject("widgets must be a non-empty array")
            return
        }
        registeredWidgets = args.widgets
        invoke.resolve(["results": true])
    }

    @objc func setWidgetConfig(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(SetWidgetConfigArgs.self)
        guard !args.widgetId.isEmpty else {
            invoke.reject("widgetId must not be empty")
            return
        }
        var map = try readDataMap(group: args.group)
        map[configKey(args.widgetId)] = args.config
        touchMeta(&map)
        try writeDataMap(map, group: args.group)
        invoke.resolve(["results": true])
    }

    @objc func getWidgetConfig(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(GetWidgetConfigArgs.self)
        guard !args.widgetId.isEmpty else {
            invoke.reject("widgetId must not be empty")
            return
        }
        let map = try readDataMap(group: args.group)
        if let value = map[configKey(args.widgetId)] {
            invoke.resolve(["results": value])
        } else {
            invoke.resolve(["results": NSNull()])
        }
    }

    @objc func pollPendingActions(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(GroupArgs.self)
        // Read → take → write with retry so a concurrent Intent append is not wiped.
        var out: [[String: Any]] = []
        for _ in 0..<5 {
            var map = try readDataMap(group: args.group)
            let raw = map[pendingActionsKey] ?? "[]"
            var claimed: [[String: Any]] = []
            if let data = raw.data(using: .utf8),
               let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
                claimed = arr
            }
            map[pendingActionsKey] = "[]"
            touchMeta(&map)
            try writeDataMap(map, group: args.group)
            // Re-read: if producer appended after our read, merge leftovers back.
            var again = try readDataMap(group: args.group)
            let againRaw = again[pendingActionsKey] ?? "[]"
            if let data = againRaw.data(using: .utf8),
               let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
               !arr.isEmpty {
                // We cleared; producer wrote after us — keep their queue, return what we claimed.
                out = claimed
                break
            }
            out = claimed
            break
        }
        invoke.resolve(["results": out])
    }

    @objc func reloadAllTimelines(_ invoke: Invoke) throws {
        #if canImport(WidgetKit)
        if #available(iOS 14.0, macOS 11.0, *) {
            WidgetCenter.shared.reloadAllTimelines()
            invoke.resolve(["results": true])
        } else {
            invoke.reject("WidgetKit requires iOS 14.0+ / macOS 11.0+")
        }
        #else
        invoke.reject("WidgetKit is not available on this platform")
        #endif
    }

    @objc func reloadTimelines(_ invoke: Invoke) throws {
        let args = try invoke.parseArgs(ReloadTimelinesArgs.self)

        #if canImport(WidgetKit)
        if #available(iOS 14.0, macOS 11.0, *) {
            WidgetCenter.shared.reloadTimelines(ofKind: args.ofKind)
            invoke.resolve(["results": true])
        } else {
            invoke.reject("WidgetKit requires iOS 14.0+ / macOS 11.0+")
        }
        #else
        invoke.reject("WidgetKit is not available on this platform")
        #endif
    }

    @objc func requestWidget(_ invoke: Invoke) throws {
        invoke.reject("requestWidget is not supported on iOS/macOS. Users add widgets manually via the widget gallery.")
    }
}

@_cdecl("init_plugin_widgets")
func initPlugin() -> Plugin {
    return WidgetPlugin()
}