mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 10:26:07 +00:00
* feat(mobile): default pairing to full node access * chore: leave release notes to release workflow * refactor(mobile): bound pairing helpers * chore(i18n): refresh mobile access inventory * chore(ci): lower mobile pairing LOC baseline * fix(mobile): complete pairing validation artifacts * ci: remove stale transcript target export baseline * fix(ios): wrap access upgrade guidance * fix(ios): preserve localized access key * fix(ci): dedupe read-only SQLite guardrail * fix(mobile): secure full-access node setup
1477 lines
58 KiB
Swift
1477 lines
58 KiB
Swift
import OpenClawKit
|
||
import SwiftUI
|
||
|
||
/// iOS Settings-style icon: white glyph on a solid rounded-square, sized for a List row.
|
||
struct SettingsIcon: View {
|
||
let systemName: String
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
Image(systemName: self.systemName)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 28, height: 28)
|
||
.background(RoundedRectangle(cornerRadius: 7, style: .continuous).fill(self.color))
|
||
}
|
||
}
|
||
|
||
private struct AppearanceSettingsRow: View {
|
||
@Environment(AppAppearanceModel.self) private var appearanceModel
|
||
|
||
private var preference: AppAppearancePreference {
|
||
self.appearanceModel.preference
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationLink {
|
||
AppearanceSettingsScreen()
|
||
} label: {
|
||
self.rowLabel
|
||
}
|
||
.accessibilityIdentifier("settings-appearance-row")
|
||
.accessibilityLabel("Appearance")
|
||
.accessibilityValue(self.preference.label)
|
||
.accessibilityHint("Choose system, light, or dark appearance")
|
||
}
|
||
|
||
private var rowLabel: some View {
|
||
HStack(spacing: 12) {
|
||
ProIconBadge(
|
||
systemName: "circle.lefthalf.filled",
|
||
color: .secondary)
|
||
|
||
Text("Appearance")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
Text(self.preference.label)
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct AppearanceSettingsScreen: View {
|
||
@Environment(AppAppearanceModel.self) private var appearanceModel
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
List {
|
||
Section {
|
||
ForEach(AppAppearancePreference.allCases) { preference in
|
||
Button {
|
||
self.select(preference)
|
||
} label: {
|
||
Label {
|
||
HStack {
|
||
Text(preference.label)
|
||
.font(OpenClawType.body)
|
||
Spacer()
|
||
if preference == self.appearanceModel.preference {
|
||
Image(systemName: "checkmark")
|
||
.foregroundStyle(OpenClawBrand.accent)
|
||
}
|
||
}
|
||
} icon: {
|
||
Image(systemName: preference.systemImage)
|
||
}
|
||
}
|
||
.foregroundStyle(.primary)
|
||
.accessibilityIdentifier("settings-appearance-\(preference.rawValue)")
|
||
.accessibilityValue(
|
||
preference == self.appearanceModel.preference ? "Selected" : "")
|
||
}
|
||
} footer: {
|
||
Text("System follows this device’s appearance setting.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
.navigationTitle("Appearance")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
|
||
private func select(_ preference: AppAppearancePreference) {
|
||
guard preference != self.appearanceModel.preference else { return }
|
||
self.dismiss()
|
||
Task { @MainActor in
|
||
// Changing the root scheme while an iPad split-view destination is active can
|
||
// leave that destination blank. Apply only after the native pop transition.
|
||
try? await Task.sleep(for: .milliseconds(500))
|
||
self.appearanceModel.select(preference)
|
||
}
|
||
}
|
||
}
|
||
|
||
extension SettingsProTab {
|
||
var appearanceRow: some View {
|
||
AppearanceSettingsRow()
|
||
}
|
||
|
||
var gatewaySection: some View {
|
||
Section("Gateway") {
|
||
HStack(spacing: 8) {
|
||
NavigationLink(value: SettingsRoute.gateway) {
|
||
self.gatewayConnectionRow
|
||
}
|
||
if self.gatewayRegistry.entries.count > 1 {
|
||
self.gatewayQuickSwitchMenu
|
||
}
|
||
}
|
||
SettingsDetailRow("Address", value: .verbatim(self.gatewayAddress))
|
||
SettingsDetailRow("Server", value: .verbatim(self.gatewayServer))
|
||
SettingsDetailRow(
|
||
"Agents",
|
||
value: .verbatim(self.appModel.gatewayAgents.count.formatted()))
|
||
self.gatewayActions
|
||
}
|
||
}
|
||
|
||
var gatewayConnectionRow: some View {
|
||
LabeledContent {
|
||
Text(self.gatewayStatusDetail)
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(self.gatewayStatusColor)
|
||
} label: {
|
||
Text("Connection")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder var settingsListSection: some View {
|
||
Section {
|
||
self.settingsListRow(
|
||
icon: "checkmark.shield.fill",
|
||
iconColor: self.pendingApproval == nil ? .green : .orange,
|
||
title: "Approvals",
|
||
route: .approvals,
|
||
badgeValue: self.pendingApproval == nil ? nil : "1")
|
||
self.settingsListRow(
|
||
icon: "person.2.fill",
|
||
iconColor: .blue,
|
||
title: "Permissions",
|
||
route: .permissions)
|
||
self.settingsListRow(
|
||
icon: "point.3.connected.trianglepath.dotted",
|
||
iconColor: .purple,
|
||
title: "Channels",
|
||
route: .channels)
|
||
self.settingsListRow(
|
||
icon: "sparkles",
|
||
iconColor: OpenClawBrand.accent,
|
||
title: "Skills",
|
||
route: .skills)
|
||
self.settingsListRow(
|
||
icon: "waveform",
|
||
iconColor: .pink,
|
||
title: "Voice & Talk",
|
||
route: .voice)
|
||
}
|
||
|
||
Section {
|
||
self.appearanceRow
|
||
self.settingsListRow(
|
||
icon: "stethoscope",
|
||
iconColor: .teal,
|
||
title: "Diagnostics",
|
||
route: .diagnostics)
|
||
self.settingsListRow(
|
||
icon: "hand.raised.fill",
|
||
iconColor: .indigo,
|
||
title: "Privacy",
|
||
route: .privacy)
|
||
self.settingsListRow(
|
||
icon: "applewatch",
|
||
iconColor: .green,
|
||
title: "Apple Watch",
|
||
route: .appleWatch)
|
||
self.settingsListRow(
|
||
icon: "info.circle.fill",
|
||
iconColor: .gray,
|
||
title: "About",
|
||
route: .about)
|
||
} header: {
|
||
Text("Device")
|
||
.font(OpenClawType.captionSemiBold)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Section {
|
||
self.settingsListRow(
|
||
icon: "doc.text",
|
||
iconColor: .gray,
|
||
title: "Licenses",
|
||
route: .licenses)
|
||
.accessibilityIdentifier("settings-licenses-row")
|
||
}
|
||
}
|
||
|
||
func settingsListRow(
|
||
icon: String,
|
||
iconColor: Color,
|
||
title: LocalizedStringKey,
|
||
route: SettingsRoute,
|
||
badgeValue: String? = nil) -> some View
|
||
{
|
||
NavigationLink(value: route) {
|
||
Label {
|
||
Text(title)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
} icon: {
|
||
SettingsIcon(systemName: icon, color: iconColor)
|
||
}
|
||
}
|
||
.badge(badgeValue.map { Text($0).font(OpenClawType.captionSemiBold) })
|
||
}
|
||
|
||
@ViewBuilder
|
||
func destination(for route: SettingsRoute) -> some View {
|
||
switch route {
|
||
case .channels:
|
||
SettingsChannelsDestination()
|
||
.navigationTitle(title(for: route))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
case .skills:
|
||
SettingsSkillsDestination()
|
||
.navigationTitle(title(for: route))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
default:
|
||
List {
|
||
switch route {
|
||
case .gateway:
|
||
self.gatewayDestination
|
||
case .appleWatch:
|
||
self.appleWatchDestination
|
||
case .approvals:
|
||
self.approvalsDestination
|
||
case .permissions:
|
||
self.permissionsDestination
|
||
case .skills:
|
||
EmptyView()
|
||
case .voice:
|
||
self.voiceDestination
|
||
case .diagnostics:
|
||
self.diagnosticsDestination
|
||
case .privacy:
|
||
self.privacyDestination
|
||
case .notifications:
|
||
self.notificationsDestination
|
||
case .about:
|
||
self.aboutDestination
|
||
case .licenses:
|
||
self.licensesDestination
|
||
case .channels:
|
||
EmptyView()
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
.navigationTitle(title(for: route))
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task(id: route) {
|
||
guard route == .appleWatch else { return }
|
||
await self.appModel.refreshWatchMessagingStatus()
|
||
}
|
||
.toolbar {
|
||
if let headerLeadingAction {
|
||
ToolbarItem(placement: .topBarLeading) {
|
||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||
}
|
||
}
|
||
ToolbarItem(placement: .principal) {
|
||
Text(title(for: route))
|
||
.font(OpenClawType.headline)
|
||
.foregroundStyle(.primary)
|
||
}
|
||
if route == .gateway {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
self.openGatewayQRScanner()
|
||
} label: {
|
||
Image(systemName: "qrcode.viewfinder")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
}
|
||
.disabled(self.connectingGateway != nil)
|
||
.accessibilityLabel("Scan QR")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Ordered by intent: connection state, then pairing (the first-run action),
|
||
/// then facts/preferences; manual entry and credentials are plumbing at the end.
|
||
var gatewayDestination: some View {
|
||
Group {
|
||
self.gatewayStatusCard
|
||
|
||
Section {
|
||
Button {
|
||
Task { await self.reconnectGateway() }
|
||
} label: {
|
||
Label("Reconnect", systemImage: "arrow.triangle.2.circlepath")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.isReconnectingGateway || self.appModel.isAppleReviewDemoModeEnabled)
|
||
Button {
|
||
Task { await self.runDiagnostics() }
|
||
} label: {
|
||
Label("Diagnose", systemImage: "cross.case")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.isRefreshingGateway)
|
||
}
|
||
|
||
self.gatewaySetupCard
|
||
self.pairedGatewaysCard
|
||
|
||
self.detailListCard {
|
||
SettingsDetailRow("Address", value: .verbatim(self.gatewayAddress))
|
||
SettingsDetailRow("Server", value: .verbatim(self.gatewayServer))
|
||
SettingsDetailRow(
|
||
"Discovered",
|
||
value: .verbatim(self.gatewayController.gateways.count.formatted()))
|
||
SettingsDetailRow(
|
||
"Default Agent",
|
||
value: .verbatim(self.appModel.activeAgentName))
|
||
SettingsDetailRow(
|
||
"Agents",
|
||
value: .verbatim(self.appModel.gatewayAgents.count.formatted()))
|
||
SettingsDetailRow(
|
||
"Access",
|
||
value: .verbatim(
|
||
self.appModel.isOperatorGatewayConnected
|
||
? (self.appModel.hasOperatorAdminScope ? "Full" : "Limited")
|
||
: "Not available"))
|
||
}
|
||
|
||
if self.appModel.isOperatorGatewayConnected,
|
||
!self.appModel.hasOperatorAdminScope
|
||
{
|
||
Section("Upgrade access") {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("This phone has limited Gateway access.")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text(
|
||
"Use a secure wss:// or Tailscale Serve Gateway, then scan a full-access setup code from the Control UI or openclaw qr and reconnect to enable settings and upgrades.") // swiftlint:disable:this line_length
|
||
.font(OpenClawType.caption) // Keep the native localization key contiguous.
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
Button {
|
||
self.openGatewayQRScanner()
|
||
} label: {
|
||
Label("Scan Full-Access Code", systemImage: "qrcode.viewfinder")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
}
|
||
|
||
self.agentSelectionCard
|
||
self.deviceIdentityCard
|
||
self.manualGatewayCard
|
||
self.gatewayAdvancedCard
|
||
}
|
||
.font(OpenClawType.body)
|
||
}
|
||
|
||
private var gatewayStatusCard: some View {
|
||
// Hero pairing action honors the same connect lock as the other scanner
|
||
// entry points; an in-flight attempt must not race a second scan.
|
||
let showScanHero = self.gatewayNeedsPairing && self.connectingGateway == nil
|
||
// Unapplied `self.openGatewayQRScanner` in a ternary crashes the Swift 6
|
||
// type checker ("failed to produce diagnostic"); build the optional closure imperatively.
|
||
var scanAction: (() -> Void)?
|
||
if showScanHero {
|
||
scanAction = { self.openGatewayQRScanner() }
|
||
}
|
||
return self.detailStatusCard(
|
||
icon: "antenna.radiowaves.left.and.right",
|
||
title: "Gateway",
|
||
detail: .verbatim(self.gatewayStatusDetail),
|
||
value: .verbatim(self.gatewayStatusValue),
|
||
color: self.gatewayStatusColor,
|
||
actionTitle: showScanHero ? "Scan QR to Pair" : nil,
|
||
actionSystemImage: "qrcode.viewfinder",
|
||
action: scanAction)
|
||
}
|
||
|
||
var gatewayQuickSwitchMenu: some View {
|
||
Menu {
|
||
ForEach(self.gatewayRegistry.entries) { entry in
|
||
Button {
|
||
Task { await self.switchGateway(to: entry) }
|
||
} label: {
|
||
Label {
|
||
Text(entry.name)
|
||
.font(OpenClawType.body)
|
||
} icon: {
|
||
Image(systemName: GatewayStableIdentifier.matches(
|
||
entry.stableID,
|
||
self.gatewayRegistry.activeStableID)
|
||
? "checkmark.circle.fill"
|
||
: "circle")
|
||
}
|
||
}
|
||
.disabled(
|
||
GatewayStableIdentifier.matches(entry.stableID, self.gatewayRegistry.activeStableID) ||
|
||
self.connectingGateway != nil)
|
||
}
|
||
} label: {
|
||
Image(systemName: "arrow.triangle.2.circlepath")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(OpenClawBrand.accent)
|
||
}
|
||
.accessibilityLabel("Switch Gateway")
|
||
}
|
||
|
||
var approvalsDestination: some View {
|
||
Group {
|
||
self.detailStatusCard(
|
||
icon: "checkmark.shield.fill",
|
||
title: "Approvals",
|
||
detail: .verbatim(self.notificationsNeedAttention
|
||
? String(localized: "Out-of-app approval alerts need notification permission.")
|
||
: (self.pendingApprovalCount == 0
|
||
? String(localized: "No gateway actions are waiting for review.")
|
||
: String(localized: "Review pending gateway actions."))),
|
||
value: self.notificationsNeedAttention
|
||
? .verbatim(String(localized: "Alerts Off"))
|
||
: (self.pendingApprovalCount == 0
|
||
? .verbatim(String(localized: "clear"))
|
||
: .verbatim(self.approvalWaitingText)),
|
||
color: self.notificationsNeedAttention ? OpenClawBrand.warn :
|
||
(self.pendingApprovalCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn))
|
||
|
||
if self.notificationsNeedAttention {
|
||
self.approvalNotificationsWarningCard
|
||
}
|
||
|
||
self.approvalsReviewCard
|
||
}
|
||
}
|
||
|
||
var appleWatchDestination: some View {
|
||
Group {
|
||
let watchStatus = self.appModel.watchMessagingStatus
|
||
self.detailStatusCard(
|
||
icon: "applewatch",
|
||
title: "Apple Watch",
|
||
detail: .verbatim(watchStatus.appInstalled
|
||
? String(
|
||
localized: "Relay remains available; direct mode adds an independent Gateway node.")
|
||
: String(
|
||
localized: "Install the OpenClaw watch app before enabling direct mode.")),
|
||
value: .verbatim(
|
||
watchStatus.reachable
|
||
? String(localized: "Reachable")
|
||
: (watchStatus.appInstalled
|
||
? String(localized: "Installed")
|
||
: String(localized: "Unavailable"))),
|
||
color: watchStatus.appInstalled ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||
|
||
Section {
|
||
Button {
|
||
Task { await self.sendDirectWatchSetup() }
|
||
} label: {
|
||
Label("Enable Direct Gateway Connection", systemImage: "point.3.connected.trianglepath.dotted")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(
|
||
self.isSendingWatchDirectSetup
|
||
|| !self.appModel.isOperatorGatewayConnected
|
||
|| !self.appModel.hasOperatorAdminScope
|
||
|| !watchStatus.appInstalled)
|
||
|
||
if let statusText = self.watchDirectSetupStatusText {
|
||
Text(statusText)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
} footer: {
|
||
Text(
|
||
"""
|
||
The watch receives a one-time pairing code and stores its own device token. \
|
||
A reachable secure Gateway URL is required away from the iPhone.
|
||
""")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
|
||
Section("Direct node features") {
|
||
SettingsDetailRow("Device", value: "Info and status")
|
||
SettingsDetailRow("Notifications", value: "While app is active")
|
||
}
|
||
}
|
||
}
|
||
|
||
var approvalNotificationsWarningCard: some View {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Notifications are off")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text("Enable Notifications to receive approval alerts while OpenClaw is not open.")
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
if self.directRoute == nil {
|
||
Button {
|
||
self.openNotificationsRouteFromApprovals()
|
||
} label: {
|
||
Label("Open Notifications", systemImage: "bell.badge")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
var approvalsReviewCard: some View {
|
||
if !self.appModel.pendingExecApprovalInboxItems.isEmpty {
|
||
Section("Pending approvals") {
|
||
ForEach(self.appModel.pendingExecApprovalInboxItems) { item in
|
||
Button {
|
||
self.appModel.presentPendingExecApprovalFromInbox(item.id)
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(item.prompt.commandPreview ?? item.prompt.commandText)
|
||
.font(OpenClawType.body)
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(2)
|
||
Text(item.prompt.gatewayStableID)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.accessibilityLabel("Review exec approval")
|
||
.accessibilityValue(item.prompt.commandPreview ?? item.prompt.commandText)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let pendingApproval {
|
||
Section("Reviewing") {
|
||
ForEach(self.approvalItems, id: \.id) { item in
|
||
SettingsApprovalRow(item: item)
|
||
}
|
||
if let warningText = pendingApproval.warningText {
|
||
Label {
|
||
Text(warningText)
|
||
.font(OpenClawType.caption)
|
||
} icon: {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
}
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
if let errorText = self.appModel.pendingExecApprovalPromptErrorText {
|
||
Text(errorText)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(OpenClawBrand.danger)
|
||
}
|
||
if let resolvedText = self.appModel.pendingExecApprovalPromptResolvedText {
|
||
Text(resolvedText)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(self.approvalOutcomeColor)
|
||
Button {
|
||
self.appModel.dismissPendingExecApprovalPrompt()
|
||
} label: {
|
||
Label("Dismiss", systemImage: "xmark")
|
||
.font(OpenClawType.body)
|
||
}
|
||
} else {
|
||
if pendingApproval.allowsAllowOnce {
|
||
Button {
|
||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
|
||
} label: {
|
||
Label("Allow Once", systemImage: "checkmark")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||
}
|
||
if pendingApproval.allowsAllowAlways {
|
||
Button {
|
||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always") }
|
||
} label: {
|
||
Label("Allow Always", systemImage: "checkmark.shield")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||
}
|
||
if pendingApproval.allowsDeny {
|
||
Button(role: .destructive) {
|
||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
|
||
} label: {
|
||
Label("Deny", systemImage: "xmark")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||
}
|
||
if self.appModel.pendingExecApprovalPromptResolving,
|
||
self.appModel.pendingExecApprovalPromptCanDismiss
|
||
{
|
||
Button(role: .cancel) {
|
||
self.appModel.dismissPendingExecApprovalPrompt()
|
||
} label: {
|
||
Label("Dismiss", systemImage: "xmark")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if self.pendingApprovalCount == 0 {
|
||
Section {
|
||
Label {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("No approvals waiting")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text(self.approvalEmptyDetail)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
} icon: {
|
||
Image(systemName: "checkmark.shield.fill")
|
||
.foregroundStyle(OpenClawBrand.ok)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var approvalOutcomeColor: Color {
|
||
switch self.appModel.pendingExecApprovalPromptOutcome?.tone {
|
||
case .success:
|
||
OpenClawBrand.ok
|
||
case .danger:
|
||
OpenClawBrand.danger
|
||
case .warning:
|
||
OpenClawBrand.warn
|
||
case .neutral, nil:
|
||
.secondary
|
||
}
|
||
}
|
||
|
||
var permissionsDestination: some View {
|
||
Group {
|
||
self.toggleCard(
|
||
title: "Camera",
|
||
isOn: self.$cameraEnabled)
|
||
|
||
self.locationModeCard
|
||
|
||
self.toggleCard(
|
||
title: "Keep Awake",
|
||
isOn: self.$preventSleep)
|
||
|
||
self.privacyAccessCard
|
||
}
|
||
}
|
||
|
||
var voiceDestination: some View {
|
||
Group {
|
||
self.detailStatusCard(
|
||
icon: "waveform",
|
||
title: "Voice & Talk",
|
||
detail: .verbatim(self.appModel.talkMode.gatewayTalkVoiceModeTitle),
|
||
value: .verbatim(self.voiceDetail),
|
||
color: self.talkEnabled || self.voiceWakeEnabled ? OpenClawBrand.accent : .secondary)
|
||
|
||
self.voiceFeatureCard
|
||
self.talkVoiceSettingsCard
|
||
self.shareSettingsCard
|
||
}
|
||
}
|
||
|
||
var diagnosticsDestination: some View {
|
||
Group {
|
||
self.detailStatusCard(
|
||
icon: "checklist.checked",
|
||
title: "Health Check",
|
||
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
|
||
value: .verbatim(self.diagnosticsHealthValue),
|
||
color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||
|
||
Section {
|
||
Button {
|
||
Task { await self.runDiagnostics() }
|
||
} label: {
|
||
Label("Run Diagnostics", systemImage: "cross.case")
|
||
.font(OpenClawType.body)
|
||
}
|
||
.disabled(self.isRefreshingGateway)
|
||
}
|
||
|
||
self.diagnosticChecksCard
|
||
|
||
self.detailListCard {
|
||
SettingsDetailRow("Device", value: .verbatim(DeviceInfoHelper.deviceFamily()))
|
||
SettingsDetailRow(
|
||
"Platform",
|
||
value: .verbatim(DeviceInfoHelper.platformStringForDisplay()))
|
||
SettingsDetailRow(
|
||
"App",
|
||
value: .verbatim(DeviceInfoHelper.openClawVersionString()))
|
||
SettingsDetailRow("Model", value: .verbatim(DeviceInfoHelper.modelIdentifier()))
|
||
}
|
||
|
||
self.diagnosticsAdvancedCard
|
||
}
|
||
}
|
||
|
||
var privacyDestination: some View {
|
||
Group {
|
||
self.notificationsSection
|
||
|
||
self.toggleCard(
|
||
title: "Camera Access",
|
||
isOn: self.$cameraEnabled)
|
||
|
||
self.locationModeCard
|
||
|
||
self.toggleCard(
|
||
title: "Background Listening",
|
||
isOn: self.$talkBackgroundEnabled)
|
||
|
||
self.privacyAccessCard
|
||
}
|
||
}
|
||
|
||
var notificationsDestination: some View {
|
||
self.notificationsSection
|
||
}
|
||
|
||
var notificationsSection: some View {
|
||
Section("Notifications") {
|
||
HStack(spacing: 12) {
|
||
SettingsIcon(systemName: "bell.fill", color: self.notificationStatusColor)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Notifications")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text(self.notificationStatusDetail)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
Spacer(minLength: 8)
|
||
Toggle(isOn: self.notificationToggleBinding) {
|
||
Text("Notifications")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
}
|
||
.labelsHidden()
|
||
.disabled(self.notificationStatus == .checking || self.isRequestingNotificationAuthorization)
|
||
.accessibilityIdentifier("settings-notifications-toggle")
|
||
.accessibilityValue(self.notificationServingActive
|
||
? String(localized: "On")
|
||
: String(localized: "Off"))
|
||
.accessibilityHint("Turns OpenClaw notification delivery on or off")
|
||
}
|
||
|
||
HStack(alignment: .top, spacing: 10) {
|
||
Image(systemName: "network")
|
||
.font(OpenClawType.captionSemiBold)
|
||
.foregroundStyle(OpenClawBrand.accent)
|
||
.frame(width: 22, height: 22)
|
||
Text(self.notificationRelayDetail)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
.accessibilityIdentifier("settings-privacy-notifications-section")
|
||
}
|
||
|
||
var gatewayActions: some View {
|
||
Group {
|
||
self.gatewayActionButton(
|
||
title: "Reconnect",
|
||
icon: "arrow.triangle.2.circlepath",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: self.isReconnectingGateway,
|
||
isDisabled: self.appModel.isAppleReviewDemoModeEnabled)
|
||
{
|
||
Task { await self.reconnectGateway() }
|
||
}
|
||
|
||
self.gatewayActionButton(
|
||
title: "Diagnose",
|
||
icon: "cross.case",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: self.isRefreshingGateway)
|
||
{
|
||
Task { await self.runDiagnostics() }
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder var licensesDestination: some View {
|
||
let documents = LicenseDocumentLoader.bundledDocuments()
|
||
if documents.isEmpty {
|
||
ContentUnavailableView(
|
||
"No Licenses Bundled",
|
||
systemImage: "doc.text",
|
||
description: Text("License files are not available in this build."))
|
||
.font(OpenClawType.body)
|
||
} else {
|
||
Section {
|
||
ForEach(documents) { document in
|
||
NavigationLink {
|
||
LicenseDocumentDetailView(document: document)
|
||
} label: {
|
||
Label {
|
||
Text(document.title)
|
||
.font(OpenClawType.subhead)
|
||
} icon: {
|
||
SettingsIcon(systemName: "doc.text", color: .gray)
|
||
}
|
||
}
|
||
}
|
||
} footer: {
|
||
Text("OpenClaw appreciates its partners in the open-source community.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
.accessibilityIdentifier("settings-licenses-list")
|
||
}
|
||
}
|
||
|
||
/// Native inset-grouped action row (plain tinted text, no pill chrome).
|
||
func gatewayActionButton(
|
||
title: LocalizedStringKey,
|
||
icon: String,
|
||
color: Color,
|
||
isBusy: Bool,
|
||
isDisabled: Bool = false,
|
||
action: @escaping () -> Void) -> some View
|
||
{
|
||
Button(action: action) {
|
||
HStack {
|
||
Label(title, systemImage: icon)
|
||
.font(OpenClawType.body)
|
||
Spacer()
|
||
if isBusy {
|
||
ProgressView().controlSize(.small)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(color)
|
||
.disabled(isBusy || isDisabled)
|
||
.accessibilityLabel(Text(title))
|
||
}
|
||
|
||
var aboutDestination: some View {
|
||
Group {
|
||
Section {
|
||
VStack(spacing: 12) {
|
||
OpenClawProMark(size: 96, shadowRadius: 18, interactive: true)
|
||
.accessibilityHidden(true)
|
||
VStack(spacing: 2) {
|
||
Text("OpenClaw")
|
||
.font(OpenClawType.title2SemiBold)
|
||
Text("Personal AI on your devices")
|
||
.font(OpenClawType.footnote)
|
||
.foregroundStyle(.secondary)
|
||
SettingsBuildMetadataStrip(metadata: DeviceInfoHelper.buildMetadata())
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.top, 4)
|
||
.accessibilityElement(children: .contain)
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
|
||
// Concise public details only; deep hardware identifiers live in Diagnostics.
|
||
detailListCard {
|
||
SettingsDetailRow("Device", value: .verbatim(DeviceInfoHelper.deviceFamily()))
|
||
SettingsDetailRow(
|
||
"iOS",
|
||
value: .verbatim(DeviceInfoHelper.iOSVersionStringForDisplay()))
|
||
}
|
||
|
||
Section {
|
||
self.aboutLinkRow(
|
||
title: "Website",
|
||
icon: "globe",
|
||
color: .blue,
|
||
url: URL(string: "https://openclaw.ai")!)
|
||
self.aboutLinkRow(
|
||
title: "Docs",
|
||
icon: "book.fill",
|
||
color: .orange,
|
||
url: URL(string: "https://docs.openclaw.ai")!)
|
||
self.aboutLinkRow(
|
||
title: "GitHub",
|
||
icon: "chevron.left.slash.chevron.right",
|
||
color: .gray,
|
||
url: URL(string: "https://github.com/openclaw/openclaw")!)
|
||
self.aboutLinkRow(
|
||
title: "Discord",
|
||
icon: "bubble.left.and.bubble.right.fill",
|
||
color: .indigo,
|
||
url: URL(string: "https://discord.gg/clawd")!)
|
||
} footer: {
|
||
Text("© 2026 OpenClaw Foundation — MIT License.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// About link row with explicit branded label; shorthand `Link("Title", ...)`
|
||
/// would bypass the typography audit and OpenClawType styling.
|
||
func aboutLinkRow(
|
||
title: LocalizedStringKey,
|
||
icon: String,
|
||
color: Color,
|
||
url: URL) -> some View
|
||
{
|
||
Link(destination: url) {
|
||
HStack {
|
||
Label {
|
||
Text(title)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(.primary)
|
||
} icon: {
|
||
SettingsIcon(systemName: icon, color: color)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "arrow.up.right")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.accessibilityLabel(Text(title))
|
||
}
|
||
|
||
func toggleCard(title: LocalizedStringKey, isOn: Binding<Bool>) -> some View {
|
||
Section {
|
||
self.settingsToggle(title, isOn: isOn)
|
||
}
|
||
}
|
||
|
||
var locationModeCard: some View {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Button {
|
||
self.handleLocationSharingTap()
|
||
} label: {
|
||
HStack {
|
||
Text("Location")
|
||
.font(OpenClawType.body)
|
||
.foregroundStyle(.primary)
|
||
Spacer(minLength: 8)
|
||
ZStack {
|
||
OpenClawToggleIndicator(isOn: self.locationSettingsPresentation.sharingControlIsOn)
|
||
.opacity(self.isChangingLocationMode ? 0 : 1)
|
||
if self.isChangingLocationMode {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
}
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(self.isChangingLocationMode)
|
||
.accessibilityIdentifier("settings-location-sharing-toggle")
|
||
.accessibilityLabel("Location Sharing")
|
||
.accessibilityValue(self.locationSettingsPresentation.sharingControlIsOn
|
||
? String(localized: "On")
|
||
: String(localized: "Off"))
|
||
|
||
if self.locationSettingsPresentation.showsAccessLevel,
|
||
let accessLevelText = self.locationSettingsPresentation.accessLevelText
|
||
{
|
||
Divider()
|
||
Button {
|
||
self.showLocationAccessDialog = true
|
||
} label: {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text("Access Level")
|
||
.font(OpenClawType.body)
|
||
.foregroundStyle(.primary)
|
||
Spacer(minLength: 8)
|
||
Text(accessLevelText)
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.trailing)
|
||
.lineLimit(2)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(self.isChangingLocationMode)
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityIdentifier("settings-location-access-level")
|
||
.accessibilityLabel("Access Level")
|
||
.accessibilityValue(accessLevelText)
|
||
.accessibilityHint("Chooses While Using the App or Always")
|
||
}
|
||
|
||
if let locationPermissionDetailText {
|
||
Text(locationPermissionDetailText)
|
||
.font(OpenClawType.caption2)
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
}
|
||
|
||
if let locationPermissionWarningText {
|
||
Text(locationPermissionWarningText)
|
||
.font(OpenClawType.caption2)
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var agentSelectionCard: some View {
|
||
Section {
|
||
Picker("Default Agent", selection: self.$selectedAgentPickerId) {
|
||
Text("Default").font(OpenClawType.body).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).font(OpenClawType.body).tag(agent.id)
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
} footer: {
|
||
Text("Used for new Chat and Talk sessions.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
|
||
/// One section owns the whole pairing story: scan, paste, and discovered
|
||
/// gateways; splitting these across the page hid Scan QR below plumbing.
|
||
var gatewaySetupCard: some View {
|
||
Section {
|
||
self.gatewayActionButton(
|
||
title: "Scan QR",
|
||
icon: "qrcode.viewfinder",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: false,
|
||
isDisabled: self.connectingGateway != nil)
|
||
{
|
||
self.openGatewayQRScanner()
|
||
}
|
||
TextField("Paste setup code", text: self.$setupCode)
|
||
.font(OpenClawType.body)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.disabled(self.connectingGateway != nil)
|
||
self.gatewayActionButton(
|
||
title: "Connect",
|
||
icon: "bolt.horizontal.circle",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: self.setupAttemptID != nil,
|
||
isDisabled: !self.canApplyGatewaySetup || self.connectingGateway != nil)
|
||
{
|
||
Task { await self.applySetupCodeAndConnect() }
|
||
}
|
||
if self.gatewayController.gateways.isEmpty {
|
||
Text("No gateways found yet. Use manual setup if Bonjour is blocked.")
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(self.gatewayController.gateways) { gateway in
|
||
self.discoveredGatewayRow(gateway)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Add Gateway")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
} footer: {
|
||
if let warning = self.tailnetWarningText {
|
||
Text(warning).font(OpenClawType.footnote).foregroundStyle(OpenClawBrand.warn)
|
||
} else if let status = self.setupStatusLine {
|
||
Text(status)
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
}
|
||
|
||
var pairedGatewaysCard: some View {
|
||
Section {
|
||
if self.gatewayRegistry.entries.isEmpty {
|
||
Text("Pair a gateway to make it available here.")
|
||
.font(OpenClawType.subhead)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
ForEach(self.gatewayRegistry.entries) { entry in
|
||
self.pairedGatewayRow(entry)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Paired Gateways")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
} footer: {
|
||
Text("Switch gateways without pairing again.")
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
|
||
func pairedGatewayRow(_ entry: GatewaySettingsStore.GatewayRegistryEntry) -> some View {
|
||
let isActive = GatewayStableIdentifier.matches(
|
||
entry.stableID,
|
||
self.gatewayRegistry.activeStableID)
|
||
return Button {
|
||
guard !isActive else { return }
|
||
Task { await self.switchGateway(to: entry) }
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(entry.name)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(.primary)
|
||
Text(self.gatewayEndpointSummary(entry))
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer(minLength: 8)
|
||
if self.connectingGateway == .gateway(entry.id) {
|
||
ProgressView()
|
||
.controlSize(.small)
|
||
} else if isActive {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(OpenClawBrand.accent)
|
||
.accessibilityLabel("Active Gateway")
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(self.connectingGateway != nil)
|
||
.swipeActions {
|
||
Button(role: .destructive) {
|
||
self.pendingForgetGateway = entry
|
||
} label: {
|
||
Label {
|
||
Text("Forget")
|
||
.font(OpenClawType.captionSemiBold)
|
||
} icon: {
|
||
Image(systemName: "trash")
|
||
}
|
||
}
|
||
}
|
||
.contextMenu {
|
||
Button(role: .destructive) {
|
||
self.pendingForgetGateway = entry
|
||
} label: {
|
||
Label {
|
||
Text("Forget Gateway")
|
||
.font(OpenClawType.body)
|
||
} icon: {
|
||
Image(systemName: "trash")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func discoveredGatewayRow(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> some View {
|
||
let availability = self.gatewayController.discoveredGatewayConnectionAvailability(gateway)
|
||
return VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(verbatim: gateway.name)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: " • "))
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(2)
|
||
}
|
||
Spacer(minLength: 8)
|
||
if availability.canConnect {
|
||
Button {
|
||
Task { await self.connect(gateway) }
|
||
} label: {
|
||
if self.connectingGateway == .gateway(gateway.id) {
|
||
ProgressView().controlSize(.small)
|
||
} else {
|
||
Text(availability.actionTitle)
|
||
.font(OpenClawType.captionSemiBold)
|
||
}
|
||
}
|
||
.font(OpenClawType.captionSemiBold)
|
||
.buttonStyle(.bordered)
|
||
.disabled(self.connectingGateway != nil)
|
||
} else {
|
||
Text(availability.actionTitle)
|
||
.font(OpenClawType.captionSemiBold)
|
||
.foregroundStyle(OpenClawBrand.warn)
|
||
}
|
||
}
|
||
|
||
if let guidanceText = availability.guidanceText {
|
||
Text(guidanceText)
|
||
.font(OpenClawType.caption)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
var manualGatewayCard: some View {
|
||
Section("Manual Gateway") {
|
||
self.settingsToggle("Use Manual Gateway", isOn: self.manualGatewayEnabledBinding)
|
||
TextField("Host", text: self.manualHostBinding)
|
||
.font(OpenClawType.body)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
TextField("Port", text: self.manualPortBinding)
|
||
.font(OpenClawType.body)
|
||
.keyboardType(.numberPad)
|
||
Picker(selection: self.manualGatewayTLSBinding) {
|
||
Text("Unencrypted")
|
||
.font(OpenClawType.captionSemiBold)
|
||
.tag(false)
|
||
Text("Secure (TLS)")
|
||
.font(OpenClawType.captionSemiBold)
|
||
.tag(true)
|
||
} label: {
|
||
Text("Connection security")
|
||
.font(OpenClawType.captionSemiBold)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.disabled(self.manualGatewayTransport.requiresTLS)
|
||
if let helperText = self.manualGatewayTransport.helperText {
|
||
Text(helperText)
|
||
.font(OpenClawType.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
self.gatewayActionButton(
|
||
title: "Connect Manual",
|
||
icon: "network",
|
||
color: OpenClawBrand.accent,
|
||
isBusy: self.connectingGateway == .manual,
|
||
isDisabled: self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
|| !self.manualPortIsValid)
|
||
{
|
||
Task { await self.connectManual() }
|
||
}
|
||
}
|
||
.disabled(self.setupAttemptID != nil)
|
||
}
|
||
|
||
private var manualGatewayTransport: GatewayManualTransportPresentation {
|
||
GatewayConnectionController.manualTransportPresentation(
|
||
host: self.manualGatewayHost,
|
||
requestedTLS: self.manualGatewayTLS)
|
||
}
|
||
|
||
private var manualGatewayTLSBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { self.manualGatewayTransport.effectiveTLS },
|
||
set: { enabled in
|
||
guard !self.manualGatewayTransport.requiresTLS else { return }
|
||
self.manualGatewayTLS = enabled
|
||
})
|
||
}
|
||
|
||
var gatewayAdvancedCard: some View {
|
||
Section {
|
||
self.settingsToggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||
self.gatewaySecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
|
||
self.gatewaySecureField("Gateway Password", text: self.gatewayPasswordBinding)
|
||
if let headersStableID = self.gatewayCustomHeadersTargetStableID {
|
||
NavigationLink {
|
||
GatewayCustomHeadersSettingsView(gatewayStableID: headersStableID)
|
||
} label: {
|
||
Text("Custom Headers")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
Button(role: .destructive) {
|
||
self.showResetOnboardingAlert = true
|
||
} label: {
|
||
Label("Reset Onboarding", systemImage: "arrow.counterclockwise")
|
||
.font(OpenClawType.body)
|
||
}
|
||
}
|
||
}
|
||
|
||
func gatewaySecureField(
|
||
_ placeholder: LocalizedStringKey,
|
||
text: Binding<String>) -> some View
|
||
{
|
||
ZStack(alignment: .leading) {
|
||
SecureField("", text: text)
|
||
.font(OpenClawType.subhead)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.accessibilityLabel(Text(placeholder))
|
||
if text.wrappedValue.isEmpty {
|
||
Text(placeholder)
|
||
.font(OpenClawType.subheadSemiBold)
|
||
.foregroundStyle(.tertiary)
|
||
.padding(.horizontal, 8)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
.font(OpenClawType.subhead)
|
||
}
|
||
|
||
var voiceFeatureCard: some View {
|
||
Section {
|
||
self.settingsToggle("Voice Wake", isOn: self.$voiceWakeEnabled) { enabled in
|
||
self.appModel.setVoiceWakeEnabled(enabled)
|
||
}
|
||
self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in
|
||
guard !self.appModel.isAppleReviewDemoModeEnabled else {
|
||
self.talkEnabled = false
|
||
return
|
||
}
|
||
self.appModel.setTalkEnabled(enabled)
|
||
}
|
||
.disabled(self.appModel.isAppleReviewDemoModeEnabled)
|
||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||
Text(option.label).font(OpenClawType.body).tag(option.id)
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
self.settingsToggle("Background Listening", isOn: self.$talkBackgroundEnabled)
|
||
self.settingsToggle("Speakerphone", isOn: self.talkSpeakerphoneBinding)
|
||
NavigationLink {
|
||
VoiceWakeWordsSettingsView()
|
||
} label: {
|
||
SettingsDetailRow(
|
||
"Wake Words",
|
||
value: .verbatim(
|
||
VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords)))
|
||
}
|
||
}
|
||
}
|
||
|
||
var talkVoiceSettingsCard: some View {
|
||
Group {
|
||
if self.gatewayConnected,
|
||
let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
|
||
{
|
||
Section {
|
||
TalkRuntimeIssueBanner(
|
||
issue: issue,
|
||
onOpenSettings: nil,
|
||
onShowDetails: {
|
||
self.showTalkIssueDetails = true
|
||
})
|
||
}
|
||
}
|
||
Section("Voice") {
|
||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||
Text(option.label).font(OpenClawType.body).tag(option.rawValue)
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
if self.shouldShowRealtimeVoicePicker {
|
||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||
Text("Gateway Default").font(OpenClawType.body).tag("")
|
||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).font(OpenClawType.body).tag(voice)
|
||
}
|
||
}
|
||
.font(OpenClawType.body)
|
||
}
|
||
SettingsDetailRow(
|
||
"Voice Mode",
|
||
value: .localized(self.appModel.talkMode.gatewayTalkVoiceModeTitle))
|
||
SettingsDetailRow(
|
||
"Active Voice",
|
||
value: .verbatim(self.gatewayTalkActiveVoiceDetail))
|
||
if let issue = self.gatewayTalkLastIssueDetail {
|
||
SettingsDetailRow("Last Voice Issue", value: .verbatim(issue))
|
||
}
|
||
SettingsDetailRow(
|
||
"Transport",
|
||
value: .localized(self.appModel.talkMode.gatewayTalkTransportLabel))
|
||
SettingsDetailRow("API Key", value: .verbatim(self.talkApiKeyStatus))
|
||
}
|
||
}
|
||
}
|
||
|
||
var shareSettingsCard: some View {
|
||
Section {
|
||
self.settingsToggle("Show Talk Control", isOn: self.$talkButtonEnabled)
|
||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||
.font(OpenClawType.body)
|
||
.lineLimit(2...5)
|
||
.textInputAutocapitalization(.sentences)
|
||
Button {
|
||
Task { await self.appModel.runSharePipelineSelfTest() }
|
||
} label: {
|
||
Label("Run Share Self-Test", systemImage: "checkmark.seal")
|
||
.font(OpenClawType.body)
|
||
}
|
||
} footer: {
|
||
Text(self.appModel.lastShareEventText)
|
||
.font(OpenClawType.footnote)
|
||
}
|
||
}
|
||
|
||
var privacyAccessCard: some View {
|
||
Section {
|
||
PrivacyAccessSectionView()
|
||
}
|
||
}
|
||
|
||
var diagnosticsAdvancedCard: some View {
|
||
Section {
|
||
self.settingsToggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) { enabled in
|
||
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
|
||
}
|
||
self.settingsToggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
|
||
NavigationLink {
|
||
GatewayDiscoveryDebugLogView()
|
||
} label: {
|
||
SettingsDetailRow(
|
||
"Discovery Logs",
|
||
value: .verbatim(self.gatewayController.discoveryStatusText))
|
||
}
|
||
}
|
||
}
|
||
|
||
var deviceIdentityCard: some View {
|
||
Section("Device") {
|
||
TextField("Device Name", text: self.$displayName)
|
||
.font(OpenClawType.body)
|
||
SettingsDetailRow("Instance ID", value: .verbatim(self.instanceId))
|
||
}
|
||
}
|
||
|
||
func settingsToggle(
|
||
_ title: LocalizedStringKey,
|
||
isOn: Binding<Bool>,
|
||
onChange: ((Bool) -> Void)? = nil) -> some View
|
||
{
|
||
// Native Toggle rows can ignore visible-row taps on iOS 26; reuse the shared indicator row.
|
||
Button {
|
||
isOn.wrappedValue.toggle()
|
||
} label: {
|
||
HStack {
|
||
Text(title)
|
||
.font(OpenClawType.body)
|
||
Spacer(minLength: 8)
|
||
OpenClawToggleIndicator(isOn: isOn.wrappedValue)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(Text(title))
|
||
.accessibilityValue(isOn.wrappedValue
|
||
? String(localized: "On")
|
||
: String(localized: "Off"))
|
||
.onChange(of: isOn.wrappedValue) { _, enabled in
|
||
onChange?(enabled)
|
||
}
|
||
}
|
||
}
|