mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 09:34:30 +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
|
else
|
||||||
echo "[resources] prole.properties not found at $props_src (skipping)"
|
echo "[resources] prole.properties not found at $props_src (skipping)"
|
||||||
fi
|
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) ---
|
# --- Dependency preparation (RoyalVNCKit) ---
|
||||||
@ -556,7 +566,7 @@ build_one_arch() {
|
|||||||
gen_statusbar_icon_png
|
gen_statusbar_icon_png
|
||||||
copy_extra_resources
|
copy_extra_resources
|
||||||
local built_bin
|
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"
|
mkdir -p "$MACOS_DIR"
|
||||||
cp "$built_bin" "$MACOS_DIR/${APP_NAME}"
|
cp "$built_bin" "$MACOS_DIR/${APP_NAME}"
|
||||||
# Embed any SwiftPM dynamic libs (e.g., RoyalVNCKit) into the app bundle
|
# Embed any SwiftPM dynamic libs (e.g., RoyalVNCKit) into the app bundle
|
||||||
@ -582,8 +592,8 @@ build_universal() {
|
|||||||
prepare_dependencies
|
prepare_dependencies
|
||||||
ensure_dirs
|
ensure_dirs
|
||||||
gen_plist
|
gen_plist
|
||||||
local built_arm64; built_arm64=$(spm_build_binary arm64) || { echo "[spm] arm64 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) || { echo "[spm] x86_64 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"
|
mkdir -p "$MACOS_DIR"
|
||||||
xcrun lipo -create -output "$MACOS_DIR/${APP_NAME}" "$built_arm64" "$built_x86"
|
xcrun lipo -create -output "$MACOS_DIR/${APP_NAME}" "$built_arm64" "$built_x86"
|
||||||
codesign_app
|
codesign_app
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user