fix(ios): remove permission pre-prompts

This commit is contained in:
joshavant
2026-08-07 00:42:03 -05:00
committed by Josh Avant
parent 8c68f74ef9
commit 0bcbaf2eda
12 changed files with 139 additions and 528 deletions
+46 -206
View File
@@ -26113,70 +26113,6 @@
"surface": "apple",
"id": "native.apple.1a8232361f3d72fb"
},
{
"kind": "ui-named-argument",
"line": 14,
"path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift",
"source": "Allow access",
"surface": "apple",
"id": "native.apple.9dcd64ab340a6c15"
},
{
"kind": "ui-named-argument",
"line": 15,
"path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift",
"source": "Choose what your agent can use on this iPhone. Nothing is on until you allow it.",
"surface": "apple",
"id": "native.apple.933a2ac28d1d2aec"
},
{
"kind": "ui-call",
"line": 31,
"path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift",
"source": "You can change any of these later in Settings.",
"surface": "apple",
"id": "native.apple.47114a39f7a71c3b"
},
{
"kind": "ui-call",
"line": 43,
"path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift",
"source": "Continue",
"surface": "apple",
"id": "native.apple.eabf54e2b640a4a1"
},
{
"kind": "ui-localized-call",
"line": 70,
"path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift",
"source": "Limited",
"surface": "apple",
"id": "native.apple.5f425282ece878eb"
},
{
"kind": "ui-localized-call",
"line": 78,
"path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift",
"source": "Allow",
"surface": "apple",
"id": "native.apple.c5db9e6eae115f7a"
},
{
"kind": "ui-localized-call",
"line": 80,
"path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift",
"source": "Open Settings",
"surface": "apple",
"id": "native.apple.f591e16105268e99"
},
{
"kind": "ui-localized-call",
"line": 82,
"path": "apps/ios/Sources/Onboarding/OnboardingPermissionsStep.swift",
"source": "Manage",
"surface": "apple",
"id": "native.apple.12ed416835c36555"
},
{
"kind": "conditional-branch",
"line": 11,
@@ -26595,7 +26531,7 @@
},
{
"kind": "ui-modifier",
"line": 193,
"line": 191,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "QR Scanner Unavailable",
"surface": "apple",
@@ -26603,7 +26539,7 @@
},
{
"kind": "ui-call",
"line": 199,
"line": 197,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "OK",
"surface": "apple",
@@ -26611,7 +26547,7 @@
},
{
"kind": "ui-call",
"line": 304,
"line": 302,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Scan Setup Code",
"surface": "apple",
@@ -26619,7 +26555,7 @@
},
{
"kind": "ui-call",
"line": 312,
"line": 310,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Cancel",
"surface": "apple",
@@ -26627,7 +26563,7 @@
},
{
"kind": "ui-call",
"line": 319,
"line": 317,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Photos",
"surface": "apple",
@@ -26635,7 +26571,7 @@
},
{
"kind": "ui-modifier",
"line": 360,
"line": 358,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Back",
"surface": "apple",
@@ -26643,7 +26579,7 @@
},
{
"kind": "ui-call",
"line": 368,
"line": 366,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Close",
"surface": "apple",
@@ -26651,7 +26587,7 @@
},
{
"kind": "ui-modifier",
"line": 394,
"line": 392,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Dismiss Keyboard",
"surface": "apple",
@@ -26659,7 +26595,7 @@
},
{
"kind": "ui-call",
"line": 449,
"line": 443,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Mode",
"surface": "apple",
@@ -26667,7 +26603,7 @@
},
{
"kind": "ui-call",
"line": 450,
"line": 444,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Discovery",
"surface": "apple",
@@ -26675,7 +26611,7 @@
},
{
"kind": "ui-call",
"line": 452,
"line": 446,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Status",
"surface": "apple",
@@ -26683,7 +26619,7 @@
},
{
"kind": "ui-call",
"line": 470,
"line": 464,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Choose a mode first.",
"surface": "apple",
@@ -26691,7 +26627,7 @@
},
{
"kind": "ui-call",
"line": 475,
"line": 469,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Back to Mode Selection",
"surface": "apple",
@@ -26699,7 +26635,7 @@
},
{
"kind": "ui-named-argument",
"line": 519,
"line": 513,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Manual Fallback",
"surface": "apple",
@@ -26707,7 +26643,7 @@
},
{
"kind": "ui-named-argument",
"line": 523,
"line": 517,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Domain Settings",
"surface": "apple",
@@ -26715,7 +26651,7 @@
},
{
"kind": "ui-call",
"line": 534,
"line": 528,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Developer Local",
"surface": "apple",
@@ -26723,7 +26659,7 @@
},
{
"kind": "ui-call",
"line": 537,
"line": 531,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.",
"surface": "apple",
@@ -26731,7 +26667,7 @@
},
{
"kind": "ui-call",
"line": 565,
"line": 559,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway rejected credentials. Scan a fresh setup code or update token/password.",
"surface": "apple",
@@ -26739,7 +26675,7 @@
},
{
"kind": "ui-call",
"line": 573,
"line": 567,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "OpenClaw is checking gateway and node access.",
"surface": "apple",
@@ -26747,7 +26683,7 @@
},
{
"kind": "ui-call",
"line": 587,
"line": 581,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Resume After Approval",
"surface": "apple",
@@ -26755,7 +26691,7 @@
},
{
"kind": "ui-call",
"line": 593,
"line": 587,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Pairing Approval",
"surface": "apple",
@@ -26763,7 +26699,7 @@
},
{
"kind": "ui-localized-call",
"line": 599,
"line": 593,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Request ID: %@",
"surface": "apple",
@@ -26771,7 +26707,7 @@
},
{
"kind": "ui-localized-call",
"line": 602,
"line": 596,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Request ID: check `openclaw devices list`.",
"surface": "apple",
@@ -26779,7 +26715,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 606,
"line": 600,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Approve this device on the gateway.\n1) `%1$@`\n2) `/pair approve` in your OpenClaw chat\n%2$@\nOpenClaw will also retry automatically when you return to this app.",
"surface": "apple",
@@ -26787,7 +26723,7 @@
},
{
"kind": "ui-call",
"line": 623,
"line": 617,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Scan Setup Code Again",
"surface": "apple",
@@ -26795,7 +26731,7 @@
},
{
"kind": "ui-call",
"line": 636,
"line": 630,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Retry Connection",
"surface": "apple",
@@ -26803,7 +26739,7 @@
},
{
"kind": "ui-call",
"line": 667,
"line": 661,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Enter setup code",
"surface": "apple",
@@ -26811,7 +26747,7 @@
},
{
"kind": "ui-call",
"line": 683,
"line": 677,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Apply",
"surface": "apple",
@@ -26819,7 +26755,7 @@
},
{
"kind": "ui-call",
"line": 701,
"line": 695,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Setup Code",
"surface": "apple",
@@ -26827,7 +26763,7 @@
},
{
"kind": "ui-call",
"line": 704,
"line": 698,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Use this if you have a setup code instead of scanning.",
"surface": "apple",
@@ -26835,7 +26771,7 @@
},
{
"kind": "ui-call",
"line": 716,
"line": 710,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Host",
"surface": "apple",
@@ -26843,7 +26779,7 @@
},
{
"kind": "ui-call",
"line": 717,
"line": 711,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Port",
"surface": "apple",
@@ -26851,7 +26787,7 @@
},
{
"kind": "ui-call",
"line": 720,
"line": 714,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Discovery Domain (optional)",
"surface": "apple",
@@ -26859,7 +26795,7 @@
},
{
"kind": "ui-call",
"line": 725,
"line": 719,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway Auth Token",
"surface": "apple",
@@ -26867,7 +26803,7 @@
},
{
"kind": "ui-call",
"line": 729,
"line": 723,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Gateway Password",
"surface": "apple",
@@ -26875,7 +26811,7 @@
},
{
"kind": "ui-call",
"line": 759,
"line": 753,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Unencrypted",
"surface": "apple",
@@ -26883,7 +26819,7 @@
},
{
"kind": "ui-call",
"line": 762,
"line": 756,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Secure (TLS)",
"surface": "apple",
@@ -26891,7 +26827,7 @@
},
{
"kind": "ui-call",
"line": 766,
"line": 760,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Connection security",
"surface": "apple",
@@ -26899,7 +26835,7 @@
},
{
"kind": "ui-call",
"line": 836,
"line": 830,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Connecting…",
"surface": "apple",
@@ -26907,7 +26843,7 @@
},
{
"kind": "ui-call",
"line": 840,
"line": 834,
"path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift",
"source": "Connect",
"surface": "apple",
@@ -26947,31 +26883,7 @@
},
{
"kind": "ui-localized-call",
"line": 60,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Notifications",
"surface": "apple",
"id": "native.apple.96d65a5563690719"
},
{
"kind": "ui-localized-call",
"line": 61,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Camera",
"surface": "apple",
"id": "native.apple.d290a46515add091"
},
{
"kind": "ui-localized-call",
"line": 62,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Microphone",
"surface": "apple",
"id": "native.apple.0eeedbc8aaafcd2c"
},
{
"kind": "ui-localized-call",
"line": 63,
"line": 41,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Photos",
"surface": "apple",
@@ -26979,7 +26891,7 @@
},
{
"kind": "ui-localized-call",
"line": 64,
"line": 42,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Contacts",
"surface": "apple",
@@ -26987,7 +26899,7 @@
},
{
"kind": "ui-localized-call",
"line": 65,
"line": 43,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Calendar",
"surface": "apple",
@@ -26995,84 +26907,12 @@
},
{
"kind": "ui-localized-call",
"line": 66,
"line": 44,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Reminders",
"surface": "apple",
"id": "native.apple.9ed5c19cf1e84490"
},
{
"kind": "ui-localized-call",
"line": 67,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Location",
"surface": "apple",
"id": "native.apple.866b9df8afcadf07"
},
{
"kind": "ui-localized-call",
"line": 73,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Pairing approvals and replies from your agent.",
"surface": "apple",
"id": "native.apple.f205ac6778705a7d"
},
{
"kind": "ui-localized-call",
"line": 74,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Scan setup codes and take photos on request.",
"surface": "apple",
"id": "native.apple.061a17077ee3be94"
},
{
"kind": "ui-localized-call",
"line": 75,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Talk with your agent using voice.",
"surface": "apple",
"id": "native.apple.c735ac6b282765f9"
},
{
"kind": "ui-localized-call",
"line": 76,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Share recent photos when you ask.",
"surface": "apple",
"id": "native.apple.ebe8aadcf512ecdb"
},
{
"kind": "ui-localized-call",
"line": 77,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Look up and add people you mention.",
"surface": "apple",
"id": "native.apple.4c4bc12c0787383c"
},
{
"kind": "ui-localized-call",
"line": 78,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Check and add calendar events.",
"surface": "apple",
"id": "native.apple.5a4006fbb9b23db1"
},
{
"kind": "ui-localized-call",
"line": 79,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "List, add, and complete reminders.",
"surface": "apple",
"id": "native.apple.2b2a4f1aab6a12ec"
},
{
"kind": "ui-localized-call",
"line": 80,
"path": "apps/ios/Sources/Permissions/DevicePermissions.swift",
"source": "Answer location and nearby questions.",
"surface": "apple",
"id": "native.apple.3854ae1f0606bafe"
},
{
"kind": "conditional-branch",
"line": 490,
@@ -27685,9 +27525,9 @@
"kind": "ui-localized-call",
"line": 169,
"path": "apps/ios/Sources/Settings/PrivacyAccessSectionView.swift",
"source": "Allow",
"source": "Continue",
"surface": "apple",
"id": "native.apple.0b10201d7e452dc4"
"id": "native.apple.856d1960275796ef"
},
{
"kind": "ui-localized-call",
+19 -5
View File
@@ -169,6 +169,12 @@ The push wake and node status demo completed.
## Device Permissions
OpenClaw does not present an aggregate permissions page during onboarding.
Optional access is requested when the reviewer invokes the related feature or
taps `Continue` for a permission in Settings. `Continue` immediately presents
the native iOS authorization prompt, with no additional OpenClaw dialog or
deferral action.
1. Tap `Settings`.
2. Tap `Permissions`.
3. Confirm these current app controls are available:
@@ -177,11 +183,19 @@ The push wake and node status demo completed.
- `Keep Awake`
4. Confirm the always-visible `Apple Health` section identifies the integration
as `Apple Health Summaries`.
5. Expand `Privacy & Access` and confirm these request controls are available:
- `Contacts` / `Request Access`
- `Calendar (Add Events)` / `Request Access`
- `Calendar (View Events)` / `Request Full Access`
- `Reminders` / `Request Access`
5. Expand `Privacy & Access` and confirm these controls show `Continue` when
access has not previously been requested:
- `Contacts`
- `Photos`
- `Calendar (Add Events)`
- `Calendar (View Events)`
- `Reminders`
6. Tap `Continue` for any permission that has not been requested. Confirm the
corresponding native iOS authorization prompt appears immediately, then
make the permission choice in that system prompt.
If the review device already has a decision for a permission, the row shows
its current status or a management action instead of `Continue`.
### Optional Apple Health summary
@@ -1,103 +0,0 @@
import SwiftUI
/// First-run permissions page: one tap per grant, live status, always skippable.
/// System prompts fire only from the row actions; product opt-ins stay in Settings.
struct OnboardingPermissionsStep: View {
@Environment(\.scenePhase) private var scenePhase
@State private var permissions = DevicePermissionsModel()
let onContinue: () -> Void
var body: some View {
OnboardingActivationCanvas {
VStack(alignment: .leading, spacing: 0) {
OnboardingHeroHeader(
title: "Allow access",
subtitle: "Choose what your agent can use on this iPhone. Nothing is on until you allow it.")
.padding(.top, 18)
OnboardingIntroPanel {
VStack(alignment: .leading, spacing: 2) {
ForEach(DevicePermissionKind.allCases) { kind in
self.row(for: kind)
if kind != DevicePermissionKind.allCases.last {
Divider()
.padding(.leading, 48)
}
}
}
}
.padding(.top, 30)
Text("You can change any of these later in Settings.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.padding(.top, 14)
Spacer(minLength: 32)
Button {
self.onContinue()
} label: {
Text("Continue")
.font(OpenClawType.subheadSemiBold)
}
.buttonStyle(OpenClawPrimaryActionButtonStyle())
.padding(.top, 22)
}
}
.task {
await self.permissions.refresh()
}
.onChange(of: self.scenePhase) { _, phase in
guard phase == .active else { return }
Task { await self.permissions.refresh() }
}
}
private func row(for kind: DevicePermissionKind) -> some View {
let grant = self.permissions.grant(for: kind)
return DevicePermissionRow(
identifierPrefix: "onboarding-permission",
identifier: kind.rawValue,
symbol: kind.symbol,
tint: kind.tint,
title: kind.title,
detail: kind.detail,
grant: grant,
isRequesting: self.permissions.requesting.contains(kind),
statusLabel: grant == .limited ? LocalizedStringResource("Limited") : nil,
actionTitle: Self.actionTitle(for: grant),
action: self.action(for: kind, grant: grant))
}
private static func actionTitle(for grant: DevicePermissionGrant) -> LocalizedStringResource? {
switch grant {
case .notRequested:
LocalizedStringResource("Allow")
case .denied:
LocalizedStringResource("Open Settings")
case .limited:
LocalizedStringResource("Manage")
case .granted:
nil
}
}
private func action(for kind: DevicePermissionKind, grant: DevicePermissionGrant) -> (() -> Void)? {
switch grant {
case .notRequested:
{ Task { await self.permissions.request(kind) } }
case .denied, .limited:
{ self.openSystemSettings() }
case .granted:
nil
}
}
private func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
@@ -3,7 +3,6 @@ import SwiftUI
enum OnboardingStep: Int, CaseIterable {
case intro
case permissions
case welcome
case mode
case connect
@@ -24,7 +23,6 @@ enum OnboardingStep: Int, CaseIterable {
var title: LocalizedStringKey {
switch self {
case .intro: "Welcome"
case .permissions: "Permissions"
case .welcome: "Connect Gateway"
case .mode: "Gateway Setup"
case .connect: "Gateway Details"
@@ -35,7 +33,7 @@ enum OnboardingStep: Int, CaseIterable {
var canGoBack: Bool {
switch self {
case .intro, .permissions, .welcome, .success:
case .intro, .welcome, .success:
false
case .mode, .connect, .auth:
true
@@ -74,7 +74,7 @@ struct OnboardingWizardView: View {
}
private var isFullScreenStep: Bool {
self.step == .intro || self.step == .permissions || self.step == .welcome || self.step == .success
self.step == .intro || self.step == .welcome || self.step == .success
}
private var currentProblem: GatewayConnectionProblem? {
@@ -139,8 +139,6 @@ struct OnboardingWizardView: View {
switch self.step {
case .intro:
self.introStep
case .permissions:
self.permissionsStep
case .welcome:
self.welcomeStep
case .success:
@@ -403,10 +401,6 @@ struct OnboardingWizardView: View {
OnboardingIntroStep(onContinue: self.advanceFromIntro)
}
private var permissionsStep: some View {
OnboardingPermissionsStep(onContinue: self.advanceFromPermissions)
}
private var welcomeStep: some View {
OnboardingWelcomeStep(
statusLine: self.statusLine,
@@ -1148,13 +1142,7 @@ extension OnboardingWizardView {
}
private func advanceFromIntro() {
self.statusLine = ""
self.navigate(to: .permissions)
}
private func advanceFromPermissions() {
// Marked here, not on the intro Continue: an interrupted first run must
// replay intro + permissions on relaunch instead of skipping them forever.
// An interrupted first run replays the intro until the user explicitly continues.
OnboardingStateStore.markFirstRunIntroSeen()
self.requestLocalNetworkAccess(reason: "onboarding_continue")
self.statusLine = ""
@@ -1162,9 +1150,8 @@ extension OnboardingWizardView {
}
private func requestLocalNetworkAccessIfPastIntro(reason: String) {
// The local-network prompt waits until pairing starts so it never stacks
// on top of the permission prompts users trigger on the permissions step.
guard self.step != .intro, self.step != .permissions else { return }
// Keep the first-run intro focused; request local-network access when pairing starts.
guard self.step != .intro else { return }
self.requestLocalNetworkAccess(reason: reason)
}
@@ -1,6 +1,6 @@
import SwiftUI
/// Capsule action used by permission rows: filled for the initial "Allow",
/// Capsule action used by permission rows: filled for the initial "Continue",
/// bordered for repair actions like "Open Settings" or "Upgrade".
struct DevicePermissionActionButtonStyle: ButtonStyle {
let prominent: Bool
@@ -38,7 +38,7 @@ struct DevicePermissionActionButtonStyle: ButtonStyle {
}
/// One device permission with an icon tile, explanation, and a single clear
/// affordance: Allow when unset, a green check when granted, a repair action otherwise.
/// affordance: Continue when unset, a green check when granted, a repair action otherwise.
struct DevicePermissionRow: View {
let identifierPrefix: String
let identifier: String
@@ -1,12 +1,9 @@
import AVFoundation
import Contacts
import CoreLocation
import EventKit
import Photos
import SwiftUI
import UserNotifications
/// Closed grant state for one device permission, shared by onboarding and Settings rows.
/// Closed grant state for one device permission shown in Settings.
enum DevicePermissionGrant: Equatable {
case granted
case limited
@@ -14,75 +11,42 @@ enum DevicePermissionGrant: Equatable {
case denied
}
/// Device permissions the connected agent can use from this iPhone.
enum DevicePermissionKind: String, CaseIterable, Identifiable {
case notifications
case camera
case microphone
/// Device permissions managed by the Privacy & Access Settings section.
enum DevicePermissionKind {
case photos
case contacts
case calendar
case reminders
case location
var id: String {
self.rawValue
}
var symbol: String {
switch self {
case .notifications: "bell.badge.fill"
case .camera: "camera.fill"
case .microphone: "mic.fill"
case .photos: "photo.on.rectangle"
case .contacts: "person.crop.circle.fill"
case .calendar: "calendar"
case .reminders: "checklist"
case .location: "location.fill"
}
}
var tint: Color {
switch self {
case .notifications: .red
case .camera: .indigo
case .microphone: .pink
case .photos: .orange
case .contacts: .blue
case .calendar: .teal
case .reminders: .green
case .location: .purple
}
}
var title: LocalizedStringResource {
switch self {
case .notifications: LocalizedStringResource("Notifications")
case .camera: LocalizedStringResource("Camera")
case .microphone: LocalizedStringResource("Microphone")
case .photos: LocalizedStringResource("Photos")
case .contacts: LocalizedStringResource("Contacts")
case .calendar: LocalizedStringResource("Calendar")
case .reminders: LocalizedStringResource("Reminders")
case .location: LocalizedStringResource("Location")
}
}
var detail: LocalizedStringResource {
switch self {
case .notifications: LocalizedStringResource("Pairing approvals and replies from your agent.")
case .camera: LocalizedStringResource("Scan setup codes and take photos on request.")
case .microphone: LocalizedStringResource("Talk with your agent using voice.")
case .photos: LocalizedStringResource("Share recent photos when you ask.")
case .contacts: LocalizedStringResource("Look up and add people you mention.")
case .calendar: LocalizedStringResource("Check and add calendar events.")
case .reminders: LocalizedStringResource("List, add, and complete reminders.")
case .location: LocalizedStringResource("Answer location and nearby questions.")
}
}
}
/// Pure statusgrant maps so onboarding and Settings agree on one vocabulary.
/// Pure statusgrant maps keep every Settings row on one vocabulary.
enum DevicePermissionStatusMap {
static func contacts(_ status: CNAuthorizationStatus) -> DevicePermissionGrant {
switch status {
@@ -124,117 +88,4 @@ enum DevicePermissionStatusMap {
@unknown default: .denied
}
}
static func capture(_ status: AVAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorized: .granted
case .notDetermined: .notRequested
case .denied, .restricted: .denied
@unknown default: .denied
}
}
static func microphone(_ permission: AVAudioApplication.recordPermission) -> DevicePermissionGrant {
switch permission {
case .granted: .granted
case .undetermined: .notRequested
case .denied: .denied
@unknown default: .denied
}
}
static func notifications(_ status: UNAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorized, .ephemeral, .provisional: .granted
case .notDetermined: .notRequested
case .denied: .denied
@unknown default: .denied
}
}
static func location(_ status: CLAuthorizationStatus) -> DevicePermissionGrant {
switch status {
case .authorizedAlways, .authorizedWhenInUse: .granted
case .notDetermined: .notRequested
case .denied, .restricted: .denied
@unknown default: .denied
}
}
}
/// Live grant state + request plumbing for the onboarding permissions step.
/// Requests only raise the system prompts; product opt-ins (push relay serving,
/// Health sharing) remain explicit choices in Settings.
@MainActor
@Observable
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(eventKitPermissions: EventKitPermissionRequester = EventKitPermissionRequester()) {
self.eventKitPermissions = eventKitPermissions
self.locationService.setAuthorizationChangeHandler { [weak self] snapshot in
self?.grants[.location] = DevicePermissionStatusMap.location(snapshot.authorizationStatus)
}
}
func grant(for kind: DevicePermissionKind) -> DevicePermissionGrant {
self.grants[kind] ?? .notRequested
}
func refresh() async {
var next: [DevicePermissionKind: DevicePermissionGrant] = [
.camera: DevicePermissionStatusMap.capture(AVCaptureDevice.authorizationStatus(for: .video)),
.microphone: DevicePermissionStatusMap.microphone(AVAudioApplication.shared.recordPermission),
.photos: DevicePermissionStatusMap.photos(PhotoLibraryAccess.authorizationStatus()),
.contacts: DevicePermissionStatusMap.contacts(CNContactStore.authorizationStatus(for: .contacts)),
.calendar: DevicePermissionStatusMap.eventKitRead(EKEventStore.authorizationStatus(for: .event)),
.reminders: DevicePermissionStatusMap.eventKitRead(EKEventStore.authorizationStatus(for: .reminder)),
.location: DevicePermissionStatusMap.location(self.locationService.authorizationStatus()),
]
let settings = await UNUserNotificationCenter.current().notificationSettings()
next[.notifications] = DevicePermissionStatusMap.notifications(settings.authorizationStatus)
self.grants = next
}
func request(_ kind: DevicePermissionKind) async {
guard self.grant(for: kind) == .notRequested, !self.requesting.contains(kind) else { return }
self.requesting.insert(kind)
defer { self.requesting.remove(kind) }
switch kind {
case .notifications:
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [
.alert,
.badge,
.sound,
])
case .camera:
_ = await PermissionRequestBridge.awaitRequest { completion in
AVCaptureDevice.requestAccess(for: .video, completionHandler: completion)
}
case .microphone:
_ = await TalkModeManager.requestMicrophonePermission()
case .photos:
_ = await PhotoLibraryAccess.requestReadWrite()
case .contacts:
_ = await PermissionRequestBridge.awaitRequest { completion in
CNContactStore().requestAccess(for: .contacts) { granted, _ in
completion(granted)
}
}
case .calendar:
_ = await self.eventKitPermissions.requestFullAccessToEvents()
case .reminders:
_ = await self.eventKitPermissions.requestFullAccessToReminders()
case .location:
_ = await self.locationService.ensureAuthorization(mode: .whileUsing)
}
await self.refresh()
}
}
@@ -166,7 +166,7 @@ struct PrivacyAccessSectionView: View {
{
switch grant {
case .notRequested:
LocalizedStringResource("Allow")
LocalizedStringResource("Continue")
case .denied:
LocalizedStringResource("Open Settings")
case .limited:
+52 -26
View File
@@ -1,9 +1,8 @@
import AVFoundation
import Contacts
import EventKit
import Foundation
import Photos
import Testing
import UserNotifications
@testable import OpenClaw
struct DevicePermissionsTests {
@@ -31,31 +30,58 @@ struct DevicePermissionsTests {
#expect(DevicePermissionStatusMap.eventKitRead(.denied) == .denied)
}
@Test func `capture microphone notification and location statuses map to shared grants`() {
#expect(DevicePermissionStatusMap.capture(.authorized) == .granted)
#expect(DevicePermissionStatusMap.capture(.notDetermined) == .notRequested)
#expect(DevicePermissionStatusMap.capture(.denied) == .denied)
#expect(DevicePermissionStatusMap.microphone(.granted) == .granted)
#expect(DevicePermissionStatusMap.microphone(.undetermined) == .notRequested)
#expect(DevicePermissionStatusMap.microphone(.denied) == .denied)
#expect(DevicePermissionStatusMap.notifications(.authorized) == .granted)
#expect(DevicePermissionStatusMap.notifications(.provisional) == .granted)
#expect(DevicePermissionStatusMap.notifications(.notDetermined) == .notRequested)
#expect(DevicePermissionStatusMap.notifications(.denied) == .denied)
#expect(DevicePermissionStatusMap.location(.authorizedWhenInUse) == .granted)
#expect(DevicePermissionStatusMap.location(.authorizedAlways) == .granted)
#expect(DevicePermissionStatusMap.location(.notDetermined) == .notRequested)
#expect(DevicePermissionStatusMap.location(.restricted) == .denied)
@Test func `first-run onboarding moves directly from intro to pairing`() {
#expect(OnboardingStep.intro.previous == nil)
#expect(OnboardingStep.welcome.previous == .intro)
#expect(!OnboardingStep.welcome.canGoBack)
#expect(OnboardingStep.welcome.manualProgressTitle.isEmpty)
}
@Test func `first-run onboarding inserts permissions between intro and pairing`() {
#expect(OnboardingStep.permissions.previous == .intro)
#expect(OnboardingStep.welcome.previous == .permissions)
#expect(!OnboardingStep.permissions.canGoBack)
// Permissions is a first-run page, not part of the manual connect progress trail.
#expect(OnboardingStep.permissions.manualProgressTitle.isEmpty)
@Test func `onboarding has no aggregate system permission prompt`() throws {
let onboardingDirectory = Self.sourceRoot()
.appending(path: "Onboarding", directoryHint: .isDirectory)
let wizard = try String(
contentsOf: onboardingDirectory.appending(path: "OnboardingWizardView.swift"),
encoding: .utf8)
#expect(!FileManager.default.fileExists(
atPath: onboardingDirectory.appending(path: "OnboardingPermissionsStep.swift").path))
#expect(!wizard.contains("OnboardingPermissionsStep"))
#expect(!wizard.contains("navigate(to: .permissions)"))
}
@Test func `settings first request says Continue and immediately requests permission`() throws {
let settings = try String(
contentsOf: Self.sourceRoot()
.appending(path: "Settings", directoryHint: .isDirectory)
.appending(path: "PrivacyAccessSectionView.swift"),
encoding: .utf8)
let actionTitles = try #require(Self.extract(
settings,
from: " private func standardActionTitle(",
to: " /// `limitedRequests`"))
let action = try #require(Self.extract(
settings,
from: " private func standardAction(",
to: " private func requestContacts()"))
#expect(actionTitles.contains("case .notRequested:\n LocalizedStringResource(\"Continue\")"))
#expect(!actionTitles.contains("LocalizedStringResource(\"Allow\")"))
#expect(action.contains("await request()"))
#expect(action.contains("case .notRequested:\n return run"))
}
private static func sourceRoot() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appending(path: "Sources", directoryHint: .isDirectory)
}
private static func extract(_ source: String, from start: String, to end: String) -> String? {
guard let startRange = source.range(of: start),
let endRange = source.range(of: end, range: startRange.upperBound..<source.endIndex)
else { return nil }
return String(source[startRange.lowerBound..<endRange.lowerBound])
}
}
+2 -2
View File
@@ -317,7 +317,8 @@ struct SwiftUIRenderSmokeTests {
#expect(kind == .image)
return OpenClawChatLoadedMedia.data(OpenClawChatMediaData(
data: Data(base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=")!,
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8A" +
"AusB9Y9Zl1sAAAAASUVORK5CYII=")!,
mimeType: "image/png"))
})
let window = Self.host(root, size: CGSize(width: 393, height: 420))
@@ -501,7 +502,6 @@ struct SwiftUIRenderSmokeTests {
@Test @MainActor func `onboarding activation screens build across appearance and type size`() {
let screens: [AnyView] = [
AnyView(OnboardingIntroStep(onContinue: {})),
AnyView(OnboardingPermissionsStep(onContinue: {})),
AnyView(OnboardingWelcomeStep(
statusLine: "",
isConnecting: false,
@@ -681,9 +681,8 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
XCTAssertTrue(app.staticTexts["Allow access"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
XCTAssertFalse(app.staticTexts["Allow access"].exists)
let copySetupCommand = app.buttons["Copy setup code command"]
XCTAssertTrue(copySetupCommand.waitForExistence(timeout: 8))
@@ -893,8 +892,6 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
XCTAssertTrue(app.staticTexts["Allow access"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
XCTAssertTrue(app.buttons["Connect Manually"].waitForExistence(timeout: 8))
app.buttons["Connect Manually"].tap()
@@ -954,6 +951,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
let request = try XCTUnwrap(self.app?.buttons["privacy-access-photos-action"])
XCTAssertTrue(request.waitForExistence(timeout: 5))
XCTAssertEqual(request.label, "Continue")
request.tap()
self.app?.tap()
@@ -1280,8 +1278,6 @@ extension OpenClawSnapshotUITests {
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
XCTAssertTrue(app.staticTexts["Allow access"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
XCTAssertTrue(app.buttons["Connect Manually"].waitForExistence(timeout: 8))
app.buttons["Connect Manually"].tap()
+6 -4
View File
@@ -33,10 +33,12 @@ Availability: iPhone app builds are distributed through Apple channels when enab
## Quick start (pair + connect)
On first launch the app walks through a short pairing explainer and a
permissions page (notifications, camera, microphone, photos, contacts,
calendar, reminders, location). Every grant is optional and can be changed
later in **Settings** -> **Permissions**, or in the iOS Settings app.
On first launch the app walks through a short pairing explainer, then Gateway
setup. It does not present an aggregate permissions page. Optional access is
requested when you use the related feature, or after you tap **Continue** for
that permission under **Settings** -> **Permissions** -> **Privacy & Access**.
**Continue** immediately presents the native iOS authorization prompt. You can
change granted access later in the iOS Settings app.
1. Start an authenticated Gateway with a route your phone can reach. Tailscale
Serve is the recommended remote path: