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