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:
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
+ }
+}