mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
- Default to Application Window mode with full app menu; status bar overlay remains available - Restore clickable startup Splash Tip (5s) with animated GIF and live counter; use light theme (Aqua) - Status bar item: monospaced "P" icon; left click shows overlay (status mode) or main window (app mode) - Overlay: add Maximize button (□) to toggle back to main window; keep click‑through elsewhere - Global hotkey Cmd+Opt+Shift+P toggles modes; Prole menu item mirrors the same toggle and updates title dynamically - Application menu (Prole): Show Status Bar / Show Main Window (contextual), Refresh Now, Quit - Application Window layout: non‑scrolling, vertical 1‑line rows (svc, k3s aggregate, local) with top‑right timestamp; bottom‑left controls (⟳ Refresh, _ Minimize) - Parameterize endpoints via `prole.properties` (bundled + user override). Replace legacy raspberry with retropie defaults - ServiceChecker + UI read endpoints from Config loader; tooltips/labels reflect configured hosts/ports - Build script: generate `Info.plist` with `LSUIElement=false`; bundle resources (`prole-type.gif`, `prole.properties`); ad‑hoc codesign. Universal build supported - Documentation: rewrite README with technical build/run/config details and operational posture Files: - proleStatus/Sources/: AppDelegate.swift, OverlayWindow.swift, StatusView.swift, StatusItemController.swift, SplashTipWindowController.swift, MainWindowController.swift, AppStatusView.swift, ServiceChecker.swift, Config.swift - proleStatus/build.sh - proleStatus/prole.properties - proleStatus/README.md Notes: - Build verified via `./build.sh build` (arm64). App starts in App Window mode with light theme; menu + hotkey + overlay toggle operate as intended
173 lines
7.6 KiB
Swift
173 lines
7.6 KiB
Swift
import Foundation
|
|
import Network
|
|
|
|
final class ServiceChecker {
|
|
static let statusDidChangeNotification = Notification.Name("ServiceChecker.statusDidChange")
|
|
static let forceRefreshNotification = Notification.Name("ServiceChecker.forceRefresh")
|
|
|
|
private let queue = DispatchQueue(label: "prole.status.checker")
|
|
private var timer: DispatchSourceTimer?
|
|
|
|
// Public reachability flags
|
|
private(set) var svcReachable = false
|
|
private(set) var raspberryReachable = false
|
|
private(set) var piReachable = false
|
|
private(set) var localReachable = false
|
|
|
|
// Latency measurements (ms)
|
|
private(set) var svcLatency: Int = -1
|
|
private(set) var raspberryLatency: Int = -1
|
|
private(set) var piLatency: Int = -1
|
|
private(set) var localLatency: Int = -1
|
|
|
|
// Last error messages (for tooltips when red)
|
|
private(set) var svcError: String? = nil
|
|
private(set) var raspberryError: String? = nil
|
|
private(set) var piError: String? = nil
|
|
private(set) var localError: String? = nil
|
|
|
|
init() {
|
|
NotificationCenter.default.addObserver(self, selector: #selector(forceRefresh), name: Self.forceRefreshNotification, object: nil)
|
|
}
|
|
|
|
func start(interval: TimeInterval = 15) {
|
|
timer?.cancel()
|
|
let t = DispatchSource.makeTimerSource(queue: queue)
|
|
t.schedule(deadline: .now(), repeating: interval)
|
|
t.setEventHandler { [weak self] in self?.refreshAll() }
|
|
dlog("ServiceChecker: starting timer, interval=\(interval)s")
|
|
t.resume()
|
|
timer = t
|
|
}
|
|
|
|
@objc private func forceRefresh() { queue.async { self.refreshAll() } }
|
|
|
|
private func refreshAll() {
|
|
dlog("ServiceChecker: begin refresh round")
|
|
let group = DispatchGroup()
|
|
|
|
// clear previous errors before a new round
|
|
svcError = nil; raspberryError = nil; piError = nil; localError = nil
|
|
|
|
let cfg = Config.shared
|
|
group.enter(); tcpPing(host: cfg.svcHost, port: UInt16(cfg.svcPort)) { [weak self] ok, ms, err in
|
|
self?.svcReachable = ok; self?.svcLatency = ms; self?.svcError = err; group.leave()
|
|
}
|
|
group.enter(); tcpPing(host: cfg.retropieHost, port: UInt16(cfg.retropiePort)) { [weak self] ok, ms, err in
|
|
self?.raspberryReachable = ok; self?.raspberryLatency = ms; self?.raspberryError = err; group.leave()
|
|
}
|
|
group.enter(); tcpPing(host: cfg.piHost, port: UInt16(cfg.piPort)) { [weak self] ok, ms, err in
|
|
self?.piReachable = ok; self?.piLatency = ms; self?.piError = err; group.leave()
|
|
}
|
|
group.enter(); tcpPing(host: cfg.localHost, port: UInt16(cfg.localPort)) { [weak self] ok, ms, err in
|
|
self?.localReachable = ok; self?.localLatency = ms; self?.localError = err; group.leave()
|
|
}
|
|
|
|
group.notify(queue: .main) {
|
|
dlog("ServiceChecker: refresh round complete → posting statusDidChangeNotification")
|
|
NotificationCenter.default.post(name: Self.statusDidChangeNotification, object: self)
|
|
}
|
|
}
|
|
|
|
private func tcpPing(host: String, port: UInt16, timeout: TimeInterval = 2.0, completion: @escaping (Bool, Int, String?) -> Void) {
|
|
dlog("tcpPing: attempting \(host):\(port) timeout=\(timeout)s")
|
|
let start = DispatchTime.now()
|
|
let params = NWParameters.tcp
|
|
params.allowLocalEndpointReuse = true
|
|
let endpoint = NWEndpoint.hostPort(host: .name(host, nil), port: .init(integerLiteral: port))
|
|
let conn = NWConnection(to: endpoint, using: params)
|
|
|
|
// Ensure completion is invoked exactly once
|
|
var finished = false
|
|
func finishOnce(_ ok: Bool, _ ms: Int, _ err: String?, reason: String) {
|
|
// All state updates and timeout run on `queue` so this is serialized
|
|
if finished { return }
|
|
finished = true
|
|
dlog("tcpPing: finishOnce(\(host):\(port)) reason=\(reason) ok=\(ok) ms=\(ms) err=\(err ?? "nil")")
|
|
completion(ok, ms, err)
|
|
}
|
|
|
|
// Prepare timeout work item so we can cancel it on success/failure
|
|
let timeoutWork = DispatchWorkItem { [weak conn] in
|
|
if finished { return }
|
|
dlog("tcpPing: timeout reached for \(host):\(port); cancelling connection")
|
|
conn?.cancel()
|
|
finishOnce(false, -1, "connection timed out", reason: "timeout")
|
|
}
|
|
|
|
conn.stateUpdateHandler = { state in
|
|
switch state {
|
|
case .ready:
|
|
let elapsed = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds
|
|
let ms = Int(Double(elapsed) / 1_000_000.0)
|
|
dlog("tcpPing: READY \(host):\(port) in \(ms) ms")
|
|
timeoutWork.cancel()
|
|
finishOnce(true, ms, nil, reason: "ready")
|
|
conn.cancel() // will emit .cancelled; ignored due to finished=true
|
|
case .failed(let error):
|
|
let msg = Self.describeNWError(error)
|
|
dlog("tcpPing: FAILED \(host):\(port) — \(msg)")
|
|
timeoutWork.cancel()
|
|
finishOnce(false, -1, msg, reason: "failed")
|
|
conn.cancel()
|
|
case .cancelled:
|
|
// If we already finished (e.g., due to .ready), this is expected; ignore.
|
|
if finished {
|
|
dlog("tcpPing: CANCELLED \(host):\(port) after finish — ignoring")
|
|
} else {
|
|
// Cancel without prior .ready/.failed implies timeout or external cancel
|
|
finishOnce(false, -1, "connection cancelled (possible timeout)", reason: "cancelled-before-finish")
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
conn.start(queue: queue)
|
|
// Schedule timeout on the same queue
|
|
queue.asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
|
|
}
|
|
|
|
// Tooltips
|
|
func tooltipForSvc() -> String {
|
|
let host = Config.shared.svcHost
|
|
let port = Config.shared.svcPort
|
|
if svcReachable { return "\(host):\(port) — reachable (\(svcLatency) ms)" }
|
|
var s = "\(host):\(port) — unreachable"
|
|
if let e = svcError { s += "\nError: \(e)" }
|
|
return s
|
|
}
|
|
func tooltipForAggregateK3s() -> String {
|
|
let rHost = Config.shared.retropieHost
|
|
let pHost = Config.shared.piHost
|
|
var r = raspberryReachable ? "\(rHost) ✓ (\(raspberryLatency) ms)" : "\(rHost) ✗"
|
|
var p = piReachable ? "\(pHost) ✓ (\(piLatency) ms)" : "\(pHost) ✗"
|
|
if !raspberryReachable, let e = raspberryError { r += " — \(e)" }
|
|
if !piReachable, let e = piError { p += " — \(e)" }
|
|
let overall: String
|
|
switch (raspberryReachable, piReachable) {
|
|
case (true, true): overall = "overall: green"
|
|
case (true, false), (false, true): overall = "overall: yellow"
|
|
default: overall = "overall: red"
|
|
}
|
|
return "k3s aggregate — \(overall)\n\(r)\n\(p)"
|
|
}
|
|
func tooltipForLocal() -> String {
|
|
let host = Config.shared.localHost
|
|
let port = Config.shared.localPort
|
|
if localReachable { return "k3d \(host):\(port) — reachable (\(localLatency) ms)" }
|
|
var s = "k3d \(host):\(port) — unreachable"
|
|
if let e = localError { s += "\nError: \(e)" }
|
|
return s
|
|
}
|
|
|
|
private static func describeNWError(_ error: NWError) -> String {
|
|
switch error {
|
|
case .posix(let code): return "POSIX \(code.rawValue): \(code)"
|
|
case .dns(let code): return "DNS \(code): \(code)"
|
|
case .tls(let status): return "TLS/OSStatus \(status)"
|
|
case .wifiAware(let reason): return "Wi-Fi Aware: \(reason)"
|
|
@unknown default: return "Unknown network error"
|
|
}
|
|
}
|
|
}
|