prole/prole-app/Sources/AppDelegate.swift
chrisfu 8a6e8738db 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
2025-12-01 21:08:30 -08:00

228 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AppKit
// AppDelegate is the app "traffic controller".
// It wires up:
// - the status bar overlay window (Status Bar mode)
// - the regular main window (Application Window mode)
// - the global hotkey Cmd+Opt+Shift+P to toggle modes
// - the top application menu (Prole → Show/Hide, Refresh, Quit)
//
// Tip for learners:
// In macOS apps, NSApplication + NSApp.run() starts the event loop.
// AppDelegate receives lifecycle callbacks like applicationDidFinishLaunching.
final class AppDelegate: NSObject, NSApplicationDelegate {
private var overlayWindowController: OverlayWindowController!
private var mainWindowController: MainWindowController!
private var hotKeyManager: HotKeyManager!
private var serviceChecker: ServiceChecker!
private var statusItemController: StatusItemController!
private var splashTipController: SplashTipWindowController?
private var appMenuToggleStatusBarItem: NSMenuItem?
// We keep the app in one of two simple modes.
// - statusBar: shows the thin overlay near the macOS menu bar
// - appWindow: shows the regular resizable window
private enum Mode { case statusBar, appWindow }
private var mode: Mode = .appWindow
func applicationDidFinishLaunching(_ notification: Notification) {
dlog("applicationDidFinishLaunching")
// We'll start in Application Window mode and use regular activation policy
// .regular = normal app with menu bar and windows
// .accessory = utility app without a Dock icon/menu bar (good for status‑bar utilities)
NSApp.setActivationPolicy(.regular)
dlog("Activation policy set to .regular (starting in App Window mode)")
// Show startup tip splash for 5 seconds with animated GIF and counter
let splash = SplashTipWindowController()
self.splashTipController = splash
splash.show()
// ServiceChecker does the lightweight TCP checks on a background timer
serviceChecker = ServiceChecker()
dlog("ServiceChecker created")
// Overlay (Status Bar mode) — a thin, mostly click‑through window near the top
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker)
dlog("OverlayWindowController created; computing initial frame/visibility")
overlayWindowController.showIfMenuBarVisible()
// Main window (Application Window mode) — vertical, simple status view
mainWindowController = MainWindowController(serviceChecker: serviceChecker, actions: .init(
onRefresh: { [weak self] in self?.refreshNow() },
onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() }
))
dlog("MainWindowController created")
// Create status bar item with icon and click handler
// Status bar item with a monospaced "P" and its own right‑click menu
statusItemController = StatusItemController(actions: .init(
onLeftClick: { [weak self] in
guard let self = self else { return }
switch self.mode {
case .statusBar: self.overlayWindowController.showOverlayNow()
case .appWindow: self.mainWindowController.show()
}
},
onToggleStatusBar: { [weak self] in self?.toggleStatusBarVisibility() },
onToggleAppWindow: { [weak self] in self?.toggleAppWindowVisibility() },
onRefresh: { [weak self] in self?.refreshNow() },
onQuit: { NSApp.terminate(nil) }
))
dlog("Status bar item created")
// Register the global hotkey: Cmd+Opt+Shift+P → toggle modes
hotKeyManager = HotKeyManager()
hotKeyManager.registerGlobalHotKey(modifiers: [.command, .option, .shift], key: .p) { [weak self] in
self?.toggleMode()
}
dlog("Global hotkey registered (Cmd+Opt+Shift+P) — toggle modes")
// Observe screen/space/menu bar visibility changes
// (So the overlay can reposition itself correctly.)
DistributedNotificationCenter.default().addObserver(self, selector: #selector(handleConfigChange), name: NSNotification.Name("com.apple.HIToolbox.inputSourceChanged"), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(handleConfigChange), name: NSApplication.didChangeScreenParametersNotification, object: nil)
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(spaceChanged), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
// Observe request to show main window from overlay maximize button
NotificationCenter.default.addObserver(self, selector: #selector(showMainWindowRequested), name: .showMainWindow, object: nil)
// Observe toggle mode request (simulate Cmd+Opt+Shift+P)
NotificationCenter.default.addObserver(self, selector: #selector(menuToggleMode), name: .toggleMode, object: nil)
// Check status every 30 seconds
// (Learner tweak: change this interval to refresh more/less often.)
dlog("Starting ServiceChecker timer (interval: 30s)")
serviceChecker.start(interval: 30)
// Release the splash controller reference shortly after it is expected to auto-dismiss
DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) { [weak self] in
self?.splashTipController = nil
}
// Build application menu (shown when activation policy is .regular)
setupApplicationMenu()
// Start in Application Window mode by default
switchToAppWindowMode()
// Ensure menu reflects current visibility
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: mainWindowController.isVisible)
updateApplicationMenuTitles()
}
@objc private func handleConfigChange() {
dlog("handleConfigChange → recomputeFrameAndVisibility")
if mode == .statusBar { overlayWindowController.recomputeFrameAndVisibility() }
}
@objc private func spaceChanged() {
dlog("spaceChanged → recomputeFrameAndVisibility")
if mode == .statusBar { overlayWindowController.recomputeFrameAndVisibility() }
}
// MARK: - Mode and Actions
private func toggleMode() {
// Simple 2‑state switch
switch mode {
case .statusBar:
switchToAppWindowMode()
case .appWindow:
switchToStatusBarMode()
}
}
private func switchToStatusBarMode() {
mode = .statusBar
mainWindowController.hide()
overlayWindowController.showOverlayNow()
NSApp.setActivationPolicy(.accessory)
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: false)
dlog("Switched to Status Bar mode")
updateApplicationMenuTitles()
}
private func switchToAppWindowMode() {
mode = .appWindow
overlayWindowController.hideOverlay()
mainWindowController.show()
NSApp.setActivationPolicy(.regular)
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: true)
dlog("Switched to Application Window mode")
updateApplicationMenuTitles()
}
private func toggleStatusBarVisibility() {
if statusItemController.isVisible {
statusItemController.hideStatusItem()
} else {
statusItemController.showStatusItem()
}
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: (mode == .appWindow && mainWindowController.isVisible))
updateApplicationMenuTitles()
}
private func toggleAppWindowVisibility() {
if mainWindowController.isVisible {
mainWindowController.hide()
if mode == .appWindow { mode = .statusBar }
} else {
mainWindowController.show()
mode = .appWindow
overlayWindowController.hideOverlay()
}
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: mainWindowController.isVisible)
}
private func refreshNow() {
// Broadcast a notification; ServiceChecker listens and forces a check.
NotificationCenter.default.post(name: ServiceChecker.forceRefreshNotification, object: nil)
}
// MARK: - Application Menu
private func setupApplicationMenu() {
// Build a minimal menu programmatically to keep the project small
let mainMenu = NSMenu(title: "MainMenu")
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu(title: "Prole")
// Toggle between Application Window and Status Bar modes (same as Cmd+Opt+Shift+P)
// Title updates dynamically via updateApplicationMenuTitles()
let toggle = NSMenuItem(title: "Show Status Bar", action: #selector(menuToggleMode), keyEquivalent: "p")
toggle.keyEquivalentModifierMask = [.command, .option, .shift]
toggle.target = self
appMenu.addItem(toggle)
self.appMenuToggleStatusBarItem = toggle
appMenu.addItem(NSMenuItem.separator())
// Refresh
let refresh = NSMenuItem(title: "Refresh Now", action: #selector(menuRefresh), keyEquivalent: "r")
refresh.target = self
appMenu.addItem(refresh)
appMenu.addItem(NSMenuItem.separator())
// Quit
let quit = NSMenuItem(title: "Quit ProleStatus", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q")
appMenu.addItem(quit)
appMenuItem.submenu = appMenu
NSApp.mainMenu = mainMenu
}
private func updateApplicationMenuTitles() {
if let item = appMenuToggleStatusBarItem {
// When we're showing the app window, offer to "Show Status Bar" (i.e., switch to status bar mode)
// When we're in status bar mode, offer to "Show Main Window" (i.e., switch to app window mode)
item.title = (mode == .appWindow) ? "Show Status Bar" : "Show Main Window"
}
}
@objc private func menuToggleMode() { toggleMode() }
@objc private func menuRefresh() { refreshNow() }
// Invoked by overlay maximize button
@objc private func showMainWindowRequested() {
switchToAppWindowMode()
}
}