PrintToVeraeTimeStamp/platform/macos/menubar/main.swift
Cursor Agent 86ebbbaea9
Fix the menu-bar helper so swiftc accepts it on macOS 14.
The status item types are visible to the menu class, and the JSON helper calls match its signature.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 16:20:46 +00:00

410 lines
14 KiB
Swift

import AppKit
import Foundation
// verae-menubar is the AppKit status item used when the Go menu bar cannot
// stay running. It lives in Verae NetPrint.app next to verae-print.
// Usage: verae-menubar [--smoke]
private let controlDefault = "http://127.0.0.1:8630"
private let logName = "Library/Logs/Verae/netprint-tray.log"
private let session: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 3
cfg.timeoutIntervalForResource = 8
cfg.waitsForConnectivity = false
return URLSession(configuration: cfg)
}()
private func logPath() -> String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return (home as NSString).appendingPathComponent(logName)
}
private func trayLog(_ msg: String) {
let line = ISO8601DateFormatter().string(from: Date()) + " verae-menubar " + msg + "\n"
_ = line.withCString { fputs($0, stderr) }
let path = logPath()
let dir = (path as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
if let data = line.data(using: .utf8) {
if FileManager.default.fileExists(atPath: path),
let handle = FileHandle(forWritingAtPath: path) {
handle.seekToEndOfFile()
try? handle.write(contentsOf: data)
try? handle.close()
} else {
FileManager.default.createFile(atPath: path, contents: data)
}
}
}
private func sibling(_ name: String) -> String {
var dir = (CommandLine.arguments[0] as NSString).deletingLastPathComponent
if dir.isEmpty || dir == "." {
dir = "/Applications/Verae NetPrint.app/Contents/MacOS"
}
return (dir as NSString).appendingPathComponent(name)
}
private func aquaSession() -> Bool {
if ProcessInfo.processInfo.environment["SECURITYSESSIONID"] != nil {
return true
}
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
p.arguments = ["managername"]
let pipe = Pipe()
p.standardOutput = pipe
try? p.run()
p.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let name = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return name == "Aqua"
}
struct PrinterStatus {
var running = false
var name = "Verae Timestamp"
var jobsToday = 0
var receipts = ""
var control = controlDefault
var address = ""
var port = 0
}
private func fetchStatus() -> PrinterStatus {
var st = PrinterStatus()
guard let url = URL(string: controlDefault + "/api/v1/status") else { return st }
let sem = DispatchSemaphore(value: 0)
var body: Data?
session.dataTask(with: url) { data, response, _ in
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
body = data
}
sem.signal()
}.resume()
_ = sem.wait(timeout: .now() + 3)
guard let body, let obj = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else {
return st
}
st.running = obj["running"] as? Bool ?? false
if let name = obj["name"] as? String, !name.isEmpty { st.name = name }
st.jobsToday = obj["jobsToday"] as? Int ?? 0
st.receipts = obj["receiptsUrl"] as? String ?? ""
if st.receipts.isEmpty { st.receipts = obj["adminUrl"] as? String ?? "" }
if let c = obj["controlUrl"] as? String, !c.isEmpty { st.control = c }
st.port = obj["port"] as? Int ?? 0
if let addrs = obj["addresses"] as? [String], let first = addrs.first { st.address = first }
return st
}
private func postJSON(_ path: String, _ body: [String: Any]) -> Bool {
guard let url = URL(string: controlDefault + path),
let data = try? JSONSerialization.data(withJSONObject: body) else { return false }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = data
let sem = DispatchSemaphore(value: 0)
var ok = false
session.dataTask(with: req) { _, response, _ in
if let http = response as? HTTPURLResponse, http.statusCode < 300 { ok = true }
sem.signal()
}.resume()
_ = sem.wait(timeout: .now() + 4)
return ok
}
final class EventWatch: NSObject, URLSessionDataDelegate {
var buffer = ""
var after: Int64 = 0
var seen = Set<Int64>()
let queue = OperationQueue()
var sse: URLSession?
var task: URLSessionDataTask?
weak var tray: MenuTray?
func start() {
queue.maxConcurrentOperationCount = 1
sse = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: queue)
connect()
}
func connect() {
guard let sse, let url = URL(string: "\(controlDefault)/api/v1/events?after=\(after)") else { return }
task = sse.dataTask(with: url)
task?.resume()
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
buffer += String(data: data, encoding: .utf8) ?? ""
while let range = buffer.range(of: "\n\n") {
let block = String(buffer[..<range.lowerBound])
buffer = String(buffer[range.upperBound...])
handle(block)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error { trayLog("events: \(error.localizedDescription)") }
Thread.sleep(forTimeInterval: 1)
connect()
}
func handle(_ block: String) {
var event = ""
var data = ""
for line in block.split(separator: "\n", omittingEmptySubsequences: false) {
if line.hasPrefix("event:") { event = line.dropFirst(6).trimmingCharacters(in: .whitespaces).description }
if line.hasPrefix("data:") { data = line.dropFirst(5).trimmingCharacters(in: .whitespaces).description }
}
guard event == "job", let raw = data.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: raw) as? [String: Any] else { return }
let seq = (obj["seq"] as? NSNumber)?.int64Value ?? 0
if seq == 0 || seen.contains(seq) { return }
let airdrop = obj["airdrop"] as? Bool ?? false
if airdrop {
let id = obj["id"] as? Int ?? 0
let receipt = obj["receiptId"] as? String ?? ""
let pdfURL = obj["certificateUrl"] as? String ?? ""
let jsonURL = obj["receiptJsonUrl"] as? String ?? ""
if !present(id: id, receipt: receipt, pdfURL: pdfURL, jsonURL: jsonURL) {
trayLog("AirDrop present failed for job \(id); not acknowledging")
return
}
}
seen.insert(seq)
if seq > after { after = seq }
}
func present(id: Int, receipt: String, pdfURL: String, jsonURL: String) -> Bool {
let root = (FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path ?? "/tmp")
let dir = ((root as NSString).appendingPathComponent("Verae/AirDrop/\(id)"))
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
let pdf = (dir as NSString).appendingPathComponent("certificate.pdf")
let js = (dir as NSString).appendingPathComponent("receipt.json")
guard download(pdfURL, to: pdf) else { return false }
var jsonPath = ""
if !jsonURL.isEmpty {
guard download(jsonURL, to: js) else { return false }
jsonPath = js
}
DispatchQueue.main.async { [weak self] in
self?.tray?.remember(pdf: pdf, json: jsonPath, receipt: receipt)
}
guard launchAirDrop(pdf: pdf, json: jsonPath) else { return false }
if !ack(id) { trayLog("airdrop ack job \(id) failed") }
return true
}
func download(_ urlString: String, to dest: String) -> Bool {
guard let url = URL(string: urlString) else { return false }
let sem = DispatchSemaphore(value: 0)
var data: Data?
session.dataTask(with: url) { body, response, _ in
if let http = response as? HTTPURLResponse, http.statusCode == 200 { data = body }
sem.signal()
}.resume()
_ = sem.wait(timeout: .now() + 30)
guard let data else { return false }
return FileManager.default.createFile(atPath: dest, contents: data)
}
func launchAirDrop(pdf: String, json: String) -> Bool {
let helper = sibling("verae-airdrop")
guard FileManager.default.isExecutableFile(atPath: helper) else {
trayLog("verae-airdrop missing")
return false
}
let p = Process()
p.executableURL = URL(fileURLWithPath: helper)
p.arguments = json.isEmpty ? [pdf] : [pdf, json]
do {
try p.run()
return true
} catch {
trayLog("verae-airdrop: \(error.localizedDescription)")
return false
}
}
func ack(_ id: Int) -> Bool {
postJSON("/api/v1/airdrop/ack", ["id": id])
}
}
final class MenuTray: NSObject, NSMenuDelegate {
let item: NSStatusItem
let menu = NSMenu()
var status = PrinterStatus()
var lastPDF = ""
var lastJSON = ""
var lastReceipt = ""
let events = EventWatch()
init(item: NSStatusItem) {
self.item = item
super.init()
menu.delegate = self
item.menu = menu
events.tray = self
events.start()
rebuild()
}
func remember(pdf: String, json: String, receipt: String) {
lastPDF = pdf
lastJSON = json
lastReceipt = receipt
rebuild()
}
func menuWillOpen(_ menu: NSMenu) {
status = fetchStatus()
rebuild()
}
func statusLine() -> String {
let state = status.running ? "Running" : "Stopped"
var where_ = status.name
if !status.address.isEmpty && status.port > 0 {
where_ = "\(status.name) · \(status.address):\(status.port)"
}
return "\(state) — \(where_) · \(status.jobsToday) jobs today"
}
func receiptsURL() -> String {
if !status.receipts.isEmpty { return status.receipts }
return controlDefault + "/receipts"
}
func settingsURL() -> String {
var base = status.control
while base.hasSuffix("/") { base.removeLast() }
if base.isEmpty { base = controlDefault }
return base + "/settings"
}
func rebuild() {
menu.removeAllItems()
add(statusLine(), action: nil, enabled: false)
add("iPhone: Share → Print → Select Printer → \(status.name)", action: nil, enabled: false)
menu.addItem(.separator())
add("Start", action: #selector(startService), enabled: !status.running)
add("Stop", action: #selector(stopService), enabled: status.running)
menu.addItem(.separator())
add("Open Receipts…", action: #selector(openReceipts), enabled: !receiptsURL().isEmpty)
add("Open Settings…", action: #selector(openSettings), enabled: true)
if !lastPDF.isEmpty {
let label = lastReceipt.isEmpty ? "AirDrop again" : "AirDrop again · \(lastReceipt)"
add(label, action: #selector(airDropAgain), enabled: true)
}
menu.addItem(.separator())
add("Quit menu", action: #selector(quit), enabled: true)
}
func add(_ title: String, action: Selector?, enabled: Bool) {
let row = NSMenuItem(title: title, action: action, keyEquivalent: "")
row.target = self
row.isEnabled = enabled
menu.addItem(row)
}
@objc func startService() {
if !postJSON("/api/v1/service", ["action": "start"]) {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/launchctl")
p.arguments = ["bootstrap", "system", "/Library/LaunchDaemons/com.verae.netprint.plist"]
try? p.run()
}
status = fetchStatus()
rebuild()
}
@objc func stopService() {
_ = postJSON("/api/v1/service", ["action": "stop"])
status = fetchStatus()
rebuild()
}
@objc func openReceipts() { open(receiptsURL()) }
@objc func openSettings() { open(settingsURL()) }
@objc func airDropAgain() {
guard !lastPDF.isEmpty else {
trayLog("no stamped PDF to AirDrop")
return
}
if !events.launchAirDrop(pdf: lastPDF, json: lastJSON) {
trayLog("AirDrop again failed")
}
}
func open(_ s: String) {
guard let u = URL(string: s) else { return }
NSWorkspace.shared.open(u)
}
@objc func quit() {
trayLog("quit")
NSApp.terminate(nil)
}
}
private func smoke() -> Never {
trayLog("smoke")
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if item.button == nil {
if !aquaSession() {
trayLog("no window server")
fputs("no window server\n", stdout)
exit(0)
}
trayLog("status item button is nil")
fputs("status item button is nil\n", stderr)
exit(1)
}
item.button?.title = "Verae"
NSStatusBar.system.removeStatusItem(item)
trayLog("smoke ok")
fputs("smoke ok\n", stdout)
exit(0)
}
if !Thread.isMainThread {
trayLog("not on the main thread")
fputs("verae-menubar: not on the main thread\n", stderr)
exit(1)
}
if CommandLine.arguments.contains("--smoke") {
smoke()
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
guard let button = item.button else {
trayLog("status item button is nil")
fputs("status item button is nil\n", stderr)
exit(1)
}
button.title = "Verae"
if let img = NSImage(systemSymbolName: "printer", accessibilityDescription: "Verae") {
img.isTemplate = true
button.image = img
}
let tray = MenuTray(item: item)
_ = tray
trayLog("status item ready")
app.run()
trayLog("NSApplication.run returned without quit")
fputs("NSApplication.run returned without quit\n", stderr)
exit(1)