tauri-plugin-background-service 1.0.1

Background service lifecycle plugin for Tauri v2 — run long-lived tasks on Android, iOS, and desktop
Documentation
import Foundation
import CallKit
import AVFoundation
import os.log

// MARK: - F3 degraded-mode decision (pure, unit-tested)

/// Pure F3(a)+(c) decision logic for iOS call delivery.
///
/// Mirrors the Kotlin/Android fast-path but expresses iOS's honest constraint:
/// there is no push relay in v1, so a suspended app simply cannot ring. The
/// surface below is the testable residue: the foreground ring gate and the
/// honest "no suspended ring" claim. The unused `BackgroundCallAppState` /
/// `CallDeliveryAction` / `deliveryAction` triple this enum previously hosted
/// was dead — only tests referenced it — and is removed in IOS-CLEAN-01.
/// Decoupled from `CXProvider` / UIKit so it is unit-testable without the iOS
/// runtime (spec-01 seam philosophy).
enum BackgroundCallDecision {

    /// Step 12 (M-NATIVE-4 = NR-6): native-ring foreground gate — the iOS twin of
    /// the Rust `event_bridge::should_ring_native`.
    ///
    /// One ring owner per app-state (DEC-060 option a — "UI owns foreground,
    /// native owns background/locked/closed"): when the webview is
    /// **foreground/visible** the in-app `IncomingCallScreen` overlay owns the
    /// ring, so the CallKit ring is **suppressed** (no double-ring); when
    /// **backgrounded/locked** the native CallKit ring fires.
    ///
    /// In production the Rust gate already suppresses `notify_mobile_incoming_call`
    /// (which drives this CallKit path) before it is reached when the webview is
    /// foreground, so this is the explicit iOS-side decision / defense-in-depth and
    /// the seam a future PushKit/APNs wake path (F3(b)) would consult. Host-build
    /// deferred (no macOS toolchain) — written + unit-shaped, recorded NOT done.
    static func shouldRingCallKit(appForeground: Bool) -> Bool {
        return !appForeground
    }

    /// Step 13 (M-NATIVE-5 = CCF-14/NR-5): the iOS twin of the Rust
    /// `the host core("ios").suspended_incoming_ring_supported`.
    ///
    /// **`false` on iOS, honestly.** No APNs/VoIP-push relay ships in v1
    /// (IOS-PUSH-01 removed the incomplete PushKit surface rather than ship a
    /// registry with no producer), so a suspended/terminated app cannot be
    /// woken to ring. The caller instead gets the documented missed-call path
    /// (Unreachable + a control-outbox record drained on the next wake), never
    /// a reliable ring — so this must never be presented as native
    /// incoming-call parity. Active-process CallKit only.
    static let suspendedIncomingRingSupported: Bool = false
}

// MARK: - Audio-session configuration (pure value, unit-tested)

/// Pure description of the desired `AVAudioSession` configuration for a call.
///
/// Decoupled from `AVAudioSession` so the routing choice is unit-testable; applied by
/// `BackgroundCallKitController` inside `CXProviderDelegate.didActivate`.
struct CallAudioSessionConfiguration: Equatable {
    let category: String
    let mode: String
    /// Route to the speaker (default for video; CallKit owns routing for audio).
    let defaultToSpeaker: Bool
    /// Allow Bluetooth A2DP output (headphones/speaker).
    let allowsBluetoothA2DP: Bool

    /// VOIP audio call: `playAndRecord` + `voiceChat`. CallKit's `didActivate` owns routing,
    /// so `defaultToSpeaker` is false (honor the system call UI / earpiece).
    static let audioCall = CallAudioSessionConfiguration(
        category: "AVAudioSessionCategoryPlayAndRecord",
        mode: "AVAudioSessionModeVoiceChat",
        defaultToSpeaker: false,
        allowsBluetoothA2DP: true
    )

    /// Video call: `playAndRecord` + `videoChat`, default to speaker — speakerphone is the
    /// sensible default when the camera is active and the screen is away from the ear.
    static let videoCall = CallAudioSessionConfiguration(
        category: "AVAudioSessionCategoryPlayAndRecord",
        mode: "AVAudioSessionModeVideoChat",
        defaultToSpeaker: true,
        allowsBluetoothA2DP: true
    )

    static func forCall(hasVideo: Bool) -> CallAudioSessionConfiguration {
        hasVideo ? .videoCall : .audioCall
    }
}

/// Device audio route for an active call (M-NATIVE-3 / CCF-11, Step 11) — mirrors
/// the Rust `CallAudioRoute` / Android `audioRouteFor`. The routing choice is
/// decoupled from `AVAudioSession` so it is unit-testable like
/// `CallAudioSessionConfiguration`; applied by `BackgroundCallKitController.setAudioRoute`.
enum CallAudioRoute: String {
    case speaker
    case earpiece
    case bluetooth
    case system

    /// Pure routing-choice → `AVAudioSession` output-port override:
    /// - `.speaker` forces the loudspeaker (`.speaker`).
    /// - `.earpiece` forces the receiver (override `.none`).
    /// - `.bluetooth` / `.system` defer to the platform/CallKit route — no override
    ///   (`nil`); BT selection is owned by the system route picker.
    var portOverride: AVAudioSession.PortOverride? {
        switch self {
        case .speaker: return .speaker
        case .earpiece: return AVAudioSession.PortOverride.none
        case .bluetooth, .system: return nil
        }
    }
}

// MARK: - Native core bridge (host-provided; no native lib ships with the plugin)

// The plugin ships no native library. A host app that bridges CallKit perform-
// actions (Answer/Reject/End) to its own native core injects a closure:
// BackgroundCallKitController.performCallAction (wired in
// BackgroundServicePlugin.callKitController to the public main-thread
// `callActionHandler`). Default is a no-op, so the plugin builds and runs
// standalone. (IOS-PUSH-01 removed the incomplete PushKit token sink.)

// MARK: - CallKit + AVAudioSession wrapper (runtime glue; device-tested)

/// CallKit + `AVAudioSession` wrapper for incoming calls (spec 08 C6, Step 16).
///
/// The headless Rust core signals an incoming call via the Tauri mobile-plugin invoke
/// `showIncomingCall` (routed by `MobileLifecycle::show_incoming_call`); this controller
/// reports it to CallKit (system ring + in-call UI) and configures the VOIP audio session.
///
/// Degraded mode (F3): because there is no APNs push relay in v1, this path is only
/// reached while the app is foreground/background-active — exactly `BackgroundCallDecision`'s
/// `.ring` arm. A suspended app is never woken to reach this path; the caller instead gets
/// `Unreachable` + a missed-call control-outbox record. The PushKit/APNs relay (F3(b)) is
/// the documented, explicitly-deferred path; since iOS 13 a VoIP push must report to
/// CallKit, so this controller is a prerequisite for any future push anyway.
final class BackgroundCallKitController: NSObject, CXProviderDelegate {

    private static let logger = OSLog(subsystem: "app.tauri.backgroundservice", category: "CallKit")

    private let provider: CXProvider
    private let callController: CXCallController
    private var activeCalls: [UUID: Bool] = [:]  // uuid → hasVideo

    /// Number of calls currently reported to CallKit. Internal (not private) so the
    /// XCTest target can assert the foreground-ring gate suppressed/allowed a report
    /// — mirrors the seam-visibility style used elsewhere (e.g. `completeOnce`).
    var activeCallCount: Int { activeCalls.count }

    /// CallKit UUID → original 32-hex Rust `call_id` (H7). Populated in
    /// `reportIncomingCall`, reverse-looked-up by the perform-handlers so the
    /// Swift→Rust bridge carries the ORIGINAL session key (not the derived UUID),
    /// and cleared on `endCall`/teardown/reset.
    private var callIdsByUUID: [UUID: String] = [:]

    /// CallKit UUIDs that have been answered (CXAnswerCallAction fulfilled). Lets a
    /// subsequent CXEndCallAction distinguish a hang-up of a live call
    /// (`call_ended`) from a decline of a still-ringing call (`call_rejected`).
    private var answeredCallUUIDs: Set<UUID> = []

    /// The call whose audio session is (about to be) active — set when an answer is
    /// performed. `didActivate` derives `hasVideo` from this call's `activeCalls`
    /// entry (L6) instead of a clobber-prone scalar.
    private var activeAudioCallUUID: UUID?

    /// The active CallKit→native-core call-action bridge (BGS-10, Step 18 Task B).
    /// The default is a no-op — the plugin ships no native library, so a consumer
    /// that doesn't bridge to a native core simply drops lock-screen call actions.
    /// A host app that bridges Answer/Reject/End to its own native core injects a
    /// closure here; XCTest swaps in a recorder to assert the perform→action
    /// mapping without a live core. The perform-handlers invoke this with
    /// `("answer" | "reject" | "end")` + the ORIGINAL 32-hex `call_id`, mirroring
    /// the Android JNI `callAction` single-symbol contract.
    var performCallAction: (String, String) -> Void = { _, _ in }

    override init() {
        self.callController = CXCallController()
        self.provider = CXProvider(configuration: BackgroundCallKitController.providerConfiguration())
        super.init()
        provider.setDelegate(self, queue: nil)
    }

    /// CallKit provider configuration for self-managed VOIP calls.
    static func providerConfiguration() -> CXProviderConfiguration {
        let config = CXProviderConfiguration()
        config.maximumCallGroups = 1
        config.maximumCallsPerCallGroup = 1
        config.supportsVideo = true
        config.supportedHandleTypes = [.generic]
        config.includesCallsInRecents = false
        return config
    }

    // MARK: - Call-id → CallKit UUID derivation (pure, unit-tested)

    /// Deterministically derive the CallKit `UUID` for a Rust call session key.
    ///
    /// `call_id` is a 32-char lowercase hex string (exactly 128 bits) minted by
    /// `core::call_manager::new_call_id` — it is NOT an RFC-4122 UUID string, so
    /// `UUID(uuidString:)` always returns `nil` for it (the prior code's
    /// `UUID(uuidString: callId) ?? UUID()` drew an INDEPENDENT random UUID on every
    /// call, so `endCall` could never dismiss the call `reportIncomingCall` had
    /// reported). Parsing the 16 raw bytes guarantees both sites derive the SAME
    /// `UUID` for a given call: CallKit dismisses the ring only when the end is
    /// reported against the UUID the call was reported with. Mirrors the Android
    /// fast-path, which keys `show`/`cancel` deterministically off the opaque string
    /// (`IncomingCallNotifier.notificationIdFor(callId) = callId.hashCode()`).
    /// Returns `nil` for a malformed (non-32-hex) id; callers fall back to a random
    /// `UUID` only in that defensive case.
    static func callKitUUID(for callId: String) -> UUID? {
        guard callId.count == 32 else { return nil }
        var bytes = [UInt8](repeating: 0, count: 16)
        var idx = callId.startIndex
        for i in 0..<16 {
            let next = callId.index(idx, offsetBy: 2)
            guard let byte = UInt8(callId[idx..<next], radix: 16) else { return nil }
            bytes[i] = byte
            idx = next
        }
        return UUID(uuid: (bytes[0], bytes[1], bytes[2], bytes[3],
                           bytes[4], bytes[5], bytes[6], bytes[7],
                           bytes[8], bytes[9], bytes[10], bytes[11],
                           bytes[12], bytes[13], bytes[14], bytes[15]))
    }

    /// Whether `callId` is the contract-shaped 32-char lowercase-hex Rust session
    /// key (L5). Reuses the same parse as `callKitUUID(for:)` so a value that
    /// validates here is guaranteed to derive a deterministic, non-fallback UUID.
    /// The plugin's `showIncomingCall`/`cancelIncomingCall` reject anything else so
    /// a malformed id never rings (and never strands a random-UUID call CallKit can
    /// never dismiss).
    static func isValidCallId(_ callId: String) -> Bool {
        return callKitUUID(for: callId) != nil
    }

    /// Report an incoming call to CallKit (system ring + in-call UI).
    ///
    /// `callerName` is the resolved display name. The webview `IncomingCallScreen` (Step 13)
    /// resolves it independently via `getByChatKey`; CallKit surfaces it on the native
    /// lock/call screen. `callId` is the Rust call session key (§3.3) — deterministically
    /// mapped to the CallKit UUID via `callKitUUID(for:)` so `endCall` can dismiss the same
    /// call; falls back to a random UUID only for a malformed id.
    func reportIncomingCall(callId: String, callerName: String, hasVideo: Bool) {
        let derived = BackgroundCallKitController.callKitUUID(for: callId)
        if derived == nil {
            os_log("reportIncomingCall: malformed call_id, using random UUID fallback",
                   log: BackgroundCallKitController.logger, type: .error)
        }
        let uuid = derived ?? UUID()
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .generic, value: callerName)
        update.hasVideo = hasVideo
        update.localizedCallerName = callerName
        update.supportsHolding = false       // Held state is v1.1 (spec §3.3)
        update.supportsGrouping = false
        update.supportsUngrouping = false
        update.supportsDTMF = false
        activeCalls[uuid] = hasVideo
        // H7: remember the original session key so the perform-handlers can bridge
        // it back to Rust (the derived/fallback UUID is not the session key).
        callIdsByUUID[uuid] = callId
        provider.reportNewIncomingCall(with: uuid, update: update) { error in
            if let error = error {
                os_log("reportNewIncomingCall failed: %{public}@",
                       log: BackgroundCallKitController.logger, type: .error, "\(error)")
            }
        }
    }

    /// End a call reported to CallKit (caller hung up / answered-elsewhere / rejected).
    ///
    /// Derives the UUID with the SAME `callKitUUID(for:)` as `reportIncomingCall` so the
    /// end is reported against the call CallKit actually has (otherwise the native ring
    /// screen would persist until `providerDidReset` and `activeCalls` would leak).
    func endCall(callId: String, reason: CXCallEndedReason) {
        let uuid = BackgroundCallKitController.callKitUUID(for: callId) ?? UUID()
        provider.reportCall(with: uuid, endedAt: Date(), reason: reason)
        forgetCall(uuid)
    }

    /// Drop all per-call bookkeeping for a CallKit UUID (call ended/declined/reset).
    private func forgetCall(_ uuid: UUID) {
        activeCalls.removeValue(forKey: uuid)
        callIdsByUUID.removeValue(forKey: uuid)
        answeredCallUUIDs.remove(uuid)
        if activeAudioCallUUID == uuid { activeAudioCallUUID = nil }
    }

    // MARK: - CXProviderDelegate

    func providerDidReset(_ provider: CXProvider) {
        activeCalls.removeAll()
        callIdsByUUID.removeAll()
        answeredCallUUIDs.removeAll()
        activeAudioCallUUID = nil
    }

    /// User tapped Answer in the native CallKit UI (H7). Bridge the answer to Rust
    /// carrying the ORIGINAL 32-hex `call_id`, then `fulfill()`. An action for a call
    /// we never reported (no map entry) is failed without bridging — there is no
    /// session key to carry.
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        guard let callId = callIdsByUUID[action.callUUID] else {
            action.fail()
            return
        }
        answeredCallUUIDs.insert(action.callUUID)
        // The answered call owns the audio session; `didActivate` reads its video
        // flag from `activeCalls` (L6).
        activeAudioCallUUID = action.callUUID
        action.fulfill()
        // BGS-10 (Step 18 Task B): route Answer DIRECTLY to the Rust control plane
        // via the native call-action bridge (FFI), not the suspended webview. CROSS-DOC: doc
        // 04 owns call-control semantics (answer_call/reject_call/end_call).
        performCallAction(callId, "answer")
    }

    /// User tapped Decline (still ringing) or End (live call) in the native CallKit
    /// UI (H7). Bridge to Rust as `call_rejected` (never answered) or `call_ended`
    /// (was answered) carrying the original `call_id`, then `fulfill()`. An action
    /// for an unknown call is failed without bridging.
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        guard let callId = callIdsByUUID[action.callUUID] else {
            action.fail()
            return
        }
        let wasAnswered = answeredCallUUIDs.contains(action.callUUID)
        forgetCall(action.callUUID)
        action.fulfill()
        // BGS-10 (Step 18 Task B): Decline of a still-ringing call → "reject"; an
        // End of a live (answered) call → "end". Routed DIRECTLY to Rust via
        // the native call-action bridge (FFI), not the suspended webview.
        performCallAction(callId, wasAnswered ? "end" : "reject")
    }

    /// CallKit activated the audio session — configure VOIP routing (F3) for the
    /// answered call, deriving `hasVideo` from `activeCalls` (L6) so an interleaved
    /// second report can no longer clobber the routing of the call being activated.
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        BackgroundCallKitController.configure(
            audioSession: audioSession,
            configuration: audioConfigurationForActiveCall()
        )
    }

    /// The audio-session configuration for the call whose audio is being activated.
    /// Prefers the answered call's `activeCalls` entry; falls back to any single
    /// active call (defensive — `maximumCallsPerCallGroup == 1`), else audio.
    /// Separated from `didActivate` so the per-call video derivation (L6) is
    /// unit-testable without a live `AVAudioSession`.
    func audioConfigurationForActiveCall() -> CallAudioSessionConfiguration {
        let hasVideo = activeAudioCallUUID.flatMap { activeCalls[$0] }
            ?? activeCalls.values.first
            ?? false
        return .forCall(hasVideo: hasVideo)
    }

    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        // No-op: CallKit owns session deactivation; the OS restores the pre-call category
        // on full call teardown.
    }

    // MARK: - Audio-session configuration (separated for isolation)

    /// Apply a `CallAudioSessionConfiguration` to a real `AVAudioSession`.
    static func configure(audioSession: AVAudioSession, configuration: CallAudioSessionConfiguration) {
        let category = AVAudioSession.Category(rawValue: configuration.category)
        let mode = AVAudioSession.Mode(rawValue: configuration.mode)
        var options: AVAudioSession.CategoryOptions = [.allowBluetoothHFP]
        if configuration.defaultToSpeaker { options.insert(.defaultToSpeaker) }
        if configuration.allowsBluetoothA2DP { options.insert(.allowBluetoothA2DP) }
        do {
            try audioSession.setCategory(category, mode: mode, options: options)
            try audioSession.setActive(true, options: [])
        } catch {
            os_log("audio session config failed: %{public}@",
                   log: BackgroundCallKitController.logger, type: .error, "\(error)")
        }
    }

    /// Apply a device audio route (M-NATIVE-3 / CCF-11, Step 11) to the live call via
    /// `AVAudioSession.overrideOutputAudioPort`. `.bluetooth`/`.system` are
    /// platform-managed (no override); the system route picker owns BT selection.
    func setAudioRoute(_ route: CallAudioRoute) {
        guard let override = route.portOverride else {
            os_log("audio route %{public}@ is platform-managed (no override)",
                   log: BackgroundCallKitController.logger, type: .info, route.rawValue)
            return
        }
        do {
            try AVAudioSession.sharedInstance().overrideOutputAudioPort(override)
            os_log("set audio route %{public}@",
                   log: BackgroundCallKitController.logger, type: .info, route.rawValue)
        } catch {
            os_log("overrideOutputAudioPort failed: %{public}@",
                   log: BackgroundCallKitController.logger, type: .error, "\(error)")
        }
    }
}