prole/prole-app/Sources/AppDelegate.swift

250 lines
11 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?
private var pfManager: PortForwardManager?
// 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")
// 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")
// 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())
// 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() }
// 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)
}
}