mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 18:44:33 +00:00
- 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)
287 lines
13 KiB
Swift
287 lines
13 KiB
Swift
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 status‑bar 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 click‑through 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 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)
|
||
|
||
// 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 2‑state 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)
|
||
}
|
||
}
|