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

CKeyReaderApp.swift

v0.1.0-build.85 Latest Commit ede38f5

Size
11 KB
Lines
211
Language
swift
SHA-256
001a785a2b63…
View on GitHub

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

1import SwiftUI
2
3@main
4struct CKeyReaderApp: App {
5 var body: some Scene {
6 WindowGroup {
7 ReaderView()
8 .preferredColorScheme(.dark)
9 }
10 }
11}
12
13struct ReaderView: View {
14 @StateObject private var scanner = CameraScanner()
15 private var softwareVersion: String {
16 guard let version = ckey_get_software_version() else { return "development build" }
17 return String(cString: version)
18 }
19
20 var body: some View {
21 VStack(spacing: 18) {
22 HStack {
23 VStack(alignment: .leading, spacing: 4) {
24 Text("CKey Reader").font(.largeTitle.bold())
25 Text("Profiles 0, 0.1 and 1–3 · \(softwareVersion)")
26 .font(.caption).foregroundStyle(.secondary)
27 }
28 Spacer()
29 Circle().fill(scanner.isRunning ? Color.green : Color.orange).frame(width: 10, height: 10)
30 }
31
32 ZStack {
33 CameraPreview(session: scanner.session)
34 RoundedRectangle(cornerRadius: 28)
35 .stroke(.white.opacity(0.8), lineWidth: 2)
36 .padding(38)
37 .allowsHitTesting(false)
38 VStack {
39 Spacer()
40 Text("Fit the full CKey inside the frame")
41 .font(.footnote.weight(.medium))
42 .padding(.horizontal, 14).padding(.vertical, 8)
43 .background(.black.opacity(0.65), in: Capsule())
44 .padding(.bottom, 54)
45 }
46 }
47 .aspectRatio(0.78, contentMode: .fit)
48 .clipShape(RoundedRectangle(cornerRadius: 28))
49 .overlay(RoundedRectangle(cornerRadius: 28).stroke(.white.opacity(0.12)))
50
51 VStack(alignment: .leading, spacing: 8) {
52 Label(scanner.status, systemImage: scanner.decodedHex == nil ? "viewfinder" : "checkmark.circle.fill")
53 .font(.headline)
54 .foregroundColor(scanner.decodedHex == nil ? Color.primary : Color.green)
55 if let hex = scanner.decodedHex {
56 Text(scanner.decodedProfile).font(.caption.weight(.semibold)).foregroundStyle(.green)
57 if let text = scanner.decodedText {
58 Text(text).font(.body.weight(.medium)).textSelection(.enabled)
59 .padding(12).frame(maxWidth: .infinity, alignment: .leading)
60 .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
61 if let action = scanner.decodedAction {
62 PayloadActionButton(action: action)
63 .font(.callout.weight(.semibold))
64 .lineLimit(2)
65 }
66 Text("\(text.utf8.count) bytes · hex \(hex)")
67 .font(.caption2.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
68 .lineLimit(2).truncationMode(.middle)
69 } else {
70 Text(hex).font(.system(.callout, design: .monospaced)).textSelection(.enabled)
71 .padding(12).frame(maxWidth: .infinity, alignment: .leading)
72 .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
73 Text("Binary payload · \(hex.count / 2) bytes").font(.caption2).foregroundStyle(.secondary)
74 }
75 Text("Detection: \(String(format: "%.1f", scanner.detectionMilliseconds)) ms · CKey: \(String(format: "%.1f", scanner.decodeMilliseconds)) ms")
76 .font(.caption.monospacedDigit()).foregroundStyle(.secondary)
77 } else {
78 Text("The phone finds the symbol, then the C++ core checks its keys, error correction, and checksum.")
79 .font(.subheadline).foregroundStyle(.secondary)
80 Text("Detection: \(String(format: "%.1f", scanner.detectionMilliseconds)) ms · CKey: \(String(format: "%.1f", scanner.decodeMilliseconds)) ms · candidates: \(scanner.candidateCount)")
81 .font(.caption.monospacedDigit()).foregroundStyle(.secondary)
82 if !scanner.candidateSizes.isEmpty {
83 Text("Border components: \(scanner.candidateSizes)")
84 .font(.caption.monospacedDigit()).foregroundStyle(.tertiary)
85 }
86 Text(scanner.scanDetails)
87 .font(.caption.monospacedDigit())
88 .foregroundStyle(.secondary)
89 .textSelection(.enabled)
90 .frame(maxWidth: .infinity, alignment: .leading)
91 .padding(10)
92 .background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
93 }
94 }
95 .frame(maxWidth: .infinity, alignment: .leading)
96
97 Button {
98 scanner.scanAgain()
99 } label: {
100 Label(scanner.decodedHex == nil ? "Scanning" : "Scan Again", systemImage: "arrow.clockwise")
101 .frame(maxWidth: .infinity).padding(.vertical, 12)
102 }
103 .buttonStyle(.borderedProminent)
104 .disabled(scanner.decodedHex == nil)
105
106 Spacer(minLength: 0)
107 }
108 .padding()
109 .task { scanner.start() }
110 .onDisappear { scanner.stop() }
111 }
112}
113
114/// What a decoded text payload offers to do, per spec/CKey-Payload-Conventions.md.
115/// Only allowlisted forms get an action, and nothing happens until the user
116/// taps it; everything else (including javascript:, data:, file:, and unknown
117/// app schemes) is shown as plain text only.
118enum PayloadAction {
119 case web(URL)
120 case email(URL, address: String)
121 case call(URL, number: String)
122 case message(URL, number: String)
123 case map(URL, label: String)
124 case contact(file: URL, name: String)
125
126 static func interpret(_ text: String) -> PayloadAction? {
127 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
128 if trimmed.uppercased().hasPrefix("BEGIN:VCARD") { return contactCard(trimmed) }
129 guard let colon = trimmed.firstIndex(of: ":") else { return nil }
130 let scheme = trimmed[..<colon].lowercased()
131 let rest = String(trimmed[trimmed.index(after: colon)...])
132 let beforeQuery = String(rest.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first ?? "")
133 switch scheme {
134 case "http", "https":
135 guard let url = URL(string: trimmed), url.host != nil else { return nil }
136 return .web(url)
137 case "mailto":
138 let address = beforeQuery.removingPercentEncoding ?? beforeQuery
139 guard address.contains("@"), let url = URL(string: trimmed) else { return nil }
140 return .email(url, address: address)
141 case "tel", "sms":
142 // E.164 is recommended (tel:+15551234567); keep only dialable characters.
143 let number = beforeQuery.filter { $0.isASCII && ($0.isNumber || $0 == "+") }
144 guard number.filter(\.isNumber).count >= 3, let dial = URL(string: "\(scheme):\(number)") else { return nil }
145 if scheme == "tel" { return .call(dial, number: number) }
146 return .message(URL(string: trimmed) ?? dial, number: number)
147 case "geo":
148 // RFC 5870: geo:lat,lon[,alt][;params][?q=label]
149 let coordinates = beforeQuery.split(separator: ";").first?.split(separator: ",") ?? []
150 guard coordinates.count >= 2, let lat = Double(coordinates[0]), let lon = Double(coordinates[1]),
151 (-90.0...90.0).contains(lat), (-180.0...180.0).contains(lon) else { return nil }
152 var items = [URLQueryItem(name: "ll", value: "\(lat),\(lon)")]
153 let label = URLComponents(string: "geo:x?" + (rest.split(separator: "?", maxSplits: 1).dropFirst().first.map(String.init) ?? ""))?
154 .queryItems?.first(where: { $0.name == "q" })?.value
155 if let label, !label.isEmpty { items.append(URLQueryItem(name: "q", value: label)) }
156 return maps(items, label: label ?? String(format: "%.5f, %.5f", lat, lon))
157 case "address":
158 // CKey convention: the rest of the payload is a free-text postal
159 // address (UTF-8, no percent-encoding); line breaks separate lines.
160 let address = rest.split(whereSeparator: \.isNewline)
161 .map { $0.trimmingCharacters(in: .whitespaces) }
162 .filter { !$0.isEmpty }
163 .joined(separator: ", ")
164 guard !address.isEmpty else { return nil }
165 return maps([URLQueryItem(name: "address", value: address)], label: address)
166 default:
167 return nil
168 }
169 }
170
171 /// iOS exposes no public "default map app" API; maps.apple.com links are
172 /// routed by the system to Maps.
173 private static func maps(_ items: [URLQueryItem], label: String) -> PayloadAction? {
174 var components = URLComponents()
175 components.scheme = "https"; components.host = "maps.apple.com"; components.path = "/"
176 components.queryItems = items
177 guard let url = components.url else { return nil }
178 return .map(url, label: label)
179 }
180
181 /// vCard payloads are offered through the share sheet as a .vcf file,
182 /// which lets the user review and add the card in Contacts.
183 private static func contactCard(_ card: String) -> PayloadAction? {
184 let name = card.split(whereSeparator: \.isNewline)
185 .first { $0.uppercased().hasPrefix("FN:") || $0.uppercased().hasPrefix("FN;") }
186 .flatMap { line in line.split(separator: ":", maxSplits: 1).dropFirst().first.map(String.init) } ?? "contact"
187 let file = FileManager.default.temporaryDirectory.appendingPathComponent("ckey-contact.vcf")
188 do { try card.write(to: file, atomically: true, encoding: .utf8) } catch { return nil }
189 return .contact(file: file, name: name)
190 }
191}
192
193struct PayloadActionButton: View {
194 let action: PayloadAction
195 var body: some View {
196 switch action {
197 case .web(let url):
198 Link(destination: url) { Label("Open \(url.host ?? "link")", systemImage: "safari") }
199 case .email(let url, let address):
200 Link(destination: url) { Label("Email \(address)", systemImage: "envelope") }
201 case .call(let url, let number):
202 Link(destination: url) { Label("Call \(number)", systemImage: "phone") }
203 case .message(let url, let number):
204 Link(destination: url) { Label("Message \(number)", systemImage: "message") }
205 case .map(let url, let label):
206 Link(destination: url) { Label("Show \(label) in Maps", systemImage: "map") }
207 case .contact(let file, let name):
208 ShareLink(item: file) { Label("Add \(name) to Contacts…", systemImage: "person.crop.circle.badge.plus") }
209 }
210 }
211}