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 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") // 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() 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 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() ircWindowController.hide() if mode == .appWindow { mode = .statusBar } } else { mainWindowController.show() if let ref = mainWindowController.window { dbStatusWindowController.position(relativeTo: ref) ircWindowController.position(below: ref) } dbStatusWindowController.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) } }