mobiler 0.45.0

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

/// Bluetooth Low Energy — matches the Android contract. ops:
///   - "scan"    : scan ~4s → JSON [{id,name,rssi}] of nearby peripherals.
///   - "connect" : input = device id (the CBPeripheral UUID from scan) → discover services → "connected".
///   - "read"    : input = {"device":id,"service":uuid,"characteristic":uuid} → value (UTF-8 or hex).
///   - "write"   : input = {device,service,characteristic,value,"hex"?,"without_response"?} → ok.
///   - notify    : cx.subscribe(key,"bluetooth","notify",{device,service,characteristic},on) → each
///                 characteristic-change value (UTF-8 or hex) until cx.unsubscribe.
/// Needs NSBluetoothAlwaysUsageDescription. Cannot be tested on the simulator (no BLE) — needs a
/// real device near a peripheral.
enum BluetoothPlugin {
    static func handle(op: String, input: String) async -> PluginResponse {
        switch op {
        case "scan": return await BleManager.shared.scan()
        case "connect": return await BleManager.shared.connect(input.trimmingCharacters(in: .whitespaces))
        case "read": return await BleManager.shared.read(input)
        case "write": return await BleManager.shared.write(input)
        default: return PluginResponse(ok: false, output: "unknown op '\(op)'")
        }
    }

    // Streaming notify (cx.subscribe): enable notifications + emit each change; park until the
    // subscription's Task is cancelled (cx.unsubscribe), then disable + detach.
    static func subscribe(op: String, input: String, emit: @escaping @Sendable (PluginResponse) -> Void) async {
        let sink: @Sendable (String) -> Void = { emit(PluginResponse(ok: true, output: $0)) }
        if let err = await BleManager.shared.startNotify(input, sink) {
            emit(PluginResponse(ok: false, output: err))
            return
        }
        await withTaskCancellationHandler {
            while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) }
        } onCancel: {
            Task { await BleManager.shared.stopNotify() }
        }
    }
}

// Single central manager for the app's lifetime. All CoreBluetooth callbacks run on the main queue;
// `@unchecked Sendable` because the continuations are resumed there and each is nil-guarded.
final class BleManager: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, @unchecked Sendable {
    static let shared = BleManager()

    private var central: CBCentralManager!
    private var discovered: [String: CBPeripheral] = [:]
    private var rssis: [String: Int] = [:]
    private var connected: [String: CBPeripheral] = [:]
    private var scanCont: CheckedContinuation<PluginResponse, Never>?
    private var connectCont: CheckedContinuation<PluginResponse, Never>?
    private var readCont: CheckedContinuation<PluginResponse, Never>?
    private var writeCont: CheckedContinuation<PluginResponse, Never>?
    private var notifySink: (@Sendable (String) -> Void)?
    private weak var notifyPeripheral: CBPeripheral?
    private var notifyCharacteristic: CBCharacteristic?

    override init() {
        super.init()
        central = CBCentralManager(delegate: self, queue: .main)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {}

    private func poweredOn() -> Bool { central.state == .poweredOn }

    // The first call happens before CoreBluetooth finishes powering on / the user answers the
    // permission prompt — poll briefly so the first tap works instead of needing a second. But
    // only wait on a transient state (.unknown/.resetting); a terminal state (denied/off/unsupported)
    // returns immediately so the user gets an actionable message, not a 6s hang.
    private func ensurePoweredOn() async -> Bool {
        if central.state == .unknown || central.state == .resetting {
            var waited = 0
            while central.state != .poweredOn && waited < 6000 {
                try? await Task.sleep(nanoseconds: 200_000_000)
                waited += 200
            }
        }
        return poweredOn()
    }

    private func stateMessage() -> String {
        switch central.state {
        case .unauthorized: return "denied"
        case .poweredOff: return "bluetooth off"
        case .unsupported: return "bluetooth unsupported"
        default: return "bluetooth unavailable"
        }
    }

    // MARK: scan
    func scan() async -> PluginResponse {
        guard await ensurePoweredOn() else { return PluginResponse(ok: false, output: stateMessage()) }
        discovered.removeAll(); rssis.removeAll()
        return await withCheckedContinuation { cont in
            scanCont = cont
            central.scanForPeripherals(withServices: nil)
            DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in self?.finishScan() }
        }
    }

    private func finishScan() {
        central.stopScan()
        guard let cont = scanCont else { return }
        scanCont = nil
        let arr: [[String: Any]] = discovered.map { id, p in
            ["id": id, "name": p.name ?? "", "rssi": rssis[id] ?? 0]
        }
        let data = (try? JSONSerialization.data(withJSONObject: arr)) ?? Data("[]".utf8)
        cont.resume(returning: PluginResponse(ok: true, output: String(data: data, encoding: .utf8) ?? "[]"))
    }

    func centralManager(_ c: CBCentralManager, didDiscover p: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
        let id = p.identifier.uuidString
        discovered[id] = p
        rssis[id] = RSSI.intValue
    }

    // MARK: connect
    func connect(_ id: String) async -> PluginResponse {
        guard await ensurePoweredOn() else { return PluginResponse(ok: false, output: stateMessage()) }
        guard let p = discovered[id] else { return PluginResponse(ok: false, output: "unknown device — scan first") }
        return await withCheckedContinuation { cont in
            connectCont = cont
            p.delegate = self
            central.connect(p)
            DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
                guard let self = self, let c = self.connectCont else { return }
                self.connectCont = nil
                c.resume(returning: PluginResponse(ok: false, output: "connect timeout"))
            }
        }
    }

    func centralManager(_ c: CBCentralManager, didConnect p: CBPeripheral) {
        connected[p.identifier.uuidString] = p
        p.discoverServices(nil)
    }

    func centralManager(_ c: CBCentralManager, didFailToConnect p: CBPeripheral, error: Error?) {
        guard let cont = connectCont else { return }
        connectCont = nil
        cont.resume(returning: PluginResponse(ok: false, output: error?.localizedDescription ?? "connect failed"))
    }

    func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) {
        for s in p.services ?? [] { p.discoverCharacteristics(nil, for: s) }
        guard let cont = connectCont else { return }
        connectCont = nil
        cont.resume(returning: PluginResponse(ok: true, output: "connected"))
    }

    func peripheral(_ p: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {}

    // MARK: read
    func read(_ input: String) async -> PluginResponse {
        guard let data = input.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let id = obj["device"] as? String,
              let svc = obj["service"] as? String,
              let chr = obj["characteristic"] as? String,
              let p = connected[id]
        else { return PluginResponse(ok: false, output: "not connected — connect first") }
        guard let service = p.services?.first(where: { $0.uuid.uuidString.caseInsensitiveCompare(svc) == .orderedSame }),
              let characteristic = service.characteristics?.first(where: { $0.uuid.uuidString.caseInsensitiveCompare(chr) == .orderedSame })
        else { return PluginResponse(ok: false, output: "characteristic not found") }
        return await withCheckedContinuation { cont in
            readCont = cont
            p.readValue(for: characteristic)
            DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
                guard let self = self, let c = self.readCont else { return }
                self.readCont = nil
                c.resume(returning: PluginResponse(ok: false, output: "read timeout"))
            }
        }
    }

    // Routes both read responses and notifications (CoreBluetooth uses one callback for both): a pending
    // read resumes its continuation; otherwise the value is a notification → the stream sink.
    func peripheral(_ p: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        if let cont = readCont {
            readCont = nil
            if let v = characteristic.value {
                cont.resume(returning: PluginResponse(ok: true, output: decodeValue(v)))
            } else {
                cont.resume(returning: PluginResponse(ok: false, output: error?.localizedDescription ?? "no value"))
            }
            return
        }
        if let sink = notifySink, let v = characteristic.value { sink(decodeValue(v)) }
    }

    // MARK: write
    func write(_ input: String) async -> PluginResponse {
        guard await ensurePoweredOn() else { return PluginResponse(ok: false, output: stateMessage()) }
        guard let (p, characteristic) = lookup(input),
              let data = input.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else { return PluginResponse(ok: false, output: "not connected / characteristic not found") }
        let value = bytesFrom(obj)
        if (obj["without_response"] as? Bool) ?? false {
            p.writeValue(value, for: characteristic, type: .withoutResponse)
            return PluginResponse(ok: true, output: "")
        }
        return await withCheckedContinuation { cont in
            writeCont = cont
            p.writeValue(value, for: characteristic, type: .withResponse)
            DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
                guard let self = self, let c = self.writeCont else { return }
                self.writeCont = nil
                c.resume(returning: PluginResponse(ok: false, output: "write timeout"))
            }
        }
    }

    func peripheral(_ p: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
        guard let cont = writeCont else { return }
        writeCont = nil
        cont.resume(returning: error == nil ? PluginResponse(ok: true, output: "") : PluginResponse(ok: false, output: error!.localizedDescription))
    }

    // MARK: notify
    func startNotify(_ input: String, _ sink: @escaping @Sendable (String) -> Void) async -> String? {
        guard await ensurePoweredOn() else { return stateMessage() }
        guard let (p, characteristic) = lookup(input) else { return "not connected / characteristic not found" }
        notifySink = sink
        notifyPeripheral = p
        notifyCharacteristic = characteristic
        p.setNotifyValue(true, for: characteristic)
        return nil
    }

    func stopNotify() {
        if let p = notifyPeripheral, let c = notifyCharacteristic { p.setNotifyValue(false, for: c) }
        notifySink = nil
        notifyPeripheral = nil
        notifyCharacteristic = nil
    }

    // MARK: helpers
    private func lookup(_ input: String) -> (CBPeripheral, CBCharacteristic)? {
        guard let data = input.data(using: .utf8),
              let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let id = obj["device"] as? String,
              let svc = obj["service"] as? String,
              let chr = obj["characteristic"] as? String,
              let p = connected[id],
              let service = p.services?.first(where: { $0.uuid.uuidString.caseInsensitiveCompare(svc) == .orderedSame }),
              let characteristic = service.characteristics?.first(where: { $0.uuid.uuidString.caseInsensitiveCompare(chr) == .orderedSame })
        else { return nil }
        return (p, characteristic)
    }

    private func bytesFrom(_ obj: [String: Any]) -> Data {
        let v = (obj["value"] as? String) ?? ""
        guard (obj["hex"] as? Bool) ?? false else { return Data(v.utf8) }
        var bytes = [UInt8]()
        var i = v.startIndex
        while i < v.endIndex, let j = v.index(i, offsetBy: 2, limitedBy: v.endIndex) {
            if let b = UInt8(v[i..<j], radix: 16) { bytes.append(b) }
            i = j
        }
        return Data(bytes)
    }

    private func decodeValue(_ v: Data) -> String {
        let text = String(data: v, encoding: .utf8)
        return (text != nil && !(text!.isEmpty)) ? text! : v.map { String(format: "%02x", $0) }.joined()
    }
}