aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--NEWS2
-rw-r--r--README.adoc19
-rw-r--r--xM/CMakeLists.txt32
-rw-r--r--xM/main.swift1372
4 files changed, 1421 insertions, 4 deletions
diff --git a/NEWS b/NEWS
index 8177a84..70f3849 100644
--- a/NEWS
+++ b/NEWS
@@ -33,6 +33,8 @@
* Added a Win32 frontend for xC called xW
+ * Added a Cocoa frontend for xC called xM
+
* Added a Go port of xD called xS
diff --git a/README.adoc b/README.adoc
index 4a82d33..6784e84 100644
--- a/README.adoc
+++ b/README.adoc
@@ -2,8 +2,9 @@ xK
==
'xK' (chat kit) is an IRC software suite consisting of a daemon, bot, terminal
-client, and web + Win32 frontends for the client. It's all you're ever going to
-need for chatting, so long as you can make do with slightly minimalist software.
+client, and web/Windows/macOS frontends for the client. It's all you're ever
+going to need for chatting, so long as you can make do with slightly minimalist
+software.
They're all lean on dependencies, and offer a maximally permissive licence.
@@ -140,13 +141,23 @@ endpoint as the third command line argument in this case.
xW
~~
The Win32 frontend is a separate CMake subproject that should be compiled
-using MinGW-w64. In order to run it, make a shortcut for the executable and
-include the relay address in its _Target_ field:
+using MinGW-w64. To avoid having to specify the relay address each time you
+run it, create a shortcut for the executable and include the address in its
+_Target_ field:
C:\...\xW.exe 127.0.0.1 9000
It works reasonably well starting with Windows 7.
+xM
+~~
+The Cocoa frontend is a separate CMake subproject that requires Xcode to build.
+It is currently not that usable. The relay address can either be passed on
+the command line, or preset in the _defaults_ database:
+
+ $ defaults write name.janouch.xM relayHost 127.0.0.1
+ $ defaults write name.janouch.xM relayPort 9000
+
Client Certificates
-------------------
'xC' will use the SASL EXTERNAL method to authenticate using the TLS client
diff --git a/xM/CMakeLists.txt b/xM/CMakeLists.txt
new file mode 100644
index 0000000..3386926
--- /dev/null
+++ b/xM/CMakeLists.txt
@@ -0,0 +1,32 @@
+# Swift language support
+cmake_minimum_required (VERSION 3.15)
+
+file (READ ../xK-version project_version)
+configure_file (../xK-version xK-version.tag COPYONLY)
+string (STRIP "${project_version}" project_version)
+
+# There were two issues when building this from the main CMakeLists.txt:
+# a) renaming main.swift to xM.swift requires removing top-level statements,
+# b) there is a "redefinition of module 'FFI'" error.
+project (xM VERSION "${project_version}"
+ DESCRIPTION "Cocoa frontend for xC" LANGUAGES Swift)
+
+set (root "${PROJECT_SOURCE_DIR}/..")
+add_custom_command (OUTPUT xC-proto.swift
+ COMMAND env LC_ALL=C awk
+ -f ${root}/liberty/tools/lxdrgen.awk
+ -f ${root}/liberty/tools/lxdrgen-swift.awk
+ -v PrefixCamel=Relay
+ ${root}/xC.lxdr > xC-proto.swift
+ DEPENDS
+ ${root}/liberty/tools/lxdrgen.awk
+ ${root}/liberty/tools/lxdrgen-swift.awk
+ ${root}/xC.lxdr
+ COMMENT "Generating xC relay protocol code" VERBATIM)
+
+set (MACOSX_BUNDLE_GUI_IDENTIFIER name.janouch.${PROJECT_NAME})
+
+# Other requirements: macOS 10.14 for Network, and macOS 11 for Logger.
+set (CMAKE_Swift_LANGUAGE_VERSION 5)
+add_executable (xM MACOSX_BUNDLE
+ main.swift ${PROJECT_BINARY_DIR}/xC-proto.swift)
diff --git a/xM/main.swift b/xM/main.swift
new file mode 100644
index 0000000..91e3499
--- /dev/null
+++ b/xM/main.swift
@@ -0,0 +1,1372 @@
+/*
+ * main.swift: Cocoa frontend for xC
+ *
+ * This is in effect a port of xW.cpp to macOS frameworks.
+ *
+ * Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name>
+ *
+ * Permission to use, copy, modify, and/or distribute this software for any
+ * purpose with or without fee is hereby granted.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+ * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+ * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+ * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ *
+ */
+
+import AppKit
+import Network
+import os
+
+let projectName = "xM"
+
+// --- RPC ---------------------------------------------------------------------
+
+class RelayRPC {
+ var connection: NWConnection?
+
+ // Callbacks:
+
+ var onConnected: (() -> Void)?
+ var onFailed: ((String) -> Void)?
+ var onEvent: ((RelayEventMessage) -> Void)?
+
+ // Command processing:
+
+ typealias Callback = (String, RelayResponseData?) -> ()
+
+ /// Outgoing message counter
+ var commandSeq: UInt32 = 0
+ /// Callbacks for messages processed by the server
+ var commandCallbacks = Dictionary<UInt32, Callback>()
+
+ func resetState() {
+ self.connection = nil
+
+ // TODO(p): Consider cancelling all of them from here.
+ self.commandCallbacks.removeAll()
+ }
+
+ func connect(host: String, port: String) -> Bool {
+ let nwHost = NWEndpoint.Host(host)
+ let nwPort = NWEndpoint.Port(port)
+ if nwPort == nil {
+ return false
+ }
+
+ // TODO(p): Consider how to behave after failure.
+ self.resetState()
+
+ self.connection = NWConnection(host: nwHost, port: nwPort!, using: .tcp)
+ self.connection!.stateUpdateHandler = onStateChange(to:)
+ self.receiveFrame()
+ // We directly update the UI from callbacks, avoid threading.
+ self.connection!.start(queue: .main)
+ return true
+ }
+
+ func fail(message: String) {
+ // We cannot pass a message along with cancellation state transition.
+ self.onFailed?(message)
+ self.connection!.cancel()
+ }
+
+ func onStateChange(to: NWConnection.State) {
+ switch to {
+ case .waiting(let error):
+ // This is most likely fatal, despite what the documentation says.
+ self.fail(message: error.localizedDescription)
+ case .ready:
+ self.onConnected?()
+ case .failed(let error):
+ self.onFailed?(error.localizedDescription)
+ default:
+ return
+ }
+ }
+
+ func processCallbacks(
+ commandSeq: UInt32, error: String, response: RelayResponseData?) {
+ if let callback = self.commandCallbacks[commandSeq] {
+ callback(error, response)
+ } else if !error.isEmpty {
+ Logger().warning("Unawaited response: \(error)")
+ } else {
+ Logger().warning("Unawaited response")
+ }
+
+ self.commandCallbacks.removeValue(forKey: commandSeq)
+
+ // We don't particularly care about wraparound issues.
+ for (seq, callback) in self.commandCallbacks where seq < commandSeq {
+ callback("No response.", nil)
+ self.commandCallbacks.removeValue(forKey: seq)
+ }
+ }
+
+ func process(message: RelayEventMessage) {
+ switch message.data {
+ case let data as RelayEventDataError:
+ self.processCallbacks(
+ commandSeq: data.commandSeq, error: data.error, response: nil)
+ case let data as RelayEventDataResponse:
+ self.processCallbacks(
+ commandSeq: data.commandSeq, error: "", response: data.data)
+ default:
+ self.onEvent?(message)
+ }
+ }
+
+ func receiveMessage(length: Int) {
+ self.connection!.receive(
+ minimumIncompleteLength: length, maximumLength: length) {
+ (content, context, isComplete, error) in
+ guard let content = content else {
+ // TODO(p): Do we need to bring it down explicitly on error?
+ if isComplete {
+ self.fail(message: "Connection closed.")
+ }
+ return
+ }
+
+ var r = RelayReader(data: content)
+ do {
+ self.process(message: try RelayEventMessage(from: &r))
+ self.receiveFrame()
+ } catch {
+ self.fail(message: "Deserialization failed.")
+ }
+ }
+ }
+
+ func receiveFrame() {
+ let length = MemoryLayout<UInt32>.size
+ self.connection!.receive(
+ minimumIncompleteLength: length, maximumLength: length) {
+ (content, context, isComplete, error) in
+ guard let content = content else {
+ // TODO(p): Do we need to bring it down explicitly on error?
+ if isComplete {
+ self.fail(message: "Connection closed.")
+ }
+ return
+ }
+
+ var r = RelayReader(data: content)
+ do {
+ let len: UInt32 = try r.read()
+ if let len = Int(exactly: len) {
+ self.receiveMessage(length: len)
+ } else {
+ self.fail(message: "Frame length overflow.")
+ }
+ } catch {
+ self.fail(message: "Deserialization failed.")
+ }
+ }
+ }
+
+ func send(data: RelayCommandData, callback: Callback? = nil) {
+ self.commandSeq += 1
+ let m = RelayCommandMessage(commandSeq: self.commandSeq, data: data)
+ if let callback = callback {
+ self.commandCallbacks[m.commandSeq] = callback
+ }
+
+ var w = RelayWriter()
+ m.encode(to: &w)
+ var prefix = RelayWriter()
+ prefix.append(UInt32(w.data.count))
+
+ self.connection!.batch() {
+ self.connection!.send(content: prefix.data,
+ completion: .contentProcessed({ error in }))
+ self.connection!.send(content: w.data,
+ completion: .contentProcessed({ error in }))
+ }
+ }
+}
+
+// --- State -------------------------------------------------------------------
+
+class Server {
+ var state: RelayServerState = .disconnected
+ var user: String = ""
+ var userModes: String = ""
+}
+
+struct BufferLine {
+ var leaked: Bool = false
+
+ var isUnimportant: Bool = false
+ var isHighlight: Bool = false
+ var rendition: RelayRendition = .bare
+ var when: UInt64 = 0
+ var text = NSAttributedString()
+}
+
+class Buffer {
+ var bufferName: String = ""
+ var hideUnimportant: Bool = false
+ var kind: RelayBufferKind = .global
+ var serverName: String = ""
+ var lines: Array<BufferLine> = []
+
+ // Channel:
+
+ var topic = NSAttributedString()
+ var modes: String = ""
+
+ // Stats:
+
+ var newMessages: UInt32 = 0
+ var newUnimportantMessages: UInt32 = 0
+ var highlighted: Bool = false
+
+ // Input:
+
+ var input: String = ""
+ var inputSelection: NSRange? = nil
+ var history: Array<String> = []
+ var historyAt: Int = 0
+}
+
+var relayRPC = RelayRPC()
+
+var relayBuffers: Array<Buffer> = []
+var relayBufferCurrent: String = ""
+var relayBufferLast: String = ""
+
+var relayServers: Dictionary<String, Server> = [:]
+
+// --- Buffers -----------------------------------------------------------------
+
+func bufferBy(name: String) -> Buffer? {
+ return relayBuffers.first(where: { b in b.bufferName == name })
+}
+
+func bufferActivate(name: String) {
+ let activate = RelayCommandDataBufferActivate(bufferName: name)
+ relayRPC.send(data: activate)
+}
+
+func bufferToggleUnimportant(name: String) {
+ let toggle = RelayCommandDataBufferToggleUnimportant(bufferName: name)
+ relayRPC.send(data: toggle)
+}
+
+// --- GUI ---------------------------------------------------------------------
+
+let app = NSApplication.shared
+
+let menu = NSMenu()
+app.mainMenu = menu
+
+let applicationMenuItem = NSMenuItem()
+menu.addItem(applicationMenuItem)
+let applicationMenu = NSMenu()
+applicationMenuItem.submenu = applicationMenu
+applicationMenu.addItem(NSMenuItem(title: "Quit " + projectName,
+ action: #selector(app.terminate), keyEquivalent: "q"))
+
+let bufferMenuItem = NSMenuItem()
+menu.addItem(bufferMenuItem)
+let bufferMenu = NSMenu(title: "Buffer")
+bufferMenuItem.submenu = bufferMenu
+
+let uiStackView = NSStackView()
+uiStackView.orientation = .vertical
+uiStackView.spacing = 0
+uiStackView.alignment = .leading
+
+func pushWithMargins(view: NSView) {
+ let box = NSStackView()
+ box.orientation = .horizontal
+ box.edgeInsets = NSEdgeInsetsMake(4, 8, 4, 8)
+ box.addArrangedSubview(view)
+ uiStackView.addArrangedSubview(box)
+}
+
+// TODO(p): Consider replacing with NSTextView,
+// to avoid font changes when selected.
+let uiTopic = NSTextField(wrappingLabelWithString: "")
+uiTopic.isEditable = false
+uiTopic.isBezeled = false
+uiTopic.drawsBackground = false
+// Otherwise clicking it removes string attributes.
+uiTopic.allowsEditingTextAttributes = true
+pushWithMargins(view: uiTopic)
+
+let uiSeparator1 = NSBox()
+uiSeparator1.boxType = .separator
+uiStackView.addArrangedSubview(uiSeparator1)
+
+let uiBufferList = NSTableView()
+uiBufferList.addTableColumn(
+ NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Buffer name")))
+uiBufferList.headerView = nil
+uiBufferList.style = .plain
+uiBufferList.rowSizeStyle = .default
+uiBufferList.allowsEmptySelection = false
+
+let uiBufferListScroll = NSScrollView()
+uiBufferListScroll.setFrameSize(CGSize(width: 1, height: 1))
+uiBufferListScroll.documentView = uiBufferList
+uiBufferListScroll.hasVerticalScroller = true
+uiBufferListScroll.verticalScroller?.refusesFirstResponder = true
+
+let uiBuffer = NSTextView()
+uiBuffer.isEditable = false
+uiBuffer.focusRingType = .default
+// Otherwise it skips a lot when scrolling.
+uiBuffer.layoutManager?.allowsNonContiguousLayout = false
+uiBuffer.autoresizingMask = [.width, .height]
+
+let uiBufferScroll = NSScrollView()
+uiBufferScroll.setFrameSize(CGSize(width: 2, height: 1))
+uiBufferScroll.documentView = uiBuffer
+uiBufferScroll.hasVerticalScroller = true
+uiBufferScroll.verticalScroller?.refusesFirstResponder = true
+
+let uiSplitView = NSSplitView()
+uiSplitView.isVertical = true
+uiSplitView.dividerStyle = .thin
+uiSplitView.addArrangedSubview(uiBufferListScroll)
+uiSplitView.addArrangedSubview(uiBufferScroll)
+uiStackView.addArrangedSubview(uiSplitView)
+
+NSLayoutConstraint.activate([
+ uiSplitView.leadingAnchor.constraint(
+ equalTo: uiStackView.leadingAnchor),
+ uiSplitView.trailingAnchor.constraint(
+ equalTo: uiStackView.trailingAnchor),
+])
+
+let uiSeparator2 = NSBox()
+uiSeparator2.boxType = .separator
+uiStackView.addArrangedSubview(uiSeparator2)
+
+let uiStatus = NSTextField()
+uiStatus.isEditable = false
+uiStatus.isBezeled = false
+uiStatus.drawsBackground = false
+uiStatus.stringValue = "Connecting..."
+pushWithMargins(view: uiStatus)
+
+let uiSeparator3 = NSBox()
+uiSeparator3.boxType = .separator
+uiStackView.addArrangedSubview(uiSeparator3)
+
+let uiBottomStackView = NSStackView()
+uiBottomStackView.orientation = .horizontal
+pushWithMargins(view: uiBottomStackView)
+
+let uiPrompt = NSTextField()
+uiPrompt.isEditable = false
+uiPrompt.isBezeled = false
+uiPrompt.drawsBackground = false
+uiPrompt.isHidden = true
+uiBottomStackView.addArrangedSubview(uiPrompt)
+
+let uiInput = NSTextField()
+uiBottomStackView.addArrangedSubview(uiInput)
+
+let uiWindow = NSWindow(
+ contentRect: NSMakeRect(0, 0, 640, 480),
+ styleMask: [.titled, .closable, .resizable],
+ backing: .buffered, defer: true)
+uiWindow.title = projectName
+uiWindow.contentView = uiStackView
+
+// --- Current buffer ----------------------------------------------------------
+
+func bufferAtBottom() -> Bool {
+ // TODO(p): Actually implement.
+ // Consider uiBufferScroll.verticalScroller.floatValue.
+ return true
+}
+
+func bufferScrollToBottom() {
+ uiBuffer.scrollToEndOfDocument(nil)
+}
+
+// --- UI state refresh --------------------------------------------------------
+
+func refreshIcon() {
+ // TODO(p): Perhaps adjust NSApplication.applicationIconImage.
+}
+
+func refreshTopic(topic: NSAttributedString) {
+ uiTopic.attributedStringValue = topic
+}
+
+func refreshBufferList() {
+ uiBufferList.reloadData()
+}
+
+func serverStateToString(state: RelayServerState) -> String {
+ switch state {
+ case .disconnected:
+ return "disconnected"
+ case .connecting:
+ return "connecting"
+ case .connected:
+ return "connected"
+ case .registered:
+ return "registered"
+ case .disconnecting:
+ return "disconnecting"
+ }
+}
+
+func refreshPrompt() {
+ var prompt = String()
+ if let b = bufferBy(name: relayBufferCurrent) {
+ if let server = relayServers[b.serverName] {
+ prompt = server.user
+ if !server.userModes.isEmpty {
+ prompt += "(\(server.userModes))"
+ }
+ if prompt.isEmpty {
+ prompt = "(\(serverStateToString(state: server.state)))"
+ }
+ }
+ }
+
+ if prompt.isEmpty {
+ uiPrompt.isHidden = true
+ } else {
+ uiPrompt.isHidden = false
+ uiPrompt.stringValue = prompt
+ }
+}
+
+func refreshStatus() {
+ var status = relayBufferCurrent
+ if let b = bufferBy(name: relayBufferCurrent) {
+ if !b.modes.isEmpty {
+ status += "(+\(b.modes))"
+ }
+ if b.hideUnimportant {
+ status += "<H>"
+ }
+ } else {
+ status = "Synchronizing..."
+ }
+
+ // XXX: This indicator should probably be on the right side of the window.
+ if !bufferAtBottom() {
+ status += " ^"
+ }
+
+ uiStatus.stringValue = status
+}
+
+// --- Buffer output -----------------------------------------------------------
+
+func convertColor(color: Int16) -> NSColor {
+ let base16: [UInt16] = [
+ 0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
+ 0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff,
+ ]
+ if color < 16 {
+ let r = CGFloat(0xf & (base16[Int(color)] >> 8)) / 0xf
+ let g = CGFloat(0xf & (base16[Int(color)] >> 4)) / 0xf
+ let b = CGFloat(0xf & (base16[Int(color)] >> 0)) / 0xf
+ return NSColor(red: r, green: g, blue: b, alpha: 1)
+ }
+ if color >= 216 {
+ let g = CGFloat(8 + (color - 216) * 10) / 0xff
+ return NSColor(white: g, alpha: 1)
+ }
+
+ let i = color - 16
+ let r = (i / 36)
+ let g = (i / 6) % 6
+ let b = (i % 6)
+ let rr = r > 0 ? CGFloat(55 + 40 * r) / 0xff : 0
+ let gg = g > 0 ? CGFloat(55 + 40 * g) / 0xff : 0
+ let bb = b > 0 ? CGFloat(55 + 40 * b) / 0xff : 0
+ return NSColor(red: rr, green: gg, blue: bb, alpha: 1)
+}
+
+func convertItemFormatting(item: RelayItemData,
+ attrs: inout [NSAttributedString.Key : Any], inverse: inout Bool) {
+ switch item {
+ case is RelayItemDataReset:
+ attrs.removeAll()
+ inverse = false
+ case is RelayItemDataFlipBold:
+ // TODO(p): Need to select a font: applyFontTraits(_:range:)?
+ break
+ case is RelayItemDataFlipItalic:
+ // TODO(p): Need to select a font: applyFontTraits(_:range:)?
+ break
+ case is RelayItemDataFlipUnderline:
+ if attrs[.underlineStyle] != nil {
+ attrs.removeValue(forKey: .underlineStyle)
+ } else {
+ attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue
+ }
+ case is RelayItemDataFlipCrossedOut:
+ if attrs[.strikethroughStyle] != nil {
+ attrs.removeValue(forKey: .strikethroughStyle)
+ } else {
+ attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+ }
+ case is RelayItemDataFlipInverse:
+ inverse = !inverse
+ case is RelayItemDataFlipMonospace:
+ // TODO(p): Need to select a font: applyFontTraits(_:range:)?
+ break
+ case let data as RelayItemDataFgColor:
+ if data.color < 0 {
+ attrs.removeValue(forKey: .foregroundColor)
+ } else {
+ attrs[.foregroundColor] = convertColor(color: data.color)
+ }
+ case let data as RelayItemDataBgColor:
+ if data.color < 0 {
+ attrs.removeValue(forKey: .backgroundColor)
+ } else {
+ attrs[.backgroundColor] = convertColor(color: data.color)
+ }
+ default:
+ return
+ }
+}
+
+func convertItems(items: [RelayItemData]) -> NSAttributedString {
+ let result = NSMutableAttributedString()
+ var attrs = [NSAttributedString.Key : Any]()
+ var inverse = false
+ for item in items {
+ guard let text = item as? RelayItemDataText else {
+ convertItemFormatting(item: item, attrs: &attrs, inverse: &inverse)
+ continue
+ }
+
+ // TODO(p): Handle inverse text.
+ result.append(NSAttributedString(string: text.text, attributes: attrs))
+ }
+ if let detector = try? NSDataDetector(types:
+ NSTextCheckingResult.CheckingType.link.rawValue) {
+ for m in detector.matches(
+ in: result.string, range: NSMakeRange(0, result.length)) {
+ guard let url = m.url else {
+ continue
+ }
+ let raw = (result.string as NSString).substring(with: m.range)
+ if raw.contains("://") {
+ result.addAttribute(.link, value: url, range: m.range)
+ }
+ }
+ }
+ return result
+}
+
+func convertBufferLine(line: RelayEventDataBufferLine) -> BufferLine {
+ var bl = BufferLine()
+ bl.isUnimportant = line.isUnimportant
+ bl.isHighlight = line.isHighlight
+ bl.rendition = line.rendition
+ bl.when = line.when
+ bl.text = convertItems(items: line.items)
+ return bl
+}
+
+func bufferPrintLine(line: BufferLine) {
+ guard let ts = uiBuffer.textStorage else {
+ return
+ }
+
+ let current = Date(timeIntervalSince1970: Double(line.when / 1000))
+
+ // TODO(p): Print date changes.
+ if ts.length != 0 {
+ ts.append(NSAttributedString(string: "\n"))
+ }
+
+ let formatter = DateFormatter()
+ formatter.dateFormat = "HH:mm:ss"
+ ts.append(NSAttributedString(
+ string: formatter.string(from: current),
+ attributes: [
+ .foregroundColor: NSColor(white: 0xbb / 0xff, alpha: 1),
+ .backgroundColor: NSColor(white: 0xf8 / 0xff, alpha: 1),
+ .font: NSFont.monospacedDigitSystemFont(
+ ofSize: uiBuffer.font!.pointSize, weight: .regular)
+ ]))
+
+ ts.append(NSAttributedString(string: " "))
+
+ let prefix = NSMutableAttributedString()
+ var foreground: NSColor? = nil
+ switch line.rendition {
+ case .bare:
+ break
+ case .indent:
+ prefix.mutableString.append(" ")
+ case .status:
+ prefix.mutableString.append(" - ")
+ case .error:
+ prefix.mutableString.append("=!= ")
+ foreground = NSColor.red
+ case .join:
+ prefix.mutableString.append("--> ")
+ foreground = NSColor(red: 0, green: 0.5, blue: 0, alpha: 1)
+ case .part:
+ prefix.mutableString.append("<-- ")
+ foreground = NSColor(red: 0.5, green: 0, blue: 0, alpha: 1)
+ case .action:
+ prefix.mutableString.append(" * ")
+ foreground = NSColor(red: 0.5, green: 0, blue: 0, alpha: 1)
+ }
+ if let color = foreground {
+ prefix.addAttribute(.foregroundColor, value: color,
+ range: NSMakeRange(0, prefix.length))
+ }
+
+ // FIXME: Fixed pitch doesn't actually work.
+ prefix.applyFontTraits(.boldFontMask,
+ range: NSMakeRange(0, prefix.length))
+ prefix.applyFontTraits(.fixedPitchFontMask,
+ range: NSMakeRange(0, prefix.length))
+
+ if line.leaked {
+ let deattributed = NSMutableAttributedString(attributedString: prefix)
+ deattributed.append(line.text)
+ let whole = NSMakeRange(0, deattributed.length)
+ deattributed.removeAttribute(.backgroundColor, range: whole)
+ deattributed.addAttributes(
+ [.foregroundColor: NSColor(white: 0.5, alpha: 1)], range: whole)
+ ts.append(deattributed)
+ } else {
+ ts.append(prefix)
+ ts.append(line.text)
+ }
+}
+
+func bufferPrintSeparator() {
+ guard let ts = uiBuffer.textStorage else {
+ return
+ }
+ if ts.length != 0 {
+ ts.append(NSAttributedString(string: "\n"))
+ }
+
+ // TODO(p): Figure out if we can't add an actual horizontal line.
+ ts.append(NSAttributedString(
+ string: "---",
+ attributes: [.foregroundColor: NSColor.controlAccentColor]))
+}
+
+func refreshBuffer(b: Buffer) {
+ // TODO(p): See if we can pause updating.
+ // If not, consider updating textStorage atomically.
+ if let ts = uiBuffer.textStorage {
+ ts.setAttributedString(NSAttributedString())
+ }
+
+ var i: Int = 0
+ let markBefore = b.lines.count
+ - Int(b.newMessages) - Int(b.newUnimportantMessages)
+ for line in b.lines {
+ if i == markBefore {
+ bufferPrintSeparator()
+ }
+ if !line.isUnimportant || !b.hideUnimportant {
+ bufferPrintLine(line: line)
+ }
+
+ i += 1
+ }
+
+ // TODO(p): Output any trailing date change.
+ // FIXME: When the topic wraps, this doesn't scroll correctly.
+ bufferScrollToBottom()
+}
+
+// --- Event processing --------------------------------------------------------
+
+relayRPC.onConnected = {
+ let hello = RelayCommandDataHello(version: UInt32(relayVersion))
+ relayRPC.send(data: hello)
+}
+
+relayRPC.onFailed = { error in
+ let alert = NSAlert()
+ alert.messageText = "Relay connection failed"
+ alert.informativeText = error
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ exit(EXIT_FAILURE)
+}
+
+func onBufferLine(b: Buffer, m: RelayEventDataBufferLine) {
+ // Initial sync: skip all other processing, let highlights be.
+ guard let bc = bufferBy(name: relayBufferCurrent) else {
+ b.lines.append(convertBufferLine(line: m))
+ return
+ }
+
+ let display = (!m.isUnimportant || !bc.hideUnimportant) &&
+ (b.bufferName == relayBufferCurrent || m.leakToActive)
+ let toBottom = display &&
+ bufferAtBottom()
+ // TODO(p): Once the log view is implemented, replace the "true" here.
+ let visible = display &&
+ toBottom &&
+ !uiWindow.isMiniaturized &&
+ true
+ let separate = display &&
+ !visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0
+
+ var line = convertBufferLine(line: m)
+ if !(visible || m.leakToActive) ||
+ b.newMessages != 0 || b.newUnimportantMessages != 0 {
+ if line.isUnimportant || m.leakToActive {
+ b.newUnimportantMessages += 1
+ } else {
+ b.newMessages += 1
+ }
+ }
+
+ b.lines.append(line)
+ if m.leakToActive {
+ line.leaked = true
+ bc.lines.append(line)
+ if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 {
+ if line.isUnimportant {
+ bc.newUnimportantMessages += 1
+ } else {
+ bc.newMessages += 1
+ }
+ }
+ }
+
+ if separate {
+ bufferPrintSeparator()
+ }
+ if display {
+ bufferPrintLine(line: line)
+ }
+ if toBottom {
+ bufferScrollToBottom()
+ }
+
+ if line.isHighlight || (!visible && !line.isUnimportant &&
+ b.kind == .privateMessage) {
+ NSSound.beep()
+
+ if !visible {
+ b.highlighted = true
+ refreshIcon()
+ }
+ }
+
+ refreshBufferList()
+}
+
+relayRPC.onEvent = { message in
+ Logger().debug("Processing message \(message.eventSeq)")
+
+ switch message.data {
+ case _ as RelayEventDataPing:
+ let pong = RelayCommandDataPingResponse(eventSeq: message.eventSeq)
+ relayRPC.send(data: pong)
+
+ case let data as RelayEventDataBufferLine:
+ guard let b = bufferBy(name: data.bufferName) else {
+ break
+ }
+
+ onBufferLine(b: b, m: data)
+
+ case let data as RelayEventDataBufferUpdate:
+ let b: Buffer
+ if let buffer = bufferBy(name: data.bufferName) {
+ b = buffer
+ } else {
+ b = Buffer()
+ b.bufferName = data.bufferName
+ relayBuffers.append(b)
+ refreshBufferList()
+ }
+
+ let hidingToggled = b.hideUnimportant != data.hideUnimportant
+ b.hideUnimportant = data.hideUnimportant
+ b.kind = data.context.kind
+ b.serverName.removeAll()
+ switch data.context {
+ case let context as RelayBufferContextServer:
+ b.serverName = context.serverName
+ case let context as RelayBufferContextChannel:
+ b.serverName = context.serverName
+ b.modes = context.modes
+ b.topic = convertItems(items: context.topic)
+ case let context as RelayBufferContextPrivateMessage:
+ b.serverName = context.serverName
+ default:
+ break
+ }
+
+ if b.bufferName == relayBufferCurrent {
+ refreshTopic(topic: b.topic)
+ refreshStatus()
+
+ if hidingToggled {
+ refreshBuffer(b: b)
+ }
+ }
+
+ case let data as RelayEventDataBufferStats:
+ guard let b = bufferBy(name: data.bufferName) else {
+ break
+ }
+
+ b.newMessages = data.newMessages
+ b.newUnimportantMessages = data.newUnimportantMessages
+ b.highlighted = data.highlighted
+
+ refreshIcon()
+
+ case let data as RelayEventDataBufferRename:
+ guard let b = bufferBy(name: data.bufferName) else {
+ break
+ }
+
+ b.bufferName = data.new
+
+ refreshBufferList()
+ if b.bufferName == relayBufferCurrent {
+ relayBufferCurrent = data.new
+ refreshStatus()
+ }
+ if b.bufferName == relayBufferLast {
+ relayBufferLast = data.new
+ }
+
+ case let data as RelayEventDataBufferRemove:
+ guard let b = bufferBy(name: data.bufferName) else {
+ break
+ }
+
+ relayBuffers.removeAll(where: { $0 === b })
+ refreshBufferList()
+
+ refreshIcon()
+
+ case let data as RelayEventDataBufferActivate:
+ let old = bufferBy(name: relayBufferCurrent)
+ relayBufferLast = relayBufferCurrent
+ relayBufferCurrent = data.bufferName
+ guard let b = bufferBy(name: data.bufferName) else {
+ break
+ }
+
+ if let old = old {
+ old.newMessages = 0
+ old.newUnimportantMessages = 0
+ old.highlighted = false
+
+ old.input = uiInput.stringValue
+ // As in the textShouldBeginEditing delegate method.
+ if let editor = uiInput.currentEditor() {
+ old.inputSelection = editor.selectedRange
+ }
+
+ old.historyAt = old.history.count
+ }
+
+ // TODO(p): Disable log display, once implemented.
+ if !uiWindow.isMiniaturized {
+ b.highlighted = false
+ }
+
+ if let i = relayBuffers.firstIndex(where: { $0 === b }) {
+ uiBufferList.selectRowIndexes(
+ IndexSet(integer: i), byExtendingSelection: false)
+ }
+
+ refreshIcon()
+ refreshTopic(topic: b.topic)
+ refreshBuffer(b: b)
+ refreshPrompt()
+ refreshStatus()
+
+ uiInput.stringValue = b.input
+ uiWindow.makeFirstResponder(uiInput)
+ if let editor = uiInput.currentEditor() {
+ // As in the textShouldEndEditing delegate method.
+ if let selection = b.inputSelection {
+ editor.selectedRange = selection
+ } else {
+ editor.selectedRange = NSMakeRange(editor.string.count, 0)
+ }
+ }
+
+ case let data as RelayEventDataBufferInput:
+ guard let b = bufferBy(name: data.bufferName) else {
+ break
+ }
+ if b.historyAt == b.history.count {
+ b.historyAt += 1
+ }
+
+ b.history.append(data.text)
+
+ case let data as RelayEventDataBufferClear:
+ guard let b = bufferBy(name: data.bufferName) else {
+ break
+ }
+
+ b.lines.removeAll()
+ if b.bufferName == relayBufferCurrent {
+ refreshBuffer(b: b)
+ }
+
+ case let data as RelayEventDataServerUpdate:
+ let s: Server
+ if let server = relayServers[data.serverName] {
+ s = server
+ } else {
+ s = Server()
+ relayServers[data.serverName] = s
+ }
+
+ s.state = data.data.state
+ s.user.removeAll()
+ s.userModes.removeAll()
+ if let registered = data.data as? RelayServerDataRegistered {
+ s.user = registered.user
+ s.userModes = registered.userModes
+ }
+
+ refreshPrompt()
+
+ case let data as RelayEventDataServerRename:
+ relayServers[data.new] = relayServers[data.serverName]
+ relayServers.removeValue(forKey: data.serverName)
+
+ case let data as RelayEventDataServerRemove:
+ relayServers.removeValue(forKey: data.serverName)
+
+ default:
+ return
+ }
+}
+
+// --- Input line --------------------------------------------------------------
+
+struct InputStamp: Equatable {
+ let input: String
+ let selection: NSRange
+
+ init?() {
+ guard let textView = uiInput.currentEditor() as? NSTextView else {
+ return nil
+ }
+
+ self.input = textView.string
+ self.selection = textView.selectedRange()
+ }
+}
+
+class InputDelegate: NSObject, NSTextFieldDelegate {
+ func inputSubmit() -> Bool {
+ guard let b = bufferBy(name: relayBufferCurrent) else {
+ return false
+ }
+
+ let input = RelayCommandDataBufferInput(
+ bufferName: b.bufferName, text: uiInput.stringValue)
+
+ // Buffer.history[Buffer.history.count] is virtual,
+ // and is represented either by edit contents when it's currently
+ // being edited, or by Buffer.input in all other cases.
+ b.history.append(uiInput.stringValue)
+ b.historyAt = b.history.count
+ uiInput.stringValue = ""
+
+ relayRPC.send(data: input)
+ return true
+ }
+
+ func inputComplete(state: InputStamp,
+ error: String, data: RelayResponseDataBufferComplete?) {
+ guard let data = data else {
+ NSSound.beep()
+ Logger().warning("\(error)")
+ return
+ }
+
+ guard let textView = uiInput.currentEditor() as? NSTextView else {
+ return
+ }
+ guard let preceding =
+ String(state.input.utf8.prefix(Int(data.start))) else {
+ return
+ }
+
+ if var insert = data.completions.first {
+ if data.completions.count == 1 {
+ insert += " "
+ }
+
+ textView.insertText(insert, replacementRange: NSMakeRange(
+ preceding.count, NSMaxRange(state.selection) - preceding.count))
+ }
+
+ if data.completions.count != 1 {
+ NSSound.beep()
+ }
+
+ // TODO(p): Show all completion options.
+ // Cocoa text completion isn't useful, because it searches for word
+ // boundaries on its own (otherwise see NSControlTextEditingDelegate).
+ }
+
+ func inputComplete(textView: NSTextView) -> Bool {
+ // TODO(p): Also add an increasing counter to the stamp.
+ guard let state = InputStamp() else {
+ return false
+ }
+ if state.selection.length != 0 {
+ return false
+ }
+
+ let prefix = state.input.prefix(state.selection.location)
+ let complete = RelayCommandDataBufferComplete(
+ bufferName: relayBufferCurrent,
+ text: state.input,
+ position: UInt32(prefix.utf8.count))
+ relayRPC.send(data: complete) { (error, data) in
+ if state != InputStamp() {
+ return
+ }
+
+ self.inputComplete(state: state, error: error,
+ data: data as? RelayResponseDataBufferComplete)
+ }
+ return true
+ }
+
+ func inputUp(textView: NSTextView) -> Bool {
+ guard let b = bufferBy(name: relayBufferCurrent) else {
+ return false
+ }
+ if b.historyAt < 1 {
+ return false
+ }
+
+ if b.historyAt == b.history.count {
+ b.input = uiInput.stringValue
+ }
+ b.historyAt -= 1
+ uiInput.stringValue = b.history[b.historyAt]
+ textView.selectedRange = NSMakeRange(textView.string.count, 0)
+ return true
+ }
+
+ func inputDown(textView: NSTextView) -> Bool {
+ guard let b = bufferBy(name: relayBufferCurrent) else {
+ return false
+ }
+ if b.historyAt >= b.history.count {
+ return false
+ }
+
+ b.historyAt += 1
+ if b.historyAt == b.history.count {
+ uiInput.stringValue = b.input
+ } else {
+ uiInput.stringValue = b.history[b.historyAt]
+ }
+ textView.selectedRange = NSMakeRange(textView.string.count, 0)
+ return true
+ }
+
+ func control(_ control: NSControl,
+ textShouldBeginEditing fieldEditor: NSText) -> Bool {
+ guard let b = bufferBy(name: relayBufferCurrent) else {
+ return false
+ }
+ if let selection = b.inputSelection {
+ fieldEditor.selectedRange = selection
+ } else {
+ fieldEditor.selectedRange = NSMakeRange(fieldEditor.string.count, 0)
+ }
+ return true
+ }
+
+ func control(_ control: NSControl,
+ textShouldEndEditing fieldEditor: NSText) -> Bool {
+ guard let b = bufferBy(name: relayBufferCurrent) else {
+ return true
+ }
+ b.inputSelection = fieldEditor.selectedRange
+ return true
+ }
+
+ func control(_ control: NSControl, textView: NSTextView,
+ doCommandBy commandSelector: Selector) -> Bool {
+ // TODO(p): Emacs-style cursor movement shortcuts.
+ // TODO(p): Once the log is implemented, scroll that if visible.
+ var success = true
+ switch commandSelector {
+ case #selector(NSStandardKeyBindingResponding.insertTab(_:)):
+ success = self.inputComplete(textView: textView)
+ case #selector(NSStandardKeyBindingResponding.insertNewline(_:)):
+ success = self.inputSubmit()
+ case #selector(NSStandardKeyBindingResponding.scrollPageUp(_:)):
+ uiBuffer.doCommand(by: commandSelector)
+ case #selector(NSStandardKeyBindingResponding.scrollPageDown(_:)):
+ uiBuffer.doCommand(by: commandSelector)
+ case #selector(NSStandardKeyBindingResponding.moveUp(_:)):
+ success = self.inputUp(textView: textView)
+ case #selector(NSStandardKeyBindingResponding.moveDown(_:)):
+ success = self.inputDown(textView: textView)
+ default:
+ return false
+ }
+ if !success {
+ NSSound.beep()
+ }
+ return true
+ }
+}
+
+// --- General UI --------------------------------------------------------------
+
+class BufferListDataSource: NSObject, NSTableViewDataSource {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ return relayBuffers.count
+ }
+
+ func tableView(_ tableView: NSTableView,
+ objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
+ let b = relayBuffers[row]
+ let result = NSMutableAttributedString(string: b.bufferName)
+ if b.bufferName != relayBufferCurrent && b.newMessages != 0 {
+ result.mutableString.append(" (\(b.newMessages))")
+ result.applyFontTraits(.boldFontMask,
+ range: NSMakeRange(0, result.length))
+ }
+ if b.highlighted {
+ result.addAttribute(.foregroundColor,
+ value: NSColor.controlAccentColor,
+ range: NSMakeRange(0, result.length))
+ }
+ return result
+ }
+}
+
+class BufferListDelegate: NSObject, NSTableViewDelegate {
+ func tableView(_ tableView: NSTableView,
+ shouldEdit: NSTableColumn?, row: Int) -> Bool {
+ return false
+ }
+
+ func tableView(_ tableView: NSTableView,
+ shouldSelectRow row: Int) -> Bool {
+ // The framework would select a row during synchronization.
+ if !relayBufferCurrent.isEmpty && row < relayBuffers.count {
+ bufferActivate(name: relayBuffers[row].bufferName)
+ }
+ return false
+ }
+}
+
+class ApplicationDelegate: NSObject, NSApplicationDelegate {
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ // We need to call them from here,
+ // or the menu wouldn't be clickable right after activation.
+ app.setActivationPolicy(.regular)
+ app.activate(ignoringOtherApps: true)
+ }
+}
+
+class WindowDelegate: NSObject, NSWindowDelegate {
+ func windowWillClose(_ notification: Notification) {
+ app.terminate(nil)
+ }
+
+ func windowDidDeminiaturize(_ notification: Notification) {
+ guard let b = bufferBy(name: relayBufferCurrent) else {
+ return
+ }
+
+ b.highlighted = false
+ refreshIcon()
+ }
+
+ // Buffer indexes rotated to start after the current buffer.
+ func rotatedBuffers() -> Array<Buffer> {
+ guard let i = relayBuffers.firstIndex(
+ where: { $0.bufferName == relayBufferCurrent }) else {
+ return relayBuffers
+ }
+ let start = i + 1
+ return Array<Buffer>(relayBuffers[start...] + relayBuffers[..<start])
+ }
+
+ @objc func actionPreviousBuffer() {
+ let rotated = self.rotatedBuffers()
+ if rotated.count > 1 {
+ bufferActivate(name: rotated[rotated.count - 2].bufferName)
+ }
+ }
+
+ @objc func actionNextBuffer() {
+ if let following = self.rotatedBuffers().first {
+ bufferActivate(name: following.bufferName)
+ }
+ }
+
+ @objc func actionSwitchBuffer() {
+ if !relayBufferLast.isEmpty {
+ bufferActivate(name: relayBufferLast)
+ }
+ }
+
+ @objc func actionGotoHighlight() {
+ for b in rotatedBuffers() {
+ if b.highlighted {
+ bufferActivate(name: b.bufferName)
+ return
+ }
+ }
+ }
+
+ @objc func actionGotoActivity() {
+ for b in rotatedBuffers() {
+ if b.newMessages != 0 {
+ bufferActivate(name: b.bufferName)
+ return
+ }
+ }
+ }
+
+ @objc func actionToggleUnimportant() {
+ if let b = bufferBy(name: relayBufferCurrent) {
+ bufferToggleUnimportant(name: b.bufferName)
+ }
+ }
+}
+
+// --- Accelerators ------------------------------------------------------------
+
+func pushAccelerator(_ title: String, _ action: Selector,
+ _ keyEquivalent: String, _ modifiers: NSEvent.ModifierFlags,
+ hidden: Bool = false) {
+ let item = NSMenuItem(
+ title: title, action: action, keyEquivalent: keyEquivalent)
+ item.keyEquivalentModifierMask = modifiers
+
+ // isAlternate doesn't really cover our needs.
+ if hidden {
+ item.isHidden = true
+ item.allowsKeyEquivalentWhenHidden = true
+ }
+ bufferMenu.addItem(item)
+}
+
+pushAccelerator("Previous buffer",
+ #selector(WindowDelegate.actionPreviousBuffer),
+ "p", [.control])
+pushAccelerator("Next buffer",
+ #selector(WindowDelegate.actionNextBuffer),
+ "n", [.control])
+pushAccelerator("Previous buffer",
+ #selector(WindowDelegate.actionPreviousBuffer),
+ String(UnicodeScalar(NSF5FunctionKey)!), [], hidden: true)
+pushAccelerator("Next buffer",
+ #selector(WindowDelegate.actionNextBuffer),
+ String(UnicodeScalar(NSF6FunctionKey)!), [], hidden: true)
+pushAccelerator("Previous buffer",
+ #selector(WindowDelegate.actionPreviousBuffer),
+ String(UnicodeScalar(NSPageUpFunctionKey)!), [.control], hidden: true)
+pushAccelerator("Next buffer",
+ #selector(WindowDelegate.actionNextBuffer),
+ String(UnicodeScalar(NSPageDownFunctionKey)!), [.control], hidden: true)
+
+// Let's add macOS browser shortcuts for good measure.
+pushAccelerator("Previous buffer",
+ #selector(WindowDelegate.actionPreviousBuffer),
+ "[", [.shift, .command], hidden: true)
+pushAccelerator("Next buffer",
+ #selector(WindowDelegate.actionNextBuffer),
+ "]", [.shift, .command], hidden: true)
+
+pushAccelerator("Switch buffer",
+ #selector(WindowDelegate.actionSwitchBuffer),
+ "\t", [.control])
+
+// TODO(p): Remove .command, and ignore these with the right Option key.
+bufferMenu.addItem(NSMenuItem.separator())
+pushAccelerator("Go to highlight",
+ #selector(WindowDelegate.actionGotoHighlight),
+ "!", [.command, .option])
+pushAccelerator("Go to activity",
+ #selector(WindowDelegate.actionGotoActivity),
+ "a", [.command, .option])
+
+bufferMenu.addItem(NSMenuItem.separator())
+pushAccelerator("Toggle unimportant",
+ #selector(WindowDelegate.actionToggleUnimportant),
+ "H", [.command, .option])
+
+// --- Delegation setup --------------------------------------------------------
+
+let uiInputDelegate = InputDelegate()
+uiInput.delegate = uiInputDelegate
+let uiBufferListDataSource = BufferListDataSource()
+uiBufferList.dataSource = uiBufferListDataSource
+let uiBufferListDelegate = BufferListDelegate()
+uiBufferList.delegate = uiBufferListDelegate
+
+let appDelegate = ApplicationDelegate()
+app.delegate = appDelegate
+let uiWindowDelegate = WindowDelegate()
+uiWindow.delegate = uiWindowDelegate
+
+// --- Startup -----------------------------------------------------------------
+
+// TODO(p): Ideally, we would show a dialog to enter this information.
+let defaults = UserDefaults.standard
+var relayHost: String? = defaults.string(forKey: "relayHost")
+var relayPort: String? = defaults.string(forKey: "relayPort")
+
+if CommandLine.arguments.count >= 3 {
+ relayHost = CommandLine.arguments[1]
+ relayPort = CommandLine.arguments[2]
+}
+
+if relayHost == nil || relayPort == nil {
+ CFUserNotificationDisplayAlert(
+ 0, kCFUserNotificationStopAlertLevel, nil, nil, nil,
+ "\(projectName): Usage error" as CFString,
+ ("The relay address and port either need to be stored " +
+ "in your user defaults, or passed on the command line.") as CFString,
+ nil, nil, nil, nil)
+ exit(EXIT_FAILURE)
+}
+
+if !relayRPC.connect(host: relayHost!, port: relayPort!) {
+ CFUserNotificationDisplayAlert(
+ 0, kCFUserNotificationStopAlertLevel, nil, nil, nil,
+ "\(projectName): Usage error" as CFString,
+ "Invalid relay address." as CFString,
+ nil, nil, nil, nil)
+ exit(EXIT_FAILURE)
+}
+
+uiWindow.center()
+uiWindow.makeFirstResponder(uiInput)
+uiWindow.makeKeyAndOrderFront(nil)
+app.run()