mirror of
https://github.com/dredx/prole.git
synced 2026-09-28 10:54:30 +00:00
171 lines
7.4 KiB
Swift
171 lines
7.4 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 = "ProleStatus"
|
||
window.center()
|
||
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 }
|
||
window.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: false)
|
||
}
|
||
|
||
func hide() {
|
||
window?.orderOut(nil)
|
||
}
|
||
|
||
var isVisible: Bool { window?.isVisible ?? 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")
|
||
}
|
||
}
|