@hasna/economy 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ // Shows the Economy pairing code as a QR code in the terminal, for the iPhone app's Connect → Scan code.
2
+ // Run it through native/Tools/pair-qr.py. The one-use code is issued inside the owner's Terminal:
3
+ // it is never an argument, never written to a file, and the screen and its
4
+ // scrollback are cleared once the code has been scanned.
5
+ //
6
+ // QR codes come from CoreImage's built-in CIQRCodeGenerator, so no extra tool is needed.
7
+ // `--self-test` renders a synthetic code the same way and decodes it with CoreImage's detector.
8
+ import CoreImage
9
+ import Foundation
10
+
11
+ /// The same text as Pairing.code(server:key:) in the app.
12
+ func pairingCode(server: String, key: String) -> String {
13
+ let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
14
+ func escaped(_ text: String) -> String { text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? "" }
15
+ return "hasna-economy://connect?server=\(escaped(server))&key=\(escaped(key))"
16
+ }
17
+
18
+ /// The QR modules, row by row from the top, true for dark. One pixel per module from CoreImage.
19
+ func qrModules(_ text: String) -> [[Bool]]? {
20
+ guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
21
+ filter.setValue(Data(text.utf8), forKey: "inputMessage")
22
+ filter.setValue("M", forKey: "inputCorrectionLevel")
23
+ guard let image = filter.outputImage else { return nil }
24
+ let extent = image.extent.integral
25
+ let width = Int(extent.width)
26
+ let height = Int(extent.height)
27
+ var pixels = [UInt8](repeating: 0, count: width * height * 4)
28
+ CIContext().render(image, toBitmap: &pixels, rowBytes: width * 4, bounds: extent, format: .RGBA8,
29
+ colorSpace: CGColorSpaceCreateDeviceRGB())
30
+ return (0..<height).map { y in (0..<width).map { x in pixels[(y * width + x) * 4] < 128 } }
31
+ }
32
+
33
+ /// Adds a light margin of `quiet` modules on every side.
34
+ func withQuietZone(_ modules: [[Bool]], _ quiet: Int = 4) -> [[Bool]] {
35
+ let width = (modules.first?.count ?? 0) + quiet * 2
36
+ let blank = [Bool](repeating: false, count: width)
37
+ let pad = [Bool](repeating: false, count: quiet)
38
+ return Array(repeating: blank, count: quiet) + modules.map { pad + $0 + pad } + Array(repeating: blank, count: quiet)
39
+ }
40
+
41
+ /// Two module rows per text line with half blocks, black on white whatever the terminal theme.
42
+ func terminalLines(_ modules: [[Bool]]) -> [String] {
43
+ var rows = modules
44
+ if rows.count % 2 == 1 { rows.append([Bool](repeating: false, count: rows.first?.count ?? 0)) }
45
+ return stride(from: 0, to: rows.count, by: 2).map { y in
46
+ let line = zip(rows[y], rows[y + 1]).map { top, bottom -> String in
47
+ switch (top, bottom) {
48
+ case (true, true): "█"
49
+ case (true, false): "▀"
50
+ case (false, true): "▄"
51
+ case (false, false): " "
52
+ }
53
+ }.joined()
54
+ return "\u{1B}[30;107m" + line + "\u{1B}[0m"
55
+ }
56
+ }
57
+
58
+ /// Reads the modules back from the terminal lines, to prove they encode exactly the code.
59
+ func modulesFromLines(_ lines: [String]) -> [[Bool]] {
60
+ lines.flatMap { line -> [[Bool]] in
61
+ let body = line.replacingOccurrences(of: "\u{1B}[30;107m", with: "").replacingOccurrences(of: "\u{1B}[0m", with: "")
62
+ let top = body.map { $0 == "█" || $0 == "▀" }
63
+ let bottom = body.map { $0 == "█" || $0 == "▄" }
64
+ return [top, bottom]
65
+ }
66
+ }
67
+
68
+ /// Decodes a module grid with CoreImage's QR detector, drawn as the terminal shows it.
69
+ func decode(_ modules: [[Bool]]) -> String? {
70
+ let scale = 8
71
+ let height = modules.count
72
+ let width = modules.first?.count ?? 0
73
+ var pixels = [UInt8](repeating: 255, count: width * scale * height * scale)
74
+ for (y, row) in modules.enumerated() {
75
+ for (x, dark) in row.enumerated() where dark {
76
+ for dy in 0..<scale {
77
+ for dx in 0..<scale { pixels[(y * scale + dy) * width * scale + x * scale + dx] = 0 }
78
+ }
79
+ }
80
+ }
81
+ guard let provider = CGDataProvider(data: Data(pixels) as CFData),
82
+ let cgImage = CGImage(width: width * scale, height: height * scale, bitsPerComponent: 8, bitsPerPixel: 8,
83
+ bytesPerRow: width * scale, space: CGColorSpaceCreateDeviceGray(),
84
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue),
85
+ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent),
86
+ let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
87
+ else { return nil }
88
+ return (detector.features(in: CIImage(cgImage: cgImage)).first as? CIQRCodeFeature)?.messageString
89
+ }
90
+
91
+ /// A QR finder pattern sits in the top-left, top-right and bottom-left corners, never bottom-right.
92
+ func isUpright(_ modules: [[Bool]]) -> Bool {
93
+ let size = modules.count
94
+ func finder(_ x: Int, _ y: Int) -> Bool {
95
+ (0..<7).allSatisfy { i in modules[y][x + i] && modules[y + 6][x + i] && modules[y + i][x] && modules[y + i][x + 6] }
96
+ && modules[y + 3][x + 3]
97
+ }
98
+ guard size >= 21, modules.allSatisfy({ $0.count == size }) else { return false }
99
+ return finder(0, 0) && finder(size - 7, 0) && finder(0, size - 7) && !finder(size - 7, size - 7)
100
+ }
101
+
102
+ func fail(_ message: String, _ code: Int32) -> Never {
103
+ FileHandle.standardError.write(Data((message + "\n").utf8))
104
+ exit(code)
105
+ }
106
+
107
+ /// Check the real ancestry as well as the TTY: a captured harness PTY is still a TTY.
108
+ /// This prevents accidental transcript output, not access by other owner processes.
109
+ func ownerTerminal(environment: [String: String], tty: Bool, parent: Int32,
110
+ lookup: (Int32) -> (Int32, String)?) -> Bool {
111
+ guard tty else { return false }
112
+ for key in ["CODEX_THREAD_ID", "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "CLAUDECODE", "SUMI_SESSION_ID", "OPENCODE_SESSION_ID"] {
113
+ if let value = environment[key], !value.isEmpty { return false }
114
+ }
115
+ var pid = parent
116
+ for _ in 0..<12 {
117
+ guard let (next, executable) = lookup(pid) else { return false }
118
+ if executable == "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal" { return true }
119
+ guard ["Python", "python3", "bash", "zsh", "login", "bun", "secrets", "swift", "swift-frontend"]
120
+ .contains(URL(fileURLWithPath: executable).lastPathComponent), next > 1, next != pid else { return false }
121
+ pid = next
122
+ }
123
+ return false
124
+ }
125
+
126
+ func parentProcess(_ pid: Int32) -> (Int32, String)? {
127
+ let process = Process()
128
+ let output = Pipe()
129
+ process.executableURL = URL(fileURLWithPath: "/bin/ps")
130
+ process.arguments = ["-p", String(pid), "-o", "ppid=", "-o", "comm="]
131
+ process.standardOutput = output
132
+ process.standardError = FileHandle.nullDevice
133
+ do { try process.run() } catch { return nil }
134
+ let data = output.fileHandleForReading.readDataToEndOfFile()
135
+ process.waitUntilExit()
136
+ guard process.terminationStatus == 0, data.count <= 4096,
137
+ let text = String(data: data, encoding: .utf8) else { return nil }
138
+ let fields = text.trimmingCharacters(in: .whitespacesAndNewlines).split(maxSplits: 1, whereSeparator: { $0.isWhitespace })
139
+ guard fields.count == 2, let next = Int32(fields[0]) else { return nil }
140
+ return (next, String(fields[1]))
141
+ }
142
+
143
+ func selfTest() {
144
+ let terminal = "/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal"
145
+ guard !ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { _ in (200, "/runtime/codex") }),
146
+ !ownerTerminal(environment: [:], tty: false, parent: 100, lookup: { _ in (200, terminal) }),
147
+ !ownerTerminal(environment: ["CODEX_THREAD_ID": "fixture"], tty: true, parent: 100, lookup: { _ in (200, terminal) }),
148
+ !ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { _ in nil }),
149
+ ownerTerminal(environment: [:], tty: true, parent: 100, lookup: { pid in
150
+ pid == 100 ? (200, "/usr/bin/python3") : (300, terminal)
151
+ }) else { fail("pair-qr self-test: FAIL (owner Terminal boundary)", 1) }
152
+ // A synthetic key with characters that need escaping; not a credential.
153
+ let key = "selftest-" + UUID().uuidString + "+/=&"
154
+ let code = pairingCode(server: "https://api.hasna.com/economy", key: key)
155
+ guard let modules = qrModules(code) else { fail("pair-qr self-test: FAIL (CoreImage made no QR code)", 1) }
156
+ let trimmed = trimQuietZone(modules)
157
+ guard isUpright(trimmed) else { fail("pair-qr self-test: FAIL (the code is mirrored or rotated)", 1) }
158
+ let lines = terminalLines(withQuietZone(trimmed))
159
+ guard decode(modulesFromLines(lines)) == code else { fail("pair-qr self-test: FAIL (the terminal rendering does not decode to the code)", 1) }
160
+ print("pair-qr self-test: PASS (\(trimmed.count)x\(trimmed.count) modules, \(lines.count) terminal lines, decoded to the exact pairing code)")
161
+ }
162
+
163
+ /// CoreImage adds its own margin; drop it so the finder patterns sit at the edges.
164
+ func trimQuietZone(_ modules: [[Bool]]) -> [[Bool]] {
165
+ let rows = modules.indices.filter { modules[$0].contains(true) }
166
+ let columns = (0..<(modules.first?.count ?? 0)).filter { x in modules.contains { $0[x] } }
167
+ guard let top = rows.first, let bottom = rows.last, let left = columns.first, let right = columns.last else { return modules }
168
+ return modules[top...bottom].map { Array($0[left...right]) }
169
+ }
170
+
171
+ /// Clears the screen and the terminal's scrollback.
172
+ let clearSequence = "\u{1B}[2J\u{1B}[3J\u{1B}[H"
173
+
174
+ /// Ctrl-C, a hang-up or a kill also clear the code before exiting. Kept alive for the whole run.
175
+ var signalSources: [DispatchSourceSignal] = []
176
+
177
+ func clearOnSignals() {
178
+ for number in [SIGINT, SIGTERM, SIGHUP] {
179
+ signal(number, SIG_IGN)
180
+ let source = DispatchSource.makeSignalSource(signal: number, queue: .global())
181
+ source.setEventHandler {
182
+ FileHandle.standardOutput.write(Data(clearSequence.utf8))
183
+ exit(128 + number)
184
+ }
185
+ source.resume()
186
+ signalSources.append(source)
187
+ }
188
+ }
189
+
190
+ func show() {
191
+ let environment = ProcessInfo.processInfo.environment
192
+ guard let key = environment["ECONOMY_PAIR_CODE"], !key.isEmpty else { fail("ECONOMY_PAIR_CODE is not set", 2) }
193
+ unsetenv("ECONOMY_PAIR_CODE")
194
+ let server = environment["ECONOMY_PAIR_SERVER"] ?? "https://api.hasna.com/economy"
195
+ guard ownerTerminal(environment: environment, tty: isatty(STDOUT_FILENO) != 0 && isatty(STDIN_FILENO) != 0,
196
+ parent: getppid(), lookup: parentProcess) else {
197
+ fail("Refusing to show a connection code outside the direct owner Terminal path.", 3)
198
+ }
199
+ guard let modules = qrModules(pairingCode(server: server, key: key)) else { fail("CoreImage made no QR code", 1) }
200
+ let lines = terminalLines(withQuietZone(trimQuietZone(modules)))
201
+ guard key.range(of: "^hec_[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil,
202
+ server == "https://api.hasna.com/economy" else { fail("Invalid Economy pairing request", 2) }
203
+ clearOnSignals()
204
+ let lifetime = max(1, min(290, Int(environment["ECONOMY_PAIR_TTL"] ?? "290") ?? 290))
205
+ DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(lifetime)) {
206
+ FileHandle.standardOutput.write(Data(clearSequence.utf8))
207
+ exit(0)
208
+ }
209
+ print("")
210
+ lines.forEach { print(" " + $0) }
211
+ print("\n Economy → Connect → Scan code. Server: \(server)")
212
+ print(" Press Return when the iPhone has connected; the screen and scrollback are then cleared (Ctrl-C clears too). This screen closes within five minutes.")
213
+ if let tty = FileHandle(forReadingAtPath: "/dev/tty") {
214
+ _ = tty.availableData
215
+ }
216
+ print(clearSequence, terminator: "")
217
+ }
218
+
219
+ if CommandLine.arguments.contains("--self-test") {
220
+ selfTest()
221
+ } else {
222
+ show()
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,6 +33,8 @@
33
33
  "files": [
34
34
  "dist",
35
35
  "docs",
36
+ "native/Tools/pair-qr.py",
37
+ "native/Tools/pair-qr.swift",
36
38
  "postinstall.js",
37
39
  "LICENSE",
38
40
  "README.md",