field_ios

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

commit 4779c742f1063b5672870fdbd4297b267d8e1b38
parent 7df0250936011a9f289be57bed0683887f5e5106
Author: triesap <triesap@radroots.dev>
Date:   Fri,  3 Oct 2025 22:03:42 +0100

Migrated application state, runtime lifecycle, and key management into shared `RadrootsKit` module with dedicated classes for persistence and provisioning.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 48++++++++++++++++++++----------------------------
MRadroots/App/App.swift | 10+++++-----
DRadroots/App/AppLogging.swift | 28----------------------------
DRadroots/App/AppState.swift | 38--------------------------------------
DRadroots/Features/Home/HomeView.swift | 29-----------------------------
MRadroots/Info.plist | 4----
MRadroots/Resources/Localizations/en.lproj/Localizable.strings | 3+++
MRadroots/Shared/Localisation/Strings.swift | 3+++
ARadroots/Views/RootView.swift | 39+++++++++++++++++++++++++++++++++++++++
ARadroots/Views/SettingsView.swift | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Views/SetupView.swift | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadrootsKit/Sources/RadrootsKit/AppState.swift | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift | 19+++++++++++++++++++
14 files changed, 402 insertions(+), 132 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -7,13 +7,13 @@ objects = { /* Begin PBXBuildFile section */ - 049D620DD8C02816893BF765 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE1472FFD63A33F3AEA6C6C /* AppState.swift */; }; - 1D3283928F795E3DE793DD9F /* AppLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7161D3DA05584F8CC034E392 /* AppLogging.swift */; }; + 009A19C0FBB0BA98FFAB539B /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD48C5F6B21222517F1CD77B /* RootView.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 */; }; - 8E31BEF1598DD26236F94F11 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59247695507084F4976728B5 /* HomeView.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 */; }; DCE468F668A3C346E716B04C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CCF0F7B3C57D8D770F178329 /* Assets.xcassets */; }; E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA7BAA021EE13E829390B /* Bundle+Build.swift */; }; F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */; }; @@ -21,19 +21,19 @@ /* Begin PBXFileReference section */ 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>"; }; 2FE790CA1CD31208947913B9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; }; 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>"; }; - 59247695507084F4976728B5 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; - 7161D3DA05584F8CC034E392 /* AppLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogging.swift; sourceTree = "<group>"; }; 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.local.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>"; }; ADE61264E2C98E73828E8680 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; }; - CBE1472FFD63A33F3AEA6C6C /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.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>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -52,20 +52,10 @@ isa = PBXGroup; children = ( 2818363B157125491FB84A1E /* App.swift */, - 7161D3DA05584F8CC034E392 /* AppLogging.swift */, - CBE1472FFD63A33F3AEA6C6C /* AppState.swift */, ); path = App; sourceTree = "<group>"; }; - 2F0DE71BD6FCCAD5776C7619 /* Features */ = { - isa = PBXGroup; - children = ( - 78B305036F16E40A91C5FE2A /* Home */, - ); - path = Features; - sourceTree = "<group>"; - }; 5FD6379AE27C57D02E8C7EE1 /* Radroots */ = { isa = PBXGroup; children = ( @@ -73,9 +63,9 @@ 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */, 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */, 23C2D7FF63B61CD356979E82 /* App */, - 2F0DE71BD6FCCAD5776C7619 /* Features */, E9C466456E8A8EEB73EE47F5 /* Resources */, 932F9ACAF6A7D30E34F2E375 /* Shared */, + BD0E20D32DF34D9E7C3EBCD2 /* Views */, ); path = Radroots; sourceTree = "<group>"; @@ -96,14 +86,6 @@ path = Localisation; sourceTree = "<group>"; }; - 78B305036F16E40A91C5FE2A /* Home */ = { - isa = PBXGroup; - children = ( - 59247695507084F4976728B5 /* HomeView.swift */, - ); - path = Home; - sourceTree = "<group>"; - }; 932F9ACAF6A7D30E34F2E375 /* Shared */ = { isa = PBXGroup; children = ( @@ -130,6 +112,16 @@ path = Extensions; sourceTree = "<group>"; }; + BD0E20D32DF34D9E7C3EBCD2 /* Views */ = { + isa = PBXGroup; + children = ( + FD48C5F6B21222517F1CD77B /* RootView.swift */, + E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */, + 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */, + ); + path = Views; + sourceTree = "<group>"; + }; C4F02317699AB4FA59315D05 = { isa = PBXGroup; children = ( @@ -236,11 +228,11 @@ buildActionMask = 2147483647; files = ( C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */, - 1D3283928F795E3DE793DD9F /* AppLogging.swift in Sources */, - 049D620DD8C02816893BF765 /* AppState.swift in Sources */, E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */, - 8E31BEF1598DD26236F94F11 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, + 009A19C0FBB0BA98FFAB539B /* RootView.swift in Sources */, + C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */, + 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */, 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Radroots/App/App.swift b/Radroots/App/App.swift @@ -1,13 +1,13 @@ import SwiftUI +import RadrootsKit @main -struct RadrootsApp: App { - @StateObject private var appState = AppState() - +struct MyApp: App { var body: some Scene { WindowGroup { - HomeView() - .environmentObject(appState) + RadrootsProvider { + RootView() + } } } } diff --git a/Radroots/App/AppLogging.swift b/Radroots/App/AppLogging.swift @@ -1,28 +0,0 @@ -import Foundation -import RadrootsKit - -public enum AppLogging { - public static func configure() { - let fm = FileManager.default - - do { - let base = try fm.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - - let logsDir = base.appendingPathComponent("Logs", isDirectory: true) - try fm.createDirectory(at: logsDir, withIntermediateDirectories: true) - - _ = try initLogging( - dir: logsDir.path, - fileName: "radroots-ios.log", - isStdout: true - ) - } catch { - _ = try? initLoggingStdout() - } - } -} diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -1,38 +0,0 @@ -import Foundation -import RadrootsKit - -@MainActor -final class AppState: ObservableObject { - private let radroots = Radroots() - - @Published private(set) var runtimeInfo: RuntimeInfo? - @Published private(set) var error: Error? - - init() { - Task { @MainActor in - await self.startRuntime() - } - } - - func infoJSON() async -> String { - await MainActor.run { [radroots] in - radroots.runtime?.infoJson() ?? "{}" - } - } - - private func startRuntime() async { - do { - try radroots.start( - bundleId: Bundle.main.bundleIdentifier ?? "unknown", - version: Bundle.main.version ?? "0", - build: Bundle.main.buildNumber ?? "0", - buildSha: Bundle.main.buildSHA - ) - runtimeInfo = radroots.info() - RadrootsLogger.info("Radroots runtime started successfully") - } catch { - self.error = error - RadrootsLogger.error("Failed to start Radroots runtime: \(error.localizedDescription)") - } - } -} diff --git a/Radroots/Features/Home/HomeView.swift b/Radroots/Features/Home/HomeView.swift @@ -1,29 +0,0 @@ -import SwiftUI -import RadrootsKit - -struct HomeView: View { - @EnvironmentObject private var appState: AppState - @State private var infoJSONString: String = "{}" - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(Ls.appName) - .font(.largeTitle.bold()) - Text(infoJSONString) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - .padding() - } - .navigationTitle(Ls.appName) - .navigationBarTitleDisplayMode(.large) - .task { - infoJSONString = await appState.infoJSON() - } - } -} diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -35,10 +35,6 @@ <dict> <key>UISceneConfigurationName</key> <string>Default Configuration</string> - <key>UISceneDelegateClassName</key> - <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string> - <key>UIMainStoryboardFile</key> - <string></string> </dict> </array> </dict> diff --git a/Radroots/Resources/Localizations/en.lproj/Localizable.strings b/Radroots/Resources/Localizations/en.lproj/Localizable.strings @@ -1 +1,4 @@ "app_name" = "Radroots"; +"settings" = "Settings"; +"setup_greeting_header" = "Welcome to Radroots!"; +"setup_greeting_header_sub" = "Your device will be configured by the setup wizard"; diff --git a/Radroots/Shared/Localisation/Strings.swift b/Radroots/Shared/Localisation/Strings.swift @@ -2,4 +2,7 @@ import SwiftUI enum Ls { static var appName: LocalizedStringKey { "app_name" } + static var settings: LocalizedStringKey { "settings" } + static var setupGreetingHeader: LocalizedStringKey { "setup_greeting_header" } + static var setupGreetingHeaderSub: LocalizedStringKey { "setup_greeting_header_sub" } } diff --git a/Radroots/Views/RootView.swift b/Radroots/Views/RootView.swift @@ -0,0 +1,39 @@ +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 @@ -0,0 +1,57 @@ +import SwiftUI +import RadrootsKit + +struct SettingsView: View { + @EnvironmentObject private var app: AppState + + 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)) + } + } + } else { + Text("No key loaded") + } + } + + 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) + } + } + } + .navigationTitle("Settings") + .onAppear { app.refresh() } + } + + private func color(for light: AppState.RelayLight) -> Color { + switch light { + case .green: return .green + case .yellow: return .yellow + case .red: return .red + } + } +} diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift @@ -0,0 +1,82 @@ +import SwiftUI +import RadrootsKit + +struct SetupView: View { + @EnvironmentObject private var app: Radroots + @EnvironmentObject private var keys: RadrootsKeys + @State private var busy = false + @State private var errorText: String? + + var body: some View { + VStack(spacing: 24) { + Spacer() + + VStack(spacing: 8) { + Text(Ls.setupGreetingHeader) + .font(.largeTitle).bold() + Text(Ls.setupGreetingHeaderSub) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal, 24) + } + + Button { + generate() + } label: { + HStack { + if busy { ProgressView().tint(.white) } + Text(busy ? "Generating…" : "Generate & Save Keypair") + .bold() + } + .frame(maxWidth: .infinity) + .padding() + .background(busy ? Color.gray : Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .disabled(busy) + .padding(.horizontal, 24) + + if let npub = keys.npub { + Text("Public key") + .font(.caption).foregroundColor(.secondary) + Text(npub) + .font(.footnote) + .multilineTextAlignment(.center) + .textSelection(.enabled) + .padding(.horizontal, 24) + } + + Spacer() + } + .padding(.vertical, 16) + .alert("Key Generation Failed", isPresented: Binding( + get: { errorText != nil }, + set: { _ in errorText = nil } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(errorText ?? "") + } + } + + @MainActor + private func generate() { + busy = true + Task { + defer { busy = false } + do { + if app.runtime == nil { + try app.start() // ensure net-core runtime exists for key ops + } + 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 + } catch { + errorText = String(describing: error) + } + } + } +} diff --git a/RadrootsKit/Sources/RadrootsKit/AppState.swift b/RadrootsKit/Sources/RadrootsKit/AppState.swift @@ -0,0 +1,71 @@ +import Foundation +import Combine + +@MainActor +public final class AppState: ObservableObject { + @Published public private(set) var infoJSONString: String = "" + @Published public private(set) var hasKey: Bool = false + @Published public private(set) var npub: String? + @Published public private(set) var relayConnectedCount: UInt32 = 0 + @Published public private(set) var relayConnectingCount: UInt32 = 0 + @Published public private(set) var relayLight: RelayLight = .red + @Published public private(set) var relayLastError: String? + + public enum RelayLight { + case red, yellow, green + } + + public let radroots: Radroots + public let keys: RadrootsKeys + + private var statusTimer: Timer? + + public init(radroots: Radroots = Radroots(), keys: RadrootsKeys = RadrootsKeys()) { + self.radroots = radroots + self.keys = keys + } + + 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() + } + } + refresh() + } + + public func refresh() { + guard let rt = radroots.runtime else { return } + self.infoJSONString = rt.infoJson() + self.hasKey = rt.keysIsLoaded() + self.npub = rt.keysNpub() + updateStatus() + } + + private func startPollingStatus() { + statusTimer?.invalidate() + statusTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateStatus() + } + } + } + + 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 + switch s.light { + case .green: self.relayLight = .green + case .yellow: self.relayLight = .yellow + case .red: self.relayLight = .red + @unknown default: self.relayLight = .red + } + } +} diff --git a/RadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift b/RadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift @@ -0,0 +1,103 @@ +import Foundation +import Security + +@MainActor +public final class RadrootsKeys: ObservableObject { + @Published public private(set) var hasKey: Bool = false + @Published public private(set) var npub: String? + + public init() {} + + public func loadFromKeychainIfPresent(runtime: RadrootsRuntime) { + if let account = Keychain.activeAccount() ?? Keychain.accounts().first { + if let data = Keychain.load(service: Keychain.service, account: account), + let hex = String(data: data, encoding: .utf8) { + try? runtime.keysLoadHex32(hex: hex) + } + } + self.hasKey = runtime.keysIsLoaded() + self.npub = runtime.keysNpub() + } + + public func generateAndPersist(runtime: RadrootsRuntime) throws { + _ = try runtime.keysGenerateInMemory() + let hex = try runtime.keysExportSecretHex() + let account = runtime.keysNpub() ?? "profile-\(Int(Date().timeIntervalSince1970))" + Keychain.save(service: Keychain.service, account: account, data: Data(hex.utf8)) + Keychain.setActiveAccount(account) + self.hasKey = runtime.keysIsLoaded() + self.npub = runtime.keysNpub() + } +} + +private enum Keychain { + static let service = "com.radroots.keys" + static let activeService = "com.radroots.keys.active" + static let activeAccountKey = "active" + + static func accounts() -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let items = result as? [[String: Any]] else { return [] } + return items.compactMap { $0[kSecAttrAccount as String] as? String } + } + + static func activeAccount() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: activeService, + kSecAttrAccount as String: activeAccountKey, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func setActiveAccount(_ account: String) { + let data = Data(account.utf8) + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: activeService, + kSecAttrAccount as String: activeAccountKey + ] + SecItemDelete(base as CFDictionary) + var query = base + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + query[kSecValueData as String] = data + SecItemAdd(query as CFDictionary, nil) + } + + static func load(service: String, account: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return data + } + + static func save(service: String, account: String, data: Data) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecValueData as String: data + ] + SecItemAdd(query as CFDictionary, nil) + } +} diff --git a/RadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift b/RadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift @@ -0,0 +1,18 @@ +import SwiftUI + +public struct RadrootsProvider<Content: View>: View { + @StateObject private var appState = AppState() + private let content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + Group { content() } + .environmentObject(appState) + .environmentObject(appState.keys) + .environmentObject(appState.radroots) + .task { try? appState.start() } + } +} +\ No newline at end of file