apple_kit

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

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 }