mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 12:34:30 +00:00
165 lines
7.2 KiB
Swift
165 lines
7.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 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)
|
||
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() {
|
||
updatePFDetails()
|
||
actions.onRefresh()
|
||
}
|
||
@objc private func didTapMinimize() { actions.onMinimizeToStatusBar() }
|
||
|
||
// MARK: - Port Forward Details (from init-port-fowards.sh)
|
||
@objc private func updatePFDetails() {
|
||
let res = PFScriptBridge.status()
|
||
if res.code == 0 {
|
||
let trimmed = res.out.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
detailsLabel.stringValue = trimmed.isEmpty ? "init-port-fowards.sh: no status" : trimmed
|
||
} else {
|
||
let err = res.err.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
detailsLabel.stringValue = "init-port-fowards.sh status error (code \(res.code))\n\(err)"
|
||
}
|
||
}
|
||
}
|