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.isRichText = false // plain text; ensure standard copy works cleanly tv.usesFindBar = true // Start slightly smaller than system size; will auto-fit to window afterwards tv.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) tv.textColor = .labelColor tv.backgroundColor = .textBackgroundColor // Important: when used as NSScrollView.documentView, the text view should // use frame-based resizing rather than Auto Layout constraints. Enable // autoresizing mask translation so it grows with the scroll content. tv.translatesAutoresizingMaskIntoConstraints = true tv.isVerticallyResizable = true tv.isHorizontallyResizable = false tv.minSize = NSSize(width: 0, height: 0) tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) tv.textContainerInset = NSSize(width: 6, height: 8) if let tc = tv.textContainer { tc.widthTracksTextView = true tc.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) } // Context menu with basic commands routed to first responder let m = NSMenu(title: "Context") let copyItem = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") copyItem.keyEquivalentModifierMask = [.command] copyItem.target = nil // route to first responder m.addItem(copyItem) m.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) tv.menu = m 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] // Adjusted default size: width -33%, height -25% from previous let initialRect = NSRect(x: 0, y: 0, width: 965, height: 630) 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) window.delegate = self // 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.borderType = .noBorder scrollView.drawsBackground = true scrollView.backgroundColor = .textBackgroundColor scrollView.documentView = outputTextView // Make the document view track the scroll view's content size outputTextView.frame = scrollView.contentView.bounds outputTextView.autoresizingMask = [.width, .height] 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() // Perform an initial fit shortly after layout DispatchQueue.main.async { [weak self] in self?.adjustFontToFit() } } 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) // Ensure the text view becomes first responder so Cmd+C routes to it window.makeFirstResponder(outputTextView) 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() { // Show a quick placeholder immediately so the view is never blank DispatchQueue.main.async { [weak self] in self?.outputTextView.string = "Loading status…" self?.outputTextView.scrollToBeginningOfDocument(nil) } 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 text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { text = "(no output received)" } 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 // Ensure we show the beginning of the output (header) self?.outputTextView.scrollToBeginningOfDocument(nil) self?.outputTextView.scrollRangeToVisible(NSRange(location: 0, length: 0)) self?.adjustFontToFit() } } } } // MARK: - Auto-fit font to keep full output visible extension MainWindowController: NSWindowDelegate { func windowDidResize(_ notification: Notification) { adjustFontToFit() } } private extension MainWindowController { func adjustFontToFit() { guard let font = outputTextView.font, let tc = outputTextView.textContainer, let lm = outputTextView.layoutManager else { return } // Available height inside the scroll content let availableHeight = scrollView.contentView.bounds.height guard availableHeight > 0 else { return } // Helper to measure content height for a given font size func contentHeight(for size: CGFloat) -> CGFloat { outputTextView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) // Invalidate layout and measure lm.invalidateLayout(forCharacterRange: NSRange(location: 0, length: lm.numberOfGlyphs), actualCharacterRange: nil) lm.ensureLayout(for: tc) let used = lm.usedRect(for: tc) return used.height + outputTextView.textContainerInset.height * 2.0 } // Bounds for font size var minSize: CGFloat = 9 var maxSize: CGFloat = max(12, font.pointSize) // If there is lots of space, allow growing up to a sensible cap let cap: CGFloat = 16 maxSize = min(maxSize, cap) // First, try to shrink if needed var best = maxSize var h = contentHeight(for: best) if h > availableHeight { // Decrease until it fits or we hit min var s = best while s > minSize { let next = max(minSize, s - 0.5) let nh = contentHeight(for: next) if nh <= availableHeight { best = next; h = nh; break } s = next } } else { // Try to grow a bit (keeping content fully visible) for readability var s = best while s < cap { let next = min(cap, s + 0.5) let nh = contentHeight(for: next) if nh > availableHeight { break } best = next; h = nh; s = next } } // Apply the chosen size (already applied during measurement but ensure final value) outputTextView.font = NSFont.monospacedSystemFont(ofSize: best, weight: .regular) // Keep view scrolled to top so header stays visible after relayout outputTextView.scrollToBeginningOfDocument(nil) } }