prole/prole-app/Sources/AppDelegate.swift
chrisfu b2cbe72c4a ServiceChecker: background refresh + Preferences reload; PF crash fix; Prefs Save commit inputs
- ServiceChecker now runs as a background timer task (30s), skips overlapping runs, and caches per-endpoint state from Preferences (services + kubernetes). Legacy UI flags are kept in sync for StatusView/Overlay.
- Auto-reload on Preferences save: ServiceChecker listens to Config.didChange, clears caches, invalidates freshness, and triggers an immediate refresh.
- Preferences: ensure in-progress text edits are committed before saving (endEditing) for Kubernetes, Services, and Ports tabs.
- PortForwardManager: fix crash in termination handler by reading terminationStatus from the provided Process instance, then updating state on the manager queue.
- Minor: keep existing UI wiring; logging intact for diagnostics.

TODO: remove (list unused code paths)
- Config.primaryKube (unused in prole-app)
- Config.string(_:, default:) (unused in prole-app)
- Config.int(_:, default:) (unused in prole-app)
- PortForwardManager.parsePFCommand tuple parts `modeBackground` and `keepAlive` (never read by callers)
- Config.pfModeBackground and Config.pfKeepAlive (currently ineffective because their values aren’t used downstream)
2025-12-09 18:16:14 -08:00

287 lines
13 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 workstationWindowController: WorkstationWindowController!
private var ircWindowController: IRCWindowController!
private var hotKeyManager: HotKeyManager!
private var serviceChecker: ServiceChecker!
private var statusItemController: StatusItemController!
// Splash screen removed keep no reference
private var appMenuToggleStatusBarItem: NSMenuItem?
private var pfManager: PortForwardManager?
private var preferencesWindowController: PreferencesWindowController?
// 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 statusbar utilities)
NSApp.setActivationPolicy(.regular)
dlog("Activation policy set to .regular (starting in App Window mode)")
// Splash removed: start directly
// 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 clickthrough window near the top
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker, pfManager: pfManager)
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() }
), pfManager: pfManager)
dlog("MainWindowController created")
// Workstation window (VNC viewer) shown to the right of Prole Status when not in status bar mode
workstationWindowController = WorkstationWindowController()
dlog("WorkstationWindowController created")
// IRC window short vertically, long horizontally, positioned below Prole Status
ircWindowController = IRCWindowController()
dlog("IRCWindowController created")
// Create status bar item with icon and click handler
// Status bar item with a monospaced "P" and its own rightclick 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)
// No splash controller to release
// Build application menu (shown when activation policy is .regular)
setupApplicationMenu()
// Observe config changes: refresh status on save
NotificationCenter.default.addObserver(self, selector: #selector(menuRefresh), name: Config.didChangeNotification, object: nil)
// Start in Application Window mode by default
// Ensure main window is positioned at the left and visible immediately
if let mainWin = mainWindowController.window { MainWindowController.positionWindowAtLeftEdge(mainWin) }
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 2state switch
switch mode {
case .statusBar:
switchToAppWindowMode()
case .appWindow:
switchToStatusBarMode()
}
}
private func switchToStatusBarMode() {
mode = .statusBar
mainWindowController.hide()
workstationWindowController.hide()
ircWindowController.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()
// Position workstation window to the right of the main window and show it
if let ref = mainWindowController.window { workstationWindowController.position(rightOf: ref) }
workstationWindowController.show()
// Position IRC window below the main window and show it
if let ref = mainWindowController.window { ircWindowController.position(below: ref) }
ircWindowController.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()
workstationWindowController.hide()
ircWindowController.hide()
if mode == .appWindow { mode = .statusBar }
} else {
mainWindowController.show()
if let ref = mainWindowController.window { workstationWindowController.position(rightOf: ref) }
workstationWindowController.show()
if let ref = mainWindowController.window { ircWindowController.position(below: ref) }
ircWindowController.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
// Preferences
let prefs = NSMenuItem(title: "Preferences…", action: #selector(menuPreferences), keyEquivalent: ",")
prefs.keyEquivalentModifierMask = [.command]
prefs.target = self
appMenu.addItem(prefs)
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)
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 Prole", 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() }
@objc private func menuResetPortForwards() { pfManager?.reset() }
@objc private func menuPreferences() {
if preferencesWindowController == nil {
preferencesWindowController = PreferencesWindowController()
}
preferencesWindowController?.showWindow(nil)
NSApp.activate(ignoringOtherApps: true)
}
// Invoked by overlay maximize button
@objc private func showMainWindowRequested() {
switchToAppWindowMode()
// Maximize (zoom) the window to ensure it's fully visible/readable
mainWindowController.window?.zoom(nil)
}
}