field_ios

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

commit 163d048cb97c2da301e6629d683b90e8db3f98ce
parent df8db96e34cb5d86f2ab8b3232e8ea1cf32ad677
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 17:31:59 +0000

ios: validate shared engine lifecycle integration

- close the async sdk explicitly on host teardown
- clear in-memory signing state while retaining keychain custody
- add simulator tests for identity relay and idempotent shutdown
- document manual prompt background cancellation and logging checks

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots.xcodeproj/xcshareddata/xcschemes/Radroots.xcscheme | 13+++++++++++++
MRadroots/App/AppState.swift | 13+++++++++++++
MRadroots/App/RadrootsProvider.swift | 7+++++++
MRadroots/Runtime/FieldRuntimeService.swift | 4++++
MRadroots/Runtime/Radroots.swift | 7+++++++
ARadrootsTests/SharedEngineLifecycleTests.swift | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/shared-engine-lifecycle-smoke-checklist.md | 44++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 15+++++++++++++++
9 files changed, 256 insertions(+), 0 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */; }; 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.swift */; }; 82903551F5E15FBDAC388D20 /* FieldExternalActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */; }; + 850F3C1E444A4C0BC51A608D /* SharedEngineLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4ADF0DD703CA020D0CF67D /* SharedEngineLifecycleTests.swift */; }; 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE0EB327C10171444553378 /* PostFeedView.swift */; }; 8F6D0970610DF68816DE1A98 /* Radroots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F0F21496E7A8490EB14AC5B /* Radroots.swift */; }; 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C7DE4207398DE242519F9C /* CopyButton.swift */; }; @@ -60,6 +61,16 @@ F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 5B242C3EC56D6F3216103D77 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 572C41532B5066EC7641561C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 469EC50C49E7266DF1C2000B; + remoteInfo = Radroots; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 08FA88664E5E3ED3A24D56CC /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; 0A0274A0260D1C04F40C71AF /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; @@ -70,6 +81,7 @@ 227028B4EBDC6703999FB9DA /* ToastModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastModifier.swift; sourceTree = "<group>"; }; 2818363B157125491FB84A1E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldLocationCheckIn.swift; sourceTree = "<group>"; }; + 318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = RadrootsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 32238D3DFB4465DEEDE6E003 /* FieldRuntimeError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldRuntimeError.swift; sourceTree = "<group>"; }; 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldTelemetry.swift; sourceTree = "<group>"; }; 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldExternalActions.swift; sourceTree = "<group>"; }; @@ -77,6 +89,7 @@ 481E7791F4F6CF82FA3117C1 /* FieldIdentityImportFailureUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldIdentityImportFailureUITestProbe.swift; sourceTree = "<group>"; }; 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFileAccessUITestProbe.swift; sourceTree = "<group>"; }; 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; }; + 4C4ADF0DD703CA020D0CF67D /* SharedEngineLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedEngineLifecycleTests.swift; sourceTree = "<group>"; }; 4E150C6C18B2A06F2F3227C6 /* FieldIdentityPolicyUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldIdentityPolicyUITestProbe.swift; sourceTree = "<group>"; }; 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.git.xcconfig; sourceTree = "<group>"; }; 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldBackgroundExecutionUITestProbe.swift; sourceTree = "<group>"; }; @@ -145,6 +158,14 @@ path = App; sourceTree = "<group>"; }; + 31C88176C5674CFF5F9CFBEA /* RadrootsTests */ = { + isa = PBXGroup; + children = ( + 4C4ADF0DD703CA020D0CF67D /* SharedEngineLifecycleTests.swift */, + ); + path = RadrootsTests; + sourceTree = "<group>"; + }; 579F407D96CCAFD4000EF363 /* Config */ = { isa = PBXGroup; children = ( @@ -176,6 +197,7 @@ isa = PBXGroup; children = ( 93AA285819DD1269C3EAD80A /* Radroots.app */, + 318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */, ); name = Products; sourceTree = "<group>"; @@ -276,6 +298,7 @@ isa = PBXGroup; children = ( 5FD6379AE27C57D02E8C7EE1 /* Radroots */, + 31C88176C5674CFF5F9CFBEA /* RadrootsTests */, 97FA23F0FD7E25C1AF2585FB /* Frameworks */, 6240123423927396E47D6B3E /* Products */, ); @@ -340,6 +363,24 @@ productReference = 93AA285819DD1269C3EAD80A /* Radroots.app */; productType = "com.apple.product-type.application"; }; + 67B2EF8D69F6CEA9BE5A8B04 /* RadrootsTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DCDE79513C7AE684F9540473 /* Build configuration list for PBXNativeTarget "RadrootsTests" */; + buildPhases = ( + 4948BBB44D48B961DE81F508 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + C4ECFE4B8C347EE1F784B9BA /* PBXTargetDependency */, + ); + name = RadrootsTests; + packageProductDependencies = ( + ); + productName = RadrootsTests; + productReference = 318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -369,6 +410,7 @@ projectRoot = ""; targets = ( 469EC50C49E7266DF1C2000B /* Radroots */, + 67B2EF8D69F6CEA9BE5A8B04 /* RadrootsTests */, ); }; /* End PBXProject section */ @@ -408,6 +450,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 4948BBB44D48B961DE81F508 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 850F3C1E444A4C0BC51A608D /* SharedEngineLifecycleTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 78F7810CC77628CB591F8CD2 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -464,6 +514,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + C4ECFE4B8C347EE1F784B9BA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 469EC50C49E7266DF1C2000B /* Radroots */; + targetProxy = 5B242C3EC56D6F3216103D77 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 8D19AA8D515FC8F5D2407378 /* Localizable.strings */ = { isa = PBXVariantGroup; @@ -498,6 +556,24 @@ }; name = Debug; }; + 8113D02CF1312FB10249F707 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Radroots.app/Radroots"; + }; + name = Release; + }; D930E9391B5B63DB518922D2 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 08FA88664E5E3ED3A24D56CC /* Release.xcconfig */; @@ -520,6 +596,24 @@ }; name = Release; }; + F1654BB285BEA5667F11CBD2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Radroots.app/Radroots"; + }; + name = Debug; + }; F4E5B115657562F65E2A7C00 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -660,6 +754,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + DCDE79513C7AE684F9540473 /* Build configuration list for PBXNativeTarget "RadrootsTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1654BB285BEA5667F11CBD2 /* Debug */, + 8113D02CF1312FB10249F707 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Radroots.xcodeproj/xcshareddata/xcschemes/Radroots.xcscheme b/Radroots.xcodeproj/xcshareddata/xcschemes/Radroots.xcscheme @@ -39,7 +39,20 @@ </BuildableReference> </MacroExpansion> <Testables> + <TestableReference + skipped = "NO" + parallelizable = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "67B2EF8D69F6CEA9BE5A8B04" + BuildableName = "RadrootsTests.xctest" + BlueprintName = "RadrootsTests" + ReferencedContainer = "container:Radroots.xcodeproj"> + </BuildableReference> + </TestableReference> </Testables> + <CommandLineArguments> + </CommandLineArguments> </TestAction> <LaunchAction buildConfiguration = "Debug" diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -220,6 +220,19 @@ public final class AppState: ObservableObject { } } + public func shutdown() async { + statusTask?.cancel() + statusTask = nil + telemetryProbeTask?.cancel() + telemetryProbeTask = nil + await backgroundExecution?.cancelAll() + if let service = runtimeService { + try? await service.nostrIdentityLockHostCustodyRuntime() + } + backgroundExecution = nil + await radroots.shutdown() + } + public func continueWithLocalIdentity() async throws { let service = try requireRuntimeService() do { diff --git a/Radroots/App/RadrootsProvider.swift b/Radroots/App/RadrootsProvider.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit public struct RadrootsProvider<Content: View>: View { @Environment(\.scenePhase) private var scenePhase @@ -37,5 +38,11 @@ public struct RadrootsProvider<Content: View>: View { break } } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification)) { _ in + Task { await appState.shutdown() } + } + .onDisappear { + Task { await appState.shutdown() } + } } } diff --git a/Radroots/Runtime/FieldRuntimeService.swift b/Radroots/Runtime/FieldRuntimeService.swift @@ -32,6 +32,10 @@ public final class FieldRuntimeService: @unchecked Sendable { await runValue { $0.infoJson() } } + public func shutdown() async throws -> SdkShutdownRecord { + try await runtime.shutdown() + } + public func nostrSetDefaultRelays(_ relays: [String]) async throws { try await run { try $0.nostrSetDefaultRelays(relays: relays) } } diff --git a/Radroots/Runtime/Radroots.swift b/Radroots/Runtime/Radroots.swift @@ -40,4 +40,11 @@ public final class Radroots: ObservableObject { public func info() -> RuntimeInfo? { runtime?.info() } + + public func shutdown() async { + guard let runtimeService else { return } + _ = try? await runtimeService.shutdown() + self.runtimeService = nil + runtime = nil + } } diff --git a/RadrootsTests/SharedEngineLifecycleTests.swift b/RadrootsTests/SharedEngineLifecycleTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import Radroots + +final class SharedEngineLifecycleTests: XCTestCase { + private let secret = String(repeating: "01", count: 32) + + func testIdentityRemainsHostCustodiedAndLockClearsRuntimeState() async throws { + let service = try FieldRuntimeService(runtime: RadrootsRuntime()) + let validated = try await service.nostrIdentityValidateHostCustodySecret(secretKey: secret) + let initialSnapshot = try await service.nostrIdentitySnapshot() + XCTAssertFalse(initialSnapshot.hasSelectedSigningIdentity) + + let selected = try await service.nostrIdentityRestoreHostCustodySecret( + secretKey: secret, + label: "Simulator", + makeSelected: true + ) + XCTAssertEqual(selected.id, validated.id) + let selectedSnapshot = try await service.nostrIdentitySnapshot() + XCTAssertTrue(selectedSnapshot.hasSelectedSigningIdentity) + + try await service.nostrIdentityLockHostCustodyRuntime() + let lockedSnapshot = try await service.nostrIdentitySnapshot() + XCTAssertFalse(lockedSnapshot.hasSelectedSigningIdentity) + _ = try await service.shutdown() + } + + func testRelayStatusReportsCapabilitiesWithoutConnectionCounts() async throws { + let service = try FieldRuntimeService(runtime: RadrootsRuntime()) + try await service.nostrSetDefaultRelays(["wss://relay.damus.io"]) + + let status = try await service.nostrConnectionStatus() + XCTAssertTrue(status.configured) + XCTAssertTrue(status.sourceAvailable) + XCTAssertTrue(status.sinkAvailable) + XCTAssertNil(status.lastError) + _ = try await service.shutdown() + } + + func testShutdownIsExplicitAndIdempotent() async throws { + let service = try FieldRuntimeService(runtime: RadrootsRuntime()) + let first = try await service.shutdown() + let second = try await service.shutdown() + + XCTAssertEqual(first.state, "closed") + XCTAssertFalse(first.alreadyClosed) + XCTAssertEqual(second.state, "closed") + XCTAssertTrue(second.alreadyClosed) + } +} diff --git a/docs/shared-engine-lifecycle-smoke-checklist.md b/docs/shared-engine-lifecycle-smoke-checklist.md @@ -0,0 +1,44 @@ +# Shared engine lifecycle smoke checklist + +Run this checklist on an iOS 18 or newer simulator after regenerating the FFI +artifact from `RadrootsFFI/source.lock`. Use a disposable app installation and +do not enter a production identity. + +## Automated prerequisite + +- Run the `Radroots` scheme unit tests on an arm64 simulator. +- Confirm the app and `SharedEngineLifecycleTests` both pass. +- Confirm the generated binding has no post-stream start, next, or stop API. + +## Host custody and prompts + +- Create a local identity and accept the Apple user-presence prompt. +- Background and foreground the app; confirm the identity remains locked until + the Apple prompt succeeds and no secret appears in logs or diagnostics. +- Cancel the prompt once; confirm the UI reports a bounded error and does not + replace the selected Keychain item. +- Sign out; confirm the runtime identity is cleared while the Keychain-backed + identity remains available for a later unlock. +- Reset local identity; confirm the Keychain item, public metadata, pending + background work, and in-memory signer are all removed. + +## Relay, cancellation, and background behavior + +- Configure one valid relay and confirm read/write operations report + `Available`; no connection count is displayed or exported. +- Open and close the feed; confirm polling stops on disappearance and resumes + with bounded fetches when reopened. +- Send the app to the background during a fetch; confirm Apple background task + scheduling remains host-owned and the app returns without a stuck spinner. +- Terminate the app from the simulator switcher; confirm the next launch starts + a fresh runtime and requires host restoration of the selected identity. + +## Logging and error presentation + +- Exercise invalid secret, invalid relay, and unavailable network paths. +- Confirm SDK errors are presented through their secret-safe message and + capability category, with no secret, raw event payload, or private path in + unified logging, file logging, diagnostics JSON, or the UI. +- Export diagnostics and confirm format + `radroots_field_ios_diagnostics_v2` contains only configured relays, + categorical source/sink availability, and the bounded last error. diff --git a/project.yml b/project.yml @@ -40,8 +40,23 @@ targets: script: | GIT_SHA=$(git rev-parse --short HEAD || echo "unknown") echo "GIT_SHA = $GIT_SHA" > "$SRCROOT/Radroots/radroots.git.xcconfig" + RadrootsTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "18.0" + sources: + - path: RadrootsTests + settings: + base: + SWIFT_VERSION: 6.0 + GENERATE_INFOPLIST_FILE: true + dependencies: + - target: Radroots schemes: Radroots: build: targets: Radroots: all + test: + targets: + - RadrootsTests