From eb19016442bb611cde0c7fb76e0f2d9d8ce9d199 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 11 Aug 2026 15:27:04 +0530 Subject: [PATCH 1/3] fix(macOS): hold an App Nap exemption while relaying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bitchat/App/MacRelayActivityController.swift | 72 ++++++++++++ bitchat/App/RelayActivityAssertion.swift | 113 +++++++++++++++++++ bitchat/BitchatApp.swift | 17 ++- 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 bitchat/App/MacRelayActivityController.swift create mode 100644 bitchat/App/RelayActivityAssertion.swift 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() } From 2f685bc8ecf8e89a4edb96bd1cc3a18c8fe6cd17 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 11 Aug 2026 15:27:04 +0530 Subject: [PATCH 2/3] test(macOS): cover relay activity gating and assertion lifecycle Covers the full transport truth table plus the bookkeeping a leak would otherwise only reveal in Instruments: no assertion while idle, begin exactly once, no stacking when a second transport comes up, hold through losing one transport, release with the last, re-arm after a radio cycles, and idempotent release. --- .../RelayActivityAssertionTests.swift | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 bitchatTests/RelayActivityAssertionTests.swift diff --git a/bitchatTests/RelayActivityAssertionTests.swift b/bitchatTests/RelayActivityAssertionTests.swift new file mode 100644 index 00000000..3dae363e --- /dev/null +++ b/bitchatTests/RelayActivityAssertionTests.swift @@ -0,0 +1,240 @@ +// +// RelayActivityAssertionTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if os(macOS) + +import Foundation +import Testing +@testable import bitchat + +/// macOS App Nap freezes Tor and BLE relay work once every window is minimized +/// or occluded (#1593). The fix holds a `ProcessInfo` activity — but only while +/// a transport is actually relaying, so an idle configuration does not pay for +/// an assertion it cannot use. +@MainActor +struct RelayActivityAssertionTests { + + /// Counts assertions so a leak (begin without end) or a double-begin is + /// visible to the test rather than only to Instruments. + private final class SpyAsserter: ProcessActivityAsserting { + private(set) var beginCount = 0 + private(set) var endCount = 0 + private(set) var lastReason: String? + var outstanding: Int { beginCount - endCount } + + func beginActivity(reason: String) -> NSObjectProtocol { + beginCount += 1 + lastReason = reason + return NSString(string: "activity-\(beginCount)") + } + + func endActivity(_ token: NSObjectProtocol) { + endCount += 1 + } + } + + private typealias State = RelayActivityAssertion.TransportState + + private func state(tor: Bool = false, ready: Bool = false, ble: Bool = false) -> State { + State(torDesired: tor, torReady: ready, bluetoothPoweredOn: ble) + } + + // MARK: - Policy + + @Test("An idle configuration holds no assertion") + func idleConfigurationHoldsNothing() { + // Tor off and Bluetooth off: nothing to relay, so nothing to protect. + #expect(!RelayActivityAssertion.shouldHold(state())) + } + + @Test("BLE alone justifies the assertion") + func bluetoothAloneJustifiesTheAssertion() { + // Mesh-only, Tor deliberately off — the BLE relay is exactly what App + // Nap would freeze on a minimized window. + #expect(RelayActivityAssertion.shouldHold(state(ble: true))) + } + + @Test("Tor bootstrap justifies the assertion before it reports ready") + func torBootstrapJustifiesTheAssertion() { + // Dropping the assertion here is what would leave a minimized window + // stuck "starting tor…" indefinitely. + #expect(RelayActivityAssertion.shouldHold(state(tor: true))) + } + + @Test("Tor carrying traffic holds even if the policy flag lagged") + func torReadyWithoutDesireStillHolds() { + #expect(RelayActivityAssertion.shouldHold(state(ready: true))) + } + + @Test("Every transport combination matches the documented policy") + func everyTransportCombinationMatchesPolicy() { + for tor in [false, true] { + for ready in [false, true] { + for ble in [false, true] { + #expect( + RelayActivityAssertion.shouldHold(state(tor: tor, ready: ready, ble: ble)) + == (tor || ready || ble), + "tor=\(tor) ready=\(ready) ble=\(ble)" + ) + } + } + } + } + + // MARK: - Assertion lifecycle + + @Test("Nothing is asserted before a transport comes up") + func doesNotHoldOnCreation() { + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + #expect(!assertion.isHolding) + #expect(spy.beginCount == 0) + } + + @Test("The assertion begins once, with the documented reason") + func beginsOnceWhenATransportComesUp() { + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.apply(state(ble: true)) + + #expect(assertion.isHolding) + #expect(spy.beginCount == 1) + #expect(spy.lastReason == RelayActivityAssertion.activityReason) + } + + @Test("Further active updates do not stack assertions") + func repeatedActiveUpdatesDoNotStack() { + // The three Combine sources fire independently; a second "still active" + // update must not leak another ProcessInfo token. + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.apply(state(ble: true)) + assertion.apply(state(tor: true, ble: true)) + assertion.apply(state(tor: true, ready: true, ble: true)) + + #expect(spy.beginCount == 1) + #expect(spy.outstanding == 1) + } + + @Test("The assertion survives losing one transport and drops with the last") + func releasesOnlyWhenTheLastTransportGoesDown() { + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.apply(state(tor: true, ble: true)) + assertion.apply(state(tor: true)) // bluetooth off, Tor still up + #expect(assertion.isHolding) + #expect(spy.endCount == 0) + + assertion.apply(state()) // everything off + #expect(!assertion.isHolding) + #expect(spy.outstanding == 0) + } + + @Test("Repeated idle updates do not double-release") + func repeatedIdleUpdatesDoNotDoubleRelease() { + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.apply(state(ble: true)) + assertion.apply(state()) + assertion.apply(state()) + + #expect(spy.endCount == 1) + #expect(spy.outstanding == 0) + } + + @Test("A transport cycling off and on re-arms the exemption") + func reacquiresAfterTransportsComeBack() { + // e.g. the Bluetooth radio cycling — the relay must not stay throttled. + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.apply(state(ble: true)) + assertion.apply(state()) + assertion.apply(state(ble: true)) + + #expect(assertion.isHolding) + #expect(spy.beginCount == 2) + #expect(spy.endCount == 1) + #expect(spy.outstanding == 1) + } + + // MARK: - update() + + @Test("A single-flag update drives the policy") + func updateAppliesASingleFlag() { + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.update { $0.bluetoothPoweredOn = true } + #expect(assertion.isHolding) + #expect(spy.beginCount == 1) + + assertion.update { $0.bluetoothPoweredOn = false } + #expect(!assertion.isHolding) + #expect(spy.outstanding == 0) + } + + @Test("A no-op update does not touch the assertion") + func updateIgnoresUnchangedState() { + // `removeDuplicates()` upstream should prevent this, but the sinks are + // independent and the assertion must not depend on that. + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.update { $0.bluetoothPoweredOn = true } + assertion.update { $0.bluetoothPoweredOn = true } + + #expect(spy.beginCount == 1) + } + + // MARK: - Teardown + + @Test("Release hands the assertion back") + func releaseHandsTheAssertionBack() { + // applicationWillTerminate path. + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.apply(state(tor: true)) + assertion.release() + + #expect(!assertion.isHolding) + #expect(spy.outstanding == 0) + } + + @Test("Release is idempotent") + func releaseIsIdempotent() { + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.apply(state(tor: true)) + assertion.release() + assertion.release() + + #expect(spy.endCount == 1) + #expect(spy.outstanding == 0) + } + + @Test("Release without a held assertion is safe") + func releaseWithoutActivityIsSafe() { + let spy = SpyAsserter() + let assertion = RelayActivityAssertion(asserter: spy) + + assertion.release() + + #expect(spy.beginCount == 0) + #expect(spy.endCount == 0) + } +} + +#endif From 9be1b305f1925cfa3d342ac51b513726cc78b9b8 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 11 Aug 2026 15:39:09 +0530 Subject: [PATCH 3/3] docs(tor): describe the macOS App Nap exemption and its gating --- docs/TOR-INTEGRATION.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/TOR-INTEGRATION.md b/docs/TOR-INTEGRATION.md index 46f80951..da2c41db 100644 --- a/docs/TOR-INTEGRATION.md +++ b/docs/TOR-INTEGRATION.md @@ -40,6 +40,14 @@ Private messages target the built-in relay set plus any relays added by hand (`N - The xcframework must include iOS device, iOS simulator, and macOS arm64 slices. - Any refresh reviews the Rust source, `Cargo.lock`, generated header, build script, and new hashes together. A binary-only update is not acceptable. +## macOS: stay alive while minimized + +On iOS, `AppRuntime` puts Tor dormant when the scene enters the background — the OS will suspend the process anyway. macOS has no equivalent scene-phase dormancy path: Tor is meant to keep running so a docked install can still bridge Nostr for nearby mesh peers. + +macOS App Nap can still freeze that work when the window is minimized or fully occluded by another app. `MacRelayActivityController` therefore holds a `ProcessInfo` activity (`.userInitiatedAllowingIdleSystemSleep`) while a transport is actually relaying — Tor switched on (including bootstrap) or Bluetooth powered on. With Tor off and Bluetooth unavailable there is nothing to relay, so the assertion is released rather than costing battery for an idle process. + +Closing the last window still quits the app, so stopping bitchat still stops the radios. See #1593. + ## Known gap: no bridges or pluggable transports `arti-client` is built with `default-features = false` and features `["tokio", "rustls"]` only — no `pt-client`, no `bridge-client` — and `arti-bitchat/src/lib.rs` bootstraps from stock configuration with no bridge lines and no configurable directory authorities.