mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 18:34:31 +00:00
- Replaced all IRCKit (0.16) usage with NozeIO `swift-nio-irc-client` (SwiftPM). - Enabled IRC window with new client: connects and joins `#prole` (plain TCP for now; SSL checkbox displays a notice). - Enabled RoyalVNC and removed all optional/flag-based handling. Integrated RoyalVNC via SwiftPM (`royalvnc` on `main`). - Updated VNC UI to current RoyalVNC API: `VNCConnection` + `VNCCAFramebufferView` with a strong delegate reference. - Simplified build to use SwiftPM for both IRC and RoyalVNC. - Fixed runtime embedding and loader issues: - Copy SwiftPM-built `.dylib` products (e.g., `libRoyalVNCKit.dylib`) into `Contents/Frameworks` and codesign them. - Added `@executable_path/../Frameworks` to app rpaths with `install_name_tool`. - Removed manual RoyalVNCKit `.xcframework` building/embedding and any IRCKit traces. #### Key changes - `prole-app/build.sh`: - Build app via SwiftPM; removed IRCKit and manual RoyalVNC build logic. - Embed SwiftPM `.dylib` outputs into `Contents/Frameworks` and set app rpath. - Cleaned usage text; dependencies now handled by SwiftPM. - `prole-app/Package.swift`: - Add `swift-nio-irc-client` (NozeIO) dependency. - Add RoyalVNC via SwiftPM: `https://github.com/royalapplications/royalvnc` on `main`. - `prole-app/Sources/IRCWindowController.swift`: - Migrate to NozeIO `IRC` package API; re-enable Connect; join `#prole`. - Import AppKit; provide convenience init for transcript view. - `prole-app/Sources/WorkstationWindowController.swift`: - Import `RoyalVNCKit`; use `VNCConnection` + `VNCCAFramebufferView`. - Implement `VNCConnectionDelegate` and keep a strong reference to the delegate. - `prole-app/debug.sh`: - Removed IRCKit logs section; kept RoyalVNC logs earlier; then removed manual RoyalVNC altogether. This build now launches successfully (no dyld errors), opens the Workstation window (RoyalVNC), and the IRC window connects via the new client. ### Suggested follow-ups - If TLS is required for IRC, add `NIOSSL` integration and wire SSL checkbox to TLS connection. - Optionally remove leftover Vendor references if any local cache remains.
185 lines
8.2 KiB
Swift
185 lines
8.2 KiB
Swift
import AppKit
|
||
|
||
// MainWindowController builds the regular window you can move/resize.
|
||
// It embeds an AppStatusView (3 simple rows, no scrolling) and a tiny
|
||
// control bar in the bottom‑left with:
|
||
// - ⟳ Refresh (forces a status check)
|
||
// - _ Minimize to Status Bar (switches to overlay mode)
|
||
final class MainWindowController: NSWindowController {
|
||
struct Actions {
|
||
let onRefresh: () -> Void
|
||
let onMinimizeToStatusBar: () -> Void
|
||
}
|
||
|
||
private let statusView = StatusView()
|
||
private let detailsLabel: NSTextField = {
|
||
let tf = NSTextField(labelWithString: "")
|
||
tf.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize(for: .small), weight: .regular)
|
||
tf.textColor = .secondaryLabelColor
|
||
tf.alignment = .left
|
||
tf.lineBreakMode = .byWordWrapping
|
||
tf.cell?.wraps = true
|
||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||
return tf
|
||
}()
|
||
private var pfManager: PortForwardManager?
|
||
private let actions: Actions
|
||
|
||
init(serviceChecker: ServiceChecker, actions: Actions, pfManager: PortForwardManager? = nil) {
|
||
// Initialize stored properties before calling super.init
|
||
self.actions = actions
|
||
let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||
let initialRect = NSRect(x: 0, y: 0, width: 720, height: 420)
|
||
let window = NSWindow(contentRect: initialRect, styleMask: style, backing: .buffered, defer: false)
|
||
super.init(window: window)
|
||
|
||
window.isReleasedWhenClosed = false
|
||
window.title = "Prole Status — ⌘⌥⇧P to toggle"
|
||
window.level = .normal
|
||
window.collectionBehavior = [.canJoinAllSpaces]
|
||
window.appearance = NSAppearance(named: .aqua)
|
||
|
||
// A plain NSView acts as the content container
|
||
let content = NSView()
|
||
content.translatesAutoresizingMaskIntoConstraints = false
|
||
window.contentView = content
|
||
|
||
// Header label to avoid title bar overlap and give more context
|
||
let headerLabel: NSTextField = {
|
||
let tf = NSTextField(labelWithString: "Prole — System Status")
|
||
tf.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
|
||
tf.lineBreakMode = .byWordWrapping
|
||
tf.cell?.wraps = true
|
||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||
return tf
|
||
}()
|
||
|
||
statusView.translatesAutoresizingMaskIntoConstraints = false
|
||
content.addSubview(headerLabel)
|
||
content.addSubview(statusView)
|
||
content.addSubview(detailsLabel)
|
||
|
||
// Bottom-left control bar with Refresh and Minimize buttons
|
||
let controls = NSStackView()
|
||
controls.orientation = .horizontal
|
||
controls.spacing = 8
|
||
controls.alignment = .centerY
|
||
controls.translatesAutoresizingMaskIntoConstraints = false
|
||
content.addSubview(controls)
|
||
|
||
let refreshButton = NSButton(title: "⟳", target: nil, action: nil)
|
||
refreshButton.bezelStyle = .texturedRounded
|
||
refreshButton.toolTip = "Refresh Now"
|
||
refreshButton.target = self
|
||
refreshButton.action = #selector(didTapRefresh)
|
||
|
||
let minimizeButton = NSButton(title: "_", target: nil, action: nil)
|
||
minimizeButton.bezelStyle = .texturedRounded
|
||
minimizeButton.toolTip = "Minimize to Status Bar"
|
||
minimizeButton.target = self
|
||
minimizeButton.action = #selector(didTapMinimize)
|
||
|
||
controls.addArrangedSubview(refreshButton)
|
||
controls.addArrangedSubview(minimizeButton)
|
||
|
||
// NSWindow.contentLayoutGuide is typed as Any? on AppKit; cast safely to NSLayoutGuide.
|
||
// Fallback to the content view's anchors if unavailable.
|
||
let layoutGuide = window.contentLayoutGuide as Any?
|
||
let guide = (layoutGuide as? NSLayoutGuide)
|
||
let topAnchorRef = guide?.topAnchor ?? content.topAnchor
|
||
let leadingAnchorRef = guide?.leadingAnchor ?? content.leadingAnchor
|
||
let trailingAnchorRef = guide?.trailingAnchor ?? content.trailingAnchor
|
||
let bottomAnchorRef = guide?.bottomAnchor ?? content.bottomAnchor
|
||
NSLayoutConstraint.activate([
|
||
// Header at the top inside the content layout guide (avoids title bar)
|
||
headerLabel.topAnchor.constraint(equalTo: topAnchorRef, constant: 12),
|
||
headerLabel.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 12),
|
||
headerLabel.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -12),
|
||
|
||
// StatusView beneath the header, full width
|
||
statusView.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 8),
|
||
statusView.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
||
statusView.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -8),
|
||
|
||
// Details label below statusView
|
||
detailsLabel.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 12),
|
||
detailsLabel.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
||
detailsLabel.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -8),
|
||
|
||
// Controls pinned bottom-left inside safe area
|
||
controls.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
||
controls.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -8)
|
||
])
|
||
|
||
statusView.bindTo(serviceChecker: serviceChecker)
|
||
self.pfManager = pfManager
|
||
statusView.setPortForwardManager(pfManager)
|
||
if let m = pfManager {
|
||
NotificationCenter.default.addObserver(self, selector: #selector(updatePFDetails), name: PortForwardManager.statusDidChangeNotification, object: m)
|
||
}
|
||
updatePFDetails()
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
func show() {
|
||
guard let window = window else { return }
|
||
MainWindowController.positionWindowAtLeftEdge(window)
|
||
window.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: false)
|
||
}
|
||
|
||
func hide() {
|
||
window?.orderOut(nil)
|
||
}
|
||
|
||
var isVisible: Bool { window?.isVisible ?? false }
|
||
|
||
// MARK: - Positioning helpers
|
||
static func positionWindowAtLeftEdge(_ window: NSWindow, margin: CGFloat = 12) {
|
||
guard let screen = window.screen ?? NSScreen.main else { return }
|
||
let vis = screen.visibleFrame
|
||
var frame = window.frame
|
||
// Place near left edge and below the menu bar, keep current size
|
||
frame.origin.x = vis.origin.x + margin
|
||
// Align top to visible frame top with small margin
|
||
frame.origin.y = vis.maxY - frame.height - margin
|
||
// Ensure not offscreen vertically
|
||
if frame.origin.y < vis.origin.y + margin { frame.origin.y = vis.origin.y + margin }
|
||
window.setFrame(frame, display: true, animate: false)
|
||
}
|
||
|
||
// MARK: - Actions
|
||
@objc private func didTapRefresh() { actions.onRefresh() }
|
||
@objc private func didTapMinimize() { actions.onMinimizeToStatusBar() }
|
||
|
||
// MARK: - Port Forward Details
|
||
@objc private func updatePFDetails() {
|
||
guard let m = pfManager else { detailsLabel.stringValue = ""; return }
|
||
if m.details.isEmpty {
|
||
if Config.shared.pfEnabled {
|
||
detailsLabel.stringValue = "Port Forwards: enabled but no commands loaded (check prole.properties path)"
|
||
} else {
|
||
detailsLabel.stringValue = "Port Forwards: none configured"
|
||
}
|
||
return
|
||
}
|
||
var lines: [String] = ["Port Forwards:"]
|
||
let df = DateFormatter(); df.dateStyle = .short; df.timeStyle = .medium
|
||
for d in m.details {
|
||
let pid = d.pid != nil ? String(d.pid!) : "-"
|
||
let status = d.running ? "running" : "stopped"
|
||
var extra = ""
|
||
if !d.running {
|
||
if let code = d.lastExitCode, let when = d.lastExitAt {
|
||
extra = " (exit=\(code) at \(df.string(from: when)))"
|
||
} else if let code = d.lastExitCode {
|
||
extra = " (exit=\(code))"
|
||
}
|
||
}
|
||
lines.append(" • PID \(pid) • \(status)\(extra) — \(d.command)")
|
||
}
|
||
detailsLabel.stringValue = lines.joined(separator: "\n")
|
||
}
|
||
}
|