mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:54:32 +00:00
- Deleted `init-port-forwards.sh`, removing deprecated placeholder script. - Introduced `dbStatus` command in `PFScriptBridge.swift` for querying database status with `kubecolor`. - Improved `StatusView` UI: refined button interactivity and font standardization. - Added `DBStatusWindowController` to `AppDelegate` for better database status visibility and control. - Updated `OverlayWindow`: adjusted window levels and interaction behavior for smoother UX.
322 lines
15 KiB
Swift
322 lines
15 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 dbStatusWindowController: DBStatusWindowController!
|
||
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?
|
||
// Port-forwarding is now managed by a user LaunchAgent. Keep a small helper.
|
||
private let launchAgentManager = LaunchAgentManager()
|
||
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")
|
||
// Bootstrap PROLE_HOME directories and config/log files
|
||
ProleEnv.bootstrap()
|
||
Logger.shared.info("Prole Tools starting up")
|
||
// 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
|
||
|
||
// No more helper script bundling. All shell commands go via $PROLE_HOME/env.sh.
|
||
|
||
// ServiceChecker does the lightweight TCP checks on a background timer
|
||
serviceChecker = ServiceChecker()
|
||
dlog("ServiceChecker created")
|
||
// Note: In-app port-forward supervision has been removed.
|
||
// Port-forwards are handled by LaunchAgents. Nothing to start here.
|
||
|
||
// Overlay (Status Bar mode) — a thin, mostly click‑through window near the top
|
||
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker, pfManager: nil)
|
||
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: nil)
|
||
dlog("MainWindowController created")
|
||
|
||
// Database status window — positioned slightly down and left of main window
|
||
dbStatusWindowController = DBStatusWindowController(actions: .init(
|
||
onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() }
|
||
))
|
||
dlog("DBStatusWindowController 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()
|
||
dbStatusWindowController.hide()
|
||
workstationWindowController.hide()
|
||
ircWindowController.hide()
|
||
overlayWindowController.showOverlayNow()
|
||
NSApp.setActivationPolicy(.accessory)
|
||
// Ensure the status bar icon is visible while in minimized scrolling status bar mode
|
||
statusItemController.showStatusItem()
|
||
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: false)
|
||
dlog("Switched to Status Bar mode")
|
||
updateApplicationMenuTitles()
|
||
}
|
||
|
||
private func switchToAppWindowMode() {
|
||
mode = .appWindow
|
||
overlayWindowController.hideOverlay()
|
||
mainWindowController.show()
|
||
// Position database status window slightly down and to the left of the main window
|
||
if let ref = mainWindowController.window { dbStatusWindowController.position(relativeTo: ref) }
|
||
dbStatusWindowController.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)
|
||
// Hide the status bar icon while the main window UI is active (optional per requirements)
|
||
statusItemController.hideStatusItem()
|
||
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()
|
||
dbStatusWindowController.hide()
|
||
workstationWindowController.hide()
|
||
ircWindowController.hide()
|
||
if mode == .appWindow { mode = .statusBar }
|
||
} else {
|
||
mainWindowController.show()
|
||
if let ref = mainWindowController.window {
|
||
dbStatusWindowController.position(relativeTo: ref)
|
||
workstationWindowController.position(rightOf: ref)
|
||
ircWindowController.position(below: ref)
|
||
}
|
||
dbStatusWindowController.show()
|
||
workstationWindowController.show()
|
||
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 Tools")
|
||
// 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())
|
||
|
||
// Restart Port Forwards (delegates to etc/init-port-fowards.sh)
|
||
let resetPF = NSMenuItem(title: "Restart 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 Tools", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q")
|
||
appMenu.addItem(quit)
|
||
|
||
appMenuItem.submenu = appMenu
|
||
|
||
// Add a standard Edit menu so Copy/Select All route to the first responder (e.g., our NSTextView)
|
||
let editMenuItem = NSMenuItem()
|
||
mainMenu.addItem(editMenuItem)
|
||
let editMenu = NSMenu(title: "Edit")
|
||
// Standard items; targets left nil so they go to first responder
|
||
let copy = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||
copy.keyEquivalentModifierMask = [.command]
|
||
editMenu.addItem(copy)
|
||
let selectAll = NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||
selectAll.keyEquivalentModifierMask = [.command]
|
||
editMenu.addItem(selectAll)
|
||
editMenuItem.submenu = editMenu
|
||
|
||
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() {
|
||
// User-triggered restart remains available, but we do not auto-run it anywhere else.
|
||
_ = PFScriptBridge.restart()
|
||
}
|
||
@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() {
|
||
dlog("AppDelegate: showMainWindowRequested notification received")
|
||
switchToAppWindowMode()
|
||
// Maximize (zoom) the window to ensure it's fully visible/readable
|
||
mainWindowController.window?.zoom(nil)
|
||
}
|
||
}
|