prole/prole-app/Sources/AppDelegate.swift
chrisfu 197c72dd7a Refactor prole-app and establish temporary release process
- Moved prole-tools-app to prole-app at the project root to make it self-contained for transition to its own repository.
- Created prole-tools-app/dist/ directory to host build artifacts.
- Generated distribution artifacts (Prole Tools.app and Prole Tools.zip) using prole-app/build.sh package.
- Checked in the generated artifacts to prole-tools-app/dist/ (bypassing .gitignore for temporary release process).
Changes Summary
•
Renamed directory prole-tools-app/ to prole-app/.
•
Populated prole-tools-app/dist/ with the latest build output from prole-app/build.sh.
•
Staged all changes, including the forced addition of ignored artifacts in prole-tools-app/dist/.
2026-01-18 15:04:23 -08:00

322 lines
15 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 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 statusbar 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 clickthrough 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 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()
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)
}
}