prole/prole-tools-app/Sources/PreferencesWindowController.swift
chrisfu 0e01595de0 refactor: switch to environment wrapper; standardize env.sh usage and improve $PROLE_HOME management
- Replace direct script invocation with `$PROLE_HOME/env.sh` wrapper for consistent environment setup across all components.
- Update runtime processes to dynamically resolve `$PROLE_HOME` or fallback to `$HOME/.prole`.
- Deprecate `init-port-forwards.sh` script bundling; remove from LaunchAgentManager and build system.
- Overhaul `env.sh` generation to include execution capability, customizable paths, and improved error handling.
- Standardize app name and paths to "Prole Tools" across all files and UI elements.
- Adjust build output to align with new structure, including executable naming and resource packaging.
2025-12-15 22:00:32 -08:00

588 lines
26 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
final class PreferencesWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSTabViewDelegate {
private enum Tab: Int { case kubernetes = 0, services = 1, ports = 2, agentPF = 3 }
private var kubeItems: [Config.KubernetesEndpoint] = []
private var serviceItems: [Config.ServiceEndpoint] = []
private var portItems: [PortMappingXML] = []
private let tabView = NSTabView()
private let launchAgentManager = LaunchAgentManager()
private let agentCommandsTextView = NSTextView()
private var didCenterOnce = false
// Tables
private let kubeTable = NSTableView()
private let svcTable = NSTableView()
private let portTable = NSTableView()
convenience init() {
let rect = NSRect(x: 0, y: 0, width: 640, height: 400)
// Make Preferences window resizable to avoid cramped layouts
let window = NSWindow(contentRect: rect, styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
self.init(window: window)
window.isReleasedWhenClosed = false
window.title = "Preferences"
// Ensure the window has a sensible minimum size so controls remain visible
window.contentMinSize = NSSize(width: 520, height: 320)
// Let macOS remember user-adjusted placement on subsequent opens
window.setFrameAutosaveName("PreferencesWindow")
kubeItems = Config.shared.kubernetes
serviceItems = Config.shared.services
portItems = PortMappingsXMLStore.load()
setupUI()
}
override func showWindow(_ sender: Any?) {
super.showWindow(sender)
// Center only on first show so subsequent shows restore autosaved frame
if !didCenterOnce {
window?.center()
didCenterOnce = true
}
NSApp.activate(ignoringOtherApps: true)
}
private func setupUI() {
guard let content = window?.contentView else { return }
tabView.translatesAutoresizingMaskIntoConstraints = false
// Make sure tabs are at the top and styled like standard preferences
tabView.tabPosition = .top
if #available(macOS 11.0, *) {
tabView.tabViewType = .topTabsBezelBorder
} else {
tabView.tabViewType = .topTabsBezelBorder
}
tabView.delegate = self
content.addSubview(tabView)
// Prefer the window's contentLayoutGuide to avoid title-bar overlap and ensure proper insets
if let guide = window?.contentLayoutGuide as? NSLayoutGuide {
NSLayoutConstraint.activate([
tabView.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 12),
tabView.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -12),
tabView.topAnchor.constraint(equalTo: guide.topAnchor, constant: 12),
tabView.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -12)
])
} else {
NSLayoutConstraint.activate([
tabView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12),
tabView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12),
tabView.topAnchor.constraint(equalTo: content.topAnchor, constant: 12),
tabView.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12)
])
}
// Tabs
addKubernetesTab()
addServicesTab()
addPortsTab()
addAgentPortForwardsTab()
// Ensure initial data is visible on all tabs
kubeTable.reloadData()
svcTable.reloadData()
portTable.reloadData()
// Load agent commands
loadAgentCommandsIntoEditor()
// Column sizing and layout stabilization
sizeColumnsAndLayout(table: kubeTable)
sizeColumnsAndLayout(table: svcTable)
sizeColumnsAndLayout(table: portTable)
// Ensure tab content views adopt the tab's content rect and resize with it
syncAllTabContentFrames()
tabView.selectTabViewItem(at: 0)
// Observe window resizes to keep current tab content sized correctly
if let win = window {
NotificationCenter.default.addObserver(self, selector: #selector(windowDidResize), name: NSWindow.didResizeNotification, object: win)
}
}
// MARK: - Tabs
private func addKubernetesTab() {
let item = NSTabViewItem(identifier: Tab.kubernetes.rawValue)
item.label = "Kubernetes"
let view = makeTableContainer(table: kubeTable, columns: [
(identifier: NSUserInterfaceItemIdentifier("host"), title: "Hostname", width: 360),
(identifier: NSUserInterfaceItemIdentifier("port"), title: "Port", width: 100)
], addAction: #selector(addKube), removeAction: #selector(removeKube), saveAction: #selector(saveKube))
adoptTabContentSizing(view)
item.view = view
tabView.addTabViewItem(item)
}
private func addServicesTab() {
let item = NSTabViewItem(identifier: Tab.services.rawValue)
item.label = "Services"
let view = makeTableContainer(table: svcTable, columns: [
(identifier: NSUserInterfaceItemIdentifier("name"), title: "Service Name", width: 180),
(identifier: NSUserInterfaceItemIdentifier("host"), title: "Hostname", width: 260),
(identifier: NSUserInterfaceItemIdentifier("port"), title: "Port", width: 100)
], addAction: #selector(addService), removeAction: #selector(removeService), saveAction: #selector(saveService))
adoptTabContentSizing(view)
item.view = view
tabView.addTabViewItem(item)
}
private func addPortsTab() {
let item = NSTabViewItem(identifier: Tab.ports.rawValue)
item.label = "Ports"
let view = makeTableContainer(table: portTable, columns: [
(identifier: NSUserInterfaceItemIdentifier("service"), title: "Target (svc/deploy/pod)", width: 260),
(identifier: NSUserInterfaceItemIdentifier("namespace"), title: "Namespace", width: 160),
(identifier: NSUserInterfaceItemIdentifier("expose"), title: "Host Port", width: 100),
(identifier: NSUserInterfaceItemIdentifier("internal"), title: "Service Port", width: 100)
], addAction: #selector(addPort), removeAction: #selector(removePort), saveAction: #selector(savePort))
adoptTabContentSizing(view)
item.view = view
tabView.addTabViewItem(item)
}
private func addAgentPortForwardsTab() {
let item = NSTabViewItem(identifier: Tab.agentPF.rawValue)
item.label = "Port Forwards (Agent)"
let container = NSView()
container.translatesAutoresizingMaskIntoConstraints = true
container.autoresizingMask = [.width, .height]
let scroll = NSScrollView()
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.hasVerticalScroller = true
scroll.documentView = agentCommandsTextView
agentCommandsTextView.isVerticallyResizable = true
agentCommandsTextView.isHorizontallyResizable = true
agentCommandsTextView.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
agentCommandsTextView.autoresizingMask = [.width, .height]
let helpLabel = NSTextField(labelWithString: "One kubectl command per line. These are stored in your LaunchAgent plist and run by a helper script.")
helpLabel.lineBreakMode = .byWordWrapping
helpLabel.translatesAutoresizingMaskIntoConstraints = false
let saveButton = NSButton(title: "Save", target: self, action: #selector(saveAgentCommands))
saveButton.bezelStyle = .rounded
let reloadButton = NSButton(title: "Reload Agent", target: self, action: #selector(reloadAgent))
reloadButton.bezelStyle = .rounded
let hstack = NSStackView(views: [helpLabel, NSView(), saveButton, reloadButton])
hstack.translatesAutoresizingMaskIntoConstraints = false
hstack.orientation = .horizontal
hstack.alignment = .centerY
hstack.spacing = 8
container.addSubview(scroll)
container.addSubview(hstack)
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: container.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: container.trailingAnchor),
scroll.topAnchor.constraint(equalTo: container.topAnchor),
scroll.bottomAnchor.constraint(equalTo: hstack.topAnchor, constant: -8),
hstack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
hstack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
hstack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
])
adoptTabContentSizing(container)
item.view = container
tabView.addTabViewItem(item)
}
private func loadAgentCommandsIntoEditor() {
let cmds = launchAgentManager.readCommands()
agentCommandsTextView.string = cmds.joined(separator: "\n")
}
@objc private func saveAgentCommands() {
let raw = agentCommandsTextView.string
let cmds = raw
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
launchAgentManager.writeCommands(cmds)
}
@objc private func reloadAgent() {
saveAgentCommands()
launchAgentManager.resetAgent()
}
private func makeTableContainer(table: NSTableView, columns: [(identifier: NSUserInterfaceItemIdentifier, title: String, width: CGFloat)], addAction: Selector, removeAction: Selector, saveAction: Selector) -> NSView {
let scroll = NSScrollView()
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.autohidesScrollers = true
scroll.autoresizesSubviews = true
scroll.contentView.copiesOnScroll = false
// Important: use frame-based layout inside NSScrollView's documentView to ensure visibility on all tabs
table.translatesAutoresizingMaskIntoConstraints = true
table.autoresizingMask = [.width, .height]
table.headerView = NSTableHeaderView()
table.usesAlternatingRowBackgroundColors = true
table.allowsColumnReordering = false
table.allowsColumnResizing = true
table.usesAutomaticRowHeights = false
table.rowHeight = 28
table.intercellSpacing = NSSize(width: 4, height: 4)
// Using sequential autoresizing prevents later columns from collapsing when the view first appears
table.columnAutoresizingStyle = .sequentialColumnAutoresizingStyle
table.delegate = self
table.dataSource = self
// Columns
for c in columns {
let col = NSTableColumn(identifier: c.identifier)
col.title = c.title
// Allow user resizing while also participating in automatic resizing
col.resizingMask = [.autoresizingMask, .userResizingMask]
col.minWidth = max(80, c.width * 0.4)
col.maxWidth = max(c.width * 2.0, col.minWidth + 40)
col.width = c.width
// Fit to header initially to ensure visibility even before rows render
col.sizeToFit()
// Enforce requested minimum character widths per tab/column
if table === svcTable {
if c.identifier.rawValue == "name" || c.identifier.rawValue == "host" {
let minChars = widthForChars(32) + 12 // padding
col.minWidth = max(col.minWidth, minChars)
// equal starting widths for name and host; keep port smaller
}
} else if table === portTable {
if c.identifier.rawValue == "service" {
let minChars = widthForChars(32) + 12
col.minWidth = max(col.minWidth, minChars)
}
}
table.addTableColumn(col)
}
// Set an initial frame and attach as documentView
table.frame = NSRect(origin: .zero, size: NSSize(width: 800, height: 600))
scroll.documentView = table
// Buttons
let addButton = NSButton(title: "+", target: self, action: addAction)
addButton.bezelStyle = .texturedRounded
let removeButton = NSButton(title: "", target: self, action: removeAction)
removeButton.bezelStyle = .texturedRounded
let saveButton = NSButton(title: "Save", target: self, action: saveAction)
saveButton.bezelStyle = .rounded
let spacer = NSView()
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let buttons = NSStackView(views: [addButton, removeButton, spacer, saveButton])
buttons.orientation = .horizontal
buttons.alignment = .centerY
buttons.spacing = 8
buttons.distribution = .fill
buttons.translatesAutoresizingMaskIntoConstraints = false
let container = NSView()
// Very important: NSTabView expects its item.view to be frame-based. If we
// disable translatesAutoresizingMaskIntoConstraints on this container, the
// tab content can collapse to a tiny area. Keep it frame-based and let the
// tab view drive its size via frame/autoresizing.
container.translatesAutoresizingMaskIntoConstraints = true
container.autoresizingMask = [.width, .height]
container.addSubview(scroll)
container.addSubview(buttons)
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: container.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: container.trailingAnchor),
scroll.topAnchor.constraint(equalTo: container.topAnchor),
scroll.bottomAnchor.constraint(equalTo: buttons.topAnchor, constant: -8),
buttons.leadingAnchor.constraint(equalTo: container.leadingAnchor),
buttons.trailingAnchor.constraint(equalTo: container.trailingAnchor),
buttons.bottomAnchor.constraint(equalTo: container.bottomAnchor),
// Ensure a minimal height for the buttons bar so it doesn't collapse
buttons.heightAnchor.constraint(greaterThanOrEqualToConstant: 32)
])
return container
}
// MARK: - NSTabViewDelegate
func tabView(_ tabView: NSTabView, didSelect tabViewItem: NSTabViewItem?) {
// Refresh data when switching tabs to avoid stale/blank views
kubeTable.reloadData()
svcTable.reloadData()
portTable.reloadData()
// Force layout for the newly visible table to avoid zero-sized text fields
if let id = tabViewItem?.identifier as? Int, let tab = Tab(rawValue: id) {
// Ensure the selected tab's content view fills the tab content area
if let v = tabViewItem?.view { adoptTabContentSizing(v) }
switch tab {
case .kubernetes: forceLayout(for: kubeTable)
case .services: forceLayout(for: svcTable)
case .ports: forceLayout(for: portTable)
case .agentPF: break
}
}
}
// MARK: - Actions
@objc private func addKube() {
kubeItems.append(.init(host: "", port: 6443))
kubeTable.reloadData()
}
@objc private func removeKube() {
let row = kubeTable.selectedRow
if row >= 0 && row < kubeItems.count {
kubeItems.remove(at: row)
kubeTable.reloadData()
}
}
@objc private func saveKube() {
// Commit any in-progress text edits to our backing arrays
window?.endEditing(for: nil)
Config.shared.kubernetes = kubeItems
Config.shared.save()
}
@objc private func addService() {
serviceItems.append(.init(name: "", host: "", port: 0))
svcTable.reloadData()
}
@objc private func removeService() {
let row = svcTable.selectedRow
if row >= 0 && row < serviceItems.count {
serviceItems.remove(at: row)
svcTable.reloadData()
}
}
@objc private func saveService() {
// Commit any in-progress text edits to our backing arrays
window?.endEditing(for: nil)
Config.shared.services = serviceItems
Config.shared.save()
}
@objc private func addPort() {
portItems.append(.init(id: "", namespace: "default", target: "svc/", address: "127.0.0.1", hostPort: "", servicePort: "", proto: "TCP", description: ""))
portTable.reloadData()
}
@objc private func removePort() {
let row = portTable.selectedRow
if row >= 0 && row < portItems.count {
portItems.remove(at: row)
portTable.reloadData()
}
}
@objc private func savePort() {
// Commit any in-progress text edits to our backing arrays
window?.endEditing(for: nil)
do {
try PortMappingsXMLStore.save(portItems)
} catch {
NSSound.beep()
}
}
// MARK: - NSTableView
func numberOfRows(in tableView: NSTableView) -> Int {
if tableView === kubeTable { return kubeItems.count }
if tableView === svcTable { return serviceItems.count }
if tableView === portTable { return portItems.count }
return 0
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
guard let tableColumn = tableColumn else { return nil }
let colId = tableColumn.identifier
// Reuse a cell per column identifier
let cell: NSTableCellView
if let reused = tableView.makeView(withIdentifier: colId, owner: self) as? NSTableCellView {
cell = reused
} else {
let newCell = NSTableCellView()
newCell.identifier = colId
let tf = NSTextField()
tf.isBordered = true
tf.isEditable = true
tf.lineBreakMode = .byTruncatingTail
tf.controlSize = .regular
tf.font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
tf.setContentHuggingPriority(.defaultLow, for: .horizontal)
tf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
tf.target = self
tf.action = #selector(cellEdited(_:))
tf.translatesAutoresizingMaskIntoConstraints = false
newCell.addSubview(tf)
newCell.textField = tf
NSLayoutConstraint.activate([
tf.leadingAnchor.constraint(equalTo: newCell.leadingAnchor, constant: 4),
tf.trailingAnchor.constraint(equalTo: newCell.trailingAnchor, constant: -4),
tf.topAnchor.constraint(equalTo: newCell.topAnchor, constant: 2),
tf.bottomAnchor.constraint(equalTo: newCell.bottomAnchor, constant: -2)
])
cell = newCell
}
guard let tf = cell.textField else { return cell }
tf.tag = row
if tableView === kubeTable {
let item = kubeItems[row]
if colId.rawValue == "host" { tf.stringValue = item.host }
else if colId.rawValue == "port" { tf.stringValue = String(item.port) }
} else if tableView === svcTable {
let item = serviceItems[row]
if colId.rawValue == "name" { tf.stringValue = item.name }
else if colId.rawValue == "host" { tf.stringValue = item.host }
else if colId.rawValue == "port" { tf.stringValue = String(item.port) }
} else if tableView === portTable {
let item = portItems[row]
if colId.rawValue == "service" { tf.stringValue = item.target }
else if colId.rawValue == "namespace" { tf.stringValue = item.namespace }
else if colId.rawValue == "expose" { tf.stringValue = item.hostPort }
else if colId.rawValue == "internal" { tf.stringValue = item.servicePort }
}
return cell
}
@objc private func cellEdited(_ sender: NSTextField) {
// Find table and column from hierarchy
var v: NSView? = sender
var table: NSTableView?
var cell: NSTableCellView?
while let cur = v {
if let tv = cur as? NSTableView { table = tv; break }
if let c = cur as? NSTableCellView { cell = c }
v = cur.superview
}
guard let tv = table, let cid = cell?.identifier?.rawValue else { return }
let row = sender.tag
let val = sender.stringValue
if tv === kubeTable {
if cid == "host" { kubeItems[row].host = val }
else if cid == "port" { kubeItems[row].port = Int(val) ?? 6443 }
} else if tv === svcTable {
if cid == "name" { serviceItems[row].name = val }
else if cid == "host" { serviceItems[row].host = val }
else if cid == "port" { serviceItems[row].port = Int(val) ?? 0 }
} else if tv === portTable {
if cid == "service" { portItems[row].target = val }
else if cid == "namespace" { portItems[row].namespace = val }
else if cid == "expose" { portItems[row].hostPort = val }
else if cid == "internal" { portItems[row].servicePort = val }
}
}
// MARK: - Helpers
@objc private func windowDidResize() {
// Keep current tab content sized to the tab's content rect during window resizes
syncCurrentTabContentFrame()
// Re-layout the currently visible table for updated width
if let id = tabView.selectedTabViewItem?.identifier as? Int, let tab = Tab(rawValue: id) {
switch tab {
case .kubernetes: sizeColumnsAndLayout(table: kubeTable)
case .services: sizeColumnsAndLayout(table: svcTable)
case .ports: sizeColumnsAndLayout(table: portTable)
case .agentPF: break
}
}
}
private func adoptTabContentSizing(_ view: NSView) {
view.translatesAutoresizingMaskIntoConstraints = true
view.autoresizingMask = [.width, .height]
view.frame = tabView.contentRect
}
private func syncCurrentTabContentFrame() {
if let v = tabView.selectedTabViewItem?.view { adoptTabContentSizing(v) }
}
private func syncAllTabContentFrames() {
for item in tabView.tabViewItems {
if let v = item.view { adoptTabContentSizing(v) }
}
}
private func sizeColumnsAndLayout(table: NSTableView) {
// Defer sizing to when the table is in a window so we have real bounds
DispatchQueue.main.async {
guard let sv = table.enclosingScrollView else { return }
// Expand the table to at least the visible area so columns can lay out
let visible = sv.contentView.bounds.size
var f = table.frame
f.size.width = max(f.size.width, visible.width)
f.size.height = max(f.size.height, visible.height)
table.frame = f
// Distribute widths sensibly depending on which table this is
self.distributeColumnWidths(for: table, availableWidth: visible.width)
table.noteNumberOfRowsChanged()
self.forceLayout(for: table)
}
}
private func forceLayout(for table: NSTableView) {
// Ensure the scroll view and its content lay out now; dispatch to next runloop
DispatchQueue.main.async {
table.enclosingScrollView?.tile()
table.layoutSubtreeIfNeeded()
table.enclosingScrollView?.layoutSubtreeIfNeeded()
table.headerView?.layoutSubtreeIfNeeded()
}
}
// Distribute columns per table type to avoid first-column-only syndrome when appearing
private func distributeColumnWidths(for table: NSTableView, availableWidth: CGFloat) {
guard availableWidth.isFinite, availableWidth > 0 else { return }
let cols = table.tableColumns
if cols.isEmpty { return }
// Side paddings inside container are ~0 here; keep a small safety margin
let padding: CGFloat = 8
let width = max(100, availableWidth - padding)
func apply(_ fractions: [CGFloat]) {
// Normalize fractions
let sum = fractions.reduce(0, +)
let norm = sum > 0 ? fractions.map { $0 / sum } : Array(repeating: 1.0 / CGFloat(fractions.count), count: fractions.count)
for (i, col) in cols.enumerated() {
let frac = i < norm.count ? norm[i] : (1.0 / CGFloat(cols.count))
let target = max(col.minWidth, min(col.maxWidth, width * frac))
col.width = target
}
}
if table === kubeTable {
// host, port
apply([0.75, 0.25])
} else if table === svcTable {
// name, host, port make name and host same size by default
apply([0.41, 0.41, 0.18])
} else if table === portTable {
// service, namespace, expose, internal
apply([0.42, 0.26, 0.16, 0.16])
} else {
// Fallback equal distribution
apply(Array(repeating: 1.0, count: cols.count))
}
}
}
// MARK: - Character width utilities
private extension PreferencesWindowController {
/// Approximate pixel width for N characters using the table cell font
func widthForChars(_ count: Int) -> CGFloat {
let chars = max(0, count)
if chars == 0 { return 0 }
// Use a wide glyph to avoid underestimating; system font to match cells
let sample = String(repeating: "W", count: chars) as NSString
let font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
let size = sample.size(withAttributes: [.font: font])
return ceil(size.width)
}
}