mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 0a7ab8589a
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
1016 lines
47 KiB
Swift
1016 lines
47 KiB
Swift
import OpenClawKit
|
|
import Network
|
|
import Observation
|
|
import os
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct SettingsTab: View {
|
|
private struct FeatureHelp: Identifiable {
|
|
let id = UUID()
|
|
let title: String
|
|
let message: String
|
|
}
|
|
|
|
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
|
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
|
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
|
@Environment(\.dismiss) private var dismiss
|
|
@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
|
|
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
|
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
|
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
|
@AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true
|
|
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
|
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
|
|
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
|
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
|
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
|
@AppStorage("gateway.autoconnect") private var gatewayAutoConnect: Bool = false
|
|
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
|
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
|
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
|
|
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
|
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
|
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
|
|
|
// Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard).
|
|
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
|
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
|
@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 = ""
|
|
@AppStorage("gateway.setupCode") private var setupCode: String = ""
|
|
@State private var setupStatusText: String?
|
|
@State private var manualGatewayPortText: String = ""
|
|
@State private var gatewayExpanded: Bool = true
|
|
@State private var selectedAgentPickerId: String = ""
|
|
|
|
@State private var showResetOnboardingAlert: Bool = false
|
|
@State private var activeFeatureHelp: FeatureHelp?
|
|
@State private var suppressCredentialPersist: Bool = false
|
|
|
|
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
|
|
|
|
var body: some View {
|
|
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)
|
|
|
|
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")
|
|
}
|
|
}
|
|
.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 {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
self.dismiss()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
}
|
|
.accessibilityLabel("Close")
|
|
}
|
|
}
|
|
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
|
Button("Reset", role: .destructive) {
|
|
self.resetOnboarding()
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text(
|
|
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
|
|
}
|
|
.alert(item: self.$activeFeatureHelp) { help in
|
|
Alert(
|
|
title: Text(help.title),
|
|
message: Text(help.message),
|
|
dismissButton: .default(Text("OK")))
|
|
}
|
|
.onAppear {
|
|
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
|
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
|
}
|
|
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
|
if newValue != self.selectedAgentPickerId {
|
|
self.selectedAgentPickerId = newValue
|
|
}
|
|
}
|
|
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
|
}
|
|
.onChange(of: self.gatewayToken) { _, newValue in
|
|
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
|
|
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)
|
|
}
|
|
.onChange(of: self.manualGatewayPort) { _, _ in
|
|
self.syncManualPortText()
|
|
}
|
|
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
|
if newValue != nil {
|
|
self.setupCode = ""
|
|
self.setupStatusText = nil
|
|
return
|
|
}
|
|
if self.manualGatewayEnabled {
|
|
self.setupStatusText = self.appModel.gatewayStatusText
|
|
}
|
|
}
|
|
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
|
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) { _, 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.gatewayTrustPromptAlert()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func gatewayList(showing: GatewayListMode) -> some View {
|
|
if self.gatewayController.gateways.isEmpty {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("No gateways found yet.")
|
|
.foregroundStyle(.secondary)
|
|
Text("If your gateway is on another network, connect it and ensure DNS is working.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
|
|
case let .manual(host, port, _, _) = lastKnown
|
|
{
|
|
Button {
|
|
Task { await self.connectLastKnown() }
|
|
} label: {
|
|
self.lastKnownButtonLabel(host: host, port: port)
|
|
}
|
|
.disabled(self.connectingGatewayID != nil)
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(self.appModel.seamColor)
|
|
}
|
|
}
|
|
} else {
|
|
let connectedID = self.appModel.connectedGatewayID
|
|
let rows = self.gatewayController.gateways.filter { gateway in
|
|
let isConnected = gateway.stableID == connectedID
|
|
switch showing {
|
|
case .all:
|
|
return true
|
|
case .availableOnly:
|
|
return !isConnected
|
|
}
|
|
}
|
|
|
|
if rows.isEmpty, showing == .availableOnly {
|
|
Text("No other gateways found.")
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(rows) { gateway in
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
// Avoid localized-string formatting edge cases from Bonjour-advertised names.
|
|
Text(verbatim: gateway.name)
|
|
let detailLines = self.gatewayDetailLines(gateway)
|
|
ForEach(detailLines, id: \.self) { line in
|
|
Text(verbatim: line)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
|
|
Button {
|
|
Task { await self.connect(gateway) }
|
|
} label: {
|
|
if self.connectingGatewayID == gateway.id {
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
} else {
|
|
Text("Connect")
|
|
}
|
|
}
|
|
.disabled(self.connectingGatewayID != nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum GatewayListMode: Equatable {
|
|
case all
|
|
case availableOnly
|
|
}
|
|
|
|
private var isGatewayConnected: Bool {
|
|
let status = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if status.contains("connected") { return true }
|
|
return self.appModel.gatewayServerName != nil && !status.contains("offline")
|
|
}
|
|
|
|
private var gatewaySummaryText: String {
|
|
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
|
|
return server
|
|
}
|
|
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? "Not connected" : trimmed
|
|
}
|
|
|
|
private func platformString() -> String {
|
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
|
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
|
}
|
|
|
|
private func deviceFamily() -> String {
|
|
switch UIDevice.current.userInterfaceIdiom {
|
|
case .pad:
|
|
"iPad"
|
|
case .phone:
|
|
"iPhone"
|
|
default:
|
|
"iOS"
|
|
}
|
|
}
|
|
|
|
private func openClawVersionString() -> String {
|
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
|
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
|
|
let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmedBuild.isEmpty || trimmedBuild == version {
|
|
return version
|
|
}
|
|
return "\(version) (\(trimmedBuild))"
|
|
}
|
|
|
|
private func featureToggle(
|
|
_ title: String,
|
|
isOn: Binding<Bool>,
|
|
help: String,
|
|
onChange: ((Bool) -> Void)? = nil
|
|
) -> some View {
|
|
HStack(spacing: 8) {
|
|
Toggle(title, isOn: isOn)
|
|
Button {
|
|
self.activeFeatureHelp = FeatureHelp(title: title, message: help)
|
|
} label: {
|
|
Image(systemName: "info.circle")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("\(title) info")
|
|
}
|
|
.onChange(of: isOn.wrappedValue) { _, newValue in
|
|
onChange?(newValue)
|
|
}
|
|
}
|
|
|
|
private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
|
self.connectingGatewayID = gateway.id
|
|
self.manualGatewayEnabled = false
|
|
self.preferredGatewayStableID = gateway.stableID
|
|
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
|
self.lastDiscoveredGatewayStableID = gateway.stableID
|
|
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
|
defer { self.connectingGatewayID = nil }
|
|
|
|
let err = await self.gatewayController.connectWithDiagnostics(gateway)
|
|
if let err {
|
|
self.setupStatusText = err
|
|
}
|
|
}
|
|
|
|
private func connectLastKnown() async {
|
|
self.connectingGatewayID = "last-known"
|
|
defer { self.connectingGatewayID = nil }
|
|
await self.gatewayController.connectLastKnown()
|
|
}
|
|
|
|
private func gatewayDebugText() -> String {
|
|
var lines: [String] = [
|
|
"gateway: \(self.appModel.gatewayStatusText)",
|
|
"discovery: \(self.gatewayController.discoveryStatusText)",
|
|
]
|
|
lines.append("server: \(self.appModel.gatewayServerName ?? "—")")
|
|
lines.append("address: \(self.appModel.gatewayRemoteAddress ?? "—")")
|
|
if let last = self.gatewayController.discoveryDebugLog.last?.message {
|
|
lines.append("discovery log: \(last)")
|
|
}
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func lastKnownButtonLabel(host: String, port: Int) -> some View {
|
|
if self.connectingGatewayID == "last-known" {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
Text("Connecting…")
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "bolt.horizontal.circle.fill")
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Connect last known")
|
|
Text("\(host):\(port)")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
private var manualPortBinding: Binding<String> {
|
|
Binding(
|
|
get: { self.manualGatewayPortText },
|
|
set: { newValue in
|
|
let filtered = newValue.filter(\.isNumber)
|
|
if self.manualGatewayPortText != filtered {
|
|
self.manualGatewayPortText = filtered
|
|
}
|
|
if filtered.isEmpty {
|
|
if self.manualGatewayPort != 0 {
|
|
self.manualGatewayPort = 0
|
|
}
|
|
} else if let port = Int(filtered), self.manualGatewayPort != port {
|
|
self.manualGatewayPort = port
|
|
}
|
|
})
|
|
}
|
|
|
|
private var manualPortIsValid: Bool {
|
|
if self.manualGatewayPortText.isEmpty { return true }
|
|
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
|
}
|
|
|
|
private func syncManualPortText() {
|
|
if self.manualGatewayPort > 0 {
|
|
let next = String(self.manualGatewayPort)
|
|
if self.manualGatewayPortText != next {
|
|
self.manualGatewayPortText = next
|
|
}
|
|
} else if !self.manualGatewayPortText.isEmpty {
|
|
self.manualGatewayPortText = ""
|
|
}
|
|
}
|
|
|
|
private func applySetupCodeAndConnect() async {
|
|
self.setupStatusText = nil
|
|
guard self.applySetupCode() else { return }
|
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let resolvedPort = self.resolvedManualPort(host: host)
|
|
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
GatewayDiagnostics.log(
|
|
"setup code applied host=\(host) port=\(resolvedPort ?? -1) tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
|
|
guard let port = resolvedPort else {
|
|
self.setupStatusText = "Failed: invalid port"
|
|
return
|
|
}
|
|
let ok = await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS)
|
|
guard ok else { return }
|
|
self.setupStatusText = "Setup code applied. Connecting…"
|
|
await self.connectManual()
|
|
}
|
|
|
|
@discardableResult
|
|
private func applySetupCode() -> Bool {
|
|
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !raw.isEmpty else {
|
|
self.setupStatusText = "Paste a setup code to continue."
|
|
return false
|
|
}
|
|
|
|
guard let payload = GatewaySetupCode.decode(raw: raw) else {
|
|
self.setupStatusText = "Setup code not recognized."
|
|
return false
|
|
}
|
|
|
|
if let urlString = payload.url, let url = URL(string: urlString) {
|
|
self.applySetupURL(url)
|
|
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
self.manualGatewayHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let port = payload.port {
|
|
self.manualGatewayPort = port
|
|
self.manualGatewayPortText = String(port)
|
|
} else {
|
|
self.manualGatewayPort = 0
|
|
self.manualGatewayPortText = ""
|
|
}
|
|
if let tls = payload.tls {
|
|
self.manualGatewayTLS = tls
|
|
}
|
|
} else if let url = URL(string: raw), url.scheme != nil {
|
|
self.applySetupURL(url)
|
|
} else {
|
|
self.setupStatusText = "Setup code missing URL or host."
|
|
return false
|
|
}
|
|
|
|
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.gatewayToken = trimmedToken
|
|
if !trimmedInstanceId.isEmpty {
|
|
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
|
}
|
|
}
|
|
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.gatewayPassword = trimmedPassword
|
|
if !trimmedInstanceId.isEmpty {
|
|
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private func applySetupURL(_ url: URL) {
|
|
guard let host = url.host, !host.isEmpty else { return }
|
|
self.manualGatewayHost = host
|
|
if let port = url.port {
|
|
self.manualGatewayPort = port
|
|
self.manualGatewayPortText = String(port)
|
|
} else {
|
|
self.manualGatewayPort = 0
|
|
self.manualGatewayPortText = ""
|
|
}
|
|
let scheme = (url.scheme ?? "").lowercased()
|
|
if scheme == "wss" || scheme == "https" {
|
|
self.manualGatewayTLS = true
|
|
} else if scheme == "ws" || scheme == "http" {
|
|
self.manualGatewayTLS = false
|
|
}
|
|
}
|
|
|
|
private func resolvedManualPort(host: String) -> Int? {
|
|
if self.manualGatewayPort > 0 {
|
|
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
|
|
}
|
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
if self.manualGatewayTLS && trimmed.lowercased().hasSuffix(".ts.net") {
|
|
return 443
|
|
}
|
|
return 18789
|
|
}
|
|
|
|
private func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
|
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return false }
|
|
|
|
if Self.isTailnetHostOrIP(trimmed) && !Self.hasTailnetIPv4() {
|
|
let msg = "Tailscale is off on this iPhone. Turn it on, then try again."
|
|
self.setupStatusText = msg
|
|
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
|
|
self.gatewayLogger.warning("\(msg, privacy: .public)")
|
|
return false
|
|
}
|
|
|
|
self.setupStatusText = "Checking gateway reachability…"
|
|
let ok = await Self.probeTCP(host: trimmed, port: port, timeoutSeconds: 3)
|
|
if !ok {
|
|
let msg = "Can't reach gateway at \(trimmed):\(port). Check Tailscale or LAN."
|
|
self.setupStatusText = msg
|
|
GatewayDiagnostics.log("preflight fail: unreachable host=\(trimmed) port=\(port)")
|
|
self.gatewayLogger.warning("\(msg, privacy: .public)")
|
|
return false
|
|
}
|
|
GatewayDiagnostics.log("preflight ok host=\(trimmed) port=\(port) tls=\(useTLS)")
|
|
return true
|
|
}
|
|
|
|
private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool {
|
|
await TCPProbe.probe(
|
|
host: host,
|
|
port: port,
|
|
timeoutSeconds: timeoutSeconds,
|
|
queueLabel: "gateway.preflight")
|
|
}
|
|
|
|
// (GatewaySetupCode) decode raw setup codes.
|
|
|
|
private func connectManual() async {
|
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !host.isEmpty else {
|
|
self.setupStatusText = "Failed: host required"
|
|
return
|
|
}
|
|
guard self.manualPortIsValid else {
|
|
self.setupStatusText = "Failed: invalid port"
|
|
return
|
|
}
|
|
|
|
self.connectingGatewayID = "manual"
|
|
self.manualGatewayEnabled = true
|
|
defer { self.connectingGatewayID = nil }
|
|
|
|
GatewayDiagnostics.log(
|
|
"connect manual host=\(host) port=\(self.manualGatewayPort) tls=\(self.manualGatewayTLS)")
|
|
await self.gatewayController.connectManual(
|
|
host: host,
|
|
port: self.manualGatewayPort,
|
|
useTLS: self.manualGatewayTLS)
|
|
}
|
|
|
|
private var setupStatusLine: String? {
|
|
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
|
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
|
|
if !trimmedSetup.isEmpty { return trimmedSetup }
|
|
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
|
|
return gatewayStatus
|
|
}
|
|
|
|
private var tailnetWarningText: String? {
|
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !host.isEmpty else { return nil }
|
|
guard Self.isTailnetHostOrIP(host) else { return nil }
|
|
guard !Self.hasTailnetIPv4() else { return nil }
|
|
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
|
|
}
|
|
|
|
private func friendlyGatewayMessage(from raw: String) -> String? {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
let lower = trimmed.lowercased()
|
|
if lower.contains("pairing required") {
|
|
return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again."
|
|
}
|
|
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
|
|
return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again."
|
|
}
|
|
if lower.contains("device signature expired") || lower.contains("device signature invalid") {
|
|
return "Secure handshake failed. Check that your iPhone time is correct, then tap Connect again."
|
|
}
|
|
if lower.contains("connect timed out") || lower.contains("timed out") {
|
|
return "Connection timed out. Make sure Tailscale is connected, then try again."
|
|
}
|
|
if lower.contains("unauthorized role") {
|
|
return "Connected, but some controls are restricted for nodes. This is expected."
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private 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
|
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
|
|
|
var addr = ptr.pointee.ifa_addr.pointee
|
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
|
let result = getnameinfo(
|
|
&addr,
|
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
|
&buffer,
|
|
socklen_t(buffer.count),
|
|
nil,
|
|
0,
|
|
NI_NUMERICHOST)
|
|
guard result == 0 else { continue }
|
|
let len = buffer.prefix { $0 != 0 }
|
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
|
if self.isTailnetIPv4(ip) { return true }
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private 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)
|
|
}
|
|
|
|
private 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
|
|
}
|
|
|
|
private static func parseHostPort(from address: String) -> SettingsHostPort? {
|
|
SettingsNetworkingHelpers.parseHostPort(from: address)
|
|
}
|
|
|
|
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
|
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
|
}
|
|
|
|
private func resetOnboarding() {
|
|
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
|
|
self.appModel.disconnectGateway()
|
|
self.connectingGatewayID = nil
|
|
self.setupStatusText = nil
|
|
self.setupCode = ""
|
|
self.gatewayAutoConnect = false
|
|
|
|
self.suppressCredentialPersist = true
|
|
defer { self.suppressCredentialPersist = false }
|
|
|
|
self.gatewayToken = ""
|
|
self.gatewayPassword = ""
|
|
|
|
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmedInstanceId.isEmpty {
|
|
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
|
|
}
|
|
|
|
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
|
|
GatewaySettingsStore.clearLastGatewayConnection()
|
|
|
|
// RootCanvas also short-circuits onboarding when these are true.
|
|
self.onboardingComplete = false
|
|
self.hasConnectedOnce = false
|
|
|
|
// Clear manual override so it doesn't count as an existing gateway config.
|
|
self.manualGatewayEnabled = false
|
|
self.manualGatewayHost = ""
|
|
|
|
// Force re-present even without app restart.
|
|
self.onboardingRequestID += 1
|
|
|
|
// The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show.
|
|
self.dismiss()
|
|
}
|
|
|
|
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
|
var lines: [String] = []
|
|
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
|
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
|
|
|
let gatewayPort = gateway.gatewayPort
|
|
let canvasPort = gateway.canvasPort
|
|
if gatewayPort != nil || canvasPort != nil {
|
|
let gw = gatewayPort.map(String.init) ?? "—"
|
|
let canvas = canvasPort.map(String.init) ?? "—"
|
|
lines.append("Ports: gateway \(gw) · canvas \(canvas)")
|
|
}
|
|
|
|
if lines.isEmpty {
|
|
lines.append(gateway.debugID)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
}
|