From 859280ee11992d5ea4bd72a87d2e53640909de98 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 10:41:51 +0800 Subject: [PATCH] fix(macos): coalesce location permission requests (#116183) --- CHANGELOG.md | 1 + .../Sources/OpenClaw/PermissionManager.swift | 84 ++++++++++++++----- .../PermissionManagerLocationTests.swift | 26 ++++++ 3 files changed, 90 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41170403b1e0..792fb0f571d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/macos/Sources/OpenClaw/PermissionManager.swift b/apps/macos/Sources/OpenClaw/PermissionManager.swift index 42cbfa0b71a5..5570e78364ea 100644 --- a/apps/macos/Sources/OpenClaw/PermissionManager.swift +++ b/apps/macos/Sources/OpenClaw/PermissionManager.swift @@ -295,12 +295,42 @@ enum LocationPermissionHelper { } } +@MainActor +final class LocationPermissionRequestCoordinator { + private var continuations: [CheckedContinuation] = [] + + 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? + private let requests = LocationPermissionRequestCoordinator() private var timeoutTask: Task? + 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 diff --git a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift index 2edf040bb75e..3c68941e89aa 100644 --- a/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/PermissionManagerLocationTests.swift @@ -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))