Remove inline shell from app; bundle external init script; fix build and packaging

•
Strip all embedded shell from LaunchAgentManager.swift; use external etc/init-port-fowards.sh copied from app Resources at runtime; set executable perms
•
Set defaultHelperScript to empty to eliminate inline script
•
Build script: bundle init-port-fowards.sh into Prole.app/Contents/Resources and chmod +x; capture spm_build_binary path via tail -n1
•
Fix malformed multiline XML in PortMappingsXML.save()
•
Preferences: handle new .agentPF tab in didSelect and windowDidResize
Result: Prole.app builds cleanly on arm64; no inline shell remains; embedded dylibs packaged and codesigned
This commit is contained in:
chrisfu 2025-12-15 00:39:52 -08:00
parent 39b2c1ae4b
commit d1e4cb5db3
5 changed files with 836 additions and 3 deletions

View File

@ -0,0 +1,117 @@
import Foundation
// Lightweight helper to manage the user LaunchAgent that runs kubectl port-forwards.
// This replaces the in-app PortForwardManager supervision.
final class LaunchAgentManager {
// Filenames/paths
private let label = "org.prole.prole-db.kpf-dev"
private var plistURL: URL {
let home = FileManager.default.homeDirectoryForCurrentUser
return home.appendingPathComponent("Library/LaunchAgents/\(label).plist")
}
private var helperScriptURL: URL {
let home = FileManager.default.homeDirectoryForCurrentUser
return home.appendingPathComponent("Library/Application Support/Prole/bin/prole-kpf.sh")
}
// Key that stores the list of commands inside the plist (array of strings)
private let commandsKey = "ProleCommands"
// Read commands from the LaunchAgent plist. Returns empty if missing.
func readCommands() -> [String] {
guard let data = try? Data(contentsOf: plistURL) else { return [] }
var format = PropertyListSerialization.PropertyListFormat.xml
guard let obj = try? PropertyListSerialization.propertyList(from: data, options: [], format: &format),
let dict = obj as? [String: Any],
let cmds = dict[commandsKey] as? [String] else {
return []
}
return cmds
}
// Write commands back to the plist, preserving other keys if present.
func writeCommands(_ commands: [String]) {
var dict: [String: Any] = [:]
if let data = try? Data(contentsOf: plistURL),
let obj = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil),
let existing = obj as? [String: Any] {
dict = existing
}
dict[commandsKey] = commands
if let out = try? PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) {
// Ensure parent directory exists
try? FileManager.default.createDirectory(at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try? out.write(to: plistURL)
}
}
// Reload the LaunchAgent: try kickstart; if not present, bootstrap then kickstart.
func resetAgent() {
let uid = getuid()
let domainTarget = "gui/\(uid)/\(label)"
// Try kickstart first (reload)
let ks = run("/bin/launchctl", ["kickstart", "-k", domainTarget])
if ks.exitCode == 0 { return }
// If kickstart failed, try bootout then bootstrap, then kickstart again
_ = run("/bin/launchctl", ["bootout", "gui/\(uid)", domainTarget])
// bootstrap requires path to plist
_ = run("/bin/launchctl", ["bootstrap", "gui/\(uid)", plistURL.path])
_ = run("/bin/launchctl", ["kickstart", "-k", domainTarget])
}
// Ensure the helper shell script exists by copying the external bundled script; no inline content.
func ensureHelperScript() {
let fm = FileManager.default
let dir = helperScriptURL.deletingLastPathComponent()
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
// Locate external script in app bundle Resources
let bundle = Bundle.main
let scriptInBundleURL = bundle.url(forResource: "init-port-fowards", withExtension: "sh")
guard let srcURL = scriptInBundleURL else { return }
var needsCopy = true
if fm.fileExists(atPath: helperScriptURL.path) {
if let srcAttr = try? fm.attributesOfItem(atPath: srcURL.path),
let dstAttr = try? fm.attributesOfItem(atPath: helperScriptURL.path),
let srcMod = srcAttr[.modificationDate] as? Date,
let dstMod = dstAttr[.modificationDate] as? Date,
dstMod >= srcMod {
needsCopy = false
}
}
if needsCopy {
_ = try? fm.removeItem(at: helperScriptURL)
do {
try fm.copyItem(at: srcURL, to: helperScriptURL)
} catch {
if let data = try? Data(contentsOf: srcURL) {
try? data.write(to: helperScriptURL)
}
}
try? fm.setAttributes([.posixPermissions: NSNumber(value: Int16(0o755))], ofItemAtPath: helperScriptURL.path)
}
}
static let defaultHelperScript = ""
// MARK: - Helpers
@discardableResult
private func run(_ path: String, _ args: [String]) -> (exitCode: Int32, out: String, err: String) {
let p = Process()
p.executableURL = URL(fileURLWithPath: path)
p.arguments = args
let outPipe = Pipe(); let errPipe = Pipe()
p.standardOutput = outPipe
p.standardError = errPipe
do { try p.run() } catch {
return (exitCode: -1, out: "", err: String(describing: error))
}
p.waitUntilExit()
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
let outStr = String(data: outData, encoding: .utf8) ?? ""
let errStr = String(data: errData, encoding: .utf8) ?? ""
return (p.terminationStatus, outStr, errStr)
}
}

View File

@ -0,0 +1,42 @@
import Foundation
// Thin wrapper to call etc/init-port-fowards.sh for status/restart.
enum PFScriptBridge {
private static var scriptURL: URL {
// Resolve script path:
// 1) PROLE_HOME env var if set
// 2) default to ~/dev/prole (matches existing scripts)
let env = ProcessInfo.processInfo.environment
let fm = FileManager.default
if let home = env["PROLE_HOME"], !home.isEmpty {
return URL(fileURLWithPath: home).appendingPathComponent("etc/init-port-fowards.sh")
}
let defaultHome = fm.homeDirectoryForCurrentUser.appendingPathComponent("dev/prole")
return defaultHome.appendingPathComponent("etc/init-port-fowards.sh")
}
@discardableResult
static func restart() -> (code: Int32, out: String, err: String) {
return runScript(arg: "restart")
}
static func status() -> (code: Int32, out: String, err: String) {
return runScript(arg: "status")
}
private static func runScript(arg: String) -> (code: Int32, out: String, err: String) {
let p = Process()
p.executableURL = scriptURL
p.arguments = [arg]
let outPipe = Pipe(); let errPipe = Pipe()
p.standardOutput = outPipe
p.standardError = errPipe
do { try p.run() } catch {
return (-1, "", String(describing: error))
}
p.waitUntilExit()
let out = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let err = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return (p.terminationStatus, out, err)
}
}

View File

@ -0,0 +1,77 @@
import Foundation
struct PortMappingXML: Equatable {
var id: String
var namespace: String
var target: String
var address: String
var hostPort: String
var servicePort: String
var proto: String
var description: String
}
enum PortMappingsXMLStore {
private static func configURL() -> URL {
// Determine PROLE_HOME similarly to PFScriptBridge
let env = ProcessInfo.processInfo.environment
if let home = env["PROLE_HOME"], !home.isEmpty {
return URL(fileURLWithPath: home).appendingPathComponent("conf/port-mappings.properties")
}
let defaultHome = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("dev/prole")
return defaultHome.appendingPathComponent("conf/port-mappings.properties")
}
static func load() -> [PortMappingXML] {
let url = configURL()
guard let data = try? Data(contentsOf: url) else { return [] }
let parser = XMLParser(data: data)
let delegate = ParserDelegate()
parser.delegate = delegate
if parser.parse() { return delegate.items }
return []
}
static func save(_ items: [PortMappingXML]) throws {
let url = configURL()
var xml = """
<?xml version="1.0" encoding="UTF-8"?>
<portMappings>
"""
for m in items {
xml += " <mapping id=\"\(escape(m.id))\" namespace=\"\(escape(m.namespace))\" target=\"\(escape(m.target))\" address=\"\(escape(m.address))\" hostPort=\"\(escape(m.hostPort))\" servicePort=\"\(escape(m.servicePort))\" protocol=\"\(escape(m.proto))\" description=\"\(escape(m.description))\"/>\n"
}
xml += """
</portMappings>
"""
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try xml.data(using: .utf8)?.write(to: url)
}
private static func escape(_ s: String) -> String {
return s.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
private final class ParserDelegate: NSObject, XMLParserDelegate {
var items: [PortMappingXML] = []
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
if elementName == "mapping" {
let m = PortMappingXML(
id: attributeDict["id"] ?? "",
namespace: attributeDict["namespace"] ?? "default",
target: attributeDict["target"] ?? "",
address: attributeDict["address"] ?? "127.0.0.1",
hostPort: attributeDict["hostPort"] ?? "",
servicePort: attributeDict["servicePort"] ?? "",
proto: attributeDict["protocol"] ?? "TCP",
description: attributeDict["description"] ?? ""
)
items.append(m)
}
}
}
}

View File

@ -0,0 +1,587 @@
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)
}
}

View File

@ -263,6 +263,16 @@ copy_extra_resources() {
else
echo "[resources] prole.properties not found at $props_src (skipping)"
fi
# Copy kubectl port-forward init script into Resources (consumed by app at runtime)
local kpf_src="$ROOT_DIR/../etc/init-port-fowards.sh"
if [[ -f "$kpf_src" ]]; then
cp "$kpf_src" "$RESOURCES_DIR/init-port-fowards.sh"
chmod +x "$RESOURCES_DIR/init-port-fowards.sh" || true
echo "[resources] Copied init-port-fowards.sh into Resources"
else
echo "[resources] init-port-fowards.sh not found at $kpf_src (skipping)"
fi
}
# --- Dependency preparation (RoyalVNCKit) ---
@ -556,7 +566,7 @@ build_one_arch() {
gen_statusbar_icon_png
copy_extra_resources
local built_bin
built_bin=$(spm_build_binary "$arch") || { echo "[spm] build failed" >&2; exit 1; }
built_bin=$(spm_build_binary "$arch" | tail -n1) || { echo "[spm] build failed" >&2; exit 1; }
mkdir -p "$MACOS_DIR"
cp "$built_bin" "$MACOS_DIR/${APP_NAME}"
# Embed any SwiftPM dynamic libs (e.g., RoyalVNCKit) into the app bundle
@ -582,8 +592,8 @@ build_universal() {
prepare_dependencies
ensure_dirs
gen_plist
local built_arm64; built_arm64=$(spm_build_binary arm64) || { echo "[spm] arm64 build failed" >&2; exit 1; }
local built_x86; built_x86=$(spm_build_binary x86_64) || { echo "[spm] x86_64 build failed" >&2; exit 1; }
local built_arm64; built_arm64=$(spm_build_binary arm64 | tail -n1) || { echo "[spm] arm64 build failed" >&2; exit 1; }
local built_x86; built_x86=$(spm_build_binary x86_64 | tail -n1) || { echo "[spm] x86_64 build failed" >&2; exit 1; }
mkdir -p "$MACOS_DIR"
xcrun lipo -create -output "$MACOS_DIR/${APP_NAME}" "$built_arm64" "$built_x86"
codesign_app