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 }