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 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[.. 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") ) 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()) } }