Files
openclaw/apps/ios/Sources/Design/SettingsProTabSupport.swift
Peter Steinberger b57e7b74e6 feat: add native Skills management parity (#105814)
* feat(apps): add native skills management parity

* chore(i18n): refresh native skills inventory

* style(apps): apply canonical Swift formatting

* style(ios): wrap skills status message

* chore(i18n): refresh native skill locales

* refactor(apps): trim native skills review surface

* chore(i18n): regenerate native app resources

* chore: keep release changelog owner-only

* fix(ios): remove dead gateway and watch wrappers
2026-07-13 00:11:11 -07:00

606 lines
20 KiB
Swift

import Darwin
import OpenClawKit
import SwiftUI
import UIKit
import UserNotifications
enum SettingsRoute: Hashable {
case gateway
case appleWatch
case approvals
case permissions
case channels
case skills
case voice
case diagnostics
case privacy
case notifications
case licenses
case about
}
enum SettingsLayout {
static let cardRadius: CGFloat = OpenClawProMetric.cardRadius
static let rowHeight: CGFloat = 58
}
/// Canonical label/value list row for Settings and Talk surfaces. Keep every
/// detail row on this view so row typography cannot drift between sections;
/// plain `LabeledContent(String, value:)` renders unbranded system fonts.
struct SettingsDetailRow: View {
let label: LocalizedStringKey
let value: OpenClawTextValue
init(_ label: LocalizedStringKey, value: OpenClawTextValue) {
self.label = label
self.value = value
}
var body: some View {
LabeledContent {
self.value.text
.font(OpenClawType.subhead)
.lineLimit(1)
.truncationMode(.middle)
} label: {
Text(self.label)
.font(OpenClawType.body)
}
}
}
struct SettingsBuildMetadataStrip: View {
let metadata: ArtifactBuildInfo
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.layoutDirection) private var layoutDirection
private struct Field: Identifiable {
enum ID: String {
case version
case commit
case built
}
let id: ID
let title: LocalizedStringKey
let value: String?
let forceLeftToRight: Bool
}
private var fields: [Field] {
[
Field(id: .version, title: "Version", value: self.metadata.versionDisplay, forceLeftToRight: true),
Field(id: .commit, title: "Commit", value: self.metadata.shortCommit, forceLeftToRight: true),
Field(id: .built, title: "Built", value: self.metadata.localizedBuildDate(), forceLeftToRight: false),
]
}
var body: some View {
Group {
if self.dynamicTypeSize.isAccessibilitySize {
self.metadataColumn
} else {
ViewThatFits(in: .horizontal) {
self.metadataRow
self.metadataColumn
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.accessibilityElement(children: .ignore)
.accessibilityLabel(self.metadataAccessibilityLabel)
.accessibilityActions {
if self.metadata.gitCommit != nil {
Button {
self.copyCommit()
} label: {
Text("Copy full commit hash")
.font(OpenClawType.subheadSemiBold)
}
}
Button {
self.copyBuildInfo()
} label: {
Text("Copy build info")
.font(OpenClawType.subheadSemiBold)
}
}
.contextMenu {
if self.metadata.gitCommit != nil {
Button {
self.copyCommit()
} label: {
Label {
Text("Copy Commit")
.font(OpenClawType.subheadSemiBold)
} icon: {
Image(systemName: "number")
}
}
}
Button {
self.copyBuildInfo()
} label: {
Label {
Text("Copy Build Info")
.font(OpenClawType.subheadSemiBold)
} icon: {
Image(systemName: "doc.on.doc")
}
}
}
}
private var metadataRow: some View {
HStack(alignment: .center, spacing: 0) {
ForEach(Array(self.fields.enumerated()), id: \.element.id) { index, field in
if index > 0 {
Divider()
.frame(height: 30)
}
self.metadataField(field, alignment: .center)
.frame(minWidth: 72, maxWidth: .infinity)
.padding(.horizontal, 4)
}
}
.frame(minWidth: 240)
}
private var metadataColumn: some View {
VStack(alignment: .center, spacing: 8) {
ForEach(self.fields) { field in
self.metadataField(field, alignment: .center)
}
}
}
private func metadataField(_ field: Field, alignment: HorizontalAlignment) -> some View {
VStack(alignment: alignment, spacing: 1) {
Text(field.title)
.font(OpenClawType.caption2SemiBold)
.textCase(.uppercase)
Group {
if let value = field.value {
Text(verbatim: value)
} else {
Text("Unavailable")
}
}
.font(OpenClawType.monoSmall)
.lineLimit(1)
.minimumScaleFactor(0.72)
.environment(
\.layoutDirection,
field.forceLeftToRight ? .leftToRight : self.layoutDirection)
}
}
private var metadataAccessibilityLabel: String {
let version = self.metadata.versionDisplay
let commit = self.metadata.spokenCommit
let timestamp = self.metadata.buildTimestamp
let built = self.metadata.localizedBuildDate() ?? timestamp
if let commit, let timestamp, let built {
return String(
format: String(
localized: "Version %1$@, commit %2$@, built %3$@, timestamp %4$@"),
version,
commit,
built,
timestamp)
}
if let commit {
return String(
format: String(
localized: "Version %1$@, commit %2$@, build date unavailable"),
version,
commit)
}
if let timestamp, let built {
return String(
format: String(
localized: "Version %1$@, commit unavailable, built %2$@, timestamp %3$@"),
version,
built,
timestamp)
}
return String(
format: String(
localized: "Version %@, commit unavailable, build date unavailable"),
version)
}
private func copyCommit() {
guard let gitCommit = self.metadata.gitCommit else { return }
UIPasteboard.general.string = gitCommit
}
private func copyBuildInfo() {
UIPasteboard.general.string = self.metadata.copyText
}
}
struct SettingsApprovalItem: Identifiable {
let id: String
let icon: String
let title: OpenClawTextValue
let detail: OpenClawTextValue
let priority: OpenClawTextValue
let color: Color
}
struct SettingsApprovalRow: View {
let item: SettingsApprovalItem
var body: some View {
HStack(spacing: 10) {
Image(systemName: self.item.icon)
.font(OpenClawType.captionBold)
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(self.item.color)
}
VStack(alignment: .leading, spacing: 2) {
self.item.title.text
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
self.item.detail.text
.font(OpenClawType.caption2Medium)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
self.item.priority.text
.font(OpenClawType.captionBold)
.foregroundStyle(self.item.color)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background {
Capsule()
.fill(self.item.color.opacity(0.10))
}
}
.padding(.vertical, 7)
}
}
enum SettingsNotificationStatus: Equatable {
case checking
case allowed
case notAllowed
case notSet
case unknown
init(_ status: UNAuthorizationStatus) {
switch status {
case .authorized, .provisional, .ephemeral:
self = .allowed
case .denied:
self = .notAllowed
case .notDetermined:
self = .notSet
@unknown default:
self = .unknown
}
}
var allowsNotifications: Bool {
self == .allowed
}
}
enum SettingsNotificationPresentation: Equatable {
case checking
case enabled
case off
case setup
case denied
case notSet
case unknown
var text: String {
switch self {
case .checking: String(localized: "Checking")
case .enabled: String(localized: "Enabled")
case .off: String(localized: "Off")
case .setup: String(localized: "Setup")
case .denied: String(localized: "Denied")
case .notSet: String(localized: "Not Enabled")
case .unknown: String(localized: "Unknown")
}
}
var detail: String {
switch self {
case .checking:
String(localized: "Checking iOS notification permission.")
case .enabled:
String(
localized: "OpenClaw can show approval prompts and event alerts when the app is not active.")
case .off:
String(localized: "OpenClaw notifications are off.")
case .setup:
String(
localized: "Finish notification setup to receive alerts when the app is not active.")
case .denied:
String(localized: "Notifications have been denied. Enable them in iOS Settings.")
case .notSet:
String(
localized: "Enable notifications to receive approval prompts and event alerts outside the app.")
case .unknown:
String(localized: "OpenClaw cannot determine the current notification permission state.")
}
}
var color: Color {
switch self {
case .enabled:
OpenClawBrand.ok
case .denied, .setup, .unknown:
OpenClawBrand.warn
case .checking, .notSet, .off:
.secondary
}
}
var isActive: Bool {
self == .enabled
}
var needsAttention: Bool {
self != .checking && self != .enabled
}
}
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
case gatewayOffline
case discoveryUnavailable
case talkConfigMissing
case notificationsUnavailable
}
enum SettingsDiagnostics {
static func issues(
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationsAllowed: Bool) -> [SettingsDiagnosticIssue]
{
var issues: [SettingsDiagnosticIssue] = []
if !gatewayConnected { issues.append(.gatewayOffline) }
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
if !notificationsAllowed { issues.append(.notificationsUnavailable) }
return issues
}
static func issueCount(
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationsAllowed: Bool) -> Int
{
self.issues(
gatewayConnected: gatewayConnected,
discoveredGatewayCount: discoveredGatewayCount,
talkConfigLoaded: talkConfigLoaded,
notificationsAllowed: notificationsAllowed).count
}
static func timestamp(_ date: Date) -> String {
date.formatted(date: .omitted, time: .shortened)
}
}
extension SettingsProTab {
static func hasTailnetIPv4() -> Bool {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
guard let addrPtr = ptr.pointee.ifa_addr else { continue }
let family = addrPtr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = addrPtr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(addrPtr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if self.isTailnetIPv4(ip) { return true }
}
return false
}
static func isTailnetHostOrIP(_ host: String) -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") { return true }
return self.isTailnetIPv4(trimmed)
}
static func isTailnetIPv4(_ ip: String) -> Bool {
let parts = ip.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
guard (0...255).contains(a), (0...255).contains(b) else { return false }
return a == 100 && b >= 64 && b <= 127
}
}
#if DEBUG
#Preview("Gateway settings states") {
SettingsGatewayStatesPreview()
}
private struct SettingsGatewayStatesPreview: View {
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.stateSection("Connected") {
self.gatewayStatusCard(
title: "Gateway online",
detail: "Connected to openclaw-gateway.tailnet.ts.net.",
value: "online",
color: OpenClawBrand.ok)
self.gatewayFactsCard(
address: "100.88.41.20:18789",
server: "openclaw-gateway",
discovered: "3",
agent: "Aiden")
}
self.stateSection("Loading") {
self.gatewayStatusCard(
title: "Checking gateway",
detail: "Refreshing connection, discovery, and device trust state.",
value: "loading",
color: OpenClawBrand.accent)
self.gatewayActionsCard(isBusy: true)
}
self.stateSection("Empty") {
self.gatewayStatusCard(
title: "No gateway configured",
detail: "Scan a setup QR code, paste a setup code, or choose a discovered gateway.",
value: "setup",
color: .secondary)
self.setupActionsCard
}
self.stateSection("Error") {
self.gatewayStatusCard(
title: "Tailscale warning",
detail: "Tailscale is off on this device. Turn it on, then try again.",
value: "network",
color: OpenClawBrand.warn)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func stateSection(
_ title: String,
@ViewBuilder content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(OpenClawType.subheadSemiBold)
.foregroundStyle(.secondary)
content()
}
}
private func gatewayStatusCard(
title: String,
detail: String,
value: String,
color: Color) -> some View
{
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
ProStatusRow(
icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash",
title: .localized(title),
detail: .localized(detail),
value: value,
color: color,
actionTitle: value == "setup" ? "Scan QR" : nil,
action: value == "setup" ? {} : nil)
}
}
private func gatewayFactsCard(
address: String,
server: String,
discovered: String,
agent: String) -> some View
{
ProCard(radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
self.factRow("Address", value: address)
Divider()
self.factRow("Server", value: server)
Divider()
self.factRow("Discovered", value: discovered)
Divider()
self.factRow("Default Agent", value: agent)
}
}
}
private func factRow(_ label: String, value: String) -> some View {
HStack {
Text(label)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
.font(OpenClawType.captionMedium)
.lineLimit(1)
.truncationMode(.middle)
}
.frame(height: SettingsLayout.rowHeight)
}
private func gatewayActionsCard(isBusy: Bool) -> some View {
ProCard(radius: SettingsLayout.cardRadius) {
HStack(spacing: 10) {
self.previewButton("Reconnect", systemImage: "arrow.triangle.2.circlepath", isBusy: isBusy)
self.previewButton("Diagnose", systemImage: "cross.case", isBusy: isBusy)
}
}
}
private var setupActionsCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
self.previewButton("Scan QR", systemImage: "qrcode.viewfinder", isBusy: false)
self.previewButton("Connect", systemImage: "link", isBusy: false)
}
Text("Discovered gateways and manual setup live here when the gateway has not connected yet.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
}
}
private func previewButton(
_ title: String,
systemImage: String,
isBusy: Bool) -> some View
{
Button {} label: {
Label(title, systemImage: systemImage)
.font(OpenClawType.captionSemiBold)
.frame(maxWidth: .infinity)
}
.font(OpenClawType.captionSemiBold)
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(isBusy)
}
}
#endif