commit 6dd31fc2461c0a5c0294c9083167ead645882b4f
parent 86efc576d4ce8fca722147fad69a354e9338e328
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 14:53:14 -0700
external-actions: add runtime boundary
Diffstat:
4 files changed, 171 insertions(+), 0 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -32,6 +32,7 @@
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 */; };
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 */; };
@@ -69,6 +70,7 @@
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>"; };
2FE790CA1CD31208947913B9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.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>"; };
466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; };
491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFileAccessUITestProbe.swift; sourceTree = "<group>"; };
@@ -191,6 +193,7 @@
7CC1D11E2E296B4B54E7E8A9 /* FieldCaptureIntake.swift */,
E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */,
EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */,
+ 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */,
491D57E540BAB04F24619737 /* FieldFileAccessUITestProbe.swift */,
CA942715DB13FFD494FD35A0 /* FieldIdentityPublicMetadataStore.swift */,
9EB0A2CFCBBFA9D204C6992B /* FieldLocalState.swift */,
@@ -434,6 +437,7 @@
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 */,
D4CFDE54747B6D6957977025 /* FieldIdentityPublicMetadataStore.swift in Sources */,
6E15D30653861F26AC45B501 /* FieldLocalState.swift in Sources */,
diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift
@@ -43,6 +43,8 @@ public final class AppState: ObservableObject {
@Published public private(set) var relayLastError: String?
@Published public private(set) var fileAccessProbeValue: String?
@Published public private(set) var documentInterchangeProbeValue: String?
+ @Published public private(set) var externalActionStatus: String?
+ @Published public private(set) var canOpenNostrProfile: Bool = false
@Published public private(set) var locationCheckInState: FieldLocationCheckInState = .idle(
RadrootsLocationServicesAvailability(locationServicesEnabled: false, authorization: .unavailable)
)
@@ -79,6 +81,7 @@ public final class AppState: ObservableObject {
private var identityMetadataStore: FieldIdentityPublicMetadataStore?
private var captureIntake: FieldCaptureIntake?
private let locationCheckIn = FieldLocationCheckIn.configured()
+ private let externalActions = FieldExternalActions.configured()
public init(radroots: Radroots = Radroots()) {
self.radroots = radroots
@@ -125,6 +128,7 @@ public final class AppState: ObservableObject {
try await connect(using: service)
startPollingStatus()
}
+ await refreshNostrProfileExternalActionCapability()
try refreshFileAccessProbe(
bundleIdentifier: appBundleIdentifier,
resetLocalStateRequested: resetLocalStateRequested,
@@ -162,6 +166,7 @@ public final class AppState: ObservableObject {
setLocked(false)
try await connect(using: service)
await refreshRuntimeState(using: service)
+ await refreshNostrProfileExternalActionCapability()
startPollingStatus()
}
@@ -171,6 +176,7 @@ public final class AppState: ObservableObject {
setLocked(false)
try await connect(using: service)
await refreshRuntimeState(using: service)
+ await refreshNostrProfileExternalActionCapability()
startPollingStatus()
}
@@ -187,6 +193,7 @@ public final class AppState: ObservableObject {
setLocked(false)
try await connect(using: service)
await refreshRuntimeState(using: service)
+ await refreshNostrProfileExternalActionCapability()
startPollingStatus()
}
@@ -213,6 +220,8 @@ public final class AppState: ObservableObject {
relayConnectingCount = 0
relayLight = .red
relayLastError = nil
+ canOpenNostrProfile = false
+ externalActionStatus = nil
await refreshRuntimeState(using: service)
try refreshFileAccessProbe(
bundleIdentifier: try bundleIdentifier(),
@@ -268,6 +277,31 @@ public final class AppState: ObservableObject {
}
}
+ public func refreshNostrProfileExternalActionCapability() async {
+ guard let npub else {
+ canOpenNostrProfile = false
+ return
+ }
+ canOpenNostrProfile = await externalActions.canOpenPublicNostrProfile(npub: npub)
+ }
+
+ public func openAppSettingsRecovery() async {
+ await requestExternalAction {
+ try await externalActions.openAppSettings()
+ }
+ }
+
+ public func openCurrentNostrProfile() async {
+ guard let npub else {
+ externalActionStatus = "No public Nostr identity is selected."
+ canOpenNostrProfile = false
+ return
+ }
+ await requestExternalAction {
+ try await externalActions.openPublicNostrProfile(npub: npub)
+ }
+ }
+
func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument {
try documentInterchange().prepareDiagnosticsExport(
infoJSONString: infoJSONString,
@@ -438,6 +472,7 @@ public final class AppState: ObservableObject {
hasKey = false
npub = nil
identityLabel = nil
+ canOpenNostrProfile = false
}
}
@@ -519,6 +554,7 @@ public final class AppState: ObservableObject {
npub = nil
identityLabel = nil
identities = []
+ canOpenNostrProfile = false
}
private func secureIdentityStoreOrConfigured() throws -> FieldSecureIdentityStore {
@@ -580,6 +616,17 @@ public final class AppState: ObservableObject {
)
}
+ private func requestExternalAction(
+ _ action: () async throws -> FieldExternalActionRequestRecord
+ ) async {
+ do {
+ let record = try await action()
+ externalActionStatus = record.statusText
+ } catch {
+ externalActionStatus = error.localizedDescription
+ }
+ }
+
private func setLocked(_ value: Bool) {
isLocked = value
UserDefaults.standard.set(value, forKey: lockKey)
diff --git a/Radroots/Info.plist b/Radroots/Info.plist
@@ -24,6 +24,10 @@
<string>Radroots uses your location only when you tap Location Check-in.</string>
<key>NSCameraUsageDescription</key>
<string>Radroots uses the camera only when you capture photo evidence or scan a document.</string>
+ <key>LSApplicationQueriesSchemes</key>
+ <array>
+ <string>nostr</string>
+ </array>
<key>GIT_SHA</key>
<string>$(GIT_SHA)</string>
diff --git a/Radroots/Runtime/FieldExternalActions.swift b/Radroots/Runtime/FieldExternalActions.swift
@@ -0,0 +1,116 @@
+import Foundation
+import RadrootsKit
+import RadrootsKitTesting
+
+public struct FieldExternalActionRequestRecord: Equatable, Sendable {
+ public let kind: RadrootsExternalActionDestinationKind
+ public let urlString: String?
+
+ init(destination: RadrootsExternalActionDestination) {
+ self.kind = destination.kind
+ self.urlString = destination.url?.absoluteString
+ }
+
+ public var statusText: String {
+ switch kind {
+ case .appSettings:
+ "Requested app settings"
+ case .web:
+ "Requested web link"
+ case .nostr:
+ "Requested Nostr profile"
+ case .appleMaps:
+ "Requested Apple Maps"
+ }
+ }
+}
+
+final class FieldExternalActions: Sendable {
+ private let actions: any RadrootsExternalActions
+
+ init(actions: any RadrootsExternalActions) {
+ self.actions = actions
+ }
+
+ static func configured() -> FieldExternalActions {
+ guard uiTestWasRequested else {
+ return FieldExternalActions(actions: RadrootsAppleExternalActions())
+ }
+ return FieldExternalActions(actions: uiTestExternalActions())
+ }
+
+ func canOpenPublicNostrProfile(npub: String) async -> Bool {
+ guard let destination = try? publicNostrProfileDestination(npub: npub) else {
+ return false
+ }
+ return await actions.canOpen(destination).canOpen
+ }
+
+ func openAppSettings() async throws -> FieldExternalActionRequestRecord {
+ let destination = RadrootsExternalActionDestination.appSettings
+ try await actions.open(RadrootsExternalActionRequest(destination: destination))
+ return FieldExternalActionRequestRecord(destination: destination)
+ }
+
+ func openPublicNostrProfile(npub: String) async throws -> FieldExternalActionRequestRecord {
+ let destination = try publicNostrProfileDestination(npub: npub)
+ try await actions.open(RadrootsExternalActionRequest(destination: destination))
+ return FieldExternalActionRequestRecord(destination: destination)
+ }
+
+ private func publicNostrProfileDestination(npub: String) throws -> RadrootsExternalActionDestination {
+ try RadrootsExternalActionDestination.nostr("nostr:\(npub)")
+ }
+
+ private static var uiTestWasRequested: Bool {
+ let arguments = ProcessInfo.processInfo.arguments
+ let environment = ProcessInfo.processInfo.environment
+ return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" ||
+ arguments.contains("--radroots-field-ios-ui-test")
+ }
+
+ private static func uiTestExternalActions() -> RadrootsFakeExternalActions {
+ RadrootsFakeExternalActions(
+ defaultCanOpen: uiTestCanOpen,
+ openOutcome: uiTestOpenOutcome
+ )
+ }
+
+ private static var uiTestCanOpen: Bool {
+ let environment = ProcessInfo.processInfo.environment
+ if let raw = environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_NOSTR_CAN_OPEN"] {
+ return parseBool(raw) ?? true
+ }
+ if let raw = environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_CAN_OPEN"] {
+ return parseBool(raw) ?? true
+ }
+ return true
+ }
+
+ private static var uiTestOpenOutcome: Result<Void, RadrootsExternalActionError> {
+ let raw = ProcessInfo.processInfo.environment["RADROOTS_FIELD_IOS_UI_TEST_EXTERNAL_ACTIONS_OPEN_OUTCOME"]?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ switch raw {
+ case nil, "", "success":
+ return .success(())
+ case "unavailable":
+ return .failure(.unavailable("external actions are unavailable in this UI test"))
+ case "transient_failure":
+ return .failure(.transientFailure("external action failed in this UI test"))
+ default:
+ return .failure(.blockedByPolicy("unsupported UI-test external action outcome"))
+ }
+ }
+
+ private static func parseBool(_ raw: String) -> Bool? {
+ switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
+ case "1", "true", "yes":
+ true
+ case "0", "false", "no":
+ false
+ default:
+ nil
+ }
+ }
+}