fix(ios): finish Calendar and Reminders grants without relaunch (#116504)

This commit is contained in:
PollyBot13
2026-08-03 03:01:41 +02:00
committed by GitHub
parent ec7e5dd769
commit d9669ce72c
5 changed files with 139 additions and 41 deletions
+12 -12
View File
@@ -27747,7 +27747,7 @@
},
{
"kind": "ui-call",
"line": 48,
"line": 49,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Privacy & Access",
"surface": "apple",
@@ -27755,7 +27755,7 @@
},
{
"kind": "ui-localized-call",
"line": 65,
"line": 66,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Search and add contacts from the assistant.",
"surface": "apple",
@@ -27763,7 +27763,7 @@
},
{
"kind": "ui-localized-call",
"line": 80,
"line": 81,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Read photos you select for the assistant.",
"surface": "apple",
@@ -27771,7 +27771,7 @@
},
{
"kind": "ui-localized-call",
"line": 81,
"line": 82,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Read recent photos for the assistant.",
"surface": "apple",
@@ -27779,7 +27779,7 @@
},
{
"kind": "ui-localized-call",
"line": 83,
"line": 84,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Limited",
"surface": "apple",
@@ -27787,7 +27787,7 @@
},
{
"kind": "ui-localized-call",
"line": 96,
"line": 97,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Calendar (Add Events)",
"surface": "apple",
@@ -27795,7 +27795,7 @@
},
{
"kind": "ui-localized-call",
"line": 97,
"line": 98,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Add events with least privilege.",
"surface": "apple",
@@ -27803,7 +27803,7 @@
},
{
"kind": "ui-localized-call",
"line": 111,
"line": 112,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "List and read calendar events.",
"surface": "apple",
@@ -27811,7 +27811,7 @@
},
{
"kind": "ui-localized-call",
"line": 126,
"line": 127,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "List, add, and complete reminders.",
"surface": "apple",
@@ -27819,7 +27819,7 @@
},
{
"kind": "ui-localized-call",
"line": 128,
"line": 129,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Add-Only",
"surface": "apple",
@@ -27827,7 +27827,7 @@
},
{
"kind": "ui-localized-call",
"line": 168,
"line": 169,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Allow",
"surface": "apple",
@@ -27835,7 +27835,7 @@
},
{
"kind": "ui-localized-call",
"line": 170,
"line": 171,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Open Settings",
"surface": "apple",
@@ -170,10 +170,13 @@ enum DevicePermissionStatusMap {
final class DevicePermissionsModel {
private(set) var grants: [DevicePermissionKind: DevicePermissionGrant] = [:]
private(set) var requesting: Set<DevicePermissionKind> = []
private let eventKitPermissions: EventKitPermissionRequester
/// Owns its manager so authorization callbacks resolve without the app-wide service.
private let locationService = LocationService()
init() {
init(eventKitPermissions: EventKitPermissionRequester = EventKitPermissionRequester()) {
self.eventKitPermissions = eventKitPermissions
self.locationService.setAuthorizationChangeHandler { [weak self] snapshot in
self?.grants[.location] = DevicePermissionStatusMap.location(snapshot.authorizationStatus)
}
@@ -225,17 +228,9 @@ final class DevicePermissionsModel {
}
}
case .calendar:
_ = await PermissionRequestBridge.awaitRequest { completion in
EKEventStore().requestFullAccessToEvents { granted, _ in
completion(granted)
}
}
_ = await self.eventKitPermissions.requestFullAccessToEvents()
case .reminders:
_ = await PermissionRequestBridge.awaitRequest { completion in
EKEventStore().requestFullAccessToReminders { granted, _ in
completion(granted)
}
}
_ = await self.eventKitPermissions.requestFullAccessToReminders()
case .location:
_ = await self.locationService.ensureAuthorization(mode: .whileUsing)
}
@@ -0,0 +1,51 @@
import EventKit
protocol EventKitPermissionStore: AnyObject {
func requestWriteOnlyAccessToEvents(
completion: @escaping @Sendable (Bool, (any Error)?) -> Void)
func requestFullAccessToEvents(
completion: @escaping @Sendable (Bool, (any Error)?) -> Void)
func requestFullAccessToReminders(
completion: @escaping @Sendable (Bool, (any Error)?) -> Void)
}
extension EKEventStore: EventKitPermissionStore {}
/// Keeps one event store alive while Calendar or Reminders permission sheets are active.
///
/// EventKit recommends retaining a long-lived store. A temporary store can disappear
/// before its asynchronous authorization callback completes, leaving the UI waiting
/// even though iOS persisted the user's choice.
/// `PermissionRequestBridge` starts every store request on the main actor; the unchecked
/// conformance only allows that private store owner to cross the bridge's sendable closure.
final class EventKitPermissionRequester: @unchecked Sendable {
private let store: any EventKitPermissionStore
init(store: any EventKitPermissionStore = EKEventStore()) {
self.store = store
}
func requestWriteOnlyAccessToEvents() async -> Bool {
await PermissionRequestBridge.awaitRequest { [self] completion in
self.store.requestWriteOnlyAccessToEvents { granted, _ in
completion(granted)
}
}
}
func requestFullAccessToEvents() async -> Bool {
await PermissionRequestBridge.awaitRequest { [self] completion in
self.store.requestFullAccessToEvents { granted, _ in
completion(granted)
}
}
}
func requestFullAccessToReminders() async -> Bool {
await PermissionRequestBridge.awaitRequest { [self] completion in
self.store.requestFullAccessToReminders { granted, _ in
completion(granted)
}
}
}
}
@@ -34,6 +34,7 @@ struct PrivacyAccessSectionView: View {
@State private var remindersStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder)
@State private var photosStatus = PhotoLibraryAccess.authorizationStatus()
@State private var requestingIdentifiers: Set<String> = []
@State private var eventKitPermissions = EventKitPermissionRequester()
@Environment(\.scenePhase) private var scenePhase
@@ -255,30 +256,15 @@ struct PrivacyAccessSectionView: View {
}
private func requestCalendarWriteOnly() async -> Bool {
await PermissionRequestBridge.awaitRequest { completion in
let store = EKEventStore()
store.requestWriteOnlyAccessToEvents { granted, _ in
completion(granted)
}
}
await self.eventKitPermissions.requestWriteOnlyAccessToEvents()
}
private func requestCalendarFull() async -> Bool {
await PermissionRequestBridge.awaitRequest { completion in
let store = EKEventStore()
store.requestFullAccessToEvents { granted, _ in
completion(granted)
}
}
await self.eventKitPermissions.requestFullAccessToEvents()
}
private func requestRemindersFull() async -> Bool {
await PermissionRequestBridge.awaitRequest { completion in
let store = EKEventStore()
store.requestFullAccessToReminders { granted, _ in
completion(granted)
}
}
await self.eventKitPermissions.requestFullAccessToReminders()
}
private func openSettings() {
@@ -0,0 +1,66 @@
import EventKit
@testable import OpenClaw
import Testing
@Suite(.serialized) struct EventKitPermissionRequesterTests {
@Test func `requester retains its event store`() throws {
var store: TestEventKitPermissionStore? = TestEventKitPermissionStore()
let weakStore = WeakEventKitPermissionStore(store)
let requester = EventKitPermissionRequester(store: try #require(store))
store = nil
withExtendedLifetime(requester) {
#expect(weakStore.value != nil)
}
}
@Test func `requester routes every EventKit grant through its retained store`() async {
let store = TestEventKitPermissionStore()
let requester = EventKitPermissionRequester(store: store)
#expect(await requester.requestWriteOnlyAccessToEvents())
#expect(await requester.requestFullAccessToEvents())
#expect(await requester.requestFullAccessToReminders())
#expect(store.requests == [.writeOnlyEvents, .fullEvents, .fullReminders])
}
}
private final class WeakEventKitPermissionStore {
weak var value: TestEventKitPermissionStore?
init(_ value: TestEventKitPermissionStore?) {
self.value = value
}
}
private final class TestEventKitPermissionStore: EventKitPermissionStore {
enum Request: Equatable {
case writeOnlyEvents
case fullEvents
case fullReminders
}
private(set) var requests: [Request] = []
func requestWriteOnlyAccessToEvents(
completion: @escaping @Sendable (Bool, (any Error)?) -> Void
) {
requests.append(.writeOnlyEvents)
completion(true, nil)
}
func requestFullAccessToEvents(
completion: @escaping @Sendable (Bool, (any Error)?) -> Void
) {
requests.append(.fullEvents)
completion(true, nil)
}
func requestFullAccessToReminders(
completion: @escaping @Sendable (Bool, (any Error)?) -> Void
) {
requests.append(.fullReminders)
completion(true, nil)
}
}