Review fixes: banner in the sheet, real fix targets, tor-clear tests

- The people/DM sheet covers the root header, so the connectivity
  banner vanished exactly where people sit longest. It now mirrors
  into the sheet via a top safe-area inset over both the people list
  and the DM view.
- "tap to fix" for powered-off Bluetooth opened the macOS privacy
  permission pane, where an already-authorized person can't fix
  anything. New SystemSettings.bluetoothPower routes to the Bluetooth
  pane; denied still goes to the privacy anchor. The one-shot alert's
  settings button routes by state the same way.
- Tests pin that torBlocked clears on tor-ready and on preference
  change (no stale "tor can't connect" after toggling tor off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-09 11:57:45 +02:00
parent 09b3f294ac
commit 12b55afdad
5 changed files with 60 additions and 16 deletions

View File

@ -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

View File

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

View File

@ -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: {

View File

@ -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: {

View File

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