mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:54:32 +00:00
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:
parent
39b2c1ae4b
commit
d1e4cb5db3
117
prole-app/Sources/LaunchAgentManager.swift
Normal file
117
prole-app/Sources/LaunchAgentManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
42
prole-app/Sources/PFScriptBridge.swift
Normal file
42
prole-app/Sources/PFScriptBridge.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
77
prole-app/Sources/PortMappingsXML.swift
Normal file
77
prole-app/Sources/PortMappingsXML.swift
Normal 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: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
587
prole-app/Sources/PreferencesWindowController.swift
Normal file
587
prole-app/Sources/PreferencesWindowController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user