mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 22:52:34 +00:00
365 lines
14 KiB
Swift
365 lines
14 KiB
Swift
import CoreLocation
|
|
import OpenClawIPC
|
|
import OpenClawKit
|
|
import SwiftUI
|
|
|
|
struct PermissionsSettings: View {
|
|
let status: [Capability: Bool]
|
|
let refresh: () async -> Void
|
|
let showOnboarding: () -> Void
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
SettingsPageHeader(
|
|
title: "Permissions",
|
|
subtitle: "macOS access for notifications, capture, voice, and device context.")
|
|
|
|
self.permissionSummaryPanel
|
|
|
|
SettingsCardGroup("System Access") {
|
|
PermissionStatusList(status: self.status, refresh: self.refresh)
|
|
}
|
|
|
|
SettingsCardGroup("Location") {
|
|
LocationAccessSettings()
|
|
}
|
|
|
|
SettingsCardGroup("Setup") {
|
|
SettingsCardRow(
|
|
title: "Onboarding walkthrough",
|
|
subtitle: "Use this if macOS prompts were skipped or permissions need a fresh walkthrough.",
|
|
showsDivider: false)
|
|
{
|
|
Button("Restart onboarding") { self.showOnboarding() }
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
}
|
|
.settingsDetailContent()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private var permissionSummaryPanel: some View {
|
|
let granted = self.status.values.filter(\.self).count
|
|
let total = Capability.allCases.count
|
|
let complete = granted == total
|
|
|
|
return HStack(alignment: .center, spacing: 14) {
|
|
ZStack {
|
|
Circle()
|
|
.fill((complete ? Color.green : Color.orange).opacity(0.18))
|
|
Image(systemName: complete ? "checkmark.shield.fill" : "shield.lefthalf.filled")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(complete ? .green : .orange)
|
|
}
|
|
.frame(width: 46, height: 46)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(complete ? "All access granted" : "\(granted) of \(total) permissions granted")
|
|
.font(.headline)
|
|
Text("OpenClaw only asks for macOS capabilities when a feature needs them.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
Spacer(minLength: 18)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.06))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LocationAccessSettings: View {
|
|
private static let controlWidth: CGFloat = 180
|
|
|
|
@AppStorage(locationModeKey) private var locationModeRaw: String = OpenClawLocationMode.off.rawValue
|
|
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
|
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
SettingsCardRow(
|
|
title: "Location access",
|
|
subtitle: "Allow agents to use device location when a tool asks for it.")
|
|
{
|
|
Picker("Location Access", selection: self.$locationModeRaw) {
|
|
Text("Off").tag(OpenClawLocationMode.off.rawValue)
|
|
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
|
|
Text("Always").tag(OpenClawLocationMode.always.rawValue)
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.frame(width: Self.controlWidth, alignment: .trailing)
|
|
}
|
|
|
|
SettingsCardRow(
|
|
title: "Precise location",
|
|
subtitle: "Always may require System Settings to approve background location.",
|
|
showsDivider: false)
|
|
{
|
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
|
.labelsHidden()
|
|
.toggleStyle(.switch)
|
|
.frame(width: Self.controlWidth, alignment: .trailing)
|
|
.disabled(self.locationMode == .off)
|
|
}
|
|
}
|
|
.onAppear {
|
|
self.lastLocationModeRaw = self.locationModeRaw
|
|
}
|
|
.onChange(of: self.locationModeRaw) { _, newValue in
|
|
let previous = self.lastLocationModeRaw
|
|
self.lastLocationModeRaw = newValue
|
|
guard let mode = OpenClawLocationMode(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: OpenClawLocationMode {
|
|
OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
|
}
|
|
|
|
private func requestLocationAuthorization(mode: OpenClawLocationMode) 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
|
|
@State private var pendingCapability: Capability?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(Capability.allCases.enumerated()), id: \.element) { index, cap in
|
|
PermissionRow(
|
|
capability: cap,
|
|
status: self.status[cap] ?? false,
|
|
isPending: self.pendingCapability == cap,
|
|
showsDivider: index != Capability.allCases.count - 1)
|
|
{
|
|
Task { await self.handle(cap) }
|
|
}
|
|
}
|
|
|
|
SettingsCardRow(
|
|
title: "Refresh status",
|
|
subtitle: "Recheck macOS after approving access in System Settings.",
|
|
showsDivider: false)
|
|
{
|
|
Button {
|
|
Task { await self.refresh() }
|
|
} label: {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.font(.footnote)
|
|
.help("Refresh status")
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func handle(_ cap: Capability) async {
|
|
guard self.pendingCapability == nil else { return }
|
|
self.pendingCapability = cap
|
|
defer { self.pendingCapability = nil }
|
|
|
|
_ = await PermissionManager.ensure([cap], interactive: true)
|
|
await self.refreshStatusTransitions()
|
|
}
|
|
|
|
@MainActor
|
|
private func refreshStatusTransitions() async {
|
|
await self.refresh()
|
|
|
|
// TCC and notification settings can settle after the prompt closes or when the app regains focus.
|
|
for delay in [300_000_000, 900_000_000, 1_800_000_000] {
|
|
try? await Task.sleep(nanoseconds: UInt64(delay))
|
|
await self.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PermissionRow: View {
|
|
let capability: Capability
|
|
let status: Bool
|
|
let isPending: Bool
|
|
let compact: Bool
|
|
let showsDivider: Bool
|
|
let action: () -> Void
|
|
|
|
init(
|
|
capability: Capability,
|
|
status: Bool,
|
|
isPending: Bool = false,
|
|
compact: Bool = false,
|
|
showsDivider: Bool = false,
|
|
action: @escaping () -> Void)
|
|
{
|
|
self.capability = capability
|
|
self.status = status
|
|
self.isPending = isPending
|
|
self.compact = compact
|
|
self.showsDivider = showsDivider
|
|
self.action = action
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
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)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.layoutPriority(1)
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
if self.status {
|
|
Label("Granted", systemImage: "checkmark.circle.fill")
|
|
.labelStyle(.iconOnly)
|
|
.foregroundStyle(.green)
|
|
.font(.title3)
|
|
.help("Granted")
|
|
} else if self.isPending {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.frame(width: 78)
|
|
} else {
|
|
Button("Grant") { self.action() }
|
|
.buttonStyle(.bordered)
|
|
.controlSize(self.compact ? .small : .regular)
|
|
.frame(minWidth: self.compact ? 68 : 78, alignment: .trailing)
|
|
}
|
|
|
|
if self.status {
|
|
Text("Granted")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.green)
|
|
} else if self.isPending {
|
|
Text("Checking…")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("Request access")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(minWidth: self.compact ? 86 : 104, alignment: .trailing)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.horizontal, self.compact ? 0 : 14)
|
|
.padding(.vertical, self.compact ? 4 : 11)
|
|
|
|
if self.showsDivider {
|
|
Divider()
|
|
.padding(.leading, self.compact ? 0 : 14)
|
|
.padding(.trailing, self.compact ? 0 : 14)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|