RadrootsExternalActions.swift (8051B)
1 import Foundation 2 3 public enum RadrootsExternalActionDestinationKind: String, Sendable, Equatable, Hashable, CaseIterable { 4 case appSettings 5 case web 6 case nostr 7 case appleMaps 8 } 9 10 public struct RadrootsExternalActionDestination: Sendable, Equatable, Hashable { 11 public let kind: RadrootsExternalActionDestinationKind 12 public let url: URL? 13 14 private init(kind: RadrootsExternalActionDestinationKind, url: URL?) { 15 self.kind = kind 16 self.url = url 17 } 18 19 public static let appSettings = RadrootsExternalActionDestination(kind: .appSettings, url: nil) 20 21 public static func web(_ value: String) throws -> Self { 22 try Self(kind: .web, url: RadrootsExternalActionValidation.normalizedWebURL(value)) 23 } 24 25 public static func nostr(_ value: String) throws -> Self { 26 try Self(kind: .nostr, url: RadrootsExternalActionValidation.normalizedNostrURI(value)) 27 } 28 29 public static func appleMaps(_ value: String) throws -> Self { 30 try Self(kind: .appleMaps, url: RadrootsExternalActionValidation.normalizedAppleMapsURL(value)) 31 } 32 33 public static func appleMaps( 34 coordinate: RadrootsLocationCoordinate, 35 label: String? = nil 36 ) throws -> Self { 37 try Self( 38 kind: .appleMaps, 39 url: RadrootsExternalActionValidation.appleMapsURL(coordinate: coordinate, label: label) 40 ) 41 } 42 } 43 44 public struct RadrootsExternalActionRequest: Sendable, Equatable, Hashable { 45 public let destination: RadrootsExternalActionDestination 46 47 public init(destination: RadrootsExternalActionDestination) { 48 self.destination = destination 49 } 50 } 51 52 public struct RadrootsExternalActionCapability: Sendable, Equatable, Hashable { 53 public let destination: RadrootsExternalActionDestination 54 public let canOpen: Bool 55 56 public init(destination: RadrootsExternalActionDestination, canOpen: Bool) { 57 self.destination = destination 58 self.canOpen = canOpen 59 } 60 } 61 62 public enum RadrootsExternalActionError: Error, Equatable, Sendable { 63 case invalidRequest(String) 64 case blockedByPolicy(String) 65 case unavailable(String) 66 case transientFailure(String) 67 case permanentFailure(String) 68 } 69 70 extension RadrootsExternalActionError: LocalizedError { 71 public var errorDescription: String? { 72 switch self { 73 case .invalidRequest(let message): 74 message 75 case .blockedByPolicy(let message): 76 message 77 case .unavailable(let message): 78 message 79 case .transientFailure(let message): 80 message 81 case .permanentFailure(let message): 82 message 83 } 84 } 85 } 86 87 public protocol RadrootsExternalActions: Sendable { 88 func canOpen(_ destination: RadrootsExternalActionDestination) async -> RadrootsExternalActionCapability 89 func open(_ request: RadrootsExternalActionRequest) async throws 90 } 91 92 public enum RadrootsExternalActionValidation { 93 public static func normalizedWebURL(_ value: String) throws -> URL { 94 let trimmed = try trimmedNonEmpty(value, field: "web url") 95 try rejectWhitespaceOrControl(trimmed, field: "web url") 96 guard let components = URLComponents(string: trimmed), 97 components.scheme?.lowercased() == "https", 98 components.host?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, 99 components.user == nil, 100 components.password == nil, 101 let url = components.url else { 102 throw RadrootsExternalActionError.blockedByPolicy("external web urls must use https with a host") 103 } 104 return url 105 } 106 107 public static func normalizedNostrURI(_ value: String) throws -> URL { 108 let trimmed = try trimmedNonEmpty(value, field: "nostr uri") 109 try rejectWhitespaceOrControl(trimmed, field: "nostr uri") 110 guard trimmed.lowercased().hasPrefix("nostr:") else { 111 throw RadrootsExternalActionError.blockedByPolicy("nostr uri must use the nostr scheme") 112 } 113 let payload = String(trimmed.dropFirst("nostr:".count)) 114 guard !payload.isEmpty else { 115 throw RadrootsExternalActionError.invalidRequest("nostr uri payload must not be empty") 116 } 117 guard payload.range(of: "^[A-Za-z0-9]+$", options: .regularExpression) != nil else { 118 throw RadrootsExternalActionError.invalidRequest("nostr uri payload must be bech32-like public text") 119 } 120 let normalizedPayload = payload.lowercased() 121 if normalizedPayload.hasPrefix("nsec") { 122 throw RadrootsExternalActionError.blockedByPolicy("nostr secret payloads cannot be opened externally") 123 } 124 let allowedPrefixes = ["npub1", "nprofile1", "note1", "nevent1", "naddr1", "nrelay1"] 125 guard allowedPrefixes.contains(where: { normalizedPayload.hasPrefix($0) }) else { 126 throw RadrootsExternalActionError.blockedByPolicy("nostr uri payload must be a public Nostr identifier") 127 } 128 guard let url = URL(string: "nostr:\(normalizedPayload)") else { 129 throw RadrootsExternalActionError.invalidRequest("nostr uri is not a valid url") 130 } 131 return url 132 } 133 134 public static func normalizedAppleMapsURL(_ value: String) throws -> URL { 135 let trimmed = try trimmedNonEmpty(value, field: "apple maps url") 136 try rejectWhitespaceOrControl(trimmed, field: "apple maps url") 137 guard let components = URLComponents(string: trimmed), 138 components.scheme?.lowercased() == "https", 139 components.host?.lowercased() == "maps.apple.com", 140 components.user == nil, 141 components.password == nil, 142 let url = components.url else { 143 throw RadrootsExternalActionError.blockedByPolicy("apple maps urls must use https://maps.apple.com") 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("failed to construct apple maps url") 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("\(field) must not be empty") 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("\(field) cannot contain whitespace or control characters") 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("apple maps label cannot contain control characters") 198 } 199 return trimmed 200 } 201 }