apple_kit

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

RadrootsExternalActions.swift (7550B)


      1 import Foundation
      2 
      3 public enum RadrootsExternalActionDestinationKind: String, Sendable, Equatable, Hashable,
      4     CaseIterable
      5 {
      6     case appSettings
      7     case web
      8     case nostr
      9     case appleMaps
     10 }
     11 
     12 public struct RadrootsExternalActionDestination: Sendable, Equatable, Hashable {
     13     public let kind: RadrootsExternalActionDestinationKind
     14     public let url: URL?
     15 
     16     private init(kind: RadrootsExternalActionDestinationKind, url: URL?) {
     17         self.kind = kind
     18         self.url = url
     19     }
     20 
     21     public static let appSettings = RadrootsExternalActionDestination(kind: .appSettings, url: nil)
     22 
     23     public static func web(_ value: String) throws -> Self {
     24         try Self(kind: .web, url: RadrootsExternalActionValidation.normalizedWebURL(value))
     25     }
     26 
     27     public static func nostr(_ value: String) throws -> Self {
     28         try Self(kind: .nostr, url: RadrootsExternalActionValidation.normalizedNostrURI(value))
     29     }
     30 
     31     public static func appleMaps(_ value: String) throws -> Self {
     32         try Self(kind: .appleMaps, url: RadrootsExternalActionValidation.normalizedAppleMapsURL(value))
     33     }
     34 
     35     public static func appleMaps(
     36         coordinate: RadrootsLocationCoordinate,
     37         label: String? = nil
     38     ) throws -> Self {
     39         try Self(
     40             kind: .appleMaps,
     41             url: RadrootsExternalActionValidation.appleMapsURL(coordinate: coordinate, label: label)
     42         )
     43     }
     44 }
     45 
     46 public struct RadrootsExternalActionRequest: Sendable, Equatable, Hashable {
     47     public let destination: RadrootsExternalActionDestination
     48 
     49     public init(destination: RadrootsExternalActionDestination) {
     50         self.destination = destination
     51     }
     52 }
     53 
     54 public struct RadrootsExternalActionCapability: Sendable, Equatable, Hashable {
     55     public let destination: RadrootsExternalActionDestination
     56     public let canOpen: Bool
     57 
     58     public init(destination: RadrootsExternalActionDestination, canOpen: Bool) {
     59         self.destination = destination
     60         self.canOpen = canOpen
     61     }
     62 }
     63 
     64 public enum RadrootsExternalActionError: Error, Equatable, Sendable {
     65     case invalidRequest
     66     case blockedByPolicy
     67     case unavailable
     68     case transientFailure
     69     case permanentFailure
     70 }
     71 
     72 extension RadrootsExternalActionError: LocalizedError {
     73     public var errorDescription: String? {
     74         switch self {
     75         case .invalidRequest: "The external action request is invalid."
     76         case .blockedByPolicy: "The external action is blocked by policy."
     77         case .unavailable: "The external action is unavailable."
     78         case .transientFailure: "The external action could not be completed temporarily."
     79         case .permanentFailure: "The external action could not be completed."
     80         }
     81     }
     82 }
     83 
     84 public protocol RadrootsExternalActions: Sendable {
     85     func canOpen(_ destination: RadrootsExternalActionDestination) async
     86         -> RadrootsExternalActionCapability
     87     func open(_ request: RadrootsExternalActionRequest) async throws
     88 }
     89 
     90 public enum RadrootsExternalActionValidation {
     91     public static func normalizedWebURL(_ value: String) throws -> URL {
     92         let trimmed = try trimmedNonEmpty(value, field: "web url")
     93         try rejectWhitespaceOrControl(trimmed, field: "web url")
     94         guard let components = URLComponents(string: trimmed),
     95               components.scheme?.lowercased() == "https",
     96               components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
     97               components.user == nil,
     98               components.password == nil,
     99               let url = components.url
    100         else {
    101             throw RadrootsExternalActionError.blockedByPolicy
    102         }
    103         return url
    104     }
    105 
    106     public static func normalizedNostrURI(_ value: String) throws -> URL {
    107         let trimmed = try trimmedNonEmpty(value, field: "nostr uri")
    108         try rejectWhitespaceOrControl(trimmed, field: "nostr uri")
    109         guard trimmed.lowercased().hasPrefix("nostr:") else {
    110             throw RadrootsExternalActionError.blockedByPolicy
    111         }
    112         let payload = String(trimmed.dropFirst("nostr:".count))
    113         guard !payload.isEmpty else {
    114             throw RadrootsExternalActionError.invalidRequest
    115         }
    116         guard payload.range(of: "^[A-Za-z0-9]+$", options: .regularExpression) != nil else {
    117             throw RadrootsExternalActionError.invalidRequest
    118         }
    119         let normalizedPayload = payload.lowercased()
    120         if normalizedPayload.hasPrefix("nsec") {
    121             throw RadrootsExternalActionError.blockedByPolicy
    122         }
    123         let allowedPrefixes = ["npub1", "nprofile1", "note1", "nevent1", "naddr1", "nrelay1"]
    124         guard allowedPrefixes.contains(where: { normalizedPayload.hasPrefix($0) }) else {
    125             throw RadrootsExternalActionError.blockedByPolicy
    126         }
    127         guard let url = URL(string: "nostr:\(normalizedPayload)") else {
    128             throw RadrootsExternalActionError.invalidRequest
    129         }
    130         return url
    131     }
    132 
    133     public static func normalizedAppleMapsURL(_ value: String) throws -> URL {
    134         let trimmed = try trimmedNonEmpty(value, field: "apple maps url")
    135         try rejectWhitespaceOrControl(trimmed, field: "apple maps url")
    136         guard let components = URLComponents(string: trimmed),
    137               components.scheme?.lowercased() == "https",
    138               components.host?.lowercased() == "maps.apple.com",
    139               components.user == nil,
    140               components.password == nil,
    141               let url = components.url
    142         else {
    143             throw RadrootsExternalActionError.blockedByPolicy
    144         }
    145         return url
    146     }
    147 
    148     public static func appleMapsURL(
    149         coordinate: RadrootsLocationCoordinate,
    150         label: String? = nil
    151     ) throws -> URL {
    152         var components = URLComponents()
    153         components.scheme = "https"
    154         components.host = "maps.apple.com"
    155         components.path = "/"
    156         var queryItems = [
    157             URLQueryItem(
    158                 name: "ll",
    159                 value: "\(coordinate.latitude),\(coordinate.longitude)"
    160             )
    161         ]
    162         if let label {
    163             let normalizedLabel = try normalizedOptionalLabel(label)
    164             if let normalizedLabel {
    165                 queryItems.append(URLQueryItem(name: "q", value: normalizedLabel))
    166             }
    167         }
    168         components.queryItems = queryItems
    169         guard let url = components.url else {
    170             throw RadrootsExternalActionError.permanentFailure
    171         }
    172         return url
    173     }
    174 
    175     static func trimmedNonEmpty(_ value: String, field: String) throws -> String {
    176         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
    177         guard !trimmed.isEmpty else {
    178             throw RadrootsExternalActionError.invalidRequest
    179         }
    180         return trimmed
    181     }
    182 
    183     static func rejectWhitespaceOrControl(_ value: String, field: String) throws {
    184         let forbidden = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
    185         guard value.unicodeScalars.allSatisfy({ !forbidden.contains($0) }) else {
    186             throw RadrootsExternalActionError.invalidRequest
    187         }
    188     }
    189 
    190     static func normalizedOptionalLabel(_ value: String) throws -> String? {
    191         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
    192         guard !trimmed.isEmpty else {
    193             return nil
    194         }
    195         let forbidden = CharacterSet.controlCharacters
    196         guard trimmed.unicodeScalars.allSatisfy({ !forbidden.contains($0) }) else {
    197             throw RadrootsExternalActionError.invalidRequest
    198         }
    199         return trimmed
    200     }
    201 }