mobiler 0.44.0

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

// background-fetch (free, bundled, EXPERIMENTAL). The OS wakes the app on its own schedule to run a
// brief task that posts a local notification AND buffers a {"type":"fetch","id":..} event delivered to
// the core on next foreground (launch-from-dead, like push/system). The Rust core never runs in the
// background — this is a native opportunistic wake + buffered delivery.
//
// The single task identifier `mobiler.refresh` is registered at launch (App.swift `// mobiler:app-launch`
// → bootstrap()) and listed in BGTaskSchedulerPermittedIdentifiers. BGTaskScheduler.register MUST run
// before didFinishLaunching returns, which a lazy request/response plugin can't do.
private let TASK_ID = "mobiler.refresh"

enum BackgroundFetchPlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        switch op {
        case "schedule": return await BackgroundFetchBridge.shared.schedule(input)
        case "cancel": return await BackgroundFetchBridge.shared.cancel()
        default: return PluginResponse(ok: false, output: "unknown op '\(op)'")
        }
    }

    // Streaming entrypoint (cx.subscribe): attach a sink; buffered fetch events flush on attach.
    static func subscribe(op: String, input: String, emit: @escaping @Sendable (PluginResponse) -> Void) async {
        let sink: @Sendable (String) -> Void = { emit(PluginResponse(ok: true, output: $0)) }
        await MainActor.run { BackgroundFetchBridge.shared.attach(sink) }
        await withTaskCancellationHandler {
            while !Task.isCancelled {
                try? await Task.sleep(nanoseconds: 1_000_000_000)
            }
        } onCancel: {
            Task { @MainActor in BackgroundFetchBridge.shared.detach() }
        }
    }

    // Launch hook — register the BGTask handler (must happen before didFinishLaunching returns).
    static func bootstrap() {
        BGTaskScheduler.shared.register(forTaskWithIdentifier: TASK_ID, using: nil) { task in
            // BGAppRefreshTask runs ~30s in the background.
            Task { @MainActor in
                BackgroundFetchBridge.shared.handleRefresh()
                BackgroundFetchBridge.shared.submitRequest()  // reschedule the next wake
                task.setTaskCompleted(success: true)
            }
        }
    }
}

// Owns the buffer + scheduling state for the process lifetime. Persists pending events in UserDefaults
// so a wake that ran in a since-killed background process is still delivered on the next launch.
@MainActor
final class BackgroundFetchBridge {
    static let shared = BackgroundFetchBridge()

    private var sink: (@Sendable (String) -> Void)?
    private let bufferKey = "mobiler.bgfetch.buffer"   // [String] of pending event JSON
    private let labelKey = "mobiler.bgfetch.label"     // app's opaque id echoed in events
    private let intervalKey = "mobiler.bgfetch.interval"
    private let titleKey = "mobiler.bgfetch.title"
    private let bodyKey = "mobiler.bgfetch.body"

    func schedule(_ input: String) -> PluginResponse {
        guard let data = input.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else { return PluginResponse(ok: false, output: "invalid input JSON") }
        let d = UserDefaults.standard
        d.set((obj["id"] as? String) ?? "refresh", forKey: labelKey)
        let interval = (obj["min_interval_seconds"] as? Double)
            ?? (obj["min_interval_seconds"] as? Int).map(Double.init) ?? 3600
        d.set(interval, forKey: intervalKey)
        d.set((obj["notify_title"] as? String) ?? "", forKey: titleKey)
        d.set((obj["notify_body"] as? String) ?? "", forKey: bodyKey)
        submitRequest()
        return PluginResponse(ok: true, output: "")
    }

    func cancel() -> PluginResponse {
        BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: TASK_ID)
        return PluginResponse(ok: true, output: "")
    }

    func submitRequest() {
        let interval = UserDefaults.standard.double(forKey: intervalKey)
        let request = BGAppRefreshTaskRequest(identifier: TASK_ID)
        request.earliestBeginDate = Date(timeIntervalSinceNow: max(60, interval))
        try? BGTaskScheduler.shared.submit(request)
    }

    // Called when the OS runs the refresh task — notify + buffer an event.
    func handleRefresh() {
        let d = UserDefaults.standard
        let title = d.string(forKey: titleKey) ?? ""
        if !title.isEmpty {
            let content = UNMutableNotificationContent()
            content.title = title
            content.body = d.string(forKey: bodyKey) ?? ""
            content.sound = .default
            let request = UNNotificationRequest(identifier: "mobiler.bgfetch", content: content, trigger: nil)
            UNUserNotificationCenter.current().add(request)
        }
        let label = d.string(forKey: labelKey) ?? "refresh"
        emit("{\"type\":\"fetch\",\"id\":\(Self.jsonString(label))}")
    }

    // --- stream sink (buffer-and-flush) ---

    func attach(_ sink: @escaping @Sendable (String) -> Void) {
        self.sink = sink
        let buf = UserDefaults.standard.stringArray(forKey: bufferKey) ?? []
        for payload in buf { sink(payload) }
        UserDefaults.standard.removeObject(forKey: bufferKey)
    }

    func detach() { sink = nil }

    private func emit(_ payload: String) {
        if let sink {
            sink(payload)
        } else {
            var buf = UserDefaults.standard.stringArray(forKey: bufferKey) ?? []
            buf.append(payload)
            UserDefaults.standard.set(buf, forKey: bufferKey)
        }
    }

    // JSON-encode a string (quotes + escapes): encode `[s]`, strip the brackets.
    private static func jsonString(_ s: String) -> String {
        if let data = try? JSONSerialization.data(withJSONObject: [s]),
           let arr = String(data: data, encoding: .utf8), arr.count >= 2 {
            return String(arr.dropFirst().dropLast())
        }
        return "\"\""
    }
}