diff --git a/bitchat/App/MacRelayActivityController.swift b/bitchat/App/MacRelayActivityController.swift new file mode 100644 index 00000000..7325cd20 --- /dev/null +++ b/bitchat/App/MacRelayActivityController.swift @@ -0,0 +1,72 @@ +// +// MacRelayActivityController.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if os(macOS) + +import Combine +import CoreBluetooth +import Foundation +import Tor + +/// Drives `RelayActivityAssertion` from the live transports, so the App Nap +/// exemption is held exactly while Tor or the BLE mesh is relaying (#1593). +@MainActor +final class MacRelayActivityController { + private let assertion: RelayActivityAssertion + private var cancellables = Set() + + // Default arguments are evaluated in a nonisolated context, so neither the + // assertion nor the `.shared` singletons can be defaulted in a signature — + // they are main-actor isolated. Both are resolved in-body instead. + init() { + assertion = RelayActivityAssertion() + } + + /// Observes the live transports. Called once the runtime exists. + func observe(chatViewModel: ChatViewModel) { + let activation = NetworkActivationService.shared + let tor = TorManager.shared + cancellables.removeAll() + + // Tor "on" per user preference *and* activation policy — this covers + // the bootstrap window, before `isReady` flips. + Publishers.CombineLatest(activation.$activationAllowed, activation.$userTorEnabled) + .map { $0 && $1 } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] torDesired in + self?.assertion.update { $0.torDesired = torDesired } + } + .store(in: &cancellables) + + tor.$isReady + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] ready in + self?.assertion.update { $0.torReady = ready } + } + .store(in: &cancellables) + + chatViewModel.$bluetoothState + .map { $0 == .poweredOn } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] poweredOn in + self?.assertion.update { $0.bluetoothPoweredOn = poweredOn } + } + .store(in: &cancellables) + } + + /// Drops the transport subscriptions and releases the assertion. + func stop() { + cancellables.removeAll() + assertion.release() + } +} + +#endif diff --git a/bitchat/App/RelayActivityAssertion.swift b/bitchat/App/RelayActivityAssertion.swift new file mode 100644 index 00000000..788c5d03 --- /dev/null +++ b/bitchat/App/RelayActivityAssertion.swift @@ -0,0 +1,113 @@ +// +// RelayActivityAssertion.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if os(macOS) + +import Foundation + +/// Abstracts `ProcessInfo`'s activity assertions so the gating policy can be +/// exercised without touching real power-management state. +protocol ProcessActivityAsserting: AnyObject { + func beginActivity(reason: String) -> NSObjectProtocol + func endActivity(_ token: NSObjectProtocol) +} + +final class ProcessInfoActivityAsserter: ProcessActivityAsserting { + func beginActivity(reason: String) -> NSObjectProtocol { + ProcessInfo.processInfo.beginActivity( + options: [.userInitiatedAllowingIdleSystemSleep], + reason: reason + ) + } + + func endActivity(_ token: NSObjectProtocol) { + ProcessInfo.processInfo.endActivity(token) + } +} + +/// Holds an App Nap exemption while a transport is actually relaying. +/// +/// macOS throttles timers, networking and BLE callbacks for an app whose +/// windows are all minimized or fully occluded, which stalls Tor and the mesh +/// relay even though the process is still "running" (#1593). iOS has explicit +/// scene-phase dormancy handling; macOS had none. +/// +/// The assertion is deliberately *not* held for the whole process lifetime: a +/// configuration with Tor switched off and Bluetooth unavailable is not +/// relaying anything, so an assertion there is pure battery cost. +/// +/// Transport wiring lives in `MacRelayActivityController`; this type stays free +/// of Combine and AppKit so the policy and the begin/end bookkeeping are +/// directly testable. +@MainActor +final class RelayActivityAssertion { + static let activityReason = "Relaying bitchat traffic over Tor and BLE mesh" + + /// Inputs that decide whether the process is doing relay work. + struct TransportState: Equatable { + /// Tor is switched on by the user and network activation policy allows + /// it. Covers bootstrap, which needs CPU before `torReady` flips. + var torDesired: Bool = false + /// Tor has finished bootstrapping and is carrying traffic. + var torReady: Bool = false + /// The BLE mesh radio is powered on, so we can scan/advertise/relay. + var bluetoothPoweredOn: Bool = false + } + + /// Pure policy. + /// + /// `torDesired` is included alongside `torReady` on purpose: dropping the + /// assertion mid-bootstrap is what would leave a minimized window stuck + /// part-way through connecting. + static func shouldHold(_ state: TransportState) -> Bool { + state.torDesired || state.torReady || state.bluetoothPoweredOn + } + + private let asserter: ProcessActivityAsserting + private var token: NSObjectProtocol? + private(set) var state = TransportState() + + /// Whether the App Nap exemption is currently held. + var isHolding: Bool { token != nil } + + init(asserter: ProcessActivityAsserting = ProcessInfoActivityAsserter()) { + self.asserter = asserter + } + + /// Applies a state change, beginning or ending the assertion if the policy + /// verdict flipped. Idempotent: repeated same-verdict updates never stack + /// or double-release assertions. + func apply(_ newState: TransportState) { + state = newState + let wanted = Self.shouldHold(state) + if wanted, token == nil { + token = asserter.beginActivity(reason: Self.activityReason) + } else if !wanted, let held = token { + asserter.endActivity(held) + token = nil + } + } + + /// Mutates one field of the transport state, re-evaluating the policy. + /// Used by the per-transport Combine sinks, which each know about one flag. + func update(_ mutate: (inout TransportState) -> Void) { + var next = state + mutate(&next) + guard next != state else { return } + apply(next) + } + + /// Releases the assertion regardless of transport state (app teardown). + func release() { + guard let held = token else { return } + asserter.endActivity(held) + token = nil + } +} + +#endif diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index cf2e1bd1..72cabb25 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -89,10 +89,25 @@ final class AppDelegate: NSObject, UIApplicationDelegate { #if os(macOS) import AppKit +@MainActor final class MacAppDelegate: NSObject, NSApplicationDelegate { - weak var runtime: AppRuntime? + /// Holds off App Nap while a transport is actually relaying, so Tor and the + /// BLE mesh keep serving neighbors when the window is minimized or fully + /// occluded by another app (#1593). + /// + /// Scoped to the minimize/occlusion case only: closing the last window + /// still quits, so "I closed bitchat" continues to mean the radios stop. + private let relayActivity = MacRelayActivityController() + + weak var runtime: AppRuntime? { + didSet { + guard let runtime else { return } + relayActivity.observe(chatViewModel: runtime.chatViewModel) + } + } func applicationWillTerminate(_ notification: Notification) { + relayActivity.stop() runtime?.applicationWillTerminate() }