proleStatus bar: proper size, transparent, click through and scrolling

This commit is contained in:
chrisfu 2025-11-17 21:18:36 -08:00
parent d706cfb8a6
commit ea49e60d92
10 changed files with 317 additions and 35 deletions

View File

@ -22,6 +22,7 @@ brew install mysql-client python@3.14 python-tk@3.14 postgresql@17
# start k3d cluster
k3d cluster create prole-service-cluster -a 2 --registry-create k8s-prole-org-registry:k8s.prole.org:5000 --api-port 10.0.0.205:6443
k3d cluster create prole-dev-cluster -a 2 --api-port 0.0.0.0:6443
# build prole-db Docker image
```commandline

View File

@ -48,6 +48,9 @@ chmod +x build.sh # first time only
# Run the built app
./build.sh run
# Debug: run in foreground with verbose logs to stdout
./build.sh debug
# Package into a zip
./build.sh package
@ -59,3 +62,9 @@ Outputs:
- The app bundle is placed at `proleStatus/dist/ProleStatus.app`.
- The universal build creates a fat binary using `lipo`.
- The app is adhoc signed for local running (`codesign -s -`). For distribution/notarization, replace with your signing identity.
Debug mode notes:
- Use `./build.sh debug` to run the app binary directly in the foreground with verbose logs.
- The script sets the environment variable `PROLESTATUS_DEBUG=1` which enables detailed lifecycle and network probe logging to stdout.
- You will see messages about window placement, menu bar visibility, timer rounds, and perhost TCP results.
- Press Ctrl+C in the terminal to terminate, or use the apps context menu → Quit.

View File

@ -6,16 +6,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var serviceChecker: ServiceChecker!
func applicationDidFinishLaunching(_ notification: Notification) {
dlog("applicationDidFinishLaunching")
NSApp.setActivationPolicy(.accessory) // visible menu-less app
dlog("Activation policy set to .accessory")
serviceChecker = ServiceChecker()
dlog("ServiceChecker created")
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker)
dlog("OverlayWindowController created; computing initial frame/visibility")
overlayWindowController.showIfMenuBarVisible()
hotKeyManager = HotKeyManager()
hotKeyManager.registerGlobalHotKey(modifiers: [.command, .option, .shift], key: .p) { [weak self] in
self?.overlayWindowController.showContextMenu()
}
dlog("Global hotkey registered (Cmd+Opt+Shift+P)")
// Observe screen/space/menu bar visibility changes
DistributedNotificationCenter.default().addObserver(self, selector: #selector(handleConfigChange), name: NSNotification.Name("com.apple.HIToolbox.inputSourceChanged"), object: nil)
@ -23,14 +28,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(spaceChanged), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
// Check status every 30 seconds
dlog("Starting ServiceChecker timer (interval: 30s)")
serviceChecker.start(interval: 30)
}
@objc private func handleConfigChange() {
dlog("handleConfigChange → recomputeFrameAndVisibility")
overlayWindowController.recomputeFrameAndVisibility()
}
@objc private func spaceChanged() {
dlog("spaceChanged → recomputeFrameAndVisibility")
overlayWindowController.recomputeFrameAndVisibility()
}
}

View File

@ -0,0 +1,21 @@
import Foundation
enum Debug {
static let isEnabled: Bool = {
if let v = ProcessInfo.processInfo.environment["PROLESTATUS_DEBUG"] {
return v == "1" || v.lowercased() == "true" || v.lowercased() == "yes"
}
return false
}()
static func log(_ message: @autoclosure () -> String) {
guard isEnabled else { return }
let ts = ISO8601DateFormatter().string(from: Date())
FileHandle.standardOutput.write(("[ProleStatus] " + ts + " " + message() + "\n").data(using: .utf8)!)
}
}
@inline(__always)
func dlog(_ message: @autoclosure () -> String) {
Debug.log(message())
}

View File

@ -2,7 +2,7 @@ import AppKit
final class OverlayWindowController: NSWindowController {
private let statusView = StatusView()
private let visualEffect = NSVisualEffectView()
// Fully transparent background; no visual effect overlay
init(serviceChecker: ServiceChecker) {
let style: NSWindow.StyleMask = [.borderless]
@ -15,30 +15,20 @@ final class OverlayWindowController: NSWindowController {
window.backgroundColor = .clear
window.isOpaque = false
// Visual effect that matches the menu bar
visualEffect.material = .menu
visualEffect.blendingMode = .behindWindow
visualEffect.state = .active
visualEffect.translatesAutoresizingMaskIntoConstraints = false
statusView.translatesAutoresizingMaskIntoConstraints = false
let container = NSView()
let container = TransparentContainerView()
container.wantsLayer = true
container.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(visualEffect)
visualEffect.addSubview(statusView)
container.postsFrameChangedNotifications = true
container.layer?.backgroundColor = NSColor.clear.cgColor
container.addSubview(statusView)
window.contentView = container
NSLayoutConstraint.activate([
visualEffect.leadingAnchor.constraint(equalTo: container.leadingAnchor),
visualEffect.trailingAnchor.constraint(equalTo: container.trailingAnchor),
visualEffect.topAnchor.constraint(equalTo: container.topAnchor),
visualEffect.bottomAnchor.constraint(equalTo: container.bottomAnchor),
statusView.centerXAnchor.constraint(equalTo: visualEffect.centerXAnchor),
statusView.centerYAnchor.constraint(equalTo: visualEffect.centerYAnchor)
statusView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
statusView.centerYAnchor.constraint(equalTo: container.centerYAnchor)
])
statusView.bindTo(serviceChecker: serviceChecker)
@ -52,26 +42,42 @@ final class OverlayWindowController: NSWindowController {
func showIfMenuBarVisible() { recomputeFrameAndVisibility() }
func recomputeFrameAndVisibility() {
guard let screen = NSScreen.main else { window?.orderOut(nil); return }
guard let screen = NSScreen.main else {
dlog("No main screen detected; ordering window out")
window?.orderOut(nil); return
}
let frame = screen.frame
let visible = screen.visibleFrame
let menuBarHeight = frame.maxY - visible.maxY // 0 if auto-hidden or full-screen space
if menuBarHeight <= 0.5 { // treat as hidden/no menu bar
dlog("Menu bar not visible (height≈0). Hiding overlay window.")
window?.orderOut(nil)
return
}
let barHeight = NSStatusBar.system.thickness // expected to be same as menu bar height
let y = visible.maxY - barHeight
let rect = NSRect(x: frame.minX, y: y, width: frame.width, height: barHeight)
// Make the overlay a bit taller for better readability than the status bar thickness
let statusThickness = NSStatusBar.system.thickness
let overlayHeight = statusThickness + 6.0
let y = visible.maxY - overlayHeight
let rect = NSRect(x: frame.minX, y: y, width: frame.width, height: overlayHeight)
dlog("Computed overlay frame: x=\(rect.origin.x), y=\(rect.origin.y), w=\(rect.size.width), h=\(rect.size.height) | menuBarHeight=\(menuBarHeight), statusBarThickness=\(statusThickness), overlayHeight=\(overlayHeight)")
window?.setFrame(rect, display: true)
window?.orderFrontRegardless()
// Ensure we remain click-through after any movement/visibility change
window?.ignoresMouseEvents = true
dlog("Overlay window ordered front")
}
func showContextMenu() {
guard let window = window, window.isVisible else { return }
// Temporarily enable mouse interactions so the context menu can be used
let wasIgnoring = window.ignoresMouseEvents
if wasIgnoring {
dlog("Enabling mouse events for context menu interaction")
window.ignoresMouseEvents = false
}
let menu = NSMenu(title: "ProleStatus")
menu.addItem(withTitle: statusView.aggregateStatusSummary(), action: nil, keyEquivalent: "")
menu.addItem(.separator())
@ -81,6 +87,11 @@ final class OverlayWindowController: NSWindowController {
let point = NSPoint(x: window.frame.midX, y: window.frame.minY)
menu.popUp(positioning: nil, at: point, in: nil)
// Restore click-through after the menu is dismissed
if wasIgnoring {
dlog("Restoring click-through after context menu")
window.ignoresMouseEvents = true
}
}
@objc private func refreshNow() {
@ -88,6 +99,7 @@ final class OverlayWindowController: NSWindowController {
}
@objc private func quit() {
dlog("Quit requested via context menu; terminating app")
NSApp.terminate(nil)
}
}
@ -100,7 +112,18 @@ final class OverlayWindow: NSWindow {
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
isOpaque = false
backgroundColor = .clear
ignoresMouseEvents = false // allow hover tracking, but well pass clicks through via hitTest
// Default to fully click-through. We temporarily disable this only while showing the context menu.
ignoresMouseEvents = true
acceptsMouseMovedEvents = true
dlog("OverlayWindow initialized (borderless, transparent, accepts mouse moved events)")
}
}
// Transparent container view that passes through mouse clicks everywhere
// while still allowing mouse-moved events for subviews that add tracking areas.
final class TransparentContainerView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
// Ensure the entire overlay is click-through
return nil
}
}

View File

@ -35,6 +35,7 @@ final class ServiceChecker {
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
}
@ -42,6 +43,7 @@ final class ServiceChecker {
@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
@ -61,35 +63,67 @@ final class ServiceChecker {
}
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
completion(true, Int(Double(elapsed) / 1_000_000.0), nil)
conn.cancel()
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):
completion(false, -1, Self.describeNWError(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 cancelled before ready, likely timed out
completion(false, -1, "connection cancelled (possible timeout)")
default: break
// 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)
// Timeout
queue.asyncAfter(deadline: .now() + timeout) {
conn.cancel()
}
// Schedule timeout on the same queue
queue.asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
}
// Tooltips

View File

@ -5,7 +5,7 @@ final class StatusView: NSView {
private let light2 = TrafficLight()
private let light3 = TrafficLight()
private let timestampLabel: NSTextField = {
let tf = NSTextField(labelWithString: "--:--:--")
let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 +00:00")
tf.textColor = .secondaryLabelColor
tf.alignment = .left
tf.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
@ -13,13 +13,15 @@ final class StatusView: NSView {
tf.setContentCompressionResistancePriority(.required, for: .horizontal)
return tf
}()
private let marquee = MarqueeView()
private var checker: ServiceChecker?
private let timeFormatter: DateFormatter = {
let df = DateFormatter()
df.locale = .current
df.timeZone = .current
df.dateFormat = "HH:mm:ss"
// Include full date and timezone offset (e.g., 2025-11-17 20:31:05 -08:00)
df.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ"
return df
}()
private let fullFormatter: DateFormatter = {
@ -36,7 +38,7 @@ final class StatusView: NSView {
wantsLayer = true
translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [light1, light2, light3, timestampLabel])
let stack = NSStackView(views: [light1, light2, light3, timestampLabel, marquee])
stack.orientation = .horizontal
stack.alignment = .centerY
stack.distribution = .equalSpacing
@ -51,6 +53,9 @@ final class StatusView: NSView {
// Tracking for hover tooltips
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self, userInfo: nil))
// Configure marquee width to be ~64 monospace characters
configureMarqueeWidth()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
@ -85,6 +90,11 @@ final class StatusView: NSView {
let now = Date()
timestampLabel.stringValue = timeFormatter.string(from: now)
timestampLabel.toolTip = "Last updated: " + fullFormatter.string(from: now)
// Update marquee text with status summary and ensure it scrolls
let msg = aggregateStatusSummary()
marquee.setText(msg)
marquee.toolTip = msg
needsDisplay = true
}
@ -97,6 +107,162 @@ final class StatusView: NSView {
}
}
// MARK: - MarqueeView
final class MarqueeView: NSView {
private let scrollClip = NSView()
private let label1 = NSTextField(labelWithString: "")
private let label2 = NSTextField(labelWithString: "")
private var timer: Timer?
// Slow down by one third: 60 40 pts/sec
private var speedPointsPerSec: CGFloat = 40.0 // horizontal speed
private var lastTick: TimeInterval = CACurrentMediaTime()
private var currentText: String = ""
// Public width constraint can be set by container; we keep hugging low so it expands to fixed width
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
translatesAutoresizingMaskIntoConstraints = false
wantsLayer = false
scrollClip.wantsLayer = false
scrollClip.translatesAutoresizingMaskIntoConstraints = false
addSubview(scrollClip)
// Configure labels
for lbl in [label1, label2] {
lbl.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
lbl.textColor = .secondaryLabelColor
lbl.alignment = .left
lbl.backgroundColor = .clear
lbl.isBezeled = false
lbl.drawsBackground = false
lbl.lineBreakMode = .byClipping
lbl.translatesAutoresizingMaskIntoConstraints = true // we will manage frames manually
}
scrollClip.addSubview(label1)
scrollClip.addSubview(label2)
// Clip to bounds so text scrolls inside
wantsLayer = true
layer?.masksToBounds = true
setContentHuggingPriority(.defaultLow, for: .horizontal)
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
NSLayoutConstraint.activate([
scrollClip.leadingAnchor.constraint(equalTo: leadingAnchor),
scrollClip.trailingAnchor.constraint(equalTo: trailingAnchor),
scrollClip.topAnchor.constraint(equalTo: topAnchor),
scrollClip.bottomAnchor.constraint(equalTo: bottomAnchor)
])
start()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window != nil { start() } else { stop() }
}
override func layout() {
super.layout()
layoutLabelsIfNeeded()
}
override func hitTest(_ point: NSPoint) -> NSView? { nil } // click-through
func setText(_ text: String) {
if text == currentText { return }
currentText = text
label1.stringValue = text + String(repeating: " ", count: 8)
label2.stringValue = label1.stringValue
layoutLabelsIfNeeded(resetOffset: true)
}
private func layoutLabelsIfNeeded(resetOffset: Bool = false) {
let h = bounds.height
let y = (h - intrinsicLineHeight())/2.0
let size = label1.intrinsicContentSize
let w = size.width
// Place labels back-to-back for seamless loop
if resetOffset {
label1.frame = NSRect(x: 0, y: y, width: w, height: size.height)
label2.frame = NSRect(x: w, y: y, width: w, height: size.height)
} else if label1.frame.size.width == 0 || label2.frame.size.width == 0 {
label1.frame = NSRect(x: label1.frame.origin.x, y: y, width: w, height: size.height)
label2.frame = NSRect(x: label2.frame.origin.x, y: y, width: w, height: size.height)
} else {
// Keep vertically centered
label1.frame.origin.y = y
label2.frame.origin.y = y
}
}
private func intrinsicLineHeight() -> CGFloat {
let f = label1.font ?? NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
return ceil(f.ascender - f.descender)
}
private func tick() {
let now = CACurrentMediaTime()
let dt = now - lastTick
lastTick = now
let dx = CGFloat(dt) * speedPointsPerSec
// Move both labels left by dx
label1.frame.origin.x -= dx
label2.frame.origin.x -= dx
// When a label fully leaves on the left, move it to the right of the other
let totalW = label1.frame.width
if label1.frame.maxX <= 0 {
label1.frame.origin.x = label2.frame.maxX
}
if label2.frame.maxX <= 0 {
label2.frame.origin.x = label1.frame.maxX
}
}
func start() {
stop()
lastTick = CACurrentMediaTime()
timer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
self?.tick()
}
RunLoop.main.add(timer!, forMode: .common)
}
func stop() {
timer?.invalidate()
timer = nil
}
deinit { stop() }
}
// Utility to compute width for N monospace characters
private func widthForMonospaceCharacters(_ count: Int, font: NSFont) -> CGFloat {
let sample = String(repeating: "0", count: max(1, count))
let attrs: [NSAttributedString.Key: Any] = [.font: font]
let w = (sample as NSString).size(withAttributes: attrs).width
return ceil(w)
}
private extension StatusView {
func configureMarqueeWidth() {
// Match font with timestamp for visual cohesion
let font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
marquee.heightAnchor.constraint(equalToConstant: ceil(font.capHeight * 1.8)).isActive = true
let width = widthForMonospaceCharacters(64, font: font)
let wConstraint = marquee.widthAnchor.constraint(equalToConstant: width)
wConstraint.priority = .required
wConstraint.isActive = true
}
}
final class TrafficLight: NSView {
enum State { case green, yellow, red }
var state: State = .red { didSet { needsDisplay = true } }

View File

@ -2,5 +2,6 @@ import AppKit
_ = NSApplication.shared
let delegate = AppDelegate()
dlog("Process started. Setting application delegate and running app loop…")
NSApplication.shared.delegate = delegate
NSApplication.shared.run()

View File

@ -30,6 +30,7 @@ Commands:
build-universal Build a universal (arm64+x86_64) ${APP_NAME}.app
clean Remove build artifacts
run Build (if needed) and run the app
debug Build (if needed) and run in foreground with verbose logs
package Zip the built app into dist/${APP_NAME}.zip
Options (for build):
@ -178,6 +179,24 @@ case "$cmd" in
build_universal ;;
run)
run_app ;;
debug)
# Build if needed, then run the binary directly in the foreground
if [ ! -d "$APP_DIR" ]; then
"$0" build
fi
BIN="$MACOS_DIR/${APP_NAME}"
if [ ! -x "$BIN" ]; then
echo "[debug] error: binary not found at $BIN" >&2
exit 1
fi
echo "[debug] Running ${APP_NAME} in foreground with verbose logs"
echo "[debug] Press Ctrl+C to terminate. To quit from UI, use the context menu or Cmd+Q."
export PROLESTATUS_DEBUG=1
"$BIN"
status=$?
echo "[debug] ${APP_NAME} exited with status ${status}"
exit $status
;;
package)
package_app ;;
*)