commit df8db96e34cb5d86f2ab8b3232e8ea1cf32ad677 parent dfb7c93cfbead78ce1d4d9753ec56a972390252f Author: triesap <tyson@radroots.org> Date: Mon, 3 Aug 2026 17:28:41 +0000 ios: update radroots ffi artifact - pin the final app_rt source toolchain epoch and artifact checksums - regenerate the async Swift bridge and arm64 Apple framework - keep keychain executor lifecycle and polling ownership in Swift - consume categorical relay capabilities without fabricated counts Diffstat:
24 files changed, 229 insertions(+), 219 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -83,8 +83,6 @@ 63189EB90A86A9929BECD9ED /* Nostr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nostr.swift; sourceTree = "<group>"; }; 676B89EB116B60AE8C2B4313 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; }; 6FBB081610305940C7849C7C /* RelaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySettings.swift; sourceTree = "<group>"; }; - 7515B8FD2A65990C3E3E93CE /* apple_kit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = apple_kit; path = ../../../../domains/radroots/apple_kit; sourceTree = SOURCE_ROOT; }; - 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>"; }; 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldCaptureIntake.swift; sourceTree = "<group>"; }; 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldSecureIdentityStore.swift; sourceTree = "<group>"; }; @@ -162,7 +160,6 @@ isa = PBXGroup; children = ( 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */, - 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */, 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */, 23C2D7FF63B61CD356979E82 /* App */, 579F407D96CCAFD4000EF363 /* Config */, @@ -242,14 +239,6 @@ path = Shared; sourceTree = "<group>"; }; - 9458C318B571871852A3FD1B /* Packages */ = { - isa = PBXGroup; - children = ( - 7515B8FD2A65990C3E3E93CE /* apple_kit */, - ); - name = Packages; - sourceTree = "<group>"; - }; 97FA23F0FD7E25C1AF2585FB /* Frameworks */ = { isa = PBXGroup; children = ( @@ -286,7 +275,6 @@ C4F02317699AB4FA59315D05 = { isa = PBXGroup; children = ( - 9458C318B571871852A3FD1B /* Packages */, 5FD6379AE27C57D02E8C7EE1 /* Radroots */, 97FA23F0FD7E25C1AF2585FB /* Frameworks */, 6240123423927396E47D6B3E /* Products */, @@ -373,7 +361,7 @@ mainGroup = C4F02317699AB4FA59315D05; minimizedProjectReferenceProxies = 1; packageReferences = ( - D71BF9693060631950EFC310 /* XCLocalSwiftPackageReference "../../../../domains/radroots/apple_kit" */, + 5696E24009739E3BD13D6580 /* XCRemoteSwiftPackageReference "apple_kit" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6240123423927396E47D6B3E /* Products */; @@ -674,16 +662,21 @@ }; /* End XCConfigurationList section */ -/* Begin XCLocalSwiftPackageReference section */ - D71BF9693060631950EFC310 /* XCLocalSwiftPackageReference "../../../../domains/radroots/apple_kit" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = ../../../../domains/radroots/apple_kit; +/* Begin XCRemoteSwiftPackageReference section */ + 5696E24009739E3BD13D6580 /* XCRemoteSwiftPackageReference "apple_kit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/radrootslabs/apple_kit.git"; + requirement = { + kind = revision; + revision = 483f952ca0fe4b395a6c6910ccdb3fd1d2640e82; + }; }; -/* End XCLocalSwiftPackageReference section */ +/* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */ = { isa = XCSwiftPackageProductDependency; + package = 5696E24009739E3BD13D6580 /* XCRemoteSwiftPackageReference "apple_kit" */; productName = RadrootsKit; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Radroots.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "originHash" : "7d8372bdb86640cb80ede08ccb1cc767f2f406a11d037ca50a3b404cd7bd96f2", + "pins" : [ + { + "identity" : "apple_kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/radrootslabs/apple_kit.git", + "state" : { + "revision" : "483f952ca0fe4b395a6c6910ccdb3fd1d2640e82" + } + } + ], + "version" : 3 +} diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -37,8 +37,9 @@ public final class AppState: ObservableObject { @Published public private(set) var npub: String? @Published public private(set) var identityLabel: String? @Published public private(set) var identities: [NostrIdentityRecord] = [] - @Published public private(set) var relayConnectedCount: UInt32 = 0 - @Published public private(set) var relayConnectingCount: UInt32 = 0 + @Published public private(set) var relayConfigured: Bool = false + @Published public private(set) var relaySourceAvailable: Bool = false + @Published public private(set) var relaySinkAvailable: Bool = false @Published public private(set) var relayLight: RelayLight = .red @Published public private(set) var relayLastError: String? @Published public private(set) var configuredRelayURLs: [String] = [] @@ -279,8 +280,9 @@ public final class AppState: ObservableObject { setLocked(true) statusTask?.cancel() statusTask = nil - relayConnectedCount = 0 - relayConnectingCount = 0 + relayConfigured = false + relaySourceAvailable = false + relaySinkAvailable = false relayLight = .red Task { await lockRuntimeIdentity() @@ -298,8 +300,9 @@ public final class AppState: ObservableObject { try await resetRuntimeIdentityState(using: service) applyNoIdentity() setLocked(false) - relayConnectedCount = 0 - relayConnectingCount = 0 + relayConfigured = false + relaySourceAvailable = false + relaySinkAvailable = false relayLight = .red relayLastError = nil canOpenNostrProfile = false @@ -408,8 +411,9 @@ public final class AppState: ObservableObject { let document = try documentInterchange().prepareDiagnosticsExport( infoJSONString: infoJSONString, relays: relays, - connectedCount: relayConnectedCount, - connectingCount: relayConnectingCount, + configured: relayConfigured, + sourceAvailable: relaySourceAvailable, + sinkAvailable: relaySinkAvailable, lastError: relayLastError ) telemetry.documentInterchange(operation: "diagnostics_export", outcome: "success", relayCount: relays.count) @@ -461,8 +465,9 @@ public final class AppState: ObservableObject { ) apply(relaySettings: snapshot) if let service = runtimeService, runtimeIdentityReady && !isLocked { - relayConnectedCount = 0 - relayConnectingCount = 0 + relayConfigured = !snapshot.relays.isEmpty + relaySourceAvailable = false + relaySinkAvailable = false relayLight = .yellow relayLastError = nil try await service.nostrSetDefaultRelays(snapshot.relays) @@ -684,29 +689,39 @@ public final class AppState: ObservableObject { } private func refreshRelayStatus(using service: FieldRuntimeService) async { - let status = await service.nostrConnectionStatus() - relayConnectedCount = status.connected - relayConnectingCount = status.connecting - relayLastError = status.lastError ?? relayLastError - switch status.light { - case .green: - relayLight = .green - case .yellow: - relayLight = .yellow - case .red: + do { + let status = try await service.nostrConnectionStatus() + relayConfigured = status.configured + relaySourceAvailable = status.sourceAvailable + relaySinkAvailable = status.sinkAvailable + relayLastError = status.lastError ?? relayLastError + switch status.light { + case .green: + relayLight = .green + case .yellow: + relayLight = .yellow + case .red: + relayLight = .red + } + } catch { + relaySourceAvailable = false + relaySinkAvailable = false relayLight = .red + relayLastError = error.fieldRuntimeMessage } let telemetryStatus = FieldTelemetryRelayStatus( - connectedCount: relayConnectedCount, - connectingCount: relayConnectingCount, + configured: relayConfigured, + sourceAvailable: relaySourceAvailable, + sinkAvailable: relaySinkAvailable, configuredRelayCount: configuredRelayURLs.count, light: relayLight.telemetryValue ) if telemetryStatus != lastTelemetryRelayStatus { lastTelemetryRelayStatus = telemetryStatus telemetry.relayStatusChanged( - connectedCount: telemetryStatus.connectedCount, - connectingCount: telemetryStatus.connectingCount, + configured: telemetryStatus.configured, + sourceAvailable: telemetryStatus.sourceAvailable, + sinkAvailable: telemetryStatus.sinkAvailable, configuredRelayCount: telemetryStatus.configuredRelayCount, light: telemetryStatus.light ) @@ -878,8 +893,9 @@ public final class AppState: ObservableObject { bundleIdentifier: bundleIdentifier, infoJSONString: infoJSONString, relays: effectiveRelaySettings().relays, - connectedCount: relayConnectedCount, - connectingCount: relayConnectingCount, + configured: relayConfigured, + sourceAvailable: relaySourceAvailable, + sinkAvailable: relaySinkAvailable, lastError: relayLastError ) guard FieldDocumentInterchangeUITestProbe.isRequested else { @@ -970,8 +986,9 @@ public final class AppState: ObservableObject { } private struct FieldTelemetryRelayStatus: Equatable { - let connectedCount: UInt32 - let connectingCount: UInt32 + let configured: Bool + let sourceAvailable: Bool + let sinkAvailable: Bool let configuredRelayCount: Int let light: String } diff --git a/Radroots/Runtime/FieldBackgroundExecution.swift b/Radroots/Runtime/FieldBackgroundExecution.swift @@ -413,12 +413,13 @@ actor FieldBackgroundExecution { let relaySettings = try RelaySettings.effectiveSnapshot(bundleIdentifier: roots.appIdentifier) try await runtimeService.nostrSetDefaultRelays(relaySettings.relays) try await runtimeService.nostrConnectIfKeyPresent() - let status = await runtimeService.nostrConnectionStatus() + let status = try await runtimeService.nostrConnectionStatus() telemetry.backgroundExecution( operation: "relay_refresh", outcome: "success", - relayConnectedCount: status.connected, - relayConnectingCount: status.connecting, + relayConfigured: status.configured, + relaySourceAvailable: status.sourceAvailable, + relaySinkAvailable: status.sinkAvailable, identityUnlocked: true, reason: reason ) diff --git a/Radroots/Runtime/FieldDocumentInterchange.swift b/Radroots/Runtime/FieldDocumentInterchange.swift @@ -20,8 +20,9 @@ enum FieldDocumentInterchangeError: LocalizedError, Equatable { struct FieldRelayStatusDocument: Encodable, Equatable { let configuredRelays: [String] - let connectedCount: UInt32 - let connectingCount: UInt32 + let configured: Bool + let sourceAvailable: Bool + let sinkAvailable: Bool let lastError: String? } @@ -106,17 +107,19 @@ final class FieldDocumentInterchange { func prepareDiagnosticsExport( infoJSONString: String, relays: [String], - connectedCount: UInt32, - connectingCount: UInt32, + configured: Bool, + sourceAvailable: Bool, + sinkAvailable: Bool, lastError: String? ) throws -> RadrootsPreparedExportDocument { let document = FieldDiagnosticsDocument( - format: "radroots_field_ios_diagnostics_v1", + format: "radroots_field_ios_diagnostics_v2", runtime: Self.jsonValue(from: infoJSONString), relay: FieldRelayStatusDocument( configuredRelays: try Self.validatedRelays(relays), - connectedCount: connectedCount, - connectingCount: connectingCount, + configured: configured, + sourceAvailable: sourceAvailable, + sinkAvailable: sinkAvailable, lastError: lastError ) ) diff --git a/Radroots/Runtime/FieldDocumentInterchangeUITestProbe.swift b/Radroots/Runtime/FieldDocumentInterchangeUITestProbe.swift @@ -38,8 +38,9 @@ enum FieldDocumentInterchangeUITestProbe { bundleIdentifier: String, infoJSONString: String, relays: [String], - connectedCount: UInt32, - connectingCount: UInt32, + configured: Bool, + sourceAvailable: Bool, + sinkAvailable: Bool, lastError: String? ) throws -> String? { guard isRequested else { @@ -50,8 +51,9 @@ enum FieldDocumentInterchangeUITestProbe { let diagnosticsExport = try interchange.prepareDiagnosticsExport( infoJSONString: infoJSONString, relays: relays, - connectedCount: connectedCount, - connectingCount: connectingCount, + configured: configured, + sourceAvailable: sourceAvailable, + sinkAvailable: sinkAvailable, lastError: lastError ) let diagnosticsFileExists = try fileAccess.preparedExportExists(diagnosticsExport) diff --git a/Radroots/Runtime/FieldRuntimeError.swift b/Radroots/Runtime/FieldRuntimeError.swift @@ -15,12 +15,14 @@ extension RadrootsAppError { switch self { case .Initialization(_): .initialization - case .Identity(_): - .identity - case .SecureStore(_): - .secureStore - case .Relay(_): - .relay + case .Sdk(let report): + if report.capabilityId?.hasPrefix("signing.") == true { + .identity + } else if report.capabilityId?.hasPrefix("transport.") == true { + .relay + } else { + .runtime + } case .Runtime(_): .runtime case .Unsupported(_): @@ -33,13 +35,12 @@ extension RadrootsAppError { var fieldMessage: String { switch self { case .Initialization(let message), - .Identity(let message), - .SecureStore(let message), - .Relay(let message), .Runtime(let message), .Unsupported(let message), .Internal(let message): message + case .Sdk(let report): + report.message } } } diff --git a/Radroots/Runtime/FieldRuntimeService.swift b/Radroots/Runtime/FieldRuntimeService.swift @@ -1,7 +1,7 @@ import Foundation public final class FieldRuntimeService: @unchecked Sendable { - private let runtime: RadrootsRuntime + let runtime: RadrootsRuntime private let queue = DispatchQueue(label: "org.radroots.field_ios.runtime", qos: .userInitiated) public init(runtime: RadrootsRuntime) { @@ -40,8 +40,8 @@ public final class FieldRuntimeService: @unchecked Sendable { try await run { try $0.nostrConnectIfKeyPresent() } } - public func nostrConnectionStatus() async -> NostrConnectionStatus { - await runValue { $0.nostrConnectionStatus() } + public func nostrConnectionStatus() async throws -> NostrConnectionStatus { + try await runtime.nostrConnectionStatus() } public func nostrIdentitySnapshot() async throws -> NostrIdentitySnapshot { @@ -83,17 +83,13 @@ public final class FieldRuntimeService: @unchecked Sendable { } public func nostrProfileForSelf() async throws -> NostrProfileEventMetadata? { - try await run { try $0.nostrProfileForSelf() } + try await runtime.nostrProfileForSelf() } public func nostrFetchTextNotes( limit: UInt16, sinceUnix: UInt64? ) async throws -> [NostrPostEventMetadata] { - try await run { try $0.nostrFetchTextNotes(limit: limit, sinceUnix: sinceUnix) } - } - - public func nostrNextPostStreamEvent() async throws -> NostrPostEventMetadata? { - try await run { try $0.nostrNextPostEvent() } + try await runtime.nostrFetchTextNotes(limit: limit, sinceUnix: sinceUnix) } } diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift @@ -120,8 +120,9 @@ final class FieldTelemetry: @unchecked Sendable { } func relayStatusChanged( - connectedCount: UInt32, - connectingCount: UInt32, + configured: Bool, + sourceAvailable: Bool, + sinkAvailable: Bool, configuredRelayCount: Int, light: String ) { @@ -129,8 +130,9 @@ final class FieldTelemetry: @unchecked Sendable { name: "field_ios.relay.status_changed", level: light == "red" ? .warning : .info, fields: [ - try? .integer("connected_count", Int64(connectedCount)), - try? .integer("connecting_count", Int64(connectingCount)), + try? .bool("configured", configured), + try? .bool("source_available", sourceAvailable), + try? .bool("sink_available", sinkAvailable), try? .integer("configured_relay_count", configuredRelayCount), try? .string("relay_light", light) ].compactMap { $0 } @@ -224,8 +226,9 @@ final class FieldTelemetry: @unchecked Sendable { taskCount: Int? = nil, stagedBlobCount: Int? = nil, transferCount: Int? = nil, - relayConnectedCount: UInt32? = nil, - relayConnectingCount: UInt32? = nil, + relayConfigured: Bool? = nil, + relaySourceAvailable: Bool? = nil, + relaySinkAvailable: Bool? = nil, identityUnlocked: Bool? = nil, reason: String? = nil ) { @@ -246,12 +249,16 @@ final class FieldTelemetry: @unchecked Sendable { let field = try? RadrootsTelemetryField.integer("transfer_count", transferCount) { fields.append(field) } - if let relayConnectedCount, - let field = try? RadrootsTelemetryField.integer("relay_connected_count", Int64(relayConnectedCount)) { + if let relayConfigured, + let field = try? RadrootsTelemetryField.bool("relay_configured", relayConfigured) { fields.append(field) } - if let relayConnectingCount, - let field = try? RadrootsTelemetryField.integer("relay_connecting_count", Int64(relayConnectingCount)) { + if let relaySourceAvailable, + let field = try? RadrootsTelemetryField.bool("relay_source_available", relaySourceAvailable) { + fields.append(field) + } + if let relaySinkAvailable, + let field = try? RadrootsTelemetryField.bool("relay_sink_available", relaySinkAvailable) { fields.append(field) } if let identityUnlocked, diff --git a/Radroots/Runtime/Nostr.swift b/Radroots/Runtime/Nostr.swift @@ -27,19 +27,17 @@ public extension FieldRuntimeService { nip05: String? = nil, about: String? = nil ) async throws -> NostrEventId { - let id = try await run { - try $0.nostrPostProfile( - name: name, - displayName: displayName, - nip05: nip05, - about: about - ) - } + let id = try await runtime.nostrPostProfile( + name: name, + displayName: displayName, + nip05: nip05, + about: about + ) return NostrEventId(id) } func nostrPostTextNote(content: String) async throws -> NostrEventId { - let id = try await run { try $0.nostrPostTextNote(content: content) } + let id = try await runtime.nostrPostTextNote(content: content) return NostrEventId(id) } @@ -49,22 +47,12 @@ public extension FieldRuntimeService { content: String, rootEventIdHex: String? = nil ) async throws -> NostrEventId { - let id = try await run { - try $0.nostrPostReply( - parentEventIdHex: parentEventIdHex, - parentAuthorHex: parentAuthorHex, - content: content, - rootEventIdHex: rootEventIdHex - ) - } + let id = try await runtime.nostrPostReply( + parentEventIdHex: parentEventIdHex, + parentAuthorHex: parentAuthorHex, + content: content, + rootEventIdHex: rootEventIdHex + ) return NostrEventId(id) } - - func nostrStartPostStream(sinceUnix: UInt64? = nil) async throws { - try await run { try $0.nostrStartPostEventStream(sinceUnix: sinceUnix) } - } - - func nostrStopPostStream() async throws { - try await run { try $0.nostrStopPostEventStream() } - } } diff --git a/Radroots/Runtime/Radroots.swift b/Radroots/Runtime/Radroots.swift @@ -37,10 +37,6 @@ public final class Radroots: ObservableObject { return service } - deinit { - runtime?.stop() - } - public func info() -> RuntimeInfo? { runtime?.info() } diff --git a/Radroots/Views/HomeView.swift b/Radroots/Views/HomeView.swift @@ -60,7 +60,7 @@ private struct TodayView: View { Label(syncLabel, systemImage: syncImage) .font(.subheadline.weight(.semibold)) Spacer() - RelayPill(count: app.relayConnectedCount) + RelayPill(available: app.relaySourceAvailable && app.relaySinkAvailable) } } .padding(.vertical, 4) @@ -76,8 +76,8 @@ private struct TodayView: View { } Section("Relay") { - RelayMetricRow(label: "Connected", systemImage: "dot.radiowaves.left.and.right", value: app.relayConnectedCount) - RelayMetricRow(label: "Connecting", systemImage: "antenna.radiowaves.left.and.right", value: app.relayConnectingCount) + RelayMetricRow(label: "Read operations", systemImage: "arrow.down.circle", value: app.relaySourceAvailable ? "Available" : "Unavailable") + RelayMetricRow(label: "Write operations", systemImage: "arrow.up.circle", value: app.relaySinkAvailable ? "Available" : "Unavailable") if let last = app.relayLastError { Text(last) .foregroundStyle(.red) @@ -91,11 +91,11 @@ private struct TodayView: View { } private var syncLabel: String { - app.relayConnectedCount > 0 ? "Sync online" : "Waiting for relay" + app.relaySourceAvailable && app.relaySinkAvailable ? "Relay ready" : "Waiting for relay" } private var syncImage: String { - app.relayConnectedCount > 0 ? "checkmark.icloud.fill" : "icloud.slash.fill" + app.relaySourceAvailable && app.relaySinkAvailable ? "checkmark.icloud.fill" : "icloud.slash.fill" } } @@ -336,7 +336,7 @@ private struct ActivityView: View { List { Section("Recent Activity") { ActivityRow(title: "Identity ready", detail: app.npub.map(shortNpub) ?? "Local key selected", systemImage: "person.crop.circle.badge.checkmark") - ActivityRow(title: "Relay posture", detail: "\(app.relayConnectedCount) connected, \(app.relayConnectingCount) connecting", systemImage: "dot.radiowaves.left.and.right") + ActivityRow(title: "Relay posture", detail: app.relaySourceAvailable && app.relaySinkAvailable ? "Read and write operations available" : "Relay operations unavailable", systemImage: "dot.radiowaves.left.and.right") ActivityRow(title: "Draft queue", detail: "No local drafts", systemImage: "tray") } } @@ -599,14 +599,14 @@ private struct ActivityRow: View { } private struct RelayPill: View { - let count: UInt32 + let available: Bool var body: some View { - Text("\(count) connected") + Text(available ? "relay ready" : "relay unavailable") .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 6) - .foregroundStyle(count > 0 ? .green : .secondary) + .foregroundStyle(available ? .green : .secondary) .background(.thinMaterial, in: Capsule()) } } @@ -614,13 +614,13 @@ private struct RelayPill: View { private struct RelayMetricRow: View { let label: String let systemImage: String - let value: UInt32 + let value: String var body: some View { HStack { Label(label, systemImage: systemImage) Spacer() - Text("\(value)") + Text(value) .foregroundStyle(.secondary) } } diff --git a/Radroots/Views/PostCreateView.swift b/Radroots/Views/PostCreateView.swift @@ -8,7 +8,7 @@ struct PostCreateView: View { @State private var showResult = false @FocusState private var focused: Bool - private var isConnected: Bool { app.relayConnectedCount > 0 } + private var isConnected: Bool { app.relaySinkAvailable } private var canPost: Bool { isConnected && !isPosting && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } diff --git a/Radroots/Views/PostFeedView.swift b/Radroots/Views/PostFeedView.swift @@ -52,8 +52,8 @@ struct PostFeedView: View { } } } footer: { - if app.relayConnectedCount == 0 { - Text("No relays connected. Configure and connect to load posts.") + if !app.relaySourceAvailable { + Text("No relay source is available. Configure a relay to load posts.") } } } diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift @@ -15,13 +15,12 @@ final class PostFeedViewModel: ObservableObject { if posts.isEmpty { Task { await load(app: app) } } - startStream(app: app) + startPolling(app: app) } func onDisappear(app: AppState) { liveTask?.cancel() liveTask = nil - Task { try? await app.runtimeService?.nostrStopPostStream() } } func load(app: AppState) async { @@ -88,45 +87,32 @@ final class PostFeedViewModel: ObservableObject { } - private func startStream(app: AppState) { + private func startPolling(app: AppState) { guard liveTask == nil else { return } liveTask = Task { @MainActor [weak self] in guard let self else { return } guard let service = app.runtimeService else { return } - var knownIds = Set(posts.map(\.id)) - let since = posts.map(\.publishedAt).max() - do { - try await service.nostrStartPostStream(sinceUnix: since) - } catch { - errorMessage = error.fieldRuntimeMessage - } - while !Task.isCancelled { - if app.relayConnectedCount == 0 { - try? await Task.sleep(for: .seconds(1)) + if !app.relaySourceAvailable { + try? await Task.sleep(for: .seconds(2)) continue } - if knownIds.count != posts.count { - knownIds = Set(posts.map(\.id)) - } - do { - if let event = try await service.nostrNextPostStreamEvent() { - if knownIds.insert(event.id).inserted { - posts.insert(event, at: 0) - posts.sort { $0.publishedAt > $1.publishedAt } - if posts.count > 200 { - posts = Array(posts.prefix(200)) - } - } - } else { - try? await Task.sleep(for: .milliseconds(300)) + let since = posts.map(\.publishedAt).max() + let fetched = try await service.nostrFetchTextNotes(limit: 50, sinceUnix: since) + let knownIds = Set(posts.map(\.id)) + let newPosts = fetched.filter { !knownIds.contains($0.id) } + if !newPosts.isEmpty { + posts.append(contentsOf: newPosts) + posts.sort { $0.publishedAt > $1.publishedAt } + posts = Array(posts.prefix(200)) } + errorMessage = nil } catch { errorMessage = error.fieldRuntimeMessage - try? await Task.sleep(for: .milliseconds(300)) } + try? await Task.sleep(for: .seconds(5)) } } } diff --git a/Radroots/Views/ProfileView.swift b/Radroots/Views/ProfileView.swift @@ -102,7 +102,7 @@ public struct ProfileView: View { } } - private var isConnected: Bool { app.relayConnectedCount > 0 } + private var isConnected: Bool { app.relaySinkAvailable } private var isPostEnabled: Bool { isConnected && !isPosting } private var hasChanges: Bool { name != original.name || diff --git a/Radroots/Views/RelaysView.swift b/Radroots/Views/RelaysView.swift @@ -19,16 +19,16 @@ struct RelaysView: View { List { Section("Relays") { RelayMetricRow( - label: "Connected", - systemImage: "dot.radiowaves.left.and.right", - value: app.relayConnectedCount, - accessibilityID: "field_ios.relays.connected_count" + label: "Read operations", + systemImage: "arrow.down.circle", + value: app.relaySourceAvailable ? "Available" : "Unavailable", + accessibilityID: "field_ios.relays.source_available" ) RelayMetricRow( - label: "Connecting", - systemImage: "antenna.radiowaves.left.and.right", - value: app.relayConnectingCount, - accessibilityID: "field_ios.relays.connecting_count" + label: "Write operations", + systemImage: "arrow.up.circle", + value: app.relaySinkAvailable ? "Available" : "Unavailable", + accessibilityID: "field_ios.relays.sink_available" ) if let last = app.relayLastError { Text(last) @@ -180,18 +180,18 @@ struct RelaysView: View { private struct RelayMetricRow: View { let label: String let systemImage: String - let value: UInt32 + let value: String let accessibilityID: String var body: some View { HStack { Label(label, systemImage: systemImage) Spacer() - Text("\(value)") + Text(value) } .accessibilityElement(children: .ignore) .accessibilityLabel(label) - .accessibilityValue("\(value)") + .accessibilityValue(value) .accessibilityIdentifier(accessibilityID) } } diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -183,8 +183,8 @@ private struct RuntimeDiagnosticsView: View { } Section("Relay") { - LabeledContent("Connected", value: "\(app.relayConnectedCount)") - LabeledContent("Connecting", value: "\(app.relayConnectingCount)") + LabeledContent("Read operations", value: app.relaySourceAvailable ? "Available" : "Unavailable") + LabeledContent("Write operations", value: app.relaySinkAvailable ? "Available" : "Unavailable") if let relayLastError = app.relayLastError { Text(relayLastError) .foregroundStyle(.red) diff --git a/RadrootsFFI/Config/ffi-build.env b/RadrootsFFI/Config/ffi-build.env @@ -1,3 +1,3 @@ # Optional overrides for local development. # SOURCE_MODE=local -# LOCAL_FFI_MANIFEST=/absolute/path/to/field_lib/crates/ffi/Cargo.toml +# LOCAL_FFI_MANIFEST=/absolute/path/to/app_rt/crates/ffi/Cargo.toml diff --git a/RadrootsFFI/Makefile b/RadrootsFFI/Makefile @@ -5,13 +5,14 @@ SHELL := /bin/bash -include Config/ffi-build.env SOURCE_MODE ?= git -RADROOTS_FIELD_LIB_GIT_URL ?= git@github.com:radrootslabs/app_rt.git -RADROOTS_FIELD_LIB_GIT_REV ?= 922c8a82977fa0e41616ebdf978ef2df78c0063c +RADROOTS_FIELD_LIB_GIT_URL ?= https://github.com/radrootslabs/app_rt.git +RADROOTS_FIELD_LIB_GIT_REV ?= b770841d2d2ea1cafc1d4a644d596cb8727624d9 RADROOTS_FIELD_FFI_CRATE_VERSION ?= 0.1.0-alpha.1 -FFI_FEATURES ?= radroots_app_core/rt,radroots_app_core/nostr-client +RADROOTS_FIELD_SOURCE_DATE_EPOCH ?= 1785777481 LOCAL_FFI_MANIFEST ?= IPHONEOS_DEPLOYMENT_TARGET ?= 18.0 -RUSTUP_TOOLCHAIN ?= 1.92.0-aarch64-apple-darwin +RUSTUP_TOOLCHAIN ?= 1.97.1-aarch64-apple-darwin +CARGO_CONFIG_ARGS ?= IOS_ROOT := $(abspath $(CURDIR)/..) APP_ROOT := $(IOS_ROOT)/Radroots @@ -29,42 +30,39 @@ OUT_DIR := $(BUILD_ROOT)/out GENERATED_DIR := $(OUT_DIR)/generated HEADERS_DIR := $(OUT_DIR)/headers -CRATE_NAME := radroots_app_ffi FFI_OUTPUT_NAME := RadrootsFFI LIB_STEM := libradroots_app_ffi - -GIT_FIELD_LIB_DIR := $(SOURCE_ROOT)/field-lib -CRATE_ARCHIVE := $(SOURCE_ROOT)/$(CRATE_NAME)-$(RADROOTS_FIELD_FFI_CRATE_VERSION).crate -CRATE_EXTRACT_DIR := $(SOURCE_ROOT)/$(CRATE_NAME)-$(RADROOTS_FIELD_FFI_CRATE_VERSION) -CARGO_FEATURE_FLAGS := $(if $(strip $(FFI_FEATURES)),--features $(FFI_FEATURES),) +GIT_FIELD_LIB_DIR := $(SOURCE_ROOT)/app-rt ifeq ($(SOURCE_MODE),git) -FFI_MANIFEST := $(GIT_FIELD_LIB_DIR)/crates/ffi/Cargo.toml -UNIFFI_CONFIG := $(GIT_FIELD_LIB_DIR)/crates/ffi/uniffi.toml +SOURCE_WORKSPACE_ROOT := $(GIT_FIELD_LIB_DIR) +FFI_MANIFEST := $(SOURCE_WORKSPACE_ROOT)/crates/ffi/Cargo.toml +UNIFFI_CONFIG := $(SOURCE_WORKSPACE_ROOT)/crates/ffi/uniffi.toml SOURCE_TARGET := sync-source-git -else ifeq ($(SOURCE_MODE),crates) -FFI_MANIFEST := $(CRATE_EXTRACT_DIR)/Cargo.toml -UNIFFI_CONFIG := $(CRATE_EXTRACT_DIR)/uniffi.toml -SOURCE_TARGET := sync-source-crates else ifeq ($(SOURCE_MODE),local) FFI_MANIFEST := $(LOCAL_FFI_MANIFEST) -UNIFFI_CONFIG := $(dir $(LOCAL_FFI_MANIFEST))uniffi.toml +SOURCE_WORKSPACE_ROOT := $(abspath $(dir $(LOCAL_FFI_MANIFEST))/../..) +UNIFFI_CONFIG := $(SOURCE_WORKSPACE_ROOT)/crates/ffi/uniffi.toml SOURCE_TARGET := sync-source-local else -$(error SOURCE_MODE must be one of: git, crates, local) +$(error SOURCE_MODE must be one of: git, local) endif -FFI_CRATE_DIR := $(dir $(FFI_MANIFEST)) - +BINDGEN_MANIFEST := $(SOURCE_WORKSPACE_ROOT)/Cargo.toml RUSTC_BIN := $(shell rustup which --toolchain $(RUSTUP_TOOLCHAIN) rustc) -CARGO_BIN := rustup run $(RUSTUP_TOOLCHAIN) cargo -CARGO := IPHONEOS_DEPLOYMENT_TARGET=$(IPHONEOS_DEPLOYMENT_TARGET) RUSTC=$(RUSTC_BIN) CARGO_TARGET_DIR=$(TARGET_DIR) $(CARGO_BIN) +CARGO_BIN := rustup run $(RUSTUP_TOOLCHAIN) cargo $(CARGO_CONFIG_ARGS) +CARGO := IPHONEOS_DEPLOYMENT_TARGET=$(IPHONEOS_DEPLOYMENT_TARGET) \ + RUSTC=$(RUSTC_BIN) \ + CARGO_TARGET_DIR=$(TARGET_DIR) \ + RADROOTS_SOURCE_SHA=$(RADROOTS_FIELD_LIB_GIT_REV) \ + SOURCE_DATE_EPOCH=$(RADROOTS_FIELD_SOURCE_DATE_EPOCH) \ + $(CARGO_BIN) LIB_DEVICE := $(TARGET_DIR)/aarch64-apple-ios/release/$(LIB_STEM).a LIB_SIMULATOR := $(TARGET_DIR)/aarch64-apple-ios-sim/release/$(LIB_STEM).a LIB_HOST := $(TARGET_DIR)/release/$(LIB_STEM).dylib -.PHONY: all clean distclean print-config sync-source sync-source-git sync-source-crates sync-source-local ensure-toolchain build generate package install +.PHONY: all clean distclean print-config sync-source sync-source-git sync-source-local ensure-toolchain build generate package install all: sync-source build generate package install @echo "done" @@ -74,7 +72,9 @@ all: sync-source build generate package install print-config: @echo "SOURCE_MODE=$(SOURCE_MODE)" + @echo "SOURCE_WORKSPACE_ROOT=$(SOURCE_WORKSPACE_ROOT)" @echo "FFI_MANIFEST=$(FFI_MANIFEST)" + @echo "BINDGEN_MANIFEST=$(BINDGEN_MANIFEST)" @echo "UNIFFI_CONFIG=$(UNIFFI_CONFIG)" @echo "ARTIFACTS_DIR=$(ARTIFACTS_DIR)" @echo "GENERATED_SWIFT_DIR=$(GENERATED_SWIFT_DIR)" @@ -82,6 +82,9 @@ print-config: @echo "IPHONEOS_DEPLOYMENT_TARGET=$(IPHONEOS_DEPLOYMENT_TARGET)" @echo "RUSTUP_TOOLCHAIN=$(RUSTUP_TOOLCHAIN)" @echo "RUSTC_BIN=$(RUSTC_BIN)" + @echo "CARGO_CONFIG_ARGS=$(CARGO_CONFIG_ARGS)" + @echo "RADROOTS_FIELD_LIB_GIT_REV=$(RADROOTS_FIELD_LIB_GIT_REV)" + @echo "RADROOTS_FIELD_SOURCE_DATE_EPOCH=$(RADROOTS_FIELD_SOURCE_DATE_EPOCH)" clean: rm -rf $(TARGET_DIR) $(OUT_DIR) $(ARTIFACTS_DIR)/$(FFI_OUTPUT_NAME).xcframework @@ -102,37 +105,33 @@ sync-source-git: git -C $(GIT_FIELD_LIB_DIR) fetch --tags --force origin git -C $(GIT_FIELD_LIB_DIR) checkout --detach $(RADROOTS_FIELD_LIB_GIT_REV) test -f $(FFI_MANIFEST) - -sync-source-crates: - mkdir -p $(SOURCE_ROOT) - rm -rf $(CRATE_EXTRACT_DIR) - curl -fLsS https://crates.io/api/v1/crates/$(CRATE_NAME)/$(RADROOTS_FIELD_FFI_CRATE_VERSION)/download -o $(CRATE_ARCHIVE) - tar -xzf $(CRATE_ARCHIVE) -C $(SOURCE_ROOT) - test -f $(FFI_MANIFEST) + test -f $(BINDGEN_MANIFEST) sync-source-local: if [ -z "$(LOCAL_FFI_MANIFEST)" ]; then \ echo "LOCAL_FFI_MANIFEST is required when SOURCE_MODE=local"; \ exit 1; \ fi - test -f $(LOCAL_FFI_MANIFEST) + test -f $(FFI_MANIFEST) test -f $(UNIFFI_CONFIG) + test -f $(BINDGEN_MANIFEST) ensure-toolchain: rustup target add --toolchain $(RUSTUP_TOOLCHAIN) aarch64-apple-ios aarch64-apple-ios-sim build: ensure-toolchain mkdir -p $(TARGET_DIR) $(OUT_DIR) - $(CARGO) build --manifest-path $(FFI_MANIFEST) $(CARGO_FEATURE_FLAGS) --release --target aarch64-apple-ios - $(CARGO) build --manifest-path $(FFI_MANIFEST) $(CARGO_FEATURE_FLAGS) --release --target aarch64-apple-ios-sim - $(CARGO) build --manifest-path $(FFI_MANIFEST) $(CARGO_FEATURE_FLAGS) --release + $(CARGO) build --manifest-path $(FFI_MANIFEST) --release --locked --target aarch64-apple-ios + $(CARGO) build --manifest-path $(FFI_MANIFEST) --release --locked --target aarch64-apple-ios-sim + $(CARGO) build --manifest-path $(FFI_MANIFEST) --release --locked generate: rm -rf $(GENERATED_DIR) $(HEADERS_DIR) mkdir -p $(GENERATED_DIR) $(HEADERS_DIR) - cd $(FFI_CRATE_DIR) && $(CARGO) run --manifest-path Cargo.toml $(CARGO_FEATURE_FLAGS) --bin uniffi-bindgen -- \ - generate --library $(LIB_HOST) \ + $(CARGO) run --manifest-path $(BINDGEN_MANIFEST) -p radroots_app_bindgen --locked -- \ + generate $(LIB_HOST) --library \ --language swift \ + --metadata-no-deps \ --out-dir $(GENERATED_DIR) \ --config $(UNIFFI_CONFIG) cp $(GENERATED_DIR)/$(FFI_OUTPUT_NAME).h $(HEADERS_DIR)/ diff --git a/RadrootsFFI/README.md b/RadrootsFFI/README.md @@ -6,7 +6,7 @@ artifact consumed by the Radroots iOS app. ## Goals - keep the iOS project openable and buildable in Xcode for OSS developers - keep `radroots_app_ffi` reusable for other Apple clients -- support three source modes from one Makefile: `git`, `crates`, and `local` +- support pinned Git and explicit local source modes from one Makefile ## Quick start - build everything: `make all` @@ -17,16 +17,20 @@ artifact consumed by the Radroots iOS app. - `SOURCE_MODE=git` (default) - clones `RADROOTS_FIELD_LIB_GIT_URL` at `RADROOTS_FIELD_LIB_GIT_REV` - builds `radroots_app_ffi` from the checked out workspace -- `SOURCE_MODE=crates` - - downloads `radroots_app_ffi` from crates.io by version - `SOURCE_MODE=local` - requires `LOCAL_FFI_MANIFEST=/absolute/path/to/radroots_app_ffi/Cargo.toml` + - is for validating a matching local checkout before its Git ref is available ## Configuration Configuration is read from: - `RadrootsFFI/source.lock` for pinned defaults - `RadrootsFFI/Config/ffi-build.env` for optional local overrides +The source lock also records the SHA-256 identities of the generated device +archive, simulator archive, and Swift source. These are release evidence for +the pinned source/toolchain/epoch tuple; generated outputs remain ignored and +must be rebuilt before the app is compiled. + Cargo output defaults to `RadrootsFFI/.build/target` for standalone use. An inherited `CARGO_TARGET_DIR` relocates the complete device, simulator, host, and UniFFI build graph. `TARGET_DIR=/absolute/path` is the equivalent explicit Make diff --git a/RadrootsFFI/rust-toolchain.toml b/RadrootsFFI/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.92.0" +channel = "1.97.1" targets = ["aarch64-apple-ios", "aarch64-apple-ios-sim"] diff --git a/RadrootsFFI/source.lock b/RadrootsFFI/source.lock @@ -1,5 +1,8 @@ SOURCE_MODE=git -RADROOTS_FIELD_LIB_GIT_URL=git@github.com:radrootslabs/app_rt.git -RADROOTS_FIELD_LIB_GIT_REV=922c8a82977fa0e41616ebdf978ef2df78c0063c +RADROOTS_FIELD_LIB_GIT_URL=https://github.com/radrootslabs/app_rt.git +RADROOTS_FIELD_LIB_GIT_REV=b770841d2d2ea1cafc1d4a644d596cb8727624d9 RADROOTS_FIELD_FFI_CRATE_VERSION=0.1.0-alpha.1 -FFI_FEATURES=radroots_app_core/rt,radroots_app_core/nostr-client +RADROOTS_FIELD_SOURCE_DATE_EPOCH=1785777481 +RADROOTS_FIELD_FFI_DEVICE_SHA256=a43000d20e205189b26925ff39517b573581fd9af9f85fee27ab77c04ace332b +RADROOTS_FIELD_FFI_SIMULATOR_SHA256=67ffa53e8442b25c726dce12ba5149bf6d534dadc4102733e9c8a3cc43be4144 +RADROOTS_FIELD_FFI_SWIFT_SHA256=ca117f22dcdfb88c24cdc8edabe3c937ad6cbc1b2011e54f7fb1ba14246f1d8b diff --git a/project.yml b/project.yml @@ -10,8 +10,8 @@ configs: packages: RadrootsKit: - url: git@github.com:radrootslabs/apple_kit.git - branch: master + url: https://github.com/radrootslabs/apple_kit.git + revision: 483f952ca0fe4b395a6c6910ccdb3fd1d2640e82 targets: Radroots: