Files
adolf/apps/macos/Sources/OpenClaw/PermissionsSettings.swift
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

385 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)
}
}
extension Capability {
/// Importance order for permission lists: app control and context capture
/// first (they power most agent actions), voice/media next, location last.
/// Keep onboarding and Settings in the same order so users can cross-reference.
static let importanceOrdered: [Capability] = [
.appleScript,
.accessibility,
.screenRecording,
.notifications,
.microphone,
.speechRecognition,
.camera,
.location,
]
}
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.importanceOrdered.enumerated()), id: \.element) { index, cap in
PermissionRow(
capability: cap,
status: self.status[cap] ?? false,
isPending: self.pendingCapability == cap,
showsDivider: index != Capability.importanceOrdered.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)
}
// Compact rows (onboarding) skip the caption so the full
// permission list fits the fixed page without scrolling.
if !self.compact {
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