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
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | import SwiftUI |
| 2 | |
| 3 | @main |
| 4 | struct CKeyReaderApp: App { |
| 5 | var body: some Scene { |
| 6 | WindowGroup { |
| 7 | ReaderView() |
| 8 | .preferredColorScheme(.dark) |
| 9 | } |
| 10 | } |
| 11 | } |
| 12 | |
| 13 | struct 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. |
| 118 | enum 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 | |
| 193 | struct 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 | } |