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>
430 lines
15 KiB
Swift
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)
|