fix(macOS): hold an App Nap exemption while relaying

App Nap freezes timers, sockets and BLE callbacks once every window is
minimized or fully occluded, which stalls Tor and the mesh relay even
though the process is still running (#1593). iOS has scene-phase dormancy
handling; macOS had none.

Scoped to the minimize/occlusion case: closing the last window still
quits, so "I closed bitchat" continues to mean the radios stop.

The exemption is gated on a transport actually relaying rather than held
for process lifetime — Tor switched on (including bootstrap, which needs
CPU before isReady flips) or Bluetooth powered on. With Tor off and
Bluetooth unavailable there is nothing to relay, so an assertion there is
pure battery cost.

Policy and begin/end bookkeeping live in RelayActivityAssertion, free of
Combine and AppKit so both are directly testable; MacRelayActivityController
holds the transport subscriptions.
This commit is contained in:
Taksh 2026-08-11 15:27:04 +05:30
parent 9b84b36122
commit eb19016442
3 changed files with 201 additions and 1 deletions

View File

@ -0,0 +1,72 @@
//
// MacRelayActivityController.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#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<AnyCancellable>()
// 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

View File

@ -0,0 +1,113 @@
//
// RelayActivityAssertion.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#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

View File

@ -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()
}