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 4c9510c419ed1f3b23f404589abce08d763daa38
parent 42129ae36227092930d929864fdea0ebfbb805c9
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 00:18:33 +0000

refactor(ios): own runtime lifecycle behind an actor

- confine generated UniFFI types to a dedicated adapter framework
- serialize start retry stop and subscription cancellation by generation
- bound independent change streams and preserve callback ordering
- add Swift 6 lifecycle concurrency and typed shutdown stress coverage

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 515++++++++++++++++++++++++++++++++++---------------------------------------------
MRadroots/App/App.swift | 6+-----
MRadroots/App/AppEntry.swift | 137+++----------------------------------------------------------------------------
ARadroots/App/RadrootsAppModel.swift | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/App/RadrootsProvider.swift | 44++++++++++++--------------------------------
DRadroots/Runtime/FieldRuntimeError.swift | 59-----------------------------------------------------------
DRadroots/Runtime/FieldRuntimeService.swift | 99-------------------------------------------------------------------------------
DRadroots/Runtime/Radroots.swift | 50--------------------------------------------------
ARadroots/Runtime/RadrootsGeneratedRuntimeBackend.swift | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Runtime/RadrootsRuntimeClient.swift | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Runtime/RadrootsRuntimeModels.swift | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Views/RuntimeStatusView.swift | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadrootsFFI/source.lock | 2+-
ARadrootsTests/RadrootsRuntimeClientTests.swift | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DRadrootsTests/SharedEngineLifecycleTests.swift | 50--------------------------------------------------
Mproject.yml | 37++++++++++++++++++++++++++++++-------
16 files changed, 1470 insertions(+), 727 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -7,61 +7,32 @@ objects = { /* Begin PBXBuildFile section */ - 022DA21729F49893319717AA /* RelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D9496F9F05A4E79E73A247 /* RelaysView.swift */; }; - 049D620DD8C02816893BF765 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE1472FFD63A33F3AEA6C6C /* AppState.swift */; }; - 04AA409CFECBA11BFC175C5C /* RadrootsFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */; }; - 1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */; }; - 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0274A0260D1C04F40C71AF /* HomeView.swift */; }; - 25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */; }; - 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; }; - 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */; }; - 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; }; - 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; }; - 2FAE0FC43EB547F2CE7A567D /* FieldBackgroundExecutionUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */; }; - 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; }; - 3A7FA9E5BCC7590B2EAC5349 /* RelaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBB081610305940C7849C7C /* RelaySettings.swift */; }; - 3B6020E24A2DAD8ADFC2F155 /* BuildConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */; }; - 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */; }; - 5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */; }; - 5FB3E3E450EE7DFF30F3A005 /* FieldIdentityPolicyUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E150C6C18B2A06F2F3227C6 /* FieldIdentityPolicyUITestProbe.swift */; }; - 657BEA5AAFF129E10177FE63 /* Nostr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63189EB90A86A9929BECD9ED /* Nostr.swift */; }; - 6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */; }; - 7C8DD424F3E3E0AB1B133863 /* RadrootsKitBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */; }; - 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 */; }; - 9346DA48630668A65D37E14A /* FieldTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */; }; - 98FD8B614B481333C3F7708E /* FieldIdentityImportFailureUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481E7791F4F6CF82FA3117C1 /* FieldIdentityImportFailureUITestProbe.swift */; }; - A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA8F5611075F60F214A00 /* SectionWideButton.swift */; }; - A54E244A554EC6B46DF8DE48 /* RadrootsAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D317BC9F759709098490DD /* RadrootsAppDelegate.swift */; }; - ABBA5CC10933CA087E14A0E8 /* FieldBackgroundExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */; }; - B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */; }; - B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */; }; - C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818363B157125491FB84A1E /* App.swift */; }; - C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */; }; - CC5561169A29B5B2B6423959 /* FieldTelemetryUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E6A1827AB4419F806CD848F /* FieldTelemetryUITestProbe.swift */; }; - D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */; }; - D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A4289F43625DD65E6C4B25 /* CopyRow.swift */; }; - D3E08BD0EB07C4E687BDAEF0 /* FieldBackgroundURLSessionEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DA5C6DE4731C46D53B757E3 /* FieldBackgroundURLSessionEvents.swift */; }; - D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */; }; - D57452A5B550E4832913AF02 /* FieldUITestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901B3694434DC803B37651E8 /* FieldUITestSupport.swift */; }; - D62E9461833A0AA5E622A1E6 /* ToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227028B4EBDC6703999FB9DA /* ToastModifier.swift */; }; - D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */; }; - DCE468F668A3C346E716B04C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CCF0F7B3C57D8D770F178329 /* Assets.xcassets */; }; - DDDA83E35D868FE927D2ED37 /* FieldRuntimeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32238D3DFB4465DEEDE6E003 /* FieldRuntimeError.swift */; }; - E1EDAEE6B182025ACAF754A6 /* RadrootsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15DBA726450712D6DE88E951 /* RadrootsProvider.swift */; }; - E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA7BAA021EE13E829390B /* Bundle+Build.swift */; }; - E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */; }; - EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */; }; - F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */; }; + 27F796571E92A6589E0111E3 /* RadrootsKitBindings.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; }; + 47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */; }; + 5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */; }; + 69326B7DA05C115D0055AD13 /* RadrootsKitBindings.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */; }; + 7527378BC2D91420121C1C9C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F432D5450B21DFADF99FACD1 /* Localizable.strings */; }; + AA7D80A73CD6C22ED5080781 /* RadrootsKitBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879C89400666972D90996AE5 /* RadrootsKitBindings.swift */; }; + B356719A54375706DBBDC118 /* RadrootsRuntimeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DDB24124BB8917DBB49EC2C /* RadrootsRuntimeModels.swift */; }; + C356F2A068722087ED91256C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 298DA81B0A000E307098C840 /* Assets.xcassets */; }; + CA827A410369AC55B0FEE305 /* RuntimeStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9469790A906080D90F8735CB /* RuntimeStatusView.swift */; }; + CAD7B38817DC65AAF19576F7 /* RadrootsFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */; }; + CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */; }; + E236CCF8D8F8AC12AF59932E /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 397ED29DF8E9BF73B4BBFDEC /* App.swift */; }; + ED326EC0067A550727A1680D /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0D9C4E6CAE584304521D08 /* AppEntry.swift */; }; F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */; }; + F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 51F4BD021D143679140FF35F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 572C41532B5066EC7641561C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1C22115E511334B291A71594; + remoteInfo = RadrootsKitBindings; + }; 5B242C3EC56D6F3216103D77 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 572C41532B5066EC7641561C /* Project object */; @@ -71,74 +42,55 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 271D40E26A903BC9F058633E /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 69326B7DA05C115D0055AD13 /* RadrootsKitBindings.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase 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>"; }; - 138AA7BAA021EE13E829390B /* Bundle+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Build.swift"; sourceTree = "<group>"; }; - 15DBA726450712D6DE88E951 /* RadrootsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsProvider.swift; sourceTree = "<group>"; }; - 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; }; - 19D317BC9F759709098490DD /* RadrootsAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAppDelegate.swift; sourceTree = "<group>"; }; - 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>"; }; + 298DA81B0A000E307098C840 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; + 30A8A8321A8A261F6D1B480D /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; 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>"; }; - 41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; }; - 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>"; }; - 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>"; }; - 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>"; }; - 8DA5C6DE4731C46D53B757E3 /* FieldBackgroundURLSessionEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldBackgroundURLSessionEvents.swift; sourceTree = "<group>"; }; - 8E6A1827AB4419F806CD848F /* FieldTelemetryUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldTelemetryUITestProbe.swift; sourceTree = "<group>"; }; - 8F0F21496E7A8490EB14AC5B /* Radroots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radroots.swift; sourceTree = "<group>"; }; - 901B3694434DC803B37651E8 /* FieldUITestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldUITestSupport.swift; sourceTree = "<group>"; }; + 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsAppModel.swift; sourceTree = "<group>"; }; + 397ED29DF8E9BF73B4BBFDEC /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; + 3DDB24124BB8917DBB49EC2C /* RadrootsRuntimeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeModels.swift; sourceTree = "<group>"; }; + 43B0EA447E213CD1B96846D6 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; }; + 4E0D9C4E6CAE584304521D08 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; }; + 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsProvider.swift; sourceTree = "<group>"; }; + 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClient.swift; sourceTree = "<group>"; }; + 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsGeneratedRuntimeBackend.swift; sourceTree = "<group>"; }; + 67A31CC210B73EC072BD3542 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; + 879C89400666972D90996AE5 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; 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>"; }; - 9AE0EB327C10171444553378 /* PostFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedView.swift; sourceTree = "<group>"; }; - 9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldLocalState.swift; sourceTree = "<group>"; }; - A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Nav.swift"; sourceTree = "<group>"; }; - A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildConfig.swift; sourceTree = "<group>"; }; - ADE61264E2C98E73828E8680 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; }; - B289F4B276245ABE083D777F /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; + 9469790A906080D90F8735CB /* RuntimeStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeStatusView.swift; sourceTree = "<group>"; }; BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = RadrootsFFI.xcframework; path = Radroots/Frameworks/RadrootsFFI.xcframework; sourceTree = "<group>"; }; - C17CA8F5611075F60F214A00 /* SectionWideButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionWideButton.swift; sourceTree = "<group>"; }; - C1D9496F9F05A4E79E73A247 /* RelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaysView.swift; sourceTree = "<group>"; }; - C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldBackgroundExecution.swift; sourceTree = "<group>"; }; - C71A93F98C7B93188748B99B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; }; - CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCreateView.swift; sourceTree = "<group>"; }; - CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldIdentityPublicMetadataStore.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>"; }; - D448C9655B708CA3FA8712B9 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; }; - D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingSettings.swift; sourceTree = "<group>"; }; - D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldUserPresenceGate.swift; sourceTree = "<group>"; }; - DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; }; - E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; - E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldDocumentInterchange.swift; sourceTree = "<group>"; }; - E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldRuntimeService.swift; sourceTree = "<group>"; }; - EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldDocumentInterchangeUITestProbe.swift; sourceTree = "<group>"; }; - F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedViewModel.swift; sourceTree = "<group>"; }; - F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailView.swift; sourceTree = "<group>"; }; - F4C7DE4207398DE242519F9C /* CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyButton.swift; sourceTree = "<group>"; }; + D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsRuntimeClientTests.swift; sourceTree = "<group>"; }; + FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = RadrootsKitBindings.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 044C68780F358BC2C58657B8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CAD7B38817DC65AAF19576F7 /* RadrootsFFI.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1384F9BE8BB29250A1B4A26A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 04AA409CFECBA11BFC175C5C /* RadrootsFFI.xcframework in Frameworks */, + 27F796571E92A6589E0111E3 /* RadrootsKitBindings.framework in Frameworks */, F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -146,119 +98,53 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 23C2D7FF63B61CD356979E82 /* App */ = { - isa = PBXGroup; - children = ( - 2818363B157125491FB84A1E /* App.swift */, - D448C9655B708CA3FA8712B9 /* AppEntry.swift */, - CBE1472FFD63A33F3AEA6C6C /* AppState.swift */, - 19D317BC9F759709098490DD /* RadrootsAppDelegate.swift */, - 15DBA726450712D6DE88E951 /* RadrootsProvider.swift */, - ); - path = App; - sourceTree = "<group>"; - }; 31C88176C5674CFF5F9CFBEA /* RadrootsTests */ = { isa = PBXGroup; children = ( - 4C4ADF0DD703CA020D0CF67D /* SharedEngineLifecycleTests.swift */, + D0451E8A8521D320172FFA35 /* RadrootsRuntimeClientTests.swift */, ); path = RadrootsTests; sourceTree = "<group>"; }; - 579F407D96CCAFD4000EF363 /* Config */ = { + 401A3C62BEECB8BD5C500756 /* Config */ = { isa = PBXGroup; children = ( - 676B89EB116B60AE8C2B4313 /* Base.xcconfig */, - 7C294E8EF50F5E1E73F5C135 /* Common.xcconfig */, - B289F4B276245ABE083D777F /* Debug.xcconfig */, - 08FA88664E5E3ED3A24D56CC /* Release.xcconfig */, + 30A8A8321A8A261F6D1B480D /* Debug.xcconfig */, + 43B0EA447E213CD1B96846D6 /* Release.xcconfig */, ); - path = Config; - sourceTree = "<group>"; - }; - 5FD6379AE27C57D02E8C7EE1 /* Radroots */ = { - isa = PBXGroup; - children = ( - 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */, - 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */, - 23C2D7FF63B61CD356979E82 /* App */, - 579F407D96CCAFD4000EF363 /* Config */, - 902187CDBBF756677402D2BF /* Generated */, - E9C466456E8A8EEB73EE47F5 /* Resources */, - 688000357CB95AB5B3067911 /* Runtime */, - 932F9ACAF6A7D30E34F2E375 /* Shared */, - BD0E20D32DF34D9E7C3EBCD2 /* Views */, - ); - path = Radroots; + name = Config; + path = Radroots/Config; sourceTree = "<group>"; }; 6240123423927396E47D6B3E /* Products */ = { isa = PBXGroup; children = ( 93AA285819DD1269C3EAD80A /* Radroots.app */, + FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */, 318804462AADCA05FB04ACD9 /* RadrootsTests.xctest */, ); name = Products; sourceTree = "<group>"; }; - 65EC1C4AF7DC676E78603D52 /* Localisation */ = { - isa = PBXGroup; - children = ( - ADE61264E2C98E73828E8680 /* Strings.swift */, - ); - path = Localisation; - sourceTree = "<group>"; - }; - 688000357CB95AB5B3067911 /* Runtime */ = { - isa = PBXGroup; - children = ( - A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, - C614F2A8813E63C85261F492 /* FieldBackgroundExecution.swift */, - 59AC0543EF8335D691D56BD3 /* FieldBackgroundExecutionUITestProbe.swift */, - 8DA5C6DE4731C46D53B757E3 /* FieldBackgroundURLSessionEvents.swift */, - 7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */, - E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */, - EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */, - 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */, - 491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */, - 481E7791F4F6CF82FA3117C1 /* FieldIdentityImportFailureUITestProbe.swift */, - 4E150C6C18B2A06F2F3227C6 /* FieldIdentityPolicyUITestProbe.swift */, - CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */, - 9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */, - 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */, - 32238D3DFB4465DEEDE6E003 /* FieldRuntimeError.swift */, - E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */, - 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */, - 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */, - 8E6A1827AB4419F806CD848F /* FieldTelemetryUITestProbe.swift */, - 901B3694434DC803B37651E8 /* FieldUITestSupport.swift */, - D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */, - D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */, - 63189EB90A86A9929BECD9ED /* Nostr.swift */, - 8F0F21496E7A8490EB14AC5B /* Radroots.swift */, - 6FBB081610305940C7849C7C /* RelaySettings.swift */, - ); - path = Runtime; - sourceTree = "<group>"; - }; - 902187CDBBF756677402D2BF /* Generated */ = { + 64C889EFCEB42611797110F7 /* Runtime */ = { isa = PBXGroup; children = ( - DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */, + 5B14CE9958D4C71AFB41D2DD /* RadrootsGeneratedRuntimeBackend.swift */, + 57F5216F7C18E2EE21B8528A /* RadrootsRuntimeClient.swift */, + 3DDB24124BB8917DBB49EC2C /* RadrootsRuntimeModels.swift */, ); - path = Generated; + name = Runtime; + path = Radroots/Runtime; sourceTree = "<group>"; }; - 932F9ACAF6A7D30E34F2E375 /* Shared */ = { + 94F94915631E6DC54AAB0B89 /* Resources */ = { isa = PBXGroup; children = ( - D46F444AD1818932F03AC6B6 /* Components */, - 9D22575D1FAD99FE8B6FCE6C /* Extensions */, - 65EC1C4AF7DC676E78603D52 /* Localisation */, - C5BAA3C6E2D410F0C8475D89 /* Modifiers */, + 298DA81B0A000E307098C840 /* Assets.xcassets */, + CF740CDF6131C939A001727E /* Localizations */, ); - path = Shared; + name = Resources; + path = Radroots/Resources; sourceTree = "<group>"; }; 97FA23F0FD7E25C1AF2585FB /* Frameworks */ = { @@ -269,79 +155,80 @@ name = Frameworks; sourceTree = "<group>"; }; - 9D22575D1FAD99FE8B6FCE6C /* Extensions */ = { + C22A3ECCB9E9AF9E57B74576 /* App */ = { isa = PBXGroup; children = ( - 138AA7BAA021EE13E829390B /* Bundle+Build.swift */, - A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */, + 397ED29DF8E9BF73B4BBFDEC /* App.swift */, + 4E0D9C4E6CAE584304521D08 /* AppEntry.swift */, + 343F115F383EB9C8A796AC9C /* RadrootsAppModel.swift */, + 57C4A7EB045E43F109C4DCA1 /* RadrootsProvider.swift */, ); - path = Extensions; - sourceTree = "<group>"; - }; - BD0E20D32DF34D9E7C3EBCD2 /* Views */ = { - isa = PBXGroup; - children = ( - 0A0274A0260D1C04F40C71AF /* HomeView.swift */, - CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */, - F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */, - 9AE0EB327C10171444553378 /* PostFeedView.swift */, - F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */, - C71A93F98C7B93188748B99B /* ProfileView.swift */, - C1D9496F9F05A4E79E73A247 /* RelaysView.swift */, - E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */, - 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */, - ); - path = Views; + name = App; + path = Radroots/App; sourceTree = "<group>"; }; C4F02317699AB4FA59315D05 = { isa = PBXGroup; children = ( - 5FD6379AE27C57D02E8C7EE1 /* Radroots */, + C22A3ECCB9E9AF9E57B74576 /* App */, + 401A3C62BEECB8BD5C500756 /* Config */, + D17ED36BEBE689794F31C2BB /* Generated */, 31C88176C5674CFF5F9CFBEA /* RadrootsTests */, + 94F94915631E6DC54AAB0B89 /* Resources */, + 64C889EFCEB42611797110F7 /* Runtime */, + E05061D0ADB560FA929B4D9A /* Views */, 97FA23F0FD7E25C1AF2585FB /* Frameworks */, 6240123423927396E47D6B3E /* Products */, ); sourceTree = "<group>"; }; - C5BAA3C6E2D410F0C8475D89 /* Modifiers */ = { + CF740CDF6131C939A001727E /* Localizations */ = { isa = PBXGroup; children = ( - 227028B4EBDC6703999FB9DA /* ToastModifier.swift */, - ); - path = Modifiers; - sourceTree = "<group>"; - }; - C5BC6846E71297290E1EAA29 /* Localizations */ = { - isa = PBXGroup; - children = ( - 8D19AA8D515FC8F5D2407378 /* Localizable.strings */, + F432D5450B21DFADF99FACD1 /* Localizable.strings */, ); path = Localizations; sourceTree = "<group>"; }; - D46F444AD1818932F03AC6B6 /* Components */ = { + D17ED36BEBE689794F31C2BB /* Generated */ = { isa = PBXGroup; children = ( - F4C7DE4207398DE242519F9C /* CopyButton.swift */, - 41A4289F43625DD65E6C4B25 /* CopyRow.swift */, - C17CA8F5611075F60F214A00 /* SectionWideButton.swift */, + 879C89400666972D90996AE5 /* RadrootsKitBindings.swift */, ); - path = Components; + name = Generated; + path = Radroots/Generated; sourceTree = "<group>"; }; - E9C466456E8A8EEB73EE47F5 /* Resources */ = { + E05061D0ADB560FA929B4D9A /* Views */ = { isa = PBXGroup; children = ( - CCF0F7B3C57D8D770F178329 /* Assets.xcassets */, - C5BC6846E71297290E1EAA29 /* Localizations */, + 9469790A906080D90F8735CB /* RuntimeStatusView.swift */, ); - path = Resources; + name = Views; + path = Radroots/Views; sourceTree = "<group>"; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 1C22115E511334B291A71594 /* RadrootsKitBindings */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4709AFBD0CA5DE7FC73BFC95 /* Build configuration list for PBXNativeTarget "RadrootsKitBindings" */; + buildPhases = ( + 21C962D4AD2665D5ED66E70F /* Sources */, + 044C68780F358BC2C58657B8 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RadrootsKitBindings; + packageProductDependencies = ( + ); + productName = RadrootsKitBindings; + productReference = FF8CEF5491290B1218ACFCE7 /* RadrootsKitBindings.framework */; + productType = "com.apple.product-type.framework"; + }; 469EC50C49E7266DF1C2000B /* Radroots */ = { isa = PBXNativeTarget; buildConfigurationList = 2513910CE2A2ED545A78A163 /* Build configuration list for PBXNativeTarget "Radroots" */; @@ -350,10 +237,12 @@ 78F7810CC77628CB591F8CD2 /* Sources */, 0FB0BFB846269AC353E4AD0E /* Resources */, 1384F9BE8BB29250A1B4A26A /* Frameworks */, + 271D40E26A903BC9F058633E /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + 3AF61EED138E0994701CAA79 /* PBXTargetDependency */, ); name = Radroots; packageProductDependencies = ( @@ -409,6 +298,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( + 1C22115E511334B291A71594 /* RadrootsKitBindings */, 469EC50C49E7266DF1C2000B /* Radroots */, 67B2EF8D69F6CEA9BE5A8B04 /* RadrootsTests */, ); @@ -420,8 +310,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - DCE468F668A3C346E716B04C /* Assets.xcassets in Resources */, - 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */, + C356F2A068722087ED91256C /* Assets.xcassets in Resources */, + 7527378BC2D91420121C1C9C /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -450,11 +340,19 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 21C962D4AD2665D5ED66E70F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA7D80A73CD6C22ED5080781 /* RadrootsKitBindings.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4948BBB44D48B961DE81F508 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 850F3C1E444A4C0BC51A608D /* SharedEngineLifecycleTests.swift in Sources */, + CEED5B97CB1F94445162D662 /* RadrootsRuntimeClientTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -462,59 +360,25 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */, - 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */, - 049D620DD8C02816893BF765 /* AppState.swift in Sources */, - 3B6020E24A2DAD8ADFC2F155 /* BuildConfig.swift in Sources */, - E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */, - 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, - D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, - ABBA5CC10933CA087E14A0E8 /* FieldBackgroundExecution.swift in Sources */, - 2FAE0FC43EB547F2CE7A567D /* FieldBackgroundExecutionUITestProbe.swift in Sources */, - D3E08BD0EB07C4E687BDAEF0 /* FieldBackgroundURLSessionEvents.swift in Sources */, - 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */, - 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */, - E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */, - 82903551F5E15FBDAC388D20 /* FieldExternalActions.swift in Sources */, - 7E650F6DA30931E310F842E2 /* FieldFileAccessUITestProbe.swift in Sources */, - 98FD8B614B481333C3F7708E /* FieldIdentityImportFailureUITestProbe.swift in Sources */, - 5FB3E3E450EE7DFF30F3A005 /* FieldIdentityPolicyUITestProbe.swift in Sources */, - D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */, - 6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */, - 1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */, - DDDA83E35D868FE927D2ED37 /* FieldRuntimeError.swift in Sources */, - D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */, - D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */, - 9346DA48630668A65D37E14A /* FieldTelemetry.swift in Sources */, - CC5561169A29B5B2B6423959 /* FieldTelemetryUITestProbe.swift in Sources */, - D57452A5B550E4832913AF02 /* FieldUITestSupport.swift in Sources */, - 25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */, - 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, - B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */, - 657BEA5AAFF129E10177FE63 /* Nostr.swift in Sources */, - F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */, - EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */, - 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */, - 5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */, - 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */, - 8F6D0970610DF68816DE1A98 /* Radroots.swift in Sources */, - A54E244A554EC6B46DF8DE48 /* RadrootsAppDelegate.swift in Sources */, - 7C8DD424F3E3E0AB1B133863 /* RadrootsKitBindings.swift in Sources */, - E1EDAEE6B182025ACAF754A6 /* RadrootsProvider.swift in Sources */, - 3A7FA9E5BCC7590B2EAC5349 /* RelaySettings.swift in Sources */, - 022DA21729F49893319717AA /* RelaysView.swift in Sources */, - A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */, - C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */, - 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */, - 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */, - D62E9461833A0AA5E622A1E6 /* ToastModifier.swift in Sources */, - B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */, + E236CCF8D8F8AC12AF59932E /* App.swift in Sources */, + ED326EC0067A550727A1680D /* AppEntry.swift in Sources */, + 74082509C257766A166662FA /* RadrootsAppModel.swift in Sources */, + 5DAE76BBC71E4A192E029785 /* RadrootsGeneratedRuntimeBackend.swift in Sources */, + 47D9564F81A5E7CEA86B759C /* RadrootsProvider.swift in Sources */, + F7EEF54776B037D98F389AD6 /* RadrootsRuntimeClient.swift in Sources */, + B356719A54375706DBBDC118 /* RadrootsRuntimeModels.swift in Sources */, + CA827A410369AC55B0FEE305 /* RuntimeStatusView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 3AF61EED138E0994701CAA79 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1C22115E511334B291A71594 /* RadrootsKitBindings */; + targetProxy = 51F4BD021D143679140FF35F /* PBXContainerItemProxy */; + }; C4ECFE4B8C347EE1F784B9BA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 469EC50C49E7266DF1C2000B /* Radroots */; @@ -523,10 +387,10 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ - 8D19AA8D515FC8F5D2407378 /* Localizable.strings */ = { + F432D5450B21DFADF99FACD1 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( - 93D729E070C32490545FA837 /* en */, + 67A31CC210B73EC072BD3542 /* en */, ); name = Localizable.strings; sourceTree = "<group>"; @@ -534,25 +398,84 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 065194E2B92BBDACF3D27647 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"Radroots/Frameworks\"", + ); + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.radroots.RadrootsKitBindings; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; 5486087B252C6EA76ADD9BB8 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B289F4B276245ABE083D777F /* Debug.xcconfig */; + baseConfigurationReference = 30A8A8321A8A261F6D1B480D /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; + INFOPLIST_FILE = Radroots/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SDKROOT = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 762521A89ECC390CEE5533D2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "\"Radroots/Frameworks\"", ); - INFOPLIST_FILE = Radroots/Info.plist; + GENERATE_INFOPLIST_FILE = YES; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = org.radroots.RadrootsKitBindings; SDKROOT = iphoneos; - SWIFT_VERSION = 6.0; + SKIP_INSTALL = YES; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; @@ -560,6 +483,7 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -576,14 +500,11 @@ }; D930E9391B5B63DB518922D2 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 08FA88664E5E3ED3A24D56CC /* Release.xcconfig */; + baseConfigurationReference = 43B0EA447E213CD1B96846D6 /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "\"Radroots/Frameworks\"", - ); + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; INFOPLIST_FILE = Radroots/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -600,6 +521,7 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -745,6 +667,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 4709AFBD0CA5DE7FC73BFC95 /* Build configuration list for PBXNativeTarget "RadrootsKitBindings" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 762521A89ECC390CEE5533D2 /* Debug */, + 065194E2B92BBDACF3D27647 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; D17883D6A48F29A2A4DFE795 /* Build configuration list for PBXProject "Radroots" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Radroots/App/App.swift b/Radroots/App/App.swift @@ -2,14 +2,10 @@ import SwiftUI @main struct RadrootsApp: App { - @UIApplicationDelegateAdaptor(RadrootsAppDelegate.self) private var appDelegate - var body: some Scene { WindowGroup { RadrootsProvider { - AppEntry { - HomeView() - } + AppEntry() } } } diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -1,139 +1,12 @@ import SwiftUI -public struct AppEntry<Main: View>: View { - @EnvironmentObject private var appState: AppState - private let main: () -> Main - - public init(@ViewBuilder main: @escaping () -> Main) { - self.main = main - } - - public var body: some View { - Group { - switch appState.bootstrapPhase { - case .idle, .starting: - SplashView() - case .failed(let message): - StartupFailureView(message: message) { - appState.retryStartup() - } - case .ready: - if appState.canShowAppContent { - main() - } else { - NavigationStack { - SetupView() - } - } - } - } - .accessibilityIdentifier("field_ios.app_entry") - #if DEBUG - .overlay(alignment: .topLeading) { - if let probeValue = appState.fileAccessProbeValue { - Color.clear - .frame(width: 1, height: 1) - .accessibilityElement() - .accessibilityIdentifier("field_ios.file_access.probe") - .accessibilityValue(probeValue) - } - if let probeValue = appState.documentInterchangeProbeValue { - Color.clear - .frame(width: 1, height: 1) - .accessibilityElement() - .accessibilityIdentifier("field_ios.document_interchange.probe") - .accessibilityValue(probeValue) - } - if let probeValue = appState.identityPolicyProbeValue { - Color.clear - .frame(width: 1, height: 1) - .accessibilityElement() - .accessibilityIdentifier("field_ios.identity_policy.probe") - .accessibilityValue(probeValue) - } - if let probeValue = appState.identityImportFailureProbeValue { - Color.clear - .frame(width: 1, height: 1) - .accessibilityElement() - .accessibilityIdentifier("field_ios.identity_import_failure.probe") - .accessibilityValue(probeValue) - } - if let probeValue = appState.telemetryProbeValue { - Color.clear - .frame(width: 1, height: 1) - .accessibilityElement() - .accessibilityIdentifier("field_ios.telemetry.probe") - .accessibilityValue(probeValue) - } - if let probeValue = appState.backgroundExecutionProbeValue { - Color.clear - .frame(width: 1, height: 1) - .accessibilityElement() - .accessibilityIdentifier("field_ios.background_execution.probe") - .accessibilityValue(probeValue) - } - } - #endif - } -} - -private struct StartupFailureView: View { - let message: String - let onRetry: () -> Void +struct AppEntry: View { + @EnvironmentObject private var appModel: RadrootsAppModel var body: some View { - VStack(spacing: 18) { - Spacer() - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 56, weight: .semibold)) - .foregroundStyle(.red) - Text("Startup failed") - .font(.title2.weight(.semibold)) - Text(message) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .textSelection(.enabled) - Spacer() - Button { - onRetry() - } label: { - Label("Retry", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .accessibilityIdentifier("field_ios.bootstrap.retry") - } - .padding() - .accessibilityIdentifier("field_ios.bootstrap.failed") - } -} - -private struct SplashView: View { - private let splashGlyphSize: CGFloat = 160 - - var body: some View { - ZStack { - Color("RadrootsSplashBackground") - .ignoresSafeArea() - - Color.clear - .accessibilityElement() - .accessibilityLabel("Startup") - .accessibilityIdentifier("field_ios.bootstrap") - - GeometryReader { proxy in - Image("RadrootsSplashLogomark") - .resizable() - .scaledToFit() - .frame(width: splashGlyphSize, height: splashGlyphSize) - .position(x: proxy.size.width / 2, y: proxy.size.height / 2) - .accessibilityLabel("Radroots") - .accessibilityIdentifier("field_ios.splash.logo") - } - .ignoresSafeArea() + RuntimeStatusView(phase: appModel.phase) { + Task { await appModel.retry() } } - .accessibilityElement(children: .contain) + .accessibilityIdentifier("radroots.app_entry") } } diff --git a/Radroots/App/RadrootsAppModel.swift b/Radroots/App/RadrootsAppModel.swift @@ -0,0 +1,67 @@ +import Foundation + +@MainActor +final class RadrootsAppModel: ObservableObject { + enum Phase: Equatable { + case starting + case identityRequired + case running(RadrootsRuntimeSnapshot) + case failed(RadrootsRuntimeFailure) + case stopped + } + + @Published private(set) var phase: Phase = .starting + + private let runtimeClient: RadrootsRuntimeClient + private let configuration: RadrootsRuntimeLaunchConfiguration? + + init( + runtimeClient: RadrootsRuntimeClient = .production(), + configuration: RadrootsRuntimeLaunchConfiguration? = nil + ) { + self.runtimeClient = runtimeClient + self.configuration = configuration + } + + func start() async { + guard let configuration else { + phase = .identityRequired + return + } + phase = .starting + do { + phase = try await .running(runtimeClient.start(configuration: configuration)) + } catch let RadrootsRuntimeClientError.startup(failure) { + phase = .failed(failure) + } catch { + phase = .failed( + .local( + operation: "app.start", + code: "ios.app.start_failed", + safeMessage: "Radroots could not start." + ) + ) + } + } + + func retry() async { + await start() + } + + func stop() async { + do { + _ = try await runtimeClient.stop() + phase = .stopped + } catch let RadrootsRuntimeClientError.shutdown(failure) { + phase = .failed(failure) + } catch { + phase = .failed( + .local( + operation: "app.stop", + code: "ios.app.stop_failed", + safeMessage: "Radroots could not finish shutting down." + ) + ) + } + } +} diff --git a/Radroots/App/RadrootsProvider.swift b/Radroots/App/RadrootsProvider.swift @@ -1,48 +1,28 @@ import SwiftUI -import UIKit -public struct RadrootsProvider<Content: View>: View { +struct RadrootsProvider<Content: View>: View { @Environment(\.scenePhase) private var scenePhase - @StateObject private var appState = AppState() - private let onStartupError: ((Error) -> Void)? + @StateObject private var appModel: RadrootsAppModel private let content: () -> Content - public init( - onStartupError: ((Error) -> Void)? = nil, + init( + appModel: @autoclosure @escaping () -> RadrootsAppModel = RadrootsAppModel(), @ViewBuilder content: @escaping () -> Content ) { - self.onStartupError = onStartupError + _appModel = StateObject(wrappedValue: appModel()) self.content = content } - public var body: some View { + var body: some View { content() - .environmentObject(appState) - .environmentObject(appState.radroots) - .task { - do { - try await appState.start() - } catch { - onStartupError?(error) - } - } + .environmentObject(appModel) + .task { await appModel.start() } .onChange(of: scenePhase) { _, phase in - switch phase { - case .active: - appState.appDidBecomeActive() - case .background: - appState.appDidEnterBackground() - case .inactive: - break - @unknown default: - break + if phase == .active { + Task { await appModel.start() } + } else if phase == .background { + Task { await appModel.stop() } } } - .onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification)) { _ in - Task { await appState.shutdown() } - } - .onDisappear { - Task { await appState.shutdown() } - } } } diff --git a/Radroots/Runtime/FieldRuntimeError.swift b/Radroots/Runtime/FieldRuntimeError.swift @@ -1,59 +0,0 @@ -import Foundation - -enum FieldRuntimeErrorCategory: String, Sendable { - case initialization - case identity - case secureStore - case relay - case runtime - case unsupported - case internalFailure -} - -extension RadrootsAppError { - var fieldCategory: FieldRuntimeErrorCategory { - switch self { - case .Initialization(_): - .initialization - 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(_): - .unsupported - case .Internal(_): - .internalFailure - } - } - - var fieldMessage: String { - switch self { - case .Initialization(let message), - .Runtime(let message), - .Unsupported(let message), - .Internal(let message): - message - case .Sdk(let report): - report.message - } - } -} - -extension Error { - var fieldRuntimeErrorCategory: FieldRuntimeErrorCategory? { - (self as? RadrootsAppError)?.fieldCategory - } - - var fieldRuntimeMessage: String { - if let fieldError = self as? RadrootsAppError { - return fieldError.fieldMessage - } - return localizedDescription - } -} diff --git a/Radroots/Runtime/FieldRuntimeService.swift b/Radroots/Runtime/FieldRuntimeService.swift @@ -1,99 +0,0 @@ -import Foundation - -public final class FieldRuntimeService: @unchecked Sendable { - let runtime: RadrootsRuntime - private let queue = DispatchQueue(label: "org.radroots.field_ios.runtime", qos: .userInitiated) - - public init(runtime: RadrootsRuntime) { - self.runtime = runtime - } - - func run<T>(_ work: @escaping @Sendable (RadrootsRuntime) throws -> T) async throws -> T { - try await withCheckedThrowingContinuation { continuation in - queue.async { - do { - continuation.resume(returning: try work(self.runtime)) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - func runValue<T>(_ work: @escaping @Sendable (RadrootsRuntime) -> T) async -> T { - await withCheckedContinuation { continuation in - queue.async { - continuation.resume(returning: work(self.runtime)) - } - } - } - - public func infoJson() async -> String { - 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) } - } - - public func nostrConnectIfKeyPresent() async throws { - try await run { try $0.nostrConnectIfKeyPresent() } - } - - public func nostrConnectionStatus() async throws -> NostrConnectionStatus { - try await runtime.nostrConnectionStatus() - } - - public func nostrIdentitySnapshot() async throws -> NostrIdentitySnapshot { - try await run { try $0.nostrIdentitySnapshot() } - } - - public func nostrIdentityList() async throws -> [NostrIdentityRecord] { - try await run { try $0.nostrIdentityList() } - } - - public func nostrIdentityValidateHostCustodySecret(secretKey: String) async throws -> NostrHostCustodyIdentity { - try await run { try $0.nostrIdentityValidateHostCustodySecret(secretKey: secretKey) } - } - - public func nostrIdentityRestoreHostCustodySecret( - secretKey: String, - label: String?, - makeSelected: Bool - ) async throws -> NostrIdentityRecord { - try await run { - try $0.nostrIdentityRestoreHostCustodySecret( - secretKey: secretKey, - label: label, - makeSelected: makeSelected - ) - } - } - - public func nostrIdentityRemove(identityId: String) async throws { - try await run { try $0.nostrIdentityRemove(identityId: identityId) } - } - - public func nostrIdentityLockHostCustodyRuntime() async throws { - try await run { try $0.nostrIdentityLockHostCustodyRuntime() } - } - - public func nostrIdentityResetHostCustodyRuntime() async throws { - try await run { try $0.nostrIdentityResetHostCustodyRuntime() } - } - - public func nostrProfileForSelf() async throws -> NostrProfileEventMetadata? { - try await runtime.nostrProfileForSelf() - } - - public func nostrFetchTextNotes( - limit: UInt16, - sinceUnix: UInt64? - ) async throws -> [NostrPostEventMetadata] { - try await runtime.nostrFetchTextNotes(limit: limit, sinceUnix: sinceUnix) - } -} diff --git a/Radroots/Runtime/Radroots.swift b/Radroots/Runtime/Radroots.swift @@ -1,50 +0,0 @@ -import Foundation - -@MainActor -public final class Radroots: ObservableObject { - public private(set) var runtime: RadrootsRuntime? - public private(set) var runtimeService: FieldRuntimeService? - - public init() {} - - func start( - bundleId: String = Bundle.main.bundleIdentifier ?? "unknown", - version: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0", - build: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "0", - buildSha: String? = nil, - telemetry: FieldTelemetry = .shared - ) throws -> FieldRuntimeService { - let settings = LoggingSettings.load() - do { - try settings.apply(bundleIdentifier: bundleId) - } catch { - throw FieldRuntimeLoggingError.initializationFailed(error.fieldRuntimeMessage) - } - telemetry.runtimeLoggingInitialized(settings: settings) - - let rt = try RadrootsRuntime() - let resolvedSha = buildSha ?? (Bundle.main.object(forInfoDictionaryKey: "GIT_SHA") as? String) - rt.setAppInfoPlatform( - platform: "iOS", - bundleId: bundleId, - version: version, - buildNumber: build, - buildSha: resolvedSha - ) - self.runtime = rt - let service = FieldRuntimeService(runtime: rt) - self.runtimeService = service - return service - } - - 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/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift b/Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift @@ -0,0 +1,239 @@ +import Foundation +import RadrootsKitBindings + +private final class RadrootsGeneratedRuntimeObserver: RadrootsRuntimeObserver, @unchecked Sendable { + private let continuation: AsyncStream<RadrootsRuntimeChange>.Continuation + + init(continuation: AsyncStream<RadrootsRuntimeChange>.Continuation) { + self.continuation = continuation + } + + func onChange(change: FfiRuntimeChangeRecord) { + continuation.yield( + RadrootsRuntimeChange( + schemaVersion: change.schemaVersion, + generation: change.generation, + kind: change.kind.appValue, + entityID: change.entityId + ) + ) + } + + func finish() { + continuation.finish() + } +} + +private actor RadrootsGeneratedSubscriptionToken: RadrootsRuntimeSubscriptionToken { + private var handle: FfiSubscriptionHandle? + private var observer: RadrootsGeneratedRuntimeObserver? + private var deliveryTask: Task<Void, Never>? + + init( + handle: FfiSubscriptionHandle, + observer: RadrootsGeneratedRuntimeObserver, + deliveryTask: Task<Void, Never> + ) { + self.handle = handle + self.observer = observer + self.deliveryTask = deliveryTask + } + + func cancel() async { + guard let handle else { return } + self.handle = nil + observer?.finish() + observer = nil + handle.unsubscribe() + deliveryTask?.cancel() + await deliveryTask?.value + deliveryTask = nil + } +} + +private final class RadrootsGeneratedRuntimeBackend: RadrootsRuntimeBackend, @unchecked Sendable { + private let runtime: RadrootsRuntime + + init(runtime: RadrootsRuntime) { + self.runtime = runtime + } + + func snapshot() async throws -> RadrootsRuntimeSnapshot { + do { + let identity = try runtime.identityStatus() + let relay = try runtime.sdkRelayStatus() + let info = runtime.info() + return RadrootsRuntimeSnapshot( + identity: RadrootsRuntimeIdentity( + publicKeyHex: identity.publicKey, + hostSignerConfigured: identity.hostSignerConfigured + ), + relay: relay.map { report in + RadrootsRelayStatus( + profile: report.profile, + state: report.state, + readAvailability: report.readAvailability, + writeAvailability: report.writeAvailability, + relays: report.relays.map { relay in + RadrootsRelayEndpointStatus( + url: relay.relayUrl, + access: relay.access, + readState: relay.readState, + writeState: relay.writeState, + readLastAttemptUnixMilliseconds: relay.readLastAttemptUnixMs, + writeLastAttemptUnixMilliseconds: relay.writeLastAttemptUnixMs, + readNextAttemptUnixMilliseconds: relay.readNextAttemptUnixMs, + writeNextAttemptUnixMilliseconds: relay.writeNextAttemptUnixMs + ) + } + ) + }, + crateName: info.sdk.crateName, + crateVersion: info.sdk.crateVersion, + isClosed: info.sdkClosed + ) + } catch { + throw Self.failure(from: error) + } + } + + func subscribe( + bufferCapacity: Int, + receive: @escaping @Sendable (RadrootsRuntimeChange) async -> Void + ) async throws -> any RadrootsRuntimeSubscriptionToken { + let pair = AsyncStream.makeStream( + of: RadrootsRuntimeChange.self, + bufferingPolicy: .bufferingNewest(bufferCapacity) + ) + do { + let observer = RadrootsGeneratedRuntimeObserver(continuation: pair.continuation) + let handle = try runtime.subscribeChanges(observer: observer) + let deliveryTask = Task { + for await change in pair.stream { + guard !Task.isCancelled else { break } + await receive(change) + } + } + return RadrootsGeneratedSubscriptionToken( + handle: handle, + observer: observer, + deliveryTask: deliveryTask + ) + } catch { + pair.continuation.finish() + throw Self.failure(from: error) + } + } + + func shutdown() async throws -> RadrootsRuntimeShutdownReceipt { + do { + let receipt = try await runtime.shutdown() + return RadrootsRuntimeShutdownReceipt( + state: receipt.state, + alreadyClosed: receipt.alreadyClosed + ) + } catch { + throw Self.failure(from: error) + } + } + + static func start( + configuration: RadrootsRuntimeLaunchConfiguration + ) async throws -> RadrootsRuntimeBackendStart { + var createdRuntime: RadrootsRuntime? + do { + let runtime = try await RadrootsRuntime( + applicationSupportDirectory: configuration.applicationSupportDirectory, + publicKeyHex: configuration.publicKeyHex, + sourceGenerationHex: configuration.sourceGenerationHex, + sourceGenerationCreatedAtUnixMs: configuration.sourceGenerationCreatedAtUnixMilliseconds, + protectedData: configuration.protectedData.generatedValue + ) + createdRuntime = runtime + runtime.setAppInfoPlatform( + platform: "iOS", + bundleId: configuration.app.bundleIdentifier, + version: configuration.app.version, + buildNumber: configuration.app.buildNumber, + buildSha: configuration.app.buildSHA + ) + + switch configuration.networkProfile { + case .publicNetwork: + try runtime.configurePublicRelays(writableRelays: configuration.writableRelays) + try runtime.configurePublicBlossom(origins: configuration.blossomOrigins) + case .simulator: + try runtime.configureSimulatorRelays(loopbackRelays: configuration.writableRelays) + try runtime.configureSimulatorBlossom(origins: configuration.blossomOrigins) + case .device: + try runtime.configureDeviceRelays(writableRelays: configuration.writableRelays) + try runtime.configureDeviceBlossom(origins: configuration.blossomOrigins) + } + + let backend = RadrootsGeneratedRuntimeBackend(runtime: runtime) + return try await RadrootsRuntimeBackendStart( + backend: backend, + snapshot: backend.snapshot() + ) + } catch { + if let createdRuntime { + _ = try? await createdRuntime.shutdown() + } + throw Self.failure(from: error) + } + } + + private static func failure(from error: Error) -> RadrootsRuntimeFailure { + if case let RadrootsAppError.Failure(report) = error { + return RadrootsRuntimeFailure( + schemaVersion: report.schemaVersion, + code: report.code, + category: report.category, + retryable: report.retryable, + recoveryActions: report.recoveryActions, + operationID: report.operationId, + capabilityID: report.capabilityId, + safeMessage: report.safeMessage + ) + } + if let failure = error as? RadrootsRuntimeFailure { + return failure + } + return .local( + operation: "generated.runtime", + code: "ios.generated_runtime.unexpected", + safeMessage: "The Radroots runtime could not complete the operation." + ) + } +} + +extension RadrootsRuntimeClient { + static func production() -> RadrootsRuntimeClient { + RadrootsRuntimeClient { configuration in + try await RadrootsGeneratedRuntimeBackend.start(configuration: configuration) + } + } +} + +private extension RadrootsProtectedDataState { + var generatedValue: ProtectedDataAvailability { + switch self { + case .available: .available + case .unavailable: .unavailable + } + } +} + +private extension FfiRuntimeChangeKind { + var appValue: RadrootsRuntimeChangeKind { + switch self { + case .initial: .initial + case .identity: .identity + case .today: .today + case .drafts: .drafts + case .relay: .relay + case .media: .media + case .lifecycle: .lifecycle + } + } +} diff --git a/Radroots/Runtime/RadrootsRuntimeClient.swift b/Radroots/Runtime/RadrootsRuntimeClient.swift @@ -0,0 +1,346 @@ +import Foundation + +protocol RadrootsRuntimeSubscriptionToken: Sendable { + func cancel() async +} + +protocol RadrootsRuntimeBackend: Sendable { + func snapshot() async throws -> RadrootsRuntimeSnapshot + func subscribe( + bufferCapacity: Int, + receive: @escaping @Sendable (RadrootsRuntimeChange) async -> Void + ) async throws -> any RadrootsRuntimeSubscriptionToken + func shutdown() async throws -> RadrootsRuntimeShutdownReceipt +} + +struct RadrootsRuntimeBackendStart: Sendable { + let backend: any RadrootsRuntimeBackend + let snapshot: RadrootsRuntimeSnapshot +} + +typealias RadrootsRuntimeBackendFactory = @Sendable ( + RadrootsRuntimeLaunchConfiguration +) async throws -> RadrootsRuntimeBackendStart + +actor RadrootsRuntimeClient { + private struct StartupOperation: Sendable { + let generation: UInt64 + let configuration: RadrootsRuntimeLaunchConfiguration + let task: Task<Result<RadrootsRuntimeBackendStart, RadrootsRuntimeFailure>, Never> + } + + private struct ShutdownOperation: Sendable { + let generation: UInt64 + let task: Task<Result<RadrootsRuntimeShutdownReceipt, RadrootsRuntimeFailure>, Never> + } + + private struct Subscription { + let generation: UInt64 + let continuation: AsyncStream<RadrootsRuntimeChange>.Continuation + var token: (any RadrootsRuntimeSubscriptionToken)? + } + + private let factory: RadrootsRuntimeBackendFactory + private var generation: UInt64 = 0 + private var lifecycleState: RadrootsRuntimeLifecycle = .stopped + private var configuration: RadrootsRuntimeLaunchConfiguration? + private var backend: (any RadrootsRuntimeBackend)? + private var startupOperation: StartupOperation? + private var shutdownOperation: ShutdownOperation? + private var subscriptions: [UUID: Subscription] = [:] + + init(factory: @escaping RadrootsRuntimeBackendFactory) { + self.factory = factory + } + + func lifecycle() -> RadrootsRuntimeLifecycle { + lifecycleState + } + + func start( + configuration requestedConfiguration: RadrootsRuntimeLaunchConfiguration + ) async throws -> RadrootsRuntimeSnapshot { + if let shutdownOperation { + _ = try await finishShutdown(shutdownOperation) + } + + if let backend, + configuration == requestedConfiguration, + case .running = lifecycleState + { + return try await backendSnapshot(backend) + } + + if let startupOperation, + startupOperation.configuration == requestedConfiguration + { + return try await finishStartup(startupOperation) + } + + if startupOperation != nil || backend != nil { + let operation = beginShutdown() + _ = try await finishShutdown(operation) + } + + generation &+= 1 + let operationGeneration = generation + lifecycleState = .starting(generation: operationGeneration) + + let factory = factory + let task = Task<Result<RadrootsRuntimeBackendStart, RadrootsRuntimeFailure>, Never> { + do { + return try await .success(factory(requestedConfiguration)) + } catch { + return .failure(Self.failure(from: error, operation: "runtime.start")) + } + } + let operation = StartupOperation( + generation: operationGeneration, + configuration: requestedConfiguration, + task: task + ) + startupOperation = operation + return try await finishStartup(operation) + } + + func retry( + configuration requestedConfiguration: RadrootsRuntimeLaunchConfiguration + ) async throws -> RadrootsRuntimeSnapshot { + _ = try await stop() + return try await start(configuration: requestedConfiguration) + } + + func snapshot() async throws -> RadrootsRuntimeSnapshot { + guard let backend, case .running = lifecycleState else { + throw RadrootsRuntimeClientError.notRunning + } + return try await backendSnapshot(backend) + } + + func changes(bufferCapacity: Int = 16) async throws -> AsyncStream<RadrootsRuntimeChange> { + guard (1 ... 64).contains(bufferCapacity) else { + throw RadrootsRuntimeClientError.invalidBufferCapacity + } + guard let backend, case .running = lifecycleState else { + throw RadrootsRuntimeClientError.notRunning + } + + let id = UUID() + let subscriptionGeneration = generation + let pair = AsyncStream.makeStream( + of: RadrootsRuntimeChange.self, + bufferingPolicy: .bufferingNewest(bufferCapacity) + ) + pair.continuation.onTermination = { [weak self] _ in + Task { + await self?.cancelSubscription(id: id, generation: subscriptionGeneration) + } + } + subscriptions[id] = Subscription( + generation: subscriptionGeneration, + continuation: pair.continuation, + token: nil + ) + + do { + let token = try await backend.subscribe(bufferCapacity: bufferCapacity) { [weak self] change in + await self?.receive( + change, + subscriptionID: id, + generation: subscriptionGeneration + ) + } + guard generation == subscriptionGeneration, + var subscription = subscriptions[id] + else { + await token.cancel() + pair.continuation.finish() + throw RadrootsRuntimeClientError.superseded + } + subscription.token = token + subscriptions[id] = subscription + return pair.stream + } catch let error as RadrootsRuntimeClientError { + subscriptions.removeValue(forKey: id)?.continuation.finish() + throw error + } catch { + subscriptions.removeValue(forKey: id)?.continuation.finish() + throw RadrootsRuntimeClientError.subscription( + Self.failure(from: error, operation: "runtime.subscribe") + ) + } + } + + func stop() async throws -> RadrootsRuntimeShutdownReceipt { + if let shutdownOperation { + return try await finishShutdown(shutdownOperation) + } + guard startupOperation != nil || backend != nil || !subscriptions.isEmpty else { + lifecycleState = .stopped + return .alreadyStopped + } + return try await finishShutdown(beginShutdown()) + } + + private func finishStartup( + _ operation: StartupOperation + ) async throws -> RadrootsRuntimeSnapshot { + let result = await operation.task.value + guard generation == operation.generation else { + throw RadrootsRuntimeClientError.superseded + } + + if let active = startupOperation, + active.generation == operation.generation + { + startupOperation = nil + } else if let backend, + configuration == operation.configuration, + case .running = lifecycleState + { + return try await backendSnapshot(backend) + } else { + throw RadrootsRuntimeClientError.superseded + } + + switch result { + case let .success(started): + backend = started.backend + configuration = operation.configuration + lifecycleState = .running(generation: operation.generation) + return started.snapshot + case let .failure(failure): + lifecycleState = .failed(generation: operation.generation, failure: failure) + throw RadrootsRuntimeClientError.startup(failure) + } + } + + private func beginShutdown() -> ShutdownOperation { + generation &+= 1 + let operationGeneration = generation + let pendingStartup = startupOperation + let activeBackend = backend + let activeSubscriptions = Array(subscriptions.values) + + startupOperation = nil + backend = nil + configuration = nil + subscriptions.removeAll() + lifecycleState = .stopping(generation: operationGeneration) + + let task = Task<Result<RadrootsRuntimeShutdownReceipt, RadrootsRuntimeFailure>, Never> { + for subscription in activeSubscriptions { + subscription.continuation.finish() + await subscription.token?.cancel() + } + + let backendToClose: (any RadrootsRuntimeBackend)? + if let activeBackend { + backendToClose = activeBackend + } else if let pendingStartup { + switch await pendingStartup.task.value { + case let .success(started): + backendToClose = started.backend + case let .failure(failure): + return .failure(failure) + } + } else { + backendToClose = nil + } + + guard let backendToClose else { + return .success(.alreadyStopped) + } + do { + return try await .success(backendToClose.shutdown()) + } catch { + return .failure(Self.failure(from: error, operation: "runtime.shutdown")) + } + } + let operation = ShutdownOperation(generation: operationGeneration, task: task) + shutdownOperation = operation + return operation + } + + private func finishShutdown( + _ operation: ShutdownOperation + ) async throws -> RadrootsRuntimeShutdownReceipt { + let result = await operation.task.value + if shutdownOperation?.generation == operation.generation { + shutdownOperation = nil + switch result { + case .success: + lifecycleState = .stopped + case let .failure(failure): + lifecycleState = .failed(generation: operation.generation, failure: failure) + } + } + + switch result { + case let .success(receipt): + return receipt + case let .failure(failure): + throw RadrootsRuntimeClientError.shutdown(failure) + } + } + + private func backendSnapshot( + _ backend: any RadrootsRuntimeBackend + ) async throws -> RadrootsRuntimeSnapshot { + do { + return try await backend.snapshot() + } catch { + throw RadrootsRuntimeClientError.status( + Self.failure(from: error, operation: "runtime.status") + ) + } + } + + private func receive( + _ change: RadrootsRuntimeChange, + subscriptionID: UUID, + generation subscriptionGeneration: UInt64 + ) { + guard generation == subscriptionGeneration, + let subscription = subscriptions[subscriptionID], + subscription.generation == subscriptionGeneration + else { + return + } + subscription.continuation.yield(change) + } + + private func cancelSubscription(id: UUID, generation subscriptionGeneration: UInt64) async { + guard let subscription = subscriptions[id], + subscription.generation == subscriptionGeneration + else { + return + } + subscriptions.removeValue(forKey: id) + subscription.continuation.finish() + await subscription.token?.cancel() + } + + private static func failure(from error: Error, operation: String) -> RadrootsRuntimeFailure { + if let failure = error as? RadrootsRuntimeFailure { + return failure + } + if case let RadrootsRuntimeClientError.startup(failure) = error { + return failure + } + if case let RadrootsRuntimeClientError.subscription(failure) = error { + return failure + } + if case let RadrootsRuntimeClientError.status(failure) = error { + return failure + } + if case let RadrootsRuntimeClientError.shutdown(failure) = error { + return failure + } + return .local( + operation: operation, + code: "ios.runtime.unexpected", + safeMessage: "The Radroots runtime could not complete the operation." + ) + } +} diff --git a/Radroots/Runtime/RadrootsRuntimeModels.swift b/Radroots/Runtime/RadrootsRuntimeModels.swift @@ -0,0 +1,153 @@ +import Foundation + +enum RadrootsProtectedDataState: Sendable, Equatable { + case available + case unavailable +} + +enum RadrootsRuntimeNetworkProfile: Sendable, Equatable { + case publicNetwork + case simulator + case device +} + +struct RadrootsRuntimeAppMetadata: Sendable, Equatable { + let bundleIdentifier: String + let version: String + let buildNumber: String + let buildSHA: String? +} + +struct RadrootsRuntimeLaunchConfiguration: Sendable, Equatable { + let applicationSupportDirectory: String + let publicKeyHex: String + let sourceGenerationHex: String + let sourceGenerationCreatedAtUnixMilliseconds: UInt64 + let protectedData: RadrootsProtectedDataState + let networkProfile: RadrootsRuntimeNetworkProfile + let writableRelays: [String] + let blossomOrigins: [String] + let app: RadrootsRuntimeAppMetadata +} + +struct RadrootsRuntimeIdentity: Sendable, Equatable { + let publicKeyHex: String + let hostSignerConfigured: Bool +} + +struct RadrootsRelayEndpointStatus: Sendable, Equatable { + let url: String + let access: String + let readState: String + let writeState: String + let readLastAttemptUnixMilliseconds: UInt64? + let writeLastAttemptUnixMilliseconds: UInt64? + let readNextAttemptUnixMilliseconds: UInt64? + let writeNextAttemptUnixMilliseconds: UInt64? +} + +struct RadrootsRelayStatus: Sendable, Equatable { + let profile: String + let state: String + let readAvailability: String + let writeAvailability: String + let relays: [RadrootsRelayEndpointStatus] +} + +struct RadrootsRuntimeSnapshot: Sendable, Equatable { + let identity: RadrootsRuntimeIdentity + let relay: RadrootsRelayStatus? + let crateName: String + let crateVersion: String + let isClosed: Bool +} + +enum RadrootsRuntimeChangeKind: Sendable, Equatable { + case initial + case identity + case today + case drafts + case relay + case media + case lifecycle +} + +struct RadrootsRuntimeChange: Sendable, Equatable { + let schemaVersion: UInt16 + let generation: UInt64 + let kind: RadrootsRuntimeChangeKind + let entityID: String? +} + +struct RadrootsRuntimeShutdownReceipt: Sendable, Equatable { + let state: String + let alreadyClosed: Bool + + static let alreadyStopped = Self(state: "closed", alreadyClosed: true) +} + +struct RadrootsRuntimeFailure: Error, Sendable, Equatable { + let schemaVersion: UInt16 + let code: String + let category: String + let retryable: Bool + let recoveryActions: [String] + let operationID: String? + let capabilityID: String? + let safeMessage: String + + static func local(operation: String, code: String, safeMessage: String) -> Self { + Self( + schemaVersion: 1, + code: code, + category: "runtime", + retryable: false, + recoveryActions: [], + operationID: operation, + capabilityID: nil, + safeMessage: safeMessage + ) + } +} + +extension RadrootsRuntimeFailure: LocalizedError { + var errorDescription: String? { + safeMessage + } +} + +enum RadrootsRuntimeClientError: Error, Sendable, Equatable { + case invalidBufferCapacity + case notRunning + case superseded + case startup(RadrootsRuntimeFailure) + case subscription(RadrootsRuntimeFailure) + case status(RadrootsRuntimeFailure) + case shutdown(RadrootsRuntimeFailure) +} + +extension RadrootsRuntimeClientError: LocalizedError { + var errorDescription: String? { + switch self { + case .invalidBufferCapacity: + "The requested runtime event buffer is outside the supported range." + case .notRunning: + "The Radroots runtime is not running." + case .superseded: + "A newer runtime lifecycle request replaced this request." + case let .startup(failure), + let .subscription(failure), + let .status(failure), + let .shutdown(failure): + failure.safeMessage + } + } +} + +enum RadrootsRuntimeLifecycle: Sendable, Equatable { + case stopped + case starting(generation: UInt64) + case running(generation: UInt64) + case stopping(generation: UInt64) + case failed(generation: UInt64, failure: RadrootsRuntimeFailure) +} diff --git a/Radroots/Views/RuntimeStatusView.swift b/Radroots/Views/RuntimeStatusView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct RuntimeStatusView: View { + let phase: RadrootsAppModel.Phase + let retry: () -> Void + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + Spacer() + Image(systemName: symbolName) + .font(.system(size: 48, weight: .medium)) + .foregroundStyle(symbolColor) + .accessibilityHidden(true) + Text(title) + .font(.title2.weight(.semibold)) + Text(detail) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + if case .failed = phase { + Button("Retry", action: retry) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("radroots.runtime.retry") + } + Spacer() + } + .padding(24) + .navigationTitle("Radroots") + } + .accessibilityIdentifier("radroots.runtime.status") + } + + private var symbolName: String { + switch phase { + case .starting: "leaf" + case .identityRequired: "person.badge.key" + case .running: "checkmark.circle" + case .failed: "exclamationmark.triangle" + case .stopped: "pause.circle" + } + } + + private var symbolColor: Color { + switch phase { + case .failed: .red + case .running: .green + default: .accentColor + } + } + + private var title: String { + switch phase { + case .starting: "Starting Radroots" + case .identityRequired: "Set up your identity" + case .running: "Radroots is ready" + case .failed: "Radroots needs attention" + case .stopped: "Radroots is paused" + } + } + + private var detail: String { + switch phase { + case .starting: + "Preparing your local Radroots data." + case .identityRequired: + "Create or import an identity to connect your local food network." + case let .running(snapshot): + "Runtime \(snapshot.crateVersion) is connected to your local data." + case let .failed(failure): + failure.safeMessage + case .stopped: + "Your durable local work is safe." + } + } +} diff --git a/RadrootsFFI/source.lock b/RadrootsFFI/source.lock @@ -8,4 +8,4 @@ override RADROOTS_FIELD_FFI_SWIFT_SHA256 := f9f7810554c17c4ce9e320dc32bd16911114 override RADROOTS_FIELD_FFI_HEADER_SHA256 := c85a8b87c225e2548f01a8e25dfaf772ae9c3e9c1262deb16c86c9043b05836a override RADROOTS_FIELD_FFI_MODULEMAP_SHA256 := b228e67197e2c64b6f5b17a270faf640efc0e298e354b0b3301679e7c4dc321a override RADROOTS_FIELD_FFI_API_SHA256 := 27d7f2b96a7bf55ab8d2b25effc6a10e149d5afc483cb1485e5e57254cd95339 -override RADROOTS_FIELD_FFI_XCFRAMEWORK_SHA256 := 24612315a9a2710b8e67d4c05651d2c128cdc5c5669f2ad69b574af6707d36c4 +override RADROOTS_FIELD_FFI_XCFRAMEWORK_SHA256 := 356708b157a2299ba7c3f36ecb93a1259d86c71617ce28af4c499bd554eb4965 diff --git a/RadrootsTests/RadrootsRuntimeClientTests.swift b/RadrootsTests/RadrootsRuntimeClientTests.swift @@ -0,0 +1,317 @@ +@testable import Radroots +import XCTest + +final class RadrootsRuntimeClientTests: XCTestCase { + func testConcurrentStartSharesOneBackend() async throws { + let harness = RuntimeHarness() + let client = RadrootsRuntimeClient(factory: harness.start) + let configuration = makeConfiguration(generation: "01") + + let snapshots = try await withThrowingTaskGroup(of: RadrootsRuntimeSnapshot.self) { group in + for _ in 0 ..< 64 { + group.addTask { + try await client.start(configuration: configuration) + } + } + return try await group.reduce(into: []) { $0.append($1) } + } + + XCTAssertEqual(snapshots.count, 64) + let startCount = await harness.startCount() + let lifecycle = await client.lifecycle() + XCTAssertEqual(startCount, 1) + XCTAssertEqual(lifecycle, .running(generation: 1)) + _ = try await client.stop() + } + + func testIndependentSubscriptionsUseBoundedNewestBuffers() async throws { + let harness = RuntimeHarness() + let client = RadrootsRuntimeClient(factory: harness.start) + _ = try await client.start(configuration: makeConfiguration(generation: "02")) + let first = try await client.changes(bufferCapacity: 2) + let second = try await client.changes(bufferCapacity: 4) + + for generation in 1 ... 10 { + await harness.emit(change(generation: UInt64(generation))) + } + + var firstIterator = first.makeAsyncIterator() + var secondIterator = second.makeAsyncIterator() + let firstValues = await [firstIterator.next(), firstIterator.next()].compactMap { $0?.generation } + let secondValues = await [ + secondIterator.next(), + secondIterator.next(), + secondIterator.next(), + secondIterator.next(), + ].compactMap { $0?.generation } + + XCTAssertEqual(firstValues, [9, 10]) + XCTAssertEqual(secondValues, [7, 8, 9, 10]) + _ = try await client.stop() + let cancelCount = await harness.cancelCount() + XCTAssertEqual(cancelCount, 2) + } + + func testOverlappingStopsAwaitOneTypedShutdownFailure() async throws { + let failure = RadrootsRuntimeFailure.local( + operation: "test.shutdown", + code: "test.shutdown_failed", + safeMessage: "Shutdown did not complete." + ) + let harness = RuntimeHarness(shutdownFailure: failure) + let client = RadrootsRuntimeClient(factory: harness.start) + _ = try await client.start(configuration: makeConfiguration(generation: "03")) + + let results = await withTaskGroup(of: Result<RadrootsRuntimeShutdownReceipt, Error>.self) { group in + for _ in 0 ..< 32 { + group.addTask { + do { + return try await .success(client.stop()) + } catch { + return .failure(error) + } + } + } + return await group.reduce(into: []) { $0.append($1) } + } + + let shutdownCount = await harness.shutdownCount() + XCTAssertEqual(shutdownCount, 1) + XCTAssertEqual(results.count, 32) + for result in results { + guard case let .failure(error) = result else { + return XCTFail("Expected every waiter to receive the shutdown failure") + } + XCTAssertEqual(error as? RadrootsRuntimeClientError, .shutdown(failure)) + } + } + + func testNewConfigurationSupersedesAndClosesSlowStartup() async throws { + let harness = RuntimeHarness(startDelayNanoseconds: 30_000_000) + let client = RadrootsRuntimeClient(factory: harness.start) + let firstConfiguration = makeConfiguration(generation: "04") + let secondConfiguration = makeConfiguration(generation: "05") + + let first = Task { + try await client.start(configuration: firstConfiguration) + } + try await Task.sleep(nanoseconds: 2_000_000) + let second = Task { + try await client.start(configuration: secondConfiguration) + } + + do { + _ = try await first.value + XCTFail("The older startup should be superseded") + } catch { + XCTAssertEqual(error as? RadrootsRuntimeClientError, .superseded) + } + let snapshot = try await second.value + XCTAssertEqual(snapshot.identity.publicKeyHex, secondConfiguration.publicKeyHex) + let startCount = await harness.startCount() + let supersededShutdownCount = await harness.shutdownCount() + XCTAssertEqual(startCount, 2) + XCTAssertEqual(supersededShutdownCount, 1) + _ = try await client.stop() + let finalShutdownCount = await harness.shutdownCount() + XCTAssertEqual(finalShutdownCount, 2) + } + + func testBufferCapacityAndStoppedSubscriptionFailClosed() async throws { + let harness = RuntimeHarness() + let client = RadrootsRuntimeClient(factory: harness.start) + do { + _ = try await client.changes() + XCTFail("A stopped runtime cannot subscribe") + } catch { + XCTAssertEqual(error as? RadrootsRuntimeClientError, .notRunning) + } + + _ = try await client.start(configuration: makeConfiguration(generation: "06")) + for invalidCapacity in [0, 65] { + do { + _ = try await client.changes(bufferCapacity: invalidCapacity) + XCTFail("Invalid capacities must fail closed") + } catch { + XCTAssertEqual(error as? RadrootsRuntimeClientError, .invalidBufferCapacity) + } + } + _ = try await client.stop() + } + + private func makeConfiguration(generation: String) -> RadrootsRuntimeLaunchConfiguration { + RadrootsRuntimeLaunchConfiguration( + applicationSupportDirectory: "/tmp/radroots-runtime-tests-\(generation)", + publicKeyHex: String(repeating: generation, count: 32), + sourceGenerationHex: String(repeating: generation, count: 32), + sourceGenerationCreatedAtUnixMilliseconds: 1, + protectedData: .available, + networkProfile: .simulator, + writableRelays: ["ws://127.0.0.1:7447"], + blossomOrigins: ["http://127.0.0.1:3000"], + app: RadrootsRuntimeAppMetadata( + bundleIdentifier: "org.radroots.tests", + version: "0.1.0-alpha", + buildNumber: "1", + buildSHA: nil + ) + ) + } + + private func change(generation: UInt64) -> RadrootsRuntimeChange { + RadrootsRuntimeChange( + schemaVersion: 1, + generation: generation, + kind: .today, + entityID: "card-\(generation)" + ) + } +} + +private actor RuntimeHarness { + private let startDelayNanoseconds: UInt64 + private let shutdownFailure: RadrootsRuntimeFailure? + private var starts = 0 + private var shutdowns = 0 + private var cancels = 0 + private var receivers: [UUID: @Sendable (RadrootsRuntimeChange) async -> Void] = [:] + + init( + startDelayNanoseconds: UInt64 = 0, + shutdownFailure: RadrootsRuntimeFailure? = nil + ) { + self.startDelayNanoseconds = startDelayNanoseconds + self.shutdownFailure = shutdownFailure + } + + func start( + configuration: RadrootsRuntimeLaunchConfiguration + ) async throws -> RadrootsRuntimeBackendStart { + starts += 1 + if startDelayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: startDelayNanoseconds) + } + let backend = TestRuntimeBackend( + harness: self, + publicKeyHex: configuration.publicKeyHex, + shutdownFailure: shutdownFailure + ) + return await RadrootsRuntimeBackendStart( + backend: backend, + snapshot: backend.snapshotValue() + ) + } + + func addReceiver( + _ receive: @escaping @Sendable (RadrootsRuntimeChange) async -> Void + ) -> UUID { + let id = UUID() + receivers[id] = receive + return id + } + + func removeReceiver(id: UUID) { + if receivers.removeValue(forKey: id) != nil { + cancels += 1 + } + } + + func emit(_ change: RadrootsRuntimeChange) async { + for receive in receivers.values { + await receive(change) + } + } + + func recordShutdown() { + shutdowns += 1 + } + + func startCount() -> Int { + starts + } + + func shutdownCount() -> Int { + shutdowns + } + + func cancelCount() -> Int { + cancels + } +} + +private actor TestRuntimeBackend: RadrootsRuntimeBackend { + private let harness: RuntimeHarness + private let snapshotValueStored: RadrootsRuntimeSnapshot + private let shutdownFailure: RadrootsRuntimeFailure? + private var closed = false + + init( + harness: RuntimeHarness, + publicKeyHex: String, + shutdownFailure: RadrootsRuntimeFailure? + ) { + self.harness = harness + self.shutdownFailure = shutdownFailure + snapshotValueStored = RadrootsRuntimeSnapshot( + identity: RadrootsRuntimeIdentity( + publicKeyHex: publicKeyHex, + hostSignerConfigured: true + ), + relay: nil, + crateName: "radroots_mobile_ffi", + crateVersion: "0.1.0-alpha", + isClosed: false + ) + } + + func snapshotValue() -> RadrootsRuntimeSnapshot { + snapshotValueStored + } + + func snapshot() throws -> RadrootsRuntimeSnapshot { + if closed { + throw RadrootsRuntimeFailure.local( + operation: "test.snapshot", + code: "test.closed", + safeMessage: "The test runtime is closed." + ) + } + return snapshotValueStored + } + + func subscribe( + bufferCapacity _: Int, + receive: @escaping @Sendable (RadrootsRuntimeChange) async -> Void + ) async -> any RadrootsRuntimeSubscriptionToken { + let id = await harness.addReceiver(receive) + return TestSubscriptionToken(harness: harness, id: id) + } + + func shutdown() async throws -> RadrootsRuntimeShutdownReceipt { + await harness.recordShutdown() + try? await Task.sleep(nanoseconds: 10_000_000) + if let shutdownFailure { + throw shutdownFailure + } + let alreadyClosed = closed + closed = true + return RadrootsRuntimeShutdownReceipt(state: "closed", alreadyClosed: alreadyClosed) + } +} + +private actor TestSubscriptionToken: RadrootsRuntimeSubscriptionToken { + private let harness: RuntimeHarness + private let id: UUID + private var cancelled = false + + init(harness: RuntimeHarness, id: UUID) { + self.harness = harness + self.id = id + } + + func cancel() async { + guard !cancelled else { return } + cancelled = true + await harness.removeReceiver(id: id) + } +} diff --git a/RadrootsTests/SharedEngineLifecycleTests.swift b/RadrootsTests/SharedEngineLifecycleTests.swift @@ -1,50 +0,0 @@ -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/project.yml b/project.yml @@ -14,25 +14,47 @@ packages: revision: 265015fc537bfe507f12d801b9765be9764fbc95 targets: + RadrootsKitBindings: + type: framework + platform: iOS + deploymentTarget: "18.0" + sources: + - path: Radroots/Generated/RadrootsKitBindings.swift + settings: + base: + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 + GENERATE_INFOPLIST_FILE: true + PRODUCT_BUNDLE_IDENTIFIER: org.radroots.RadrootsKitBindings + SWIFT_VERSION: 5.0 + SWIFT_STRICT_CONCURRENCY: minimal + dependencies: + - framework: Radroots/Frameworks/RadrootsFFI.xcframework + embed: false Radroots: type: application platform: iOS deploymentTarget: "18.0" sources: - - path: Radroots - excludes: - - Info.plist - - Frameworks + - path: Radroots/App/App.swift + - path: Radroots/App/AppEntry.swift + - path: Radroots/App/RadrootsAppModel.swift + - path: Radroots/App/RadrootsProvider.swift + - path: Radroots/Runtime/RadrootsGeneratedRuntimeBackend.swift + - path: Radroots/Runtime/RadrootsRuntimeClient.swift + - path: Radroots/Runtime/RadrootsRuntimeModels.swift + - path: Radroots/Views/RuntimeStatusView.swift + - path: Radroots/Resources settings: base: + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 SWIFT_VERSION: 6.0 INFOPLIST_FILE: Radroots/Info.plist configFiles: Debug: Radroots/Config/Debug.xcconfig Release: Radroots/Config/Release.xcconfig dependencies: - - framework: Radroots/Frameworks/RadrootsFFI.xcframework - embed: false + - target: RadrootsKitBindings + embed: true - package: RadrootsKit preBuildScripts: - name: Generate git SHA xcconfig @@ -45,9 +67,10 @@ targets: platform: iOS deploymentTarget: "18.0" sources: - - path: RadrootsTests + - path: RadrootsTests/RadrootsRuntimeClientTests.swift settings: base: + "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64 SWIFT_VERSION: 6.0 GENERATE_INFOPLIST_FILE: true dependencies: