diff options
-rw-r--r-- | CMakeLists.txt | 5 | ||||
-rw-r--r-- | LICENSE | 2 | ||||
-rw-r--r-- | NEWS | 27 | ||||
-rw-r--r-- | README.adoc | 25 | ||||
m--------- | liberty | 0 | ||||
-rwxr-xr-x | test | 52 | ||||
-rw-r--r-- | test.lua | 72 | ||||
-rw-r--r-- | xA/.gitignore | 5 | ||||
-rw-r--r-- | xA/Makefile | 36 | ||||
-rw-r--r-- | xA/go.mod | 46 | ||||
-rw-r--r-- | xA/go.sum | 84 | ||||
-rw-r--r-- | xA/xA-highlighted.svg | 23 | ||||
-rw-r--r-- | xA/xA.go | 1651 | ||||
-rw-r--r-- | xA/xA.svg | 23 | ||||
-rw-r--r-- | xC.c | 840 | ||||
-rw-r--r-- | xC.lxdr | 53 | ||||
-rw-r--r-- | xK-version | 2 | ||||
-rw-r--r-- | xM/gen-icon.swift | 3 | ||||
-rw-r--r-- | xM/main.swift | 10 | ||||
-rw-r--r-- | xN/xN.go | 10 | ||||
-rw-r--r-- | xP/go.mod | 9 | ||||
-rw-r--r-- | xP/go.sum | 64 | ||||
-rw-r--r-- | xP/public/xP.js | 46 | ||||
-rw-r--r-- | xP/xP.go | 9 | ||||
-rw-r--r-- | xR/.gitignore | 2 | ||||
-rw-r--r-- | xR/Makefile | 17 | ||||
-rw-r--r-- | xR/go.mod | 5 | ||||
-rw-r--r-- | xR/xR.adoc | 41 | ||||
-rw-r--r-- | xR/xR.go | 134 | ||||
-rw-r--r-- | xS/Dockerfile | 10 | ||||
-rw-r--r-- | xS/Makefile | 2 | ||||
-rw-r--r-- | xT/CMakeLists.txt | 179 | ||||
-rw-r--r-- | xT/config.h.in | 7 | ||||
-rw-r--r-- | xT/xT-highlighted.svg | 29 | ||||
-rw-r--r-- | xT/xT.cpp | 1734 | ||||
-rw-r--r-- | xT/xT.desktop | 8 | ||||
-rw-r--r-- | xT/xT.svg | 29 | ||||
-rw-r--r-- | xT/xTq.cpp | 40 | ||||
-rw-r--r-- | xT/xTq.h | 15 | ||||
-rw-r--r-- | xT/xTq.qml | 105 | ||||
-rw-r--r-- | xW/xW.cpp | 191 |
41 files changed, 4982 insertions, 663 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 9afcdf4..8492a00 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -212,6 +212,11 @@ if (BUILD_TESTING) add_test (NAME custom-static-analysis COMMAND ${PROJECT_SOURCE_DIR}/test-static) endif () +option (BUILD_TESTING_WDYE "Build the integration test" OFF) +if (BUILD_TESTING_WDYE) + add_subdirectory (liberty/tools/wdye) + add_test (NAME integration COMMAND wdye "${PROJECT_SOURCE_DIR}/test.lua") +endif () # Various clang-based diagnostics, loads of fake positives and spam file (GLOB clang_tidy_sources *.c) @@ -1,4 +1,4 @@ -Copyright (c) 2014 - 2024, Přemysl Eric Janouch <p@janouch.name> +Copyright (c) 2014 - 2025, 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. @@ -1,3 +1,30 @@ +Unreleased + + * xC: added more characters as nickname delimiters, + so that @nick works as a highlight + + * xC: prevented rare crashes in relay code + + * xP: added a network lag indicator to the user interface + + * Bumped relay protocol version + + +2.1.0 (2024-12-19) "Bunnyrific" + + * xC: fixed a crash when the channel topic had too many formatting items + + * xC: fixed keyboard EOF behaviour with Readline >= 8.0 + + * xC: made it possible to stream commands into the binary + + * xM/xW: various bugfixes + + * Added a Fyne frontend for xC called xA + + * Added a Qt Widgets frontend for xC called xT + + 2.0.0 (2024-07-28) "Perfect Is the Enemy of Good" * xD: now using SHA-256 for client certificate fingerprints diff --git a/README.adoc b/README.adoc index 3db0db4..1866b2a 100644 --- a/README.adoc +++ b/README.adoc @@ -2,9 +2,9 @@ xK == 'xK' (chat kit) is an IRC software suite consisting of a daemon, bot, notifier, -terminal 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. +terminal client, and web/Windows/macOS/Linux/FreeBSD/Android/iOS 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. @@ -33,9 +33,10 @@ including link:xC.adoc#_key_bindings[keyboard shortcuts]. image::xP.webp[align="center"] -xF --- -The X11 frontend for 'xC', still under development. +xA, xT, xF, xW, xM +------------------ +Fyne, Qt Widgets, X11, Win32, Cocoa frontends for 'xC'. +Using them is not recommended. xD -- @@ -147,6 +148,18 @@ For remote use, it's recommended to put 'xP' behind a reverse proxy, with TLS, and some form of HTTP authentication. Pass the external URL of the WebSocket endpoint as the third command line argument in this case. +xA +~~ +The Fyne frontend supports all of Linux, FreeBSD, Windows, macOS, Android, and +iOS natively, albeit somewhat poorly. Only use `fyne` or `fyne-cross` after +running `make generate` first. + +xT +~~ +The Qt Widgets frontend is a separate CMake subproject. It generally supports +all desktop operating systems. To avoid having to specify the relay address +each time you run it, pass it on the command line. + xW ~~ The Win32 frontend is a separate CMake subproject that should be compiled diff --git a/liberty b/liberty -Subproject 75fc6f1c374796f9e794297c3893089009b8772 +Subproject 31ae40085206dc365a15fd6e9d13978e392f8b3 @@ -1,52 +0,0 @@ -#!/usr/bin/expect -f -# Very basic end-to-end testing for CI -set tempdir [exec mktemp -d] -set ::env(XDG_CONFIG_HOME) $tempdir - -# Run the daemon to test against -system ./xD --write-default-cfg -spawn ./xD -d - -# 10 seconds is a bit too much -set timeout 5 - -spawn ./xC - -# Fuck this Tcl shit, I want the exit code -expect_after { - eof { - puts "" - puts "Child exited prematurely" - exit 1 - } -} - -# Connect to the daemon -send "/server add localhost\n" -expect "]" -send "/set servers.localhost.addresses = \"localhost\"\n" -expect "Option changed" -send "/disconnect\n" -expect "]" -send "/connect\n" -expect "Welcome to" - -# Try some chatting -send "/join #test\n" -expect "has joined" -send "Hello\n" -expect "Hello" - -# Attributes -send "\x1bmbBold text! \x1bmc0,5And colors.\n" -expect "]" - -# Try basic commands -send "/set\n" -expect "]" -send "/help\n" -expect "]" - -# Quit -send "/quit\n" -expect "Shutting down" diff --git a/test.lua b/test.lua new file mode 100644 index 0000000..2edeca8 --- /dev/null +++ b/test.lua @@ -0,0 +1,72 @@ +#!/usr/bin/env wdye +-- Very basic end-to-end testing for CI +function exec (...) + local p = wdye.spawn(...) + local out = wdye.expect(p:eof {function (p) return p[0] end}) + if not out then + error "exec() timeout" + end + + local status = p:wait() + if status ~= 0 then + io.write(out, "\n") + error("exit status " .. status) + end + return out:gsub("%s+$", "") +end + +local temp = exec {"mktemp", "-d"} +local atexit = {} +setmetatable(atexit, {__gc = function () exec {"rm", "-rf", "--", temp} end}) + +local env = {XDG_CONFIG_HOME=temp, TERM="xterm"} +exec {"./xD", "--write-default-cfg", environ=env} + +-- Run the daemon to test against (assuming the default port 6667) +local xD = wdye.spawn {"./xD", "-d", environ=env} +local xC = wdye.spawn {"./xC", environ=env} + +function send (...) xC:send(...) end +function expect (string) + wdye.expect(xC:exact {string}, + wdye.timeout {5, function (p) error "xC timeout" end}, + xC:eof {function (p) error "xC exited prematurely" end}) +end + +-- Connect to the daemon +send "/server add localhost\n" +expect "]" +send "/set servers.localhost.addresses = \"localhost\"\n" +expect "Option changed" +send "/disconnect\n" +expect "]" +send "/connect\n" +expect "Welcome to" + +-- Try some chatting +send "/join #test\n" +expect "has joined" +send "Hello\n" +expect "Hello" + +-- Attributes +send "\x1bmbBold text! \x1bmc0,5And colors.\n" +expect "]" + +-- Try basic commands +send "/set\n" +expect "]" +send "/help\n" +expect "]" + +-- Quit +send "/quit\n" +expect "Shutting down" + +local s1 = xC:wait() +assert(s1 == 0, "xC exited abnormally: " .. s1) + +-- Send SIGINT (^C) +xD:send "\003" +local s2 = xD:wait() +assert(s2 == 0, "xD exited abnormally: " .. s2) diff --git a/xA/.gitignore b/xA/.gitignore new file mode 100644 index 0000000..5e6a147 --- /dev/null +++ b/xA/.gitignore @@ -0,0 +1,5 @@ +/xA +/proto.go +/FyneApp.toml +/*.png +/beep.raw diff --git a/xA/Makefile b/xA/Makefile new file mode 100644 index 0000000..d0f0449 --- /dev/null +++ b/xA/Makefile @@ -0,0 +1,36 @@ +.POSIX: +.SUFFIXES: +.SUFFIXES: .png .svg +AWK = env LC_ALL=C awk + +tools = ../liberty/tools +generated = FyneApp.toml xA.png xA-highlighted.png beep.raw proto.go +outputs = xA $(generated) +all: $(outputs) +generate: $(generated) + +FyneApp.toml: ../xK-version + printf "\ + [Details]\n\ + Icon = 'xA.png'\n\ + Name = 'xA'\n\ + ID = 'name.janouch.xA'\n\ + Version = '$$(cat ../xK-version)'\n\ + Build = 1\n\ + \n\ + [LinuxAndBSD]\n\ + GenericName = 'IRC Client'\n\ + Categories = ['Network', 'Chat', 'IRCClient']\n" > $@ +.svg.png: + rsvg-convert --output=$@ -- $< +beep.raw: + sox -Dr 44100 -c 1 -e signed-integer -b 16 -L -n $@ \ + synth 0.1 0 25 triangle 800 vol 0.5 fade t 0 -0 0.005 pad 0 0.05 +proto.go: $(tools)/lxdrgen.awk $(tools)/lxdrgen-go.awk ../xC.lxdr + $(AWK) -f $(tools)/lxdrgen.awk -f $(tools)/lxdrgen-go.awk \ + -v PrefixCamel=Relay ../xC.lxdr > $@ +xA: xA.go ../xK-version $(generated) + go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \ + -gcflags=all="-N -l" +clean: + rm -f $(outputs) diff --git a/xA/go.mod b/xA/go.mod new file mode 100644 index 0000000..49c25cc --- /dev/null +++ b/xA/go.mod @@ -0,0 +1,46 @@ +module janouch.name/xK/xA + +go 1.23.0 + +toolchain go1.24.0 + +require ( + fyne.io/fyne/v2 v2.6.0 + github.com/ebitengine/oto/v3 v3.3.3 +) + +require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ebitengine/purego v0.8.2 // indirect + github.com/fredbi/uri v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fyne-io/gl-js v0.1.0 // indirect + github.com/fyne-io/glfw-js v0.2.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.1 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250421151639-a9d6ed1b3d45 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yuin/goldmark v1.7.10 // indirect + golang.org/x/image v0.26.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/xA/go.sum b/xA/go.sum new file mode 100644 index 0000000..3a6f7ce --- /dev/null +++ b/xA/go.sum @@ -0,0 +1,84 @@ +fyne.io/fyne/v2 v2.6.0 h1:Rywo9yKYN4qvNuvkRuLF+zxhJYWbIFM+m4N4KV4p1pQ= +fyne.io/fyne/v2 v2.6.0/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/oto/v3 v3.3.3 h1:m6RV69OqoXYSWCDsHXN9rc07aDuDstGHtait7HXSM7g= +github.com/ebitengine/oto/v3 v3.3.3/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM= +github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM= +github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 h1:RkGhqHxEVAvPM0/R+8g7XRwQnHatO0KAuVcwHo8q9W8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= +github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= +github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20250421151639-a9d6ed1b3d45 h1:vFdvrlsVU+p/KFBWTq0lTG4fvWvG88sawGlCzM+RUEU= +github.com/jeandeaual/go-locale v0.0.0-20250421151639-a9d6ed1b3d45/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= +github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.7.10 h1:S+LrtBjRmqMac2UdtB6yyCEJm+UILZ2fefI4p7o0QpI= +github.com/yuin/goldmark v1.7.10/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= +golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/xA/xA-highlighted.svg b/xA/xA-highlighted.svg new file mode 100644 index 0000000..c1345ee --- /dev/null +++ b/xA/xA-highlighted.svg @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg version="1.1" width="512" height="512" viewBox="0 0 512 512" + xmlns="http://www.w3.org/2000/svg"> + + <defs> + <linearGradient id="background" x1="0" y1="0" x2="0" y2="1"> + <stop stop-color="#ffaa00" offset="0" /> + <stop stop-color="#ffffff" offset="1" /> + </linearGradient> + <clipPath id="clip"> + <rect x="0" y="0" width="1" height="1" /> + </clipPath> + </defs> + + <rect x="0" y="0" width="512" height="512" fill="url(#background)" /> + + <g transform="translate(64, 64) scale(384)" stroke-linecap="square"> + <g clip-path="url(#clip)"> + <path stroke="#ff0000" stroke-width="0.125" + d="M 0.25,1.1 0.65,-0.1 M 0.75,1.1 0.35,-0.1 M 0.225,0.75 0.775,0.75" /> + </g> + </g> +</svg> diff --git a/xA/xA.go b/xA/xA.go new file mode 100644 index 0000000..b4f796c --- /dev/null +++ b/xA/xA.go @@ -0,0 +1,1651 @@ +// Copyright (c) 2024 - 2025, Přemysl Eric Janouch <p@janouch.name> +// SPDX-License-Identifier: 0BSD + +package main + +import ( + "bufio" + "bytes" + "context" + _ "embed" + "encoding/binary" + "errors" + "flag" + "fmt" + "image/color" + "io" + "log" + "net" + "net/url" + "os" + "regexp" + "slices" + "strings" + "sync" + "time" + + "github.com/ebitengine/oto/v3" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/driver/mobile" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +var ( + debug = flag.Bool("debug", false, "enable debug output") + projectName = "xA" + projectVersion = "?" + + //go:embed xA.png + iconNormal []byte + //go:embed xA-highlighted.png + iconHighlighted []byte + //go:embed beep.raw + beepSample []byte + + resourceIconNormal = fyne.NewStaticResource( + "xA.png", iconNormal) + resourceIconHighlighted = fyne.NewStaticResource( + "xA-highlighted.png", iconHighlighted) +) + +// --- Theme ------------------------------------------------------------------- + +type customTheme struct{} + +const ( + colorNameRenditionError fyne.ThemeColorName = "renditionError" + colorNameRenditionJoin fyne.ThemeColorName = "renditionJoin" + colorNameRenditionPart fyne.ThemeColorName = "renditionPart" + colorNameRenditionAction fyne.ThemeColorName = "renditionAction" + + colorNameBufferTimestamp fyne.ThemeColorName = "bufferTimestamp" + colorNameBufferLeaked fyne.ThemeColorName = "bufferLeaked" +) + +func convertColor(c int) color.Color { + base16 := []uint16{ + 0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc, + 0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff, + } + if c < 16 { + r := 0xf & uint8(base16[c]>>8) + g := 0xf & uint8(base16[c]>>4) + b := 0xf & uint8(base16[c]) + return color.RGBA{r * 0x11, g * 0x11, b * 0x11, 0xff} + } + if c >= 216 { + return color.Gray{8 + uint8(c-216)*10} + } + + var ( + i = uint8(c - 16) + r = i / 36 >> 0 + g = (i / 6 >> 0) % 6 + b = i % 6 + ) + if r != 0 { + r = 55 + 40*r + } + if g != 0 { + g = 55 + 40*g + } + if b != 0 { + b = 55 + 40*b + } + return color.RGBA{r, g, b, 0xff} +} + +var ircColors = make(map[fyne.ThemeColorName]color.Color) + +func ircColorName(color int) fyne.ThemeColorName { + return fyne.ThemeColorName(fmt.Sprintf("irc%02x", color)) +} + +func init() { + for color := 0; color < 256; color++ { + ircColors[ircColorName(color)] = convertColor(color) + } +} + +func (t *customTheme) Color( + name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color { + /* + // Fyne may use a dark background with the Light variant, + // which makes the UI unusable. + if runtime.GOOS == "android" { + variant = theme.VariantDark + } + */ + /* + // Fyne 2.6.0 has a different bug, the Light variant is not applied: + variant = theme.VariantLight + */ + + // Fuck this low contrast shit, text must be black. + if name == theme.ColorNameForeground && + variant == theme.VariantLight { + return color.Black + } + + switch name { + case colorNameRenditionError: + return color.RGBA{0xff, 0x00, 0x00, 0xff} + case colorNameRenditionJoin: + return color.RGBA{0x00, 0x88, 0x00, 0xff} + case colorNameRenditionPart: + return color.RGBA{0x88, 0x00, 0x00, 0xff} + case colorNameRenditionAction: + return color.RGBA{0x88, 0x00, 0x00, 0xff} + + case colorNameBufferTimestamp, colorNameBufferLeaked: + return color.RGBA{0x88, 0x88, 0x88, 0xff} + } + + if c, ok := ircColors[name]; ok { + return c + } + return theme.DefaultTheme().Color(name, variant) +} + +func (t *customTheme) Font(style fyne.TextStyle) fyne.Resource { + return theme.DefaultTheme().Font(style) +} + +func (t *customTheme) Icon(i fyne.ThemeIconName) fyne.Resource { + return theme.DefaultTheme().Icon(i) +} + +func (t *customTheme) Size(s fyne.ThemeSizeName) float32 { + switch s { + case theme.SizeNameInnerPadding: + return 2 + default: + return theme.DefaultTheme().Size(s) + } +} + +// --- Relay state ------------------------------------------------------------- + +type server struct { + state RelayServerState + user string + userModes string +} + +type bufferLineItem struct { + format fyne.TextStyle + // For RichTextStyle.ColorName. + color fyne.ThemeColorName + // XXX: Fyne's RichText doesn't support background colours. + background fyne.ThemeColorName + text string + link *url.URL +} + +type bufferLine struct { + /// Leaked from another buffer, but temporarily staying in another one. + leaked bool + + isUnimportant bool + isHighlight bool + rendition RelayRendition + when time.Time + items []bufferLineItem +} + +type buffer struct { + bufferName string + hideUnimportant bool + kind RelayBufferKind + serverName string + lines []bufferLine + + // Channel: + + topic []bufferLineItem + modes string + + // Stats: + + newMessages int + newUnimportantMessages int + highlighted bool + + // Input: + + input string + inputRow, inputColumn int + history []string + historyAt int +} + +type callback func(err string, response *RelayResponseData) + +const ( + preferenceAddress = "address" +) + +var ( + backendAddress string + backendContext context.Context + backendCancel context.CancelFunc + backendConn net.Conn + + backendLock sync.Mutex + + // Connection state: + + commandSeq uint32 + commandCallbacks = make(map[uint32]callback) + + buffers []buffer + bufferCurrent string + bufferLast string + + servers = make(map[string]*server) + + // Sound: + + otoContext *oto.Context + otoReady chan struct{} + + // Widgets: + + inForeground = true + + wConnect *dialog.FormDialog + + wWindow fyne.Window + wTopic *widget.RichText + wBufferList *widget.List + wRichText *widget.RichText + wRichScroll *container.Scroll + wLog *logEntry + wPrompt *widget.Label + wDown *widget.Icon + wStatus *widget.Label + wEntry *inputEntry +) + +// ----------------------------------------------------------------------------- + +func showErrorMessage(text string) { + dialog.ShowError(errors.New(text), wWindow) +} + +func beep() { + if otoContext == nil { + return + } + go func() { + <-otoReady + p := otoContext.NewPlayer(bytes.NewReader(beepSample)) + p.Play() + for p.IsPlaying() { + time.Sleep(time.Second) + } + }() +} + +// --- Networking -------------------------------------------------------------- + +func relayReadMessage(r io.Reader) (m RelayEventMessage, ok bool) { + var length uint32 + if err := binary.Read(r, binary.BigEndian, &length); err != nil { + log.Println("Event receive failed: " + err.Error()) + return + } + b := make([]byte, length) + if _, err := io.ReadFull(r, b); err != nil { + log.Println("Event receive failed: " + err.Error()) + return + } + + if after, ok2 := m.ConsumeFrom(b); !ok2 { + log.Println("Event deserialization failed") + return + } else if len(after) != 0 { + log.Println("Event deserialization failed: trailing data") + return + } + + if *debug { + log.Printf("<? %v\n", b) + + j, err := m.MarshalJSON() + if err != nil { + log.Println("Event marshalling failed: " + err.Error()) + return + } + + log.Printf("<- %s\n", j) + } + return m, true +} + +func relaySend(data RelayCommandData, callback callback) bool { + backendLock.Lock() + defer backendLock.Unlock() + + m := RelayCommandMessage{ + CommandSeq: commandSeq, + Data: data, + } + if callback == nil { + callback = func(err string, response *RelayResponseData) { + if response == nil { + showErrorMessage(err) + } + } + } + commandCallbacks[m.CommandSeq] = callback + commandSeq++ + + // TODO(p): Handle errors better. + b, ok := m.AppendTo(make([]byte, 4)) + if !ok { + log.Println("Command serialization failed") + return false + } + binary.BigEndian.PutUint32(b[:4], uint32(len(b)-4)) + if _, err := backendConn.Write(b); err != nil { + log.Println("Command send failed: " + err.Error()) + return false + } + + if *debug { + log.Printf("-> %v\n", b) + } + return true +} + +// --- Buffers ----------------------------------------------------------------- + +func bufferByName(name string) *buffer { + for i := range buffers { + if buffers[i].bufferName == name { + return &buffers[i] + } + } + return nil +} + +func bufferAtBottom() bool { + return wRichScroll.Offset.Y >= + wRichScroll.Content.Size().Height-wRichScroll.Size().Height +} + +func bufferScrollToBottom() { + // XXX: Doing it once is not reliable, something's amiss. + // (In particular, nothing happens when we switch from an empty buffer + // to a buffer than needs scrolling.) + wRichScroll.ScrollToBottom() + wRichScroll.ScrollToBottom() + refreshStatus() +} + +func bufferPushLine(b *buffer, line bufferLine) { + b.lines = append(b.lines, line) + + // Fyne's text layouting is extremely slow. + // The limit could be made configurable, + // and we could use a ring buffer approach to storing the lines. + if len(b.lines) > 100 { + b.lines = slices.Delete(b.lines, 0, 1) + } +} + +// --- UI state refresh -------------------------------------------------------- + +func refreshIcon() { + resource := resourceIconNormal + for _, b := range buffers { + if b.highlighted { + resource = resourceIconHighlighted + break + } + } + wWindow.SetIcon(resource) +} + +func refreshTopic(topic []bufferLineItem) { + wTopic.Segments = nil + for _, item := range topic { + if item.link != nil { + wTopic.Segments = append(wTopic.Segments, + &widget.HyperlinkSegment{Text: item.text, URL: item.link}) + continue + } + wTopic.Segments = append(wTopic.Segments, &widget.TextSegment{ + Text: item.text, + Style: widget.RichTextStyle{ + Alignment: fyne.TextAlignLeading, + ColorName: item.color, + Inline: true, + SizeName: theme.SizeNameText, + TextStyle: item.format, + }, + }) + } + wTopic.Refresh() +} + +func refreshBufferList() { + // This seems to be enough, even for removals. + for i := range buffers { + wBufferList.RefreshItem(widget.ListItemID(i)) + } +} + +func refreshPrompt() { + var prompt string + if b := bufferByName(bufferCurrent); b == nil { + prompt = "Synchronizing..." + } else if server, ok := servers[b.serverName]; ok { + prompt = server.user + if server.userModes != "" { + prompt += "(" + server.userModes + ")" + } + if prompt == "" { + prompt = "(" + server.state.String() + ")" + } + } + wPrompt.SetText(prompt) +} + +func refreshStatus() { + if bufferAtBottom() { + wDown.Hide() + } else { + wDown.Show() + } + + status := bufferCurrent + if b := bufferByName(bufferCurrent); b != nil { + if b.modes != "" { + status += "(+" + b.modes + ")" + } + if b.hideUnimportant { + status += "<H>" + } + } + + wStatus.SetText(status) +} + +func recheckHighlighted() { + // Corresponds to the logic toggling the bool on. + if b := bufferByName(bufferCurrent); b != nil && + b.highlighted && bufferAtBottom() && + inForeground && !wLog.Visible() { + b.highlighted = false + refreshIcon() + refreshBufferList() + } +} + +// --- Buffer actions ---------------------------------------------------------- + +func bufferActivate(name string) { + relaySend(RelayCommandData{ + Variant: &RelayCommandDataBufferActivate{BufferName: name}, + }, nil) +} + +func bufferToggleUnimportant(name string) { + relaySend(RelayCommandData{ + Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name}, + }, nil) +} + +func bufferToggleLogFinish(err string, response *RelayResponseDataBufferLog) { + if response == nil { + showErrorMessage(err) + return + } + + wLog.SetText(string(response.Log)) + wLog.Show() + wRichScroll.Hide() +} + +func bufferToggleLog() { + if wLog.Visible() { + wRichScroll.Show() + wLog.Hide() + wLog.SetText("") + + recheckHighlighted() + return + } + + name := bufferCurrent + relaySend(RelayCommandData{Variant: &RelayCommandDataBufferLog{ + BufferName: name, + }}, func(err string, response *RelayResponseData) { + if bufferCurrent == name { + bufferToggleLogFinish( + err, response.Variant.(*RelayResponseDataBufferLog)) + } + }) +} + +// --- RichText formatting ----------------------------------------------------- + +func defaultBufferLineItem() bufferLineItem { return bufferLineItem{} } + +func convertItemFormatting( + item RelayItemData, cf *bufferLineItem, inverse *bool) { + switch data := item.Variant.(type) { + case *RelayItemDataReset: + *cf = defaultBufferLineItem() + case *RelayItemDataFlipBold: + cf.format.Bold = !cf.format.Bold + case *RelayItemDataFlipItalic: + cf.format.Italic = !cf.format.Italic + case *RelayItemDataFlipUnderline: + cf.format.Underline = !cf.format.Underline + case *RelayItemDataFlipCrossedOut: + // https://github.com/fyne-io/fyne/issues/1084 + case *RelayItemDataFlipInverse: + *inverse = !*inverse + case *RelayItemDataFlipMonospace: + cf.format.Monospace = !cf.format.Monospace + case *RelayItemDataFgColor: + if data.Color < 0 { + cf.color = "" + } else { + cf.color = ircColorName(int(data.Color)) + } + case *RelayItemDataBgColor: + if data.Color < 0 { + cf.background = "" + } else { + cf.background = ircColorName(int(data.Color)) + } + } +} + +var linkRE = regexp.MustCompile(`https?://` + + `(?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+` + + `(?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))`) + +func convertLinks( + item bufferLineItem, items []bufferLineItem) []bufferLineItem { + end, matches := 0, linkRE.FindAllStringIndex(item.text, -1) + for _, m := range matches { + url, _ := url.Parse(item.text[m[0]:m[1]]) + if url == nil { + continue + } + if end < m[0] { + subitem := item + subitem.text = item.text[end:m[0]] + items = append(items, subitem) + } + + subitem := item + subitem.text = item.text[m[0]:m[1]] + subitem.link = url + items = append(items, subitem) + + end = m[1] + } + if end < len(item.text) { + subitem := item + subitem.text = item.text[end:] + items = append(items, subitem) + } + return items +} + +func convertItems(items []RelayItemData) []bufferLineItem { + result := []bufferLineItem{} + cf, inverse := defaultBufferLineItem(), false + for _, it := range items { + text, ok := it.Variant.(*RelayItemDataText) + if !ok { + convertItemFormatting(it, &cf, &inverse) + continue + } + + item := cf + item.text = text.Text + if inverse { + item.color, item.background = item.background, item.color + } + result = convertLinks(item, result) + } + return result +} + +// --- Buffer output ----------------------------------------------------------- + +func convertBufferLine(m *RelayEventDataBufferLine) bufferLine { + return bufferLine{ + items: convertItems(m.Items), + isUnimportant: m.IsUnimportant, + isHighlight: m.IsHighlight, + rendition: m.Rendition, + when: time.UnixMilli(int64(m.When)), + } +} + +func bufferPrintDateChange(last, current time.Time) { + last, current = last.Local(), current.Local() + if last.Year() == current.Year() && + last.Month() == current.Month() && + last.Day() == current.Day() { + return + } + + wRichText.Segments = append(wRichText.Segments, &widget.TextSegment{ + Style: widget.RichTextStyle{ + Alignment: fyne.TextAlignLeading, + ColorName: "", + Inline: false, + SizeName: theme.SizeNameText, + TextStyle: fyne.TextStyle{Bold: true}, + }, + Text: current.Format(time.DateOnly), + }) +} + +func bufferPrintAndWatchTrailingDateChanges() { + current := time.Now() + b := bufferByName(bufferCurrent) + if b != nil && len(b.lines) != 0 { + last := b.lines[len(b.lines)-1].when + bufferPrintDateChange(last, current) + } + + // TODO(p): The watching part. +} + +func bufferPrintLine(lines []bufferLine, index int) { + line := &lines[index] + + last, current := time.Time{}, line.when + if index == 0 { + last = time.Now() + } else { + last = lines[index-1].when + } + + bufferPrintDateChange(last, current) + + texts := []widget.RichTextSegment{&widget.TextSegment{ + Text: line.when.Format("15:04:05 "), + Style: widget.RichTextStyle{ + Alignment: fyne.TextAlignLeading, + ColorName: colorNameBufferTimestamp, + Inline: true, + SizeName: theme.SizeNameText, + TextStyle: fyne.TextStyle{}, + }}} + + // Tabstops won't quite help us here, since we need it centred. + prefix := "" + pcf := widget.RichTextStyle{ + Alignment: fyne.TextAlignLeading, + Inline: true, + SizeName: theme.SizeNameText, + TextStyle: fyne.TextStyle{Monospace: true}, + } + switch line.rendition { + case RelayRenditionBare: + case RelayRenditionIndent: + prefix = " " + case RelayRenditionStatus: + prefix = " - " + case RelayRenditionError: + prefix = "=!= " + pcf.ColorName = colorNameRenditionError + case RelayRenditionJoin: + prefix = "--> " + pcf.ColorName = colorNameRenditionJoin + case RelayRenditionPart: + prefix = "<-- " + pcf.ColorName = colorNameRenditionPart + case RelayRenditionAction: + prefix = " * " + pcf.ColorName = colorNameRenditionAction + } + + if prefix != "" { + style := pcf + if line.leaked { + style.ColorName = colorNameBufferLeaked + } + texts = append(texts, &widget.TextSegment{ + Text: prefix, + Style: style, + }) + } + for _, item := range line.items { + if item.link != nil { + texts = append(texts, + &widget.HyperlinkSegment{Text: item.text, URL: item.link}) + continue + } + style := widget.RichTextStyle{ + Alignment: fyne.TextAlignLeading, + ColorName: item.color, + Inline: true, + SizeName: theme.SizeNameText, + TextStyle: item.format, + } + if line.leaked { + style.ColorName = colorNameBufferLeaked + } + texts = append(texts, &widget.TextSegment{ + Text: item.text, + Style: style, + }) + } + + wRichText.Segments = append(wRichText.Segments, + &widget.ParagraphSegment{Texts: texts}, + &widget.TextSegment{Style: widget.RichTextStyleParagraph}) +} + +func bufferPrintSeparator() { + // TODO(p): Implement our own, so that it can use the primary colour. + wRichText.Segments = append(wRichText.Segments, + &widget.SeparatorSegment{}) +} + +func refreshBuffer(b *buffer) { + wRichText.Segments = nil + + markBefore := len(b.lines) - b.newMessages - b.newUnimportantMessages + for i, line := range b.lines { + if i == markBefore { + bufferPrintSeparator() + } + if !line.isUnimportant || !b.hideUnimportant { + bufferPrintLine(b.lines, i) + } + } + + bufferPrintAndWatchTrailingDateChanges() + wRichText.Refresh() + bufferScrollToBottom() + recheckHighlighted() +} + +// --- Event processing -------------------------------------------------------- + +func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) { + line := convertBufferLine(m) + + // Initial sync: skip all other processing, let highlights be. + bc := bufferByName(bufferCurrent) + if bc == nil { + bufferPushLine(b, line) + return + } + + // Retained mode is complicated. + display := (!m.IsUnimportant || !bc.hideUnimportant) && + (b.bufferName == bufferCurrent || m.LeakToActive) + toBottom := display && bufferAtBottom() + visible := display && toBottom && inForeground && !wLog.Visible() + separate := display && + !visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0 + + bufferPushLine(b, line) + if !(visible || m.LeakToActive) || + b.newMessages != 0 || b.newUnimportantMessages != 0 { + if line.isUnimportant || m.LeakToActive { + b.newUnimportantMessages++ + } else { + b.newMessages++ + } + } + + if m.LeakToActive { + leakedLine := line + leakedLine.leaked = true + bufferPushLine(bc, leakedLine) + + if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 { + if line.isUnimportant { + bc.newUnimportantMessages++ + } else { + bc.newMessages++ + } + } + } + + if separate { + bufferPrintSeparator() + } + if display { + bufferPrintLine(bc.lines, len(bc.lines)-1) + wRichText.Refresh() + } + if toBottom { + bufferScrollToBottom() + } + + // TODO(p): On mobile, we should probably send notifications. + // Though we probably can't run in the background. + if line.isHighlight || (!visible && !line.isUnimportant && + b.kind == RelayBufferKindPrivateMessage) { + beep() + + if !visible { + b.highlighted = true + refreshIcon() + } + } + + refreshBufferList() +} + +func relayProcessCallbacks( + commandSeq uint32, err string, response *RelayResponseData) { + if handler, ok := commandCallbacks[commandSeq]; !ok { + if *debug { + log.Printf("Unawaited response: %+v\n", *response) + } + } else { + delete(commandCallbacks, commandSeq) + if handler != nil { + handler(err, response) + } + } + + // We don't particularly care about wraparound issues. + for cs, handler := range commandCallbacks { + if cs <= commandSeq { + delete(commandCallbacks, cs) + if handler != nil { + handler("No response", nil) + } + } + } +} + +func relayProcessMessage(m *RelayEventMessage) { + switch data := m.Data.Variant.(type) { + case *RelayEventDataError: + relayProcessCallbacks(data.CommandSeq, data.Error, nil) + case *RelayEventDataResponse: + relayProcessCallbacks(data.CommandSeq, "", &data.Data) + + case *RelayEventDataPing: + relaySend(RelayCommandData{ + Variant: &RelayCommandDataPingResponse{EventSeq: m.EventSeq}, + }, nil) + + case *RelayEventDataBufferLine: + b := bufferByName(data.BufferName) + if b == nil { + return + } + + relayProcessBufferLine(b, data) + case *RelayEventDataBufferUpdate: + b := bufferByName(data.BufferName) + if b == nil { + buffers = append(buffers, buffer{bufferName: data.BufferName}) + b = &buffers[len(buffers)-1] + refreshBufferList() + } + + hidingToggled := b.hideUnimportant != data.HideUnimportant + b.hideUnimportant = data.HideUnimportant + b.kind = data.Context.Variant.Kind() + b.serverName = "" + switch context := data.Context.Variant.(type) { + case *RelayBufferContextServer: + b.serverName = context.ServerName + case *RelayBufferContextChannel: + b.serverName = context.ServerName + b.modes = context.Modes + b.topic = convertItems(context.Topic) + case *RelayBufferContextPrivateMessage: + b.serverName = context.ServerName + } + + if b.bufferName == bufferCurrent { + refreshTopic(b.topic) + refreshStatus() + + if hidingToggled { + refreshBuffer(b) + } + } + case *RelayEventDataBufferStats: + b := bufferByName(data.BufferName) + if b == nil { + return + } + + b.newMessages = int(data.NewMessages) + b.newUnimportantMessages = int(data.NewUnimportantMessages) + b.highlighted = data.Highlighted + + refreshIcon() + case *RelayEventDataBufferRename: + b := bufferByName(data.BufferName) + if b == nil { + return + } + + b.bufferName = data.New + + if data.BufferName == bufferCurrent { + bufferCurrent = data.New + refreshStatus() + } + refreshBufferList() + if data.BufferName == bufferLast { + bufferLast = data.New + } + case *RelayEventDataBufferRemove: + buffers = slices.DeleteFunc(buffers, func(b buffer) bool { + return b.bufferName == data.BufferName + }) + + refreshBufferList() + refreshIcon() + case *RelayEventDataBufferActivate: + old := bufferByName(bufferCurrent) + bufferLast = bufferCurrent + bufferCurrent = data.BufferName + b := bufferByName(data.BufferName) + if b == nil { + return + } + + if old != nil { + old.newMessages = 0 + old.newUnimportantMessages = 0 + old.highlighted = false + + old.input = wEntry.Text + old.inputRow = wEntry.CursorRow + old.inputColumn = wEntry.CursorColumn + + // Note that we effectively overwrite the newest line + // with the current textarea contents, and jump there. + old.historyAt = len(old.history) + } + + if wLog.Visible() { + bufferToggleLog() + } + if inForeground { + b.highlighted = false + } + + for i := range buffers { + if buffers[i].bufferName == bufferCurrent { + wBufferList.Select(widget.ListItemID(i)) + break + } + } + + refreshIcon() + refreshTopic(b.topic) + refreshBufferList() + refreshBuffer(b) + refreshPrompt() + refreshStatus() + + wEntry.SetText(b.input) + wEntry.CursorRow = b.inputRow + wEntry.CursorColumn = b.inputColumn + wEntry.Refresh() + wWindow.Canvas().Focus(wEntry) + case *RelayEventDataBufferInput: + b := bufferByName(data.BufferName) + if b == nil { + return + } + + if b.historyAt == len(b.history) { + b.historyAt++ + } + b.history = append(b.history, data.Text) + case *RelayEventDataBufferClear: + b := bufferByName(data.BufferName) + if b == nil { + return + } + + b.lines = nil + if b.bufferName == bufferCurrent { + refreshBuffer(b) + } + + case *RelayEventDataServerUpdate: + s, existed := servers[data.ServerName] + if !existed { + s = &server{} + servers[data.ServerName] = s + } + + s.state = data.Data.Variant.State() + switch state := data.Data.Variant.(type) { + case *RelayServerDataRegistered: + s.user = state.User + s.userModes = state.UserModes + default: + s.user = "" + s.userModes = "" + } + + refreshPrompt() + case *RelayEventDataServerRename: + servers[data.New] = servers[data.ServerName] + delete(servers, data.ServerName) + case *RelayEventDataServerRemove: + delete(servers, data.ServerName) + } +} + +// --- Networking -------------------------------------------------------------- + +func relayMakeReceiver( + ctx context.Context, conn net.Conn) <-chan RelayEventMessage { + // The usual event message rarely gets above 1 kilobyte, + // thus this is set to buffer up at most 1 megabyte or so. + p := make(chan RelayEventMessage, 1000) + r := bufio.NewReaderSize(conn, 65536) + go func() { + defer close(p) + for { + m, ok := relayReadMessage(r) + if !ok { + return + } + select { + case p <- m: + case <-ctx.Done(): + return + } + } + }() + return p +} + +func relayResetState() { + commandSeq = 0 + commandCallbacks = make(map[uint32]callback) + + buffers = nil + bufferCurrent = "" + bufferLast = "" + servers = make(map[string]*server) + + refreshIcon() + refreshTopic(nil) + refreshBufferList() + wRichText.ParseMarkdown("") + refreshPrompt() + refreshStatus() +} + +func relayRun() { + fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress) + backendLock.Lock() + + fyne.DoAndWait(func() { + relayResetState() + }) + + backendContext, backendCancel = context.WithCancel(context.Background()) + defer backendCancel() + var err error + backendConn, err = net.Dial("tcp", backendAddress) + + backendLock.Unlock() + if err != nil { + fyne.DoAndWait(func() { + wConnect.Show() + showErrorMessage("Connection failed: " + err.Error()) + }) + return + } + defer backendConn.Close() + + // TODO(p): Figure out locking. + // - Messages are currently sent (semi-)synchronously, directly. + // - Is the net.Conn actually async-safe? + relaySend(RelayCommandData{ + Variant: &RelayCommandDataHello{Version: RelayVersion}, + }, nil) + + relayMessages := relayMakeReceiver(backendContext, backendConn) +Loop: + for { + select { + case m, ok := <-relayMessages: + if !ok { + break Loop + } + fyne.DoAndWait(func() { + relayProcessMessage(&m) + }) + } + } + fyne.DoAndWait(func() { + wConnect.Show() + showErrorMessage("Disconnected") + }) +} + +// --- Input line -------------------------------------------------------------- + +func inputSetContents(input string) { + wEntry.SetText(input) +} + +func inputSubmit(text string) bool { + b := bufferByName(bufferCurrent) + if b == nil { + return false + } + + b.history = append(b.history, text) + b.historyAt = len(b.history) + inputSetContents("") + + relaySend(RelayCommandData{Variant: &RelayCommandDataBufferInput{ + BufferName: b.bufferName, + Text: text, + }}, nil) + return true +} + +type inputStamp struct { + cursorRow, cursorColumn int + input string +} + +func inputGetStamp() inputStamp { + return inputStamp{ + cursorRow: wEntry.CursorRow, + cursorColumn: wEntry.CursorColumn, + input: wEntry.Text, + } +} + +func inputCompleteFinish(state inputStamp, + err string, response *RelayResponseDataBufferComplete) { + if response == nil { + showErrorMessage(err) + return + } + + if len(response.Completions) > 0 { + insert := response.Completions[0] + if len(response.Completions) == 1 { + insert += " " + } + inputSetContents(state.input[:response.Start] + insert) + + } + if len(response.Completions) != 1 { + beep() + } + + // TODO(p): Show all completion options. +} + +func inputComplete() bool { + if wEntry.SelectedText() != "" { + return false + } + + // XXX: Fyne's Entry widget makes it impossible to handle this properly. + state := inputGetStamp() + relaySend(RelayCommandData{Variant: &RelayCommandDataBufferComplete{ + BufferName: bufferCurrent, + Text: state.input, + Position: uint32(len(state.input)), + }}, func(err string, response *RelayResponseData) { + if stamp := inputGetStamp(); state == stamp { + inputCompleteFinish(state, + err, response.Variant.(*RelayResponseDataBufferComplete)) + } + }) + return true +} + +func inputUp() bool { + b := bufferByName(bufferCurrent) + if b == nil || b.historyAt < 1 { + return false + } + + if b.historyAt == len(b.history) { + b.input = wEntry.Text + } + b.historyAt-- + inputSetContents(b.history[b.historyAt]) + return true +} + +func inputDown() bool { + b := bufferByName(bufferCurrent) + if b == nil || b.historyAt >= len(b.history) { + return false + } + + b.historyAt++ + if b.historyAt == len(b.history) { + inputSetContents(b.input) + } else { + inputSetContents(b.history[b.historyAt]) + } + return true +} + +// --- General UI -------------------------------------------------------------- + +type inputEntry struct { + widget.Entry + + // selectKeyDown is a hack to exactly invert widget.Entry's behaviour, + // which groups both Shift keys together. + selectKeyDown bool +} + +func newInputEntry() *inputEntry { + e := &inputEntry{} + e.MultiLine = true + e.Wrapping = fyne.TextWrap(fyne.TextTruncateClip) + e.ExtendBaseWidget(e) + return e +} + +func (e *inputEntry) FocusLost() { + e.selectKeyDown = false + e.Entry.FocusLost() +} + +func (e *inputEntry) KeyDown(key *fyne.KeyEvent) { + // TODO(p): And perhaps on other actions, too. + relaySend(RelayCommandData{Variant: &RelayCommandDataActive{}}, nil) + + // Modified events are eaten somewhere, not reaching TypedKey or Shortcuts. + if dd, ok := fyne.CurrentApp().Driver().(desktop.Driver); ok { + modifiedKey := desktop.CustomShortcut{ + KeyName: key.Name, Modifier: dd.CurrentKeyModifiers()} + if handler := shortcuts[modifiedKey]; handler != nil { + handler() + return + } + + switch { + case modifiedKey.Modifier == fyne.KeyModifierControl && + modifiedKey.KeyName == fyne.KeyP: + inputUp() + return + case modifiedKey.Modifier == fyne.KeyModifierControl && + modifiedKey.KeyName == fyne.KeyN: + inputDown() + return + } + } + + if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight { + e.selectKeyDown = true + } + e.Entry.KeyDown(key) +} + +func (e *inputEntry) KeyUp(key *fyne.KeyEvent) { + if key.Name == desktop.KeyShiftLeft || key.Name == desktop.KeyShiftRight { + e.selectKeyDown = false + } + e.Entry.KeyUp(key) +} + +func (e *inputEntry) TypedKey(key *fyne.KeyEvent) { + if e.Disabled() { + return + } + + // Invert the Shift key behaviour here. + // Notice that this will never work on mobile. + shift := &fyne.KeyEvent{Name: desktop.KeyShiftLeft} + switch key.Name { + case fyne.KeyReturn, fyne.KeyEnter: + if e.selectKeyDown { + e.Entry.KeyUp(shift) + e.Entry.TypedKey(key) + e.Entry.KeyDown(shift) + } else if e.OnSubmitted != nil { + e.OnSubmitted(e.Text) + } + case fyne.KeyTab: + if e.selectKeyDown { + // This could also go through completion lists. + wWindow.Canvas().FocusPrevious() + } else { + inputComplete() + } + default: + e.Entry.TypedKey(key) + } +} + +func (e *inputEntry) SetText(text string) { + e.Entry.SetText(text) + if text != "" { + e.Entry.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown}) + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +type logEntry struct { + // XXX: Sadly, we can't seem to make it actually read-only. + // https://github.com/fyne-io/fyne/issues/5263 + widget.Entry +} + +func newLogEntry() *logEntry { + e := &logEntry{} + e.MultiLine = true + e.Wrapping = fyne.TextWrapWord + e.ExtendBaseWidget(e) + return e +} + +func (e *logEntry) SetText(text string) { + e.OnChanged = nil + e.Entry.SetText(text) + e.OnChanged = func(string) { e.Entry.SetText(text) } +} + +func (e *logEntry) AcceptsTab() bool { + return false +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +type customLayout struct{} + +func (l *customLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + var w, h float32 = 0, 0 + for _, o := range objects { + size := o.MinSize() + if w < size.Width { + w = size.Width + } + if h < size.Height { + h = size.Height + } + } + return fyne.NewSize(w, h) +} + +func (l *customLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + // It is not otherwise possible to be notified of resizes. + // Embedding container.Scroll either directly or as a pointer + // to override its Resize method results in brokenness. + toBottom := bufferAtBottom() + for _, o := range objects { + o.Move(fyne.NewPos(0, 0)) + o.Resize(size) + } + if toBottom { + bufferScrollToBottom() + } else { + recheckHighlighted() + refreshStatus() + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// rotatedBuffers returns buffer indexes starting with the current buffer. +func rotatedBuffers() []int { + r, start := make([]int, len(buffers)), 0 + for i := range buffers { + if buffers[i].bufferName == bufferCurrent { + start = i + break + } + } + for i := range r { + start++ + r[i] = start % len(r) + } + return r +} + +var shortcuts = map[desktop.CustomShortcut]func(){ + { + KeyName: fyne.KeyPageUp, + Modifier: fyne.KeyModifierControl, + }: func() { + if r := rotatedBuffers(); len(r) <= 0 { + } else if i := r[len(r)-1]; i == 0 { + bufferActivate(buffers[len(buffers)-1].bufferName) + } else { + bufferActivate(buffers[i-1].bufferName) + } + }, + { + KeyName: fyne.KeyPageDown, + Modifier: fyne.KeyModifierControl, + }: func() { + if r := rotatedBuffers(); len(r) <= 0 { + } else { + bufferActivate(buffers[r[0]].bufferName) + } + }, + { + KeyName: fyne.KeyTab, + Modifier: fyne.KeyModifierAlt, + }: func() { + if bufferLast != "" { + bufferActivate(bufferLast) + } + }, + { + // XXX: This makes an assumption on the keyboard layout (we want '!'). + KeyName: fyne.Key1, + Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift, + }: func() { + for _, i := range rotatedBuffers() { + if buffers[i].highlighted { + bufferActivate(buffers[i].bufferName) + break + } + } + }, + { + KeyName: fyne.KeyA, + Modifier: fyne.KeyModifierAlt, + }: func() { + for _, i := range rotatedBuffers() { + if buffers[i].newMessages != 0 { + bufferActivate(buffers[i].bufferName) + break + } + } + }, + { + KeyName: fyne.KeyH, + Modifier: fyne.KeyModifierAlt | fyne.KeyModifierShift, + }: func() { + if b := bufferByName(bufferCurrent); b != nil { + bufferToggleUnimportant(b.bufferName) + } + }, + { + KeyName: fyne.KeyH, + Modifier: fyne.KeyModifierAlt, + }: func() { + if b := bufferByName(bufferCurrent); b != nil { + bufferToggleLog() + } + }, +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), + "Usage: %s [OPTION...] [CONNECT]\n\n", os.Args[0]) + flag.PrintDefaults() + } + + flag.Parse() + if flag.NArg() > 1 { + flag.Usage() + os.Exit(1) + } + + var err error + otoContext, otoReady, err = oto.NewContext(&oto.NewContextOptions{ + SampleRate: 44100, + ChannelCount: 1, + Format: oto.FormatSignedInt16LE, + }) + if err != nil { + log.Println(err) + } + + a := app.New() + a.Settings().SetTheme(&customTheme{}) + a.SetIcon(resourceIconNormal) + wWindow = a.NewWindow(projectName) + wWindow.Resize(fyne.NewSize(640, 480)) + + a.Lifecycle().SetOnEnteredForeground(func() { + // TODO(p): Does this need locking? + inForeground = true + recheckHighlighted() + }) + a.Lifecycle().SetOnExitedForeground(func() { + inForeground = false + }) + + // TODO(p): Consider using data bindings. + wBufferList = widget.NewList(func() int { return len(buffers) }, + func() fyne.CanvasObject { + return widget.NewLabel(strings.Repeat(" ", 16)) + }, + func(id widget.ListItemID, item fyne.CanvasObject) { + label, b := item.(*widget.Label), &buffers[int(id)] + label.TextStyle.Italic = b.bufferName == bufferCurrent + label.TextStyle.Bold = false + text := b.bufferName + if b.bufferName != bufferCurrent && b.newMessages != 0 { + label.TextStyle.Bold = true + text += fmt.Sprintf(" (%d)", b.newMessages) + } + label.Importance = widget.MediumImportance + if b.highlighted { + label.Importance = widget.HighImportance + } + label.SetText(text) + }) + wBufferList.HideSeparators = true + wBufferList.OnSelected = func(id widget.ListItemID) { + // TODO(p): See if we can deselect it now without consequences. + request := buffers[int(id)].bufferName + if request != bufferCurrent { + bufferActivate(request) + } + } + + wTopic = widget.NewRichText() + wTopic.Truncation = fyne.TextTruncateEllipsis + + wRichText = widget.NewRichText() + wRichText.Wrapping = fyne.TextWrapWord + wRichScroll = container.NewVScroll(wRichText) + wRichScroll.OnScrolled = func(position fyne.Position) { + recheckHighlighted() + refreshStatus() + } + wLog = newLogEntry() + wLog.Wrapping = fyne.TextWrapWord + wLog.Hide() + + wPrompt = widget.NewLabelWithStyle( + "", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + wDown = widget.NewIcon(theme.MoveDownIcon()) + wStatus = widget.NewLabelWithStyle( + "", fyne.TextAlignTrailing, fyne.TextStyle{}) + wEntry = newInputEntry() + wEntry.OnSubmitted = func(text string) { inputSubmit(text) } + + top := container.NewVBox( + wTopic, + widget.NewSeparator(), + ) + split := container.NewHSplit(wBufferList, + container.New(&customLayout{}, wRichScroll, wLog)) + split.SetOffset(0.25) + bottom := container.NewVBox( + widget.NewSeparator(), + container.NewBorder(nil, nil, + wPrompt, container.NewHBox(wDown, wStatus)), + wEntry, + ) + wWindow.SetContent(container.NewBorder(top, bottom, nil, nil, split)) + + canvas := wWindow.Canvas() + for s, handler := range shortcuts { + canvas.AddShortcut(&s, func(fyne.Shortcut) { handler() }) + } + + // --- + + connect := false + backendAddress = a.Preferences().String(preferenceAddress) + if flag.NArg() >= 1 { + backendAddress = flag.Arg(0) + connect = true + } + + connectAddress := widget.NewEntry() + connectAddress.SetPlaceHolder("host:port") + connectAddress.SetText(backendAddress) + connectAddress.TypedKey(&fyne.KeyEvent{Name: fyne.KeyPageDown}) + connectAddress.Validator = func(text string) error { + _, _, err := net.SplitHostPort(text) + return err + } + + // TODO(p): Mobile should not have the option to cancel at all. + // The GoBack just makes us go to the background, staying useless. + wConnect = dialog.NewForm("Connect to relay", "Connect", "Exit", + []*widget.FormItem{ + {Text: "Address:", Widget: connectAddress}, + }, func(ok bool) { + if ok { + backendAddress = connectAddress.Text + go relayRun() + } else if md, ok := a.Driver().(mobile.Driver); ok { + md.GoBack() + wConnect.Show() + } else { + a.Quit() + } + }, wWindow) + if connect { + go relayRun() + } else { + wConnect.Show() + } + + wWindow.ShowAndRun() +} diff --git a/xA/xA.svg b/xA/xA.svg new file mode 100644 index 0000000..1e7bfd1 --- /dev/null +++ b/xA/xA.svg @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg version="1.1" width="512" height="512" viewBox="0 0 512 512" + xmlns="http://www.w3.org/2000/svg"> + + <defs> + <linearGradient id="background" x1="0" y1="0" x2="0" y2="1"> + <stop stop-color="#ffaa00" offset="0" /> + <stop stop-color="#ff0000" offset="1" /> + </linearGradient> + <clipPath id="clip"> + <rect x="0" y="0" width="1" height="1" /> + </clipPath> + </defs> + + <rect x="0" y="0" width="512" height="512" fill="url(#background)" /> + + <g transform="translate(64, 64) scale(384)" stroke-linecap="square"> + <g clip-path="url(#clip)"> + <path stroke="#ffffff" stroke-width="0.125" + d="M 0.25,1.1 0.65,-0.1 M 0.75,1.1 0.35,-0.1 M 0.225,0.75 0.775,0.75" /> + </g> + </g> +</svg> @@ -474,6 +474,10 @@ input_rl_start (void *input, const char *program_name) // autofilter, and we don't generally want alphabetic ordering at all rl_sort_completion_matches = false; + // Readline >= 8.0 otherwise prints spurious newlines on EOF. + if (RL_VERSION_MAJOR >= 8) + rl_variable_bind ("enable-bracketed-paste", "off"); + hard_assert (self->prompt != NULL); // The inputrc is read before any callbacks are called, so we need to // register all functions that our user may want to map up front @@ -1814,6 +1818,7 @@ struct client uint32_t event_seq; ///< Outgoing message counter bool initialized; ///< Initial sync took place + bool closing; ///< We're closing the connection struct poller_fd socket_event; ///< The socket can be read/written to }; @@ -1871,7 +1876,7 @@ enum server_state IRC_CONNECTED, ///< Trying to register IRC_REGISTERED, ///< We can chat now IRC_CLOSING, ///< Flushing output before shutdown - IRC_HALF_CLOSED ///< Connection shutdown from our side + IRC_HALF_CLOSED ///< Connection shut down from our side }; /// Convert an IRC identifier character to lower-case @@ -2259,14 +2264,6 @@ struct app_context struct str_map servers; ///< Our servers - // Relay: - - int relay_fd; ///< Listening socket FD - struct client *clients; ///< Our relay clients - - /// A single message buffer to prepare all outcoming messages within - struct relay_event_message relay_message; - // Events: struct poller_fd tty_event; ///< Terminal input event @@ -2318,6 +2315,14 @@ struct app_context char *editor_filename; ///< The file being edited by user int terminal_suspended; ///< Terminal suspension level + // Relay: + + int relay_fd; ///< Listening socket FD + struct client *clients; ///< Our relay clients + + /// A single message buffer to prepare all outcoming messages within + struct relay_event_message relay_message; + // Plugins: struct plugin *plugins; ///< Loaded plugins @@ -2388,8 +2393,6 @@ app_context_init (struct app_context *self) self->config = config_make (); poller_init (&self->poller); - self->relay_fd = -1; - self->servers = str_map_make ((str_map_free_fn) server_unref); self->servers.key_xfrm = tolower_ascii_strxfrm; @@ -2413,6 +2416,8 @@ app_context_init (struct app_context *self) self->nick_palette = filter_color_cube_for_acceptable_nick_colors (&self->nick_palette_len); + + self->relay_fd = -1; } static void @@ -2891,390 +2896,6 @@ serialize_configuration (struct config_item *root, struct str *output) config_item_write (root, true, output); } -// --- Relay output ------------------------------------------------------------ - -static void -client_kill (struct client *c) -{ - struct app_context *ctx = c->ctx; - poller_fd_reset (&c->socket_event); - xclose (c->socket_fd); - c->socket_fd = -1; - - LIST_UNLINK (ctx->clients, c); - client_destroy (c); -} - -static void -client_update_poller (struct client *c, const struct pollfd *pfd) -{ - int new_events = POLLIN; - if (c->write_buffer.len) - new_events |= POLLOUT; - - hard_assert (new_events != 0); - if (!pfd || pfd->events != new_events) - poller_fd_set (&c->socket_event, new_events); -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static void -relay_send (struct client *c) -{ - struct relay_event_message *m = &c->ctx->relay_message; - m->event_seq = c->event_seq++; - - // TODO: Also don't try sending anything if half-closed. - if (!c->initialized || c->socket_fd == -1) - return; - - // liberty has msg_{reader,writer} already, but they use 8-byte lengths. - size_t frame_len_pos = c->write_buffer.len, frame_len = 0; - str_pack_u32 (&c->write_buffer, 0); - if (!relay_event_message_serialize (m, &c->write_buffer) - || (frame_len = c->write_buffer.len - frame_len_pos - 4) > UINT32_MAX) - { - print_error ("serialization failed, killing client"); - client_kill (c); - return; - } - - uint32_t len = htonl (frame_len); - memcpy (c->write_buffer.str + frame_len_pos, &len, sizeof len); - client_update_poller (c, NULL); -} - -static void -relay_broadcast_except (struct app_context *ctx, struct client *exception) -{ - LIST_FOR_EACH (struct client, c, ctx->clients) - if (c != exception) - relay_send (c); -} - -#define relay_broadcast(ctx) relay_broadcast_except ((ctx), NULL) - -static struct relay_event_message * -relay_prepare (struct app_context *ctx) -{ - struct relay_event_message *m = &ctx->relay_message; - relay_event_message_free (m); - memset (m, 0, sizeof *m); - return m; -} - -static void -relay_prepare_ping (struct app_context *ctx) -{ - relay_prepare (ctx)->data.event = RELAY_EVENT_PING; -} - -static union relay_item_data * -relay_translate_formatter (struct app_context *ctx, union relay_item_data *p, - const struct formatter_item *i) -{ - // XXX: See attr_printer_decode_color(), this is a footgun. - int16_t c16 = i->color; - int16_t c256 = i->color >> 16; - - unsigned attrs = i->attribute; - switch (i->type) - { - case FORMATTER_ITEM_TEXT: - p->text.text = str_from_cstr (i->text); - (p++)->kind = RELAY_ITEM_TEXT; - break; - case FORMATTER_ITEM_FG_COLOR: - p->fg_color.color = c256 <= 0 ? c16 : c256; - (p++)->kind = RELAY_ITEM_FG_COLOR; - break; - case FORMATTER_ITEM_BG_COLOR: - p->bg_color.color = c256 <= 0 ? c16 : c256; - (p++)->kind = RELAY_ITEM_BG_COLOR; - break; - case FORMATTER_ITEM_ATTR: - (p++)->kind = RELAY_ITEM_RESET; - if ((c256 = ctx->theme[i->attribute].fg) >= 0) - { - p->fg_color.color = c256; - (p++)->kind = RELAY_ITEM_FG_COLOR; - } - if ((c256 = ctx->theme[i->attribute].bg) >= 0) - { - p->bg_color.color = c256; - (p++)->kind = RELAY_ITEM_BG_COLOR; - } - - attrs = ctx->theme[i->attribute].attrs; - // Fall-through - case FORMATTER_ITEM_SIMPLE: - if (attrs & TEXT_BOLD) - (p++)->kind = RELAY_ITEM_FLIP_BOLD; - if (attrs & TEXT_ITALIC) - (p++)->kind = RELAY_ITEM_FLIP_ITALIC; - if (attrs & TEXT_UNDERLINE) - (p++)->kind = RELAY_ITEM_FLIP_UNDERLINE; - if (attrs & TEXT_INVERSE) - (p++)->kind = RELAY_ITEM_FLIP_INVERSE; - if (attrs & TEXT_CROSSED_OUT) - (p++)->kind = RELAY_ITEM_FLIP_CROSSED_OUT; - if (attrs & TEXT_MONOSPACE) - (p++)->kind = RELAY_ITEM_FLIP_MONOSPACE; - break; - default: - break; - } - return p; -} - -static union relay_item_data * -relay_items (struct app_context *ctx, const struct formatter_item *items, - uint32_t *len) -{ - size_t items_len = 0; - for (size_t i = 0; items[i].type; i++) - items_len++; - - // Beware of the upper bound, currently dominated by FORMATTER_ITEM_ATTR. - union relay_item_data *a = xcalloc (items_len * 9, sizeof *a), *p = a; - for (const struct formatter_item *i = items; items_len--; i++) - p = relay_translate_formatter (ctx, p, i); - - *len = p - a; - return a; -} - -static void -relay_prepare_buffer_line (struct app_context *ctx, struct buffer *buffer, - struct buffer_line *line, bool leak_to_active) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_buffer_line *e = &m->data.buffer_line; - e->event = RELAY_EVENT_BUFFER_LINE; - e->buffer_name = str_from_cstr (buffer->name); - e->is_unimportant = !!(line->flags & BUFFER_LINE_UNIMPORTANT); - e->is_highlight = !!(line->flags & BUFFER_LINE_HIGHLIGHT); - e->rendition = 1 + line->r; - e->when = line->when * 1000; - e->leak_to_active = leak_to_active; - e->items = relay_items (ctx, line->items, &e->items_len); -} - -// TODO: Consider pushing this whole block of code much further down. -static void formatter_add (struct formatter *self, const char *format, ...); -static char *irc_to_utf8 (const char *text); - -static void -relay_prepare_channel_buffer_update (struct app_context *ctx, - struct buffer *buffer, struct relay_buffer_context_channel *e) -{ - struct channel *channel = buffer->channel; - struct formatter f = formatter_make (ctx, buffer->server); - if (channel->topic) - formatter_add (&f, "#m", channel->topic); - e->topic = relay_items (ctx, f.items, &e->topic_len); - formatter_free (&f); - - // As in make_prompt(), conceal the last known channel modes. - // XXX: This should use irc_channel_is_joined(). - if (!channel->users_len) - return; - - struct str modes = str_make (); - str_append_str (&modes, &channel->no_param_modes); - - struct str params = str_make (); - struct str_map_iter iter = str_map_iter_make (&channel->param_modes); - const char *param; - while ((param = str_map_iter_next (&iter))) - { - str_append_c (&modes, iter.link->key[0]); - str_append_c (¶ms, ' '); - str_append (¶ms, param); - } - - str_append_str (&modes, ¶ms); - str_free (¶ms); - - char *modes_utf8 = irc_to_utf8 (modes.str); - str_free (&modes); - e->modes = str_from_cstr (modes_utf8); - free (modes_utf8); -} - -static void -relay_prepare_buffer_update (struct app_context *ctx, struct buffer *buffer) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_buffer_update *e = &m->data.buffer_update; - e->event = RELAY_EVENT_BUFFER_UPDATE; - e->buffer_name = str_from_cstr (buffer->name); - e->hide_unimportant = buffer->hide_unimportant; - - struct str *server_name = NULL; - switch (buffer->type) - { - case BUFFER_GLOBAL: - e->context.kind = RELAY_BUFFER_KIND_GLOBAL; - break; - case BUFFER_SERVER: - e->context.kind = RELAY_BUFFER_KIND_SERVER; - server_name = &e->context.server.server_name; - break; - case BUFFER_CHANNEL: - e->context.kind = RELAY_BUFFER_KIND_CHANNEL; - server_name = &e->context.channel.server_name; - relay_prepare_channel_buffer_update (ctx, buffer, &e->context.channel); - break; - case BUFFER_PM: - e->context.kind = RELAY_BUFFER_KIND_PRIVATE_MESSAGE; - server_name = &e->context.private_message.server_name; - break; - } - if (server_name) - *server_name = str_from_cstr (buffer->server->name); -} - -static void -relay_prepare_buffer_stats (struct app_context *ctx, struct buffer *buffer) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_buffer_stats *e = &m->data.buffer_stats; - e->event = RELAY_EVENT_BUFFER_STATS; - e->buffer_name = str_from_cstr (buffer->name); - e->new_messages = MIN (UINT32_MAX, - buffer->new_messages_count - buffer->new_unimportant_count); - e->new_unimportant_messages = MIN (UINT32_MAX, - buffer->new_unimportant_count); - e->highlighted = buffer->highlighted; -} - -static void -relay_prepare_buffer_rename (struct app_context *ctx, struct buffer *buffer, - const char *new_name) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_buffer_rename *e = &m->data.buffer_rename; - e->event = RELAY_EVENT_BUFFER_RENAME; - e->buffer_name = str_from_cstr (buffer->name); - e->new = str_from_cstr (new_name); -} - -static void -relay_prepare_buffer_remove (struct app_context *ctx, struct buffer *buffer) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_buffer_remove *e = &m->data.buffer_remove; - e->event = RELAY_EVENT_BUFFER_REMOVE; - e->buffer_name = str_from_cstr (buffer->name); -} - -static void -relay_prepare_buffer_activate (struct app_context *ctx, struct buffer *buffer) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_buffer_activate *e = &m->data.buffer_activate; - e->event = RELAY_EVENT_BUFFER_ACTIVATE; - e->buffer_name = str_from_cstr (buffer->name); -} - -static void -relay_prepare_buffer_input (struct app_context *ctx, struct buffer *buffer, - const char *input) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_buffer_input *e = &m->data.buffer_input; - e->event = RELAY_EVENT_BUFFER_INPUT; - e->buffer_name = str_from_cstr (buffer->name); - e->text = str_from_cstr (input); -} - -static void -relay_prepare_buffer_clear (struct app_context *ctx, - struct buffer *buffer) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_buffer_clear *e = &m->data.buffer_clear; - e->event = RELAY_EVENT_BUFFER_CLEAR; - e->buffer_name = str_from_cstr (buffer->name); -} - -enum relay_server_state -relay_server_state_for_server (struct server *s) -{ - switch (s->state) - { - case IRC_DISCONNECTED: return RELAY_SERVER_STATE_DISCONNECTED; - case IRC_CONNECTING: return RELAY_SERVER_STATE_CONNECTING; - case IRC_CONNECTED: return RELAY_SERVER_STATE_CONNECTED; - case IRC_REGISTERED: return RELAY_SERVER_STATE_REGISTERED; - case IRC_CLOSING: - case IRC_HALF_CLOSED: return RELAY_SERVER_STATE_DISCONNECTING; - } - return 0; -} - -static void -relay_prepare_server_update (struct app_context *ctx, struct server *s) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_server_update *e = &m->data.server_update; - e->event = RELAY_EVENT_SERVER_UPDATE; - e->server_name = str_from_cstr (s->name); - e->data.state = relay_server_state_for_server (s); - if (s->state == IRC_REGISTERED) - { - char *user_utf8 = irc_to_utf8 (s->irc_user->nickname); - e->data.registered.user = str_from_cstr (user_utf8); - free (user_utf8); - - char *user_modes_utf8 = irc_to_utf8 (s->irc_user_modes.str); - e->data.registered.user_modes = str_from_cstr (user_modes_utf8); - free (user_modes_utf8); - } -} - -static void -relay_prepare_server_rename (struct app_context *ctx, struct server *s, - const char *new_name) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_server_rename *e = &m->data.server_rename; - e->event = RELAY_EVENT_SERVER_RENAME; - e->server_name = str_from_cstr (s->name); - e->new = str_from_cstr (new_name); -} - -static void -relay_prepare_server_remove (struct app_context *ctx, struct server *s) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_server_remove *e = &m->data.server_remove; - e->event = RELAY_EVENT_SERVER_REMOVE; - e->server_name = str_from_cstr (s->name); -} - -static void -relay_prepare_error (struct app_context *ctx, uint32_t seq, const char *message) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_error *e = &m->data.error; - e->event = RELAY_EVENT_ERROR; - e->command_seq = seq; - e->error = str_from_cstr (message); -} - -static struct relay_event_data_response * -relay_prepare_response (struct app_context *ctx, uint32_t seq) -{ - struct relay_event_message *m = relay_prepare (ctx); - struct relay_event_data_response *e = &m->data.response; - e->event = RELAY_EVENT_RESPONSE; - e->command_seq = seq; - return e; -} - // --- Terminal output --------------------------------------------------------- /// Default colour pair @@ -4515,6 +4136,394 @@ formatter_flush (struct formatter *self, FILE *stream, int flush_opts) attr_printer_reset (&state); } +// --- Relay output ------------------------------------------------------------ + +static void +client_kill (struct client *c) +{ + struct app_context *ctx = c->ctx; + poller_fd_reset (&c->socket_event); + xclose (c->socket_fd); + c->socket_fd = -1; + + LIST_UNLINK (ctx->clients, c); + client_destroy (c); +} + +static void +client_update_poller (struct client *c, const struct pollfd *pfd) +{ + // In case of closing without any data in the write buffer, + // we don't actually need to be able to write to the socket, + // but the condition should be quick to satisfy. + int new_events = POLLIN; + if (c->write_buffer.len || c->closing) + new_events |= POLLOUT; + + hard_assert (new_events != 0); + if (!pfd || pfd->events != new_events) + poller_fd_set (&c->socket_event, new_events); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +relay_send (struct client *c) +{ + struct relay_event_message *m = &c->ctx->relay_message; + m->event_seq = c->event_seq++; + if (!c->initialized || c->closing || c->socket_fd == -1) + return; + + // liberty has msg_{reader,writer} already, but they use 8-byte lengths. + size_t frame_len_pos = c->write_buffer.len, frame_len = 0; + str_pack_u32 (&c->write_buffer, 0); + if (!relay_event_message_serialize (m, &c->write_buffer) + || (frame_len = c->write_buffer.len - frame_len_pos - 4) > UINT32_MAX) + { + print_error ("serialization failed, killing client"); + + // We can't kill the client immediately, + // because more relay_send() calls may follow. + c->write_buffer.len = frame_len_pos; + c->closing = true; + } + else + { + uint32_t len = htonl (frame_len); + memcpy (c->write_buffer.str + frame_len_pos, &len, sizeof len); + } + + client_update_poller (c, NULL); +} + +static void +relay_broadcast_except (struct app_context *ctx, struct client *exception) +{ + LIST_FOR_EACH (struct client, c, ctx->clients) + if (c != exception) + relay_send (c); +} + +#define relay_broadcast(ctx) relay_broadcast_except ((ctx), NULL) + +static struct relay_event_message * +relay_prepare (struct app_context *ctx) +{ + struct relay_event_message *m = &ctx->relay_message; + relay_event_message_free (m); + memset (m, 0, sizeof *m); + return m; +} + +static void +relay_prepare_ping (struct app_context *ctx) +{ + relay_prepare (ctx)->data.event = RELAY_EVENT_PING; +} + +static union relay_item_data * +relay_translate_formatter (struct app_context *ctx, union relay_item_data *p, + const struct formatter_item *i) +{ + // XXX: See attr_printer_decode_color(), this is a footgun. + int16_t c16 = i->color; + int16_t c256 = i->color >> 16; + + unsigned attrs = i->attribute; + switch (i->type) + { + case FORMATTER_ITEM_TEXT: + p->text.text = str_from_cstr (i->text); + (p++)->kind = RELAY_ITEM_TEXT; + break; + case FORMATTER_ITEM_FG_COLOR: + p->fg_color.color = c256 <= 0 ? c16 : c256; + (p++)->kind = RELAY_ITEM_FG_COLOR; + break; + case FORMATTER_ITEM_BG_COLOR: + p->bg_color.color = c256 <= 0 ? c16 : c256; + (p++)->kind = RELAY_ITEM_BG_COLOR; + break; + case FORMATTER_ITEM_ATTR: + (p++)->kind = RELAY_ITEM_RESET; + if ((c256 = ctx->theme[i->attribute].fg) >= 0) + { + p->fg_color.color = c256; + (p++)->kind = RELAY_ITEM_FG_COLOR; + } + if ((c256 = ctx->theme[i->attribute].bg) >= 0) + { + p->bg_color.color = c256; + (p++)->kind = RELAY_ITEM_BG_COLOR; + } + + attrs = ctx->theme[i->attribute].attrs; + // Fall-through + case FORMATTER_ITEM_SIMPLE: + if (attrs & TEXT_BOLD) + (p++)->kind = RELAY_ITEM_FLIP_BOLD; + if (attrs & TEXT_ITALIC) + (p++)->kind = RELAY_ITEM_FLIP_ITALIC; + if (attrs & TEXT_UNDERLINE) + (p++)->kind = RELAY_ITEM_FLIP_UNDERLINE; + if (attrs & TEXT_INVERSE) + (p++)->kind = RELAY_ITEM_FLIP_INVERSE; + if (attrs & TEXT_CROSSED_OUT) + (p++)->kind = RELAY_ITEM_FLIP_CROSSED_OUT; + if (attrs & TEXT_MONOSPACE) + (p++)->kind = RELAY_ITEM_FLIP_MONOSPACE; + break; + default: + break; + } + return p; +} + +static union relay_item_data * +relay_items (struct app_context *ctx, const struct formatter_item *items, + uint32_t *len) +{ + size_t items_len = 0; + for (size_t i = 0; items[i].type; i++) + items_len++; + + // Beware of the upper bound, currently dominated by FORMATTER_ITEM_ATTR. + union relay_item_data *a = xcalloc (items_len * 9, sizeof *a), *p = a; + for (const struct formatter_item *i = items; items_len--; i++) + p = relay_translate_formatter (ctx, p, i); + + *len = p - a; + return a; +} + +static void +relay_prepare_buffer_line (struct app_context *ctx, struct buffer *buffer, + struct buffer_line *line, bool leak_to_active) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_line *e = &m->data.buffer_line; + e->event = RELAY_EVENT_BUFFER_LINE; + e->buffer_name = str_from_cstr (buffer->name); + e->is_unimportant = !!(line->flags & BUFFER_LINE_UNIMPORTANT); + e->is_highlight = !!(line->flags & BUFFER_LINE_HIGHLIGHT); + e->rendition = 1 + line->r; + e->when = line->when * 1000; + e->leak_to_active = leak_to_active; + e->items = relay_items (ctx, line->items, &e->items_len); +} + +static void +relay_prepare_channel_buffer_update (struct app_context *ctx, + struct buffer *buffer, struct relay_buffer_context_channel *e) +{ + struct channel *channel = buffer->channel; + struct formatter f = formatter_make (ctx, buffer->server); + if (channel->topic) + formatter_add (&f, "#m", channel->topic); + FORMATTER_ADD_ITEM (&f, END); + e->topic = relay_items (ctx, f.items, &e->topic_len); + formatter_free (&f); + + // As in make_prompt(), conceal the last known channel modes. + // XXX: This should use irc_channel_is_joined(). + if (!channel->users_len) + return; + + struct str modes = str_make (); + str_append_str (&modes, &channel->no_param_modes); + + struct str params = str_make (); + struct str_map_iter iter = str_map_iter_make (&channel->param_modes); + const char *param; + while ((param = str_map_iter_next (&iter))) + { + str_append_c (&modes, iter.link->key[0]); + str_append_c (¶ms, ' '); + str_append (¶ms, param); + } + + str_append_str (&modes, ¶ms); + str_free (¶ms); + + char *modes_utf8 = irc_to_utf8 (modes.str); + str_free (&modes); + e->modes = str_from_cstr (modes_utf8); + free (modes_utf8); +} + +static void +relay_prepare_buffer_update (struct app_context *ctx, struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_update *e = &m->data.buffer_update; + e->event = RELAY_EVENT_BUFFER_UPDATE; + e->buffer_name = str_from_cstr (buffer->name); + e->hide_unimportant = buffer->hide_unimportant; + + struct str *server_name = NULL; + switch (buffer->type) + { + case BUFFER_GLOBAL: + e->context.kind = RELAY_BUFFER_KIND_GLOBAL; + break; + case BUFFER_SERVER: + e->context.kind = RELAY_BUFFER_KIND_SERVER; + server_name = &e->context.server.server_name; + break; + case BUFFER_CHANNEL: + e->context.kind = RELAY_BUFFER_KIND_CHANNEL; + server_name = &e->context.channel.server_name; + relay_prepare_channel_buffer_update (ctx, buffer, &e->context.channel); + break; + case BUFFER_PM: + e->context.kind = RELAY_BUFFER_KIND_PRIVATE_MESSAGE; + server_name = &e->context.private_message.server_name; + break; + } + if (server_name) + *server_name = str_from_cstr (buffer->server->name); +} + +static void +relay_prepare_buffer_stats (struct app_context *ctx, struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_stats *e = &m->data.buffer_stats; + e->event = RELAY_EVENT_BUFFER_STATS; + e->buffer_name = str_from_cstr (buffer->name); + e->new_messages = MIN (UINT32_MAX, + buffer->new_messages_count - buffer->new_unimportant_count); + e->new_unimportant_messages = MIN (UINT32_MAX, + buffer->new_unimportant_count); + e->highlighted = buffer->highlighted; +} + +static void +relay_prepare_buffer_rename (struct app_context *ctx, struct buffer *buffer, + const char *new_name) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_rename *e = &m->data.buffer_rename; + e->event = RELAY_EVENT_BUFFER_RENAME; + e->buffer_name = str_from_cstr (buffer->name); + e->new = str_from_cstr (new_name); +} + +static void +relay_prepare_buffer_remove (struct app_context *ctx, struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_remove *e = &m->data.buffer_remove; + e->event = RELAY_EVENT_BUFFER_REMOVE; + e->buffer_name = str_from_cstr (buffer->name); +} + +static void +relay_prepare_buffer_activate (struct app_context *ctx, struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_activate *e = &m->data.buffer_activate; + e->event = RELAY_EVENT_BUFFER_ACTIVATE; + e->buffer_name = str_from_cstr (buffer->name); +} + +static void +relay_prepare_buffer_input (struct app_context *ctx, struct buffer *buffer, + const char *input) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_input *e = &m->data.buffer_input; + e->event = RELAY_EVENT_BUFFER_INPUT; + e->buffer_name = str_from_cstr (buffer->name); + e->text = str_from_cstr (input); +} + +static void +relay_prepare_buffer_clear (struct app_context *ctx, + struct buffer *buffer) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_buffer_clear *e = &m->data.buffer_clear; + e->event = RELAY_EVENT_BUFFER_CLEAR; + e->buffer_name = str_from_cstr (buffer->name); +} + +enum relay_server_state +relay_server_state_for_server (struct server *s) +{ + switch (s->state) + { + case IRC_DISCONNECTED: return RELAY_SERVER_STATE_DISCONNECTED; + case IRC_CONNECTING: return RELAY_SERVER_STATE_CONNECTING; + case IRC_CONNECTED: return RELAY_SERVER_STATE_CONNECTED; + case IRC_REGISTERED: return RELAY_SERVER_STATE_REGISTERED; + case IRC_CLOSING: + case IRC_HALF_CLOSED: return RELAY_SERVER_STATE_DISCONNECTING; + } + return 0; +} + +static void +relay_prepare_server_update (struct app_context *ctx, struct server *s) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_server_update *e = &m->data.server_update; + e->event = RELAY_EVENT_SERVER_UPDATE; + e->server_name = str_from_cstr (s->name); + e->data.state = relay_server_state_for_server (s); + if (s->state == IRC_REGISTERED) + { + char *user_utf8 = irc_to_utf8 (s->irc_user->nickname); + e->data.registered.user = str_from_cstr (user_utf8); + free (user_utf8); + + char *user_modes_utf8 = irc_to_utf8 (s->irc_user_modes.str); + e->data.registered.user_modes = str_from_cstr (user_modes_utf8); + free (user_modes_utf8); + } +} + +static void +relay_prepare_server_rename (struct app_context *ctx, struct server *s, + const char *new_name) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_server_rename *e = &m->data.server_rename; + e->event = RELAY_EVENT_SERVER_RENAME; + e->server_name = str_from_cstr (s->name); + e->new = str_from_cstr (new_name); +} + +static void +relay_prepare_server_remove (struct app_context *ctx, struct server *s) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_server_remove *e = &m->data.server_remove; + e->event = RELAY_EVENT_SERVER_REMOVE; + e->server_name = str_from_cstr (s->name); +} + +static void +relay_prepare_error (struct app_context *ctx, uint32_t seq, const char *message) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_error *e = &m->data.error; + e->event = RELAY_EVENT_ERROR; + e->command_seq = seq; + e->error = str_from_cstr (message); +} + +static struct relay_event_data_response * +relay_prepare_response (struct app_context *ctx, uint32_t seq) +{ + struct relay_event_message *m = relay_prepare (ctx); + struct relay_event_data_response *e = &m->data.response; + e->event = RELAY_EVENT_RESPONSE; + e->command_seq = seq; + return e; +} + // --- Buffers ----------------------------------------------------------------- static void @@ -7033,9 +7042,7 @@ irc_is_highlight (struct server *s, const char *message) cstr_transform (nick, s->irc_tolower); // Special characters allowed in nicknames by RFC 2812: []\`_^{|} and - - // Also excluded from the ASCII: common user channel prefixes: +%@&~ - // XXX: why did I exclude those? It won't match when IRC newbies use them. - const char *delimiters = ",.;:!?()<>/=#$* \t\r\n\v\f\"'"; + const char *delimiters = "\t\n\v\f\r !\"#$%&'()*+,./:;<=>?@~"; bool result = false; char *save = NULL; @@ -14603,21 +14610,23 @@ on_readline_input (char *line) if (*line) add_history (line); - // readline always erases the input line after returning from here, + // Readline always erases the input line after returning from here, // but we don't want that to happen if the command to be executed // would switch the buffer (we'd keep the already executed command in // the old buffer and delete any input restored from the new buffer) strv_append_owned (&ctx->pending_input, line); poller_idle_set (&ctx->input_event); } - else + else if (isatty (STDIN_FILENO)) { - // Prevent readline from showing the prompt twice for w/e reason + // Prevent Readline from showing the prompt twice for w/e reason CALL (ctx->input, hide); input_rl__restore (self); CALL (ctx->input, ding); } + else + request_quit (ctx, NULL); if (self->active) // Readline automatically redisplays it @@ -15715,28 +15724,31 @@ client_process_message (struct client *c, return true; } + bool acknowledge = true; switch (m->data.command) { case RELAY_COMMAND_HELLO: + c->initialized = true; if (m->data.hello.version != RELAY_VERSION) { - // TODO: This should send back an error message and shut down. log_global_error (c->ctx, "Protocol version mismatch, killing client"); - return false; + relay_prepare_error (c->ctx, + m->command_seq, "Protocol version mismatch"); + relay_send (c); + + c->closing = true; + return true; } - c->initialized = true; client_resync (c); break; case RELAY_COMMAND_PING: - relay_prepare_response (c->ctx, m->command_seq) - ->data.command = RELAY_COMMAND_PING; - relay_send (c); break; case RELAY_COMMAND_ACTIVE: reset_autoaway (c->ctx); break; case RELAY_COMMAND_BUFFER_COMPLETE: + acknowledge = false; client_process_buffer_complete (c, m->command_seq, buffer, &m->data.buffer_complete); break; @@ -15750,13 +15762,21 @@ client_process_message (struct client *c, buffer_toggle_unimportant (c->ctx, buffer); break; case RELAY_COMMAND_BUFFER_LOG: + acknowledge = false; client_process_buffer_log (c, m->command_seq, buffer); break; default: + acknowledge = false; log_global_debug (c->ctx, "Unhandled client command"); relay_prepare_error (c->ctx, m->command_seq, "Unknown command"); relay_send (c); } + if (acknowledge) + { + relay_prepare_response (c->ctx, m->command_seq) + ->data.command = m->data.command; + relay_send (c); + } return true; } @@ -15778,7 +15798,7 @@ client_process_buffer (struct client *c) break; struct relay_command_message m = {}; - bool ok = client_process_message (c, &r, &m); + bool ok = c->closing || client_process_message (c, &r, &m); relay_command_message_free (&m); if (!ok) return false; @@ -15850,7 +15870,11 @@ on_client_ready (const struct pollfd *pfd, void *user_data) { struct client *c = user_data; if (client_try_read (c) && client_try_write (c)) + { client_update_poller (c, pfd); + if (c->closing && !c->write_buffer.len) + client_kill (c); + } } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1,7 +1,8 @@ // Backwards-compatible protocol version. -const VERSION = 1; +const VERSION = 2; // From the frontend to the relay. +// All commands receive either an Event.RESPONSE, or an Event.ERROR. struct CommandMessage { // The command sequence number will be repeated in responses // in the respective fields. @@ -32,13 +33,10 @@ struct CommandMessage { // XXX: Perhaps this should rather be handled through a /buffer command. case BUFFER_TOGGLE_UNIMPORTANT: string buffer_name; - case PING_RESPONSE: - u32 event_seq; - - // Only these commands may produce Event.RESPONSE, as below, - // but any command may produce an error. case PING: void; + case PING_RESPONSE: + u32 event_seq; case BUFFER_COMPLETE: string buffer_name; string text; @@ -52,6 +50,9 @@ struct CommandMessage { struct EventMessage { u32 event_seq; union EventData switch (enum Event { + ERROR, + RESPONSE, + PING, BUFFER_LINE, BUFFER_UPDATE, @@ -64,12 +65,28 @@ struct EventMessage { SERVER_UPDATE, SERVER_RENAME, SERVER_REMOVE, - ERROR, - RESPONSE, } event) { + // Restriction: command_seq strictly follows the sequence received + // by the relay, across both of these replies. + case ERROR: + u32 command_seq; + string error; + case RESPONSE: + u32 command_seq; + union ResponseData switch (Command command) { + case BUFFER_COMPLETE: + u32 start; + string completions<>; + case BUFFER_LOG: + // UTF-8, but not guaranteed. + u8 log<>; + default: + // Reception acknowledged. + void; + } data; + case PING: void; - case BUFFER_LINE: string buffer_name; // Whether the line should also be displayed in the active buffer. @@ -188,23 +205,5 @@ struct EventMessage { string new; case SERVER_REMOVE: string server_name; - - // Restriction: command_seq strictly follows the sequence received - // by the relay, across both of these replies. - case ERROR: - u32 command_seq; - string error; - case RESPONSE: - u32 command_seq; - union ResponseData switch (Command command) { - case PING: - void; - case BUFFER_COMPLETE: - u32 start; - string completions<>; - case BUFFER_LOG: - // UTF-8, but not guaranteed. - u8 log<>; - } data; } data; }; @@ -1 +1 @@ -2.0.0 +2.1.0 diff --git a/xM/gen-icon.swift b/xM/gen-icon.swift index 712e9e6..0f7477c 100644 --- a/xM/gen-icon.swift +++ b/xM/gen-icon.swift @@ -3,6 +3,9 @@ // Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name> // SPDX-License-Identifier: 0BSD // +// As an odd regression, AppKit may be necessary for JIT linking. +import AppKit + // NSGraphicsContext mostly just weirdly wraps over Quartz, // so we do it all in Quartz directly. import CoreGraphics diff --git a/xM/main.swift b/xM/main.swift index 91e3499..39b66c5 100644 --- a/xM/main.swift +++ b/xM/main.swift @@ -173,8 +173,11 @@ class RelayRPC { 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 + self.commandCallbacks[m.commandSeq] = callback ?? { error, data in + if data == nil { + NSSound.beep() + Logger().warning("\(error)") + } } var w = RelayWriter() @@ -842,11 +845,11 @@ relayRPC.onEvent = { message in b.bufferName = data.new - refreshBufferList() if b.bufferName == relayBufferCurrent { relayBufferCurrent = data.new refreshStatus() } + refreshBufferList() if b.bufferName == relayBufferLast { relayBufferLast = data.new } @@ -1203,6 +1206,7 @@ class WindowDelegate: NSObject, NSWindowDelegate { b.highlighted = false refreshIcon() + refreshBufferList() } // Buffer indexes rotated to start after the current buffer. @@ -247,16 +247,16 @@ func main() { flag.PrintDefaults() } flag.Parse() - if flag.NArg() < 1 { - flag.Usage() - os.Exit(2) - } - if *version { fmt.Printf("%s %s\n", projectName, projectVersion) return } + if flag.NArg() < 1 { + flag.Usage() + os.Exit(2) + } + text, err := io.ReadAll(os.Stdin) if err != nil { log.Fatalln(err) @@ -1,10 +1,7 @@ module janouch.name/xK/xP -go 1.18 +go 1.21 -require nhooyr.io/websocket v1.8.7 +toolchain go1.23.2 -require ( - github.com/klauspost/compress v1.15.9 // indirect - golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect -) +require nhooyr.io/websocket v1.8.17 @@ -1,62 +1,2 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= -github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= -github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= -github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= -github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= -github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= -github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= -github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= -github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= -github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= -github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= -github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= -github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= -github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= -github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= -nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/xP/public/xP.js b/xP/public/xP.js index 5436a65..33d7d2a 100644 --- a/xP/public/xP.js +++ b/xP/public/xP.js @@ -1,4 +1,4 @@ -// Copyright (c) 2022 - 2023, Přemysl Eric Janouch <p@janouch.name> +// Copyright (c) 2022 - 2025, Přemysl Eric Janouch <p@janouch.name> // SPDX-License-Identifier: 0BSD import * as Relay from './proto.js' @@ -67,18 +67,19 @@ class RelayRPC extends EventTarget { _processOne(message) { let e = message.data + let p switch (e.event) { case Relay.Event.Error: - if (this.promised[e.commandSeq] !== undefined) - this.promised[e.commandSeq].reject(e.error) - else + if ((p = this.promised[e.commandSeq]) === undefined) console.error(`Unawaited error: ${e.error}`) + else if (p !== true) + p.reject(e.error) break case Relay.Event.Response: - if (this.promised[e.commandSeq] !== undefined) - this.promised[e.commandSeq].resolve(e.data) - else + if ((p = this.promised[e.commandSeq]) === undefined) console.error("Unawaited response") + else if (p !== true) + p.resolve(e.data) break default: e.eventSeq = message.eventSeq @@ -95,6 +96,13 @@ class RelayRPC extends EventTarget { this.promised[seq].reject("No response") delete this.promised[seq] } + m.redraw() + } + + get busy() { + for (const seq in this.promised) + return true + return false } send(params) { @@ -110,6 +118,9 @@ class RelayRPC extends EventTarget { this.ws.send(JSON.stringify({commandSeq: seq, data: params})) + this.promised[seq] = true + m.redraw() + // Automagically detect if we want a result. let data = undefined const promise = new Promise( @@ -191,6 +202,17 @@ let bufferAutoscroll = true let servers = new Map() +let lastActive = undefined + +function notifyActive() { + // Reduce unnecessary traffic. + const now = Date.now() + if (lastActive === undefined || (now - lastActive >= 5000)) { + lastActive = now + rpc.send({command: 'Active'}) + } +} + function bufferResetStats(b) { b.newMessages = 0 b.newUnimportantMessages = 0 @@ -998,7 +1020,7 @@ let Input = { onKeyDown: event => { // TODO: And perhaps on other actions, too. - rpc.send({command: 'Active'}) + notifyActive() let b = buffers.get(bufferCurrent) if (b === undefined || event.isComposing) @@ -1103,7 +1125,13 @@ let Main = { return m('.xP', {}, [ overlay, - m('.title', {}, [m('b', {}, `xP`), m(Topic)]), + m('.title', {}, [ + m('span', [ + rpc.busy ? '⋯ ' : undefined, + m('b', {}, `xP`), + ]), + m(Topic), + ]), m('.middle', {}, [m(BufferList), m(BufferContainer)]), m(Status), m('.input', {}, [m(Prompt), m(Input)]), @@ -75,12 +75,12 @@ func relayMakeReceiver(ctx context.Context, conn net.Conn) <-chan []byte { go func() { defer close(p) for { - j := relayReadFrame(r) - if j == nil { + b := relayReadFrame(r) + if b == nil { return } select { - case p <- j: + case p <- b: case <-ctx.Done(): return } @@ -145,8 +145,7 @@ func clientWriteError(ctx context.Context, ws *websocket.Conn, err error) bool { b, ok := (&RelayEventMessage{ EventSeq: 0, Data: RelayEventData{ - Interface: RelayEventDataError{ - Event: RelayEventError, + Variant: &RelayEventDataError{ CommandSeq: 0, Error: err.Error(), }, diff --git a/xR/.gitignore b/xR/.gitignore new file mode 100644 index 0000000..a9766d8 --- /dev/null +++ b/xR/.gitignore @@ -0,0 +1,2 @@ +/xR +/proto.go diff --git a/xR/Makefile b/xR/Makefile new file mode 100644 index 0000000..7fb55c5 --- /dev/null +++ b/xR/Makefile @@ -0,0 +1,17 @@ +.POSIX: +AWK = env LC_ALL=C awk + +tools = ../liberty/tools +generated = proto.go +outputs = xR $(generated) +all: $(outputs) +generate: $(generated) + +proto.go: $(tools)/lxdrgen.awk $(tools)/lxdrgen-go.awk ../xC.lxdr + $(AWK) -f $(tools)/lxdrgen.awk -f $(tools)/lxdrgen-go.awk \ + -v PrefixCamel=Relay ../xC.lxdr > $@ +xR: xR.go ../xK-version $(generated) + go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \ + -gcflags=all="-N -l" +clean: + rm -f $(outputs) diff --git a/xR/go.mod b/xR/go.mod new file mode 100644 index 0000000..998a18f --- /dev/null +++ b/xR/go.mod @@ -0,0 +1,5 @@ +module janouch.name/xK/xR + +go 1.23.0 + +toolchain go1.24.0 diff --git a/xR/xR.adoc b/xR/xR.adoc new file mode 100644 index 0000000..c3215bd --- /dev/null +++ b/xR/xR.adoc @@ -0,0 +1,41 @@ +xR(1) +===== +:doctype: manpage +:manmanual: xK Manual +:mansource: xK {release-version} + +Name +---- +xR - xC relay protocol analyzer + +Synopsis +-------- +*xR* [_OPTION_]... RELAY-ADDRESS... + +Description +----------- +*xR* connects to an *xC* relay and prints all incoming events one per line +in JSON format. The JSON objects have two additional fields: + +when:: + The time of reception (or sending) as a nanosecond precision + RFC 3339 UTC timestamp. +raw:: + The incoming event (or outgoing command) in raw binary form. + +Options +------- +*-debug*:: + Print any outgoing commands as well, which may help in debugging any issues. + +*-version*:: + Output version information and exit. + +Reporting bugs +-------------- +Use https://git.janouch.name/p/xK to report bugs, request features, +or submit pull requests. + +See also +-------- +*xC*(1) diff --git a/xR/xR.go b/xR/xR.go new file mode 100644 index 0000000..a26832d --- /dev/null +++ b/xR/xR.go @@ -0,0 +1,134 @@ +// Copyright (c) 2025, Přemysl Eric Janouch <p@janouch.name> +// SPDX-License-Identifier: 0BSD + +package main + +import ( + "encoding/binary" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "net" + "os" + "time" +) + +var ( + debug = flag.Bool("debug", false, "enable debug output") + version = flag.Bool("version", false, "show version and exit") + projectName = "xR" + projectVersion = "?" +) + +func now() string { + return time.Now().UTC().Format(time.RFC3339Nano) +} + +func relayReadFrame(r io.Reader) bool { + var length uint32 + if err := binary.Read( + r, binary.BigEndian, &length); errors.Is(err, io.EOF) { + return false + } else if err != nil { + log.Fatalln("Event receive failed: " + err.Error()) + } + b := make([]byte, length) + if _, err := io.ReadFull(r, b); errors.Is(err, io.EOF) { + return false + } else if err != nil { + log.Fatalln("Event receive failed: " + err.Error()) + } + + m := struct { + When string `json:"when"` + Binary []byte `json:"raw"` + RelayEventMessage + }{ + When: now(), + Binary: b, + } + + if after, ok := m.RelayEventMessage.ConsumeFrom(b); !ok { + log.Println("Event deserialization failed") + } else if len(after) != 0 { + log.Println("Event deserialization failed: trailing data") + return true + } + + j, err := json.Marshal(m) + if err != nil { + log.Fatalln("Event marshalling failed: " + err.Error()) + } + fmt.Printf("%s\n", j) + return true +} + +func run(addressConnect string) { + conn, err := net.Dial("tcp", addressConnect) + if err != nil { + log.Println("Connection failed: " + err.Error()) + return + } + defer conn.Close() + + // We can only support this one protocol version + // that proto.go has been generated for. + m := RelayCommandMessage{CommandSeq: 0, Data: RelayCommandData{ + Variant: &RelayCommandDataHello{Version: RelayVersion}, + }} + + b, ok := m.AppendTo(make([]byte, 4)) + if !ok { + log.Fatalln("Command serialization failed") + } + binary.BigEndian.PutUint32(b[:4], uint32(len(b)-4)) + if _, err := conn.Write(b); err != nil { + log.Fatalln("Command send failed: " + err.Error()) + } + + // You can differentiate the direction by the presence + // of .data.command or .data.event. + if *debug { + j, err := json.Marshal(struct { + When string `json:"when"` + Binary []byte `json:"raw"` + RelayCommandMessage + }{ + When: now(), + Binary: b, + RelayCommandMessage: m, + }) + if err != nil { + log.Fatalln("Command marshalling failed: " + err.Error()) + } + fmt.Printf("%s\n", j) + } + + for relayReadFrame(conn) { + } +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), + "Usage: %s [OPTION...] CONNECT\n\n", os.Args[0]) + flag.PrintDefaults() + } + flag.Parse() + if *version { + fmt.Printf("%s %s (relay protocol version %d)\n", + projectName, projectVersion, RelayVersion) + return + } + + if flag.NArg() != 1 { + flag.Usage() + os.Exit(1) + } + + // TODO(p): This program should be able to run as a filter as well. + run(flag.Arg(0)) +} diff --git a/xS/Dockerfile b/xS/Dockerfile new file mode 100644 index 0000000..cb9ceb3 --- /dev/null +++ b/xS/Dockerfile @@ -0,0 +1,10 @@ +# TODO(p): Make it possible to store configuration. +# docker/podman build --tag xs-irc --file xS/Dockerfile .. +FROM alpine:latest +LABEL org.opencontainers.image.url="https://git.janouch.name/p/xK" +RUN apk add --no-cache build-base make go +WORKDIR /xK/xS +COPY .. /xK +RUN make +EXPOSE 6667 +ENTRYPOINT ["./xS"] diff --git a/xS/Makefile b/xS/Makefile index 57927fa..770be13 100644 --- a/xS/Makefile +++ b/xS/Makefile @@ -5,7 +5,7 @@ AWK = env LC_ALL=C awk outputs = xS xS-replies.go xS.1 all: $(outputs) -xS: xS.go ../xK-version xS-replies.go +xS: xS.go xS-replies.go ../xK-version go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ xS-replies.go: xS-gen-replies.awk xS-replies $(AWK) -f xS-gen-replies.awk xS-replies > $@ diff --git a/xT/CMakeLists.txt b/xT/CMakeLists.txt new file mode 100644 index 0000000..562c15a --- /dev/null +++ b/xT/CMakeLists.txt @@ -0,0 +1,179 @@ +# As per Qt 6.8 documentation, at least 3.16 is necessary +cmake_minimum_required (VERSION 3.21.1) + +file (READ ../xK-version project_version) +configure_file (../xK-version xK-version.tag COPYONLY) +string (STRIP "${project_version}" project_version) + +# This is an entirely separate CMake project. +project (xT VERSION "${project_version}" + DESCRIPTION "Qt frontend for xC" LANGUAGES CXX) + +set (CMAKE_CXX_STANDARD 17) +set (CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package (Qt6 REQUIRED COMPONENTS Widgets Network Multimedia + Quick QuickControls2) +# XXX: The version requirement is probably for Qt Quick only. +qt_standard_project_setup (REQUIRES 6.5) + +add_compile_options ("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>") +add_compile_options ("$<$<CXX_COMPILER_ID:GNU>:-Wall;-Wextra>") +add_compile_options ("$<$<CXX_COMPILER_ID:Clang>:-Wall;-Wextra>") + +set (project_config "${PROJECT_BINARY_DIR}/config.h") +configure_file ("${PROJECT_SOURCE_DIR}/config.h.in" "${project_config}") +include_directories ("${PROJECT_SOURCE_DIR}" "${PROJECT_BINARY_DIR}") + +# Produce a beep sample +find_program (sox_EXECUTABLE sox REQUIRED) +set (beep "${PROJECT_BINARY_DIR}/beep.wav") +add_custom_command (OUTPUT "${beep}" + COMMAND ${sox_EXECUTABLE} -b 16 -Dr 44100 -n "${beep}" + synth 0.1 0 25 triangle 800 vol 0.5 fade t 0 -0 0.005 pad 0 0.05 + COMMENT "Generating a beep sample" VERBATIM) +set_property (SOURCE "${beep}" APPEND PROPERTY QT_RESOURCE_ALIAS beep.wav) + +# Rasterize SVG icons +set (root "${PROJECT_SOURCE_DIR}/..") +set (CMAKE_MODULE_PATH "${root}/liberty/cmake") +include (IconUtils) + +# It might generally be better to use QtSvg, though it is an extra dependency. +# The icon_to_png macro is not intended to be used like this. +foreach (icon xT xT-highlighted) + icon_to_png (${icon} "${PROJECT_SOURCE_DIR}/${icon}.svg" + 48 "${PROJECT_BINARY_DIR}/resources" icon_png) + set_property (SOURCE "${icon_png}" + APPEND PROPERTY QT_RESOURCE_ALIAS "${icon}.png") + list (APPEND icon_rsrc_list "${icon_png}") +endforeach () + +if (APPLE) + set (MACOSX_BUNDLE_ICON_FILE xT.icns) + icon_to_icns ("${PROJECT_SOURCE_DIR}/xT.svg" + "${MACOSX_BUNDLE_ICON_FILE}" icon_icns) +else () + # The largest size is mainly for an appropriately sized Windows icon + set (icon_base "${PROJECT_BINARY_DIR}/icons") + set (icon_png_list) + foreach (icon_size 16 32 48 256) + icon_to_png (xT "${PROJECT_SOURCE_DIR}/xT.svg" + ${icon_size} "${icon_base}" icon_png) + list (APPEND icon_png_list "${icon_png}") + endforeach () + add_custom_target (icons ALL DEPENDS ${icon_png_list}) + if (WIN32) + list (REMOVE_ITEM icon_png_list "${icon_png}") + set (icon_ico "${PROJECT_BINARY_DIR}/xT.ico") + icon_for_win32 ("${icon_ico}" "${icon_png_list}" "${icon_png}") + + set (resource_file "${PROJECT_BINARY_DIR}/xT.rc") + list (APPEND project_sources "${resource_file}") + add_custom_command (OUTPUT "${resource_file}" + COMMAND ${CMAKE_COMMAND} -E echo "1 ICON \"xT.ico\"" + > ${resource_file} VERBATIM) + set_property (SOURCE "${resource_file}" + APPEND PROPERTY OBJECT_DEPENDS ${icon_ico}) + endif () +endif () + +# Build the main executable and link it +find_program (awk_EXECUTABLE awk REQUIRED) +add_custom_command (OUTPUT xC-proto.cpp + COMMAND ${CMAKE_COMMAND} -E env LC_ALL=C ${awk_EXECUTABLE} + -f ${root}/liberty/tools/lxdrgen.awk + -f ${root}/liberty/tools/lxdrgen-cpp.awk + -v PrefixCamel=Relay + ${root}/xC.lxdr > xC-proto.cpp + DEPENDS + ${root}/liberty/tools/lxdrgen.awk + ${root}/liberty/tools/lxdrgen-cpp.awk + ${root}/xC.lxdr + COMMENT "Generating xC relay protocol code" VERBATIM) +add_custom_target (xC-proto DEPENDS ${PROJECT_BINARY_DIR}/xC-proto.cpp) + +list (APPEND project_sources "${root}/liberty/tools/lxdrgen-cpp-qt.cpp") +qt_add_executable (xT + xT.cpp ${project_config} ${project_sources} "${icon_icns}") +add_dependencies (xT xC-proto) +qt_add_resources (xT "rsrc" PREFIX / FILES "${beep}" ${icon_rsrc_list}) +target_link_libraries (xT PRIVATE Qt6::Widgets Qt6::Network Qt6::Multimedia) +set_target_properties (xT PROPERTIES WIN32_EXECUTABLE ON MACOSX_BUNDLE ON + MACOSX_BUNDLE_GUI_IDENTIFIER name.janouch.xT) + +# https://stackoverflow.com/questions/79079161 and resolved in Qt Creator 16. +set (QT_QML_GENERATE_QMLLS_INI ON) + +# TODO(p): Perhaps do it in one-or-the-other way, +# as Qt Quick sucks on the desktop, and Qt Widgets is unusable on mobile. +qt_add_executable (xTq + xTq.cpp ${project_config} ${project_sources} "${icon_icns}") +set_property (SOURCE xTq.qml APPEND PROPERTY QT_QML_SOURCE_TYPENAME Main) +qt_add_qml_module (xTq URI xTquick VERSION 1.0 QML_FILES xTq.qml) +add_dependencies (xTq xC-proto) +qt_add_resources (xTq "rsrc" PREFIX / FILES "${beep}" ${icon_rsrc_list}) +target_link_libraries (xTq PRIVATE + Qt6::Quick Qt6::QuickControls2 Qt6::Network Qt6::Multimedia) +set_target_properties (xTq PROPERTIES WIN32_EXECUTABLE ON MACOSX_BUNDLE ON + MACOSX_BUNDLE_GUI_IDENTIFIER name.janouch.xTq) + +# The files to be installed +include (GNUInstallDirs) + +if (ANDROID) + install (TARGETS xTq DESTINATION .) +elseif (APPLE OR WIN32) + install (TARGETS xT + BUNDLE DESTINATION . + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) + + # XXX: QTBUG-127075, which can be circumvented by manually running + # macdeployqt on xT.app before the install. + qt_generate_deploy_app_script (TARGET xT OUTPUT_SCRIPT deploy_xT) + install (SCRIPT "${deploy_xT}") +else () + install (TARGETS xT DESTINATION ${CMAKE_INSTALL_BINDIR}) + install (FILES ../LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR}) + + install (FILES xT.svg + DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps) + install (DIRECTORY ${icon_base} + DESTINATION ${CMAKE_INSTALL_DATADIR}) + install (FILES xT.desktop + DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) +endif () + +# Within MSYS2, windeployqt doesn't copy the compiler runtime, +# which is always linked dynamically by the Qt binaries. +# TODO(p): Consider whether or not to use MSYS2 to cross-compile, and how. +if (WIN32) + install (CODE [=[ + set (bindir "${CMAKE_INSTALL_PREFIX}/bin") + execute_process (COMMAND cygpath -m / + OUTPUT_VARIABLE cygroot OUTPUT_STRIP_TRAILING_WHITESPACE) + if (cygroot) + execute_process (COMMAND ldd "${bindir}/xT.exe" + OUTPUT_VARIABLE ldd_output OUTPUT_STRIP_TRAILING_WHITESPACE) + string (REGEX MATCHALL " /mingw64/bin/[^ ]+ " libs "${ldd_output}") + foreach (lib ${libs}) + string (STRIP "${lib}" lib) + file (COPY "${cygroot}${lib}" DESTINATION "${bindir}") + endforeach () + endif () + ]=]) +endif () + +# CPack +set (CPACK_PACKAGE_VENDOR "Premysl Eric Janouch") +set (CPACK_PACKAGE_CONTACT "Přemysl Eric Janouch <p@janouch.name>") +set (CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/../LICENSE") +set (CPACK_GENERATOR "TGZ;ZIP") +set (CPACK_PACKAGE_FILE_NAME + "${PROJECT_NAME}-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}") +set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME} ${PROJECT_VERSION}") +set (CPACK_SOURCE_GENERATOR "TGZ;ZIP") +set (CPACK_SOURCE_IGNORE_FILES "/build;/CMakeLists.txt.user") +set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}") + +include (CPack) diff --git a/xT/config.h.in b/xT/config.h.in new file mode 100644 index 0000000..d31abdd --- /dev/null +++ b/xT/config.h.in @@ -0,0 +1,7 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#define PROJECT_NAME "${PROJECT_NAME}" +#define PROJECT_VERSION "${project_version}" + +#endif // ! CONFIG_H diff --git a/xT/xT-highlighted.svg b/xT/xT-highlighted.svg new file mode 100644 index 0000000..e624b4b --- /dev/null +++ b/xT/xT-highlighted.svg @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg version="1.1" width="48" height="48" viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg"> + + <defs> + <radialGradient id="green-x"> + <stop stop-color="hsl(66, 100%, 80%)" offset="0" /> + <stop stop-color="hsl(66, 100%, 50%)" offset="1" /> + </radialGradient> + <radialGradient id="orange"> + <stop stop-color="hsl(36, 100%, 60%)" offset="0" /> + <stop stop-color="hsl(23, 100%, 60%)" offset="1" /> + </radialGradient> + <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%"> + <feDropShadow dx="0" dy="0" stdDeviation="0.05" + flood-color="rgba(0, 0, 0, .5)" /> + </filter> + </defs> + + <!-- XXX: librsvg screws up shadows on rotated objects. --> + <g filter="url(#shadow)" transform="translate(24 3) scale(16)"> + <path fill="url(#orange)" stroke="hsl(36, 100%, 20%)" stroke-width="0.1" + d="M-.8 0 H.8 V.5 H.25 V2.625 H-.25 V.5 H-.8 Z" /> + </g> + <g filter="url(#shadow)" transform="translate(24 28) rotate(-45) scale(16)"> + <path fill="url(#green-x)" stroke="hsl(66, 100%, 20%)" stroke-width="0.1" + d="M-.25 -1 H.25 V-.25 H1 V.25 H.25 V1 H-.25 V.25 H-1 V-.25 H-.25 Z" /> + </g> +</svg> diff --git a/xT/xT.cpp b/xT/xT.cpp new file mode 100644 index 0000000..b708b95 --- /dev/null +++ b/xT/xT.cpp @@ -0,0 +1,1734 @@ +/* + * xT.cpp: Qt Widgets frontend for xC + * + * Copyright (c) 2024, 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. + * + */ + +#include "xC-proto.cpp" +#include "config.h" + +#include <cstdint> +#include <functional> +#include <map> +#include <string> + +#include <QtEndian> +#include <QtDebug> + +#include <QDateTime> +#include <QRegularExpression> + +#include <QApplication> +#include <QFontDatabase> +#include <QFormLayout> +#include <QHBoxLayout> +#include <QKeyEvent> +#include <QLabel> +#include <QLineEdit> +#include <QListWidget> +#include <QMainWindow> +#include <QMessageBox> +#include <QPushButton> +#include <QScrollBar> +#include <QShortcut> +#include <QSplitter> +#include <QStackedWidget> +#include <QTextBrowser> +#include <QTextBlock> +#include <QTextEdit> +#include <QTimer> +#include <QToolButton> +#include <QVBoxLayout> +#include <QWindow> + +#include <QSoundEffect> +#include <QTcpSocket> + +struct Server { + Relay::ServerState state = {}; + QString user; + QString user_modes; +}; + +struct BufferLineItem { + QTextCharFormat format = {}; + QString text; +}; + +struct BufferLine { + /// Leaked from another buffer, but temporarily staying in another one. + bool leaked = {}; + + bool is_unimportant = {}; + bool is_highlight = {}; + Relay::Rendition rendition = {}; + uint64_t when = {}; + std::vector<BufferLineItem> items; +}; + +struct Buffer { + QString buffer_name; + bool hide_unimportant = {}; + Relay::BufferKind kind = {}; + QString server_name; + std::vector<BufferLine> lines; + + // Channel: + + std::vector<BufferLineItem> topic; + QString modes; + + // Stats: + + uint32_t new_messages = {}; + uint32_t new_unimportant_messages = {}; + bool highlighted = {}; + + // Input: + + // The input is stored as rich text. + QString input; + int input_start = {}; + int input_end = {}; + std::vector<QString> history; + size_t history_at = {}; +}; + +using Callback = std::function< + void(std::wstring error, const Relay::ResponseData *response)>; + +struct { + QMainWindow *wMain; ///< Main program window + QLabel *wTopic; ///< Channel topic + QListWidget *wBufferList; ///< Buffer list + QStackedWidget *wStack; ///< Buffer backlog/log stack + QTextBrowser *wBuffer; ///< Buffer backlog + QTextBrowser *wLog; ///< Buffer log + QLabel *wPrompt; ///< User name, etc. + QToolButton *wButtonB; ///< Toggle bold formatting + QToolButton *wButtonI; ///< Toggle italic formatting + QToolButton *wButtonU; ///< Toggle underlined formatting + QLabel *wStatus; ///< Buffer name, etc. + QToolButton *wButtonLog; ///< Buffer log toggle button + QToolButton *wButtonDown; ///< Scroll indicator + QTextEdit *wInput; ///< User input + + QTimer *date_change_timer; ///< Timer for day changes + + QLineEdit *wConnectHost; ///< Connection dialog: host + QLineEdit *wConnectPort; ///< Connection dialog: port + QDialog *wConnectDialog; ///< Connection details dialog + + // Networking: + + QString host; ///< Host as given by user + QString port; ///< Post/service as given by user + + QTcpSocket *socket; ///< Buffered relay socket + + // Relay protocol: + + uint32_t command_seq; ///< Outgoing message counter + + std::map<uint32_t, Callback> command_callbacks; + + std::vector<Buffer> buffers; ///< List of all buffers + QString buffer_current; ///< Current buffer name or "" + QString buffer_last; ///< Previous buffer name or "" + + std::map<QString, Server> servers; +} g; + +static void +show_error_message(const QString &message) +{ + QMessageBox::critical(g.wMain, "Error", message, QMessageBox::Ok); +} + +static void +beep() +{ + // We don't want to reuse the same instance. + auto *se = new QSoundEffect(g.wMain); + QObject::connect(se, &QSoundEffect::playingChanged, [=] { + if (!se->isPlaying()) + se->deleteLater(); + }); + QObject::connect(se, &QSoundEffect::statusChanged, [=] { + if (se->status() == QSoundEffect::Error) + se->deleteLater(); + }); + + se->setSource(QUrl("qrc:/beep.wav")); + se->setLoopCount(1); + se->setVolume(0.5); + se->play(); +} + +// --- Networking -------------------------------------------------------------- + +static void +on_relay_generic_response( + std::wstring error, const Relay::ResponseData *response) +{ + if (!response) + show_error_message(QString::fromStdWString(error)); +} + +static void +relay_send(Relay::CommandData *data, Callback callback = {}) +{ + Relay::CommandMessage m = {}; + m.command_seq = ++g.command_seq; + m.data.reset(data); + LibertyXDR::Writer w; + m.serialize(w); + + if (callback) + g.command_callbacks[m.command_seq] = std::move(callback); + else + g.command_callbacks[m.command_seq] = on_relay_generic_response; + + auto len = qToBigEndian<uint32_t>(w.data.size()); + auto prefix = reinterpret_cast<const char *>(&len); + auto mdata = reinterpret_cast<const char *>(w.data.data()); + if (g.socket->write(prefix, sizeof len) < 0 || + g.socket->write(mdata, w.data.size()) < 0) { + g.socket->abort(); + } +} + +// --- Buffers ----------------------------------------------------------------- + +static Buffer * +buffer_by_name(const QString &name) +{ + for (auto &b : g.buffers) + if (b.buffer_name == name) + return &b; + return nullptr; +} + +static Buffer * +buffer_by_name(const std::wstring &name) +{ + // The C++ LibertyXDR backend unfortunately targets Win32. + return buffer_by_name(QString::fromStdWString(name)); +} + +static bool +buffer_at_bottom() +{ + auto sb = g.wBuffer->verticalScrollBar(); + return sb->value() == sb->maximum(); +} + +static void +buffer_scroll_to_bottom() +{ + auto sb = g.wBuffer->verticalScrollBar(); + sb->setValue(sb->maximum()); +} + +// --- UI state refresh -------------------------------------------------------- + +static void +refresh_icon() +{ + // This blocks Linux themes, but oh well. + QIcon icon(":/xT.png"); + for (const auto &b : g.buffers) + if (b.highlighted) + icon = QIcon(":/xT-highlighted.png"); + + g.wMain->setWindowIcon(icon); +} + +static void +textedit_replacesel( + QTextEdit *e, const QTextCharFormat &cf, const QString &text) +{ + auto cursor = e->textCursor(); + if (cf.fontFixedPitch()) { + auto fixed = QFontDatabase::systemFont(QFontDatabase::FixedFont); + auto adjusted = cf; + // For some reason, setting the families to empty also works. + adjusted.setFontFamilies(fixed.families()); + cursor.setCharFormat(adjusted); + } else { + cursor.setCharFormat(cf); + } + cursor.insertText(text); +} + +static void +refresh_topic(const std::vector<BufferLineItem> &topic) +{ + QTextDocument doc; + QTextCursor cursor(&doc); + for (const auto &it : topic) { + cursor.setCharFormat(it.format); + cursor.insertText(it.text); + } + g.wTopic->setText(doc.toHtml()); +} + +static void +refresh_buffer_list_item(QListWidgetItem *item, const Buffer &b) +{ + auto text = b.buffer_name; + QFont font; + QBrush color; + if (b.buffer_name != g.buffer_current && b.new_messages) { + text += " (" + QString::number(b.new_messages) + ")"; + font.setBold(true); + } + if (b.highlighted) + color = QColor(0xff, 0x5f, 0x00); + + item->setForeground(color); + item->setText(text); + item->setFont(font); +} + +static void +refresh_buffer_list() +{ + for (size_t i = 0; i < g.buffers.size(); i++) + refresh_buffer_list_item(g.wBufferList->item(i), g.buffers.at(i)); +} + +static QString +server_state_to_string(Relay::ServerState state) +{ + switch (state) { + case Relay::ServerState::DISCONNECTED: return "disconnected"; + case Relay::ServerState::CONNECTING: return "connecting"; + case Relay::ServerState::CONNECTED: return "connected"; + case Relay::ServerState::REGISTERED: return "registered"; + case Relay::ServerState::DISCONNECTING: return "disconnecting"; + } + return {}; +} + +static void +refresh_prompt() +{ + QString prompt; + auto b = buffer_by_name(g.buffer_current); + if (!b) { + prompt = "Synchronizing..."; + } else if (auto server = g.servers.find(b->server_name); + server != g.servers.end()) { + prompt = server->second.user; + if (!server->second.user_modes.isEmpty()) + prompt += "(" + server->second.user_modes + ")"; + if (prompt.isEmpty()) + prompt = "(" + server_state_to_string(server->second.state) + ")"; + } + g.wPrompt->setText(prompt); +} + +static void +refresh_status() +{ + g.wButtonDown->setEnabled(!buffer_at_bottom()); + + QString status = g.buffer_current; + if (auto b = buffer_by_name(g.buffer_current)) { + if (!b->modes.isEmpty()) + status += "(+" + b->modes + ")"; + if (b->hide_unimportant) + status += "<H>"; + } + + // Buffer scrolling would cause a ton of flickering redraws. + if (g.wStatus->text() != status) + g.wStatus->setText(status); +} + +static void +recheck_highlighted() +{ + // Corresponds to the logic toggling the bool on. + auto b = buffer_by_name(g.buffer_current); + if (b && b->highlighted && buffer_at_bottom() && + !g.wMain->isMinimized() && !g.wLog->isVisible()) { + b->highlighted = false; + refresh_icon(); + refresh_buffer_list(); + } +} + +// --- Buffer actions ---------------------------------------------------------- + +static void +buffer_activate(const QString &name) +{ + auto activate = new Relay::CommandData_BufferActivate(); + activate->buffer_name = name.toStdWString(); + relay_send(activate); +} + +static void +buffer_toggle_unimportant(const QString &name) +{ + auto toggle = new Relay::CommandData_BufferToggleUnimportant(); + toggle->buffer_name = name.toStdWString(); + relay_send(toggle); +} + +// FIXME: This works on the wrong level, we should take a vector and output +// a filtered vector--we must disregard individual items during URL matching. +static void +convert_links(const QTextCharFormat &format, const QString &text, + std::vector<BufferLineItem> &result) +{ + static QRegularExpression link_re( + R"(https?://)" + R"((?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+)" + R"((?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\)))"); + + qsizetype end = 0; + for (const QRegularExpressionMatch &m : link_re.globalMatch(text)) { + if (end < m.capturedStart()) { + result.emplace_back(BufferLineItem{ + format, text.sliced(end, m.capturedStart() - end)}); + } + + BufferLineItem item{format, m.captured()}; + item.format.setAnchor(true); + item.format.setAnchorHref(m.captured()); + result.emplace_back(std::move(item)); + + end = m.capturedEnd(); + } + if (!end) + result.emplace_back(BufferLineItem{format, text}); + else if (end < text.length()) + result.emplace_back(BufferLineItem{format, text.sliced(end)}); +} + +static void +buffer_toggle_log( + const std::wstring &error, const Relay::ResponseData_BufferLog *response) +{ + if (!response) { + show_error_message(QString::fromStdWString(error)); + return; + } + + std::wstring log; + if (!LibertyXDR::utf8_to_wstring( + response->log.data(), response->log.size(), log)) { + show_error_message("Invalid encoding."); + return; + } + + std::vector<BufferLineItem> linkified; + convert_links({}, QString::fromStdWString(log), linkified); + + g.wButtonLog->setChecked(true); + g.wLog->setText({}); + for (const auto &it : linkified) + textedit_replacesel(g.wLog, it.format, it.text); + g.wStack->setCurrentWidget(g.wLog); + + // This triggers a relayout of some kind. + auto cursor = g.wLog->textCursor(); + cursor.movePosition(QTextCursor::End); + g.wLog->setTextCursor(cursor); + + auto sb = g.wLog->verticalScrollBar(); + sb->setValue(sb->maximum()); +} + +static void +buffer_toggle_log() +{ + if (g.wLog->isVisible()) { + g.wStack->setCurrentWidget(g.wBuffer); + g.wLog->setText(""); + g.wButtonLog->setChecked(false); + + recheck_highlighted(); + return; + } + + auto log = new Relay::CommandData_BufferLog(); + log->buffer_name = g.buffer_current.toStdWString(); + relay_send(log, [name = g.buffer_current](auto error, auto response) { + if (g.buffer_current != name) + return; + buffer_toggle_log(error, + dynamic_cast<const Relay::ResponseData_BufferLog *>(response)); + }); +} + +// --- QTextEdit formatting ---------------------------------------------------- + +static QString +rich_text_to_irc(QTextEdit *textEdit) +{ + QString irc; + for (auto block = textEdit->document()->begin(); + block.isValid(); block = block.next()) { + for (auto it = block.begin(); it != block.end(); ++it) { + auto fragment = it.fragment(); + if (!fragment.isValid()) + continue; + + // TODO(p): Colours. + QString toggles; + auto format = fragment.charFormat(); + if (format.fontWeight() >= QFont::Bold) + toggles += "\x02"; + if (format.fontFixedPitch()) + toggles += "\x11"; + if (format.fontItalic()) + toggles += "\x1d"; + if (format.fontStrikeOut()) + toggles += "\x1e"; + if (format.fontUnderline()) + toggles += "\x1f"; + irc += toggles + fragment.text() + toggles; + } + if (block.next().isValid()) + irc += "\n"; + } + return irc; +} + +static QString +irc_to_rich_text(const QString &irc) +{ + QTextDocument doc; + QTextCursor cursor(&doc); + QTextCharFormat cf; + bool bold = false, monospace = false, italic = false, crossed = false, + underline = false; + + QString current; + auto apply = [&]() { + if (!current.isEmpty()) { + cursor.insertText(current, cf); + current.clear(); + } + }; + + for (int i = 0; i < irc.length(); ++i) { + switch (irc[i].unicode()) { + case '\x02': + apply(); + bold = !bold; + cf.setFontWeight(bold ? QFont::Bold : QFont::Normal); + break; + case '\x03': + // TODO(p): Decode colours, see xC. + break; + case '\x11': + apply(); + cf.setFontFixedPitch((monospace = !monospace)); + break; + case '\x1d': + apply(); + cf.setFontItalic((italic = !italic)); + break; + case '\x1e': + apply(); + cf.setFontItalic((crossed = !crossed)); + break; + case '\x1f': + apply(); + cf.setFontUnderline((underline = !underline)); + break; + case '\x0f': + apply(); + bold = monospace = italic = crossed = underline = false; + cf = QTextCharFormat(); + break; + default: + current += irc[i]; + } + } + apply(); + return doc.toHtml(); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static QBrush +convert_color(int16_t color) +{ + static const uint16_t base16[] = { + 0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc, + 0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff, + }; + if (color < 16) { + uint8_t r = 0xf & (base16[color] >> 8); + uint8_t g = 0xf & (base16[color] >> 4); + uint8_t b = 0xf & (base16[color]); + return QColor(r * 0x11, g * 0x11, b * 0x11); + } + if (color >= 216) { + uint8_t g = 8 + (color - 216) * 10; + return QColor(g, g, g); + } + + uint8_t i = color - 16, r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6; + return QColor( + !r ? 0 : 55 + 40 * r, + !g ? 0 : 55 + 40 * g, + !b ? 0 : 55 + 40 * b); +} + +static void +convert_item_formatting( + Relay::ItemData *item, QTextCharFormat &cf, bool &inverse) +{ + if (dynamic_cast<Relay::ItemData_Reset *>(item)) { + cf = QTextCharFormat(); + inverse = false; + } else if (dynamic_cast<Relay::ItemData_FlipBold *>(item)) { + if (cf.fontWeight() <= QFont::Normal) + cf.setFontWeight(QFont::Bold); + else + cf.setFontWeight(QFont::Normal); + } else if (dynamic_cast<Relay::ItemData_FlipItalic *>(item)) { + cf.setFontItalic(!cf.fontItalic()); + } else if (dynamic_cast<Relay::ItemData_FlipUnderline *>(item)) { + cf.setFontUnderline(!cf.fontUnderline()); + } else if (dynamic_cast<Relay::ItemData_FlipCrossedOut *>(item)) { + cf.setFontStrikeOut(!cf.fontStrikeOut()); + } else if (dynamic_cast<Relay::ItemData_FlipInverse *>(item)) { + inverse = !inverse; + } else if (dynamic_cast<Relay::ItemData_FlipMonospace *>(item)) { + cf.setFontFixedPitch(!cf.fontFixedPitch()); + } else if (auto data = dynamic_cast<Relay::ItemData_FgColor *>(item)) { + if (data->color < 0) { + cf.clearForeground(); + } else { + cf.setForeground(convert_color(data->color)); + } + } else if (auto data = dynamic_cast<Relay::ItemData_BgColor *>(item)) { + if (data->color < 0) { + cf.clearBackground(); + } else { + cf.setBackground(convert_color(data->color)); + } + } +} + +static std::vector<BufferLineItem> +convert_items(const std::vector<std::unique_ptr<Relay::ItemData>> &items) +{ + QTextCharFormat cf; + std::vector<BufferLineItem> result; + bool inverse = false; + for (const auto &it : items) { + auto text = dynamic_cast<Relay::ItemData_Text *>(it.get()); + if (!text) { + convert_item_formatting(it.get(), cf, inverse); + continue; + } + + auto item_format = cf; + auto item_text = QString::fromStdWString(text->text); + if (inverse) { + auto fg = item_format.foreground(); + auto bg = item_format.background(); + item_format.setBackground(fg); + item_format.setForeground(bg); + } + convert_links(item_format, item_text, result); + } + return result; +} + +// --- Buffer output ----------------------------------------------------------- + +static BufferLine +convert_buffer_line(Relay::EventData_BufferLine &line) +{ + BufferLine self = {}; + self.items = convert_items(line.items); + self.is_unimportant = line.is_unimportant; + self.is_highlight = line.is_highlight; + self.rendition = line.rendition; + self.when = line.when; + return self; +} + +static void +buffer_print_date_change( + bool &sameline, const QDateTime &last, const QDateTime ¤t) +{ + if (last.date() == current.date()) + return; + + auto timestamp = current.toString(&"\n"[sameline] + + QLocale::system().dateFormat(QLocale::ShortFormat)); + sameline = false; + + QTextCharFormat cf; + cf.setFontWeight(QFont::Bold); + textedit_replacesel(g.wBuffer, cf, timestamp); +} + +static bool +buffer_reset_selection() +{ + auto sb = g.wBuffer->verticalScrollBar(); + auto value = sb->value(); + g.wBuffer->moveCursor(QTextCursor::End); + sb->setValue(value); + return g.wBuffer->textCursor().atBlockStart(); +} + +static void +buffer_print_and_watch_trailing_date_changes() +{ + auto current = QDateTime::currentDateTime(); + auto b = buffer_by_name(g.buffer_current); + if (b && !b->lines.empty()) { + auto last = QDateTime::fromMSecsSinceEpoch(b->lines.back().when); + bool sameline = buffer_reset_selection(); + buffer_print_date_change(sameline, last, current); + } + + QDateTime midnight(current.date().addDays(1), {}); + if (midnight < current) + return; + + // Note that after printing the first trailing update, + // follow-up updates may be duplicated if timer events arrive too early. + g.date_change_timer->start(current.msecsTo(midnight) + 1); +} + +static void +buffer_print_line(std::vector<BufferLine>::const_iterator begin, + std::vector<BufferLine>::const_iterator line) +{ + auto current = QDateTime::fromMSecsSinceEpoch(line->when); + auto last = line == begin ? QDateTime::currentDateTime() + : QDateTime::fromMSecsSinceEpoch((line - 1)->when); + + bool sameline = buffer_reset_selection(); + buffer_print_date_change(sameline, last, current); + + auto timestamp = current.toString(&"\nHH:mm:ss"[sameline]); + sameline = false; + + QTextCharFormat cf; + cf.setForeground(QColor(0xbb, 0xbb, 0xbb)); + cf.setBackground(QColor(0xf8, 0xf8, 0xf8)); + textedit_replacesel(g.wBuffer, cf, timestamp); + cf = QTextCharFormat(); + textedit_replacesel(g.wBuffer, cf, " "); + + // Tabstops won't quite help us here, since we need it centred. + QString prefix; + QTextCharFormat pcf; + pcf.setFontFixedPitch(true); + pcf.setFontWeight(QFont::Bold); + switch (line->rendition) { + break; case Relay::Rendition::BARE: + break; case Relay::Rendition::INDENT: + prefix = " "; + break; case Relay::Rendition::STATUS: + prefix = " - "; + break; case Relay::Rendition::ERROR: + prefix = "=!= "; + pcf.setForeground(QColor(0xff, 0, 0)); + break; case Relay::Rendition::JOIN: + prefix = "——> "; + pcf.setForeground(QColor(0, 0x88, 0)); + break; case Relay::Rendition::PART: + prefix = "<—— "; + pcf.setForeground(QColor(0x88, 0, 0)); + break; case Relay::Rendition::ACTION: + prefix = " * "; + pcf.setForeground(QColor(0x88, 0, 0)); + } + + if (line->leaked) { + auto color = g.wBuffer->palette().color( + QPalette::Disabled, QPalette::Text); + pcf.setForeground(color); + if (!prefix.isEmpty()) { + textedit_replacesel(g.wBuffer, pcf, prefix); + } + + for (auto it : line->items) { + it.format.setForeground(color); + it.format.clearBackground(); + textedit_replacesel(g.wBuffer, it.format, it.text); + } + } else { + if (!prefix.isEmpty()) + textedit_replacesel(g.wBuffer, pcf, prefix); + for (const auto &it : line->items) + textedit_replacesel(g.wBuffer, it.format, it.text); + } +} + +static void +buffer_print_separator() +{ + buffer_reset_selection(); + + QTextFrameFormat ff; + ff.setBackground(QColor(0xff, 0x5f, 0x00)); + ff.setHeight(1); + // FIXME: When the current frame was empty, this seems to add a newline. + g.wBuffer->textCursor().insertFrame(ff); +} + +static void +refresh_buffer(const Buffer &b) +{ + g.wBuffer->clear(); + + size_t i = 0, mark_before = b.lines.size() - + b.new_messages - b.new_unimportant_messages; + for (auto line = b.lines.begin(); line != b.lines.end(); ++line) { + if (i == mark_before) + buffer_print_separator(); + if (!line->is_unimportant || !b.hide_unimportant) + buffer_print_line(b.lines.begin(), line); + + i++; + } + + buffer_print_and_watch_trailing_date_changes(); + buffer_scroll_to_bottom(); + // TODO(p): recheck_highlighted() here, or do we handle enough signals? +} + +// --- Event processing -------------------------------------------------------- + +static void +relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m) +{ + // Initial sync: skip all other processing, let highlights be. + auto bc = buffer_by_name(g.buffer_current); + if (!bc) { + b.lines.push_back(convert_buffer_line(m)); + return; + } + + // Retained mode is complicated. + bool display = (!m.is_unimportant || !bc->hide_unimportant) && + (b.buffer_name == g.buffer_current || m.leak_to_active); + bool to_bottom = display && buffer_at_bottom(); + bool visible = display && + to_bottom && + !g.wMain->isMinimized() && + !g.wLog->isVisible(); + bool separate = display && + !visible && !bc->new_messages && !bc->new_unimportant_messages; + + auto line = b.lines.insert(b.lines.end(), convert_buffer_line(m)); + if (!(visible || m.leak_to_active) || + b.new_messages || b.new_unimportant_messages) { + if (line->is_unimportant || m.leak_to_active) + b.new_unimportant_messages++; + else + b.new_messages++; + } + + if (m.leak_to_active) { + auto line = bc->lines.insert(bc->lines.end(), convert_buffer_line(m)); + line->leaked = true; + if (!visible || bc->new_messages || bc->new_unimportant_messages) { + if (line->is_unimportant) + bc->new_unimportant_messages++; + else + bc->new_messages++; + } + } + if (separate) + buffer_print_separator(); + if (display) + buffer_print_line(bc->lines.begin(), bc->lines.end() - 1); + if (to_bottom) + buffer_scroll_to_bottom(); + + if (line->is_highlight || (!visible && !line->is_unimportant && + b.kind == Relay::BufferKind::PRIVATE_MESSAGE)) { + beep(); + + if (!visible) { + b.highlighted = true; + refresh_icon(); + } + } + + refresh_buffer_list(); +} + +static void +relay_process_callbacks(uint32_t command_seq, + const std::wstring& error, const Relay::ResponseData *response) +{ + auto &callbacks = g.command_callbacks; + auto handler = callbacks.find(command_seq); + if (handler == callbacks.end()) { + // TODO(p): Warn about an unawaited response. + } else { + if (handler->second) + handler->second(error, response); + callbacks.erase(handler); + } + + // We don't particularly care about wraparound issues. + while (!callbacks.empty() && callbacks.begin()->first <= command_seq) { + auto front = callbacks.begin(); + if (front->second) + front->second(L"No response", nullptr); + callbacks.erase(front); + } +} + +static void +relay_process_message(const Relay::EventMessage &m) +{ + switch (m.data->event) { + case Relay::Event::ERROR: + { + auto data = dynamic_cast<Relay::EventData_Error *>(m.data.get()); + relay_process_callbacks(data->command_seq, data->error, nullptr); + break; + } + case Relay::Event::RESPONSE: + { + auto data = dynamic_cast<Relay::EventData_Response *>(m.data.get()); + relay_process_callbacks(data->command_seq, {}, data->data.get()); + break; + } + + case Relay::Event::PING: + { + auto pong = new Relay::CommandData_PingResponse(); + pong->event_seq = m.event_seq; + relay_send(pong); + break; + } + + case Relay::Event::BUFFER_LINE: + { + auto &data = dynamic_cast<Relay::EventData_BufferLine &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + relay_process_buffer_line(*b, data); + break; + } + case Relay::Event::BUFFER_UPDATE: + { + auto &data = dynamic_cast<Relay::EventData_BufferUpdate &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) { + b = &*g.buffers.insert(g.buffers.end(), Buffer()); + b->buffer_name = QString::fromStdWString(data.buffer_name); + + auto item = new QListWidgetItem; + refresh_buffer_list_item(item, *b); + g.wBufferList->addItem(item); + } + + bool hiding_toggled = b->hide_unimportant != data.hide_unimportant; + b->hide_unimportant = data.hide_unimportant; + b->kind = data.context->kind; + b->server_name.clear(); + if (auto context = dynamic_cast<Relay::BufferContext_Server *>( + data.context.get())) + b->server_name = QString::fromStdWString(context->server_name); + if (auto context = dynamic_cast<Relay::BufferContext_Channel *>( + data.context.get())) { + b->server_name = QString::fromStdWString(context->server_name); + b->modes = QString::fromStdWString(context->modes); + b->topic = convert_items(context->topic); + } + if (auto context = dynamic_cast<Relay::BufferContext_PrivateMessage *>( + data.context.get())) + b->server_name = QString::fromStdWString(context->server_name); + + if (b->buffer_name == g.buffer_current) { + refresh_topic(b->topic); + refresh_status(); + + if (hiding_toggled) + refresh_buffer(*b); + } + break; + } + case Relay::Event::BUFFER_STATS: + { + auto &data = dynamic_cast<Relay::EventData_BufferStats &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + b->new_messages = data.new_messages; + b->new_unimportant_messages = data.new_unimportant_messages; + b->highlighted = data.highlighted; + + refresh_icon(); + refresh_buffer_list(); + break; + } + case Relay::Event::BUFFER_RENAME: + { + auto &data = dynamic_cast<Relay::EventData_BufferRename &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + auto original = b->buffer_name; + b->buffer_name = QString::fromStdWString(data.new_); + + if (original == g.buffer_current) { + g.buffer_current = b->buffer_name; + refresh_status(); + } + refresh_buffer_list(); + if (original == g.buffer_last) + g.buffer_last = b->buffer_name; + break; + } + case Relay::Event::BUFFER_REMOVE: + { + auto &data = dynamic_cast<Relay::EventData_BufferRemove &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + int index = b - g.buffers.data(); + delete g.wBufferList->takeItem(index); + g.buffers.erase(g.buffers.begin() + index); + + refresh_icon(); + break; + } + case Relay::Event::BUFFER_ACTIVATE: + { + auto &data = dynamic_cast<Relay::EventData_BufferActivate &>(*m.data); + Buffer *old = buffer_by_name(g.buffer_current); + g.buffer_last = g.buffer_current; + g.buffer_current = QString::fromStdWString(data.buffer_name); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + if (old) { + old->new_messages = 0; + old->new_unimportant_messages = 0; + old->highlighted = false; + + old->input = g.wInput->toHtml(); + old->input_start = g.wInput->textCursor().selectionStart(); + old->input_end = g.wInput->textCursor().selectionEnd(); + + // Note that we effectively overwrite the newest line + // with the current textarea contents, and jump there. + old->history_at = old->history.size(); + } + + if (g.wLog->isVisible()) + buffer_toggle_log(); + if (!g.wMain->isMinimized()) + b->highlighted = false; + auto item = g.wBufferList->item(b - g.buffers.data()); + refresh_buffer_list_item(item, *b); + g.wBufferList->setCurrentItem(item); + + refresh_icon(); + refresh_topic(b->topic); + refresh_buffer(*b); + refresh_prompt(); + refresh_status(); + + g.wInput->setHtml(b->input); + g.wInput->textCursor().setPosition(b->input_start); + g.wInput->textCursor().setPosition( + b->input_end, QTextCursor::KeepAnchor); + g.wInput->setFocus(); + break; + } + case Relay::Event::BUFFER_INPUT: + { + auto &data = dynamic_cast<Relay::EventData_BufferInput &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + if (b->history_at == b->history.size()) + b->history_at++; + b->history.push_back( + irc_to_rich_text(QString::fromStdWString(data.text))); + break; + } + case Relay::Event::BUFFER_CLEAR: + { + auto &data = dynamic_cast<Relay::EventData_BufferClear &>(*m.data); + auto b = buffer_by_name(data.buffer_name); + if (!b) + break; + + b->lines.clear(); + if (b->buffer_name == g.buffer_current) + refresh_buffer(*b); + break; + } + + case Relay::Event::SERVER_UPDATE: + { + auto &data = dynamic_cast<Relay::EventData_ServerUpdate &>(*m.data); + auto name = QString::fromStdWString(data.server_name); + if (!g.servers.count(name)) + g.servers.emplace(name, Server()); + + auto &server = g.servers.at(name); + server.state = data.data->state; + + server.user.clear(); + server.user_modes.clear(); + if (auto registered = dynamic_cast<Relay::ServerData_Registered *>( + data.data.get())) { + server.user = QString::fromStdWString(registered->user); + server.user_modes = QString::fromStdWString(registered->user_modes); + } + + refresh_prompt(); + break; + } + case Relay::Event::SERVER_RENAME: + { + auto &data = dynamic_cast<Relay::EventData_ServerRename &>(*m.data); + auto original = QString::fromStdWString(data.server_name); + g.servers.insert_or_assign( + QString::fromStdWString(data.new_), g.servers.at(original)); + g.servers.erase(original); + break; + } + case Relay::Event::SERVER_REMOVE: + { + auto &data = dynamic_cast<Relay::EventData_ServerRemove &>(*m.data); + auto name = QString::fromStdWString(data.server_name); + g.servers.erase(name); + break; + } + } +} + +// --- Networking -------------------------------------------------------------- + +static void +relay_show_dialog() +{ + g.wConnectHost->setText(g.host); + g.wConnectPort->setText(g.port); + g.wConnectDialog->move( + g.wMain->frameGeometry().center() - g.wConnectDialog->rect().center()); + switch (g.wConnectDialog->exec()) { + case QDialog::Accepted: + g.host = g.wConnectHost->text(); + g.port = g.wConnectPort->text(); + g.socket->connectToHost(g.host, g.port.toUShort()); + break; + case QDialog::Rejected: + QCoreApplication::exit(); + } +} + +static void +relay_process_error([[maybe_unused]] QAbstractSocket::SocketError err) +{ + show_error_message(g.socket->errorString()); + g.socket->abort(); + QTimer::singleShot(0, relay_show_dialog); +} + +static void +relay_process_connected() +{ + g.command_seq = 0; + g.command_callbacks.clear(); + + g.buffers.clear(); + g.buffer_current.clear(); + g.buffer_last.clear(); + g.servers.clear(); + + refresh_icon(); + refresh_topic({}); + g.wBufferList->clear(); + g.wBuffer->clear(); + refresh_prompt(); + refresh_status(); + + auto hello = new Relay::CommandData_Hello(); + hello->version = Relay::VERSION; + relay_send(hello); +} + +static bool +relay_process_buffer(QString &error) +{ + // How I wish I could access the internal read buffer directly. + auto s = g.socket; + union { + uint32_t frame_len = 0; + char buf[sizeof frame_len]; + }; + while (s->peek(buf, sizeof buf) == sizeof buf) { + frame_len = qFromBigEndian(frame_len); + if (s->bytesAvailable() < qint64(sizeof frame_len + frame_len)) + break; + + s->skip(sizeof frame_len); + auto b = s->read(frame_len); + LibertyXDR::Reader r; + r.data = reinterpret_cast<const uint8_t *>(b.data()); + r.length = b.size(); + + Relay::EventMessage m = {}; + if (!m.deserialize(r) || r.length) { + error = "Deserialization failed."; + return false; + } + + relay_process_message(m); + } + return true; +} + +static void +relay_process_ready() +{ + QString err; + if (!relay_process_buffer(err)) { + show_error_message(err); + g.socket->abort(); + QTimer::singleShot(0, relay_show_dialog); + } +} + +// --- Input line -------------------------------------------------------------- + +static void +input_set_contents(const QString &input) +{ + g.wInput->setHtml(input); + + auto cursor = g.wInput->textCursor(); + cursor.movePosition(QTextCursor::End); + g.wInput->setTextCursor(cursor); + g.wInput->ensureCursorVisible(); +} + +static bool +input_submit() +{ + auto b = buffer_by_name(g.buffer_current); + if (!b) + return false; + + auto input = new Relay::CommandData_BufferInput(); + input->buffer_name = b->buffer_name.toStdWString(); + input->text = rich_text_to_irc(g.wInput).toStdWString(); + + // Buffer::history[Buffer::history.size()] 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.push_back(g.wInput->toHtml()); + b->history_at = b->history.size(); + input_set_contents(""); + + relay_send(input); + return true; +} + +struct InputStamp { + int start = {}; + int end = {}; + QString input; +}; + +static InputStamp +input_stamp() +{ + // Hopefully, the selection markers match the plain text characters. + auto start = g.wInput->textCursor().selectionStart(); + auto end = g.wInput->textCursor().selectionEnd(); + return {start, end, g.wInput->toPlainText()}; +} + +static void +input_complete(const InputStamp &state, const std::wstring &error, + const Relay::ResponseData_BufferComplete *response) +{ + if (!response) { + show_error_message(QString::fromStdWString(error)); + return; + } + + auto utf8 = state.input.sliced(0, state.start).toUtf8(); + auto preceding = QString(utf8.sliced(0, response->start)); + if (response->completions.size() > 0) { + auto insert = response->completions.at(0); + if (response->completions.size() == 1) + insert += L" "; + + auto cursor = g.wInput->textCursor(); + cursor.setPosition(preceding.length()); + cursor.setPosition(state.end, QTextCursor::KeepAnchor); + cursor.insertHtml(irc_to_rich_text(QString::fromStdWString(insert))); + } + + if (response->completions.size() != 1) + beep(); + + // TODO(p): Show all completion options. +} + +static bool +input_complete() +{ + // TODO(p): Also add an increasing counter to the stamp. + auto state = input_stamp(); + if (state.start != state.end) + return false; + + auto utf8 = state.input.sliced(0, state.start).toUtf8(); + auto complete = new Relay::CommandData_BufferComplete(); + complete->buffer_name = g.buffer_current.toStdWString(); + complete->text = state.input.toStdWString(); + complete->position = utf8.size(); + relay_send(complete, [state](auto error, auto response) { + auto stamp = input_stamp(); + if (std::make_tuple(stamp.start, stamp.end, stamp.input) != + std::make_tuple(state.start, state.end, state.input)) + return; + input_complete(stamp, error, + dynamic_cast<const Relay::ResponseData_BufferComplete *>(response)); + }); + return true; +} + +static bool +input_up() +{ + auto b = buffer_by_name(g.buffer_current); + if (!b || b->history_at < 1) + return false; + + if (b->history_at == b->history.size()) + b->input = g.wInput->toHtml(); + input_set_contents(b->history.at(--b->history_at)); + return true; +} + +static bool +input_down() +{ + auto b = buffer_by_name(g.buffer_current); + if (!b || b->history_at >= b->history.size()) + return false; + + input_set_contents(++b->history_at == b->history.size() + ? b->input + : b->history.at(b->history_at)); + return true; +} + +class InputEdit : public QTextEdit { + Q_OBJECT + +public: + explicit InputEdit(QWidget *parent = nullptr) : QTextEdit(parent) {} + + void keyPressEvent(QKeyEvent *event) override + { + auto scrollable = g.wLog->isVisible() + ? g.wLog->verticalScrollBar() + : g.wBuffer->verticalScrollBar(); + + QKeyCombination combo( + event->modifiers() & ~Qt::KeypadModifier, Qt::Key(event->key())); + switch (combo.toCombined()) { + case Qt::Key_Return: + case Qt::Key_Enter: + input_submit(); + break; + case QKeyCombination(Qt::ShiftModifier, Qt::Key_Return).toCombined(): + case QKeyCombination(Qt::ShiftModifier, Qt::Key_Enter).toCombined(): + // Qt amazingly inserts U+2028 LINE SEPARATOR instead. + this->textCursor().insertText("\n"); + break; + case Qt::Key_Tab: + input_complete(); + break; + case QKeyCombination(Qt::AltModifier, Qt::Key_P).toCombined(): + case Qt::Key_Up: + input_up(); + break; + case QKeyCombination(Qt::AltModifier, Qt::Key_N).toCombined(): + case Qt::Key_Down: + input_down(); + break; + case Qt::Key_PageUp: + scrollable->setValue(scrollable->value() - scrollable->pageStep()); + break; + case Qt::Key_PageDown: + scrollable->setValue(scrollable->value() + scrollable->pageStep()); + break; + + default: + QTextEdit::keyPressEvent(event); + return; + } + event->accept(); + } +}; + +// --- General UI -------------------------------------------------------------- + +class BufferEdit : public QTextBrowser { + Q_OBJECT + +public: + explicit BufferEdit(QWidget *parent = nullptr) : QTextBrowser(parent) {} + + void resizeEvent(QResizeEvent *event) override + { + bool to_bottom = buffer_at_bottom(); + QTextBrowser::resizeEvent(event); + if (to_bottom) { + buffer_scroll_to_bottom(); + } else { + recheck_highlighted(); + refresh_status(); + } + } +}; + +static void +build_main_window() +{ + g.wMain = new QMainWindow; + refresh_icon(); + + auto central = new QWidget(g.wMain); + auto vbox = new QVBoxLayout(central); + vbox->setContentsMargins(4, 4, 4, 4); + + g.wTopic = new QLabel(central); + g.wTopic->setTextFormat(Qt::RichText); + vbox->addWidget(g.wTopic); + + auto splitter = new QSplitter(Qt::Horizontal, central); + splitter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + g.wBufferList = new QListWidget(splitter); + g.wBufferList->setSizePolicy( + QSizePolicy::Preferred, QSizePolicy::Expanding); + QObject::connect(g.wBufferList, &QListWidget::currentRowChanged, + [](int row) { + if (row >= 0 && (size_t) row < g.buffers.size()) + buffer_activate(g.buffers.at(row).buffer_name); + }); + + g.wStack = new QStackedWidget(splitter); + + g.wBuffer = new BufferEdit(g.wStack); + g.wBuffer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + g.wBuffer->setReadOnly(true); + g.wBuffer->setTextInteractionFlags( + Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard | + Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard); + g.wBuffer->setOpenExternalLinks(true); + QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::valueChanged, + []([[maybe_unused]] int value) { + recheck_highlighted(); + refresh_status(); + }); + QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::rangeChanged, + []([[maybe_unused]] int min, [[maybe_unused]] int max) { + recheck_highlighted(); + refresh_status(); + }); + + g.wLog = new QTextBrowser(g.wStack); + g.wLog->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + g.wLog->setReadOnly(true); + g.wLog->setTextInteractionFlags( + Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard | + Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard); + g.wLog->setOpenExternalLinks(true); + + g.wStack->addWidget(g.wBuffer); + g.wStack->addWidget(g.wLog); + + splitter->addWidget(g.wBufferList); + splitter->setStretchFactor(0, 1); + splitter->addWidget(g.wStack); + splitter->setStretchFactor(1, 2); + vbox->addWidget(splitter); + + auto hbox = new QHBoxLayout(); + g.wPrompt = new QLabel(central); + hbox->addWidget(g.wPrompt); + + g.wButtonB = new QToolButton(central); + g.wButtonB->setText("&B"); + g.wButtonB->setCheckable(true); + hbox->addWidget(g.wButtonB); + g.wButtonI = new QToolButton(central); + g.wButtonI->setText("&I"); + g.wButtonI->setCheckable(true); + hbox->addWidget(g.wButtonI); + g.wButtonU = new QToolButton(central); + g.wButtonU->setText("&U"); + g.wButtonU->setCheckable(true); + hbox->addWidget(g.wButtonU); + + g.wStatus = new QLabel(central); + g.wStatus->setAlignment( + Qt::AlignRight | Qt::AlignTrailing | Qt::AlignVCenter); + hbox->addWidget(g.wStatus); + + g.wButtonLog = new QToolButton(central); + g.wButtonLog->setText("&Log"); + g.wButtonLog->setCheckable(true); + QObject::connect(g.wButtonLog, &QToolButton::clicked, + []([[maybe_unused]] bool checked) { buffer_toggle_log(); }); + hbox->addWidget(g.wButtonLog); + + g.wButtonDown = new QToolButton(central); + g.wButtonDown->setIcon( + QApplication::style()->standardIcon(QStyle::SP_ArrowDown)); + g.wButtonDown->setToolButtonStyle(Qt::ToolButtonIconOnly); + QObject::connect(g.wButtonDown, &QToolButton::clicked, + []([[maybe_unused]] bool checked) { buffer_scroll_to_bottom(); }); + hbox->addWidget(g.wButtonDown); + vbox->addLayout(hbox); + + g.wInput = new InputEdit(central); + g.wInput->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + g.wInput->setMaximumHeight(50); + vbox->addWidget(g.wInput); + + // TODO(p): Figure out why this is not reliable. + QObject::connect(g.wInput, &QTextEdit::currentCharFormatChanged, + [](const QTextCharFormat &format) { + g.wButtonB->setChecked(format.fontWeight() >= QFont::Bold); + g.wButtonI->setChecked(format.fontItalic()); + g.wButtonU->setChecked(format.fontUnderline()); + }); + + QObject::connect(g.wButtonB, &QToolButton::clicked, + [](bool checked) { + auto cursor = g.wInput->textCursor(); + auto format = cursor.charFormat(); + format.setFontWeight(checked ? QFont::Bold : QFont::Normal); + cursor.mergeCharFormat(format); + g.wInput->setTextCursor(cursor); + }); + QObject::connect(g.wButtonI, &QToolButton::clicked, + [](bool checked) { + auto cursor = g.wInput->textCursor(); + auto format = cursor.charFormat(); + format.setFontItalic(checked); + cursor.mergeCharFormat(format); + g.wInput->setTextCursor(cursor); + }); + QObject::connect(g.wButtonU, &QToolButton::clicked, + [](bool checked) { + auto cursor = g.wInput->textCursor(); + auto format = cursor.charFormat(); + format.setFontUnderline(checked); + cursor.mergeCharFormat(format); + g.wInput->setTextCursor(cursor); + }); + + central->setLayout(vbox); + g.wMain->setCentralWidget(central); + g.wMain->show(); +} + +static void +build_connect_dialog() +{ + auto dialog = g.wConnectDialog = new QDialog(g.wMain); + dialog->setModal(true); + dialog->setWindowTitle("Connect to relay"); + + auto layout = new QFormLayout(); + g.wConnectHost = new QLineEdit(dialog); + layout->addRow("&Host:", g.wConnectHost); + g.wConnectPort = new QLineEdit(dialog); + auto validator = new QIntValidator(0, 0xffff, g.wConnectDialog); + g.wConnectPort->setValidator(validator); + layout->addRow("&Port:", g.wConnectPort); + + auto buttons = new QDialogButtonBox(dialog); + buttons->addButton(new QPushButton("&Connect", buttons), + QDialogButtonBox::AcceptRole); + buttons->addButton(new QPushButton("&Exit", buttons), + QDialogButtonBox::RejectRole); + QObject::connect(buttons, &QDialogButtonBox::accepted, + dialog, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, + dialog, &QDialog::reject); + + auto vbox = new QVBoxLayout(); + vbox->addLayout(layout); + vbox->addWidget(buttons); + dialog->setLayout(vbox); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static std::vector<size_t> +rotated_buffers() +{ + std::vector<size_t> rotated(g.buffers.size()); + size_t start = 0; + for (auto it = g.buffers.begin(); it != g.buffers.end(); ++it) + if (it->buffer_name == g.buffer_current) { + start = it - g.buffers.begin(); + break; + } + for (auto &index : rotated) + index = ++start % g.buffers.size(); + return rotated; +} + +static void +bind_shortcuts() +{ + auto previous_buffer = [] { + auto rotated = rotated_buffers(); + if (rotated.size() > 0) { + size_t i = (rotated.back() ? rotated.back() : g.buffers.size()) - 1; + buffer_activate(g.buffers[i].buffer_name); + } + }; + auto next_buffer = [] { + auto rotated = rotated_buffers(); + if (rotated.size() > 0) + buffer_activate(g.buffers[rotated.front()].buffer_name); + }; + auto switch_buffer = [] { + if (auto b = buffer_by_name(g.buffer_last)) + buffer_activate(b->buffer_name); + }; + auto goto_highlight = [] { + for (auto i : rotated_buffers()) + if (g.buffers[i].highlighted) { + buffer_activate(g.buffers[i].buffer_name); + break; + } + }; + auto goto_activity = [] { + for (auto i : rotated_buffers()) + if (g.buffers[i].new_messages) { + buffer_activate(g.buffers[i].buffer_name); + break; + } + }; + auto toggle_unimportant = [] { + if (auto b = buffer_by_name(g.buffer_current)) + buffer_toggle_unimportant(b->buffer_name); + }; + + new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_Tab), + g.wMain, switch_buffer); + new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Tab), + g.wMain, switch_buffer); + + new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F5), + g.wMain, previous_buffer); + new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageUp), + g.wMain, previous_buffer); + new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageUp), + g.wMain, previous_buffer); + new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F6), + g.wMain, next_buffer); + new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageDown), + g.wMain, next_buffer); + new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageDown), + g.wMain, next_buffer); + + new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_A), + g.wMain, goto_activity); + new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Exclam), + g.wMain, goto_highlight); + new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_H), + g.wMain, toggle_unimportant); +} + +int +main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + auto args = app.arguments(); + if (args.size() != 1 && args.size() != 3) { + QMessageBox::critical(nullptr, "Error", "Usage: xT [HOST PORT]", + QMessageBox::Close); + return 1; + } + + build_main_window(); + build_connect_dialog(); + bind_shortcuts(); + + g.date_change_timer = new QTimer(g.wMain); + g.date_change_timer->setSingleShot(true); + QObject::connect(g.date_change_timer, &QTimer::timeout, [] { + bool to_bottom = buffer_at_bottom(); + buffer_print_and_watch_trailing_date_changes(); + if (to_bottom) + buffer_scroll_to_bottom(); + }); + + g.socket = new QTcpSocket(g.wMain); + QObject::connect(g.socket, &QTcpSocket::errorOccurred, + relay_process_error); + QObject::connect(g.socket, &QTcpSocket::connected, + relay_process_connected); + QObject::connect(g.socket, &QTcpSocket::readyRead, + relay_process_ready); + if (args.size() == 3) { + g.host = args[1]; + g.port = args[2]; + g.socket->connectToHost(g.host, g.port.toUShort()); + } else { + // Allow it to center on its parent, which must be realized. + while (!g.wMain->windowHandle()->isExposed()) + app.processEvents(); + QTimer::singleShot(0, relay_show_dialog); + } + + int result = app.exec(); + delete g.wMain; + return result; +} + +// Normally, QObjects should be placed in header files, which we don't do. +#include "xT.moc" diff --git a/xT/xT.desktop b/xT/xT.desktop new file mode 100644 index 0000000..eeae4fd --- /dev/null +++ b/xT/xT.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=xT +GenericName=IRC Client +Icon=xT +Exec=xT +StartupNotify=false +Categories=Network;Chat;IRCClient; diff --git a/xT/xT.svg b/xT/xT.svg new file mode 100644 index 0000000..0dd85bc --- /dev/null +++ b/xT/xT.svg @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg version="1.1" width="48" height="48" viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg"> + + <defs> + <radialGradient id="grey-x"> + <stop stop-color="hsl(66, 0%, 90%)" offset="0" /> + <stop stop-color="hsl(66, 0%, 80%)" offset="1" /> + </radialGradient> + <radialGradient id="orange"> + <stop stop-color="hsl(36, 100%, 60%)" offset="0" /> + <stop stop-color="hsl(23, 100%, 60%)" offset="1" /> + </radialGradient> + <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%"> + <feDropShadow dx="0" dy="0" stdDeviation="0.05" + flood-color="rgba(0, 0, 0, .5)" /> + </filter> + </defs> + + <!-- XXX: librsvg screws up shadows on rotated objects. --> + <g filter="url(#shadow)" transform="translate(24 28) rotate(-45) scale(16)"> + <path fill="url(#grey-x)" stroke="hsl(66, 0%, 30%)" stroke-width="0.1" + d="M-.25 -1 H.25 V-.25 H1 V.25 H.25 V1 H-.25 V.25 H-1 V-.25 H-.25 Z" /> + </g> + <g filter="url(#shadow)" transform="translate(24 3) scale(16)"> + <path fill="url(#orange)" stroke="hsl(36, 100%, 20%)" stroke-width="0.1" + d="M-.8 0 H.8 V.5 H.25 V2.625 H-.25 V.5 H-.8 Z" /> + </g> +</svg> diff --git a/xT/xTq.cpp b/xT/xTq.cpp new file mode 100644 index 0000000..a6d48bf --- /dev/null +++ b/xT/xTq.cpp @@ -0,0 +1,40 @@ +/* + * xTq.cpp: Qt Quick frontend for xC + * + * Copyright (c) 2024, 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. + * + */ + +#include "xC-proto.cpp" + +#include <cstdint> + +#include <QGuiApplication> +#include <QQmlApplicationEngine> + +#include "xTq.h" + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +int +main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + QQmlApplicationEngine engine; + QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, + &app, []() { QCoreApplication::exit(-1); }, Qt::QueuedConnection); + engine.loadFromModule("xTquick", "Main"); + return app.exec(); +} diff --git a/xT/xTq.h b/xT/xTq.h new file mode 100644 index 0000000..70a0374 --- /dev/null +++ b/xT/xTq.h @@ -0,0 +1,15 @@ +#ifndef XTQ_H +#define XTQ_H + +#include <QTcpSocket> +#include <QtQmlIntegration/qqmlintegration.h> + +class RelayConnection : public QObject { + Q_OBJECT + QML_ELEMENT + +public: + QTcpSocket *socket; ///< Buffered relay socket +}; + +#endif // XTQ_H diff --git a/xT/xTq.qml b/xT/xTq.qml new file mode 100644 index 0000000..50063c9 --- /dev/null +++ b/xT/xTq.qml @@ -0,0 +1,105 @@ +import QtQuick +import QtQuick.Controls.Fusion +//import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: window + width: 640 + height: 480 + visible: true + title: qsTr("xT") + + property RelayConnection connection + + ColumnLayout { + id: column + anchors.fill: parent + anchors.margins: 6 + + ScrollView { + id: bufferScroll + Layout.fillWidth: true + Layout.fillHeight: true + TextArea { + id: buffer + text: qsTr("Buffer text") + } + } + + RowLayout { + id: row + Layout.fillWidth: true + + Label { + Layout.fillWidth: true + id: prompt + text: qsTr("Prompt") + } + + Label { + Layout.fillWidth: true + id: status + horizontalAlignment: Text.AlignRight + text: qsTr("Status") + } + } + + TextArea { + id: input + Layout.fillWidth: true + text: qsTr("Input") + } + } + + Component.onCompleted: {} + + Dialog { + id: connect + title: "Connect to relay" + anchors.centerIn: parent + modal: true + visible: true + + onRejected: Qt.quit() + onAccepted: { + // TODO(p): Store the host, store the port, initiate connection. + } + + GridLayout { + anchors.fill: parent + anchors.margins: 6 + columns: 2 + + // It is a bit silly that one has to do everything manually. + Keys.onReturnPressed: connect.accept() + + Label { text: "Host:" } + TextField { + id: connectHost + Layout.fillWidth: true + // And if this doesn't work reliably, do it after open(). + focus: true + } + Label { text: "Port:" } + TextField { + id: connectPort + Layout.fillWidth: true + } + } + + footer: DialogButtonBox { + Button { + text: qsTr("Connect") + DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole + Keys.onReturnPressed: connect.accept() + highlighted: true + } + Button { + text: qsTr("Close") + DialogButtonBox.buttonRole: DialogButtonBox.DestructiveRole + Keys.onReturnPressed: connect.reject() + } + } + } +} @@ -1,7 +1,7 @@ /* * xW.cpp: Win32 frontend for xC * - * Copyright (c) 2023, Přemysl Eric Janouch <p@janouch.name> + * Copyright (c) 2023 - 2024, 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. @@ -222,6 +222,14 @@ relay_try_write(std::wstring &error) } static void +on_relay_generic_response( + std::wstring error, const Relay::ResponseData *response) +{ + if (!response) + show_error_message(error.c_str()); +} + +static void relay_send(Relay::CommandData *data, Callback callback = {}) { Relay::CommandMessage m = {}; @@ -232,6 +240,8 @@ relay_send(Relay::CommandData *data, Callback callback = {}) if (callback) g.command_callbacks[m.command_seq] = std::move(callback); + else + g.command_callbacks[m.command_seq] = on_relay_generic_response; uint32_t len = htonl(w.data.size()); uint8_t *prefix = reinterpret_cast<uint8_t *>(&len); @@ -255,73 +265,6 @@ buffer_by_name(const std::wstring &name) return nullptr; } -static void -buffer_activate(const std::wstring &name) -{ - auto activate = new Relay::CommandData_BufferActivate(); - activate->buffer_name = name; - relay_send(activate); -} - -static void -buffer_toggle_unimportant(const std::wstring &name) -{ - auto toggle = new Relay::CommandData_BufferToggleUnimportant(); - toggle->buffer_name = name; - relay_send(toggle); -} - -// --- Current buffer ---------------------------------------------------------- - -static void -buffer_toggle_log( - const std::wstring &error, const Relay::ResponseData_BufferLog *response) -{ - if (!response) { - show_error_message(error.c_str()); - return; - } - - std::wstring log; - if (!LibertyXDR::utf8_to_wstring( - response->log.data(), response->log.size(), log)) { - show_error_message(L"Invalid encoding."); - return; - } - - std::wstring filtered; - for (auto wch : log) { - if (wch == L'\n') - filtered += L"\r\n"; - else - filtered += wch; - } - - SetWindowText(g.hwndBufferLog, filtered.c_str()); - ShowWindow(g.hwndBuffer, SW_HIDE); - ShowWindow(g.hwndBufferLog, SW_SHOW); -} - -static void -buffer_toggle_log() -{ - if (IsWindowVisible(g.hwndBufferLog)) { - ShowWindow(g.hwndBufferLog, SW_HIDE); - ShowWindow(g.hwndBuffer, SW_SHOW); - SetWindowText(g.hwndBufferLog, L""); - return; - } - - auto log = new Relay::CommandData_BufferLog(); - log->buffer_name = g.buffer_current; - relay_send(log, [name = g.buffer_current](auto error, auto response) { - if (g.buffer_current != name) - return; - buffer_toggle_log(error, - dynamic_cast<const Relay::ResponseData_BufferLog *>(response)); - }); -} - static bool buffer_at_bottom() { @@ -354,6 +297,7 @@ refresh_icon() if (b.highlighted) icon = g.hiconHighlighted; + // XXX: This may not change the taskbar icon. SendMessage(g.hwndMain, WM_SETICON, ICON_SMALL, (LPARAM) icon); SendMessage(g.hwndMain, WM_SETICON, ICON_BIG, (LPARAM) icon); } @@ -430,6 +374,88 @@ refresh_status() SetWindowText(g.hwndStatus, status.c_str()); } +static void +recheck_highlighted() +{ + // Corresponds to the logic toggling the bool on. + auto b = buffer_by_name(g.buffer_current); + if (b && b->highlighted && buffer_at_bottom() && + !IsIconic(g.hwndMain) && !IsWindowVisible(g.hwndBufferLog)) { + b->highlighted = false; + refresh_icon(); + refresh_buffer_list(); + } +} + +// --- Buffer actions ---------------------------------------------------------- + +static void +buffer_activate(const std::wstring &name) +{ + auto activate = new Relay::CommandData_BufferActivate(); + activate->buffer_name = name; + relay_send(activate); +} + +static void +buffer_toggle_unimportant(const std::wstring &name) +{ + auto toggle = new Relay::CommandData_BufferToggleUnimportant(); + toggle->buffer_name = name; + relay_send(toggle); +} + +static void +buffer_toggle_log( + const std::wstring &error, const Relay::ResponseData_BufferLog *response) +{ + if (!response) { + show_error_message(error.c_str()); + return; + } + + std::wstring log; + if (!LibertyXDR::utf8_to_wstring( + response->log.data(), response->log.size(), log)) { + show_error_message(L"Invalid encoding."); + return; + } + + std::wstring filtered; + for (auto wch : log) { + if (wch == L'\n') + filtered += L"\r\n"; + else + filtered += wch; + } + + SetWindowText(g.hwndBufferLog, filtered.c_str()); + ShowWindow(g.hwndBuffer, SW_HIDE); + ShowWindow(g.hwndBufferLog, SW_SHOW); +} + +static void +buffer_toggle_log() +{ + if (IsWindowVisible(g.hwndBufferLog)) { + ShowWindow(g.hwndBufferLog, SW_HIDE); + ShowWindow(g.hwndBuffer, SW_SHOW); + SetWindowText(g.hwndBufferLog, L""); + + recheck_highlighted(); + return; + } + + auto log = new Relay::CommandData_BufferLog(); + log->buffer_name = g.buffer_current; + relay_send(log, [name = g.buffer_current](auto error, auto response) { + if (g.buffer_current != name) + return; + buffer_toggle_log(error, + dynamic_cast<const Relay::ResponseData_BufferLog *>(response)); + }); +} + // --- Rich Edit formatting ---------------------------------------------------- static COLORREF @@ -695,7 +721,7 @@ buffer_print_line(std::vector<BufferLine>::const_iterator begin, static void buffer_print_separator() { - bool sameline = !GetWindowTextLength(g.hwndBuffer); + bool sameline = !buffer_reset_selection(); CHARFORMAT2 format = default_charformat(); format.dwEffects &= ~CFE_AUTOCOLOR; @@ -728,6 +754,7 @@ refresh_buffer(const Buffer &b) buffer_print_and_watch_trailing_date_changes(); buffer_scroll_to_bottom(); + // We will get a scroll event, so no need to recheck_highlighted() here. SendMessage(g.hwndBuffer, WM_SETREDRAW, (WPARAM) TRUE, 0); InvalidateRect(g.hwndBuffer, NULL, TRUE); @@ -749,8 +776,9 @@ relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m) // Retained mode is complicated. bool display = (!m.is_unimportant || !bc->hide_unimportant) && (b.buffer_name == g.buffer_current || m.leak_to_active); + // XXX: It would be great if it didn't autoscroll when focused. bool to_bottom = display && - buffer_at_bottom(); + (buffer_at_bottom() || GetFocus() == g.hwndBuffer); bool visible = display && to_bottom && !IsIconic(g.hwndMain) && @@ -914,11 +942,11 @@ relay_process_message(const Relay::EventMessage &m) b->buffer_name = data.new_; - refresh_buffer_list(); if (data.buffer_name == g.buffer_current) { g.buffer_current = data.new_; refresh_status(); } + refresh_buffer_list(); if (data.buffer_name == g.buffer_last) g.buffer_last = data.new_; break; @@ -1465,6 +1493,7 @@ richedit_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, { // Dragging the scrollbar doesn't result in EN_VSCROLL. LRESULT lResult = DefSubclassProc(hWnd, uMsg, wParam, lParam); + recheck_highlighted(); refresh_status(); return lResult; } @@ -1522,8 +1551,12 @@ process_resize(UINT w, UINT h) MoveWindow(g.hwndBufferList, 3, top, 150, h - top - bottom, FALSE); MoveWindow(g.hwndBuffer, 156, top, w - 159, h - top - bottom, FALSE); MoveWindow(g.hwndBufferLog, 156, top, w - 159, h - top - bottom, FALSE); - if (to_bottom) + if (to_bottom) { buffer_scroll_to_bottom(); + } else { + recheck_highlighted(); + refresh_status(); + } InvalidateRect(g.hwndMain, NULL, TRUE); } @@ -1685,8 +1718,10 @@ window_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) } case WM_SYSCOMMAND: { + // We're not deiconified yet, so duplicate recheck_highlighted(). auto b = buffer_by_name(g.buffer_current); - if (b && wParam == SC_RESTORE) { + if (wParam == SC_RESTORE && b && b->highlighted && buffer_at_bottom() && + !IsWindowVisible(g.hwndBufferLog)) { b->highlighted = false; refresh_icon(); } @@ -1694,13 +1729,15 @@ window_proc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) break; } case WM_COMMAND: - if (!lParam) + if (!lParam) { process_accelerator(LOWORD(wParam)); - else if (lParam == (LPARAM) g.hwndBufferList) + } else if (lParam == (LPARAM) g.hwndBufferList) { process_bufferlist_notification(HIWORD(wParam)); - else if (lParam == (LPARAM) g.hwndBuffer && - HIWORD(wParam) == EN_VSCROLL) + } else if (lParam == (LPARAM) g.hwndBuffer && + HIWORD(wParam) == EN_VSCROLL) { + recheck_highlighted(); refresh_status(); + } return 0; case WM_NOTIFY: switch (((LPNMHDR) lParam)->code) { |