ProleStatus: app-window default, light splash restored, status bar controls; endpoint config via properties; build + docs

- 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
This commit is contained in:
chrisfu 2025-12-02 19:42:54 -08:00
parent da8bca97f6
commit 0c69f90e71
8 changed files with 264 additions and 99 deletions

View File

@ -6,7 +6,11 @@ UI-agnostic command composition for the Prole macOS app build.
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import tkinter as tk
from tkinter import ttk, scrolledtext
from . import main as inst_main
from . import screen as ui
def get_build_command(project_root: Path, env: str | None) -> str: def get_build_command(project_root: Path, env: str | None) -> str:
"""Return the Prole app build command with an env comment suffix. """Return the Prole app build command with an env comment suffix.
@ -17,3 +21,26 @@ def get_build_command(project_root: Path, env: str | None) -> str:
env_label = (env or "Dev").strip().title() env_label = (env or "Dev").strip().title()
prole_root = str(project_root) prole_root = str(project_root)
return f"cd \"{prole_root}\" && ./prole-app/build.sh build # {env_label}" return f"cd \"{prole_root}\" && ./prole-app/build.sh build # {env_label}"
# ---------------- Screen (UI) helpers ----------------
def create_build_page(app):
"""Create the Build page UI and register it via installer.main."""
f = ttk.Frame(app.page_area)
f.place(x=0, y=0, relwidth=1, relheight=1)
ttk.Label(f, text='Build', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
ttk.Label(f, text='Choose a target and build the artifacts.', style='Body.TLabel').pack(anchor='w', padx=24)
wrap = ttk.Frame(f)
wrap.pack(anchor='w', padx=24, pady=12)
ttk.Label(wrap, text='Target Environment:', style='Body.TLabel').pack(side='left')
app.deploy_env_var = tk.StringVar(value='Dev')
ttk.Combobox(wrap, textvariable=app.deploy_env_var, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10)
# Build output console
app.build_output = scrolledtext.ScrolledText(f, height=12, bg='#fafafa', fg='#1d1d1f')
app.build_output.pack(fill='both', expand=True, padx=24, pady=12)
inst_main.register_page(app, 'build', f)
return f

View File

@ -8,7 +8,11 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import platform import platform
import subprocess import subprocess
from tkinter import messagebox import tkinter as tk
from tkinter import ttk, messagebox
from . import main as inst_main
from . import screen as ui
def check_xcode_tools() -> bool: def check_xcode_tools() -> bool:
@ -55,3 +59,59 @@ def build_prole_app(project_root: Path) -> None:
messagebox.showinfo("Copied", f"Copied to {dest}") messagebox.showinfo("Copied", f"Copied to {dest}")
except Exception as copy_err: except Exception as copy_err:
print(f"Copy to /Applications failed: {copy_err}") print(f"Copy to /Applications failed: {copy_err}")
# ---------------- Screen (UI) helpers ----------------
def _draw_status(canvas: tk.Canvas, status: str):
canvas.delete('all')
if status == 'success' or status == 'completed':
canvas.create_oval(2, 2, 18, 18, fill='#34c759', outline='')
canvas.create_line(5, 10, 9, 14, fill='white', width=2)
canvas.create_line(9, 14, 16, 6, fill='white', width=2)
elif status == 'running':
canvas.create_oval(2, 2, 18, 18, fill='#ffd60a', outline='')
elif status == 'error':
canvas.create_oval(2, 2, 18, 18, fill='#ff3b30', outline='')
else:
canvas.create_oval(2, 2, 18, 18, outline='#b0b0b0')
def _create_deploy_row(app, parent, step: dict):
row = ttk.Frame(parent)
row.pack(fill='x', pady=6)
canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0)
canvas.pack(side='left', padx=8)
lbl = ttk.Label(row, text=step['name'], style='Body.TLabel')
lbl.pack(side='left')
status = ttk.Label(row, text='Pending', style='Dim.TLabel')
status.pack(side='right', padx=8)
app.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step}
_draw_status(canvas, 'pending')
def create_deploy_page(app):
"""Create the Deploy page UI and register it via installer.main."""
f = ttk.Frame(app.page_area)
f.place(x=0, y=0, relwidth=1, relheight=1)
ttk.Label(f, text='Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
ttk.Label(f, text='Preparing to deploy. We will verify steps and perform actions as needed.', style='Body.TLabel', wraplength=800).pack(anchor='w', padx=24)
app.deploy_steps = [
{'name': 'Build Prole macOS app', 'status': 'pending'},
{'name': 'Check Docker is running', 'status': 'pending'},
{'name': 'Check or configure container registry', 'status': 'pending'},
{'name': 'Ensure target cluster', 'status': 'pending'},
{'name': 'Build prole-db Docker image', 'status': 'pending'},
{'name': 'Tag Docker image for registry', 'status': 'pending'},
{'name': 'Push image to registry', 'status': 'pending'},
{'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'},
]
app.deploy_widgets = {}
container = ttk.Frame(f)
container.pack(fill='both', expand=True, padx=16, pady=8)
for step in app.deploy_steps:
_create_deploy_row(app, container, step)
inst_main.register_page(app, 'deploy', f)
return f

View File

@ -19,6 +19,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItemController: StatusItemController! private var statusItemController: StatusItemController!
private var splashTipController: SplashTipWindowController? private var splashTipController: SplashTipWindowController?
private var appMenuToggleStatusBarItem: NSMenuItem? private var appMenuToggleStatusBarItem: NSMenuItem?
private var pfManager: PortForwardManager?
// We keep the app in one of two simple modes. // We keep the app in one of two simple modes.
// - statusBar: shows the thin overlay near the macOS menu bar // - statusBar: shows the thin overlay near the macOS menu bar
@ -42,8 +43,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// ServiceChecker does the lightweight TCP checks on a background timer // ServiceChecker does the lightweight TCP checks on a background timer
serviceChecker = ServiceChecker() serviceChecker = ServiceChecker()
dlog("ServiceChecker created") dlog("ServiceChecker created")
// Optional: Port-forward supervision (Dev)
if Config.shared.pfEnabled {
let cmds = Config.shared.pfCommands
if !cmds.isEmpty {
let mgr = PortForwardManager(commands: cmds)
self.pfManager = mgr
mgr.start()
}
}
// Overlay (Status Bar mode) — a thin, mostly click‑through window near the top // Overlay (Status Bar mode) — a thin, mostly click‑through window near the top
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker) overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker, pfManager: pfManager)
dlog("OverlayWindowController created; computing initial frame/visibility") dlog("OverlayWindowController created; computing initial frame/visibility")
overlayWindowController.showIfMenuBarVisible() overlayWindowController.showIfMenuBarVisible()
@ -51,7 +62,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
mainWindowController = MainWindowController(serviceChecker: serviceChecker, actions: .init( mainWindowController = MainWindowController(serviceChecker: serviceChecker, actions: .init(
onRefresh: { [weak self] in self?.refreshNow() }, onRefresh: { [weak self] in self?.refreshNow() },
onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() } onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() }
)) ), pfManager: pfManager)
dlog("MainWindowController created") dlog("MainWindowController created")
// Create status bar item with icon and click handler // Create status bar item with icon and click handler
@ -194,6 +205,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
appMenu.addItem(NSMenuItem.separator()) appMenu.addItem(NSMenuItem.separator())
// Reset Port Forwards (Dev)
if pfManager != nil {
let resetPF = NSMenuItem(title: "Reset Port Forwards", action: #selector(menuResetPortForwards), keyEquivalent: "")
resetPF.target = self
appMenu.addItem(resetPF)
// Details submenu with PID/state
let detailsItem = NSMenuItem(title: "Port Forward Details", action: nil, keyEquivalent: "")
let sub = NSMenu(title: "Port Forward Details")
if let mgr = pfManager {
for d in mgr.details {
let pid = d.pid != nil ? String(d.pid!) : "-"
let status = d.running ? "running" : "stopped"
let label = "PID \(pid) • \(status) — \(d.command)"
let it = NSMenuItem(title: label, action: nil, keyEquivalent: "")
sub.addItem(it)
}
if mgr.details.isEmpty {
sub.addItem(NSMenuItem(title: "No port-forwards configured", action: nil, keyEquivalent: ""))
}
}
detailsItem.submenu = sub
appMenu.addItem(detailsItem)
appMenu.addItem(NSMenuItem.separator())
}
// Refresh // Refresh
let refresh = NSMenuItem(title: "Refresh Now", action: #selector(menuRefresh), keyEquivalent: "r") let refresh = NSMenuItem(title: "Refresh Now", action: #selector(menuRefresh), keyEquivalent: "r")
refresh.target = self refresh.target = self
@ -202,7 +239,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
appMenu.addItem(NSMenuItem.separator()) appMenu.addItem(NSMenuItem.separator())
// Quit // Quit
let quit = NSMenuItem(title: "Quit ProleStatus", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q") let quit = NSMenuItem(title: "Quit Prole", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q")
appMenu.addItem(quit) appMenu.addItem(quit)
appMenuItem.submenu = appMenu appMenuItem.submenu = appMenu
@ -219,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func menuToggleMode() { toggleMode() } @objc private func menuToggleMode() { toggleMode() }
@objc private func menuRefresh() { refreshNow() } @objc private func menuRefresh() { refreshNow() }
@objc private func menuResetPortForwards() { pfManager?.reset() }
// Invoked by overlay maximize button // Invoked by overlay maximize button
@objc private func showMainWindowRequested() { @objc private func showMainWindowRequested() {

View File

@ -1,90 +1,3 @@
import AppKit
// Application-window specific status view: vertical, one line per item, no marquee.
final class AppStatusView: NSView {
private let lightSvc = TrafficLight()
private let lightK3s = TrafficLight()
private let lightLocal = TrafficLight()
private let labelSvc = NSTextField(labelWithString: "")
private let labelK3s = NSTextField(labelWithString: "")
private let labelLocal = NSTextField(labelWithString: "")
// Top-right timestamp
private let timestampLabel: NSTextField = {
let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 Z")
tf.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
tf.textColor = .secondaryLabelColor
tf.alignment = .right
tf.translatesAutoresizingMaskIntoConstraints = false
return tf
}()
private var checker: ServiceChecker?
private let timeFormatter: DateFormatter = {
let df = DateFormatter()
df.locale = .current
df.timeZone = .current
df.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ"
return df
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
translatesAutoresizingMaskIntoConstraints = false
// Configure labels
[labelSvc, labelK3s, labelLocal].forEach { l in
l.font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .regular))
l.textColor = .labelColor
l.lineBreakMode = .byTruncatingTail
l.translatesAutoresizingMaskIntoConstraints = false
}
// Rows: [light][label]
func row(light: NSView, label: NSTextField) -> NSStackView {
let s = NSStackView(views: [light, label])
s.orientation = .horizontal
s.alignment = .centerY
s.spacing = 8
s.translatesAutoresizingMaskIntoConstraints = false
return s
}
let row1 = row(light: lightSvc, label: labelSvc)
let row2 = row(light: lightK3s, label: labelK3s)
let row3 = row(light: lightLocal, label: labelLocal)
let vstack = NSStackView(views: [row1, row2, row3])
vstack.orientation = .vertical
vstack.alignment = .leading
vstack.spacing = 10
vstack.translatesAutoresizingMaskIntoConstraints = false
addSubview(vstack)
addSubview(timestampLabel)
NSLayoutConstraint.activate([
vstack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
vstack.topAnchor.constraint(equalTo: topAnchor, constant: 24),
timestampLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
timestampLabel.topAnchor.constraint(equalTo: topAnchor, constant: 10),
timestampLabel.leadingAnchor.constraint(greaterThanOrEqualTo: vstack.trailingAnchor, constant: 12)
])
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func bindTo(serviceChecker: ServiceChecker) {
self.checker = serviceChecker
NotificationCenter.default.addObserver(self, selector: #selector(updateUI), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker)
updateUI()
}
@objc private func updateUI() {
guard let c = checker else { return }
let cfg = Config.shared
// Timestamp // Timestamp
let now = Date() let now = Date()
@ -106,6 +19,60 @@ final class AppStatusView: NSView {
labelLocal.stringValue = "local: \(cfg.localHost):\(cfg.localPort) \(c.localReachable ? "✓" : "✗") \(c.localLatency >= 0 ? "(\(c.localLatency) ms)" : "")" labelLocal.stringValue = "local: \(cfg.localHost):\(cfg.localPort) \(c.localReachable ? "✓" : "✗") \(c.localLatency >= 0 ? "(\(c.localLatency) ms)" : "")"
labelLocal.toolTip = c.tooltipForLocal() labelLocal.toolTip = c.tooltipForLocal()
// Port forwards
if let m = pfManager {
let running = m.runningCount
let total = m.details.count
lightPF.state = m.allRunning ? .green : .red
labelPF.stringValue = total > 0 ? "kubectl port-forwards: \(running)/\(total) running" : "kubectl port-forwards: none configured"
labelPF.toolTip = "Managed processes: \(running)/\(total) running"
renderPFDetails(m)
} else {
lightPF.state = .red
labelPF.stringValue = "kubectl port-forwards: disabled"
labelPF.toolTip = "To enable, set pf.enabled=true in prole.properties"
clearPFDetails()
}
needsLayout = true needsLayout = true
} }
// MARK: - PF Details rendering
private func clearPFDetails() {
pfDetailsStack.arrangedSubviews.forEach { v in
pfDetailsStack.removeArrangedSubview(v)
v.removeFromSuperview()
}
}
private func renderPFDetails(_ manager: PortForwardManager) {
clearPFDetails()
let details = manager.details
guard !details.isEmpty else { return }
for d in details {
let dot = TrafficLight()
dot.state = d.running ? .green : .red
let pidText: String
if let pid = d.pid, pid > 0 {
pidText = "PID \(pid)"
} else {
pidText = "PID -"
}
let label = NSTextField(labelWithString: "\(pidText) • \(d.running ? "running" : "stopped") — \(d.command)")
label.font = NSFont.systemFont(ofSize: NSFont.systemFontSize(for: .small))
label.textColor = .secondaryLabelColor
label.lineBreakMode = .byTruncatingMiddle
let row = NSStackView(views: [dot, label])
row.orientation = .horizontal
row.alignment = .centerY
row.spacing = 8
row.translatesAutoresizingMaskIntoConstraints = false
pfDetailsStack.addArrangedSubview(row)
}
}
} }

View File

@ -78,4 +78,26 @@ final class Config {
} }
return raw return raw
} }
// Dev port-forward supervision
var pfEnabled: Bool {
let v = (props["pf.enabled"] ?? "").lowercased()
return v == "1" || v == "true" || v == "yes" || v == "on"
}
var pfCommands: [String] {
// Collect keys pf.1, pf.2, ... in ascending order
let keys = props.keys
.filter { $0.hasPrefix("pf.") }
.compactMap { k -> (Int, String)? in
let tail = k.dropFirst(3)
if let idx = Int(tail) { return (idx, k) }
return nil
}
.sorted { $0.0 < $1.0 }
.map { $0.1 }
return keys.compactMap { props[$0] }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
} }

View File

@ -14,7 +14,7 @@ final class MainWindowController: NSWindowController {
private let statusView = AppStatusView() private let statusView = AppStatusView()
private let actions: Actions private let actions: Actions
init(serviceChecker: ServiceChecker, actions: Actions) { init(serviceChecker: ServiceChecker, actions: Actions, pfManager: PortForwardManager? = nil) {
// Initialize stored properties before calling super.init // Initialize stored properties before calling super.init
self.actions = actions self.actions = actions
let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable] let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
@ -71,6 +71,7 @@ final class MainWindowController: NSWindowController {
]) ])
statusView.bindTo(serviceChecker: serviceChecker) statusView.bindTo(serviceChecker: serviceChecker)
statusView.setPortForwardManager(pfManager)
} }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

View File

@ -2,9 +2,10 @@ import AppKit
final class OverlayWindowController: NSWindowController { final class OverlayWindowController: NSWindowController {
private let statusView = StatusView() private let statusView = StatusView()
private var pfManager: PortForwardManager?
// Fully transparent background; no visual effect overlay // Fully transparent background; no visual effect overlay
init(serviceChecker: ServiceChecker) { init(serviceChecker: ServiceChecker, pfManager: PortForwardManager? = nil) {
let style: NSWindow.StyleMask = [.borderless] let style: NSWindow.StyleMask = [.borderless]
let window = OverlayWindow(contentRect: .zero, styleMask: style, backing: .buffered, defer: false) let window = OverlayWindow(contentRect: .zero, styleMask: style, backing: .buffered, defer: false)
super.init(window: window) super.init(window: window)
@ -32,6 +33,8 @@ final class OverlayWindowController: NSWindowController {
]) ])
statusView.bindTo(serviceChecker: serviceChecker) statusView.bindTo(serviceChecker: serviceChecker)
self.pfManager = pfManager
statusView.setPortForwardManager(pfManager)
// Initial placement // Initial placement
recomputeFrameAndVisibility() recomputeFrameAndVisibility()
@ -99,12 +102,31 @@ final class OverlayWindowController: NSWindowController {
dlog("Enabling mouse events for context menu interaction") dlog("Enabling mouse events for context menu interaction")
window.ignoresMouseEvents = false window.ignoresMouseEvents = false
} }
let menu = NSMenu(title: "ProleStatus") let menu = NSMenu(title: "Prole")
menu.addItem(withTitle: statusView.aggregateStatusSummary(), action: nil, keyEquivalent: "") menu.addItem(withTitle: statusView.aggregateStatusSummary(), action: nil, keyEquivalent: "")
menu.addItem(.separator()) menu.addItem(.separator())
menu.addItem(withTitle: "Refresh Now", action: #selector(refreshNow), keyEquivalent: "r").target = self menu.addItem(withTitle: "Refresh Now", action: #selector(refreshNow), keyEquivalent: "r").target = self
if let mgr = pfManager {
menu.addItem(withTitle: "Reset Port Forwards", action: #selector(resetPortForwards), keyEquivalent: "").target = self
// Details submenu
let detailsItem = NSMenuItem(title: "Port Forward Details", action: nil, keyEquivalent: "")
let sub = NSMenu(title: "Port Forward Details")
for d in mgr.details {
let pid = d.pid != nil ? String(d.pid!) : "-"
let status = d.running ? "running" : "stopped"
let label = "PID \(pid) • \(status) — \(d.command)"
let it = NSMenuItem(title: label, action: nil, keyEquivalent: "")
sub.addItem(it)
}
if mgr.details.isEmpty {
sub.addItem(NSMenuItem(title: "No port-forwards configured", action: nil, keyEquivalent: ""))
}
detailsItem.submenu = sub
menu.addItem(detailsItem)
}
menu.addItem(.separator()) menu.addItem(.separator())
menu.addItem(withTitle: "Quit ProleStatus", action: #selector(quit), keyEquivalent: "q").target = self menu.addItem(withTitle: "Quit Prole", action: #selector(quit), keyEquivalent: "q").target = self
let point = NSPoint(x: window.frame.midX, y: window.frame.minY) let point = NSPoint(x: window.frame.midX, y: window.frame.minY)
menu.popUp(positioning: nil, at: point, in: nil) menu.popUp(positioning: nil, at: point, in: nil)
@ -123,6 +145,10 @@ final class OverlayWindowController: NSWindowController {
dlog("Quit requested via context menu; terminating app") dlog("Quit requested via context menu; terminating app")
NSApp.terminate(nil) NSApp.terminate(nil)
} }
@objc private func resetPortForwards() {
pfManager?.reset()
}
} }
final class OverlayWindow: NSWindow { final class OverlayWindow: NSWindow {

View File

@ -4,6 +4,7 @@ final class StatusView: NSView {
private let light1 = TrafficLight() private let light1 = TrafficLight()
private let light2 = TrafficLight() private let light2 = TrafficLight()
private let light3 = TrafficLight() private let light3 = TrafficLight()
private let lightPF = TrafficLight()
private let timestampLabel: NSTextField = { private let timestampLabel: NSTextField = {
let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 +00:00") let tf = NSTextField(labelWithString: "0000-00-00 00:00:00 +00:00")
tf.textColor = .secondaryLabelColor tf.textColor = .secondaryLabelColor
@ -46,7 +47,7 @@ final class StatusView: NSView {
wantsLayer = true wantsLayer = true
translatesAutoresizingMaskIntoConstraints = false translatesAutoresizingMaskIntoConstraints = false
let stack = NSStackView(views: [light1, light2, light3, timestampLabel, marquee, maximizeButton]) let stack = NSStackView(views: [light1, light2, light3, lightPF, timestampLabel, marquee, maximizeButton])
stack.orientation = .horizontal stack.orientation = .horizontal
stack.alignment = .centerY stack.alignment = .centerY
stack.distribution = .equalSpacing stack.distribution = .equalSpacing
@ -73,18 +74,32 @@ final class StatusView: NSView {
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func hitTest(_ point: NSPoint) -> NSView? { override func hitTest(_ point: NSPoint) -> NSView? {
// Allow clicks only on our buttons; pass everything else through // Allow interaction with our inline controls and status lights (for tooltips),
let v = super.hitTest(point) // pass other clicks through to avoid blocking the menu bar.
if let v = v as? NSButton { return v } 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 return nil
} }
private var pfManager: PortForwardManager?
func bindTo(serviceChecker: ServiceChecker) { func bindTo(serviceChecker: ServiceChecker) {
self.checker = serviceChecker self.checker = serviceChecker
NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker) NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker)
updateLights() 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() { @objc private func updateLights() {
guard let c = checker else { return } guard let c = checker else { return }
// Light 1: svc.prole.org:443 // Light 1: svc.prole.org:443
@ -100,6 +115,15 @@ final class StatusView: NSView {
light3.state = c.localReachable ? .green : .red light3.state = c.localReachable ? .green : .red
light3.toolTip = c.tooltipForLocal() 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 // Timestamp label
let now = Date() let now = Date()
timestampLabel.stringValue = timeFormatter.string(from: now) timestampLabel.stringValue = timeFormatter.string(from: now)