prole/prole-app/Sources/IRCWindowController.swift
chrisfu 197c72dd7a Refactor prole-app and establish temporary release process
- Moved prole-tools-app to prole-app at the project root to make it self-contained for transition to its own repository.
- Created prole-tools-app/dist/ directory to host build artifacts.
- Generated distribution artifacts (Prole Tools.app and Prole Tools.zip) using prole-app/build.sh package.
- Checked in the generated artifacts to prole-tools-app/dist/ (bypassing .gitignore for temporary release process).
Changes Summary
•
Renamed directory prole-tools-app/ to prole-app/.
•
Populated prole-tools-app/dist/ with the latest build output from prole-app/build.sh.
•
Staged all changes, including the forced addition of ignored artifacts in prole-tools-app/dist/.
2026-01-18 15:04:23 -08:00

324 lines
14 KiB
Swift

import Foundation
import AppKit
import IRC
// A secondary window for IRC chat: short vertically, long horizontally.
// Title: "Prole IRC". Positioned below the main Prole Status window.
final class IRCWindowController: NSWindowController {
private let serverField: NSTextField = {
let tf = NSTextField(string: "localhost")
tf.placeholderString = "Server"
// Approximate 32-character width using a monospaced font
tf.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
tf.translatesAutoresizingMaskIntoConstraints = false
return tf
}()
private let sslCheckbox: NSButton = {
let cb = NSButton(checkboxWithTitle: "SSL", target: nil, action: nil)
cb.translatesAutoresizingMaskIntoConstraints = false
return cb
}()
private let connectButton: NSButton = {
let b = NSButton(title: "#prole", target: nil, action: nil)
b.bezelStyle = .rounded
b.translatesAutoresizingMaskIntoConstraints = false
return b
}()
private let portSuffixLabel: NSTextField = {
let tf = NSTextField(labelWithString: ":6667")
tf.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
tf.textColor = .secondaryLabelColor
tf.translatesAutoresizingMaskIntoConstraints = false
return tf
}()
private let transcriptView = IRCTranscriptView()
// Persistence keys
private let defaults = UserDefaults.standard
private let kServerKey = "irc.server"
private let kSSLKey = "irc.ssl"
private var ircClient: IRCClient?
private var joinedDefaultChannel = false
init(initialServer: String? = nil, initialSSL: Bool? = nil) {
let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
// Start with the same width as the Main (Prole Status) window (720)
let initialRect = NSRect(x: 0, y: 0, width: 720, height: 300)
let window = NSWindow(contentRect: initialRect, styleMask: style, backing: .buffered, defer: false)
super.init(window: window)
window.isReleasedWhenClosed = false
window.title = "Prole Tools IRC"
window.level = .normal
window.collectionBehavior = [.canJoinAllSpaces]
window.appearance = NSAppearance(named: .aqua)
window.minSize = NSSize(width: 480, height: 200)
// Restore persisted values
let savedServer = initialServer ?? defaults.string(forKey: kServerKey) ?? "localhost"
let savedSSL = initialSSL ?? defaults.bool(forKey: kSSLKey)
serverField.stringValue = savedServer
sslCheckbox.state = savedSSL ? .on : .off
updatePortSuffix()
// Layout
let content = NSView()
content.translatesAutoresizingMaskIntoConstraints = false
window.contentView = content
let topBar = NSStackView()
topBar.orientation = .horizontal
topBar.alignment = .centerY
topBar.spacing = 8
topBar.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(topBar)
// Make the server field flexible (no fixed minimum). It should stretch to fill available space.
serverField.setContentHuggingPriority(.defaultLow, for: .horizontal)
serverField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
connectButton.setContentHuggingPriority(.required, for: .horizontal)
connectButton.setContentCompressionResistancePriority(.required, for: .horizontal)
sslCheckbox.setContentHuggingPriority(.required, for: .horizontal)
sslCheckbox.setContentCompressionResistancePriority(.required, for: .horizontal)
portSuffixLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
portSuffixLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
topBar.addArrangedSubview(NSTextField(labelWithString: "Server:"))
topBar.addArrangedSubview(serverField)
topBar.addArrangedSubview(portSuffixLabel)
topBar.addArrangedSubview(sslCheckbox)
topBar.addArrangedSubview(connectButton)
// Transcript fills remainder
transcriptView.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(transcriptView)
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([
topBar.topAnchor.constraint(equalTo: topAnchorRef, constant: 8),
topBar.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
// Make the top bar span full width so its children (server field) can stretch
topBar.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -8),
transcriptView.topAnchor.constraint(equalTo: topBar.bottomAnchor, constant: 8),
transcriptView.leadingAnchor.constraint(equalTo: leadingAnchorRef),
transcriptView.trailingAnchor.constraint(equalTo: trailingAnchorRef),
transcriptView.bottomAnchor.constraint(equalTo: bottomAnchorRef)
])
// Wire actions
sslCheckbox.target = self; sslCheckbox.action = #selector(didToggleSSL)
connectButton.target = self; connectButton.action = #selector(didTapConnect)
connectButton.isEnabled = true
connectButton.toolTip = "Connect to #prole"
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func show() {
window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: false)
}
func hide() { window?.orderOut(nil) }
var isVisible: Bool { window?.isVisible ?? false }
// Position this window directly below the reference window, left-aligned.
func position(below refWindow: NSWindow, gap: CGFloat = 8) {
guard let this = window else { return }
let refFrame = refWindow.frame
var newFrame = this.frame
// Match width to the reference (Prole Status) window
newFrame.size.width = refFrame.size.width
newFrame.origin.x = refFrame.origin.x
newFrame.origin.y = refFrame.origin.y - newFrame.height - gap
this.setFrame(newFrame, display: true, animate: false)
}
@objc private func didToggleSSL() {
updatePortSuffix()
}
private func updatePortSuffix() {
let ssl = (sslCheckbox.state == .on)
portSuffixLabel.stringValue = ssl ? ":6697" : ":6667"
}
@objc private func didTapConnect() {
let ssl = (sslCheckbox.state == .on)
let raw = serverField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
let host = IRCWindowController.stripPort(from: raw)
let port = ssl ? 6697 : 6667
// Persist
defaults.set(host, forKey: kServerKey)
defaults.set(ssl, forKey: kSSLKey)
connectTo(host: host, port: port, ssl: ssl)
}
private static func stripPort(from server: String) -> String {
if let idx = server.lastIndex(of: ":") {
// Only strip if it looks like host:port and port is digits
let after = server[server.index(after: idx)...]
if after.allSatisfy({ $0.isNumber }) {
return String(server[..<idx])
}
}
return server
}
private func nickname() -> String {
let base = Host.current().localizedName ?? "prole"
let sanitized = base.replacingOccurrences(of: "[^A-Za-z0-9]", with: "-", options: .regularExpression)
return "prole-\(sanitized)"
}
private func connectTo(host: String, port: Int, ssl: Bool) {
// Note: TLS/SSL is not yet supported by the swift-nio-irc-client package here.
if ssl {
transcriptView.appendLine("Note: SSL/TLS not implemented in current IRC client; attempting plain connection…")
}
let nick = nickname()
let options = IRCClientOptions(
port: port,
host: host,
password: nil,
nickname: IRCNickName(nick)!,
userInfo: IRCUserInfo(username: nick, hostname: host, servername: host, realname: "Prole Tools")
)
let client = IRCClient(options: options)
self.ircClient = client
self.joinedDefaultChannel = false
// Delegate callbacks
class Delegate: IRCClientDelegate {
weak var owner: IRCWindowController?
init(owner: IRCWindowController) { self.owner = owner }
func client(_ client: IRCClient, registered nick: IRCNickName, with userInfo: IRCUserInfo) {
owner?.transcriptView.appendLine("Registered as \(nick.stringValue)")
// Join default channel
if owner?.joinedDefaultChannel == false {
client.send(.otherCommand("JOIN", ["#prole"]))
owner?.joinedDefaultChannel = true
}
}
func clientFailedToRegister(_ client: IRCClient) {
owner?.transcriptView.appendLine("Failed to register with server")
}
func client(_ client: IRCClient, received message: IRCMessage) {
// Render a few common messages
switch message.command {
case .PRIVMSG(let target, let text):
// The upstream message model may not expose a prefix property consistently across versions.
// Fallback to unknown sender for now.
let from = "?"
if case .channel(let ch) = target.first {
owner?.transcriptView.appendLine("[\(ch)] <\(from)> \(text)")
} else {
owner?.transcriptView.appendLine("<\(from)> \(text)")
}
case .NOTICE(_, let text):
owner?.transcriptView.appendLine("-notice- \(text)")
default:
break
}
}
func client(_ client: IRCClient, messageOfTheDay: String) {
owner?.transcriptView.appendLine("-motd- \(messageOfTheDay)")
}
func client(_ client: IRCClient, notice message: String, for recipients: [IRCMessageRecipient]) {
owner?.transcriptView.appendLine("-notice- \(message)")
}
func client(_ client: IRCClient, message: String, from user: IRCUserID, for recipients: [IRCMessageRecipient]) {
let who = user.nick.stringValue
owner?.transcriptView.appendLine("<\(who)> \(message)")
}
func client(_ client: IRCClient, changedUserModeTo mode: IRCUserMode) {}
func client(_ client: IRCClient, changedNickTo nick: IRCNickName) {
owner?.transcriptView.appendLine("You are now known as \(nick.stringValue)")
}
func client(_ client: IRCClient, user: IRCUserID, joined: [IRCChannelName]) {
let who = user.nick.stringValue
let channels = joined.map { $0.stringValue }.joined(separator: ", ")
owner?.transcriptView.appendLine("-- \(who) joined \(channels)")
}
func client(_ client: IRCClient, user: IRCUserID, left: [IRCChannelName], with: String?) {
let who = user.nick.stringValue
let channels = left.map { $0.stringValue }.joined(separator: ", ")
owner?.transcriptView.appendLine("-- \(who) left \(channels)")
}
func client(_ client: IRCClient, changeTopic: String, of channel: IRCChannelName) {
owner?.transcriptView.appendLine("-- topic for \(channel.stringValue): \(changeTopic)")
}
}
client.delegate = Delegate(owner: self)
transcriptView.appendLine("Connecting to \(host):\(port)")
client.connect()
}
}
// A simple scrollable transcript view that fills its container
final class IRCTranscriptView: NSView {
private let scroll = NSScrollView()
private let textView = NSTextView()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
translatesAutoresizingMaskIntoConstraints = false
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = false
scroll.borderType = .bezelBorder
textView.isEditable = false
textView.isSelectable = true
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
textView.textContainerInset = NSSize(width: 6, height: 6)
scroll.documentView = textView
addSubview(scroll)
NSLayoutConstraint.activate([
scroll.topAnchor.constraint(equalTo: topAnchor),
scroll.leadingAnchor.constraint(equalTo: leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: trailingAnchor),
scroll.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
convenience init() {
self.init(frame: .zero)
}
func appendLine(_ line: String) {
let ts = IRCTranscriptView.timestamp()
let s = "[\(ts)] \(line)\n"
if let storage = textView.textStorage {
storage.append(NSAttributedString(string: s))
} else {
textView.string.append(s)
}
textView.scrollToEndOfDocument(nil)
}
private static func timestamp() -> String {
let df = DateFormatter()
df.locale = .current
df.timeZone = .current
df.dateFormat = "HH:mm:ss"
return df.string(from: Date())
}
}