Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

CKeyPayload.swift

v0.1.0-build.85 Latest Commit ede38f5

Size
6.7 KB
Lines
120
Language
swift
SHA-256
e707db8adcf2…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1import Foundation
2
3/// What a decoded text payload offers to do (spec/CKey-Payload-Conventions.md).
4///
5/// Classification never acts. The host shows the payload and the proposed
6/// action, and acts only when the user taps it, stating the target first
7/// ("Open example.com", "Call +1 555..."). Only the allowlisted forms get an
8/// action; `javascript:`, `data:`, `file:`, `intent:`, custom app schemes and
9/// everything else return `nil` (show as plain text).
10public enum CKeyPayloadAction: Equatable, Sendable {
11 /// `http:` / `https:` with a host. Show `url.host` before opening.
12 case web(URL)
13 /// `mailto:` containing an `@`.
14 case email(URL, address: String)
15 /// `tel:` with at least three digits; `number` keeps only `+` and digits.
16 case call(URL, number: String)
17 /// `sms:` with at least three digits.
18 case message(URL, number: String)
19 /// `geo:` (RFC 5870) with valid coordinates. `mapURL` is a
20 /// maps.apple.com link (`ll=lat,lon`, plus `q=` for the label).
21 case geo(latitude: Double, longitude: Double, label: String?, mapURL: URL)
22 /// `address:` (CKey convention): non-empty lines joined with ", ".
23 /// `mapURL` is a maps.apple.com `?address=` link.
24 case address(String, mapURL: URL)
25 /// A complete vCard (`BEGIN:VCARD` ... `END:VCARD`). Offer to add the
26 /// contact, for example by sharing it as a .vcf file.
27 case contact(vCard: String, name: String)
28}
29
30/// Payload interpretation helpers. These never perform an action.
31public enum CKeyPayload {
32 /// The payload as text when it is valid UTF-8 and contains no control
33 /// characters other than tab, LF and CR (CR LF is accepted); otherwise
34 /// `nil` (show the payload as binary, for example as hex).
35 public static func text(from bytes: Data) -> String? {
36 guard !bytes.isEmpty, let text = String(data: bytes, encoding: .utf8) else { return nil }
37 // Only C0 controls (except tab, LF, CR), DEL and C1 controls make a
38 // payload binary, as the web reader does. Format characters (ZWJ in
39 // emoji sequences, soft hyphens, bidi marks, BOM) are ordinary text.
40 let printable = text.unicodeScalars.allSatisfy { scalar in
41 let v = scalar.value
42 if scalar == "\n" || scalar == "\r" || scalar == "\t" { return true }
43 return !(v < 0x20 || v == 0x7F || (0x80...0x9F).contains(v))
44 }
45 return printable ? text : nil
46 }
47
48 /// Lower-case hexadecimal, for showing binary payloads.
49 public static func hex(_ bytes: Data) -> String {
50 bytes.map { String(format: "%02x", $0) }.joined()
51 }
52
53 /// Classifies a text payload into a proposed action, or `nil` for plain
54 /// text. Schemes match case-insensitively; surrounding whitespace is ignored.
55 public static func classify(_ text: String) -> CKeyPayloadAction? {
56 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
57 if trimmed.uppercased().hasPrefix("BEGIN:VCARD") { return contactCard(trimmed) } // vCard 3.0/4.0, RFC 6350
58 guard let colon = trimmed.firstIndex(of: ":") else { return nil }
59 let scheme = trimmed[..<colon].lowercased()
60 let rest = String(trimmed[trimmed.index(after: colon)...])
61 let beforeQuery = String(rest.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first ?? "")
62 switch scheme {
63 case "http", "https":
64 guard let url = URL(string: trimmed), let host = url.host, !host.isEmpty else { return nil }
65 return .web(url)
66 case "mailto":
67 let address = beforeQuery.removingPercentEncoding ?? beforeQuery
68 guard address.contains("@"), let url = URL(string: trimmed) else { return nil }
69 return .email(url, address: address)
70 case "tel", "sms":
71 // The number ends at the first URI parameter or query (";ext=", "?body="),
72 // so parameters are never dialled as digits.
73 let numberPart = String(rest.split(whereSeparator: { $0 == "?" || $0 == ";" }).first ?? "")
74 let number = numberPart.filter { $0.isASCII && ($0.isNumber || $0 == "+") }
75 guard number.filter(\.isNumber).count >= 3, let dial = URL(string: "\(scheme):\(number)") else { return nil }
76 if scheme == "tel" { return .call(dial, number: number) }
77 return .message(URL(string: trimmed) ?? dial, number: number)
78 case "geo":
79 let coordinates = beforeQuery.split(separator: ";").first?.split(separator: ",") ?? []
80 guard coordinates.count >= 2,
81 let lat = Double(coordinates[0]), let lon = Double(coordinates[1]),
82 (-90.0...90.0).contains(lat), (-180.0...180.0).contains(lon) else { return nil }
83 let query = rest.split(separator: "?", maxSplits: 1).dropFirst().first.map(String.init) ?? ""
84 let label = URLComponents(string: "geo:x?" + query)?.queryItems?.first(where: { $0.name == "q" })?.value
85 var items = [URLQueryItem(name: "ll", value: "\(lat),\(lon)")]
86 if let label, !label.isEmpty { items.append(URLQueryItem(name: "q", value: label)) }
87 guard let url = mapsURL(items) else { return nil }
88 return .geo(latitude: lat, longitude: lon, label: (label?.isEmpty ?? true) ? nil : label, mapURL: url)
89 case "address":
90 let address = rest.split(whereSeparator: \.isNewline)
91 .map { $0.trimmingCharacters(in: .whitespaces) }
92 .filter { !$0.isEmpty }
93 .joined(separator: ", ")
94 guard !address.isEmpty, let url = mapsURL([URLQueryItem(name: "address", value: address)]) else { return nil }
95 return .address(address, mapURL: url)
96 default:
97 return nil
98 }
99 }
100
101 private static func mapsURL(_ items: [URLQueryItem]) -> URL? {
102 var components = URLComponents()
103 components.scheme = "https"
104 components.host = "maps.apple.com"
105 components.path = "/"
106 components.queryItems = items
107 return components.url
108 }
109
110 private static func contactCard(_ card: String) -> CKeyPayloadAction? {
111 // A complete card: BEGIN:VCARD on its own line and END:VCARD as the last line.
112 let lines = card.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces).uppercased() }
113 guard lines.count >= 2, lines.first == "BEGIN:VCARD", lines.last == "END:VCARD" else { return nil }
114 let name = card.split(whereSeparator: \.isNewline)
115 .first { $0.uppercased().hasPrefix("FN:") || $0.uppercased().hasPrefix("FN;") }
116 .flatMap { line in line.split(separator: ":", maxSplits: 1).dropFirst().first.map(String.init) }?
117 .trimmingCharacters(in: .whitespaces) ?? "contact"
118 return .contact(vCard: card, name: name.isEmpty ? "contact" : name)
119 }
120}