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 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:
"""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()
prole_root = str(project_root)
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
import platform
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:
@ -55,3 +59,59 @@ def build_prole_app(project_root: Path) -> None:
messagebox.showinfo("Copied", f"Copied to {dest}")
except Exception as 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 splashTipController: SplashTipWindowController?
private var appMenuToggleStatusBarItem: NSMenuItem?
private var pfManager: PortForwardManager?
// We keep the app in one of two simple modes.
// - 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 = ServiceChecker()
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
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker)
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker, pfManager: pfManager)
dlog("OverlayWindowController created; computing initial frame/visibility")
overlayWindowController.showIfMenuBarVisible()
@ -51,7 +62,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
mainWindowController = MainWindowController(serviceChecker: serviceChecker, actions: .init(
onRefresh: { [weak self] in self?.refreshNow() },
onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() }
))
), pfManager: pfManager)
dlog("MainWindowController created")
// Create status bar item with icon and click handler
@ -194,6 +205,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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
let refresh = NSMenuItem(title: "Refresh Now", action: #selector(menuRefresh), keyEquivalent: "r")
refresh.target = self
@ -202,7 +239,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
appMenu.addItem(NSMenuItem.separator())
// 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)
appMenuItem.submenu = appMenu
@ -219,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func menuToggleMode() { toggleMode() }
@objc private func menuRefresh() { refreshNow() }
@objc private func menuResetPortForwards() { pfManager?.reset() }
// Invoked by overlay maximize button
@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
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.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
}
// 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
}
// 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 actions: Actions
init(serviceChecker: ServiceChecker, actions: Actions) {
init(serviceChecker: ServiceChecker, actions: Actions, pfManager: PortForwardManager? = nil) {
// Initialize stored properties before calling super.init
self.actions = actions
let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
@ -71,6 +71,7 @@ final class MainWindowController: NSWindowController {
])
statusView.bindTo(serviceChecker: serviceChecker)
statusView.setPortForwardManager(pfManager)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

View File

@ -2,9 +2,10 @@ import AppKit
final class OverlayWindowController: NSWindowController {
private let statusView = StatusView()
private var pfManager: PortForwardManager?
// Fully transparent background; no visual effect overlay
init(serviceChecker: ServiceChecker) {
init(serviceChecker: ServiceChecker, pfManager: PortForwardManager? = nil) {
let style: NSWindow.StyleMask = [.borderless]
let window = OverlayWindow(contentRect: .zero, styleMask: style, backing: .buffered, defer: false)
super.init(window: window)
@ -32,6 +33,8 @@ final class OverlayWindowController: NSWindowController {
])
statusView.bindTo(serviceChecker: serviceChecker)
self.pfManager = pfManager
statusView.setPortForwardManager(pfManager)
// Initial placement
recomputeFrameAndVisibility()
@ -99,12 +102,31 @@ final class OverlayWindowController: NSWindowController {
dlog("Enabling mouse events for context menu interaction")
window.ignoresMouseEvents = false
}
let menu = NSMenu(title: "ProleStatus")
let menu = NSMenu(title: "Prole")
menu.addItem(withTitle: statusView.aggregateStatusSummary(), action: nil, keyEquivalent: "")
menu.addItem(.separator())
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(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)
menu.popUp(positioning: nil, at: point, in: nil)
@ -123,6 +145,10 @@ final class OverlayWindowController: NSWindowController {
dlog("Quit requested via context menu; terminating app")
NSApp.terminate(nil)
}
@objc private func resetPortForwards() {
pfManager?.reset()
}
}
final class OverlayWindow: NSWindow {

View File

@ -4,6 +4,7 @@ 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
@ -46,7 +47,7 @@ final class StatusView: NSView {
wantsLayer = true
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.alignment = .centerY
stack.distribution = .equalSpacing
@ -73,18 +74,32 @@ final class StatusView: NSView {
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func hitTest(_ point: NSPoint) -> NSView? {
// Allow clicks only on our buttons; pass everything else through
let v = super.hitTest(point)
if let v = v as? NSButton { return v }
// 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
@ -100,6 +115,15 @@ final class StatusView: NSView {
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)