mobiler 0.7.0

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

// NOTE (verify on macOS): `SharedTypes` is the facet-generated ABI types package
// (Widget/Action/Effect/Request/Requests/PluginCall/PluginResponse/...). `CoreFfi`
// comes from the uniffi-generated bindings for the `shared` crate; depending on the
// Xcode setup it's either in this same target (generated sources compiled in) or a
// module to `import`. project.yml wires that.

/// Drives the Rust core from Swift — the iOS twin of the Android `Core.kt`.
///
/// Speaks only the fixed Mobiler ABI: send an `Action`, receive a `Widget` tree +
/// capability effects. Request/response capabilities resolve **asynchronously**
/// (Swift `async/await` / `Task`), so a network call never blocks the UI — exactly
/// like the Android shell's coroutine resolution.
@MainActor
final class Core: ObservableObject {
    @Published private(set) var view: Widget

    private let core = CoreFfi()

    init() {
        // First frame straight from the core's view model.
        self.view = try! Widget.bincodeDeserialize(input: [UInt8](core.view()))
        // Restore persisted state, then fire Start so the app can load initial data.
        let saved = StoragePlugin.load()
        if !saved.isEmpty { update(.restore(data: saved)) }
        update(.start)
    }

    func update(_ action: Action) {
        process(core.update(data: Data(try! action.bincodeSerialize())))
    }

    private func process(_ effectBytes: Data) {
        let requests = try! Requests.bincodeDeserialize(input: [UInt8](effectBytes)).value
        for request in requests {
            switch request.effect {
            case .render:
                self.view = try! Widget.bincodeDeserialize(input: [UInt8](core.view()))

            // Fire-and-forget: dispatch, ignore the result, don't resolve.
            case .pluginNotify(let notify):
                Task { _ = await Plugins.handle(plugin: notify.plugin, op: notify.op, input: notify.input) }

            // Request/response: dispatch (awaiting async work), resolve the core
            // with the response, then process the effects that produces.
            case .plugin(let call):
                let id = request.id
                Task {
                    let resp = await Plugins.handle(plugin: call.plugin, op: call.op, input: call.input)
                    let next = core.resolve(id: id, data: Data(try! resp.bincodeSerialize()))
                    process(next)
                }
            }
        }
    }
}

// MARK: - Capability plugins (the iOS twin of the Android plugin registry)

/// Dispatches the opaque `{plugin, op, input}` envelope by name. Adding a plugin
/// never touches the wire ABI — only this registry.
enum Plugins {
    static func handle(plugin: String, op: String, input: String) async -> PluginResponse {
        switch plugin {
        case "http": return await HttpPlugin.handle(op: op, input: input)
        case "storage": return StoragePlugin.handle(op: op, input: input)
        case "clipboard": return await ClipboardPlugin.handle(op: op, input: input)
        case "share": return await SharePlugin.handle(op: op, input: input)
        case "browser": return await BrowserPlugin.handle(op: op, input: input)
        case "toast": return await ToastPlugin.handle(op: op, input: input)
        case "device": return await DevicePlugin.handle(op: op, input: input)
        case "haptics": return await HapticsPlugin.handle(op: op, input: input)
        case "dialog": return await DialogPlugin.handle(op: op, input: input)
        default:
            return PluginResponse(ok: false, output: "plugin '\(plugin)' not available in this build")
        }
    }
}

/// HTTP capability (paired with `cx.http`/`get`/`post`/... in Rust). `op` is the
/// method; `input` is `{"url": ..., "body": ...}`. Returns the body; `ok` = 2xx.
enum HttpPlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        guard
            let data = input.data(using: .utf8),
            let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
            let urlString = obj["url"] as? String,
            let url = URL(string: urlString)
        else {
            return PluginResponse(ok: false, output: "invalid http request")
        }
        var req = URLRequest(url: url)
        req.httpMethod = op
        if let body = obj["body"] as? String {
            req.httpBody = body.data(using: .utf8)
            req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }
        do {
            let (respData, resp) = try await URLSession.shared.data(for: req)
            let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
            let ok = (200..<300).contains(code)
            return PluginResponse(ok: ok, output: String(data: respData, encoding: .utf8) ?? "")
        } catch {
            return PluginResponse(ok: false, output: error.localizedDescription)
        }
    }
}

/// Persistence capability (paired with `cx.save` + `restore`). Backed by UserDefaults.
enum StoragePlugin {
    private static let key = "mobiler.state"
    static func load() -> String { UserDefaults.standard.string(forKey: key) ?? "" }
    static func handle(op: String, input: String) -> PluginResponse {
        switch op {
        case "save": UserDefaults.standard.set(input, forKey: key); return PluginResponse(ok: true, output: "")
        case "load": return PluginResponse(ok: true, output: load())
        default: return PluginResponse(ok: false, output: "unknown op '\(op)'")
        }
    }
}

/// Clipboard capability — copy text (UIPasteboard is main-actor only).
@MainActor
enum ClipboardPlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        UIPasteboard.general.string = input
        return PluginResponse(ok: true, output: "")
    }
}

/// Share capability — the system share sheet (UIActivityViewController).
@MainActor
enum SharePlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        guard let presenter = topViewController() else {
            return PluginResponse(ok: false, output: "no view controller to present from")
        }
        let sheet = UIActivityViewController(activityItems: [input], applicationActivities: nil)
        sheet.popoverPresentationController?.sourceView = presenter.view // iPad anchor
        presenter.present(sheet, animated: true)
        return PluginResponse(ok: true, output: "")
    }
}

/// Open a URL externally (Safari / the default handler).
@MainActor
enum BrowserPlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        guard let url = URL(string: input) else {
            return PluginResponse(ok: false, output: "invalid url")
        }
        UIApplication.shared.open(url)
        return PluginResponse(ok: true, output: "")
    }
}

/// Device info — request/response. `model` returns e.g. "Apple iPhone (iOS 18.0)".
@MainActor
enum DevicePlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        switch op {
        case "model":
            let d = UIDevice.current
            return PluginResponse(ok: true, output: "Apple \(d.model) (\(d.systemName) \(d.systemVersion))")
        default:
            return PluginResponse(ok: false, output: "unknown op '\(op)'")
        }
    }
}

/// Haptic tap — iOS has no permission requirement. `op` is the style.
@MainActor
enum HapticsPlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        let style: UIImpactFeedbackGenerator.FeedbackStyle = switch op {
        case "light": .light
        case "heavy": .heavy
        default: .medium
        }
        UIImpactFeedbackGenerator(style: style).impactOccurred()
        return PluginResponse(ok: true, output: "")
    }
}

/// Toast — iOS has no native toast, so show a transient padded label in the key
/// window (the SwiftUI/UIKit twin of Android's Toast / the web's `.toast` div).
@MainActor
enum ToastPlugin {
    static func handle(op: String, input: String) -> PluginResponse {
        guard let window = keyWindow() else { return PluginResponse(ok: false, output: "no window") }
        let label = PaddedLabel()
        label.text = input
        label.numberOfLines = 0
        label.textColor = .white
        label.textAlignment = .center
        label.font = .systemFont(ofSize: 14)
        label.backgroundColor = UIColor.black.withAlphaComponent(0.85)
        label.layer.cornerRadius = 18
        label.clipsToBounds = true
        label.alpha = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        window.addSubview(label)
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: window.centerXAnchor),
            label.bottomAnchor.constraint(equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -32),
            label.leadingAnchor.constraint(greaterThanOrEqualTo: window.leadingAnchor, constant: 24),
            label.trailingAnchor.constraint(lessThanOrEqualTo: window.trailingAnchor, constant: -24),
        ])
        UIView.animate(withDuration: 0.2) { label.alpha = 1 }
        UIView.animate(withDuration: 0.3, delay: 2.3) { label.alpha = 0 } completion: { _ in label.removeFromSuperview() }
        return PluginResponse(ok: true, output: "")
    }
}

/// Confirm dialog — request/response. Presents a UIAlertController and awaits the
/// user's choice (`ok` = confirmed) via a continuation, so the core resolves only
/// once they tap. Input is JSON `{title, message}`.
@MainActor
enum DialogPlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        guard op == "confirm" else { return PluginResponse(ok: false, output: "unknown op '\(op)'") }
        let obj = (try? JSONSerialization.jsonObject(with: Data(input.utf8))) as? [String: Any]
        let title = obj?["title"] as? String ?? ""
        let message = obj?["message"] as? String ?? ""
        guard let presenter = topViewController() else {
            return PluginResponse(ok: false, output: "no view controller to present from")
        }
        return await withCheckedContinuation { cont in
            let alert = UIAlertController(
                title: title.isEmpty ? nil : title, message: message, preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
                cont.resume(returning: PluginResponse(ok: false, output: "cancel"))
            })
            alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
                cont.resume(returning: PluginResponse(ok: true, output: "ok"))
            })
            presenter.present(alert, animated: true)
        }
    }
}

/// A UILabel with inner padding (UILabel alone has none) — for the toast pill.
private final class PaddedLabel: UILabel {
    private let insets = UIEdgeInsets(top: 10, left: 18, bottom: 10, right: 18)
    override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) }
    override var intrinsicContentSize: CGSize {
        let s = super.intrinsicContentSize
        return CGSize(width: s.width + insets.left + insets.right, height: s.height + insets.top + insets.bottom)
    }
}

/// The active key window — where the shell hangs modals/toasts (it owns no VC).
@MainActor
private func keyWindow() -> UIWindow? {
    (UIApplication.shared.connectedScenes
        .first { $0.activationState == .foregroundActive } as? UIWindowScene)?.keyWindow
}

/// The frontmost view controller — modals (the share sheet) present from here.
@MainActor
private func topViewController() -> UIViewController? {
    var top = keyWindow()?.rootViewController
    while let presented = top?.presentedViewController { top = presented }
    return top
}