mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-09-19 05:00:48 +00:00
Merge 3fa43f6092c793c272963debfb85848e868b8bd8 into 9b84b361225facd8e623f25d76f889d3dc54a879
This commit is contained in:
commit
ca17e0ca8c
65
bitchat/App/MacAppNapRiskMonitor.swift
Normal file
65
bitchat/App/MacAppNapRiskMonitor.swift
Normal file
@ -0,0 +1,65 @@
|
||||
//
|
||||
// MacAppNapRiskMonitor.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Tracks when macOS would apply App Nap to this process.
|
||||
///
|
||||
/// App Nap throttles a backgrounded app whose windows are all minimized or
|
||||
/// fully occluded. While a window is visible, the OS does not nap us and an
|
||||
/// activity assertion would be wasted battery for no benefit (#1593).
|
||||
@MainActor
|
||||
final class MacAppNapRiskMonitor: ObservableObject {
|
||||
@Published private(set) var appNapWouldApply = false
|
||||
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
func start() {
|
||||
guard observers.isEmpty else { return }
|
||||
let center = NotificationCenter.default
|
||||
let names: [Notification.Name] = [
|
||||
NSWindow.didMiniaturizeNotification,
|
||||
NSWindow.didDeminiaturizeNotification,
|
||||
NSWindow.didChangeOcclusionStateNotification,
|
||||
NSApplication.didBecomeActiveNotification,
|
||||
NSApplication.didResignActiveNotification
|
||||
]
|
||||
for name in names {
|
||||
observers.append(
|
||||
center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
observers.removeAll()
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
appNapWouldApply = Self.computeAppNapWouldApply()
|
||||
}
|
||||
|
||||
/// True when every window is minimized or not visibly on screen.
|
||||
static func computeAppNapWouldApply() -> Bool {
|
||||
let windows = NSApplication.shared.windows.filter { $0.isVisible || $0.isMiniaturized }
|
||||
guard !windows.isEmpty else { return true }
|
||||
return !windows.contains { window in
|
||||
!window.isMiniaturized && window.occlusionState.contains(.visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
47
bitchat/App/MacKeepRunningSettings.swift
Normal file
47
bitchat/App/MacKeepRunningSettings.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// MacKeepRunningSettings.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
|
||||
|
||||
/// Controls whether closing the last window quits bitchat or leaves Tor and
|
||||
/// the BLE mesh relaying from the dock (#1593, #1633).
|
||||
///
|
||||
/// Default is off: closing the window should stop the radios, matching what
|
||||
/// most people expect from a privacy-first messenger. Minimize/occlusion
|
||||
/// keep-alive is handled separately via `RelayActivityAssertion`.
|
||||
enum MacKeepRunningSettings {
|
||||
private static let enabledKey = "macos.keepRunningWhenWindowClosed"
|
||||
|
||||
/// When true, the process survives after the last window closes.
|
||||
static var enabled: Bool {
|
||||
get { enabled(in: .standard) }
|
||||
set { setEnabled(newValue, in: .standard) }
|
||||
}
|
||||
|
||||
/// Value for `applicationShouldTerminateAfterLastWindowClosed`.
|
||||
static func shouldTerminateAfterLastWindowClosed(in defaults: UserDefaults = .standard) -> Bool {
|
||||
!enabled(in: defaults)
|
||||
}
|
||||
|
||||
static func enabled(in defaults: UserDefaults) -> Bool {
|
||||
defaults.object(forKey: enabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
static func setEnabled(_ enabled: Bool, in defaults: UserDefaults) {
|
||||
defaults.set(enabled, forKey: enabledKey)
|
||||
}
|
||||
|
||||
/// Panic-wipe hook. Removing the key restores quit-on-close behaviour.
|
||||
static func reset(in defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: enabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
88
bitchat/App/MacRelayActivityController.swift
Normal file
88
bitchat/App/MacRelayActivityController.swift
Normal file
@ -0,0 +1,88 @@
|
||||
//
|
||||
// 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 let appNapMonitor = MacAppNapRiskMonitor()
|
||||
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()
|
||||
appNapMonitor.start()
|
||||
|
||||
appNapMonitor.$appNapWouldApply
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] wouldApply in
|
||||
self?.assertion.update { $0.appNapWouldApply = wouldApply }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// 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)
|
||||
|
||||
Publishers.CombineLatest(activation.$activationAllowed, chatViewModel.$bluetoothState)
|
||||
.map { allowed, state in
|
||||
RelayActivityAssertion.isBLERelayActive(
|
||||
bluetoothPoweredOn: state == .poweredOn,
|
||||
activationAllowed: allowed
|
||||
)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] bleRelayActive in
|
||||
self?.assertion.update { $0.bleRelayActive = bleRelayActive }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Drops the transport subscriptions and releases the assertion.
|
||||
func stop() {
|
||||
cancellables.removeAll()
|
||||
appNapMonitor.stop()
|
||||
assertion.release()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
123
bitchat/App/RelayActivityAssertion.swift
Normal file
123
bitchat/App/RelayActivityAssertion.swift
Normal file
@ -0,0 +1,123 @@
|
||||
//
|
||||
// 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 {
|
||||
/// App Nap would throttle us — all windows minimized or occluded.
|
||||
var appNapWouldApply: Bool = false
|
||||
/// 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
|
||||
/// BLE mesh relay is allowed and the radio is up.
|
||||
var bleRelayActive: Bool = false
|
||||
}
|
||||
|
||||
/// Pure policy.
|
||||
///
|
||||
/// The assertion is held only while App Nap would actually bite — a visible
|
||||
/// window does not need it. `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 {
|
||||
guard state.appNapWouldApply else { return false }
|
||||
return state.torDesired || state.torReady || state.bleRelayActive
|
||||
}
|
||||
|
||||
/// BLE is relaying when activation policy allows mesh work and the radio
|
||||
/// is powered on — tighter than "Bluetooth on" alone for an idle config.
|
||||
static func isBLERelayActive(bluetoothPoweredOn: Bool, activationAllowed: Bool) -> Bool {
|
||||
bluetoothPoweredOn && activationAllowed
|
||||
}
|
||||
|
||||
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
|
||||
@ -89,15 +89,31 @@ 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).
|
||||
///
|
||||
/// App Nap keep-alive is scoped to minimize/occlusion only. Closing the
|
||||
/// last window quits by default; users can opt in via settings to stay in
|
||||
/// the dock with Tor/BLE still running.
|
||||
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()
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
MacKeepRunningSettings.shouldTerminateAfterLastWindowClosed()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -31812,6 +31812,379 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.settings.keep_running.subtitle" : {
|
||||
"comment" : "Subtitle explaining the macOS keep-running-when-window-closed setting and its default-off privacy posture",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "إغلاق النافذة عادةً يُنهي bitchat ويوقف tor وشبكة البلوتوث. فعّل هذا الخيار للاستمرار في الترحيل من الـ dock — مغلق افتراضيًا، فالإغلاق يعني الخروج."
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "সাধারণত উইন্ডো বন্ধ করলে bitchat বন্ধ হয় এবং tor ও ব্লুটুথ mesh থেমে যায়। ডক থেকে relay চালিয়ে রাখতে এটি চালু করুন — ডিফল্টে বন্ধ, তাই বন্ধ মানে quit।"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "wenn du das fenster schließt, beendet sich bitchat normalerweise und tor sowie das bluetooth-mesh stoppen. schalte dies ein, um aus dem dock weiter zu relayen — standardmäßig aus, schließen heißt beenden."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "closing the window normally quits bitchat and stops tor and the bluetooth mesh. turn this on to keep relaying from the dock — off by default so closing means quit."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "cerrar la ventana normalmente cierra bitchat y detiene tor y la malla bluetooth. actívalo para seguir retransmitiendo desde el dock — desactivado por defecto, cerrar significa salir."
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "بستن پنجره معمولاً bitchat را میبندد و tor و mesh بلوتوث را متوقف میکند. برای relay از dock روشن کن — پیشفرض خاموش است تا بستن یعنی خروج."
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "karaniwang pag-sara ng bintana ay nag-quit sa bitchat at humihinto ang tor at bluetooth mesh. i-on ito para mag-relay mula sa dock — off bilang default para ang pagsara ay quit."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "fermer la fenêtre quitte bitchat et arrête tor et le maillage bluetooth. active ceci pour continuer à relayer depuis le dock — désactivé par défaut, fermer signifie quitter."
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "סגירת החלון בדרך‑כלל מסיימת את bitchat ועוצרת את tor ואת רשת ה‑bluetooth. הפעל כדי להמשיך relay מה‑dock — כבוי כברירת מחדל, סגירה פירושה יציאה."
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "आमतौर पर विंडो बंद करने पर bitchat बंद हो जाता है और tor व ब्लूटूथ mesh रुक जाता है। dock से relay जारी रखने के लिए चालू करें — डिफ़ॉल्ट बंद, बंद करना मतलब quit।"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "menutup jendela biasanya keluar dari bitchat dan menghentikan tor serta mesh bluetooth. nyalakan untuk tetap relay dari dock — mati secara bawaan, menutup berarti keluar."
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "chiudere la finestra di solito termina bitchat e ferma tor e la mesh bluetooth. attivalo per continuare a inoltrare dal dock — disattivato per impostazione predefinita, chiudere significa uscire."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "通常、ウィンドウを閉じると bitchat は終了し、tor と Bluetooth メッシュも停止します。dock から中継を続ける場合はオンにしてください — 既定はオフで、閉じる=終了です。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "보통 창을 닫으면 bitchat이 종료되고 tor와 블루투스 mesh가 멈춥니다. dock에서 계속 relay하려면 켜세요 — 기본값은 꺼짐, 닫기는 종료를 의미합니다."
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "menutup tetingkap biasanya menamatkan bitchat dan menghentikan tor serta mesh bluetooth. hidupkan untuk terus relay dari dock — dimatikan secara lalai, tutup bermaksud keluar."
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "सामान्यतया सञ्झ्याल बन्द गर्दा bitchat बन्द हुन्छ र tor तथा bluetooth mesh रोकिन्छ। dock बाट relay जारी राख्न सक्रिय गर्नुहोस् — पूर्वनिर्धारित अफ, बन्द गर्नु भनेको quit।"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "sluiten van het venster beëindigt bitchat normaal en stopt tor en het bluetooth-mesh. zet dit aan om vanuit het dock door te relayen — standaard uit, sluiten betekent afsluiten."
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "zamknięcie okna zwykle kończy bitchat i zatrzymuje tor oraz mesh bluetooth. włącz, by nadal relayować z docka — domyślnie wyłączone, zamknięcie oznacza zakończenie."
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "fechar a janela normalmente encerra o bitchat e para o tor e a mesh bluetooth. ative para continuar a retransmitir a partir do dock — desligado por predefinição, fechar significa sair."
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "fechar a janela normalmente encerra o bitchat e para o tor e a mesh bluetooth. ative para continuar retransmitindo pelo dock — desligado por padrão, fechar significa sair."
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "обычно закрытие окна завершает bitchat и останавливает tor и bluetooth‑mesh. включите, чтобы продолжать relay из dock — по умолчанию выкл., закрытие значит выход."
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "att stänga fönstret avslutar normalt bitchat och stoppar tor och bluetooth‑mesh. slå på för att fortsätta relaya från dockan — av som standard, stängning betyder avsluta."
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "சாளரத்தை மூடுவது பொதுவாக bitchat-ஐ நிறுத்தி tor மற்றும் bluetooth mesh-ஐ நிறுத்தும். dock-லிருந்து relay தொடர இதை இயக்கவும் — இயல்பாக அணை, மூடுதல் என்பது quit."
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "การปิดหน้าต่างตามปกติจะออกจาก bitchat และหยุด tor กับ bluetooth mesh เปิดเพื่อ relay ต่อจาก dock — ปิดเป็นค่าเริ่มต้น การปิดหมายถึง quit"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "pencereyi kapatmak normalde bitchat'i sonlandırır ve tor ile bluetooth mesh'i durdurur. dock'tan relay etmeye devam etmek için aç — varsayılan kapalı, kapatmak çıkmak demektir."
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "зазвичай закриття вікна завершує bitchat і зупиняє tor та bluetooth‑mesh. увімкніть, щоб продовжувати relay із dock — за замовчуванням вимк., закриття означає вихід."
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ونڈو بند کرنے سے عام طور پر bitchat بند ہو جاتا ہے اور tor و bluetooth mesh رک جاتا ہے۔ dock سے relay جاری رکھنے کے لیے آن کریں — ڈیفالٹ آف، بند کرنا quit ہے۔"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "đóng cửa sổ thường thoát bitchat và dừng tor cùng mesh bluetooth. bật để tiếp tục relay từ dock — mặc định tắt, đóng nghĩa là thoát."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "通常关闭窗口会退出 bitchat 并停止 tor 与蓝牙 mesh。开启后可在 dock 中继续中继 — 默认关闭,关闭即退出。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "通常關閉視窗會結束 bitchat 並停止 tor 與藍牙 mesh。開啟後可在 dock 中繼續中繼 — 預設關閉,關閉即結束。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_info.settings.keep_running.title" : {
|
||||
"comment" : "Title of the macOS setting that keeps Tor and the BLE mesh relaying after the last window is closed",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "الاستمرار في التشغيل عند إغلاق النافذة"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "উইন্ডো বন্ধ করলেও চালু রাখুন"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "weiterlaufen lassen, wenn das fenster geschlossen ist"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "keep running when window is closed"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "seguir en ejecución al cerrar la ventana"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ادامهٔ اجرا پس از بستن پنجره"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "patuloy na tumakbo kapag sarado ang bintana"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "continuer à tourner quand la fenêtre est fermée"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "המשך לפעול כשהחלון סגור"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "विंडो बंद होने पर भी चालू रखें"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "tetap berjalan saat jendela ditutup"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "continua a girare con la finestra chiusa"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ウィンドウを閉じても実行を続ける"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "창을 닫아도 계속 실행"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "terus berjalan apabila tetingkap ditutup"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "सञ्झ्याल बन्द भए पनि चलिरहोस्"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "blijven draaien wanneer het venster is gesloten"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "działaj dalej po zamknięciu okna"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "continuar a correr com a janela fechada"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "continuar rodando com a janela fechada"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "продолжать работу после закрытия окна"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "fortsätt köra när fönstret stängs"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "சாளரம் மூடப்பட்டாலும் தொடர்ந்து இயங்க"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ทำงานต่อเมื่อปิดหน้าต่าง"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "pencere kapanınca çalışmaya devam et"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "продовжувати роботу після закриття вікна"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ونڈو بند ہونے پر بھی چلتا رہے"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "tiếp tục chạy khi đóng cửa sổ"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "关闭窗口后仍保持运行"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "關閉視窗後仍保持執行"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"app_info.settings.privacy.title" : {
|
||||
"comment" : "Section header (uppercase) for privacy settings such as hiding notification previews",
|
||||
"extractionState" : "manual",
|
||||
|
||||
@ -1641,6 +1641,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
MeshSightingsTracker.shared.clear()
|
||||
MeshEchoSettings.reset()
|
||||
NotificationPrivacySettings.reset()
|
||||
#if os(macOS)
|
||||
MacKeepRunningSettings.reset()
|
||||
#endif
|
||||
// A hand-added relay names an operator someone chose to route through,
|
||||
// which is the kind of trace a wipe should not leave behind.
|
||||
NostrRelaySettings.reset()
|
||||
|
||||
@ -22,6 +22,9 @@ struct AppInfoView: View {
|
||||
@State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled
|
||||
@State private var locationNotesEnabled = LocationNotesSettings.enabled
|
||||
@State private var hideMessagePreviews = NotificationPrivacySettings.hideMessagePreviews
|
||||
#if os(macOS)
|
||||
@State private var keepRunningWhenWindowClosed = MacKeepRunningSettings.enabled
|
||||
#endif
|
||||
@State private var customRelays = NostrRelaySettings.customRelays()
|
||||
@State private var relayInput = ""
|
||||
@State private var relayError: String?
|
||||
@ -118,6 +121,11 @@ struct AppInfoView: View {
|
||||
static let hidePreviewsTitle = String(localized: "app_info.settings.hide_previews.title", defaultValue: "hide message previews", comment: "Title of the setting that keeps message text, sender names, and geohashes out of lock-screen notifications")
|
||||
static let hidePreviewsSubtitle = String(localized: "app_info.settings.hide_previews.subtitle", defaultValue: "notifications say that something arrived without showing the message, who sent it, or which location channel it came from. anyone holding your locked phone learns nothing from the lock screen. on by default.", comment: "Subtitle explaining what hiding notification message previews does")
|
||||
|
||||
#if os(macOS)
|
||||
static let keepRunningTitle = String(localized: "app_info.settings.keep_running.title", defaultValue: "keep running when window is closed", comment: "Title of the macOS setting that keeps Tor and the BLE mesh relaying after the last window is closed")
|
||||
static let keepRunningSubtitle = String(localized: "app_info.settings.keep_running.subtitle", defaultValue: "closing the window normally quits bitchat and stops tor and the bluetooth mesh. turn this on to keep relaying from the dock — off by default so closing means quit.", comment: "Subtitle explaining the macOS keep-running-when-window-closed setting and its default-off privacy posture")
|
||||
#endif
|
||||
|
||||
static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings")
|
||||
static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation")
|
||||
static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut")
|
||||
@ -541,6 +549,22 @@ struct AppInfoView: View {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
settingsCard {
|
||||
settingToggle(
|
||||
title: Text(verbatim: Strings.Settings.keepRunningTitle),
|
||||
subtitle: Text(verbatim: Strings.Settings.keepRunningSubtitle),
|
||||
isOn: Binding(
|
||||
get: { keepRunningWhenWindowClosed },
|
||||
set: { newValue in
|
||||
keepRunningWhenWindowClosed = newValue
|
||||
MacKeepRunningSettings.enabled = newValue
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Danger zone
|
||||
|
||||
47
bitchatTests/MacKeepRunningSettingsTests.swift
Normal file
47
bitchatTests/MacKeepRunningSettingsTests.swift
Normal file
@ -0,0 +1,47 @@
|
||||
//
|
||||
// MacKeepRunningSettingsTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct MacKeepRunningSettingsTests {
|
||||
private func isolatedDefaults() -> UserDefaults {
|
||||
let suite = "MacKeepRunningSettingsTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@Test("Closing the last window quits by default")
|
||||
func defaultQuitsOnLastWindowClose() {
|
||||
let defaults = isolatedDefaults()
|
||||
#expect(!MacKeepRunningSettings.enabled(in: defaults))
|
||||
#expect(MacKeepRunningSettings.shouldTerminateAfterLastWindowClosed(in: defaults))
|
||||
}
|
||||
|
||||
@Test("Opt-in keeps the process alive after the last window closes")
|
||||
func optInSurvivesLastWindowClose() {
|
||||
let defaults = isolatedDefaults()
|
||||
MacKeepRunningSettings.setEnabled(true, in: defaults)
|
||||
#expect(MacKeepRunningSettings.enabled(in: defaults))
|
||||
#expect(!MacKeepRunningSettings.shouldTerminateAfterLastWindowClosed(in: defaults))
|
||||
}
|
||||
|
||||
@Test("Reset restores quit-on-close")
|
||||
func resetRestoresDefault() {
|
||||
let defaults = isolatedDefaults()
|
||||
MacKeepRunningSettings.setEnabled(true, in: defaults)
|
||||
MacKeepRunningSettings.reset(in: defaults)
|
||||
#expect(MacKeepRunningSettings.shouldTerminateAfterLastWindowClosed(in: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
266
bitchatTests/RelayActivityAssertionTests.swift
Normal file
266
bitchatTests/RelayActivityAssertionTests.swift
Normal file
@ -0,0 +1,266 @@
|
||||
//
|
||||
// RelayActivityAssertionTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#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(nap: Bool = true, tor: Bool = false, ready: Bool = false, ble: Bool = false) -> State {
|
||||
State(appNapWouldApply: nap, torDesired: tor, torReady: ready, bleRelayActive: 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("A visible window holds no assertion even while relaying")
|
||||
func visibleWindowHoldsNothing() {
|
||||
#expect(!RelayActivityAssertion.shouldHold(state(nap: false, ble: true)))
|
||||
#expect(!RelayActivityAssertion.shouldHold(state(nap: false, tor: true)))
|
||||
}
|
||||
|
||||
@Test("BLE relay alone justifies the assertion once App Nap would apply")
|
||||
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("BLE relay requires activation policy, not just a powered radio")
|
||||
func bleRelayRequiresActivationPolicy() {
|
||||
#expect(RelayActivityAssertion.isBLERelayActive(bluetoothPoweredOn: true, activationAllowed: true))
|
||||
#expect(!RelayActivityAssertion.isBLERelayActive(bluetoothPoweredOn: true, activationAllowed: false))
|
||||
#expect(!RelayActivityAssertion.isBLERelayActive(bluetoothPoweredOn: false, activationAllowed: 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 nap in [false, true] {
|
||||
for tor in [false, true] {
|
||||
for ready in [false, true] {
|
||||
for ble in [false, true] {
|
||||
#expect(
|
||||
RelayActivityAssertion.shouldHold(state(nap: nap, tor: tor, ready: ready, ble: ble))
|
||||
== (nap && (tor || ready || ble)),
|
||||
"nap=\(nap) 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)
|
||||
|
||||
// update() starts from TransportState defaults (nap=false). Set nap so
|
||||
// the policy can hold — production always feeds window visibility too.
|
||||
assertion.update {
|
||||
$0.appNapWouldApply = true
|
||||
$0.bleRelayActive = true
|
||||
}
|
||||
#expect(assertion.isHolding)
|
||||
#expect(spy.beginCount == 1)
|
||||
|
||||
assertion.update { $0.bleRelayActive = 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.appNapWouldApply = true
|
||||
$0.bleRelayActive = true
|
||||
}
|
||||
assertion.update {
|
||||
$0.appNapWouldApply = true
|
||||
$0.bleRelayActive = 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
|
||||
@ -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`) only while App Nap would apply *and* a transport is actually relaying — Tor switched on (including bootstrap), or BLE mesh work allowed by activation policy with the radio powered on. A visible window or an idle configuration does not hold the assertion.
|
||||
|
||||
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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user