prole/prole-app/Sources/MainWindowController.swift

171 lines
7.4 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 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")
}
}