apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

commit 6a782e7c4a85b37e5fe730a868339d40cd4a084e
parent 38c018d3f263f07cd3f6faffdbe063b4368b669f
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 14:48:10 -0700

external-actions: add contracts and fakes

Diffstat:
ASources/RadrootsKit/RadrootsExternalActions.swift | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsExternalActionsTesting.swift | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTestingTests/RadrootsExternalActionsTestingTests.swift | 37+++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsExternalActionsTests.swift | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 392 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsExternalActions.swift b/Sources/RadrootsKit/RadrootsExternalActions.swift @@ -0,0 +1,201 @@ +import Foundation + +public enum RadrootsExternalActionDestinationKind: String, Sendable, Equatable, Hashable, CaseIterable { + case appSettings + case web + case nostr + case appleMaps +} + +public struct RadrootsExternalActionDestination: Sendable, Equatable, Hashable { + public let kind: RadrootsExternalActionDestinationKind + public let url: URL? + + private init(kind: RadrootsExternalActionDestinationKind, url: URL?) { + self.kind = kind + self.url = url + } + + public static let appSettings = RadrootsExternalActionDestination(kind: .appSettings, url: nil) + + public static func web(_ value: String) throws -> Self { + try Self(kind: .web, url: RadrootsExternalActionValidation.normalizedWebURL(value)) + } + + public static func nostr(_ value: String) throws -> Self { + try Self(kind: .nostr, url: RadrootsExternalActionValidation.normalizedNostrURI(value)) + } + + public static func appleMaps(_ value: String) throws -> Self { + try Self(kind: .appleMaps, url: RadrootsExternalActionValidation.normalizedAppleMapsURL(value)) + } + + public static func appleMaps( + coordinate: RadrootsLocationCoordinate, + label: String? = nil + ) throws -> Self { + try Self( + kind: .appleMaps, + url: RadrootsExternalActionValidation.appleMapsURL(coordinate: coordinate, label: label) + ) + } +} + +public struct RadrootsExternalActionRequest: Sendable, Equatable, Hashable { + public let destination: RadrootsExternalActionDestination + + public init(destination: RadrootsExternalActionDestination) { + self.destination = destination + } +} + +public struct RadrootsExternalActionCapability: Sendable, Equatable, Hashable { + public let destination: RadrootsExternalActionDestination + public let canOpen: Bool + + public init(destination: RadrootsExternalActionDestination, canOpen: Bool) { + self.destination = destination + self.canOpen = canOpen + } +} + +public enum RadrootsExternalActionError: Error, Equatable, Sendable { + case invalidRequest(String) + case blockedByPolicy(String) + case unavailable(String) + case transientFailure(String) + case permanentFailure(String) +} + +extension RadrootsExternalActionError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + case .blockedByPolicy(let message): + message + case .unavailable(let message): + message + case .transientFailure(let message): + message + case .permanentFailure(let message): + message + } + } +} + +public protocol RadrootsExternalActions: Sendable { + func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability + func open(_ request: RadrootsExternalActionRequest) async throws +} + +public enum RadrootsExternalActionValidation { + public static func normalizedWebURL(_ value: String) throws -> URL { + let trimmed = try trimmedNonEmpty(value, field: "web url") + try rejectWhitespaceOrControl(trimmed, field: "web url") + guard let components = URLComponents(string: trimmed), + components.scheme?.lowercased() == "https", + components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + components.user == nil, + components.password == nil, + let url = components.url else { + throw RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host") + } + return url + } + + public static func normalizedNostrURI(_ value: String) throws -> URL { + let trimmed = try trimmedNonEmpty(value, field: "nostr uri") + try rejectWhitespaceOrControl(trimmed, field: "nostr uri") + guard trimmed.lowercased().hasPrefix("nostr:") else { + throw RadrootsExternalActionError.blockedByPolicy("nostr uri must use the nostr scheme") + } + let payload = String(trimmed.dropFirst("nostr:".count)) + guard !payload.isEmpty else { + throw RadrootsExternalActionError.invalidRequest("nostr uri payload must not be empty") + } + guard payload.range(of: "^[A-Za-z0-9]+$", options: .regularExpression) != nil else { + throw RadrootsExternalActionError.invalidRequest("nostr uri payload must be bech32-like public text") + } + let normalizedPayload = payload.lowercased() + if normalizedPayload.hasPrefix("nsec") { + throw RadrootsExternalActionError.blockedByPolicy("nostr secret payloads cannot be opened externally") + } + let allowedPrefixes = ["npub1", "nprofile1", "note1", "nevent1", "naddr1", "nrelay1"] + guard allowedPrefixes.contains(where: { normalizedPayload.hasPrefix($0) }) else { + throw RadrootsExternalActionError.blockedByPolicy("nostr uri payload must be a public Nostr identifier") + } + guard let url = URL(string: "nostr:\(normalizedPayload)") else { + throw RadrootsExternalActionError.invalidRequest("nostr uri is not a valid url") + } + return url + } + + public static func normalizedAppleMapsURL(_ value: String) throws -> URL { + let trimmed = try trimmedNonEmpty(value, field: "apple maps url") + try rejectWhitespaceOrControl(trimmed, field: "apple maps url") + guard let components = URLComponents(string: trimmed), + components.scheme?.lowercased() == "https", + components.host?.lowercased() == "maps.apple.com", + components.user == nil, + components.password == nil, + let url = components.url else { + throw RadrootsExternalActionError.blockedByPolicy("apple maps urls must use https://maps.apple.com") + } + return url + } + + public static func appleMapsURL( + coordinate: RadrootsLocationCoordinate, + label: String? = nil + ) throws -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = "maps.apple.com" + components.path = "/" + var queryItems = [ + URLQueryItem( + name: "ll", + value: "\(coordinate.latitude),\(coordinate.longitude)" + ) + ] + if let label { + let normalizedLabel = try normalizedOptionalLabel(label) + if let normalizedLabel { + queryItems.append(URLQueryItem(name: "q", value: normalizedLabel)) + } + } + components.queryItems = queryItems + guard let url = components.url else { + throw RadrootsExternalActionError.permanentFailure("failed to construct apple maps url") + } + return url + } + + static func trimmedNonEmpty(_ value: String, field: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsExternalActionError.invalidRequest("\(field) must not be empty") + } + return trimmed + } + + static func rejectWhitespaceOrControl(_ value: String, field: String) throws { + let forbidden = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) + guard value.unicodeScalars.allSatisfy({ !forbidden.contains($0) }) else { + throw RadrootsExternalActionError.invalidRequest("\(field) cannot contain whitespace or control characters") + } + } + + static func normalizedOptionalLabel(_ value: String) throws -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + let forbidden = CharacterSet.controlCharacters + guard trimmed.unicodeScalars.allSatisfy({ !forbidden.contains($0) }) else { + throw RadrootsExternalActionError.invalidRequest("apple maps label cannot contain control characters") + } + return trimmed + } +} diff --git a/Sources/RadrootsKitTesting/RadrootsExternalActionsTesting.swift b/Sources/RadrootsKitTesting/RadrootsExternalActionsTesting.swift @@ -0,0 +1,78 @@ +import Foundation +import RadrootsKit + +public actor RadrootsFakeExternalActions: RadrootsExternalActions { + private var capabilityOverrides: [RadrootsExternalActionDestination: Bool] + private var defaultCanOpen: Bool + private var openOutcome: Result<Void, RadrootsExternalActionError> + private var capabilityRequestCountValue: Int + private var openRequestCountValue: Int + private var lastCapabilityDestinationValue: RadrootsExternalActionDestination? + private var openedDestinationsValue: [RadrootsExternalActionDestination] + + public init( + capabilityOverrides: [RadrootsExternalActionDestination: Bool] = [:], + defaultCanOpen: Bool = true, + openOutcome: Result<Void, RadrootsExternalActionError> = .success(()) + ) { + self.capabilityOverrides = capabilityOverrides + self.defaultCanOpen = defaultCanOpen + self.openOutcome = openOutcome + self.capabilityRequestCountValue = 0 + self.openRequestCountValue = 0 + self.lastCapabilityDestinationValue = nil + self.openedDestinationsValue = [] + } + + public func setCapability(_ canOpen: Bool, for destination: RadrootsExternalActionDestination) { + capabilityOverrides[destination] = canOpen + } + + public func setDefaultCapability(_ canOpen: Bool) { + defaultCanOpen = canOpen + } + + public func setOpenOutcome(_ outcome: Result<Void, RadrootsExternalActionError>) { + openOutcome = outcome + } + + public func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability { + capabilityRequestCountValue += 1 + lastCapabilityDestinationValue = destination + return RadrootsExternalActionCapability( + destination: destination, + canOpen: capabilityOverrides[destination] ?? defaultCanOpen + ) + } + + public func open(_ request: RadrootsExternalActionRequest) async throws { + openRequestCountValue += 1 + openedDestinationsValue.append(request.destination) + switch openOutcome { + case .success: + return + case .failure(let error): + throw error + } + } + + public var capabilityRequestCount: Int { + capabilityRequestCountValue + } + + public var openRequestCount: Int { + openRequestCountValue + } + + public var lastCapabilityDestination: RadrootsExternalActionDestination? { + lastCapabilityDestinationValue + } + + public var openedDestinations: [RadrootsExternalActionDestination] { + openedDestinationsValue + } + + public var lastOpenedDestination: RadrootsExternalActionDestination? { + openedDestinationsValue.last + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsExternalActionsTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsExternalActionsTestingTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +import RadrootsKit +import RadrootsKitTesting + +@Test func fakeExternalActionsRecordsCapabilityAndOpenRequests() async throws { + let web = try RadrootsExternalActionDestination.web("https://radroots.org") + let nostr = try RadrootsExternalActionDestination.nostr("nostr:npub1qqqqqq") + let actions = RadrootsFakeExternalActions( + capabilityOverrides: [nostr: false], + defaultCanOpen: true + ) + + #expect(await actions.canOpen(web).canOpen) + #expect(!(await actions.canOpen(nostr).canOpen)) + try await actions.open(RadrootsExternalActionRequest(destination: web)) + + #expect(await actions.capabilityRequestCount == 2) + #expect(await actions.openRequestCount == 1) + #expect(await actions.lastCapabilityDestination == nostr) + #expect(await actions.lastOpenedDestination == web) + #expect(await actions.openedDestinations == [web]) +} + +@Test func fakeExternalActionsReturnsConfiguredFailures() async throws { + let destination = RadrootsExternalActionDestination.appSettings + let actions = RadrootsFakeExternalActions( + openOutcome: .failure(.unavailable("external actions unavailable")) + ) + + await #expect(throws: RadrootsExternalActionError.unavailable("external actions unavailable")) { + try await actions.open(RadrootsExternalActionRequest(destination: destination)) + } + + #expect(await actions.openRequestCount == 1) + #expect(await actions.lastOpenedDestination == destination) +} diff --git a/Tests/RadrootsKitTests/RadrootsExternalActionsTests.swift b/Tests/RadrootsKitTests/RadrootsExternalActionsTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func webDestinationAcceptsOnlyHttpsUrlsWithHosts() throws { + let destination = try RadrootsExternalActionDestination.web(" https://radroots.org/field ") + + #expect(destination.kind == .web) + #expect(destination.url?.absoluteString == "https://radroots.org/field") + + #expect(throws: RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host")) { + _ = try RadrootsExternalActionDestination.web("http://radroots.org") + } + #expect(throws: RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host")) { + _ = try RadrootsExternalActionDestination.web("wss://radroots.org") + } + #expect(throws: RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host")) { + _ = try RadrootsExternalActionDestination.web("https:///missing-host") + } + #expect(throws: RadrootsExternalActionError.invalidRequest("web url cannot contain whitespace or control characters")) { + _ = try RadrootsExternalActionDestination.web("https://radroots.org/a b") + } +} + +@Test func nostrDestinationAllowsPublicIdentifiersAndRejectsSecrets() throws { + let destination = try RadrootsExternalActionDestination.nostr("nostr:NPUB1qqqqqq") + + #expect(destination.kind == .nostr) + #expect(destination.url?.absoluteString == "nostr:npub1qqqqqq") + + _ = try RadrootsExternalActionDestination.nostr("nostr:nprofile1qqqqqq") + _ = try RadrootsExternalActionDestination.nostr("nostr:note1qqqqqq") + _ = try RadrootsExternalActionDestination.nostr("nostr:nevent1qqqqqq") + _ = try RadrootsExternalActionDestination.nostr("nostr:naddr1qqqqqq") + _ = try RadrootsExternalActionDestination.nostr("nostr:nrelay1qqqqqq") + + #expect(throws: RadrootsExternalActionError.blockedByPolicy("nostr secret payloads cannot be opened externally")) { + _ = try RadrootsExternalActionDestination.nostr("nostr:nsec1qqqqqq") + } + #expect(throws: RadrootsExternalActionError.blockedByPolicy("nostr uri payload must be a public Nostr identifier")) { + _ = try RadrootsExternalActionDestination.nostr("nostr:relay1qqqqqq") + } + #expect(throws: RadrootsExternalActionError.invalidRequest("nostr uri cannot contain whitespace or control characters")) { + _ = try RadrootsExternalActionDestination.nostr("nostr:npub1qq q") + } +} + +@Test func appleMapsDestinationBuildsSafeMapsUrls() throws { + let coordinate = try RadrootsLocationCoordinate(latitude: 49.2827, longitude: -123.1207) + let destination = try RadrootsExternalActionDestination.appleMaps( + coordinate: coordinate, + label: "Field Site" + ) + + #expect(destination.kind == .appleMaps) + #expect(destination.url?.scheme == "https") + #expect(destination.url?.host == "maps.apple.com") + #expect(destination.url?.absoluteString.contains("ll=49.2827,-123.1207") == true) + #expect(destination.url?.absoluteString.contains("q=Field%20Site") == true) + + _ = try RadrootsExternalActionDestination.appleMaps("https://maps.apple.com/?q=Field") + + #expect(throws: RadrootsExternalActionError.blockedByPolicy("apple maps urls must use https://maps.apple.com")) { + _ = try RadrootsExternalActionDestination.appleMaps("https://example.com/maps") + } +} + +@Test func externalActionRequestPreservesDestination() throws { + let destination = try RadrootsExternalActionDestination.web("https://radroots.org") + let request = RadrootsExternalActionRequest(destination: destination) + let capability = RadrootsExternalActionCapability(destination: destination, canOpen: true) + + #expect(request.destination == destination) + #expect(capability.destination == destination) + #expect(capability.canOpen) +}