field_ios

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

commit 834ad56bfb220169bcdaba367eaaa781a0cf46e9
parent 8395046f2ab6a6bfc11d26a3c1ce15a23e07806c
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 21:47:26 -0700

app: replace setup with field login

- Removes the app-local RadrootsKit package and moves app-specific Swift into the Radroots target.
- Links generated field_lib FFI artifacts directly from app-owned generated locations.
- Adds field auth API config and Keychain-backed session token storage through apple_kit.
- Replaces raw Nostr key setup and secret export UI with login challenge verification.

Diffstat:
M.gitignore | 1+
MRadroots.xcodeproj/project.pbxproj | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MRadroots/App/App.swift | 1-
MRadroots/App/AppEntry.swift | 1-
ARadroots/App/AppState.swift | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/App/RadrootsProvider.swift | 28++++++++++++++++++++++++++++
MRadroots/Config/Base.xcconfig | 4++++
MRadroots/Config/Debug.xcconfig | 4++++
MRadroots/Info.plist | 8++++++++
ARadroots/Runtime/AuthSettings.swift | 25+++++++++++++++++++++++++
ARadroots/Runtime/BuildConfig.swift | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Runtime/FieldSessionCredentialStore.swift | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
ARadroots/Runtime/LoggingSettings.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
RRadrootsKit/Sources/RadrootsKit/Nostr.swift -> Radroots/Runtime/Nostr.swift | 0
RRadrootsKit/Sources/RadrootsKit/Radroots.swift -> Radroots/Runtime/Radroots.swift | 0
RRadrootsKit/Sources/RadrootsKit/RelaySettings.swift -> Radroots/Runtime/RelaySettings.swift | 0
RRadrootsKit/Sources/RadrootsKit/TradeListing.swift -> Radroots/Runtime/TradeListing.swift | 0
RRadrootsKit/Sources/RadrootsKit/TradeSettings.swift -> Radroots/Runtime/TradeSettings.swift | 0
MRadroots/Shared/Logging/DebugDump.swift | 1-
MRadroots/Shared/Logging/Logger.swift | 7+++----
MRadroots/Views/MarketView.swift | 1-
MRadroots/Views/PostCreateView.swift | 1-
MRadroots/Views/PostDetailView.swift | 1-
MRadroots/Views/PostFeedView.swift | 1-
MRadroots/Views/PostFeedViewModel.swift | 1-
MRadroots/Views/ProfileView.swift | 1-
MRadroots/Views/RelaysView.swift | 1-
MRadroots/Views/SettingsView.swift | 45++++++++++++---------------------------------
MRadroots/Views/SetupView.swift | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
MRadroots/Views/TradeListingCreateView.swift | 1-
MRadroots/Views/TradeListingDetailView.swift | 1-
MRadroots/Views/TradeOrderRequestView.swift | 1-
MRadrootsFFI/Makefile | 6+++---
MRadrootsFFI/source.lock | 2+-
DRadrootsKit/Package.swift | 26--------------------------
DRadrootsKit/Sources/RadrootsKit/AppState.swift | 122-------------------------------------------------------------------------------
DRadrootsKit/Sources/RadrootsKit/BuildConfig.swift | 133-------------------------------------------------------------------------------
DRadrootsKit/Sources/RadrootsKit/LoggingSettings.swift | 45---------------------------------------------
DRadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift | 28----------------------------
DRadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift | 29-----------------------------
Mproject.yml | 5++++-
41 files changed, 793 insertions(+), 511 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -22,6 +22,7 @@ Radroots/radroots.git.xcconfig RadrootsKit/Artifacts/ RadrootsKit/Sources/RadrootsKit/Generated/ Radroots/Frameworks/RadrootsFFI.xcframework/ +Radroots/Generated/ # Local overrides Radroots/radroots.local.xcconfig diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -8,27 +8,40 @@ /* 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 */; }; 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0274A0260D1C04F40C71AF /* HomeView.swift */; }; 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; }; 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; }; 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; }; 33A800AA701C354099623B24 /* MarketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C0B870E44C7B152A7FABE0 /* MarketView.swift */; }; + 35D8223F5E169DDB4E3E87C0 /* TradeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6D224B525028DE5D8C8E28D /* TradeSettings.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 */; }; 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */; }; 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */; }; 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */; }; 5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */; }; + 657BEA5AAFF129E10177FE63 /* Nostr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63189EB90A86A9929BECD9ED /* Nostr.swift */; }; + 7C8DD424F3E3E0AB1B133863 /* RadrootsKitBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */; }; 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.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 */; }; A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA8F5611075F60F214A00 /* SectionWideButton.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 */; }; C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE790CA1CD31208947913B9 /* Logger.swift */; }; C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */; }; D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A4289F43625DD65E6C4B25 /* CopyRow.swift */; }; + D57D0AED4A22098FB804B5AF /* AuthSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F392F542A3EE3AF60E02D45 /* AuthSettings.swift */; }; + D5C58A98C950D45AD027962A /* TradeListing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */; }; D62E9461833A0AA5E622A1E6 /* ToastModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227028B4EBDC6703999FB9DA /* ToastModifier.swift */; }; DCE468F668A3C346E716B04C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CCF0F7B3C57D8D770F178329 /* Assets.xcassets */; }; + DF1CB54078BD8238247D3D3A /* FieldSessionCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517CC5F5A5A0DE883725CBCF /* FieldSessionCredentialStore.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 */; }; EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */; }; F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */; }; @@ -41,37 +54,50 @@ 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>"; }; 227028B4EBDC6703999FB9DA /* ToastModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastModifier.swift; sourceTree = "<group>"; }; 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingDetailView.swift; sourceTree = "<group>"; }; 2818363B157125491FB84A1E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; 2FE790CA1CD31208947913B9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; }; - 3FADD6E2563CC9AF9F935DCE /* RadrootsKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = RadrootsKit; path = RadrootsKit; sourceTree = SOURCE_ROOT; }; 41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; }; 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; }; 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; }; + 4F392F542A3EE3AF60E02D45 /* AuthSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSettings.swift; sourceTree = "<group>"; }; + 517CC5F5A5A0DE883725CBCF /* FieldSessionCredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldSessionCredentialStore.swift; sourceTree = "<group>"; }; 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.git.xcconfig; 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>"; }; + 7515B8FD2A65990C3E3E93CE /* apple_kit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = apple_kit; path = ../../../../domains/radroots/apple_kit; sourceTree = SOURCE_ROOT; }; 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.local.xcconfig; sourceTree = "<group>"; }; 7C294E8EF50F5E1E73F5C135 /* Common.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = "<group>"; }; + 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListing.swift; sourceTree = "<group>"; }; + 8F0F21496E7A8490EB14AC5B /* Radroots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Radroots.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>"; }; 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingCreateView.swift; sourceTree = "<group>"; }; 9AE0EB327C10171444553378 /* PostFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedView.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>"; }; + 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>"; }; 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>"; }; + 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>"; }; + 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>"; }; E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDump.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>"; }; + F6D224B525028DE5D8C8E28D /* TradeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeSettings.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -79,6 +105,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 04AA409CFECBA11BFC175C5C /* RadrootsFFI.xcframework in Frameworks */, F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -91,6 +118,8 @@ children = ( 2818363B157125491FB84A1E /* App.swift */, D448C9655B708CA3FA8712B9 /* AppEntry.swift */, + CBE1472FFD63A33F3AEA6C6C /* AppState.swift */, + 15DBA726450712D6DE88E951 /* RadrootsProvider.swift */, ); path = App; sourceTree = "<group>"; @@ -114,7 +143,9 @@ 4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */, 23C2D7FF63B61CD356979E82 /* App */, 579F407D96CCAFD4000EF363 /* Config */, + 902187CDBBF756677402D2BF /* Generated */, E9C466456E8A8EEB73EE47F5 /* Resources */, + 688000357CB95AB5B3067911 /* Runtime */, 932F9ACAF6A7D30E34F2E375 /* Shared */, BD0E20D32DF34D9E7C3EBCD2 /* Views */, ); @@ -137,6 +168,30 @@ path = Localisation; sourceTree = "<group>"; }; + 688000357CB95AB5B3067911 /* Runtime */ = { + isa = PBXGroup; + children = ( + 4F392F542A3EE3AF60E02D45 /* AuthSettings.swift */, + A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */, + 517CC5F5A5A0DE883725CBCF /* FieldSessionCredentialStore.swift */, + D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */, + 63189EB90A86A9929BECD9ED /* Nostr.swift */, + 8F0F21496E7A8490EB14AC5B /* Radroots.swift */, + 6FBB081610305940C7849C7C /* RelaySettings.swift */, + 7D69D200DB1F5FA7AA561CD7 /* TradeListing.swift */, + F6D224B525028DE5D8C8E28D /* TradeSettings.swift */, + ); + path = Runtime; + sourceTree = "<group>"; + }; + 902187CDBBF756677402D2BF /* Generated */ = { + isa = PBXGroup; + children = ( + DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */, + ); + path = Generated; + sourceTree = "<group>"; + }; 932F9ACAF6A7D30E34F2E375 /* Shared */ = { isa = PBXGroup; children = ( @@ -152,11 +207,19 @@ 9458C318B571871852A3FD1B /* Packages */ = { isa = PBXGroup; children = ( - 3FADD6E2563CC9AF9F935DCE /* RadrootsKit */, + 7515B8FD2A65990C3E3E93CE /* apple_kit */, ); name = Packages; sourceTree = "<group>"; }; + 97FA23F0FD7E25C1AF2585FB /* Frameworks */ = { + isa = PBXGroup; + children = ( + BD7B47A576C4D5CE9318D3E6 /* RadrootsFFI.xcframework */, + ); + name = Frameworks; + sourceTree = "<group>"; + }; 9D22575D1FAD99FE8B6FCE6C /* Extensions */ = { isa = PBXGroup; children = ( @@ -191,6 +254,7 @@ children = ( 9458C318B571871852A3FD1B /* Packages */, 5FD6379AE27C57D02E8C7EE1 /* Radroots */, + 97FA23F0FD7E25C1AF2585FB /* Frameworks */, 6240123423927396E47D6B3E /* Products */, ); sourceTree = "<group>"; @@ -284,7 +348,7 @@ mainGroup = C4F02317699AB4FA59315D05; minimizedProjectReferenceProxies = 1; packageReferences = ( - DA033E4B08F702473D7DDDE3 /* XCLocalSwiftPackageReference "RadrootsKit" */, + D71BF9693060631950EFC310 /* XCLocalSwiftPackageReference "../../../../domains/radroots/apple_kit" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6240123423927396E47D6B3E /* Products */; @@ -337,27 +401,39 @@ files = ( C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */, 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */, + 049D620DD8C02816893BF765 /* AppState.swift in Sources */, + D57D0AED4A22098FB804B5AF /* AuthSettings.swift in Sources */, + 3B6020E24A2DAD8ADFC2F155 /* BuildConfig.swift in Sources */, E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */, 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, + DF1CB54078BD8238247D3D3A /* FieldSessionCredentialStore.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, + B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */, 33A800AA701C354099623B24 /* MarketView.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 */, + 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 */, + D5C58A98C950D45AD027962A /* TradeListing.swift in Sources */, 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */, 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */, FD9B01F8F5DD1F05A64FD556 /* TradeOrderRequestView.swift in Sources */, + 35D8223F5E169DDB4E3E87C0 /* TradeSettings.swift in Sources */, B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -382,6 +458,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"Radroots/Frameworks\"", + ); INFOPLIST_FILE = Radroots/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -400,6 +480,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"Radroots/Frameworks\"", + ); INFOPLIST_FILE = Radroots/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -555,9 +639,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - DA033E4B08F702473D7DDDE3 /* XCLocalSwiftPackageReference "RadrootsKit" */ = { + D71BF9693060631950EFC310 /* XCLocalSwiftPackageReference "../../../../domains/radroots/apple_kit" */ = { isa = XCLocalSwiftPackageReference; - relativePath = RadrootsKit; + relativePath = ../../../../domains/radroots/apple_kit; }; /* End XCLocalSwiftPackageReference section */ diff --git a/Radroots/App/App.swift b/Radroots/App/App.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit @main struct RadrootsApp: App { diff --git a/Radroots/App/AppEntry.swift b/Radroots/App/AppEntry.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit public struct AppEntry<Main: View>: View { @EnvironmentObject private var appState: AppState diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -0,0 +1,215 @@ +import Foundation + +enum FieldAppSessionError: LocalizedError { + case runtimeNotReady + case missingSessionTokenBundle + + var errorDescription: String? { + switch self { + case .runtimeNotReady: + "Runtime not ready. Please relaunch." + case .missingSessionTokenBundle: + "The authenticated session did not return tokens." + } + } +} + +@MainActor +public final class AppState: ObservableObject { + public enum BootstrapPhase { + case idle + case starting + case ready + } + + public enum RelayLight { + case red, yellow, green + } + + @Published public private(set) var bootstrapPhase: BootstrapPhase = .idle + @Published public private(set) var infoJSONString: String = "" + @Published public private(set) var sessionPhase: FieldSessionPhase = .signedOut + @Published public private(set) var username: String? + @Published public private(set) var accountDisplayName: String? + @Published public private(set) var pendingChallenge: FieldLoginChallenge? + @Published public private(set) var hasKey: Bool = false + @Published public private(set) var npub: String? + @Published public private(set) var relayConnectedCount: UInt32 = 0 + @Published public private(set) var relayConnectingCount: UInt32 = 0 + @Published public private(set) var relayLight: RelayLight = .red + @Published public private(set) var relayLastError: String? + + public var canShowAppContent: Bool { + bootstrapPhase == .ready && sessionPhase == .authenticated + } + + public var requiresSetup: Bool { + bootstrapPhase == .ready && sessionPhase != .authenticated + } + + public let radroots: Radroots + + private var sessionStore: FieldSessionCredentialStore? + private var statusTask: Task<Void, Never>? + + public init(radroots: Radroots = Radroots()) { + self.radroots = radroots + } + + deinit { + statusTask?.cancel() + } + + public func start() async throws { + guard bootstrapPhase == .idle else { return } + bootstrapPhase = .starting + do { + try radroots.start() + let store = try FieldSessionCredentialStore() + sessionStore = store + if BuildConfig.bool(.resetLocalState) == true { + try? store.delete() + } + if let rt = radroots.runtime { + try configure(runtime: rt) + try restoreSessionIfPossible(runtime: rt, store: store) + } + refresh() + bootstrapPhase = .ready + } catch { + bootstrapPhase = .idle + throw error + } + } + + public func refresh() { + guard let rt = radroots.runtime else { return } + infoJSONString = rt.infoJson() + apply(snapshot: rt.fieldSessionSnapshot()) + } + + @discardableResult + public func startLogin(username: String) throws -> FieldLoginChallenge { + let rt = try requireRuntime() + let challenge = try rt.fieldStartLogin(username: username) + apply(snapshot: rt.fieldSessionSnapshot()) + return challenge + } + + @discardableResult + public func resendLoginChallenge(challengeId: String) throws -> FieldLoginChallenge { + let rt = try requireRuntime() + let challenge = try rt.fieldResendLoginChallenge(challengeId: challengeId) + apply(snapshot: rt.fieldSessionSnapshot()) + return challenge + } + + public func verifyLogin(challengeId: String, code: String) throws { + let rt = try requireRuntime() + let snapshot = try rt.fieldVerifyLoginChallenge(challengeId: challengeId, code: code) + apply(snapshot: snapshot) + guard let tokens = try rt.fieldSessionTokenBundle() else { + throw FieldAppSessionError.missingSessionTokenBundle + } + try sessionStore?.save(tokens) + prepareAuthenticatedRuntime() + } + + public func logout() { + guard let rt = radroots.runtime else { return } + do { + _ = try rt.fieldRevokeSession() + } catch { + relayLastError = error.localizedDescription + } + try? sessionStore?.delete() + apply(snapshot: rt.fieldClearSession()) + statusTask?.cancel() + statusTask = nil + } + + private func configure(runtime: RadrootsRuntime) throws { + try runtime.fieldConfigureAuth( + authApiBaseUrl: try AuthSettings.authApiBaseURL(), + accountsApiBaseUrl: AuthSettings.accountsApiBaseURL() + ) + } + + private func restoreSessionIfPossible( + runtime: RadrootsRuntime, + store: FieldSessionCredentialStore + ) throws { + guard let tokens = try store.load() else { + apply(snapshot: runtime.fieldSessionSnapshot()) + return + } + do { + let snapshot = try runtime.fieldRestoreSession( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) + apply(snapshot: snapshot) + prepareAuthenticatedRuntime() + } catch { + try? store.delete() + apply(snapshot: runtime.fieldClearSession()) + } + } + + private func prepareAuthenticatedRuntime() { + guard let rt = radroots.runtime else { return } + do { + let snapshot = try rt.fieldPrepareAuthenticatedNostr(relays: try RelaySettings.relays()) + relayLastError = nil + apply(snapshot: snapshot) + } catch { + relayLastError = error.localizedDescription + } + startPollingStatus() + } + + private func startPollingStatus() { + statusTask?.cancel() + statusTask = Task { [weak self] in + while !Task.isCancelled { + await MainActor.run { self?.refreshRelayStatus() } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + private func refreshRelayStatus() { + guard let rt = radroots.runtime else { return } + apply(snapshot: rt.fieldSessionSnapshot()) + } + + private func apply(snapshot: FieldSessionSnapshot) { + sessionPhase = snapshot.phase + pendingChallenge = snapshot.pendingChallenge + username = snapshot.account?.username + accountDisplayName = snapshot.account?.displayName + npub = snapshot.selectedNpub + hasKey = snapshot.selectedNpub != nil + relayConnectedCount = snapshot.nostrConnected + relayConnectingCount = snapshot.nostrConnecting + relayLastError = snapshot.nostrLastError ?? relayLastError + + switch snapshot.nostrLight { + case .green: + relayLight = .green + case .yellow: + relayLight = .yellow + case .red: + relayLight = .red + @unknown default: + relayLight = .red + } + } + + private func requireRuntime() throws -> RadrootsRuntime { + guard let rt = radroots.runtime else { + throw FieldAppSessionError.runtimeNotReady + } + return rt + } +} diff --git a/Radroots/App/RadrootsProvider.swift b/Radroots/App/RadrootsProvider.swift @@ -0,0 +1,28 @@ +import SwiftUI + +public struct RadrootsProvider<Content: View>: View { + @StateObject private var appState = AppState() + private let onStartupError: ((Error) -> Void)? + private let content: () -> Content + + public init( + onStartupError: ((Error) -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.onStartupError = onStartupError + self.content = content + } + + public var body: some View { + content() + .environmentObject(appState) + .environmentObject(appState.radroots) + .task { + do { + try await appState.start() + } catch { + onStartupError?(error) + } + } + } +} diff --git a/Radroots/Config/Base.xcconfig b/Radroots/Config/Base.xcconfig @@ -7,4 +7,8 @@ RADROOTS_FIELD_IOS_LOGGING_FILTER = info RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = true RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios.log RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = wss:$(SLASH)$(SLASH)radroots.org +RADROOTS_FIELD_IOS_AUTH_API_BASE_URL = https:$(SLASH)$(SLASH)radroots.org +RADROOTS_FIELD_IOS_ACCOUNTS_API_BASE_URL = https:$(SLASH)$(SLASH)radroots.org +RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios +RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY = diff --git a/Radroots/Config/Debug.xcconfig b/Radroots/Config/Debug.xcconfig @@ -6,5 +6,9 @@ RADROOTS_FIELD_IOS_LOGGING_FILTER = debug RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED = false RADROOTS_FIELD_IOS_LOGGING_FILE_NAME = field-ios-debug.log RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS = ws:$(SLASH)$(SLASH)127.0.0.1:8080 +RADROOTS_FIELD_IOS_AUTH_API_BASE_URL = http:$(SLASH)$(SLASH)127.0.0.1:18186 +RADROOTS_FIELD_IOS_ACCOUNTS_API_BASE_URL = http:$(SLASH)$(SLASH)127.0.0.1:18182 +RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX = org.radroots.field_ios.local +RADROOTS_FIELD_IOS_RESET_LOCAL_STATE = false #include? "../radroots.local.xcconfig" diff --git a/Radroots/Info.plist b/Radroots/Info.plist @@ -69,6 +69,14 @@ <string>$(RADROOTS_FIELD_IOS_LOGGING_FILE_NAME)</string> <key>RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS</key> <string>$(RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS)</string> + <key>RADROOTS_FIELD_IOS_AUTH_API_BASE_URL</key> + <string>$(RADROOTS_FIELD_IOS_AUTH_API_BASE_URL)</string> + <key>RADROOTS_FIELD_IOS_ACCOUNTS_API_BASE_URL</key> + <string>$(RADROOTS_FIELD_IOS_ACCOUNTS_API_BASE_URL)</string> + <key>RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX</key> + <string>$(RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX)</string> + <key>RADROOTS_FIELD_IOS_RESET_LOCAL_STATE</key> + <string>$(RADROOTS_FIELD_IOS_RESET_LOCAL_STATE)</string> <key>RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY</key> <string>$(RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY)</string> </dict> diff --git a/Radroots/Runtime/AuthSettings.swift b/Radroots/Runtime/AuthSettings.swift @@ -0,0 +1,25 @@ +import Foundation + +enum AuthSettingsError: LocalizedError { + case missingAuthApiBaseURL + + var errorDescription: String? { + switch self { + case .missingAuthApiBaseURL: + "No auth API base URL configured. Set 'RADROOTS_FIELD_IOS_AUTH_API_BASE_URL'." + } + } +} + +enum AuthSettings { + static func authApiBaseURL() throws -> String { + guard let value = BuildConfig.string(.authApiBaseUrl) else { + throw AuthSettingsError.missingAuthApiBaseURL + } + return value + } + + static func accountsApiBaseURL() -> String? { + BuildConfig.string(.accountsApiBaseUrl) + } +} diff --git a/Radroots/Runtime/BuildConfig.swift b/Radroots/Runtime/BuildConfig.swift @@ -0,0 +1,137 @@ +import Foundation + +enum BuildConfigKey: String { + case envFile = "RADROOTS_FIELD_IOS_ENV_FILE" + case runtimeMode = "RADROOTS_FIELD_IOS_RUNTIME_MODE" + case loggingStdout = "RADROOTS_FIELD_IOS_LOGGING_STDOUT" + case loggingFilter = "RADROOTS_FIELD_IOS_LOGGING_FILTER" + case loggingFileEnabled = "RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED" + case loggingFileName = "RADROOTS_FIELD_IOS_LOGGING_FILE_NAME" + case nostrRelayUrls = "RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS" + case authApiBaseUrl = "RADROOTS_FIELD_IOS_AUTH_API_BASE_URL" + case accountsApiBaseUrl = "RADROOTS_FIELD_IOS_ACCOUNTS_API_BASE_URL" + case keychainServicePrefix = "RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX" + case resetLocalState = "RADROOTS_FIELD_IOS_RESET_LOCAL_STATE" + case tradeRhiPubkey = "RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY" +} + +enum BuildConfig { + static func string(_ key: BuildConfigKey) -> String? { + envString(key) ?? infoString(key).map { stripOuterQuotes($0) } + } + + static func bool(_ key: BuildConfigKey) -> Bool? { + if let env = ProcessInfo.processInfo.environment[key.rawValue], + let parsed = parseBool(env) { + return parsed + } + if let v = infoValue(for: key.rawValue) { + if let b = v as? Bool { return b } + if let s = v as? String, let parsed = parseBool(s) { return parsed } + if let n = v as? NSNumber { return n.boolValue } + } + return nil + } + + static func array(_ key: BuildConfigKey, splitBy set: CharacterSet = .whitespacesAndNewlines) -> [String]? { + if let raw = envString(key) { + return parseArray(raw, splitBy: set) + } + if let direct = infoArray(key) { + return direct + .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + guard let raw = infoString(key) else { return nil } + return parseArray(raw, splitBy: set) + } + + static func effectiveDictionary(keys: [BuildConfigKey]) -> [String: Any] { + var out: [String: Any] = [:] + for k in keys { + switch k { + case .loggingStdout, .loggingFileEnabled, .resetLocalState: + if let b = bool(k) { + out[k.rawValue] = b + } + case .nostrRelayUrls: + if let arr = array(.nostrRelayUrls) { + out[k.rawValue] = arr + } + default: + if let s = string(k) { + out[k.rawValue] = s + } + } + } + return out + } + + private static func envString(_ key: BuildConfigKey) -> String? { + ProcessInfo.processInfo.environment[key.rawValue] + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap { $0.isEmpty ? nil : stripOuterQuotes($0) } + } + + private static func parseArray(_ value: String, splitBy set: CharacterSet) -> [String]? { + var raw = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { return nil } + if raw.first == "[" { + if let data = raw.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [String] { + return arr + .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + } + raw = stripOuterQuotes(raw) + let separators = set.union(CharacterSet(charactersIn: ",;")) + raw = raw.replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + return raw + .components(separatedBy: separators) + .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + private static func infoString(_ key: BuildConfigKey) -> String? { + if let v = infoValue(for: key.rawValue) as? String, !v.isEmpty { return v } + if let n = infoValue(for: key.rawValue) as? NSNumber { return n.stringValue } + return nil + } + + private static func infoArray(_ key: BuildConfigKey) -> [String]? { + if let v = infoValue(for: key.rawValue) as? [String] { return v } + if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any], + let v = nested[key.rawValue] as? [String] { + return v + } + return nil + } + + private static func infoValue(for key: String) -> Any? { + if let v = Bundle.main.object(forInfoDictionaryKey: key) { + return v + } + if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any] { + return nested[key] + } + return nil + } + + private static func parseBool(_ s: String) -> Bool? { + switch s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes": return true + case "0", "false", "no": return false + default: return nil + } + } + + private static func stripOuterQuotes(_ s: String) -> String { + guard s.count >= 2 else { return s } + if (s.hasPrefix("\"") && s.hasSuffix("\"")) || (s.hasPrefix("'") && s.hasSuffix("'")) { + return String(s.dropFirst().dropLast()) + } + return s + } +} diff --git a/Radroots/Runtime/FieldSessionCredentialStore.swift b/Radroots/Runtime/FieldSessionCredentialStore.swift @@ -0,0 +1,52 @@ +import Foundation +import RadrootsKit + +struct StoredFieldSessionTokens: Codable, Equatable { + let accessToken: String + let refreshToken: String +} + +enum FieldSessionCredentialStoreError: LocalizedError { + case missingKeychainServicePrefix + + var errorDescription: String? { + switch self { + case .missingKeychainServicePrefix: + "No Keychain service prefix configured. Set 'RADROOTS_FIELD_IOS_KEYCHAIN_SERVICE_PREFIX'." + } + } +} + +final class FieldSessionCredentialStore { + private let secureStore: any RadrootsSecureStore + private let key = RadrootsSecureStoreKey(namespace: "field_ios", name: "session_tokens") + + init(secureStore: (any RadrootsSecureStore)? = nil) throws { + if let secureStore { + self.secureStore = secureStore + } else { + guard let servicePrefix = BuildConfig.string(.keychainServicePrefix) else { + throw FieldSessionCredentialStoreError.missingKeychainServicePrefix + } + self.secureStore = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix) + } + } + + func load() throws -> StoredFieldSessionTokens? { + guard let data = try secureStore.get(key) else { return nil } + return try JSONDecoder().decode(StoredFieldSessionTokens.self, from: data) + } + + func save(_ tokens: FieldSessionTokenBundle) throws { + let stored = StoredFieldSessionTokens( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) + let data = try JSONEncoder().encode(stored) + try secureStore.put(data, for: key, policy: .secureLocalSecret) + } + + func delete() throws { + try secureStore.delete(key) + } +} diff --git a/Radroots/Runtime/LoggingSettings.swift b/Radroots/Runtime/LoggingSettings.swift @@ -0,0 +1,49 @@ +import Foundation + +struct LoggingSettings: Equatable { + var stdout: Bool + var fileEnabled: Bool + var fileName: String + var level: String? + + static func load() -> LoggingSettings { + let stdout = BuildConfig.bool(.loggingStdout) ?? true + let fileEnabled = BuildConfig.bool(.loggingFileEnabled) ?? false + let fileName = BuildConfig.string(.loggingFileName) ?? "field-ios.log" + let level = BuildConfig.string(.loggingFilter) + return LoggingSettings(stdout: stdout, fileEnabled: fileEnabled, fileName: fileName, level: level) + } + + func apply() throws { + if let level { + setenv("RUST_LOG", level, 1) + } + if fileEnabled { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.path + try initLogging(dir: dir, fileName: fileName, isStdout: stdout) + } else { + try initLogging(dir: nil, fileName: fileName, isStdout: stdout) + } + } + + func logEffectiveConfigs() { + let keys: [BuildConfigKey] = [ + .envFile, + .runtimeMode, + .loggingStdout, + .loggingFilter, + .loggingFileEnabled, + .loggingFileName, + .nostrRelayUrls, + .authApiBaseUrl, + .accountsApiBaseUrl, + .keychainServicePrefix, + .resetLocalState, + .tradeRhiPubkey, + ] + let dict = BuildConfig.effectiveDictionary(keys: keys) + let json = (try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys])) ?? Data() + let text = String(data: json, encoding: .utf8) ?? String(describing: dict) + try? logInfo(msg: "radroots.config \(text)") + } +} diff --git a/RadrootsKit/Sources/RadrootsKit/Nostr.swift b/Radroots/Runtime/Nostr.swift diff --git a/RadrootsKit/Sources/RadrootsKit/Radroots.swift b/Radroots/Runtime/Radroots.swift diff --git a/RadrootsKit/Sources/RadrootsKit/RelaySettings.swift b/Radroots/Runtime/RelaySettings.swift diff --git a/RadrootsKit/Sources/RadrootsKit/TradeListing.swift b/Radroots/Runtime/TradeListing.swift diff --git a/RadrootsKit/Sources/RadrootsKit/TradeSettings.swift b/Radroots/Runtime/TradeSettings.swift diff --git a/Radroots/Shared/Logging/DebugDump.swift b/Radroots/Shared/Logging/DebugDump.swift @@ -1,5 +1,4 @@ import Foundation -import RadrootsKit enum DebugDump { static func posts(_ items: [NostrPostEventMetadata], label: String = "PostFeed.kind1") { diff --git a/Radroots/Shared/Logging/Logger.swift b/Radroots/Shared/Logging/Logger.swift @@ -1,5 +1,4 @@ import Foundation -import RadrootsKit import os private let oslog = os.Logger(subsystem: Bundle.main.bundleIdentifier ?? "Radroots", category: "App") @@ -8,7 +7,7 @@ enum RadrootsLogger { static func info(_ message: String) { oslog.info("\(message, privacy: .public)") do { - try RadrootsKit.logInfo(msg: message) + try logInfo(msg: message) } catch { oslog.error("logInfo failed: \(error.localizedDescription, privacy: .public)") } @@ -17,7 +16,7 @@ enum RadrootsLogger { static func error(_ message: String) { oslog.error("\(message, privacy: .public)") do { - try RadrootsKit.logError(msg: message) + try logError(msg: message) } catch { oslog.error("logError failed: \(error.localizedDescription, privacy: .public)") } @@ -27,7 +26,7 @@ enum RadrootsLogger { #if DEBUG oslog.debug("\(message, privacy: .public)") do { - try RadrootsKit.logDebug(msg: message) + try logDebug(msg: message) } catch { oslog.error("logDebug failed: \(error.localizedDescription, privacy: .public)") } diff --git a/Radroots/Views/MarketView.swift b/Radroots/Views/MarketView.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit @MainActor final class TradeListingsViewModel: ObservableObject { diff --git a/Radroots/Views/PostCreateView.swift b/Radroots/Views/PostCreateView.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit struct PostCreateView: View { @EnvironmentObject private var app: AppState diff --git a/Radroots/Views/PostDetailView.swift b/Radroots/Views/PostDetailView.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit struct PostDetailView: View { let post: NostrPostEventMetadata diff --git a/Radroots/Views/PostFeedView.swift b/Radroots/Views/PostFeedView.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit struct PostFeedView: View { @EnvironmentObject private var app: AppState diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit @MainActor final class PostFeedViewModel: ObservableObject { diff --git a/Radroots/Views/ProfileView.swift b/Radroots/Views/ProfileView.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit public struct ProfileView: View { @EnvironmentObject private var app: AppState diff --git a/Radroots/Views/RelaysView.swift b/Radroots/Views/RelaysView.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit struct RelaysView: View { @EnvironmentObject private var app: AppState diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -1,18 +1,21 @@ import SwiftUI -import RadrootsKit struct SettingsView: View { @EnvironmentObject private var app: AppState - @EnvironmentObject private var radroots: Radroots - @State private var exportError: String? var body: some View { List { Section("Account") { + if let displayName = app.accountDisplayName { + Text(displayName) + } + if let username = app.username { + CopyRow(title: "Username", value: username) + } if let npub = app.npub { CopyRow(title: "npub", value: npub) } else { - Text("No key configured") + Text("Nostr identity is prepared after sign-in.") .foregroundStyle(.secondary) } @@ -40,21 +43,11 @@ struct SettingsView: View { } } - if app.hasKey { - Section { - Button { - exportSecretHex() - } label: { - Label("Export Secret Hex (Danger)", systemImage: "square.and.arrow.up") - } - } header: { - Text("Security") - } footer: { - if let exportError { - Text(exportError).foregroundStyle(.red) - } else { - Text("Keep your secret key safe. Anyone with it controls your identity.") - } + Section { + Button(role: .destructive) { + app.logout() + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") } } } @@ -62,18 +55,4 @@ struct SettingsView: View { .inlineNavigationTitle("Settings") .accessibilityIdentifier("field_ios.settings") } - - private func exportSecretHex() { - guard let rt = radroots.runtime else { return } - exportError = nil - do { - guard let hex = try rt.accountsExportSelectedSecretHex() else { - exportError = "No selected account has an exportable secret." - return - } - UIPasteboard.general.string = hex - } catch { - exportError = String(describing: error) - } - } } diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift @@ -1,34 +1,43 @@ import SwiftUI -import RadrootsKit struct SetupView: View { @EnvironmentObject private var app: AppState - @EnvironmentObject private var radroots: Radroots - @EnvironmentObject private var keys: RadrootsKeys var onSuccess: (() -> Void)? = nil @State private var step: Step = .welcome + @State private var username = "" + @State private var code = "" + @State private var challenge: FieldLoginChallenge? @State private var isWorking = false @State private var errorMessage: String? var body: some View { ZStack { - if step == .welcome { + switch step { + case .welcome: SetupWelcomeView { withAnimation(.easeInOut(duration: 0.25)) { - step = .keySetup + step = .login } } .transition(.opacity.combined(with: .move(edge: .leading))) - } - - if step == .keySetup { - SetupKeyView( + case .login: + SetupLoginView( + username: $username, isWorking: isWorking, errorMessage: errorMessage, - onGenerate: generateKey, - onImport: importFromClipboard + onSubmit: startLogin + ) + .transition(.opacity.combined(with: .move(edge: .trailing))) + case .verify: + SetupVerifyView( + code: $code, + challenge: challenge, + isWorking: isWorking, + errorMessage: errorMessage, + onVerify: verifyLogin, + onResend: resendCode ) .transition(.opacity.combined(with: .move(edge: .trailing))) } @@ -38,43 +47,45 @@ struct SetupView: View { .accessibilityIdentifier("field_ios.setup") } - private func generateKey() { - guard let rt = radroots.runtime else { - errorMessage = "Runtime not ready. Please relaunch." - return - } + private func startLogin() { errorMessage = nil isWorking = true Task { @MainActor in do { - try keys.generateAndPersist(runtime: rt) - app.activateAfterKeyGeneration() - onSuccess?() + challenge = try app.startLogin(username: username) + code = "" + step = .verify } catch { - errorMessage = String(describing: error) + errorMessage = error.localizedDescription } isWorking = false } } - private func importFromClipboard() { - guard let rt = radroots.runtime else { - errorMessage = "Runtime not ready. Please relaunch." - return + private func resendCode() { + guard let challenge else { return } + errorMessage = nil + isWorking = true + Task { @MainActor in + do { + self.challenge = try app.resendLoginChallenge(challengeId: challenge.id) + } catch { + errorMessage = error.localizedDescription + } + isWorking = false } + } + + private func verifyLogin() { + guard let challenge else { return } errorMessage = nil isWorking = true Task { @MainActor in do { - let paste = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let hex = paste, !hex.isEmpty else { - throw NSError(domain: "Setup", code: -1, userInfo: [NSLocalizedDescriptionKey: "Clipboard is empty."]) - } - try keys.importSecretHex(hex: hex, runtime: rt) - app.activateAfterKeyGeneration() + try app.verifyLogin(challengeId: challenge.id, code: code) onSuccess?() } catch { - errorMessage = String(describing: error) + errorMessage = error.localizedDescription } isWorking = false } @@ -83,7 +94,8 @@ struct SetupView: View { private enum Step { case welcome - case keySetup + case login + case verify } private struct SetupWelcomeView: View { @@ -93,19 +105,16 @@ private struct SetupWelcomeView: View { VStack(spacing: 20) { Spacer() - Circle() - .fill(Color(.systemGray5)) + Image(systemName: "person.badge.key.fill") + .font(.system(size: 72, weight: .semibold)) + .foregroundStyle(.secondary) .frame(width: 120, height: 120) - .overlay( - Circle() - .strokeBorder(Color(.systemGray4), lineWidth: 1) - ) Text(Ls.setupGreetingHeader) .font(.title.weight(.semibold)) .multilineTextAlignment(.center) - Text(Ls.setupGreetingHeaderSub) + Text("Sign in to your Radroots account to prepare the field runtime.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -128,37 +137,109 @@ private struct SetupWelcomeView: View { } } -private struct SetupKeyView: View { +private struct SetupLoginView: View { + @Binding var username: String let isWorking: Bool let errorMessage: String? - let onGenerate: () -> Void - let onImport: () -> Void + let onSubmit: () -> Void var body: some View { VStack(spacing: 20) { VStack(spacing: 10) { - Image(systemName: "key.fill") + Image(systemName: "envelope.badge.shield.half.filled") .font(.system(size: 44, weight: .semibold)) .foregroundStyle(.secondary) - Text("Set up your Nostr identity") + Text("Sign in") .font(.title2.weight(.semibold)) .multilineTextAlignment(.center) - Text("Generate a new key or import an existing secret to get started.") + Text("Enter your Radroots username to receive a verification code.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) } - if let errorMessage { - Text(errorMessage) - .foregroundStyle(.red) + TextField("Username", text: $username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.username) + .keyboardType(.emailAddress) + .padding(14) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .accessibilityIdentifier("field_ios.setup.username") + + SetupErrorText(errorMessage) + + if isWorking { + ProgressView() + .controlSize(.large) + } + + Button { + onSubmit() + } label: { + HStack(spacing: 10) { + Image(systemName: "paperplane.fill") + Text("Send Code") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isWorking || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("field_ios.setup.start_login") + + Spacer() + } + .padding() + .accessibilityIdentifier("field_ios.setup.login") + } +} + +private struct SetupVerifyView: View { + @Binding var code: String + let challenge: FieldLoginChallenge? + let isWorking: Bool + let errorMessage: String? + let onVerify: () -> Void + let onResend: () -> Void + + var body: some View { + VStack(spacing: 20) { + VStack(spacing: 10) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(.secondary) + + Text("Enter verification code") + .font(.title2.weight(.semibold)) .multilineTextAlignment(.center) - .padding(.horizontal) + + if let challenge { + Text("We sent a code to \(challenge.maskedEmail).") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } } + TextField("Code", text: $code) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .padding(14) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .accessibilityIdentifier("field_ios.setup.code") + + SetupErrorText(errorMessage) + if isWorking { ProgressView() .controlSize(.large) @@ -166,44 +247,53 @@ private struct SetupKeyView: View { VStack(spacing: 12) { Button { - onGenerate() + onVerify() } label: { HStack(spacing: 10) { - Image(systemName: "sparkles") - Text("Generate New Key") + Image(systemName: "checkmark.circle.fill") + Text("Verify") .fontWeight(.semibold) } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) - .disabled(isWorking) - .accessibilityIdentifier("field_ios.setup.generate_key") + .disabled(isWorking || code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("field_ios.setup.verify_login") Button { - onImport() + onResend() } label: { - HStack(spacing: 10) { - Image(systemName: "doc.on.clipboard") - Text("Import Secret Hex") - } - .frame(maxWidth: .infinity) + Text("Resend Code") + .frame(maxWidth: .infinity) } .buttonStyle(.bordered) .controlSize(.large) - .disabled(isWorking) - .accessibilityIdentifier("field_ios.setup.import_secret") + .disabled(isWorking || challenge == nil) + .accessibilityIdentifier("field_ios.setup.resend_code") } - .padding(.top, 4) Spacer() + } + .padding() + .accessibilityIdentifier("field_ios.setup.verify") + } +} - Text("Your account is managed by the shared field runtime.") - .font(.footnote) - .foregroundStyle(.secondary) +private struct SetupErrorText: View { + let message: String? + + init(_ message: String?) { + self.message = message + } + + var body: some View { + if let message { + Text(message) + .foregroundStyle(.red) .multilineTextAlignment(.center) + .padding(.horizontal) + .accessibilityIdentifier("field_ios.setup.error") } - .padding() - .accessibilityIdentifier("field_ios.setup.key") } } diff --git a/Radroots/Views/TradeListingCreateView.swift b/Radroots/Views/TradeListingCreateView.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit struct TradeListingCreateView: View { @EnvironmentObject private var app: AppState diff --git a/Radroots/Views/TradeListingDetailView.swift b/Radroots/Views/TradeListingDetailView.swift @@ -1,5 +1,4 @@ import SwiftUI -import RadrootsKit @MainActor final class TradeListingDetailViewModel: ObservableObject { diff --git a/Radroots/Views/TradeOrderRequestView.swift b/Radroots/Views/TradeOrderRequestView.swift @@ -1,6 +1,5 @@ import SwiftUI import Foundation -import RadrootsKit struct TradeOrderRequestView: View { @EnvironmentObject private var app: AppState diff --git a/RadrootsFFI/Makefile b/RadrootsFFI/Makefile @@ -14,9 +14,9 @@ IPHONEOS_DEPLOYMENT_TARGET ?= 18.0 RUSTUP_TOOLCHAIN ?= 1.92.0-aarch64-apple-darwin IOS_ROOT := $(abspath $(CURDIR)/..) -KIT_ROOT := $(IOS_ROOT)/RadrootsKit -ARTIFACTS_DIR := $(KIT_ROOT)/Artifacts -GENERATED_SWIFT_DIR := $(KIT_ROOT)/Sources/RadrootsKit/Generated +APP_ROOT := $(IOS_ROOT)/Radroots +ARTIFACTS_DIR := $(APP_ROOT)/Frameworks +GENERATED_SWIFT_DIR := $(APP_ROOT)/Generated BUILD_ROOT := $(CURDIR)/.build SOURCE_ROOT := $(BUILD_ROOT)/source diff --git a/RadrootsFFI/source.lock b/RadrootsFFI/source.lock @@ -1,5 +1,5 @@ SOURCE_MODE=git RADROOTS_FIELD_LIB_GIT_URL=git@github.com:radrootslabs/field_lib.git -RADROOTS_FIELD_LIB_GIT_REV=a73f486d1dca78bfb1b1c655c2404317a7ea1e5e +RADROOTS_FIELD_LIB_GIT_REV=c674493e03b4e75bad1b314e62ffcd4bbb1db9cb RADROOTS_FIELD_FFI_CRATE_VERSION=0.1.0-alpha.1 FFI_FEATURES=radroots_field_core/rt,radroots_field_core/nostr-client diff --git a/RadrootsKit/Package.swift b/RadrootsKit/Package.swift @@ -1,26 +0,0 @@ -// swift-tools-version: 6.0 -import PackageDescription - -let package = Package( - name: "RadrootsKit", - platforms: [ - .iOS(.v16) - ], - products: [ - .library( - name: "RadrootsKit", - targets: ["RadrootsKit"] - ) - ], - targets: [ - .binaryTarget( - name: "RadrootsFFI", - path: "Artifacts/RadrootsFFI.xcframework" - ), - .target( - name: "RadrootsKit", - dependencies: ["RadrootsFFI"], - path: "Sources/RadrootsKit" - ) - ] -) diff --git a/RadrootsKit/Sources/RadrootsKit/AppState.swift b/RadrootsKit/Sources/RadrootsKit/AppState.swift @@ -1,122 +0,0 @@ -import Foundation -import Combine - -@MainActor -public final class AppState: ObservableObject { - public enum BootstrapPhase { - case idle - case starting - case ready - } - - @Published public private(set) var bootstrapPhase: BootstrapPhase = .idle - @Published public private(set) var infoJSONString: String = "" - @Published public private(set) var hasKey: Bool = false - @Published public private(set) var npub: String? - @Published public private(set) var relayConnectedCount: UInt32 = 0 - @Published public private(set) var relayConnectingCount: UInt32 = 0 - @Published public private(set) var relayLight: RelayLight = .red - @Published public private(set) var relayLastError: String? - - public var canShowAppContent: Bool { bootstrapPhase == .ready && hasKey } - public var requiresKeySetup: Bool { bootstrapPhase == .ready && !hasKey } - - public enum RelayLight { - case red, yellow, green - } - - public let radroots: Radroots - public let keys: RadrootsKeys - - private var statusTask: Task<Void, Never>? - private var cancellables = Set<AnyCancellable>() - - public init(radroots: Radroots = Radroots(), keys: RadrootsKeys = RadrootsKeys()) { - self.radroots = radroots - self.keys = keys - - keys.$hasKey - .sink { [weak self] in self?.hasKey = $0 } - .store(in: &cancellables) - - keys.$npub - .sink { [weak self] in self?.npub = $0 } - .store(in: &cancellables) - } - - deinit { - statusTask?.cancel() - } - - public func start() async throws { - guard bootstrapPhase == .idle else { return } - bootstrapPhase = .starting - do { - try radroots.start() - if let rt = radroots.runtime { - keys.loadFromRuntime(runtime: rt) - connectIfPossible() - if rt.accountsHasSelectedSigningIdentity() { - startPollingStatus() - } - } - refresh() - bootstrapPhase = .ready - } catch { - bootstrapPhase = .idle - throw error - } - } - - public func refresh() { - guard let rt = radroots.runtime else { return } - infoJSONString = rt.infoJson() - hasKey = rt.accountsHasSelectedSigningIdentity() - npub = rt.accountsSelectedNpub() - keys.refresh(runtime: rt) - updateStatus() - } - - public func activateAfterKeyGeneration() { - connectIfPossible() - startPollingStatus() - refresh() - } - - private func connectIfPossible() { - guard let rt = radroots.runtime, rt.accountsHasSelectedSigningIdentity() else { return } - do { - let relays = try RelaySettings.relays() - try rt.nostrSetDefaultRelays(relays: relays) - try rt.nostrConnectIfKeyPresent() - relayLastError = nil - } catch { - relayLastError = error.localizedDescription - } - } - - private func startPollingStatus() { - statusTask?.cancel() - statusTask = Task { [weak self] in - while !Task.isCancelled { - await MainActor.run { self?.updateStatus() } - try? await Task.sleep(nanoseconds: 1_000_000_000) - } - } - } - - private func updateStatus() { - guard let rt = radroots.runtime else { return } - let s = rt.nostrConnectionStatus() - relayConnectedCount = s.connected - relayConnectingCount = s.connecting - relayLastError = s.lastError - - switch s.light { - case .green: relayLight = .green - case .yellow: relayLight = .yellow - case .red: relayLight = .red - @unknown default: relayLight = .red - } - } -} diff --git a/RadrootsKit/Sources/RadrootsKit/BuildConfig.swift b/RadrootsKit/Sources/RadrootsKit/BuildConfig.swift @@ -1,133 +0,0 @@ -import Foundation - -enum BuildConfigKey: String { - case envFile = "RADROOTS_FIELD_IOS_ENV_FILE" - case runtimeMode = "RADROOTS_FIELD_IOS_RUNTIME_MODE" - case loggingStdout = "RADROOTS_FIELD_IOS_LOGGING_STDOUT" - case loggingFilter = "RADROOTS_FIELD_IOS_LOGGING_FILTER" - case loggingFileEnabled = "RADROOTS_FIELD_IOS_LOGGING_FILE_ENABLED" - case loggingFileName = "RADROOTS_FIELD_IOS_LOGGING_FILE_NAME" - case nostrRelayUrls = "RADROOTS_FIELD_IOS_NOSTR_RELAY_URLS" - case tradeRhiPubkey = "RADROOTS_FIELD_IOS_TRADE_RHI_PUBKEY" -} - -enum BuildConfig { - static func string(_ key: BuildConfigKey) -> String? { - envString(key) ?? infoString(key).map { stripOuterQuotes($0) } - } - - static func bool(_ key: BuildConfigKey) -> Bool? { - if let env = ProcessInfo.processInfo.environment[key.rawValue], - let parsed = parseBool(env) { - return parsed - } - if let v = infoValue(for: key.rawValue) { - if let b = v as? Bool { return b } - if let s = v as? String, let parsed = parseBool(s) { return parsed } - if let n = v as? NSNumber { return n.boolValue } - } - return nil - } - - static func array(_ key: BuildConfigKey, splitBy set: CharacterSet = .whitespacesAndNewlines) -> [String]? { - if let raw = envString(key) { - return parseArray(raw, splitBy: set) - } - if let direct = infoArray(key) { - return direct - .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - guard let raw = infoString(key) else { return nil } - return parseArray(raw, splitBy: set) - } - - static func effectiveDictionary(keys: [BuildConfigKey]) -> [String: Any] { - var out: [String: Any] = [:] - for k in keys { - switch k { - case .loggingStdout, .loggingFileEnabled: - if let b = bool(k) { - out[k.rawValue] = b - } - case .nostrRelayUrls: - if let arr = array(.nostrRelayUrls) { - out[k.rawValue] = arr - } - default: - if let s = string(k) { - out[k.rawValue] = s - } - } - } - return out - } - - private static func envString(_ key: BuildConfigKey) -> String? { - ProcessInfo.processInfo.environment[key.rawValue] - .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .flatMap { $0.isEmpty ? nil : stripOuterQuotes($0) } - } - - private static func parseArray(_ value: String, splitBy set: CharacterSet) -> [String]? { - var raw = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !raw.isEmpty else { return nil } - if raw.first == "[" { - if let data = raw.data(using: .utf8), - let arr = try? JSONSerialization.jsonObject(with: data) as? [String] { - return arr - .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - } - raw = stripOuterQuotes(raw) - let separators = set.union(CharacterSet(charactersIn: ",;")) - raw = raw.replacingOccurrences(of: "\n", with: " ") - .replacingOccurrences(of: "\r", with: " ") - return raw - .components(separatedBy: separators) - .map { stripOuterQuotes($0).trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - } - - private static func infoString(_ key: BuildConfigKey) -> String? { - if let v = infoValue(for: key.rawValue) as? String, !v.isEmpty { return v } - if let n = infoValue(for: key.rawValue) as? NSNumber { return n.stringValue } - return nil - } - - private static func infoArray(_ key: BuildConfigKey) -> [String]? { - if let v = infoValue(for: key.rawValue) as? [String] { return v } - if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any], - let v = nested[key.rawValue] as? [String] { - return v - } - return nil - } - - private static func infoValue(for key: String) -> Any? { - if let v = Bundle.main.object(forInfoDictionaryKey: key) { - return v - } - if let nested = Bundle.main.object(forInfoDictionaryKey: "Radroots") as? [String: Any] { - return nested[key] - } - return nil - } - - private static func parseBool(_ s: String) -> Bool? { - switch s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "1", "true", "yes": return true - case "0", "false", "no": return false - default: return nil - } - } - - private static func stripOuterQuotes(_ s: String) -> String { - guard s.count >= 2 else { return s } - if (s.hasPrefix("\"") && s.hasSuffix("\"")) || (s.hasPrefix("'") && s.hasSuffix("'")) { - return String(s.dropFirst().dropLast()) - } - return s - } -} diff --git a/RadrootsKit/Sources/RadrootsKit/LoggingSettings.swift b/RadrootsKit/Sources/RadrootsKit/LoggingSettings.swift @@ -1,45 +0,0 @@ -import Foundation - -struct LoggingSettings: Equatable { - var stdout: Bool - var fileEnabled: Bool - var fileName: String - var level: String? - - static func load() -> LoggingSettings { - let stdout = BuildConfig.bool(.loggingStdout) ?? true - let fileEnabled = BuildConfig.bool(.loggingFileEnabled) ?? false - let fileName = BuildConfig.string(.loggingFileName) ?? "field-ios.log" - let level = BuildConfig.string(.loggingFilter) - return LoggingSettings(stdout: stdout, fileEnabled: fileEnabled, fileName: fileName, level: level) - } - - func apply() throws { - if let level { - setenv("RUST_LOG", level, 1) - } - if fileEnabled { - let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.path - try initLogging(dir: dir, fileName: fileName, isStdout: stdout) - } else { - try initLogging(dir: nil, fileName: fileName, isStdout: stdout) - } - } - - func logEffectiveConfigs() { - let keys: [BuildConfigKey] = [ - .envFile, - .runtimeMode, - .loggingStdout, - .loggingFilter, - .loggingFileEnabled, - .loggingFileName, - .nostrRelayUrls, - .tradeRhiPubkey, - ] - let dict = BuildConfig.effectiveDictionary(keys: keys) - let json = (try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys])) ?? Data() - let text = String(data: json, encoding: .utf8) ?? String(describing: dict) - try? logInfo(msg: "radroots.config \(text)") - } -} diff --git a/RadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift b/RadrootsKit/Sources/RadrootsKit/RadrootsKeys.swift @@ -1,28 +0,0 @@ -import Foundation - -@MainActor -public final class RadrootsKeys: ObservableObject { - @Published public private(set) var hasKey: Bool = false - @Published public private(set) var npub: String? - - public init() {} - - public func loadFromRuntime(runtime: RadrootsRuntime) { - refresh(runtime: runtime) - } - - public func generateAndPersist(runtime: RadrootsRuntime) throws { - _ = try runtime.accountsGenerate(label: "iOS", makeSelected: true) - refresh(runtime: runtime) - } - - public func importSecretHex(hex: String, runtime: RadrootsRuntime) throws { - _ = try runtime.accountsImportSecret(secretKey: hex, label: "iOS", makeSelected: true) - refresh(runtime: runtime) - } - - public func refresh(runtime: RadrootsRuntime) { - self.hasKey = runtime.accountsHasSelectedSigningIdentity() - self.npub = runtime.accountsSelectedNpub() - } -} diff --git a/RadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift b/RadrootsKit/Sources/RadrootsKit/RadrootsProvider.swift @@ -1,29 +0,0 @@ -import SwiftUI - -public struct RadrootsProvider<Content: View>: View { - @StateObject private var appState = AppState() - private let onStartupError: ((Error) -> Void)? - private let content: () -> Content - - public init( - onStartupError: ((Error) -> Void)? = nil, - @ViewBuilder content: @escaping () -> Content - ) { - self.onStartupError = onStartupError - self.content = content - } - - public var body: some View { - content() - .environmentObject(appState) - .environmentObject(appState.keys) - .environmentObject(appState.radroots) - .task { - do { - try await appState.start() - } catch { - onStartupError?(error) - } - } - } -} diff --git a/project.yml b/project.yml @@ -10,7 +10,7 @@ configs: packages: RadrootsKit: - path: RadrootsKit + path: ../../../../domains/radroots/apple_kit targets: Radroots: @@ -21,6 +21,7 @@ targets: - path: Radroots excludes: - Info.plist + - Frameworks settings: base: SWIFT_VERSION: 6.0 @@ -29,6 +30,8 @@ targets: Debug: Radroots/Config/Debug.xcconfig Release: Radroots/Config/Release.xcconfig dependencies: + - framework: Radroots/Frameworks/RadrootsFFI.xcframework + embed: false - package: RadrootsKit preBuildScripts: - name: Generate git SHA xcconfig