diff --git a/bitchat/Utils/SystemSettings.swift b/bitchat/Utils/SystemSettings.swift index ab552e07..2a739ea9 100644 --- a/bitchat/Utils/SystemSettings.swift +++ b/bitchat/Utils/SystemSettings.swift @@ -14,17 +14,22 @@ import AppKit enum SystemSettings { case bluetooth + /// The radio power toggle, distinct from the `.bluetooth` privacy + /// permission anchor: an already-authorized person whose radio is + /// switched off can't fix anything from the privacy pane. + case bluetoothPower case location case microphone #if os(macOS) private static let baseURL = "x-apple.systempreferences:com.apple.preference.security" - private var macPrivacyAnchor: String { + private var macURLString: String { switch self { - case .bluetooth: "Privacy_Bluetooth" - case .location: "Privacy_LocationServices" - case .microphone: "Privacy_Microphone" + case .bluetooth: "\(Self.baseURL)?Privacy_Bluetooth" + case .bluetoothPower: "x-apple.systempreferences:com.apple.BluetoothSettings" + case .location: "\(Self.baseURL)?Privacy_LocationServices" + case .microphone: "\(Self.baseURL)?Privacy_Microphone" } } #endif @@ -35,8 +40,7 @@ enum SystemSettings { UIApplication.shared.open(url) } #elseif os(macOS) - let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)" - if let url = URL(string: urlString) { + if let url = URL(string: macURLString) { NSWorkspace.shared.open(url) } #endif diff --git a/bitchat/Views/Components/ConnectivityStatusBanner.swift b/bitchat/Views/Components/ConnectivityStatusBanner.swift index 8e59909e..4caedccf 100644 --- a/bitchat/Views/Components/ConnectivityStatusBanner.swift +++ b/bitchat/Views/Components/ConnectivityStatusBanner.swift @@ -73,19 +73,22 @@ struct ConnectivityStatusBanner: View { } } - /// Bluetooth problems deep-link to settings; the tor stall has no - /// in-app remedy (the network is blocking it), so that banner is inert. - private var opensSettings: Bool { + /// Bluetooth problems deep-link to where the fix actually lives — + /// powered-off goes to the radio controls, denied to the privacy + /// permission pane. The tor stall has no in-app remedy (the network is + /// blocking it), so that banner is inert. + private var settingsDestination: SystemSettings? { switch issue { - case .bluetoothOff, .bluetoothDenied: return true - case .bluetoothUnsupported, .torBlocked: return false + case .bluetoothOff: return .bluetoothPower + case .bluetoothDenied: return .bluetooth + case .bluetoothUnsupported, .torBlocked: return nil } } var body: some View { Group { - if opensSettings { - Button(action: { SystemSettings.bluetooth.open() }) { + if let destination = settingsDestination { + Button(action: { destination.open() }) { label } .buttonStyle(.plain) diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index a4092b43..f02bc575 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -183,6 +183,18 @@ struct ContentPeopleSheetView: View { .environmentObject(verificationModel) } } + // This sheet covers the root header where the connectivity + // banner lives; a person sitting in the people list or a DM + // would otherwise get no persistent signal that the radio is + // off or tor is stalled. Mirror it here. + .safeAreaInset(edge: .top, spacing: 0) { + if let issue = ConnectivityIssue.resolve( + bluetoothState: appChromeModel.bluetoothState, + torBlocked: appChromeModel.torBlocked + ) { + ConnectivityStatusBanner(issue: issue) + } + } } .themedSheetBackground() .foregroundColor(palette.primary) @@ -285,7 +297,10 @@ struct ContentPeopleSheetView: View { isPresented: bluetoothAlertBinding ) { Button("content.alert.bluetooth_required.settings") { - SystemSettings.bluetooth.open() + // Powered-off needs the radio controls, not the privacy pane. + (appChromeModel.bluetoothState == .poweredOff + ? SystemSettings.bluetoothPower + : SystemSettings.bluetooth).open() } Button("common.ok", role: .cancel) {} } message: { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index f10d04eb..6e3969f9 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -409,7 +409,10 @@ struct ContentView: View { }) .alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) { Button("content.alert.bluetooth_required.settings") { - SystemSettings.bluetooth.open() + // Powered-off needs the radio controls, not the privacy pane. + (appChromeModel.bluetoothState == .poweredOff + ? SystemSettings.bluetoothPower + : SystemSettings.bluetooth).open() } Button("common.ok", role: .cancel) {} } message: { diff --git a/bitchatTests/ChatViewModelTorTests.swift b/bitchatTests/ChatViewModelTorTests.swift index d4697dd8..d5eba112 100644 --- a/bitchatTests/ChatViewModelTorTests.swift +++ b/bitchatTests/ChatViewModelTorTests.swift @@ -165,14 +165,33 @@ struct ChatViewModelTorTests { viewModel.torStatusAnnounced = true viewModel.torInitialReadyAnnounced = true viewModel.torRestartPending = true + viewModel.torBlocked = true // Action viewModel.handleTorPreferenceChanged(Notification(name: .init("test"))) try? await Task.sleep(nanoseconds: 100_000_000) - // Assert: all flags reset + // Assert: all flags reset — torBlocked drives the connectivity + // banner; toggling tor off must not leave a stale "tor can't + // connect" line for a network nobody is waiting on. #expect(!viewModel.torStatusAnnounced) #expect(!viewModel.torInitialReadyAnnounced) #expect(!viewModel.torRestartPending) + #expect(!viewModel.torBlocked) + } + + @Test @MainActor + func handleTorDidBecomeReady_clearsBlockedBanner() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: a stalled bootstrap raised the banner, then tor got through. + viewModel.torBlocked = true + + // Action + viewModel.handleTorDidBecomeReady() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + #expect(!viewModel.torBlocked) } }