fix(macos): coalesce location permission requests (#116183)

This commit is contained in:
Vincent Koc
2026-07-30 10:41:51 +08:00
committed by GitHub
parent 5128df0cdd
commit 859280ee11
3 changed files with 90 additions and 21 deletions
+1
View File
@@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai
- **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129.
- **macOS remote tunnel lifecycle:** prevent cancelled or superseded restart backoffs from recreating SSH tunnels, and join a tunnel create that another caller started while the actor was suspended.
- **macOS location permission requests:** coalesce concurrent prompts so every caller resumes, and stop cancelled timeouts from opening Settings or completing a newer request.
- **Control UI update reconciliation:** preserve an unresolved managed-update request across disconnects, accept the replacement Gateway version when it proves success, and otherwise show explicit recovery guidance instead of trusting an unrelated cached update result or failing silently. Fixes #116075. Thanks @shakkernerd.
- **Control UI model readiness:** put AI setup first when no model is selectable, distinguish signed-in credentials from ready providers, and route accounts with no exposed models directly to provider recovery instead of leading with disabled default controls.
- **Control UI Talk session isolation:** stop active realtime Talk media and retire its callbacks before chat session changes, Gateway disconnects, or pane disposal so previous-session audio, transcript, camera, and status updates cannot leak into the next view. Thanks @shakkernerd.
@@ -295,12 +295,42 @@ enum LocationPermissionHelper {
}
}
@MainActor
final class LocationPermissionRequestCoordinator {
private var continuations: [CheckedContinuation<CLAuthorizationStatus, Never>] = []
var hasPendingRequests: Bool {
!self.continuations.isEmpty
}
var pendingRequestCount: Int {
self.continuations.count
}
func wait(onEnqueue: (_ isFirstRequest: Bool) -> Void) async -> CLAuthorizationStatus {
await withCheckedContinuation { continuation in
let isFirstRequest = self.continuations.isEmpty
self.continuations.append(continuation)
onEnqueue(isFirstRequest)
}
}
func finish(status: CLAuthorizationStatus) {
let continuations = self.continuations
self.continuations.removeAll()
for continuation in continuations {
continuation.resume(returning: status)
}
}
}
@MainActor
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
static let shared = LocationPermissionRequester()
private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private let requests = LocationPermissionRequestCoordinator()
private var timeoutTask: Task<Void, Never>?
private var requestedAlways = false
override init() {
super.init()
@@ -318,35 +348,47 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
return current
}
return await withCheckedContinuation { cont in
self.continuation = cont
self.timeoutTask?.cancel()
self.timeoutTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
await MainActor.run { [weak self] in
guard let self else { return }
guard self.continuation != nil else { return }
LocationPermissionHelper.openSettings()
self.finish(status: self.manager.authorizationStatus)
}
}
if always {
return await self.requests.wait { isFirstRequest in
if isFirstRequest {
self.requestedAlways = always
self.scheduleTimeout()
self.requestAuthorization(always: always)
// On macOS, requesting an actual fix makes the prompt more reliable.
self.manager.requestLocation()
} else if always, !self.requestedAlways {
self.requestedAlways = true
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
}
}
// On macOS, requesting an actual fix makes the prompt more reliable.
self.manager.requestLocation()
private func requestAuthorization(always: Bool) {
if always {
self.manager.requestAlwaysAuthorization()
} else {
self.manager.requestWhenInUseAuthorization()
}
}
private func scheduleTimeout() {
self.timeoutTask?.cancel()
self.timeoutTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: 3_000_000_000)
} catch {
return
}
guard !Task.isCancelled, let self, self.requests.hasPendingRequests else { return }
LocationPermissionHelper.openSettings()
self.finish(status: self.manager.authorizationStatus)
}
}
private func finish(status: CLAuthorizationStatus) {
self.timeoutTask?.cancel()
self.timeoutTask = nil
guard let cont = self.continuation else { return }
self.continuation = nil
cont.resume(returning: status)
self.requestedAlways = false
self.requests.finish(status: status)
}
/// nonisolated for Swift 6 strict concurrency compatibility
@@ -2,7 +2,33 @@ import CoreLocation
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct PermissionManagerLocationTests {
@Test
func `concurrent request waiters all resume`() async {
let coordinator = LocationPermissionRequestCoordinator()
let first = Task { @MainActor in
await coordinator.wait { _ in }
}
while coordinator.pendingRequestCount < 1 {
await Task.yield()
}
let second = Task { @MainActor in
await coordinator.wait { _ in }
}
while coordinator.pendingRequestCount < 2 {
await Task.yield()
}
coordinator.finish(status: .denied)
#expect(await first.value == .denied)
#expect(await second.value == .denied)
#expect(coordinator.pendingRequestCount == 0)
}
@Test
func `authorizedAlways counts for both modes`() {
#expect(PermissionManager.isLocationAuthorized(status: .authorizedAlways, requireAlways: false))