mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
- 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/.
324 lines
14 KiB
Swift
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())
|
|
}
|
|
}
|