Discord VC: voice channels, transcription, and TTS (#18774)

This commit is contained in:
Shadow
2026-02-20 16:06:07 -06:00
committed by GitHub
parent 3100b77f12
commit 4ab946eebf
23 changed files with 1924 additions and 1076 deletions

View File

@@ -1,12 +1,15 @@
import AVFoundation
import Contacts
import CoreLocation
import CoreMotion
import CryptoKit
import EventKit
import Foundation
import Darwin
import OpenClawKit
import Network
import Observation
import Photos
import ReplayKit
import Security
import Speech
@@ -701,7 +704,7 @@ final class GatewayConnectionController {
var addr = in_addr()
let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 }
guard parsed else { return false }
let value = UInt32(bigEndian: addr.s_addr)
let value = ntohl(addr.s_addr)
let firstOctet = UInt8((value >> 24) & 0xFF)
return firstOctet == 127
}
@@ -780,7 +783,6 @@ final class GatewayConnectionController {
}
private func currentCaps() -> [String] {
let permissionSnapshot = IOSPermissionCenter.statusSnapshot()
var caps = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
// Default-on: if the key doesn't exist yet, treat it as enabled.
@@ -801,19 +803,11 @@ final class GatewayConnectionController {
if WatchMessagingService.isSupportedOnDevice() {
caps.append(OpenClawCapability.watch.rawValue)
}
if permissionSnapshot.photosAllowed {
caps.append(OpenClawCapability.photos.rawValue)
}
if permissionSnapshot.contactsAllowed {
caps.append(OpenClawCapability.contacts.rawValue)
}
if permissionSnapshot.calendarReadAllowed || permissionSnapshot.calendarWriteAllowed {
caps.append(OpenClawCapability.calendar.rawValue)
}
if permissionSnapshot.remindersReadAllowed || permissionSnapshot.remindersWriteAllowed {
caps.append(OpenClawCapability.reminders.rawValue)
}
if Self.motionAvailable() && permissionSnapshot.motionAllowed {
caps.append(OpenClawCapability.photos.rawValue)
caps.append(OpenClawCapability.contacts.rawValue)
caps.append(OpenClawCapability.calendar.rawValue)
caps.append(OpenClawCapability.reminders.rawValue)
if Self.motionAvailable() {
caps.append(OpenClawCapability.motion.rawValue)
}
@@ -821,7 +815,6 @@ final class GatewayConnectionController {
}
private func currentCommands() -> [String] {
let permissionSnapshot = IOSPermissionCenter.statusSnapshot()
var commands: [String] = [
OpenClawCanvasCommand.present.rawValue,
OpenClawCanvasCommand.hide.rawValue,
@@ -865,20 +858,12 @@ final class GatewayConnectionController {
commands.append(OpenClawContactsCommand.add.rawValue)
}
if caps.contains(OpenClawCapability.calendar.rawValue) {
if permissionSnapshot.calendarReadAllowed {
commands.append(OpenClawCalendarCommand.events.rawValue)
}
if permissionSnapshot.calendarWriteAllowed {
commands.append(OpenClawCalendarCommand.add.rawValue)
}
commands.append(OpenClawCalendarCommand.events.rawValue)
commands.append(OpenClawCalendarCommand.add.rawValue)
}
if caps.contains(OpenClawCapability.reminders.rawValue) {
if permissionSnapshot.remindersReadAllowed {
commands.append(OpenClawRemindersCommand.list.rawValue)
}
if permissionSnapshot.remindersWriteAllowed {
commands.append(OpenClawRemindersCommand.add.rawValue)
}
commands.append(OpenClawRemindersCommand.list.rawValue)
commands.append(OpenClawRemindersCommand.add.rawValue)
}
if caps.contains(OpenClawCapability.motion.rawValue) {
commands.append(OpenClawMotionCommand.activity.rawValue)
@@ -889,7 +874,6 @@ final class GatewayConnectionController {
}
private func currentPermissions() -> [String: Bool] {
let permissionSnapshot = IOSPermissionCenter.statusSnapshot()
var permissions: [String: Bool] = [:]
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
@@ -899,23 +883,22 @@ final class GatewayConnectionController {
&& CLLocationManager.locationServicesEnabled()
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
permissions["photos"] = permissionSnapshot.photosAllowed
permissions["photosDenied"] = permissionSnapshot.photos.isDeniedOrRestricted
permissions["contacts"] = permissionSnapshot.contactsAllowed
permissions["contactsDenied"] = permissionSnapshot.contacts.isDeniedOrRestricted
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
permissions["photos"] = photoStatus == .authorized || photoStatus == .limited
let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
permissions["calendar"] = permissionSnapshot.calendarReadAllowed || permissionSnapshot.calendarWriteAllowed
permissions["calendarRead"] = permissionSnapshot.calendarReadAllowed
permissions["calendarWrite"] = permissionSnapshot.calendarWriteAllowed
permissions["calendarDenied"] = permissionSnapshot.calendar.isDeniedOrRestricted
let calendarStatus = EKEventStore.authorizationStatus(for: .event)
permissions["calendar"] =
calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly
let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
permissions["reminders"] =
remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly
permissions["reminders"] = permissionSnapshot.remindersReadAllowed || permissionSnapshot.remindersWriteAllowed
permissions["remindersRead"] = permissionSnapshot.remindersReadAllowed
permissions["remindersWrite"] = permissionSnapshot.remindersWriteAllowed
permissions["remindersDenied"] = permissionSnapshot.reminders.isDeniedOrRestricted
permissions["motion"] = permissionSnapshot.motionAllowed
permissions["motionDenied"] = permissionSnapshot.motion.isDeniedOrRestricted
let motionStatus = CMMotionActivityManager.authorizationStatus()
let pedometerStatus = CMPedometer.authorizationStatus()
permissions["motion"] =
motionStatus == .authorized || pedometerStatus == .authorized
let watchStatus = WatchMessagingService.currentStatusSnapshot()
permissions["watchSupported"] = watchStatus.supported

View File

@@ -44,22 +44,12 @@
</array>
<key>NSCameraUsageDescription</key>
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>OpenClaw can read your photo library when you ask it to share recent photos.</string>
<key>NSContactsUsageDescription</key>
<string>OpenClaw can read and create contacts when requested via the gateway.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>OpenClaw discovers and connects to your OpenClaw gateway on the local network.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>OpenClaw can share your location in the background when you enable Always.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>OpenClaw uses your location when you allow location sharing.</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>OpenClaw can read and add calendar events when requested via the gateway.</string>
<key>NSRemindersFullAccessUsageDescription</key>
<string>OpenClaw can read and add reminders when requested via the gateway.</string>
<key>NSMotionUsageDescription</key>
<string>OpenClaw uses Motion &amp; Fitness data for activity and pedometer commands.</string>
<key>NSMicrophoneUsageDescription</key>
<string>OpenClaw needs microphone access for voice wake.</string>
<key>NSSpeechRecognitionUsageDescription</key>

View File

@@ -1,22 +0,0 @@
import Foundation
import UIKit
@MainActor
extension NodeAppModel {
func permissionSnapshot() -> IOSPermissionSnapshot {
IOSPermissionCenter.statusSnapshot()
}
@discardableResult
func requestPermission(_ permission: IOSPermissionKind) async -> IOSPermissionSnapshot {
_ = await IOSPermissionCenter.request(permission)
return IOSPermissionCenter.statusSnapshot()
}
func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else {
return
}
UIApplication.shared.open(url)
}
}

View File

@@ -1,303 +0,0 @@
import Contacts
import CoreMotion
import EventKit
import Foundation
import Photos
enum IOSPermissionKind: String, CaseIterable, Identifiable, Sendable {
case photos
case contacts
case calendar
case reminders
case motion
var id: String { self.rawValue }
var title: String {
switch self {
case .photos:
"Photos"
case .contacts:
"Contacts"
case .calendar:
"Calendar"
case .reminders:
"Reminders"
case .motion:
"Motion & Fitness"
}
}
}
enum IOSPermissionState: String, Equatable, Sendable {
case granted
case limited
case writeOnly
case denied
case restricted
case notDetermined
case unavailable
var label: String {
switch self {
case .granted:
"Granted"
case .limited:
"Limited"
case .writeOnly:
"Write only"
case .denied:
"Denied"
case .restricted:
"Restricted"
case .notDetermined:
"Not requested"
case .unavailable:
"Unavailable"
}
}
var isDeniedOrRestricted: Bool {
self == .denied || self == .restricted
}
}
struct IOSPermissionSnapshot: Equatable, Sendable {
var photos: IOSPermissionState
var contacts: IOSPermissionState
var calendar: IOSPermissionState
var reminders: IOSPermissionState
var motion: IOSPermissionState
static let initial = IOSPermissionSnapshot(
photos: .notDetermined,
contacts: .notDetermined,
calendar: .notDetermined,
reminders: .notDetermined,
motion: .notDetermined)
func state(for kind: IOSPermissionKind) -> IOSPermissionState {
switch kind {
case .photos:
self.photos
case .contacts:
self.contacts
case .calendar:
self.calendar
case .reminders:
self.reminders
case .motion:
self.motion
}
}
var photosAllowed: Bool {
self.photos == .granted || self.photos == .limited
}
var contactsAllowed: Bool {
self.contacts == .granted || self.contacts == .limited
}
var calendarReadAllowed: Bool {
self.calendar == .granted
}
var calendarWriteAllowed: Bool {
self.calendar == .granted || self.calendar == .writeOnly
}
var remindersReadAllowed: Bool {
self.reminders == .granted
}
var remindersWriteAllowed: Bool {
self.reminders == .granted || self.reminders == .writeOnly
}
var motionAllowed: Bool {
self.motion == .granted
}
}
@MainActor
enum IOSPermissionCenter {
static func statusSnapshot() -> IOSPermissionSnapshot {
IOSPermissionSnapshot(
photos: self.mapPhotoStatus(PHPhotoLibrary.authorizationStatus(for: .readWrite)),
contacts: self.mapContactsStatus(CNContactStore.authorizationStatus(for: .contacts)),
calendar: self.mapEventKitStatus(EKEventStore.authorizationStatus(for: .event)),
reminders: self.mapEventKitStatus(EKEventStore.authorizationStatus(for: .reminder)),
motion: self.motionState())
}
static func request(_ kind: IOSPermissionKind) async -> IOSPermissionState {
switch kind {
case .photos:
await self.requestPhotosIfNeeded()
case .contacts:
await self.requestContactsIfNeeded()
case .calendar:
await self.requestCalendarIfNeeded()
case .reminders:
await self.requestRemindersIfNeeded()
case .motion:
await self.requestMotionIfNeeded()
}
return self.statusSnapshot().state(for: kind)
}
private static func requestPhotosIfNeeded() async {
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .notDetermined else {
return
}
_ = await withCheckedContinuation { (cont: CheckedContinuation<PHAuthorizationStatus, Never>) in
PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
cont.resume(returning: status)
}
}
}
private static func requestContactsIfNeeded() async {
guard CNContactStore.authorizationStatus(for: .contacts) == .notDetermined else {
return
}
let store = CNContactStore()
_ = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
store.requestAccess(for: .contacts) { granted, _ in
cont.resume(returning: granted)
}
}
}
private static func requestCalendarIfNeeded() async {
let status = EKEventStore.authorizationStatus(for: .event)
guard status == .notDetermined || status == .writeOnly else {
return
}
let store = EKEventStore()
_ = try? await store.requestFullAccessToEvents()
}
private static func requestRemindersIfNeeded() async {
let status = EKEventStore.authorizationStatus(for: .reminder)
guard status == .notDetermined || status == .writeOnly else {
return
}
let store = EKEventStore()
_ = try? await store.requestFullAccessToReminders()
}
private static func requestMotionIfNeeded() async {
guard self.motionState() == .notDetermined else {
return
}
let activityManager = CMMotionActivityManager()
await self.runPermissionProbe { complete in
let end = Date()
activityManager.queryActivityStarting(
from: end.addingTimeInterval(-120),
to: end,
to: OperationQueue()) { _, _ in
complete()
}
}
let pedometer = CMPedometer()
await self.runPermissionProbe { complete in
let end = Date()
pedometer.queryPedometerData(
from: end.addingTimeInterval(-120),
to: end) { _, _ in
complete()
}
}
}
private static func runPermissionProbe(start: (@escaping () -> Void) -> Void) async {
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
let lock = NSLock()
var resumed = false
start {
lock.lock()
defer { lock.unlock() }
guard !resumed else { return }
resumed = true
cont.resume(returning: ())
}
}
}
private static func mapPhotoStatus(_ status: PHAuthorizationStatus) -> IOSPermissionState {
switch status {
case .authorized:
.granted
case .limited:
.limited
case .denied:
.denied
case .restricted:
.restricted
case .notDetermined:
.notDetermined
@unknown default:
.restricted
}
}
private static func mapContactsStatus(_ status: CNAuthorizationStatus) -> IOSPermissionState {
switch status {
case .authorized:
.granted
case .limited:
.limited
case .denied:
.denied
case .restricted:
.restricted
case .notDetermined:
.notDetermined
@unknown default:
.restricted
}
}
private static func mapEventKitStatus(_ status: EKAuthorizationStatus) -> IOSPermissionState {
switch status {
case .authorized, .fullAccess:
.granted
case .writeOnly:
.writeOnly
case .denied:
.denied
case .restricted:
.restricted
case .notDetermined:
.notDetermined
@unknown default:
.restricted
}
}
private static func motionState() -> IOSPermissionState {
let available = CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
guard available else {
return .unavailable
}
let activity = CMMotionActivityManager.authorizationStatus()
let pedometer = CMPedometer.authorizationStatus()
if activity == .authorized || pedometer == .authorized {
return .granted
}
if activity == .restricted || pedometer == .restricted {
return .restricted
}
if activity == .denied || pedometer == .denied {
return .denied
}
return .notDetermined
}
}

View File

@@ -1,98 +0,0 @@
import SwiftUI
struct PermissionsDisclosureSection: View {
let snapshot: IOSPermissionSnapshot
let requestingPermission: IOSPermissionKind?
let onRequest: (IOSPermissionKind) -> Void
let onOpenSettings: () -> Void
let onInfo: (IOSPermissionKind) -> Void
var body: some View {
DisclosureGroup("Permissions") {
self.permissionRow(.photos)
self.permissionRow(.contacts)
self.permissionRow(.calendar)
self.permissionRow(.reminders)
self.permissionRow(.motion)
Button {
self.onOpenSettings()
} label: {
Label("Open iOS Settings", systemImage: "gear")
}
}
}
@ViewBuilder
private func permissionRow(_ kind: IOSPermissionKind) -> some View {
let state = self.snapshot.state(for: kind)
HStack(spacing: 8) {
Text(kind.title)
Spacer()
Text(state.label)
.font(.footnote)
.foregroundStyle(self.permissionStatusColor(for: state))
if self.requestingPermission == kind {
ProgressView()
.progressViewStyle(.circular)
}
if let action = self.permissionAction(for: state) {
Button(action.title) {
switch action {
case .request:
self.onRequest(kind)
case .openSettings:
self.onOpenSettings()
}
}
.disabled(self.requestingPermission != nil)
}
Button {
self.onInfo(kind)
} label: {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("\(kind.title) permission info")
}
}
private enum PermissionAction {
case request
case openSettings
var title: String {
switch self {
case .request:
"Request"
case .openSettings:
"Settings"
}
}
}
private func permissionAction(for state: IOSPermissionState) -> PermissionAction? {
switch state {
case .notDetermined, .writeOnly:
.request
case .denied, .restricted:
.openSettings
case .granted, .limited, .unavailable:
nil
}
}
private func permissionStatusColor(for state: IOSPermissionState) -> Color {
switch state {
case .granted, .limited:
.green
case .writeOnly:
.orange
case .denied, .restricted:
.red
case .notDetermined, .unavailable:
.secondary
}
}
}

View File

@@ -16,7 +16,6 @@ struct SettingsTab: View {
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
@@ -43,6 +42,7 @@ struct SettingsTab: View {
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@State private var connectingGatewayID: String?
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var defaultShareInstruction: String = ""
@@ -51,8 +51,6 @@ struct SettingsTab: View {
@State private var manualGatewayPortText: String = ""
@State private var gatewayExpanded: Bool = true
@State private var selectedAgentPickerId: String = ""
@State private var permissionSnapshot: IOSPermissionSnapshot = .initial
@State private var requestingPermission: IOSPermissionKind?
@State private var showResetOnboardingAlert: Bool = false
@State private var activeFeatureHelp: FeatureHelp?
@@ -61,23 +59,317 @@ struct SettingsTab: View {
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
var body: some View {
self.settingsScreen
.gatewayTrustPromptAlert()
}
NavigationStack {
Form {
Section {
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
if !self.isGatewayConnected {
Text(
"1. Open Telegram and message your bot: /pair\n"
+ "2. Copy the setup code it returns\n"
+ "3. Paste here and tap Connect\n"
+ "4. Back in Telegram, run /pair approve")
.font(.footnote)
.foregroundStyle(.secondary)
@ViewBuilder
private var settingsScreen: some View {
let base = NavigationStack {
self.settingsForm
}
self.lifecycleObservedSettingsScreen(self.presentedSettingsScreen(base))
}
if let warning = self.tailnetWarningText {
Text(warning)
.font(.footnote.weight(.semibold))
.foregroundStyle(.orange)
}
private func presentedSettingsScreen<Content: View>(_ content: Content) -> some View {
content
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect with setup code")
}
}
.disabled(self.connectingGatewayID != nil
|| self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let status = self.setupStatusLine {
Text(status)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if self.isGatewayConnected {
Picker("Bot", selection: self.$selectedAgentPickerId) {
Text("Default").tag("")
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
Text(name.isEmpty ? agent.id : name).tag(agent.id)
}
}
Text("Controls which bot Chat and Talk speak to.")
.font(.footnote)
.foregroundStyle(.secondary)
}
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
LabeledContent("Status", value: self.appModel.gatewayStatusText)
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress {
let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") {
Text(urlString)
}
.contextMenu {
Button {
UIPasteboard.general.string = urlString
} label: {
Label("Copy URL", systemImage: "doc.on.doc")
}
if let parts {
Button {
UIPasteboard.general.string = parts.host
} label: {
Label("Copy Host", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = "\(parts.port)"
} label: {
Label("Copy Port", systemImage: "doc.on.doc")
}
}
}
}
Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway()
}
} else {
self.gatewayList(showing: .all)
}
DisclosureGroup("Advanced") {
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect (Manual)")
}
}
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || !self.manualPortIsValid)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView()
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
Button("Reset Onboarding", role: .destructive) {
self.showResetOnboardingAlert = true
}
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
} label: {
HStack(spacing: 10) {
Circle()
.fill(self.isGatewayConnected ? Color.green : Color.secondary.opacity(0.35))
.frame(width: 10, height: 10)
Text("Gateway")
Spacer()
Text(self.gatewaySummaryText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
Section("Device") {
DisclosureGroup("Features") {
self.featureToggle(
"Voice Wake",
isOn: self.$voiceWakeEnabled,
help: "Enables wake-word activation to start a hands-free session.") { newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
self.featureToggle(
"Talk Mode",
isOn: self.$talkEnabled,
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
self.appModel.setTalkEnabled(newValue)
}
self.featureToggle(
"Background Listening",
isOn: self.$talkBackgroundEnabled,
help: "Keeps listening while the app is backgrounded. Uses more battery.")
NavigationLink {
VoiceWakeWordsSettingsView()
} label: {
LabeledContent(
"Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
}
self.featureToggle(
"Allow Camera",
isOn: self.$cameraEnabled,
help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.")
HStack(spacing: 8) {
Text("Location Access")
Spacer()
Button {
self.activeFeatureHelp = FeatureHelp(
title: "Location Access",
message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.")
} label: {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Location Access info")
}
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
Text("Off").tag(OpenClawLocationMode.off.rawValue)
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
Text("Always").tag(OpenClawLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.segmented)
self.featureToggle(
"Prevent Sleep",
isOn: self.$preventSleep,
help: "Keeps the screen awake while OpenClaw is open.")
DisclosureGroup("Advanced") {
self.featureToggle(
"Voice Directive Hint",
isOn: self.$talkVoiceDirectiveHintEnabled,
help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
self.featureToggle(
"Show Talk Button",
isOn: self.$talkButtonEnabled,
help: "Shows the floating Talk button in the main interface.")
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
.lineLimit(2 ... 6)
.textInputAutocapitalization(.sentences)
HStack(spacing: 8) {
Text("Default Share Instruction")
.font(.footnote)
.foregroundStyle(.secondary)
Spacer()
Button {
self.activeFeatureHelp = FeatureHelp(
title: "Default Share Instruction",
message: "Appends this instruction when sharing content into OpenClaw from iOS.")
} label: {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Default Share Instruction info")
}
VStack(alignment: .leading, spacing: 8) {
Button {
Task { await self.appModel.runSharePipelineSelfTest() }
} label: {
Label("Run Share Self-Test", systemImage: "checkmark.seal")
}
Text(self.appModel.lastShareEventText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
DisclosureGroup("Device Info") {
TextField("Name", text: self.$displayName)
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
LabeledContent("Device", value: self.deviceFamily())
LabeledContent("Platform", value: self.platformString())
LabeledContent("OpenClaw", value: self.openClawVersionString())
}
}
}
.navigationTitle("Settings")
.toolbar {
self.closeToolbar
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
@@ -94,42 +386,47 @@ struct SettingsTab: View {
message: Text(help.message),
dismissButton: .default(Text("OK")))
}
}
@ToolbarContentBuilder
private var closeToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
private func lifecycleObservedSettingsScreen<Content: View>(_ content: Content) -> some View {
content
.onAppear {
self.handleOnAppear()
}
.onChange(of: self.scenePhase) { _, newValue in
self.handleScenePhaseChange(newValue)
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 ?? ""
}
.onChange(of: self.selectedAgentPickerId) { _, newValue in
self.handleSelectedAgentPickerChange(newValue)
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
}
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
self.handleAppSelectedAgentIdChange(newValue)
if newValue != self.selectedAgentPickerId {
self.selectedAgentPickerId = newValue
}
}
.onChange(of: self.preferredGatewayStableID) { _, newValue in
self.handlePreferredGatewayStableIdChange(newValue)
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
}
.onChange(of: self.gatewayToken) { _, newValue in
self.handleGatewayTokenChange(newValue)
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
self.handleGatewayPasswordChange(newValue)
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)
@@ -138,430 +435,41 @@ struct SettingsTab: View {
self.syncManualPortText()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
self.handleGatewayServerNameChange(newValue)
if newValue != nil {
self.setupCode = ""
self.setupStatusText = nil
return
}
if self.manualGatewayEnabled {
self.setupStatusText = self.appModel.gatewayStatusText
}
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
self.handleGatewayStatusTextChange(newValue)
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) { oldValue, newValue in
self.handleLocationModeChange(from: oldValue, to: newValue)
}
}
private func handleOnAppear() {
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 ?? ""
self.refreshPermissionSnapshot()
}
private func handleScenePhaseChange(_ newValue: ScenePhase) {
guard newValue == .active else { return }
self.refreshPermissionSnapshot()
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
}
private func handleSelectedAgentPickerChange(_ newValue: String) {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
}
private func handleAppSelectedAgentIdChange(_ newValue: String) {
if newValue != self.selectedAgentPickerId {
self.selectedAgentPickerId = newValue
}
}
private func handlePreferredGatewayStableIdChange(_ newValue: String) {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
}
private func handleGatewayTokenChange(_ newValue: String) {
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)
}
private func handleGatewayPasswordChange(_ newValue: String) {
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)
}
private func handleGatewayServerNameChange(_ newValue: String?) {
if newValue != nil {
self.setupCode = ""
self.setupStatusText = nil
return
}
if self.manualGatewayEnabled {
self.setupStatusText = self.appModel.gatewayStatusText
}
}
private func handleGatewayStatusTextChange(_ newValue: String) {
guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.setupStatusText = trimmed
}
private func handleLocationModeChange(from oldValue: String, to newValue: String) {
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.appModel.requestLocationPermissions(mode: mode)
if !granted {
await MainActor.run {
self.locationEnabledModeRaw = oldValue
}
return
}
await MainActor.run {
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
}
}
}
@ViewBuilder
private var settingsForm: some View {
Form {
self.gatewaySection
self.deviceSection
}
}
@ViewBuilder
private var gatewaySection: some View {
Section {
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
if !self.isGatewayConnected {
Text(
"1. Open Telegram and message your bot: /pair\n"
+ "2. Copy the setup code it returns\n"
+ "3. Paste here and tap Connect\n"
+ "4. Back in Telegram, run /pair approve")
.font(.footnote)
.foregroundStyle(.secondary)
if let warning = self.tailnetWarningText {
Text(warning)
.font(.footnote.weight(.semibold))
.foregroundStyle(.orange)
}
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect with setup code")
.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
}
.disabled(self.connectingGatewayID != nil
|| self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let status = self.setupStatusLine {
Text(status)
.font(.footnote)
.foregroundStyle(.secondary)
await MainActor.run {
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
}
}
if self.isGatewayConnected {
Picker("Bot", selection: self.$selectedAgentPickerId) {
Text("Default").tag("")
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
Text(name.isEmpty ? agent.id : name).tag(agent.id)
}
}
Text("Controls which bot Chat and Talk speak to.")
.font(.footnote)
.foregroundStyle(.secondary)
}
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
LabeledContent("Status", value: self.appModel.gatewayStatusText)
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
if let serverName = self.appModel.gatewayServerName {
LabeledContent("Server", value: serverName)
if let addr = self.appModel.gatewayRemoteAddress {
let parts = Self.parseHostPort(from: addr)
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
LabeledContent("Address") {
Text(urlString)
}
.contextMenu {
Button {
UIPasteboard.general.string = urlString
} label: {
Label("Copy URL", systemImage: "doc.on.doc")
}
if let parts {
Button {
UIPasteboard.general.string = parts.host
} label: {
Label("Copy Host", systemImage: "doc.on.doc")
}
Button {
UIPasteboard.general.string = "\(parts.port)"
} label: {
Label("Copy Port", systemImage: "doc.on.doc")
}
}
}
}
Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway()
}
} else {
self.gatewayList(showing: .all)
}
DisclosureGroup("Advanced") {
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port (optional)", text: self.manualPortBinding)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect (Manual)")
}
}
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty || !self.manualPortIsValid)
Text(
"Use this when mDNS/Bonjour discovery is blocked. "
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
GatewayDiscoveryDebugLogView()
}
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
TextField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
Button("Reset Onboarding", role: .destructive) {
self.showResetOnboardingAlert = true
}
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
Text(self.gatewayDebugText())
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
} label: {
HStack(spacing: 10) {
Circle()
.fill(self.isGatewayConnected ? Color.green : Color.secondary.opacity(0.35))
.frame(width: 10, height: 10)
Text("Gateway")
Spacer()
Text(self.gatewaySummaryText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
@ViewBuilder
private var deviceSection: some View {
Section("Device") {
DisclosureGroup("Features") {
self.featureToggle(
"Voice Wake",
isOn: self.$voiceWakeEnabled,
help: "Enables wake-word activation to start a hands-free session.") { newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
self.featureToggle(
"Talk Mode",
isOn: self.$talkEnabled,
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
self.appModel.setTalkEnabled(newValue)
}
self.featureToggle(
"Background Listening",
isOn: self.$talkBackgroundEnabled,
help: "Keeps listening while the app is backgrounded. Uses more battery.")
NavigationLink {
VoiceWakeWordsSettingsView()
} label: {
LabeledContent(
"Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
}
self.featureToggle(
"Allow Camera",
isOn: self.$cameraEnabled,
help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.")
HStack(spacing: 8) {
Text("Location Access")
Spacer()
Button {
self.activeFeatureHelp = FeatureHelp(
title: "Location Access",
message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.")
} label: {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Location Access info")
}
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
Text("Off").tag(OpenClawLocationMode.off.rawValue)
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
Text("Always").tag(OpenClawLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.segmented)
self.featureToggle(
"Prevent Sleep",
isOn: self.$preventSleep,
help: "Keeps the screen awake while OpenClaw is open.")
DisclosureGroup("Advanced") {
self.featureToggle(
"Voice Directive Hint",
isOn: self.$talkVoiceDirectiveHintEnabled,
help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.")
self.featureToggle(
"Show Talk Button",
isOn: self.$talkButtonEnabled,
help: "Shows the floating Talk button in the main interface.")
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
.lineLimit(2 ... 6)
.textInputAutocapitalization(.sentences)
HStack(spacing: 8) {
Text("Default Share Instruction")
.font(.footnote)
.foregroundStyle(.secondary)
Spacer()
Button {
self.activeFeatureHelp = FeatureHelp(
title: "Default Share Instruction",
message: "Appends this instruction when sharing content into OpenClaw from iOS.")
} label: {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Default Share Instruction info")
}
VStack(alignment: .leading, spacing: 8) {
Button {
Task { await self.appModel.runSharePipelineSelfTest() }
} label: {
Label("Run Share Self-Test", systemImage: "checkmark.seal")
}
Text(self.appModel.lastShareEventText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
DisclosureGroup("Device Info") {
TextField("Name", text: self.$displayName)
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
LabeledContent("Device", value: self.deviceFamily())
LabeledContent("Platform", value: self.platformString())
LabeledContent("OpenClaw", value: self.openClawVersionString())
}
PermissionsDisclosureSection(
snapshot: self.permissionSnapshot,
requestingPermission: self.requestingPermission,
onRequest: { kind in
Task { await self.requestPermission(kind) }
},
onOpenSettings: {
self.appModel.openSystemSettings()
},
onInfo: { kind in
self.activeFeatureHelp = FeatureHelp(
title: kind.title,
message: self.permissionHelp(for: kind))
})
}
.gatewayTrustPromptAlert()
}
@ViewBuilder
@@ -701,33 +609,6 @@ struct SettingsTab: View {
}
}
private func permissionHelp(for kind: IOSPermissionKind) -> String {
switch kind {
case .photos:
"Required for photos.latest tool access."
case .contacts:
"Required for contacts.search and contacts.add."
case .calendar:
"Full access enables calendar.events and calendar.add."
case .reminders:
"Full access enables reminders.list and reminders.add."
case .motion:
"Required for motion.activity and motion.pedometer."
}
}
private func refreshPermissionSnapshot() {
self.permissionSnapshot = self.appModel.permissionSnapshot()
}
private func requestPermission(_ kind: IOSPermissionKind) async {
self.requestingPermission = kind
_ = await self.appModel.requestPermission(kind)
self.refreshPermissionSnapshot()
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
self.requestingPermission = nil
}
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id
self.manualGatewayEnabled = false

View File

@@ -108,13 +108,8 @@ targets:
NSBonjourServices:
- _openclaw-gw._tcp
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
NSPhotoLibraryUsageDescription: OpenClaw can read your photo library when you ask it to share recent photos.
NSContactsUsageDescription: OpenClaw can read and create contacts when requested via the gateway.
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
NSCalendarsFullAccessUsageDescription: OpenClaw can read and add calendar events when requested via the gateway.
NSRemindersFullAccessUsageDescription: OpenClaw can read and add reminders when requested via the gateway.
NSMotionUsageDescription: OpenClaw uses Motion & Fitness data for activity and pedometer commands.
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
UISupportedInterfaceOrientations: