PrintToVeraeTimeStamp/platform/macos/menubar/main.swift
Cursor Agent 5ed87da68f
Some checks failed
installers / netprint (push) Has been cancelled
installers / macos-pkg (push) Has been cancelled
installers / publish (push) Has been cancelled
Keep the macOS receipts SMB share across reboots.
Save the installer choice as shareReceiptsSMB, recreate the share when it is missing, and show the live smb:// address on the receipts page.

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

430 lines
15 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
var supportsShare = false
var shareOn = false
var smbURL = ""
}
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 }
st.supportsShare = obj["supportsSMBShare"] as? Bool ?? false
st.shareOn = obj["shareReceiptsSMB"] as? Bool ?? false
st.smbURL = obj["smbUrl"] as? String ?? ""
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()
_ = postJSON("/api/v1/receipts-share/ensure", [String: Any]())
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 status.supportsShare {
let title = status.shareOn ? "Share receipts folder over SMB ✓" : "Share receipts folder over SMB"
add(title, action: #selector(toggleShare), enabled: status.running)
}
if !status.smbURL.isEmpty {
add("Files — \(status.smbURL)", action: nil, enabled: false)
}
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 toggleShare() {
_ = postJSON("/api/v1/settings", ["shareReceiptsSMB": !status.shareOn])
status = fetchStatus()
rebuild()
}
@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)