prole/prole-app/Sources/MainWindowController.swift

165 lines
7.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 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)"
}
}
}