prole/prole-app/Sources/MainWindowController.swift
chrisfu 64455fa886 ### Milestone: Remove IRCKit, migrate to swift-nio IRC, enable RoyalVNC via SwiftPM, and fix runtime embedding
- 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.
2025-12-05 23:37:54 -08:00

185 lines
8.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 bottomleft 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")
}
}