mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 17:44:33 +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
340 lines
13 KiB
Swift
340 lines
13 KiB
Swift
import AppKit
|
|
|
|
final class StatusView: NSView {
|
|
private let light1 = TrafficLight()
|
|
private let light2 = TrafficLight()
|
|
private let light3 = TrafficLight()
|
|
private let lightPF = TrafficLight()
|
|
private let timestampLabel: NSTextField = {
|
|
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)
|
|
tf.setContentHuggingPriority(.required, for: .horizontal)
|
|
tf.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
return tf
|
|
}()
|
|
private let marquee = MarqueeView()
|
|
private let maximizeButton: NSButton = {
|
|
let b = NSButton(title: "□", target: nil, action: nil)
|
|
b.bezelStyle = .texturedRounded
|
|
b.toolTip = "Show Main Window"
|
|
b.setContentHuggingPriority(.required, for: .horizontal)
|
|
b.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
return b
|
|
}()
|
|
|
|
private var checker: ServiceChecker?
|
|
private let timeFormatter: DateFormatter = {
|
|
let df = DateFormatter()
|
|
df.locale = .current
|
|
df.timeZone = .current
|
|
// 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 = {
|
|
let df = DateFormatter()
|
|
df.locale = .current
|
|
df.timeZone = .current
|
|
df.dateStyle = .medium
|
|
df.timeStyle = .medium
|
|
return df
|
|
}()
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
wantsLayer = true
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let stack = NSStackView(views: [light1, light2, light3, lightPF, timestampLabel, marquee, maximizeButton])
|
|
stack.orientation = .horizontal
|
|
stack.alignment = .centerY
|
|
stack.distribution = .equalSpacing
|
|
stack.spacing = 12
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
stack.centerYAnchor.constraint(equalTo: centerYAnchor)
|
|
])
|
|
|
|
// Wire button actions
|
|
maximizeButton.target = self
|
|
maximizeButton.action = #selector(didTapMaximize)
|
|
|
|
// 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") }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
// Allow interaction with our inline controls and status lights (for tooltips),
|
|
// pass other clicks through to avoid blocking the menu bar.
|
|
if let v = super.hitTest(point) {
|
|
if v is NSButton { return v }
|
|
if v is TrafficLight { return v }
|
|
if let tl = v.toolTip, !tl.isEmpty { return v }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private var pfManager: PortForwardManager?
|
|
|
|
func bindTo(serviceChecker: ServiceChecker) {
|
|
self.checker = serviceChecker
|
|
NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker)
|
|
updateLights()
|
|
}
|
|
|
|
func setPortForwardManager(_ manager: PortForwardManager?) {
|
|
self.pfManager = manager
|
|
if let m = manager {
|
|
NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: PortForwardManager.statusDidChangeNotification, object: m)
|
|
}
|
|
updateLights()
|
|
}
|
|
|
|
@objc private func updateLights() {
|
|
guard let c = checker else { return }
|
|
// Light 1: svc.prole.org:443
|
|
light1.state = c.svcReachable ? .green : .red
|
|
light1.toolTip = c.tooltipForSvc()
|
|
|
|
// Light 2: aggregate raspberry + pi
|
|
let count = (c.raspberryReachable ? 1 : 0) + (c.piReachable ? 1 : 0)
|
|
light2.state = count == 2 ? .green : (count == 1 ? .yellow : .red)
|
|
light2.toolTip = c.tooltipForAggregateK3s()
|
|
|
|
// Light 3: localhost k3d
|
|
light3.state = c.localReachable ? .green : .red
|
|
light3.toolTip = c.tooltipForLocal()
|
|
|
|
// Light 4: port-forward supervision status (Dev)
|
|
if let m = pfManager {
|
|
lightPF.state = m.allRunning ? .green : .red
|
|
lightPF.toolTip = m.allRunning ? "kubectl port-forwards: running" : "kubectl port-forwards: not all running"
|
|
} else {
|
|
lightPF.state = .red
|
|
lightPF.toolTip = "kubectl port-forwards: disabled"
|
|
}
|
|
|
|
// Timestamp label
|
|
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
|
|
}
|
|
|
|
func aggregateStatusSummary() -> String {
|
|
guard let c = checker else { return "No status" }
|
|
let cfg = Config.shared
|
|
let svc = c.svcReachable ? "\(cfg.svcHost):\(cfg.svcPort) ✓" : "\(cfg.svcHost):\(cfg.svcPort) ✗"
|
|
let agg = "k3s: \(cfg.retropieHost) \(c.raspberryReachable ? "✓" : "✗"), \(cfg.piHost) \(c.piReachable ? "✓" : "✗")"
|
|
let loc = c.localReachable ? "k3d \(cfg.localHost):\(cfg.localPort) ✓" : "k3d \(cfg.localHost):\(cfg.localPort) ✗"
|
|
return [svc, agg, loc].joined(separator: " • ")
|
|
}
|
|
|
|
// MARK: - Button actions
|
|
@objc private func didTapMaximize() {
|
|
NotificationCenter.default.post(name: .toggleMode, object: nil)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification bridge
|
|
extension Notification.Name {
|
|
static let showMainWindow = Notification.Name("ProleStatus.showMainWindow")
|
|
static let toggleMode = Notification.Name("ProleStatus.toggleMode")
|
|
}
|
|
|
|
final class TrafficLight: NSView {
|
|
enum State { case green, yellow, red }
|
|
var state: State = .red { didSet { needsDisplay = true } }
|
|
|
|
override var intrinsicContentSize: NSSize { NSSize(width: 14, height: 14) }
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
super.draw(dirtyRect)
|
|
let rect = bounds.insetBy(dx: 1, dy: 1)
|
|
let path = NSBezierPath(ovalIn: rect)
|
|
let color: NSColor
|
|
switch state {
|
|
case .green: color = NSColor.systemGreen
|
|
case .yellow: color = NSColor.systemYellow
|
|
case .red: color = NSColor.systemRed
|
|
}
|
|
color.setFill()
|
|
path.fill()
|
|
|
|
// subtle ring for better contrast against menu material
|
|
NSColor.black.withAlphaComponent(0.12).setStroke()
|
|
path.lineWidth = 1
|
|
path.stroke()
|
|
}
|
|
}
|