field_ios

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

commit 948ebebe06c1046ba0c346c7fbff931815c3c724
parent 4779c742f1063b5672870fdbd4297b267d8e1b38
Author: triesap <triesap@radroots.dev>
Date:   Sat,  4 Oct 2025 00:14:01 +0100

Add home, profile, and tab navigation views with a refactored app entry, replacing the legacy root container with a tabbed navigation stack. Refactor settings, setup, and state management to support account lifecycle, key import/export with keychain persistence, and async task–based status polling.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 16++++++++++++----
MRadroots/App/App.swift | 4++--
ARadroots/App/AppRoot.swift | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Views/HomeView.swift | 41+++++++++++++++++++++++++++++++++++++++++
ARadroots/Views/ProfileView.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
DRadroots/Views/RootView.swift | 39---------------------------------------
MRadroots/Views/SettingsView.swift | 75++++++++++++++++++++++++++++++++++++++-------------------------------------
MRadroots/Views/SetupView.swift | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
MRadrootsKit/Sources/RadrootsKit/AppState.swift | 15++++++++++-----
9 files changed, 346 insertions(+), 143 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -7,10 +7,12 @@ objects = { /* Begin PBXBuildFile section */ - 009A19C0FBB0BA98FFAB539B /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD48C5F6B21222517F1CD77B /* RootView.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 */; }; 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.swift */; }; + AAB91B301EF4BDC34E1E509D /* AppRoot.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B38C31DC432181E11AF /* AppRoot.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 */; }; @@ -20,6 +22,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 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>"; }; 2818363B157125491FB84A1E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; @@ -31,9 +34,10 @@ 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>"; }; ADE61264E2C98E73828E8680 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.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>"; }; E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; - FD48C5F6B21222517F1CD77B /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; }; + FDE71B38C31DC432181E11AF /* AppRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoot.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -52,6 +56,7 @@ isa = PBXGroup; children = ( 2818363B157125491FB84A1E /* App.swift */, + FDE71B38C31DC432181E11AF /* AppRoot.swift */, ); path = App; sourceTree = "<group>"; @@ -115,7 +120,8 @@ BD0E20D32DF34D9E7C3EBCD2 /* Views */ = { isa = PBXGroup; children = ( - FD48C5F6B21222517F1CD77B /* RootView.swift */, + 0A0274A0260D1C04F40C71AF /* HomeView.swift */, + C71A93F98C7B93188748B99B /* ProfileView.swift */, E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */, 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */, ); @@ -228,9 +234,11 @@ buildActionMask = 2147483647; files = ( C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */, + AAB91B301EF4BDC34E1E509D /* AppRoot.swift in Sources */, E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */, + 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, - 009A19C0FBB0BA98FFAB539B /* RootView.swift in Sources */, + 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */, C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */, 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */, 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */, diff --git a/Radroots/App/App.swift b/Radroots/App/App.swift @@ -2,11 +2,11 @@ import SwiftUI import RadrootsKit @main -struct MyApp: App { +struct RadrootsApp: App { var body: some Scene { WindowGroup { RadrootsProvider { - RootView() + AppRootView() } } } diff --git a/Radroots/App/AppRoot.swift b/Radroots/App/AppRoot.swift @@ -0,0 +1,81 @@ +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/Views/HomeView.swift b/Radroots/Views/HomeView.swift @@ -0,0 +1,41 @@ +import SwiftUI +import RadrootsKit + +struct HomeView: 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) + } + } + + 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) + } + } + } + .listStyle(.insetGrouped) + } +} diff --git a/Radroots/Views/ProfileView.swift b/Radroots/Views/ProfileView.swift @@ -0,0 +1,48 @@ +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 ?? "—") + } + 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) + } + } + } + .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 load() async { + guard let rt = radroots.runtime, appState.hasKey else { + apply(profile: nil) + return + } + let profile = rt.nostrProfileForSelf() + apply(profile: profile) +} +} diff --git a/Radroots/Views/RootView.swift b/Radroots/Views/RootView.swift @@ -1,39 +0,0 @@ -import SwiftUI -import RadrootsKit - -struct RootView: View { - @EnvironmentObject private var app: AppState - - var body: some View { - NavigationStack { - List { - Section { - NavigationLink("Settings") { - SettingsView() - } - NavigationLink("Setup") { - SetupView() - } - } - } - .navigationTitle("Radroots") - } - .onAppear { app.refresh() } - .applyKeyChangeHandler(app: app) - } -} - -private extension View { - @ViewBuilder - func applyKeyChangeHandler(app: AppState) -> some View { - if #available(iOS 17.0, *) { - self.onChange(of: app.hasKey) { _, newValue in - if newValue { app.refresh() } - } - } else { - self.onChange(of: app.hasKey) { _ in - app.refresh() - } - } - } -} diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -3,55 +3,56 @@ import RadrootsKit struct SettingsView: View { @EnvironmentObject private var app: AppState + @EnvironmentObject private var radroots: Radroots + @EnvironmentObject private var keys: RadrootsKeys + @State private var exportError: String? var body: some View { - Form { - Section("Runtime Info") { - TextEditor(text: .constant(app.infoJSONString)) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 200) - .disabled(true) - } - - Section("Keys") { - if app.hasKey { - VStack(alignment: .leading, spacing: 8) { - Text("Key loaded") - .font(.headline) - if let npub = app.npub { - Text(npub) - .textSelection(.enabled) - .font(.system(.footnote, design: .monospaced)) - } + List { + Section("Account") { + if let npub = app.npub { + HStack { + Text("npub") + Spacer() + Text(npub) + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) } } else { - Text("No key loaded") + Text("No key configured") + .foregroundStyle(.secondary) } } - Section("Relays") { - HStack(spacing: 8) { - Circle() - .fill(color(for: app.relayLight)) - .frame(width: 10, height: 10) - Text("Connected \(app.relayConnectedCount) • Connecting \(app.relayConnectingCount)") - } - if let err = app.relayLastError { - Text(err) - .font(.footnote) - .foregroundStyle(.secondary) + if app.hasKey { + Section { + Button(role: .none) { + exportSecretHex() + } label: { + Label("Export Secret Hex (Danger)", systemImage: "square.and.arrow.up") + } + } footer: { + if let exportError { + Text(exportError).foregroundStyle(.red) + } else { + Text("Keep your secret key safe. Anyone with it controls your identity.") + } } } } - .navigationTitle("Settings") - .onAppear { app.refresh() } + .listStyle(.insetGrouped) } - private func color(for light: AppState.RelayLight) -> Color { - switch light { - case .green: return .green - case .yellow: return .yellow - case .red: return .red + private func exportSecretHex() { + guard let rt = radroots.runtime else { return } + exportError = nil + do { + let hex = try rt.keysExportSecretHex() + UIPasteboard.general.string = hex + } catch { + exportError = String(describing: error) } } } diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift @@ -2,81 +2,139 @@ import SwiftUI import RadrootsKit struct SetupView: View { - @EnvironmentObject private var app: Radroots + @EnvironmentObject private var app: AppState + @EnvironmentObject private var radroots: Radroots @EnvironmentObject private var keys: RadrootsKeys - @State private var busy = false - @State private var errorText: String? + + var onSuccess: (() -> Void)? = nil + + @State private var isWorking = false + @State private var errorMessage: String? var body: some View { - VStack(spacing: 24) { - Spacer() + 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: 8) { - Text(Ls.setupGreetingHeader) - .font(.largeTitle).bold() - Text(Ls.setupGreetingHeaderSub) - .font(.body) - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - .padding(.horizontal, 24) - } + 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) + } + .buttonStyle(.borderedProminent) + .disabled(isWorking) - Button { - generate() - } label: { - HStack { - if busy { ProgressView().tint(.white) } - Text(busy ? "Generating…" : "Generate & Save Keypair") - .bold() + Button { + importFromClipboard() + } label: { + Text("Import Secret Hex from Clipboard") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(isWorking) } - .frame(maxWidth: .infinity) - .padding() - .background(busy ? Color.gray : Color.accentColor) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - .disabled(busy) - .padding(.horizontal, 24) + .padding(.top, 8) + + Spacer() - if let npub = keys.npub { - Text("Public key") - .font(.caption).foregroundColor(.secondary) - Text(npub) + Text("Your private key is stored securely in the iOS Keychain.") .font(.footnote) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .textSelection(.enabled) - .padding(.horizontal, 24) } + .padding() + .navigationTitle("Setup") + } + } - Spacer() + private func generateKey() { + guard let rt = radroots.runtime else { + errorMessage = "Runtime not ready. Please relaunch." + return } - .padding(.vertical, 16) - .alert("Key Generation Failed", isPresented: Binding( - get: { errorText != nil }, - set: { _ in errorText = nil } - )) { - Button("OK", role: .cancel) {} - } message: { - Text(errorText ?? "") + errorMessage = nil + isWorking = true + Task { @MainActor in + do { + try keys.generateAndPersist(runtime: rt) + app.refresh() + onSuccess?() + } catch { + errorMessage = String(describing: error) + } + isWorking = false } } - @MainActor - private func generate() { - busy = true - Task { - defer { busy = false } + private func importFromClipboard() { + guard let rt = radroots.runtime else { + errorMessage = "Runtime not ready. Please relaunch." + return + } + errorMessage = nil + isWorking = true + Task { @MainActor in do { - if app.runtime == nil { - try app.start() // ensure net-core runtime exists for key ops + let paste = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let hex = paste, !hex.isEmpty else { + throw NSError(domain: "Setup", code: -1, userInfo: [NSLocalizedDescriptionKey: "Clipboard is empty."]) } - guard let rt = app.runtime else { - throw NSError(domain: "Radroots", code: -1, userInfo: [NSLocalizedDescriptionKey: "Runtime not initialized."]) - } - try keys.generateAndPersist(runtime: rt) // saves in Keychain and sets active profile + 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) + + app.refresh() + onSuccess?() } catch { - errorText = String(describing: error) + errorMessage = String(describing: error) } + isWorking = false } } } + +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 @@ -18,13 +18,17 @@ public final class AppState: ObservableObject { public let radroots: Radroots public let keys: RadrootsKeys - private var statusTimer: Timer? + private var statusTask: Task<Void, Never>? public init(radroots: Radroots = Radroots(), keys: RadrootsKeys = RadrootsKeys()) { self.radroots = radroots self.keys = keys } + deinit { + statusTask?.cancel() + } + public func start() throws { try radroots.start() if let rt = radroots.runtime { @@ -47,10 +51,11 @@ public final class AppState: ObservableObject { } private func startPollingStatus() { - statusTimer?.invalidate() - statusTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.updateStatus() + statusTask?.cancel() + statusTask = Task { [weak self] in + while !Task.isCancelled { + await MainActor.run { self?.updateStatus() } + try? await Task.sleep(nanoseconds: 1_000_000_000) } } }