import ClawdbotIPC import ClawdbotKit import CoreLocation import SwiftUI struct PermissionsSettings: View { let status: [Capability: Bool] let refresh: () async -> Void let showOnboarding: () -> Void var body: some View { VStack(alignment: .leading, spacing: 14) { SystemRunSettingsView() Text("Allow these so Clawdbot can notify and capture when needed.") .padding(.top, 4) PermissionStatusList(status: self.status, refresh: self.refresh) .padding(.horizontal, 2) .padding(.vertical, 6) LocationAccessSettings() Button("Restart onboarding") { self.showOnboarding() } .buttonStyle(.bordered) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) } } private struct LocationAccessSettings: View { @AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue @AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true @State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue var body: some View { VStack(alignment: .leading, spacing: 6) { Text("Location Access") .font(.body) Picker("", selection: self.$locationModeRaw) { Text("Off").tag(ClawdbotLocationMode.off.rawValue) Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue) Text("Always").tag(ClawdbotLocationMode.always.rawValue) } .labelsHidden() .pickerStyle(.menu) Toggle("Precise Location", isOn: self.$locationPreciseEnabled) .disabled(self.locationMode == .off) Text("Always may require System Settings to approve background location.") .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) } .onAppear { self.lastLocationModeRaw = self.locationModeRaw } .onChange(of: self.locationModeRaw) { _, newValue in let previous = self.lastLocationModeRaw self.lastLocationModeRaw = newValue guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return } Task { let granted = await self.requestLocationAuthorization(mode: mode) if !granted { await MainActor.run { self.locationModeRaw = previous self.lastLocationModeRaw = previous } } } } } private var locationMode: ClawdbotLocationMode { ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off } private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool { guard mode != .off else { return true } guard CLLocationManager.locationServicesEnabled() else { await MainActor.run { LocationPermissionHelper.openSettings() } return false } let status = CLLocationManager().authorizationStatus let requireAlways = mode == .always if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) { return true } let updated = await LocationPermissionRequester.shared.request(always: requireAlways) return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways) } } struct PermissionStatusList: View { let status: [Capability: Bool] let refresh: () async -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { ForEach(Capability.allCases, id: \.self) { cap in PermissionRow(capability: cap, status: self.status[cap] ?? false) { Task { await self.handle(cap) } } } Button { Task { await self.refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) .controlSize(.small) .font(.footnote) .padding(.top, 2) .help("Refresh status") } } @MainActor private func handle(_ cap: Capability) async { _ = await PermissionManager.ensure([cap], interactive: true) await self.refresh() } } struct PermissionRow: View { let capability: Capability let status: Bool let compact: Bool let action: () -> Void init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) { self.capability = capability self.status = status self.compact = compact self.action = action } var body: some View { HStack(spacing: self.compact ? 10 : 12) { ZStack { Circle().fill(self.status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15)) .frame(width: self.iconSize, height: self.iconSize) Image(systemName: self.icon) .foregroundStyle(self.status ? Color.green : Color.secondary) } VStack(alignment: .leading, spacing: 2) { Text(self.title).font(.body.weight(.semibold)) Text(self.subtitle).font(.caption).foregroundStyle(.secondary) } Spacer() if self.status { Label("Granted", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } else { Button("Grant") { self.action() } .buttonStyle(.bordered) } } .padding(.vertical, self.compact ? 4 : 6) } private var iconSize: CGFloat { self.compact ? 28 : 32 } private var title: String { switch self.capability { case .appleScript: "Automation (AppleScript)" case .notifications: "Notifications" case .accessibility: "Accessibility" case .screenRecording: "Screen Recording" case .microphone: "Microphone" case .speechRecognition: "Speech Recognition" case .camera: "Camera" case .location: "Location" } } private var subtitle: String { switch self.capability { case .appleScript: "Control other apps (e.g. Terminal) for automation actions" case .notifications: "Show desktop alerts for agent activity" case .accessibility: "Control UI elements when an action requires it" case .screenRecording: "Capture the screen for context or screenshots" case .microphone: "Allow Voice Wake and audio capture" case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device" case .camera: "Capture photos and video from the camera" case .location: "Share location when requested by the agent" } } private var icon: String { switch self.capability { case .appleScript: "applescript" case .notifications: "bell" case .accessibility: "hand.raised" case .screenRecording: "display" case .microphone: "mic" case .speechRecognition: "waveform" case .camera: "camera" case .location: "location" } } } #if DEBUG struct PermissionsSettings_Previews: PreviewProvider { static var previews: some View { PermissionsSettings( status: [ .appleScript: true, .notifications: true, .accessibility: false, .screenRecording: false, .microphone: true, .speechRecognition: false, ], refresh: {}, showOnboarding: {}) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) } } #endif