Files
openclaw/apps/ios/Sources/Settings/SettingsTab.swift
2026-02-14 17:55:20 +01:00

972 lines
44 KiB
Swift

import OpenClawKit
import Network
import Observation
import os
import SwiftUI
import UIKit
struct SettingsTab: View {
@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("camera.enabled") private var cameraEnabled: Bool = true
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
@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
@State private var connectingGatewayID: String?
@State private var localIPAddress: String?
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State private var gatewayToken: String = ""
@State private var gatewayPassword: 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 = ""
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)
}
DisclosureGroup("Advanced") {
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)
}
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 Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
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") {
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
.onChange(of: self.voiceWakeEnabled) { _, newValue in
self.appModel.setVoiceWakeEnabled(newValue)
}
Toggle("Talk Mode", isOn: self.$talkEnabled)
.onChange(of: self.talkEnabled) { _, newValue in
self.appModel.setTalkEnabled(newValue)
}
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
NavigationLink {
VoiceWakeWordsSettingsView()
} label: {
LabeledContent(
"Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
}
Toggle("Allow Camera", isOn: self.$cameraEnabled)
Text("Allows the gateway to request photos or short video clips (foreground only).")
.font(.footnote)
.foregroundStyle(.secondary)
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)
}
.pickerStyle(.segmented)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always requires system permission and may prompt to open Settings.")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Prevent Sleep", isOn: self.$preventSleep)
Text("Keeps the screen awake while OpenClaw is open.")
.font(.footnote)
.foregroundStyle(.secondary)
}
DisclosureGroup("Device Info") {
TextField("Name", text: self.$displayName)
Text(self.instanceId)
.font(.footnote)
.foregroundStyle(.secondary)
LabeledContent("IP", value: self.localIPAddress ?? "")
.contextMenu {
if let ip = self.localIPAddress {
Button {
UIPasteboard.general.string = ip
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
}
LabeledContent("Platform", value: self.platformString())
LabeledContent("Version", value: self.appVersion())
LabeledContent("Model", value: self.modelIdentifier())
}
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
.onAppear {
self.localIPAddress = Self.primaryIPv4Address()
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) ?? ""
}
// 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
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
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.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) {
Text(gateway.name)
let detailLines = self.gatewayDetailLines(gateway)
ForEach(detailLines, id: \.self) { line in
Text(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 var locationMode: OpenClawLocationMode {
OpenClawLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off
}
private func appVersion() -> String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
}
private func deviceFamily() -> String {
switch UIDevice.current.userInterfaceIdiom {
case .pad:
"iPad"
case .phone:
"iPhone"
default:
"iOS"
}
}
private func modelIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
}
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
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 }
await self.gatewayController.connect(gateway)
}
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 struct SetupPayload: Codable {
var url: String?
var host: String?
var port: Int?
var tls: Bool?
var token: String?
var password: String?
}
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 = self.decodeSetupPayload(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 {
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
let endpointHost = NWEndpoint.Host(host)
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
return await withCheckedContinuation { cont in
let queue = DispatchQueue(label: "gateway.preflight")
let finished = OSAllocatedUnfairLock(initialState: false)
let finish: @Sendable (Bool) -> Void = { ok in
let shouldResume = finished.withLock { flag -> Bool in
if flag { return false }
flag = true
return true
}
guard shouldResume else { return }
connection.cancel()
cont.resume(returning: ok)
}
connection.stateUpdateHandler = { state in
switch state {
case .ready:
finish(true)
case .failed, .cancelled:
finish(false)
default:
break
}
}
connection.start(queue: queue)
queue.asyncAfter(deadline: .now() + timeoutSeconds) {
finish(false)
}
}
}
private func decodeSetupPayload(raw: String) -> SetupPayload? {
if let payload = decodeSetupPayloadFromJSON(raw) {
return payload
}
if let decoded = decodeBase64Payload(raw),
let payload = decodeSetupPayloadFromJSON(decoded)
{
return payload
}
return nil
}
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(SetupPayload.self, from: data)
}
private func decodeBase64Payload(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let normalized = trimmed
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padding = normalized.count % 4
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
guard let data = Data(base64Encoded: padded) else { return nil }
return String(data: data, encoding: .utf8)
}
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 primaryIPv4Address() -> String? {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
defer { freeifaddrs(addrList) }
var fallback: String?
var en0: String?
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 name = String(cString: ptr.pointee.ifa_name)
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 name == "en0" { en0 = ip; break }
if fallback == nil { fallback = ip }
}
return en0 ?? fallback
}
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 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
}
}