From d1e4cb5db358f4789696b59cefb4a6f6ba7a7dcf Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 15 Dec 2025 00:39:52 -0800 Subject: [PATCH] =?UTF-8?q?Remove=20inline=20shell=20from=20app;=20bundle?= =?UTF-8?q?=20external=20init=20script;=20fix=20build=20and=20packaging=20?= =?UTF-8?q?=E2=80=A2=20Strip=20all=20embedded=20shell=20from=20LaunchAgent?= =?UTF-8?q?Manager.swift;=20use=20external=20etc/init-port-fowards.sh=20co?= =?UTF-8?q?pied=20from=20app=20Resources=20at=20runtime;=20set=20executabl?= =?UTF-8?q?e=20perms=20=E2=80=A2=20Set=20defaultHelperScript=20to=20empty?= =?UTF-8?q?=20to=20eliminate=20inline=20script=20=E2=80=A2=20Build=20scrip?= =?UTF-8?q?t:=20bundle=20init-port-fowards.sh=20into=20Prole.app/Contents/?= =?UTF-8?q?Resources=20and=20chmod=20+x;=20capture=20spm=5Fbuild=5Fbinary?= =?UTF-8?q?=20path=20via=20tail=20-n1=20=E2=80=A2=20Fix=20malformed=20mult?= =?UTF-8?q?iline=20XML=20in=20PortMappingsXML.save()=20=E2=80=A2=20Prefere?= =?UTF-8?q?nces:=20handle=20new=20.agentPF=20tab=20in=20didSelect=20and=20?= =?UTF-8?q?windowDidResize=20Result:=20Prole.app=20builds=20cleanly=20on?= =?UTF-8?q?=20arm64;=20no=20inline=20shell=20remains;=20embedded=20dylibs?= =?UTF-8?q?=20packaged=20and=20codesigned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prole-app/Sources/LaunchAgentManager.swift | 117 ++++ prole-app/Sources/PFScriptBridge.swift | 42 ++ prole-app/Sources/PortMappingsXML.swift | 77 +++ .../Sources/PreferencesWindowController.swift | 587 ++++++++++++++++++ prole-app/build.sh | 16 +- 5 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 prole-app/Sources/LaunchAgentManager.swift create mode 100644 prole-app/Sources/PFScriptBridge.swift create mode 100644 prole-app/Sources/PortMappingsXML.swift create mode 100644 prole-app/Sources/PreferencesWindowController.swift diff --git a/prole-app/Sources/LaunchAgentManager.swift b/prole-app/Sources/LaunchAgentManager.swift new file mode 100644 index 0000000..493dea9 --- /dev/null +++ b/prole-app/Sources/LaunchAgentManager.swift @@ -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) + } +} diff --git a/prole-app/Sources/PFScriptBridge.swift b/prole-app/Sources/PFScriptBridge.swift new file mode 100644 index 0000000..9e07b66 --- /dev/null +++ b/prole-app/Sources/PFScriptBridge.swift @@ -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) + } +} diff --git a/prole-app/Sources/PortMappingsXML.swift b/prole-app/Sources/PortMappingsXML.swift new file mode 100644 index 0000000..29bf847 --- /dev/null +++ b/prole-app/Sources/PortMappingsXML.swift @@ -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 = """ + + + """ + for m in items { + xml += " \n" + } + xml += """ + + """ + 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) + } + } + } +} diff --git a/prole-app/Sources/PreferencesWindowController.swift b/prole-app/Sources/PreferencesWindowController.swift new file mode 100644 index 0000000..e672547 --- /dev/null +++ b/prole-app/Sources/PreferencesWindowController.swift @@ -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) + } +} diff --git a/prole-app/build.sh b/prole-app/build.sh index 3326fa9..c08089f 100755 --- a/prole-app/build.sh +++ b/prole-app/build.sh @@ -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