mobiler 0.34.0

Build mobile apps in Rust — one core, native UI on Android, iOS, and the web (CLI)
import SwiftUI
import SharedTypes
import UIKit
import UserNotifications

/// App entry — the generic Mobiler shell. `Core` drives the Rust core; `render`
/// turns its `Widget` tree into SwiftUI. The whole UI is decided in Rust.
@main
struct {{NAME}}App: App {
    // Bridges UIKit app-lifecycle + APNs/notification callbacks (which only arrive on a
    // UIApplicationDelegate) into the SwiftUI app — see AppDelegate + PushBridge below.
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    @StateObject private var core = Core()

    var body: some Scene {
        WindowGroup {
            RootView(core: core)
        }
    }
}

private struct RootView: View {
    @ObservedObject var core: Core
    // Regular width (iPad / large landscape) caps the content column so a phone
    // layout doesn't stretch edge-to-edge on a big screen; compact fills as before.
    @Environment(\.horizontalSizeClass) private var hSize
    var body: some View {
        // Re-renders whenever the core publishes a new view model. A Scaffold
        // renders its own bars + scrollable body; any other root we wrap in a
        // scroll view so tall content scrolls (mirrors the Android shell).
        if case .scaffold = core.view {
            render(core.view) { core.update($0) }
        } else {
            ScrollView {
                render(core.view) { core.update($0) }
                    .padding(16)
                    .frame(maxWidth: hSize == .regular ? 760 : .infinity, alignment: .leading)
                    .frame(maxWidth: .infinity)  // center the capped column
            }
        }
    }
}

// UIKit app-delegate adaptor — the ONLY place APNs token + remote-notification callbacks arrive in a
// SwiftUI app. It forwards them to `PushBridge` (below). Inert unless the `push` plugin's `register`
// op runs: a push-less app never calls registerForRemoteNotifications, so it costs nothing.
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        return true
    }

    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
        // Forward both: the hex string (native-APNs push plugin) and the raw Data (the Firebase-only
        // push plugin hands the raw token to Messaging.apnsToken).
        PushBridge.shared.didRegister(token: hex, raw: deviceToken)
    }

    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        PushBridge.shared.didFail(error: error)
    }

    // Foreground receipt — show the banner AND forward the payload to the events stream.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        PushBridge.shared.didReceive(userInfo: notification.request.content.userInfo)
        completionHandler([.banner, .sound])
    }

    // Tap — forward the payload. Also fires when a tap LAUNCHES the app; PushBridge buffers it until
    // the core subscribes.
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        PushBridge.shared.didReceive(userInfo: response.notification.request.content.userInfo)
        completionHandler()
    }
}

// Always-present, plugin-agnostic forwarder between the AppDelegate and the optional `push` plugin.
// The plugin attaches a token-waiter (register op) + an event sink (events stream); until a sink is
// attached, inbound payloads buffer and flush on attach — so a notification that launched a dead
// process still reaches the app. App.swift references nothing from the plugin, so this compiles and
// stays dormant in push-less apps.
@MainActor
final class PushBridge {
    static let shared = PushBridge()

    private var tokenWaiters: [(Result<String, Error>) -> Void] = []
    private var sink: (@Sendable (String) -> Void)?
    private var buffer: [String] = []

    /// The raw APNs device token (set by the AppDelegate). The Firebase-only push plugin reads this to
    /// hand to `Messaging.messaging().apnsToken`; the native-APNs plugin uses the hex string instead.
    private(set) var rawAPNsToken: Data?

    // --- called by the push plugin ---

    /// Await the APNs device token (resolved by the AppDelegate's didRegister callback).
    func awaitToken() async throws -> String {
        try await withCheckedThrowingContinuation { cont in
            tokenWaiters.append { cont.resume(with: $0) }
        }
    }

    func attach(_ sink: @escaping @Sendable (String) -> Void) {
        self.sink = sink
        for payload in buffer { sink(payload) }
        buffer.removeAll()
    }

    func detach() { sink = nil }

    // --- called by the AppDelegate ---

    func didRegister(token: String, raw: Data) {
        rawAPNsToken = raw
        if tokenWaiters.isEmpty {
            // An out-of-band rotation (no register call in flight) → notify the app via the stream.
            emit("{\"type\":\"token_refresh\",\"token\":\"\(token)\"}")
        } else {
            let waiters = tokenWaiters
            tokenWaiters.removeAll()
            for resume in waiters { resume(.success(token)) }
        }
    }

    func didFail(error: Error) {
        let waiters = tokenWaiters
        tokenWaiters.removeAll()
        for resume in waiters { resume(.failure(error)) }
    }

    func didReceive(userInfo: [AnyHashable: Any]) {
        emit(Self.jsonString(from: userInfo))
    }

    private func emit(_ payload: String) {
        if let sink { sink(payload) } else { buffer.append(payload) }
    }

    private static func jsonString(from userInfo: [AnyHashable: Any]) -> String {
        let stringKeyed = Dictionary(uniqueKeysWithValues: userInfo.compactMap { key, value in
            (key as? String).map { ($0, value) }
        })
        if let data = try? JSONSerialization.data(withJSONObject: stringKeyed),
           let json = String(data: data, encoding: .utf8) {
            return json
        }
        return "{}"
    }
}