mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 17:34:30 +00:00
- Replace direct script invocation with `$PROLE_HOME/env.sh` wrapper for consistent environment setup across all components. - Update runtime processes to dynamically resolve `$PROLE_HOME` or fallback to `$HOME/.prole`. - Deprecate `init-port-forwards.sh` script bundling; remove from LaunchAgentManager and build system. - Overhaul `env.sh` generation to include execution capability, customizable paths, and improved error handling. - Standardize app name and paths to "Prole Tools" across all files and UI elements. - Adjust build output to align with new structure, including executable naming and resource packaging.
207 lines
8.8 KiB
Swift
207 lines
8.8 KiB
Swift
import AppKit
|
|
|
|
// MainWindowController builds the regular window you can move/resize.
|
|
// Redesigned per requirements: clear the canvas and show the output of
|
|
// etc/init-port-forward.sh in a themed window. Includes:
|
|
// - ⟳ Refresh (manual trigger)
|
|
// - A refresh interval selector in the upper-right (10s, 30s [default], 1min, 5min)
|
|
// - _ Minimize to Status Bar (switches to overlay mode)
|
|
final class MainWindowController: NSWindowController {
|
|
struct Actions {
|
|
let onRefresh: () -> Void
|
|
let onMinimizeToStatusBar: () -> Void
|
|
}
|
|
|
|
// Scrollable, monospaced text view to display script output
|
|
private let outputTextView: NSTextView = {
|
|
let tv = NSTextView()
|
|
tv.isEditable = false
|
|
tv.isSelectable = true
|
|
tv.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
|
tv.textColor = .labelColor
|
|
tv.backgroundColor = .textBackgroundColor
|
|
tv.translatesAutoresizingMaskIntoConstraints = false
|
|
return tv
|
|
}()
|
|
private let scrollView: NSScrollView = {
|
|
let sv = NSScrollView()
|
|
sv.hasVerticalScroller = true
|
|
sv.hasHorizontalScroller = false
|
|
sv.autohidesScrollers = true
|
|
sv.translatesAutoresizingMaskIntoConstraints = false
|
|
return sv
|
|
}()
|
|
private let intervalPopup: NSPopUpButton = {
|
|
let p = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
p.translatesAutoresizingMaskIntoConstraints = false
|
|
p.addItems(withTitles: ["10s", "30s", "1 min", "5 min"])
|
|
p.selectItem(withTitle: "30s")
|
|
p.toolTip = "Auto-refresh interval"
|
|
return p
|
|
}()
|
|
private var refreshTimer: Timer?
|
|
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
|
|
}()
|
|
content.addSubview(headerLabel)
|
|
// Prepare scroll view + text view
|
|
scrollView.documentView = outputTextView
|
|
content.addSubview(scrollView)
|
|
// Upper-right interval selector
|
|
content.addSubview(intervalPopup)
|
|
|
|
// Bottom-left control bar (minimize only; refresh is always automatic)
|
|
let controls = NSStackView()
|
|
controls.orientation = .horizontal
|
|
controls.spacing = 8
|
|
controls.alignment = .centerY
|
|
controls.translatesAutoresizingMaskIntoConstraints = false
|
|
content.addSubview(controls)
|
|
|
|
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(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(lessThanOrEqualTo: trailingAnchorRef, constant: -12),
|
|
|
|
// Interval selector at top-right, aligned with header baseline
|
|
intervalPopup.centerYAnchor.constraint(equalTo: headerLabel.centerYAnchor),
|
|
intervalPopup.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -12),
|
|
|
|
// Scroll view occupies the content area below header
|
|
scrollView.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 8),
|
|
scrollView.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
|
scrollView.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -8),
|
|
scrollView.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -44),
|
|
|
|
// Controls pinned bottom-left inside safe area
|
|
controls.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
|
controls.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -8)
|
|
])
|
|
|
|
// Wire actions
|
|
intervalPopup.target = self
|
|
intervalPopup.action = #selector(didChangeInterval)
|
|
|
|
// Initial load and timer
|
|
runAndDisplay()
|
|
resetTimer()
|
|
}
|
|
|
|
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 didTapMinimize() { actions.onMinimizeToStatusBar() }
|
|
|
|
// MARK: - Script output rendering and auto-refresh
|
|
@objc private func didChangeInterval() { resetTimer() }
|
|
|
|
private func selectedIntervalSeconds() -> TimeInterval {
|
|
switch intervalPopup.titleOfSelectedItem {
|
|
case "10s": return 10
|
|
case "30s": return 30
|
|
case "1 min": return 60
|
|
case "5 min": return 300
|
|
default: return 30
|
|
}
|
|
}
|
|
|
|
private func resetTimer() {
|
|
refreshTimer?.invalidate()
|
|
let interval = selectedIntervalSeconds()
|
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
self?.runAndDisplay()
|
|
}
|
|
RunLoop.main.add(refreshTimer!, forMode: .common)
|
|
}
|
|
|
|
private func runAndDisplay() {
|
|
DispatchQueue.global(qos: .utility).async {
|
|
let res = PFScriptBridge.status()
|
|
let now = ISO8601DateFormatter().string(from: Date())
|
|
var text = ""
|
|
// Always display combined stdout + stderr; include exit code if non-zero
|
|
text = res.out + (res.err.isEmpty ? "" : (res.out.isEmpty ? res.err : "\n" + res.err))
|
|
if res.code != 0 { text = "[exit code: \(res.code)]\n" + text }
|
|
let invocation = PFScriptBridge.invocationDescription()
|
|
let header = "# \(invocation) — status (\(now))\n\n"
|
|
let final = header + text
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.outputTextView.string = final
|
|
// Scroll to top
|
|
self?.outputTextView.scroll(NSPoint(x: 0, y: 0))
|
|
}
|
|
}
|
|
}
|
|
}
|