2026-01-30 03:15:10 +01:00
import OpenClawKit
2025-12-14 01:54:48 +00:00
import Network
2025-12-14 05:04:58 +00:00
import Observation
2026-02-08 18:08:13 +01:00
import os
2025-12-12 21:19:34 +00:00
import SwiftUI
2025-12-13 23:29:32 +00:00
import UIKit
2025-12-12 21:19:34 +00:00
struct SettingsTab : View {
2026-02-17 20:08:50 +00:00
private struct FeatureHelp : Identifiable {
let id = UUID ( )
let title : String
let message : String
}
2025-12-14 05:04:58 +00:00
@ Environment ( NodeAppModel . self ) private var appModel : NodeAppModel
@ Environment ( VoiceWakeManager . self ) private var voiceWake : VoiceWakeManager
2026-01-19 05:44:36 +00:00
@ Environment ( GatewayConnectionController . self ) private var gatewayController : GatewayConnectionController
2025-12-13 01:49:04 +00:00
@ Environment ( \ . dismiss ) private var dismiss
2026-02-02 17:27:56 +00:00
@ AppStorage ( " node.displayName " ) private var displayName : String = " iOS Node "
2025-12-12 21:19:34 +00:00
@ AppStorage ( " node.instanceId " ) private var instanceId : String = UUID ( ) . uuidString
@ AppStorage ( " voiceWake.enabled " ) private var voiceWakeEnabled : Bool = false
2025-12-29 23:21:05 +01:00
@ AppStorage ( " talk.enabled " ) private var talkEnabled : Bool = false
2025-12-30 00:01:21 +01:00
@ AppStorage ( " talk.button.enabled " ) private var talkButtonEnabled : Bool = true
2026-02-16 17:36:17 +00:00
@ AppStorage ( " talk.background.enabled " ) private var talkBackgroundEnabled : Bool = false
2026-02-16 17:33:42 +00:00
@ AppStorage ( " talk.voiceDirectiveHint.enabled " ) private var talkVoiceDirectiveHintEnabled : Bool = true
2025-12-14 00:23:34 +00:00
@ AppStorage ( " camera.enabled " ) private var cameraEnabled : Bool = true
2026-01-30 03:15:10 +01:00
@ AppStorage ( " location.enabledMode " ) private var locationEnabledModeRaw : String = OpenClawLocationMode . off . rawValue
2025-12-17 21:01:47 +01:00
@ AppStorage ( " screen.preventSleep " ) private var preventSleep : Bool = true
2026-01-19 05:44:36 +00:00
@ AppStorage ( " gateway.preferredStableID " ) private var preferredGatewayStableID : String = " "
@ AppStorage ( " gateway.lastDiscoveredStableID " ) private var lastDiscoveredGatewayStableID : String = " "
2026-02-08 18:08:13 +01:00
@ AppStorage ( " gateway.autoconnect " ) private var gatewayAutoConnect : Bool = false
2026-01-19 05:44:36 +00:00
@ 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
2025-12-21 14:20:10 +01:00
@ AppStorage ( " canvas.debugStatusEnabled " ) private var canvasDebugStatusEnabled : Bool = false
2026-02-16 16:07:22 +00:00
// O n b o a r d i n g c o n t r o l ( R o o t C a n v a s l i s t e n s t o o n b o a r d i n g . r e q u e s t I D a n d f o r c e - o p e n s t h e w i z a r d ) .
@ AppStorage ( " onboarding.requestID " ) private var onboardingRequestID : Int = 0
@ AppStorage ( " gateway.onboardingComplete " ) private var onboardingComplete : Bool = false
@ AppStorage ( " gateway.hasConnectedOnce " ) private var hasConnectedOnce : Bool = false
2026-01-19 05:44:36 +00:00
@ State private var connectingGatewayID : String ?
2026-02-20 16:06:07 -06:00
@ State private var lastLocationModeRaw : String = OpenClawLocationMode . off . rawValue
2026-01-19 05:44:36 +00:00
@ State private var gatewayToken : String = " "
@ State private var gatewayPassword : String = " "
2026-02-17 20:08:50 +00:00
@ State private var defaultShareInstruction : String = " "
2026-02-08 18:08:13 +01:00
@ 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 = " "
2026-02-16 16:07:22 +00:00
@ State private var showResetOnboardingAlert : Bool = false
2026-02-17 20:08:50 +00:00
@ State private var activeFeatureHelp : FeatureHelp ?
2026-02-16 16:07:22 +00:00
@ State private var suppressCredentialPersist : Bool = false
2026-02-08 18:08:13 +01:00
private let gatewayLogger = Logger ( subsystem : " ai.openclaw.ios " , category : " GatewaySettings " )
2025-12-12 21:19:34 +00:00
var body : some View {
2026-02-20 16:06:07 -06:00
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 )
2026-02-08 18:08:13 +01:00
2026-02-20 16:06:07 -06:00
if let warning = self . tailnetWarningText {
Text ( warning )
. font ( . footnote . weight ( . semibold ) )
. foregroundStyle ( . orange )
}
2026-02-08 18:08:13 +01:00
2026-02-20 16:06:07 -06:00
TextField ( " Paste setup code " , text : self . $ setupCode )
. textInputAutocapitalization ( . never )
. autocorrectionDisabled ( )
2025-12-12 21:19:34 +00:00
2026-02-20 16:06:07 -06:00
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 )
2026-02-08 18:08:13 +01:00
2026-02-20 16:06:07 -06:00
if let status = self . setupStatusLine {
Text ( status )
. font ( . footnote )
. foregroundStyle ( . secondary )
}
}
2025-12-13 23:40:12 +00:00
2026-02-20 16:06:07 -06:00
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 )
}
2025-12-13 02:02:38 +00:00
2026-02-20 16:06:07 -06:00
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 " )
}
}
}
}
2025-12-13 02:02:38 +00:00
2026-02-20 16:06:07 -06:00
Button ( " Disconnect " , role : . destructive ) {
self . appModel . disconnectGateway ( )
}
} else {
self . gatewayList ( showing : . all )
}
2025-12-14 01:54:48 +00:00
2026-02-20 16:06:07 -06:00
DisclosureGroup ( " Advanced " ) {
Toggle ( " Use Manual Gateway " , isOn : self . $ manualGatewayEnabled )
2025-12-14 01:54:48 +00:00
2026-02-20 16:06:07 -06:00
TextField ( " Host " , text : self . $ manualGatewayHost )
. textInputAutocapitalization ( . never )
. autocorrectionDisabled ( )
2025-12-14 01:54:48 +00:00
2026-02-20 16:06:07 -06:00
TextField ( " Port (optional) " , text : self . manualPortBinding )
. keyboardType ( . numberPad )
2026-01-19 05:44:36 +00:00
2026-02-20 16:06:07 -06:00
Toggle ( " Use TLS " , isOn : self . $ manualGatewayTLS )
2025-12-14 04:34:00 +00:00
2026-02-20 16:06:07 -06:00
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 )
2026-02-16 16:07:22 +00:00
2026-02-20 16:06:07 -06:00
Toggle ( " Discovery Debug Logs " , isOn : self . $ discoveryDebugLogsEnabled )
. onChange ( of : self . discoveryDebugLogsEnabled ) { _ , newValue in
self . gatewayController . setDiscoveryDebugLoggingEnabled ( newValue )
}
2025-12-14 04:34:00 +00:00
2026-02-20 16:06:07 -06:00
NavigationLink ( " Discovery Logs " ) {
GatewayDiscoveryDebugLogView ( )
}
2025-12-21 14:20:10 +01:00
2026-02-20 16:06:07 -06:00
Toggle ( " Debug Canvas Status " , isOn : self . $ canvasDebugStatusEnabled )
2026-01-19 05:44:36 +00:00
2026-02-20 16:06:07 -06:00
TextField ( " Gateway Auth Token " , text : self . $ gatewayToken )
. textInputAutocapitalization ( . never )
. autocorrectionDisabled ( )
2026-01-19 05:44:36 +00:00
2026-02-20 16:06:07 -06:00
SecureField ( " Gateway Password " , text : self . $ gatewayPassword )
2025-12-18 01:00:36 +01:00
2026-02-20 16:06:07 -06:00
Button ( " Reset Onboarding " , role : . destructive ) {
self . showResetOnboardingAlert = true
2026-02-20 19:26:30 +00:00
}
2025-12-18 01:00:36 +01:00
2026-02-20 16:06:07 -06:00
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 )
2026-02-08 18:08:13 +01:00
}
2026-02-20 19:26:30 +00:00
}
}
2026-01-04 00:54:44 +01:00
2026-02-20 16:06:07 -06:00
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 ) )
2026-02-20 19:26:30 +00:00
}
2026-02-20 16:06:07 -06:00
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 ( )
2026-02-17 20:08:50 +00:00
Button {
2026-02-20 16:06:07 -06:00
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. " )
2026-02-17 20:08:50 +00:00
} label : {
2026-02-20 16:06:07 -06:00
Image ( systemName : " info.circle " )
. foregroundStyle ( . secondary )
2026-02-17 20:08:50 +00:00
}
2026-02-20 16:06:07 -06:00
. 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 " ) {
2026-02-21 10:34:20 +02:00
VStack ( alignment : . leading , spacing : 8 ) {
Text ( " Talk Voice (Gateway) " )
. font ( . footnote . weight ( . semibold ) )
. foregroundStyle ( . secondary )
LabeledContent ( " Provider " , value : " ElevenLabs " )
LabeledContent (
" API Key " ,
value : self . appModel . talkMode . gatewayTalkConfigLoaded
? ( self . appModel . talkMode . gatewayTalkApiKeyConfigured ? " Configured " : " Not configured " )
: " Not loaded " )
LabeledContent (
" Default Model " ,
value : self . appModel . talkMode . gatewayTalkDefaultModelId ? ? " eleven_v3 (fallback) " )
LabeledContent (
" Default Voice " ,
value : self . appModel . talkMode . gatewayTalkDefaultVoiceId ? ? " auto (first available) " )
Text ( " Configured on gateway via talk.apiKey, talk.modelId, and talk.voiceId. " )
. font ( . footnote )
. foregroundStyle ( . secondary )
}
2026-02-20 16:06:07 -06:00
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 ( )
2026-02-17 20:08:50 +00:00
Button {
2026-02-20 16:06:07 -06:00
self . activeFeatureHelp = FeatureHelp (
title : " Default Share Instruction " ,
message : " Appends this instruction when sharing content into OpenClaw from iOS. " )
2026-02-17 20:08:50 +00:00
} label : {
2026-02-20 16:06:07 -06:00
Image ( systemName : " info.circle " )
. foregroundStyle ( . secondary )
2026-02-17 20:08:50 +00:00
}
2026-02-20 16:06:07 -06:00
. buttonStyle ( . plain )
. accessibilityLabel ( " Default Share Instruction info " )
}
2026-02-08 18:08:13 +01:00
2026-02-20 16:06:07 -06:00
VStack ( alignment : . leading , spacing : 8 ) {
2026-02-17 20:08:50 +00:00
Button {
2026-02-20 16:06:07 -06:00
Task { await self . appModel . runSharePipelineSelfTest ( ) }
2026-02-17 20:08:50 +00:00
} label : {
2026-02-20 16:06:07 -06:00
Label ( " Run Share Self-Test " , systemImage : " checkmark.seal " )
2026-02-17 20:08:50 +00:00
}
2026-02-20 16:06:07 -06:00
Text ( self . appModel . lastShareEventText )
. font ( . footnote )
. foregroundStyle ( . secondary )
2026-02-17 20:08:50 +00:00
}
}
2026-02-08 18:08:13 +01:00
}
2026-02-20 16:06:07 -06:00
DisclosureGroup ( " Device Info " ) {
TextField ( " Name " , text : self . $ displayName )
Text ( self . instanceId )
. font ( . footnote )
. foregroundStyle ( . secondary )
. lineLimit ( 1 )
. truncationMode ( . middle )
2026-02-24 13:40:35 +08:00
LabeledContent ( " Device " , value : DeviceInfoHelper . deviceFamily ( ) )
LabeledContent ( " Platform " , value : DeviceInfoHelper . platformStringForDisplay ( ) )
LabeledContent ( " OpenClaw " , value : DeviceInfoHelper . openClawVersionString ( ) )
2026-02-08 18:08:13 +01:00
}
2025-12-18 01:00:36 +01:00
}
2026-02-20 16:06:07 -06:00
}
. navigationTitle ( " Settings " )
. toolbar {
ToolbarItem ( placement : . topBarTrailing ) {
2025-12-13 01:49:04 +00:00
Button {
2026-02-20 16:06:07 -06:00
self . dismiss ( )
2025-12-13 01:49:04 +00:00
} label : {
2026-02-20 16:06:07 -06:00
Image ( systemName : " xmark " )
2025-12-13 01:49:04 +00:00
}
2026-02-20 16:06:07 -06:00
. accessibilityLabel ( " Close " )
2026-02-16 16:07:22 +00:00
}
2026-02-20 16:06:07 -06:00
}
. alert ( " Reset Onboarding? " , isPresented : self . $ showResetOnboardingAlert ) {
Button ( " Reset " , role : . destructive ) {
self . resetOnboarding ( )
2026-01-19 05:44:36 +00:00
}
2026-02-20 16:06:07 -06:00
Button ( " Cancel " , role : . cancel ) { }
} message : {
Text (
" This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard. " )
2026-02-08 18:08:13 +01:00
}
2026-02-20 16:06:07 -06:00
. 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 ) ? ? " "
2026-02-08 18:08:13 +01:00
}
2026-02-20 16:06:07 -06:00
self . defaultShareInstruction = ShareToAgentSettings . loadDefaultInstruction ( )
self . appModel . refreshLastShareEventFromRelay ( )
// K e e p s e t u p f r o n t - a n d - c e n t e r w h e n d i s c o n n e c t e d ; k e e p t h i n g s c o m p a c t o n c e c o n n e c t e d .
self . gatewayExpanded = ! self . isGatewayConnected
self . selectedAgentPickerId = self . appModel . selectedAgentId ? ? " "
2026-02-21 10:34:20 +02:00
if self . isGatewayConnected {
self . appModel . reloadTalkConfig ( )
}
2026-02-20 16:06:07 -06:00
}
. 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
2026-02-08 18:08:13 +01:00
}
2026-02-20 16:06:07 -06:00
if self . manualGatewayEnabled {
self . setupStatusText = self . appModel . gatewayStatusText
2026-02-08 18:08:13 +01:00
}
2026-02-20 16:06:07 -06:00
}
. 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
2026-01-04 00:54:44 +01:00
}
2026-02-20 16:06:07 -06:00
return
2026-02-20 18:57:04 +00:00
}
2026-02-20 16:06:07 -06:00
await MainActor . run {
self . gatewayController . refreshActiveGatewayRegistrationFromSettings ( )
2026-01-04 00:54:44 +01:00
}
}
}
2025-12-12 21:19:34 +00:00
}
2026-02-20 16:06:07 -06:00
. gatewayTrustPromptAlert ( )
2025-12-12 21:19:34 +00:00
}
2025-12-13 02:02:38 +00:00
@ ViewBuilder
2026-01-19 05:44:36 +00:00
private func gatewayList ( showing : GatewayListMode ) -> some View {
if self . gatewayController . gateways . isEmpty {
2026-02-08 18:08:13 +01:00
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 )
2026-02-14 17:47:13 +01:00
if let lastKnown = GatewaySettingsStore . loadLastGatewayConnection ( ) ,
case let . manual ( host , port , _ , _ ) = lastKnown
{
2026-02-08 18:08:13 +01:00
Button {
Task { await self . connectLastKnown ( ) }
} label : {
2026-02-14 17:47:13 +01:00
self . lastKnownButtonLabel ( host : host , port : port )
2026-02-08 18:08:13 +01:00
}
. disabled ( self . connectingGatewayID != nil )
. buttonStyle ( . borderedProminent )
. tint ( self . appModel . seamColor )
}
}
2025-12-13 02:02:38 +00:00
} else {
2026-01-19 05:44:36 +00:00
let connectedID = self . appModel . connectedGatewayID
let rows = self . gatewayController . gateways . filter { gateway in
let isConnected = gateway . stableID = = connectedID
2025-12-13 04:28:12 +00:00
switch showing {
case . all :
return true
case . availableOnly :
return ! isConnected
}
}
if rows . isEmpty , showing = = . availableOnly {
2026-01-19 05:44:36 +00:00
Text ( " No other gateways found. " )
2025-12-13 04:28:12 +00:00
. foregroundStyle ( . secondary )
} else {
2026-01-19 05:44:36 +00:00
ForEach ( rows ) { gateway in
2025-12-13 02:02:38 +00:00
HStack {
VStack ( alignment : . leading , spacing : 2 ) {
2026-02-16 16:07:22 +00:00
// A v o i d l o c a l i z e d - s t r i n g f o r m a t t i n g e d g e c a s e s f r o m B o n j o u r - a d v e r t i s e d n a m e s .
Text ( verbatim : gateway . name )
2026-01-19 05:44:36 +00:00
let detailLines = self . gatewayDetailLines ( gateway )
2025-12-29 22:11:12 +01:00
ForEach ( detailLines , id : \ . self ) { line in
2026-02-16 16:07:22 +00:00
Text ( verbatim : line )
2025-12-29 22:11:12 +01:00
. font ( . footnote )
. foregroundStyle ( . secondary )
}
2025-12-13 02:02:38 +00:00
}
Spacer ( )
2025-12-13 17:58:03 +00:00
Button {
2026-01-19 05:44:36 +00:00
Task { await self . connect ( gateway ) }
2025-12-13 17:58:03 +00:00
} label : {
2026-01-19 05:44:36 +00:00
if self . connectingGatewayID = = gateway . id {
2025-12-13 17:58:03 +00:00
ProgressView ( )
. progressViewStyle ( . circular )
} else {
Text ( " Connect " )
}
2025-12-13 02:02:38 +00:00
}
2026-01-19 05:44:36 +00:00
. disabled ( self . connectingGatewayID != nil )
2025-12-13 02:02:38 +00:00
}
}
}
}
}
2026-01-19 05:44:36 +00:00
private enum GatewayListMode : Equatable {
2025-12-13 04:28:12 +00:00
case all
case availableOnly
2025-12-13 02:02:38 +00:00
}
2026-02-08 18:08:13 +01:00
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
}
2026-02-17 20:08:50 +00:00
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 )
2025-12-18 02:05:06 +00:00
}
}
2026-01-19 05:44:36 +00:00
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 }
2025-12-12 21:19:34 +00:00
2026-02-16 16:07:22 +00:00
let err = await self . gatewayController . connectWithDiagnostics ( gateway )
if let err {
self . setupStatusText = err
}
2025-12-12 21:19:34 +00:00
}
2025-12-13 17:58:03 +00:00
2026-02-08 18:08:13 +01:00
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
}
2026-02-15 20:38:26 +00:00
guard let payload = GatewaySetupCode . decode ( raw : raw ) else {
2026-02-08 18:08:13 +01:00
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 {
2026-02-15 20:38:26 +00:00
await TCPProbe . probe (
host : host ,
port : port ,
timeoutSeconds : timeoutSeconds ,
queueLabel : " gateway.preflight " )
2026-02-08 18:08:13 +01:00
}
2026-02-15 20:38:26 +00:00
// ( G a t e w a y S e t u p C o d e ) d e c o d e r a w s e t u p c o d e s .
2026-02-08 18:08:13 +01:00
2025-12-14 01:54:48 +00:00
private func connectManual ( ) async {
2026-01-19 05:44:36 +00:00
let host = self . manualGatewayHost . trimmingCharacters ( in : . whitespacesAndNewlines )
2025-12-14 01:54:48 +00:00
guard ! host . isEmpty else {
2026-02-08 18:08:13 +01:00
self . setupStatusText = " Failed: host required "
2025-12-14 01:54:48 +00:00
return
}
2026-02-08 18:08:13 +01:00
guard self . manualPortIsValid else {
self . setupStatusText = " Failed: invalid port "
2025-12-14 01:54:48 +00:00
return
}
2026-01-19 05:44:36 +00:00
self . connectingGatewayID = " manual "
self . manualGatewayEnabled = true
defer { self . connectingGatewayID = nil }
2026-01-16 05:28:33 +00:00
2026-02-08 18:08:13 +01:00
GatewayDiagnostics . log (
" connect manual host= \( host ) port= \( self . manualGatewayPort ) tls= \( self . manualGatewayTLS ) " )
2026-01-19 05:44:36 +00:00
await self . gatewayController . connectManual (
host : host ,
port : self . manualGatewayPort ,
useTLS : self . manualGatewayTLS )
2026-01-16 05:28:33 +00:00
}
2026-02-08 18:08:13 +01:00
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
}
2025-12-14 02:46:59 +00:00
private static func parseHostPort ( from address : String ) -> SettingsHostPort ? {
SettingsNetworkingHelpers . parseHostPort ( from : address )
2025-12-13 23:40:12 +00:00
}
private static func httpURLString ( host : String ? , port : Int ? , fallback : String ) -> String {
2025-12-14 02:46:59 +00:00
SettingsNetworkingHelpers . httpURLString ( host : host , port : port , fallback : fallback )
2025-12-13 23:40:12 +00:00
}
2025-12-29 22:11:12 +01:00
2026-02-16 16:07:22 +00:00
private func resetOnboarding ( ) {
// D i s c o n n e c t f i r s t s o R o o t C a n v a s d o e s n ' t i n s t a n t l y m a r k o n b o a r d i n g c o m p l e t e a g a i n .
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 )
}
// R e s e t o n b o a r d i n g s t a t e + c l e a r s a v e d g a t e w a y c o n n e c t i o n ( t h e t w o t h i n g s R o o t C a n v a s c h e c k s ) .
GatewaySettingsStore . clearLastGatewayConnection ( )
// R o o t C a n v a s a l s o s h o r t - c i r c u i t s o n b o a r d i n g w h e n t h e s e a r e t r u e .
self . onboardingComplete = false
self . hasConnectedOnce = false
// C l e a r m a n u a l o v e r r i d e s o i t d o e s n ' t c o u n t a s a n e x i s t i n g g a t e w a y c o n f i g .
self . manualGatewayEnabled = false
self . manualGatewayHost = " "
// F o r c e r e - p r e s e n t e v e n w i t h o u t a p p r e s t a r t .
self . onboardingRequestID += 1
// T h e o n b o a r d i n g w i z a r d i s p r e s e n t e d f r o m R o o t C a n v a s ; d i s m i s s S e t t i n g s s o i t c a n s h o w .
self . dismiss ( )
}
2026-01-19 05:44:36 +00:00
private func gatewayDetailLines ( _ gateway : GatewayDiscoveryModel . DiscoveredGateway ) -> [ String ] {
2025-12-29 22:11:12 +01:00
var lines : [ String ] = [ ]
2026-01-19 05:44:36 +00:00
if let lanHost = gateway . lanHost { lines . append ( " LAN: \( lanHost ) " ) }
if let tailnet = gateway . tailnetDns { lines . append ( " Tailnet: \( tailnet ) " ) }
2025-12-29 22:11:12 +01:00
2026-01-19 05:44:36 +00:00
let gatewayPort = gateway . gatewayPort
let canvasPort = gateway . canvasPort
if gatewayPort != nil || canvasPort != nil {
2025-12-29 22:11:12 +01:00
let gw = gatewayPort . map ( String . init ) ? ? " — "
let canvas = canvasPort . map ( String . init ) ? ? " — "
2026-01-19 05:44:36 +00:00
lines . append ( " Ports: gateway \( gw ) · canvas \( canvas ) " )
2025-12-29 22:11:12 +01:00
}
if lines . isEmpty {
2026-01-19 05:44:36 +00:00
lines . append ( gateway . debugID )
2025-12-29 22:11:12 +01:00
}
return lines
}
2025-12-12 21:19:34 +00:00
}