field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

commit 8a94e2310397bf7205bbc635a2a682a0bbc670bc
parent 948ebebe06c1046ba0c346c7fbff931815c3c724
Author: triesap <triesap@radroots.dev>
Date:   Sat,  4 Oct 2025 21:12:34 +0100

Add build-time configuration system with new xcconfig files, dynamic logging initialization, and relay/environment settings integration. Replace legacy app root with a gated app entry and expand UI with updated profile, relay, and setup views plus shared navigation and button components.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 48++++++++++++++++++++++++++++++++++++++++++------
MRadroots/App/App.swift | 4+++-
ARadroots/App/AppEntry.swift | 37+++++++++++++++++++++++++++++++++++++
DRadroots/App/AppRoot.swift | 81-------------------------------------------------------------------------------
ARadroots/Config/Base.xcconfig | 8++++++++
ARadroots/Config/Common.xcconfig | 1+
ARadroots/Config/Debug.xcconfig | 7+++++++
ARadroots/Config/Release.xcconfig | 7+++++++
MRadroots/Info.plist | 11+++++++++++
ARadroots/Shared/Components/SectionWideButton.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Shared/Extensions/View+Nav.swift | 13+++++++++++++
MRadroots/Shared/Logging/Logger.swift | 2+-
MRadroots/Views/HomeView.swift | 69++++++++++++++++++++++++++++++++++++++++++++++-----------------------
MRadroots/Views/ProfileView.swift | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
ARadroots/Views/RelaysView.swift | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Views/SetupView.swift | 115++++++++++++++++++++++++++++---------------------------------------------------
MRadrootsKit/Sources/RadrootsKit/AppState.swift | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
ARadrootsKit/Sources/RadrootsKit/BuildConfig.swift | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadrootsKit/Sources/RadrootsKit/LoggingSettings.swift | 36++++++++++++++++++++++++++++++++++++
MRadrootsKit/Sources/RadrootsKit/Radroots.swift | 19++++++++++---------
MRadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift | 11++++++++++-
MRadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift | 20+++++++++++++++-----
ARadrootsKit/Sources/RadrootsKit/RelaySettings.swift | 37+++++++++++++++++++++++++++++++++++++
23 files changed, 761 insertions(+), 256 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -7,12 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 022DA21729F49893319717AA /* RelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D9496F9F05A4E79E73A247 /* RelaysView.swift */; }; 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0274A0260D1C04F40C71AF /* HomeView.swift */; }; 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; }; 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; }; 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; }; + 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; }; 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.swift */; }; - AAB91B301EF4BDC34E1E509D /* AppRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B38C31DC432181E11AF /* AppRoot.swift */; }; + A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA8F5611075F60F214A00 /* SectionWideButton.swift */; }; + B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */; }; C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818363B157125491FB84A1E /* App.swift */; }; C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE790CA1CD31208947913B9 /* Logger.swift */; }; C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */; }; @@ -22,6 +25,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 08FA88664E5E3ED3A24D56CC /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; 0A0274A0260D1C04F40C71AF /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; 138AA7BAA021EE13E829390B /* Bundle+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Build.swift"; sourceTree = "<group>"; }; 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; }; @@ -30,14 +34,20 @@ 3FADD6E2563CC9AF9F935DCE /* RadrootsKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = RadrootsKit; path = RadrootsKit; sourceTree = SOURCE_ROOT; }; 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; }; 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.git.xcconfig; sourceTree = "<group>"; }; + 676B89EB116B60AE8C2B4313 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; }; 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.local.xcconfig; sourceTree = "<group>"; }; + 7C294E8EF50F5E1E73F5C135 /* Common.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = "<group>"; }; 93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93D729E070C32490545FA837 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; + A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Nav.swift"; sourceTree = "<group>"; }; ADE61264E2C98E73828E8680 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; }; + B289F4B276245ABE083D777F /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; + C17CA8F5611075F60F214A00 /* SectionWideButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionWideButton.swift; sourceTree = "<group>"; }; + C1D9496F9F05A4E79E73A247 /* RelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaysView.swift; sourceTree = "<group>"; }; C71A93F98C7B93188748B99B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; }; CCF0F7B3C57D8D770F178329 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; + D448C9655B708CA3FA8712B9 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; }; E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; - FDE71B38C31DC432181E11AF /* AppRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoot.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,11 +66,22 @@ isa = PBXGroup; children = ( 2818363B157125491FB84A1E /* App.swift */, - FDE71B38C31DC432181E11AF /* AppRoot.swift */, + D448C9655B708CA3FA8712B9 /* AppEntry.swift */, ); path = App; sourceTree = "<group>"; }; + 579F407D96CCAFD4000EF363 /* Config */ = { + isa = PBXGroup; + children = ( + 676B89EB116B60AE8C2B4313 /* Base.xcconfig */, + 7C294E8EF50F5E1E73F5C135 /* Common.xcconfig */, + B289F4B276245ABE083D777F /* Debug.xcconfig */, + 08FA88664E5E3ED3A24D56CC /* Release.xcconfig */, + ); + path = Config; + sourceTree = "<group>"; + }; 5FD6379AE27C57D02E8C7EE1 /* Radroots */ = { isa = PBXGroup; children = ( @@ -68,6 +89,7 @@ 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */, 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */, 23C2D7FF63B61CD356979E82 /* App */, + 579F407D96CCAFD4000EF363 /* Config */, E9C466456E8A8EEB73EE47F5 /* Resources */, 932F9ACAF6A7D30E34F2E375 /* Shared */, BD0E20D32DF34D9E7C3EBCD2 /* Views */, @@ -94,6 +116,7 @@ 932F9ACAF6A7D30E34F2E375 /* Shared */ = { isa = PBXGroup; children = ( + D46F444AD1818932F03AC6B6 /* Components */, 9D22575D1FAD99FE8B6FCE6C /* Extensions */, 65EC1C4AF7DC676E78603D52 /* Localisation */, F16A19713274742D956C3A4D /* Logging */, @@ -113,6 +136,7 @@ isa = PBXGroup; children = ( 138AA7BAA021EE13E829390B /* Bundle+Build.swift */, + A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */, ); path = Extensions; sourceTree = "<group>"; @@ -122,6 +146,7 @@ children = ( 0A0274A0260D1C04F40C71AF /* HomeView.swift */, C71A93F98C7B93188748B99B /* ProfileView.swift */, + C1D9496F9F05A4E79E73A247 /* RelaysView.swift */, E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */, 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */, ); @@ -145,6 +170,14 @@ path = Localizations; sourceTree = "<group>"; }; + D46F444AD1818932F03AC6B6 /* Components */ = { + isa = PBXGroup; + children = ( + C17CA8F5611075F60F214A00 /* SectionWideButton.swift */, + ); + path = Components; + sourceTree = "<group>"; + }; E9C466456E8A8EEB73EE47F5 /* Resources */ = { isa = PBXGroup; children = ( @@ -234,14 +267,17 @@ buildActionMask = 2147483647; files = ( C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */, - AAB91B301EF4BDC34E1E509D /* AppRoot.swift in Sources */, + 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */, E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */, + 022DA21729F49893319717AA /* RelaysView.swift in Sources */, + A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */, C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */, 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */, 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */, + B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -261,7 +297,7 @@ /* Begin XCBuildConfiguration section */ 5486087B252C6EA76ADD9BB8 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */; + baseConfigurationReference = B289F4B276245ABE083D777F /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; @@ -279,7 +315,7 @@ }; D930E9391B5B63DB518922D2 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */; + baseConfigurationReference = 08FA88664E5E3ED3A24D56CC /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; diff --git a/Radroots/App/App.swift b/Radroots/App/App.swift @@ -6,7 +6,9 @@ struct RadrootsApp: App { var body: some Scene { WindowGroup { RadrootsProvider { - AppRootView() + AppEntry { + HomeView() + } } } } diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -0,0 +1,37 @@ +import SwiftUI +import RadrootsKit + +public struct AppEntry<Main: View>: View { + @EnvironmentObject private var appState: AppState + private let main: () -> Main + + public init(@ViewBuilder main: @escaping () -> Main) { + self.main = main + } + + public var body: some View { + NavigationStack { + Group { + switch appState.bootstrapPhase { + case .idle, .starting: + SplashView() + case .ready: + if appState.canShowAppContent { + main() + } else { + SetupView() + } + } + } + } + } +} + +private struct SplashView: View { + var body: some View { + ZStack { + Color(.systemBackground).ignoresSafeArea() + ProgressView().controlSize(.large) + } + } +} diff --git a/Radroots/App/AppRoot.swift b/Radroots/App/AppRoot.swift @@ -1,81 +0,0 @@ -import SwiftUI -import RadrootsKit - -struct AppRootView: View { - @EnvironmentObject private var app: AppState - @State private var selectedTab: MainTab = .home - - var body: some View { - MainTabs(selection: $selectedTab) - .fullScreenCover( - isPresented: Binding( - get: { app.hasKey == false }, - set: { _ in } - ) - ) { - SetupView { - app.refresh() - } - .interactiveDismissDisabled(true) - } - } -} - -enum MainTab: Hashable { - case home - case settings -} - -private struct MainTabs: View { - @EnvironmentObject private var app: AppState - @Binding var selection: MainTab - - var body: some View { - TabView(selection: $selection) { - RequiresKey { - NavigationStack { - HomeView() - .navigationTitle("Home") - } - } - .tabItem { Label("Home", systemImage: "house.fill") } - .tag(MainTab.home) - - NavigationStack { - SettingsView() - .navigationTitle("Settings") - } - .tabItem { Label("Settings", systemImage: "gearshape.fill") } - .tag(MainTab.settings) - } - } -} - -private struct RequiresKey<Content: View>: View { - @EnvironmentObject private var app: AppState - @ViewBuilder var content: () -> Content - - var body: some View { - if app.hasKey { - content() - } else { - LockedView() - } - } -} - -private struct LockedView: View { - var body: some View { - VStack(spacing: 12) { - Image(systemName: "lock.fill") - .font(.system(size: 44, weight: .semibold)) - Text("Locked") - .font(.title2.weight(.semibold)) - Text("Create or import a Nostr key to continue.") - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .padding() - } -} diff --git a/Radroots/Config/Base.xcconfig b/Radroots/Config/Base.xcconfig @@ -0,0 +1,8 @@ +#include "../radroots.xcconfig" +#include "Common.xcconfig" + +RR_LOG_STDOUT = YES +RR_LOG_LEVEL = info +RR_LOG_FILE_ENABLED = NO +RR_LOG_FILE_NAME = radroots.log +NOSTR_RELAYS = wss:$(SLASH)$(SLASH)relay.radroots.org diff --git a/Radroots/Config/Common.xcconfig b/Radroots/Config/Common.xcconfig @@ -0,0 +1 @@ +SLASH = / diff --git a/Radroots/Config/Debug.xcconfig b/Radroots/Config/Debug.xcconfig @@ -0,0 +1,7 @@ +#include "Base.xcconfig" + +RR_LOG_STDOUT = YES +RR_LOG_LEVEL = debug +RR_LOG_FILE_ENABLED = NO +RR_LOG_FILE_NAME = radroots_debug.log +NOSTR_RELAYS = ws:$(SLASH)$(SLASH)localhost:21648 diff --git a/Radroots/Config/Release.xcconfig b/Radroots/Config/Release.xcconfig @@ -0,0 +1,7 @@ +#include "Base.xcconfig" + +RR_LOG_STDOUT = NO +RR_LOG_LEVEL = info +RR_LOG_FILE_ENABLED = YES +RR_LOG_FILE_NAME = radroots.log +NOSTR_RELAYS = wss:$(SLASH)$(SLASH)relay.radroots.org diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -56,5 +56,16 @@ <string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeRight</string> </array> + + <key>RR_LOG_STDOUT</key> + <string>$(RR_LOG_STDOUT)</string> + <key>RR_LOG_LEVEL</key> + <string>$(RR_LOG_LEVEL)</string> + <key>RR_LOG_FILE_ENABLED</key> + <string>$(RR_LOG_FILE_ENABLED)</string> + <key>RR_LOG_FILE_NAME</key> + <string>$(RR_LOG_FILE_NAME)</string> + <key>NOSTR_RELAYS</key> + <string>$(NOSTR_RELAYS)</string> </dict> </plist> diff --git a/Radroots/Shared/Components/SectionWideButton.swift b/Radroots/Shared/Components/SectionWideButton.swift @@ -0,0 +1,45 @@ +import SwiftUI + +public struct SectionWideButton: View { + private let title: String + private let enabled: Bool + private let isProminent: Bool + private let action: () -> Void + + public init( + _ title: String, + enabled: Bool = true, + isProminent: Bool = false, + action: @escaping () -> Void + ) { + self.title = title + self.enabled = enabled + self.isProminent = isProminent + self.action = action + } + + public var body: some View { + Button(action: action) { + Text(title) + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .foregroundStyle(foregroundStyle) + .disabled(!enabled) + .listRowBackground(backgroundStyle) + .animation(.easeInOut(duration: 0.15), value: isProminent) + .accessibilityAddTraits(.isButton) + } + + private var backgroundStyle: Color { + guard enabled else { return Color.secondary.opacity(0.25) } + return isProminent ? .accentColor : Color.secondary.opacity(0.15) + } + + private var foregroundStyle: Color { + guard enabled else { return .secondary } + return isProminent ? .white : .primary + } +} diff --git a/Radroots/Shared/Extensions/View+Nav.swift b/Radroots/Shared/Extensions/View+Nav.swift @@ -0,0 +1,13 @@ +import SwiftUI + +extension View { + func inlineNavigationTitle(_ title: LocalizedStringKey) -> some View { + navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } + + func inlineNavigationTitle(_ title: String) -> some View { + navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Radroots/Shared/Logging/Logger.swift b/Radroots/Shared/Logging/Logger.swift @@ -2,7 +2,7 @@ import Foundation import RadrootsKit import os -private let oslog = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "App") +private let oslog = os.Logger(subsystem: Bundle.main.bundleIdentifier ?? "Radroots", category: "App") enum RadrootsLogger { static func info(_ message: String) { diff --git a/Radroots/Views/HomeView.swift b/Radroots/Views/HomeView.swift @@ -1,41 +1,64 @@ import SwiftUI import RadrootsKit +private enum HomeTab: Hashable { + case home + case settings +} + struct HomeView: View { + @State private var selection: HomeTab = .home + + var body: some View { + TabView(selection: $selection) { + HomeDashboardView() + .tabItem { Label("Home", systemImage: "house.fill") } + .tag(HomeTab.home) + + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape.fill") } + .tag(HomeTab.settings) + } + } +} + +private struct HomeDashboardView: View { @EnvironmentObject private var app: AppState var body: some View { List { Section("Your Identity") { - HStack { - Text("npub") - Spacer() - Text(app.npub ?? "—") - .font(.callout.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) + NavigationLink { + ProfileView() + } label: { + HStack { + Text("Profile") + Spacer() + Text(app.npub ?? "—") + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } } } Section("Relays") { - HStack { - Label("Connected", systemImage: "dot.radiowaves.left.and.right") - Spacer() - Text("\(app.relayConnectedCount)") - } - HStack { - Label("Connecting", systemImage: "antenna.radiowaves.left.and.right") - Spacer() - Text("\(app.relayConnectingCount)") - } - if let last = app.relayLastError { - Text(last) - .foregroundStyle(.red) - .font(.footnote) + NavigationLink { + RelaysView() + } label: { + HStack { + Text("Relays") + Spacer() + if app.relayConnectedCount > 0 { + Label("\(app.relayConnectedCount)", systemImage: "dot.radiowaves.left.and.right") + .labelStyle(.titleAndIcon) + .foregroundStyle(.secondary) + } + } } } } - .listStyle(.insetGrouped) + .inlineNavigationTitle("Home") } } diff --git a/Radroots/Views/ProfileView.swift b/Radroots/Views/ProfileView.swift @@ -1,48 +1,177 @@ import SwiftUI import RadrootsKit -struct ProfileView: View { -@EnvironmentObject private var appState: AppState -@EnvironmentObject private var radroots: Radroots - -@State private var name: String = "" -@State private var displayName: String = "" -@State private var nip05: String = "" -@State private var about: String = "" - -var body: some View { - Form { - Section("Account") { - LabeledContent("npub", value: appState.npub ?? "—") +public struct ProfileView: View { + @EnvironmentObject private var app: AppState + + @State private var name: String = "" + @State private var displayName: String = "" + @State private var nip05: String = "" + @State private var about: String = "" + + @State private var original: OriginalProfile = .empty + @State private var isLoading: Bool = false + @State private var isPosting: Bool = false + @State private var postMessage: String? + @State private var showMessage: Bool = false + @FocusState private var focusedField: Field? + + enum Field: Hashable { case name, displayName, nip05, about } + + public init() {} + + public var body: some View { + Form { + Section(header: Text("Profile")) { + LabeledContent("name") { + TextField("name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focusedField, equals: .name) + .onSubmit { focusedField = .displayName } + } + + LabeledContent("display_name") { + TextField("display name", text: $displayName) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focusedField, equals: .displayName) + .onSubmit { focusedField = .nip05 } + } + + LabeledContent("nip05") { + TextField("user@example.com", text: $nip05) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focusedField, equals: .nip05) + .onSubmit { focusedField = .about } + } + + LabeledContent("about") { + TextEditor(text: $about) + .frame(minHeight: 120) + .focused($focusedField, equals: .about) + } + } + + Section { + SectionWideButton("Post Kind 0", enabled: isPostEnabled, isProminent: hasChanges) { + post() + } + .animation(.easeInOut(duration: 0.15), value: hasChanges) + } footer: { + VStack(alignment: .leading, spacing: 6) { + if !isConnected { + Text("No relays connected. Connect to at least one relay to post.") + } + if let msg = postMessage { + Text(msg) + } + } + } + } + .inlineNavigationTitle("Profile") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if isLoading || isPosting { ProgressView() } + } + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Button("Done") { focusedField = nil } + } + } + } + .onAppear { loadProfile() } + .refreshable { loadProfile() } + .alert("Post Result", isPresented: $showMessage) { + Button("OK", role: .cancel) { } + } message: { + Text(postMessage ?? "") } - Section("Profile") { - LabeledContent("name", value: name.isEmpty ? "—" : name) - LabeledContent("display_name", value: displayName.isEmpty ? "—" : displayName) - LabeledContent("nip05", value: nip05.isEmpty ? "—" : nip05) - VStack(alignment: .leading, spacing: 8) { - Text("about").font(.footnote).foregroundStyle(.secondary) - Text(about.isEmpty ? "—" : about) + } + + private var isConnected: Bool { app.relayConnectedCount > 0 } + private var isPostEnabled: Bool { isConnected && !isPosting } + private var hasChanges: Bool { + name != original.name || + displayName != original.displayName || + nip05 != original.nip05 || + about != original.about + } + + private func loadProfile() { + guard let rt = app.radroots.runtime else { return } + isLoading = true + Task { + let prof = rt.nostrProfileForSelf() + await MainActor.run { + self.original = OriginalProfile.from(prof) + self.name = original.name + self.displayName = original.displayName + self.nip05 = original.nip05 + self.about = original.about + self.isLoading = false } } } - .navigationTitle("Profile") - .task { await load() } - .refreshable { await load() } -} -private func apply(profile: NostrProfile?) { - name = profile?.name ?? "" - displayName = profile?.displayName ?? "" - nip05 = profile?.nip05 ?? "" - about = profile?.about ?? "" + private func post() { + guard let rt = app.radroots.runtime, isPostEnabled else { return } + isPosting = true + postMessage = nil + let payload = PostPayload(name: name, displayName: displayName, nip05: nip05, about: about) + Task { + do { + let id = try rt.nostrPostProfile( + name: payload.name, + displayName: payload.displayName, + nip05: payload.nip05, + about: payload.about + ) + await MainActor.run { + self.original = OriginalProfile(name: name, displayName: displayName, nip05: nip05, about: about) + self.isPosting = false + self.postMessage = "Posted kind:0 event: \(id)" + self.showMessage = true + self.app.refresh() + } + } catch { + await MainActor.run { + self.isPosting = false + self.postMessage = "Failed to post profile: \(error)" + self.showMessage = true + } + } + } + } } -private func load() async { - guard let rt = radroots.runtime, appState.hasKey else { - apply(profile: nil) - return +private struct OriginalProfile: Equatable { + var name: String + var displayName: String + var nip05: String + var about: String + + static let empty = OriginalProfile(name: "", displayName: "", nip05: "", about: "") + + static func from(_ p: NostrProfile?) -> OriginalProfile { + OriginalProfile( + name: p?.name ?? "", + displayName: p?.displayName ?? "", + nip05: p?.nip05 ?? "", + about: p?.about ?? "" + ) } - let profile = rt.nostrProfileForSelf() - apply(profile: profile) } + +private struct PostPayload { + var name: String + var displayName: String + var nip05: String + var about: String } diff --git a/Radroots/Views/RelaysView.swift b/Radroots/Views/RelaysView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import RadrootsKit + +struct RelaysView: View { + @EnvironmentObject private var app: AppState + + private var configuredRelays: [String] { + (try? RelaySettings.relays()) ?? [] + } + + var body: some View { + List { + Section("Relays") { + RelayMetricRow(label: "Connected", systemImage: "dot.radiowaves.left.and.right", value: app.relayConnectedCount) + RelayMetricRow(label: "Connecting", systemImage: "antenna.radiowaves.left.and.right", value: app.relayConnectingCount) + if let last = app.relayLastError { + Text(last) + .foregroundStyle(.red) + .font(.footnote) + } + } + + Section("Configured Relays") { + if configuredRelays.isEmpty { + Text("No relays configured") + .foregroundStyle(.secondary) + } else { + ForEach(configuredRelays, id: \.self) { url in + Text(url) + .font(.callout.monospaced()) + } + } + } + } + .inlineNavigationTitle("Relays") + } +} + +private struct RelayMetricRow: View { + let label: String + let systemImage: String + let value: UInt32 + + var body: some View { + HStack { + Label(label, systemImage: systemImage) + Spacer() + Text("\(value)") + } + } +} diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift @@ -12,55 +12,53 @@ struct SetupView: View { @State private var errorMessage: String? var body: some View { - NavigationStack { - VStack(spacing: 24) { - Image(systemName: "key.fill") - .font(.system(size: 60, weight: .bold)) - Text("Set up your Nostr Identity") - .font(.title2.weight(.semibold)) + VStack(spacing: 24) { + Image(systemName: "key.fill") + .font(.system(size: 60, weight: .bold)) + Text("Set up your Nostr Identity") + .font(.title2.weight(.semibold)) - if let errorMessage { - Text(errorMessage) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.horizontal) - } + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } - VStack(spacing: 12) { - Button { - generateKey() - } label: { - HStack { - if isWorking { ProgressView().padding(.trailing, 8) } - Text("Generate New Key") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) + VStack(spacing: 12) { + Button { + generateKey() + } label: { + HStack { + if isWorking { ProgressView().padding(.trailing, 8) } + Text("Generate New Key") + .fontWeight(.semibold) } - .buttonStyle(.borderedProminent) - .disabled(isWorking) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isWorking) - Button { - importFromClipboard() - } label: { - Text("Import Secret Hex from Clipboard") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(isWorking) + Button { + importFromClipboard() + } label: { + Text("Import Secret Hex from Clipboard") + .frame(maxWidth: .infinity) } - .padding(.top, 8) + .buttonStyle(.bordered) + .disabled(isWorking) + } + .padding(.top, 8) - Spacer() + Spacer() - Text("Your private key is stored securely in the iOS Keychain.") - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding() - .navigationTitle("Setup") + Text("Your private key is stored securely in the iOS Keychain.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } + .padding() + .inlineNavigationTitle("Setup") } private func generateKey() { @@ -95,12 +93,7 @@ struct SetupView: View { guard let hex = paste, !hex.isEmpty else { throw NSError(domain: "Setup", code: -1, userInfo: [NSLocalizedDescriptionKey: "Clipboard is empty."]) } - try rt.keysLoadHex32(hex: hex) - let exported = try rt.keysExportSecretHex() - let account = rt.keysNpub() ?? "profile-\(Int(Date().timeIntervalSince1970))" - KeychainBridge.save(account: account, hex: exported) - KeychainBridge.setActiveAccount(account: account) - + try keys.importSecretHex(hex: hex, runtime: rt) app.refresh() onSuccess?() } catch { @@ -110,31 +103,3 @@ struct SetupView: View { } } } - -private enum KeychainBridge { - static func save(account: String, hex: String) { - let data = Data(hex.utf8) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.radroots.keys", - kSecAttrAccount as String: account, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - kSecValueData as String: data - ] - SecItemAdd(query as CFDictionary, nil) - } - - static func setActiveAccount(account: String) { - let data = Data(account.utf8) - let base: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.radroots.keys.active", - kSecAttrAccount as String: "active" - ] - SecItemDelete(base as CFDictionary) - var query = base - query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - query[kSecValueData as String] = data - SecItemAdd(query as CFDictionary, nil) - } -} diff --git a/RadrootsKit/Sources/RadrootsKit/AppState.swift b/RadrootsKit/Sources/RadrootsKit/AppState.swift @@ -3,6 +3,13 @@ import Combine @MainActor public final class AppState: ObservableObject { + public enum BootstrapPhase { + case idle + case starting + case ready + } + + @Published public private(set) var bootstrapPhase: BootstrapPhase = .idle @Published public private(set) var infoJSONString: String = "" @Published public private(set) var hasKey: Bool = false @Published public private(set) var npub: String? @@ -11,6 +18,9 @@ public final class AppState: ObservableObject { @Published public private(set) var relayLight: RelayLight = .red @Published public private(set) var relayLastError: String? + public var canShowAppContent: Bool { bootstrapPhase == .ready && hasKey } + public var requiresKeySetup: Bool { bootstrapPhase == .ready && !hasKey } + public enum RelayLight { case red, yellow, green } @@ -19,37 +29,71 @@ public final class AppState: ObservableObject { public let keys: RadrootsKeys private var statusTask: Task<Void, Never>? + private var cancellables = Set<AnyCancellable>() public init(radroots: Radroots = Radroots(), keys: RadrootsKeys = RadrootsKeys()) { self.radroots = radroots self.keys = keys + + keys.$hasKey + .sink { [weak self] in self?.hasKey = $0 } + .store(in: &cancellables) + + keys.$npub + .sink { [weak self] in self?.npub = $0 } + .store(in: &cancellables) } deinit { statusTask?.cancel() } - public func start() throws { - try radroots.start() - if let rt = radroots.runtime { - keys.loadFromKeychainIfPresent(runtime: rt) - if rt.keysIsLoaded() { - try? rt.nostrSetDefaultRelays(relays: ["wss://relay.damus.io"]) - try? rt.nostrConnectIfKeyPresent() - startPollingStatus() + public func start() async throws { + guard bootstrapPhase == .idle else { return } + bootstrapPhase = .starting + do { + try radroots.start() + if let rt = radroots.runtime { + keys.loadFromKeychainIfPresent(runtime: rt) + connectIfPossible() + if rt.keysIsLoaded() { + startPollingStatus() + } } + refresh() + bootstrapPhase = .ready + } catch { + bootstrapPhase = .idle + throw error } - refresh() } public func refresh() { guard let rt = radroots.runtime else { return } - self.infoJSONString = rt.infoJson() - self.hasKey = rt.keysIsLoaded() - self.npub = rt.keysNpub() + infoJSONString = rt.infoJson() + hasKey = rt.keysIsLoaded() + npub = rt.keysNpub() updateStatus() } + public func activateAfterKeyGeneration() { + connectIfPossible() + startPollingStatus() + refresh() + } + + private func connectIfPossible() { + guard let rt = radroots.runtime, rt.keysIsLoaded() else { return } + do { + let relays = try RelaySettings.relays() + try rt.nostrSetDefaultRelays(relays: relays) + try rt.nostrConnectIfKeyPresent() + relayLastError = nil + } catch { + relayLastError = error.localizedDescription + } + } + private func startPollingStatus() { statusTask?.cancel() statusTask = Task { [weak self] in @@ -63,14 +107,15 @@ public final class AppState: ObservableObject { private func updateStatus() { guard let rt = radroots.runtime else { return } let s = rt.nostrConnectionStatus() - self.relayConnectedCount = s.connected - self.relayConnectingCount = s.connecting - self.relayLastError = s.lastError + relayConnectedCount = s.connected + relayConnectingCount = s.connecting + relayLastError = s.lastError + switch s.light { - case .green: self.relayLight = .green - case .yellow: self.relayLight = .yellow - case .red: self.relayLight = .red - @unknown default: self.relayLight = .red + case .green: relayLight = .green + case .yellow: relayLight = .yellow + case .red: relayLight = .red + @unknown default: relayLight = .red } } } diff --git a/RadrootsKit/Sources/RadrootsKit/BuildConfig.swift b/RadrootsKit/Sources/RadrootsKit/BuildConfig.swift @@ -0,0 +1,113 @@ +import Foundation + +enum BuildConfigKey: String { + case logStdout = "RR_LOG_STDOUT" + case logLevel = "RR_LOG_LEVEL" + case logFileEnabled = "RR_LOG_FILE_ENABLED" + case logFileName = "RR_LOG_FILE_NAME" + case nostrRelays = "NOSTR_RELAYS" +} + +enum BuildConfig { + static func string(_ key: BuildConfigKey) -> String? { + let info = infoString(key).map { stripOuterQuotes($0) } + let env = ProcessInfo.processInfo.environment[key.rawValue] + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap { $0.isEmpty ? nil : stripOuterQuotes($0) } + return info ?? env + } + + static func bool(_ key: BuildConfigKey) -> Bool? { + if let v = infoValue(for: key.rawValue) { + if let b = v as? Bool { return b } + if let s = v as? String, let parsed = parseBool(s) { return parsed } + if let n = v as? NSNumber { return n.boolValue } + } + if let env = ProcessInfo.processInfo.environment[key.rawValue], + let parsed = parseBool(env) { + return parsed + } + return nil + } + + static func array(_ key: BuildConfigKey, splitBy set: CharacterSet = .whitespacesAndNewlines) -> [String]? { + if let direct = infoArray(key) { + return direct + .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + guard var raw = string(key)?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty else { return nil } + if raw.first == "[" { + if let data = raw.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [String] { + return arr + .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + } + raw = stripOuterQuotes(raw) + let separators = set.union(CharacterSet(charactersIn: ",;")) + raw = raw.replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + return raw + .components(separatedBy: separators) + .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + static func effectiveDictionary(keys: [BuildConfigKey]) -> [String: Any] { + var out: [String: Any] = [:] + for k in keys { + if let b = bool(k) { + out[k.rawValue] = b + } else if k == .nostrRelays, let arr = array(.nostrRelays) { + out[k.rawValue] = arr + } else if let s = string(k) { + out[k.rawValue] = s + } + } + return out + } + + private static func infoString(_ key: BuildConfigKey) -> String? { + if let v = infoValue(for: key.rawValue) as? String, !v.isEmpty { return v } + if let n = infoValue(for: key.rawValue) as? NSNumber { return n.stringValue } + return nil + } + + private static func infoArray(_ key: BuildConfigKey) -> [String]? { + if let v = infoValue(for: key.rawValue) as? [String] { return v } + if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any], + let v = nested[key.rawValue] as? [String] { + return v + } + return nil + } + + private static func infoValue(for key: String) -> Any? { + if let v = Bundle.main.object(forInfoDictionaryKey: key) { + return v + } + if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any] { + return nested[key] + } + return nil + } + + private static func parseBool(_ s: String) -> Bool? { + switch s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes": return true + case "0", "false", "no": return false + default: return nil + } + } + + private static func stripOuterQuotes(_ s: String) -> String { + guard s.count >= 2 else { return s } + if (s.hasPrefix("\"") && s.hasSuffix("\"")) || (s.hasPrefix("'") && s.hasSuffix("'")) { + return String(s.dropFirst().dropLast()) + } + return s + } +} diff --git a/RadrootsKit/Sources/RadrootsKit/LoggingSettings.swift b/RadrootsKit/Sources/RadrootsKit/LoggingSettings.swift @@ -0,0 +1,36 @@ +import Foundation + +struct LoggingSettings: Equatable { + var stdout: Bool + var fileEnabled: Bool + var fileName: String + var level: String? + + static func load() -> LoggingSettings { + let stdout = BuildConfig.bool(.logStdout) ?? true + let fileEnabled = BuildConfig.bool(.logFileEnabled) ?? false + let fileName = BuildConfig.string(.logFileName) ?? "radroots.log" + let level = BuildConfig.string(.logLevel) + return LoggingSettings(stdout: stdout, fileEnabled: fileEnabled, fileName: fileName, level: level) + } + + func apply() throws { + if let level { + setenv("RUST_LOG", level, 1) + } + if fileEnabled { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.path + try initLogging(dir: dir, fileName: fileName, isStdout: stdout) + } else { + try initLogging(dir: nil, fileName: fileName, isStdout: stdout) + } + } + + func logEffectiveConfigs() { + let keys: [BuildConfigKey] = [.logStdout, .logLevel, .logFileEnabled, .logFileName, .nostrRelays] + let dict = BuildConfig.effectiveDictionary(keys: keys) + let json = (try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys])) ?? Data() + let text = String(data: json, encoding: .utf8) ?? String(describing: dict) + try? logInfo(msg: "radroots.config \(text)") + } +} diff --git a/RadrootsKit/Sources/RadrootsKit/Radroots.swift b/RadrootsKit/Sources/RadrootsKit/Radroots.swift @@ -12,14 +12,22 @@ public final class Radroots: ObservableObject { build: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "0", buildSha: String? = nil ) throws { - try initLoggingIfNeeded() + let settings = LoggingSettings.load() + do { + try settings.apply() + } catch { + try? initLoggingStdout() + } + settings.logEffectiveConfigs() + let rt = try RadrootsRuntime() + let resolvedSha = buildSha ?? (Bundle.main.object(forInfoDictionaryKey: "GIT_SHA") as? String) rt.setAppInfoPlatform( platform: "iOS", bundleId: bundleId, version: version, buildNumber: build, - buildSha: buildSha + buildSha: resolvedSha ) self.runtime = rt } @@ -31,11 +39,4 @@ public final class Radroots: ObservableObject { public func info() -> RuntimeInfo? { runtime?.info() } - - private func initLoggingIfNeeded() throws { - if (try? initLoggingStdout()) == nil { - let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.path - try initLogging(dir: dir, fileName: "radroots.log", isStdout: true) - } - } } diff --git a/RadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift b/RadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift @@ -21,8 +21,17 @@ public final class RadrootsKeys: ObservableObject { public func generateAndPersist(runtime: RadrootsRuntime) throws { _ = try runtime.keysGenerateInMemory() + try persistCurrentKey(runtime: runtime, accountOverride: nil) + } + + public func importSecretHex(hex: String, runtime: RadrootsRuntime) throws { + try runtime.keysLoadHex32(hex: hex) + try persistCurrentKey(runtime: runtime, accountOverride: nil) + } + + private func persistCurrentKey(runtime: RadrootsRuntime, accountOverride: String?) throws { let hex = try runtime.keysExportSecretHex() - let account = runtime.keysNpub() ?? "profile-\(Int(Date().timeIntervalSince1970))" + let account = accountOverride ?? runtime.keysNpub() ?? "profile-\(Int(Date().timeIntervalSince1970))" Keychain.save(service: Keychain.service, account: account, data: Data(hex.utf8)) Keychain.setActiveAccount(account) self.hasKey = runtime.keysIsLoaded() diff --git a/RadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift b/RadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift @@ -2,17 +2,28 @@ import SwiftUI public struct RadrootsProvider<Content: View>: View { @StateObject private var appState = AppState() + private let onStartupError: ((Error) -> Void)? private let content: () -> Content - public init(@ViewBuilder content: @escaping () -> Content) { + public init( + onStartupError: ((Error) -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.onStartupError = onStartupError self.content = content } public var body: some View { - Group { content() } + content() .environmentObject(appState) .environmentObject(appState.keys) .environmentObject(appState.radroots) - .task { try? appState.start() } + .task { + do { + try await appState.start() + } catch { + onStartupError?(error) + } + } } -} -\ No newline at end of file +} diff --git a/RadrootsKit/Sources/RadrootsKit/RelaySettings.swift b/RadrootsKit/Sources/RadrootsKit/RelaySettings.swift @@ -0,0 +1,37 @@ +import Foundation + +public enum RelaySettingsError: LocalizedError { + case noRelaysConfigured + + public var errorDescription: String? { + "No Nostr relays configured. Set build setting 'NOSTR_RELAYS'." + } +} + +public enum RelaySettings { + public static func relays() throws -> [String] { + guard let parts = BuildConfig.array(.nostrRelays) else { + throw RelaySettingsError.noRelaysConfigured + } + let normalized = normalize(parts) + guard !normalized.isEmpty else { + throw RelaySettingsError.noRelaysConfigured + } + return normalized + } + + private static func normalize(_ urls: [String]) -> [String] { + var seen = Set<String>() + var out: [String] = [] + for u in urls { + let trimmed = u.trimmingCharacters(in: .whitespacesAndNewlines) + let unquoted = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) + let lower = unquoted.lowercased() + guard lower.hasPrefix("ws://") || lower.hasPrefix("wss://") else { continue } + if seen.insert(lower).inserted { + out.append(unquoted) + } + } + return out + } +}