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