diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8864a4e69..59fc9f276b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- iOS: restore first-use Contacts, Calendar, and Reminders permission prompts and add Privacy & Access status/actions in Settings. Thanks @BunsDev. - CLI/migrate: humanize Codex conflict-status messaging across the migrate UI so selection prompts and plan/result rows say "Codex skill already installed in workspace" instead of surfacing internal `MIGRATION_REASON_*` codes. Thanks @sjf. - CLI/migrate: render migrate result rows with distinct glyphs for manual-review (πŸ”) and archive (πŸ“–) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf. - CLI/migrate: split Codex migrate output into separate preview and result phases so the Before plan and After result render through clack with independently tunable copy. Thanks @sjf. diff --git a/apps/ios/Sources/Calendar/CalendarService.swift b/apps/ios/Sources/Calendar/CalendarService.swift index 94b2d9ea3f5..bed56de4e8f 100644 --- a/apps/ios/Sources/Calendar/CalendarService.swift +++ b/apps/ios/Sources/Calendar/CalendarService.swift @@ -4,15 +4,19 @@ import OpenClawKit final class CalendarService: CalendarServicing { func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload { - let store = EKEventStore() let status = EKEventStore.authorizationStatus(for: .event) - let authorized = EventKitAuthorization.allowsRead(status: status) + let authorized: Bool = if status == .notDetermined || status == .writeOnly { + await Self.requestFullEventAccess() + } else { + EventKitAuthorization.allowsRead(status: status) + } guard authorized else { throw NSError(domain: "Calendar", code: 1, userInfo: [ NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", ]) } + let store = EKEventStore() let (start, end) = Self.resolveRange( startISO: params.startISO, endISO: params.endISO) @@ -37,15 +41,19 @@ final class CalendarService: CalendarServicing { } func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload { - let store = EKEventStore() let status = EKEventStore.authorizationStatus(for: .event) - let authorized = EventKitAuthorization.allowsWrite(status: status) + let authorized: Bool = if status == .notDetermined { + await Self.requestWriteOnlyEventAccess() + } else { + EventKitAuthorization.allowsWrite(status: status) + } guard authorized else { throw NSError(domain: "Calendar", code: 2, userInfo: [ NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission", ]) } + let store = EKEventStore() let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { throw NSError(domain: "Calendar", code: 3, userInfo: [ @@ -95,6 +103,24 @@ final class CalendarService: CalendarServicing { return OpenClawCalendarAddPayload(event: payload) } + private static func requestFullEventAccess() async -> Bool { + await PermissionRequestBridge.awaitRequest { completion in + let store = EKEventStore() + store.requestFullAccessToEvents { granted, _ in + completion(granted) + } + } + } + + private static func requestWriteOnlyEventAccess() async -> Bool { + await PermissionRequestBridge.awaitRequest { completion in + let store = EKEventStore() + store.requestWriteOnlyAccessToEvents { granted, _ in + completion(granted) + } + } + } + private static func resolveCalendar( store: EKEventStore, calendarId: String?, diff --git a/apps/ios/Sources/Contacts/ContactsService.swift b/apps/ios/Sources/Contacts/ContactsService.swift index c2881436a04..1a25948829a 100644 --- a/apps/ios/Sources/Contacts/ContactsService.swift +++ b/apps/ios/Sources/Contacts/ContactsService.swift @@ -97,14 +97,17 @@ final class ContactsService: ContactsServicing { return OpenClawContactsAddPayload(contact: Self.payload(from: persisted)) } - private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool { + private static func ensureAuthorization(status: CNAuthorizationStatus) async -> Bool { switch status { case .authorized, .limited: return true case .notDetermined: - // Don’t prompt during node.invoke; the caller should instruct the user to grant permission. - // Prompts block the invoke and lead to timeouts in headless flows. - return false + return await PermissionRequestBridge.awaitRequest { completion in + let store = CNContactStore() + store.requestAccess(for: .contacts) { granted, _ in + completion(granted) + } + } case .restricted, .denied: return false @unknown default: @@ -113,15 +116,14 @@ final class ContactsService: ContactsServicing { } private static func authorizedStore() async throws -> CNContactStore { - let store = CNContactStore() let status = CNContactStore.authorizationStatus(for: .contacts) - let authorized = await Self.ensureAuthorization(store: store, status: status) + let authorized = await Self.ensureAuthorization(status: status) guard authorized else { throw NSError(domain: "Contacts", code: 1, userInfo: [ NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission", ]) } - return store + return CNContactStore() } private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] { diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 94ef26d355c..70810de5619 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -52,6 +52,14 @@ NSCameraUsageDescription OpenClaw can capture photos or short video clips when requested via the gateway. + NSCalendarsUsageDescription + OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. + NSCalendarsFullAccessUsageDescription + OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. + NSCalendarsWriteOnlyAccessUsageDescription + OpenClaw uses your calendars to add events when you enable calendar access. + NSContactsUsageDescription + OpenClaw uses your contacts so you can search and reference people while using the assistant. NSLocalNetworkUsageDescription OpenClaw discovers and connects to your OpenClaw gateway on the local network. NSLocationAlwaysAndWhenInUseUsageDescription @@ -64,6 +72,8 @@ OpenClaw may use motion data to support device-aware interactions and automations. NSPhotoLibraryUsageDescription OpenClaw needs photo library access when you choose existing photos to share with your assistant. + NSRemindersFullAccessUsageDescription + OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access. NSSpeechRecognitionUsageDescription OpenClaw uses on-device speech recognition for voice wake. NSSupportsLiveActivities diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift index 3ae10c70417..c084b0e4de8 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -1,4 +1,5 @@ import Foundation +import OpenClawKit import SwiftUI struct GatewayOnboardingView: View { diff --git a/apps/ios/Sources/Permissions/PermissionRequestBridge.swift b/apps/ios/Sources/Permissions/PermissionRequestBridge.swift new file mode 100644 index 00000000000..927b5679f18 --- /dev/null +++ b/apps/ios/Sources/Permissions/PermissionRequestBridge.swift @@ -0,0 +1,64 @@ +import Foundation + +enum PermissionRequestBridge { + final class Box: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var hasResumed = false + + func install(_ continuation: CheckedContinuation) -> Bool { + self.lock.lock() + if self.hasResumed { + self.lock.unlock() + continuation.resume(returning: false) + return false + } + self.continuation = continuation + self.lock.unlock() + return true + } + + func resume(_ value: Bool) { + self.lock.lock() + guard !self.hasResumed else { + self.lock.unlock() + return + } + self.hasResumed = true + let continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: value) + } + + func canStartRequest() -> Bool { + self.lock.lock() + let canStart = !self.hasResumed + self.lock.unlock() + return canStart + } + } + + static func awaitRequest( + _ start: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool + { + let box = Box() + return await withTaskCancellationHandler { + await withCheckedContinuation(isolation: nil) { continuation in + guard !Task.isCancelled else { + continuation.resume(returning: false) + return + } + guard box.install(continuation) else { return } + Task { @MainActor in + guard box.canStartRequest() else { return } + start { granted in + box.resume(granted) + } + } + } + } onCancel: { + box.resume(false) + } + } +} diff --git a/apps/ios/Sources/Reminders/RemindersService.swift b/apps/ios/Sources/Reminders/RemindersService.swift index 19591908fb9..da6ff67f8bb 100644 --- a/apps/ios/Sources/Reminders/RemindersService.swift +++ b/apps/ios/Sources/Reminders/RemindersService.swift @@ -4,15 +4,19 @@ import OpenClawKit final class RemindersService: RemindersServicing { func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload { - let store = EKEventStore() let status = EKEventStore.authorizationStatus(for: .reminder) - let authorized = EventKitAuthorization.allowsRead(status: status) + let authorized: Bool = if status == .notDetermined || status == .writeOnly { + await Self.requestFullReminderAccess() + } else { + EventKitAuthorization.allowsRead(status: status) + } guard authorized else { throw NSError(domain: "Reminders", code: 1, userInfo: [ NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", ]) } + let store = EKEventStore() let limit = max(1, min(params.limit ?? 50, 500)) let statusFilter = params.status ?? .incomplete @@ -48,15 +52,19 @@ final class RemindersService: RemindersServicing { } func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload { - let store = EKEventStore() let status = EKEventStore.authorizationStatus(for: .reminder) - let authorized = EventKitAuthorization.allowsWrite(status: status) + let authorized: Bool = if status == .notDetermined { + await Self.requestFullReminderAccess() + } else { + EventKitAuthorization.allowsWrite(status: status) + } guard authorized else { throw NSError(domain: "Reminders", code: 2, userInfo: [ NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission", ]) } + let store = EKEventStore() let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { throw NSError(domain: "Reminders", code: 3, userInfo: [ @@ -100,6 +108,15 @@ final class RemindersService: RemindersServicing { return OpenClawRemindersAddPayload(reminder: payload) } + private static func requestFullReminderAccess() async -> Bool { + await PermissionRequestBridge.awaitRequest { completion in + let store = EKEventStore() + store.requestFullAccessToReminders { granted, _ in + completion(granted) + } + } + } + private static func resolveList( store: EKEventStore, listId: String?, diff --git a/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift new file mode 100644 index 00000000000..a69c02db7d6 --- /dev/null +++ b/apps/ios/Sources/Settings/PrivacyAccessSectionView.swift @@ -0,0 +1,298 @@ +import Contacts +import EventKit +import SwiftUI +import UIKit + +struct PrivacyAccessSectionView: View { + @State private var contactsStatus: CNAuthorizationStatus = CNContactStore.authorizationStatus(for: .contacts) + @State private var calendarStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event) + @State private var remindersStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder) + + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + DisclosureGroup("Privacy & Access") { + self.permissionRow( + title: "Contacts", + icon: "person.crop.circle", + status: self.statusText(for: self.contactsStatus), + detail: "Search and add contacts from the assistant.", + actionTitle: self.actionTitle(for: self.contactsStatus), + action: self.handleContactsAction) + + self.permissionRow( + title: "Calendar (Add Events)", + icon: "calendar.badge.plus", + status: self.calendarWriteStatusText, + detail: "Add events with least privilege.", + actionTitle: self.calendarWriteActionTitle, + action: self.handleCalendarWriteAction) + + self.permissionRow( + title: "Calendar (View Events)", + icon: "calendar", + status: self.calendarReadStatusText, + detail: "List and read calendar events.", + actionTitle: self.calendarReadActionTitle, + action: self.handleCalendarReadAction) + + self.permissionRow( + title: "Reminders", + icon: "checklist", + status: self.remindersStatusText, + detail: "List, add, and complete reminders.", + actionTitle: self.remindersActionTitle, + action: self.handleRemindersAction) + } + .onAppear { self.refreshAll() } + .onChange(of: self.scenePhase) { _, phase in + if phase == .active { + self.refreshAll() + } + } + } + + private func permissionRow( + title: String, + icon: String, + status: String, + detail: String, + actionTitle: String?, + action: (() -> Void)?) -> some View + { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label(title, systemImage: icon) + Spacer() + Text(status) + .font(.footnote.weight(.medium)) + .foregroundStyle(self.statusColor(for: status)) + } + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + if let actionTitle, let action { + Button(actionTitle, action: action) + .font(.footnote) + .buttonStyle(.bordered) + } + } + .padding(.vertical, 2) + } + + private func statusColor(for status: String) -> Color { + switch status { + case "Allowed": + .green + case "Not Set": + .orange + case "Add-Only": + .yellow + default: + .red + } + } + + private func statusText(for cnStatus: CNAuthorizationStatus) -> String { + switch cnStatus { + case .authorized, .limited: + "Allowed" + case .notDetermined: + "Not Set" + case .denied, .restricted: + "Not Allowed" + @unknown default: + "Unknown" + } + } + + private func actionTitle(for cnStatus: CNAuthorizationStatus) -> String? { + switch cnStatus { + case .notDetermined: + "Request Access" + case .denied, .restricted: + "Open Settings" + default: + nil + } + } + + private func handleContactsAction() { + switch self.contactsStatus { + case .notDetermined: + Task { + _ = await PermissionRequestBridge.awaitRequest { completion in + let store = CNContactStore() + store.requestAccess(for: .contacts) { granted, _ in + completion(granted) + } + } + await MainActor.run { self.refreshAll() } + } + case .denied, .restricted: + self.openSettings() + default: + break + } + } + + private var calendarWriteStatusText: String { + switch self.calendarStatus { + case .authorized, .fullAccess, .writeOnly: + "Allowed" + case .notDetermined: + "Not Set" + case .denied, .restricted: + "Not Allowed" + @unknown default: + "Unknown" + } + } + + private var calendarWriteActionTitle: String? { + switch self.calendarStatus { + case .notDetermined: + "Request Access" + case .denied, .restricted: + "Open Settings" + default: + nil + } + } + + private func handleCalendarWriteAction() { + switch self.calendarStatus { + case .notDetermined: + Task { + _ = await self.requestCalendarWriteOnly() + await MainActor.run { self.refreshAll() } + } + case .denied, .restricted: + self.openSettings() + default: + break + } + } + + private var calendarReadStatusText: String { + switch self.calendarStatus { + case .authorized, .fullAccess: + "Allowed" + case .writeOnly: + "Add-Only" + case .notDetermined: + "Not Set" + case .denied, .restricted: + "Not Allowed" + @unknown default: + "Unknown" + } + } + + private var calendarReadActionTitle: String? { + switch self.calendarStatus { + case .notDetermined: + "Request Full Access" + case .writeOnly: + "Upgrade to Full Access" + case .denied, .restricted: + "Open Settings" + default: + nil + } + } + + private func handleCalendarReadAction() { + switch self.calendarStatus { + case .notDetermined, .writeOnly: + Task { + _ = await self.requestCalendarFull() + await MainActor.run { self.refreshAll() } + } + case .denied, .restricted: + self.openSettings() + default: + break + } + } + + private var remindersStatusText: String { + switch self.remindersStatus { + case .authorized, .fullAccess: + "Allowed" + case .writeOnly: + "Add-Only" + case .notDetermined: + "Not Set" + case .denied, .restricted: + "Not Allowed" + @unknown default: + "Unknown" + } + } + + private var remindersActionTitle: String? { + switch self.remindersStatus { + case .notDetermined: + "Request Access" + case .writeOnly: + "Upgrade to Full Access" + case .denied, .restricted: + "Open Settings" + default: + nil + } + } + + private func handleRemindersAction() { + switch self.remindersStatus { + case .notDetermined, .writeOnly: + Task { + _ = await self.requestRemindersFull() + await MainActor.run { self.refreshAll() } + } + case .denied, .restricted: + self.openSettings() + default: + break + } + } + + private func refreshAll() { + self.contactsStatus = CNContactStore.authorizationStatus(for: .contacts) + self.calendarStatus = EKEventStore.authorizationStatus(for: .event) + self.remindersStatus = EKEventStore.authorizationStatus(for: .reminder) + } + + private func requestCalendarWriteOnly() async -> Bool { + await PermissionRequestBridge.awaitRequest { completion in + let store = EKEventStore() + store.requestWriteOnlyAccessToEvents { granted, _ in + completion(granted) + } + } + } + + private func requestCalendarFull() async -> Bool { + await PermissionRequestBridge.awaitRequest { completion in + let store = EKEventStore() + store.requestFullAccessToEvents { granted, _ in + completion(granted) + } + } + } + + private func requestRemindersFull() async -> Bool { + await PermissionRequestBridge.awaitRequest { completion in + let store = EKEventStore() + store.requestFullAccessToReminders { granted, _ in + completion(granted) + } + } + } + + private func openSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index c9a08b00ac2..5524c81e124 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -405,6 +405,8 @@ struct SettingsTab: View { } } + AnyView(PrivacyAccessSectionView()) + DisclosureGroup("Device Info") { TextField("Name", text: self.$displayName) Text(self.instanceId) @@ -419,16 +421,7 @@ struct SettingsTab: View { } } .navigationTitle("Settings") - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - self.dismiss() - } label: { - Image(systemName: "xmark") - } - .accessibilityLabel("Close") - } - } + .modifier(SettingsCloseToolbar()) .sheet(isPresented: self.$showGatewayProblemDetails) { if let gatewayProblem = self.appModel.lastGatewayProblem { GatewayProblemDetailsSheet( @@ -488,90 +481,91 @@ struct SettingsTab: View { Text(self.scannerError ?? "") } .onAppear { - self.lastLocationModeRaw = self.locationEnabledModeRaw - self.syncManualPortText() - let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedInstanceId.isEmpty { - self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" - self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" + self.lastLocationModeRaw = self.locationEnabledModeRaw + self.syncManualPortText() + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" + self.gatewayPassword = GatewaySettingsStore + .loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" + } + self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction() + self.appModel.refreshLastShareEventFromRelay() + // Keep setup front-and-center when disconnected; keep things compact once connected. + self.gatewayExpanded = !self.isGatewayConnected + self.selectedAgentPickerId = self.appModel.selectedAgentId ?? "" + if self.isGatewayConnected { + self.appModel.reloadTalkConfig() + } } - self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction() - self.appModel.refreshLastShareEventFromRelay() - // Keep setup front-and-center when disconnected; keep things compact once connected. - self.gatewayExpanded = !self.isGatewayConnected - self.selectedAgentPickerId = self.appModel.selectedAgentId ?? "" - if self.isGatewayConnected { - self.appModel.reloadTalkConfig() + .onChange(of: self.selectedAgentPickerId) { _, newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed) } - } - .onChange(of: self.selectedAgentPickerId) { _, newValue in - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed) - } - .onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in - if newValue != self.selectedAgentPickerId { - self.selectedAgentPickerId = newValue + .onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in + if newValue != self.selectedAgentPickerId { + self.selectedAgentPickerId = newValue + } } - } - .onChange(of: self.preferredGatewayStableID) { _, newValue in - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - GatewaySettingsStore.savePreferredGatewayStableID(trimmed) - } - .onChange(of: self.gatewayToken) { _, newValue in - guard !self.suppressCredentialPersist else { return } - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !instanceId.isEmpty else { return } - GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId) - } - .onChange(of: self.gatewayPassword) { _, newValue in - guard !self.suppressCredentialPersist else { return } - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !instanceId.isEmpty else { return } - GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId) - } - .onChange(of: self.defaultShareInstruction) { _, newValue in - ShareToAgentSettings.saveDefaultInstruction(newValue) - } - .onChange(of: self.manualGatewayPort) { _, _ in - self.syncManualPortText() - } - .onChange(of: self.appModel.gatewayServerName) { _, newValue in - if newValue != nil { - self.setupCode = "" - self.setupStatusText = nil - return + .onChange(of: self.preferredGatewayStableID) { _, newValue in + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + GatewaySettingsStore.savePreferredGatewayStableID(trimmed) } - if self.manualGatewayEnabled { - self.setupStatusText = self.appModel.gatewayStatusText + .onChange(of: self.gatewayToken) { _, newValue in + guard !self.suppressCredentialPersist else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !instanceId.isEmpty else { return } + GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId) } - } - .onChange(of: self.appModel.gatewayStatusText) { _, newValue in - guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return } - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - self.setupStatusText = trimmed - } - .onChange(of: self.locationEnabledModeRaw) { _, newValue in - let previous = self.lastLocationModeRaw - self.lastLocationModeRaw = newValue - guard let mode = OpenClawLocationMode(rawValue: newValue) else { return } - Task { - let granted = await self.appModel.requestLocationPermissions(mode: mode) - if !granted { - await MainActor.run { - self.locationEnabledModeRaw = previous - self.lastLocationModeRaw = previous - } + .onChange(of: self.gatewayPassword) { _, newValue in + guard !self.suppressCredentialPersist else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !instanceId.isEmpty else { return } + GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId) + } + .onChange(of: self.defaultShareInstruction) { _, newValue in + ShareToAgentSettings.saveDefaultInstruction(newValue) + } + .onChange(of: self.manualGatewayPort) { _, _ in + self.syncManualPortText() + } + .onChange(of: self.appModel.gatewayServerName) { _, newValue in + if newValue != nil { + self.setupCode = "" + self.setupStatusText = nil return } - await MainActor.run { - self.gatewayController.refreshActiveGatewayRegistrationFromSettings() + if self.manualGatewayEnabled { + self.setupStatusText = self.appModel.gatewayStatusText + } + } + .onChange(of: self.appModel.gatewayStatusText) { _, newValue in + guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return } + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.setupStatusText = trimmed + } + .onChange(of: self.locationEnabledModeRaw) { _, newValue in + let previous = self.lastLocationModeRaw + self.lastLocationModeRaw = newValue + guard let mode = OpenClawLocationMode(rawValue: newValue) else { return } + Task { + let granted = await self.appModel.requestLocationPermissions(mode: mode) + if !granted { + await MainActor.run { + self.locationEnabledModeRaw = previous + self.lastLocationModeRaw = previous + } + return + } + await MainActor.run { + self.gatewayController.refreshActiveGatewayRegistrationFromSettings() + } } } - } } .gatewayTrustPromptAlert() } @@ -1138,4 +1132,21 @@ struct SettingsTab: View { } } +private struct SettingsCloseToolbar: ViewModifier { + @Environment(\.dismiss) private var dismiss + + func body(content: Content) -> some View { + content.toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + self.dismiss() + } label: { + Image(systemName: "xmark") + } + .accessibilityLabel("Close") + } + } + } +} + // swiftlint:enable type_body_length diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 99eda1c355e..9cfb07dc6d5 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -40,6 +40,7 @@ Sources/Onboarding/OnboardingStateStore.swift Sources/Onboarding/OnboardingWizardView.swift Sources/Onboarding/QRScannerView.swift Sources/OpenClawApp.swift +Sources/Permissions/PermissionRequestBridge.swift Sources/Push/ExecApprovalNotificationBridge.swift Sources/Push/BackgroundAliveBeacon.swift Sources/Push/PushBuildConfig.swift @@ -60,6 +61,7 @@ Sources/Services/WatchConnectivityTransport.swift Sources/Services/WatchMessagingPayloadCodec.swift Sources/Services/WatchMessagingService.swift Sources/SessionKey.swift +Sources/Settings/PrivacyAccessSectionView.swift Sources/Settings/SettingsNetworkingHelpers.swift Sources/Settings/SettingsTab.swift Sources/Settings/VoiceWakeWordsSettingsView.swift diff --git a/apps/ios/Tests/PermissionRequestBridgeTests.swift b/apps/ios/Tests/PermissionRequestBridgeTests.swift new file mode 100644 index 00000000000..8447d01068d --- /dev/null +++ b/apps/ios/Tests/PermissionRequestBridgeTests.swift @@ -0,0 +1,26 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct PermissionRequestBridgeTests { + @Test func `box resumes immediately when cancelled before install`() async { + let box = PermissionRequestBridge.Box() + box.resume(false) + let granted: Bool = await withCheckedContinuation { continuation in + _ = box.install(continuation) + } + #expect(granted == false) + #expect(box.canStartRequest() == false) + } + + @Test func `box resumes installed continuation once`() async { + let box = PermissionRequestBridge.Box() + + let granted: Bool = await withCheckedContinuation { continuation in + _ = box.install(continuation) + box.resume(true) + box.resume(false) + } + + #expect(granted == true) + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index cd219783101..3fbdac496b6 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -136,11 +136,16 @@ targets: NSBonjourServices: - _openclaw-gw._tcp NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway. + NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. + NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access. + NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access. + NSContactsUsageDescription: OpenClaw uses your contacts so you can search and reference people while using the assistant. NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing. NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always. NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake. NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations. NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant. + NSRemindersFullAccessUsageDescription: OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access. NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake. NSSupportsLiveActivities: true ITSAppUsesNonExemptEncryption: false diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 5bf818220f7..8e2dbddd5da 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -57,7 +57,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { { return link } - return fromGatewayURLString( + return self.fromGatewayURLString( trimmed, bootstrapToken: nil, token: nil, @@ -89,7 +89,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { { return link } - for candidate in setupCodeCandidates(in: trimmed) where candidate != trimmed { + for candidate in self.setupCodeCandidates(in: trimmed) where candidate != trimmed { if let data = decodeBase64Url(candidate), let link = decodeSetupPayload(from: data) { @@ -104,7 +104,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { if let urlString = payload.url?.trimmingCharacters(in: .whitespacesAndNewlines), !urlString.isEmpty { - return fromGatewayURLString( + return self.fromGatewayURLString( urlString, bootstrapToken: payload.bootstrapToken, token: payload.token,