mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
Discord VC: voice channels, transcription, and TTS (#18774)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 & Fitness data for activity and pedometer commands.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OpenClaw needs microphone access for voice wake.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user