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 | 1 | ||||
-rw-r--r-- | xA/Makefile | 13 | ||||
-rw-r--r-- | xA/go.mod | 49 | ||||
-rw-r--r-- | xA/go.sum | 673 | ||||
-rw-r--r-- | xA/xA.go | 774 | ||||
-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-- | 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-- | 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 |
37 files changed, 3822 insertions, 1454 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 492815c8fc38ad6e333b2f1c5094a329e307615 +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 index 34289f1..5e6a147 100644 --- a/xA/.gitignore +++ b/xA/.gitignore @@ -2,3 +2,4 @@ /proto.go /FyneApp.toml /*.png +/beep.raw diff --git a/xA/Makefile b/xA/Makefile index b597652..d0f0449 100644 --- a/xA/Makefile +++ b/xA/Makefile @@ -4,8 +4,10 @@ AWK = env LC_ALL=C awk tools = ../liberty/tools -outputs = FyneApp.toml xA proto.go xA.png xA-highlighted.png +generated = FyneApp.toml xA.png xA-highlighted.png beep.raw proto.go +outputs = xA $(generated) all: $(outputs) +generate: $(generated) FyneApp.toml: ../xK-version printf "\ @@ -21,11 +23,14 @@ FyneApp.toml: ../xK-version Categories = ['Network', 'Chat', 'IRCClient']\n" > $@ .svg.png: rsvg-convert --output=$@ -- $< -xA: xA.go proto.go ../xK-version xA.png xA-highlighted.png - go build -ldflags "-X 'main.projectVersion=$$(cat ../xK-version)'" -o $@ \ - -gcflags=all="-N -l" +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) @@ -1,37 +1,46 @@ module janouch.name/xK/xA -go 1.23 +go 1.23.0 -require fyne.io/fyne/v2 v2.5.2 +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.4.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.8.0 // indirect - github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934 // indirect - github.com/fyne-io/glfw-js v0.0.0-20240101223322-6e1efdc71b7a // indirect - github.com/fyne-io/image v0.0.0-20240417123036-dc0ee9e7c964 // 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-20240506104042-037f3cc74f2a // 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.2.0 // indirect + github.com/go-text/typesetting v0.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/gopherjs/gopherjs v1.17.2 // indirect - github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 // 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/nicksnyder/go-i18n/v2 v2.4.1 // 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.2.6 // 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.9.0 // indirect - github.com/yuin/goldmark v1.7.8 // indirect - golang.org/x/image v0.22.0 // indirect - golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f // indirect - golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/text v0.20.0 // 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 ) @@ -1,655 +1,84 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -fyne.io/fyne/v2 v2.5.2 h1:eSyGTmSkv10yAdAeHpDet6u2KkKxOGFc14kQu81We7Q= -fyne.io/fyne/v2 v2.5.2/go.mod h1:26gqPDvtaxHeyct+C0BBjuGd2zwAJlPkUGSBrb+d7Ug= +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 v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +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.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934 h1:dZC5aKobSN07hf71oMivxUmAofFja5GrfPK2rBlttX4= -github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg= -github.com/fyne-io/glfw-js v0.0.0-20240101223322-6e1efdc71b7a h1:ybgRdYvAHTn93HW79bLiBiJwVL4jVeyGQRZMgImoeWs= -github.com/fyne-io/glfw-js v0.0.0-20240101223322-6e1efdc71b7a/go.mod h1:gsGA2dotD4v0SR6PmPCYvS9JuOeMwAtmfvDE7mbYXMY= -github.com/fyne-io/image v0.0.0-20240417123036-dc0ee9e7c964 h1:0pTELtjlVAVGSazfwRNcqTVzqmkWb1GsNozCmmZfdZA= -github.com/fyne-io/image v0.0.0-20240417123036-dc0ee9e7c964/go.mod h1:J9Uunu842kOcTjzQj4Eq8XIDmF55szvT1PTS1cUb1UE= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +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 v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +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.2.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho= -github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I= -github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY= -github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +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/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 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/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI= -github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= -github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 h1:Po+wkNdMmN+Zj1tDsJQy7mJlPlwGNQd9JZoPjObagf8= -github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49/go.mod h1:YiutDnxPRLk5DLUFj6Rw4pRBBURZY07GFr54NdV9mQg= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= -github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= -github.com/nicksnyder/go-i18n/v2 v2.4.1 h1:zwzjtX4uYyiaU02K5Ia3zSkpJZrByARkRB4V3YPrr0g= -github.com/nicksnyder/go-i18n/v2 v2.4.1/go.mod h1:++Pl70FR6Cki7hdzZRnEEqdc2dJt+SAGotyFg/SvZMk= +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/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= 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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/rymdport/portal v0.2.6 h1:HWmU3gORu7vWcpr7VSwUS2Xx1HtJXVcUuTqEZcMEsIg= -github.com/rymdport/portal v0.2.6/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= -github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= +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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -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/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= -golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ= -golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f h1:23H/YlmTHfmmvpZ+ajKZL0qLz0+IwFOIqQA0mQbmLeM= -golang.org/x/mobile v0.0.0-20241108191957-fa514ef75a0f/go.mod h1:UbSUP4uu/C9hw9R2CkojhXlAxvayHjBdU9aRvE+c1To= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -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/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +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-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= @@ -1,30 +1,37 @@ -// Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name> +// 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" ) @@ -38,6 +45,8 @@ var ( iconNormal []byte //go:embed xA-highlighted.png iconHighlighted []byte + //go:embed beep.raw + beepSample []byte resourceIconNormal = fyne.NewStaticResource( "xA.png", iconNormal) @@ -49,6 +58,16 @@ var ( 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, @@ -103,6 +122,10 @@ func (t *customTheme) Color( 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 && @@ -110,7 +133,20 @@ func (t *customTheme) Color( return color.Black } - // TODO(p): Consider constants for stuff like timestamps. + 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 } @@ -149,6 +185,7 @@ type bufferLineItem struct { // XXX: Fyne's RichText doesn't support background colours. background fyne.ThemeColorName text string + link *url.URL } type bufferLine struct { @@ -182,14 +219,18 @@ type buffer struct { // Input: - input string - inputStart, inputEnd int - history []string - historyAt int + 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 @@ -209,25 +250,47 @@ var ( 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 *customEntry + wEntry *inputEntry ) // ----------------------------------------------------------------------------- +func showErrorMessage(text string) { + dialog.ShowError(errors.New(text), wWindow) +} + func beep() { - // TODO(p): Probably implement using https://github.com/ebitengine/oto - // and a sample generated from the Makefile like with xW. + if otoContext == nil { + return + } + go func() { + <-otoReady + p := otoContext.NewPlayer(bytes.NewReader(beepSample)) + p.Play() + for p.IsPlaying() { + time.Sleep(time.Second) + } + }() } // --- Networking -------------------------------------------------------------- @@ -274,9 +337,14 @@ func relaySend(data RelayCommandData, callback callback) bool { CommandSeq: commandSeq, Data: data, } - if callback != nil { - commandCallbacks[m.CommandSeq] = callback + if callback == nil { + callback = func(err string, response *RelayResponseData) { + if response == nil { + showErrorMessage(err) + } + } } + commandCallbacks[m.CommandSeq] = callback commandSeq++ // TODO(p): Handle errors better. @@ -308,23 +376,9 @@ func bufferByName(name string) *buffer { return nil } -func bufferActivate(name string) { - relaySend(RelayCommandData{ - Variant: &RelayCommandDataBufferActivate{BufferName: name}, - }, nil) -} - -func bufferToggleUnimportant(name string) { - relaySend(RelayCommandData{ - Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name}, - }, nil) -} - -// --- Current buffer ---------------------------------------------------------- - func bufferAtBottom() bool { - // TODO(p): Figure out how to implement this. - return false + return wRichScroll.Offset.Y >= + wRichScroll.Content.Size().Height-wRichScroll.Size().Height } func bufferScrollToBottom() { @@ -333,29 +387,41 @@ func bufferScrollToBottom() { // 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() { - highlighted := false + resource := resourceIconNormal for _, b := range buffers { if b.highlighted { - highlighted = true + resource = resourceIconHighlighted break } } - - if highlighted { - wWindow.SetIcon(resourceIconHighlighted) - } else { - wWindow.SetIcon(resourceIconNormal) - } + 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{ @@ -371,9 +437,7 @@ func refreshTopic(topic []bufferLineItem) { } func refreshBufferList() { - // TODO(p): See if this is enough, or even doing anything. - // - In particular, within RelayEventDataBufferRemove handling. - wBufferList.Refresh() + // This seems to be enough, even for removals. for i := range buffers { wBufferList.RefreshItem(widget.ListItemID(i)) } @@ -396,12 +460,13 @@ func refreshPrompt() { } func refreshStatus() { - var status string - if !bufferAtBottom() { - status += "🡇 " + if bufferAtBottom() { + wDown.Hide() + } else { + wDown.Show() } - status += bufferCurrent + status := bufferCurrent if b := bufferByName(bufferCurrent); b != nil { if b.modes != "" { status += "(+" + b.modes + ")" @@ -414,6 +479,63 @@ func refreshStatus() { 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{} } @@ -450,6 +572,39 @@ func convertItemFormatting( } } +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 @@ -465,7 +620,7 @@ func convertItems(items []RelayItemData) []bufferLineItem { if inverse { item.color, item.background = item.background, item.color } - result = append(result, item) + result = convertLinks(item, result) } return result } @@ -525,19 +680,17 @@ func bufferPrintLine(lines []bufferLine, index int) { bufferPrintDateChange(last, current) - // TODO(p): Why don't the colour names work? texts := []widget.RichTextSegment{&widget.TextSegment{ Text: line.when.Format("15:04:05 "), Style: widget.RichTextStyle{ Alignment: fyne.TextAlignLeading, - ColorName: "", + ColorName: colorNameBufferTimestamp, Inline: true, SizeName: theme.SizeNameText, TextStyle: fyne.TextStyle{}, }}} // Tabstops won't quite help us here, since we need it centred. - // TODO(p): Why don't the colour names work? prefix := "" pcf := widget.RichTextStyle{ Alignment: fyne.TextAlignLeading, @@ -553,48 +706,48 @@ func bufferPrintLine(lines []bufferLine, index int) { prefix = " - " case RelayRenditionError: prefix = "=!= " - //pcf.ColorName = theme.ColorRed + pcf.ColorName = colorNameRenditionError case RelayRenditionJoin: prefix = "--> " - //pcf.ColorName = theme.ColorGreen + pcf.ColorName = colorNameRenditionJoin case RelayRenditionPart: prefix = "<-- " - //pcf.ColorName = theme.ColorRed + pcf.ColorName = colorNameRenditionPart case RelayRenditionAction: prefix = " * " - //pcf.ColorName = theme.ColorRed + pcf.ColorName = colorNameRenditionAction } - // TODO(p): Also scan for URLs, and transform line.items before use. - // &widget.HyperlinkSegment{Text: "X", URL: testURL}, - - // TODO(p): Render timestamps as well. - if line.leaked { - // TODO(p): Similar as below, but everything greyed out. - if prefix != "" { + if prefix != "" { + style := pcf + if line.leaked { + style.ColorName = colorNameBufferLeaked } - for _, item := range line.items { - _ = item + 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 } - } else { - if prefix != "" { - texts = append(texts, &widget.TextSegment{ - Text: prefix, - Style: pcf, - }) + style := widget.RichTextStyle{ + Alignment: fyne.TextAlignLeading, + ColorName: item.color, + Inline: true, + SizeName: theme.SizeNameText, + TextStyle: item.format, } - for _, item := range line.items { - texts = append(texts, &widget.TextSegment{ - Text: item.text, - 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, @@ -624,6 +777,7 @@ func refreshBuffer(b *buffer) { bufferPrintAndWatchTrailingDateChanges() wRichText.Refresh() bufferScrollToBottom() + recheckHighlighted() } // --- Event processing -------------------------------------------------------- @@ -634,7 +788,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) { // Initial sync: skip all other processing, let highlights be. bc := bufferByName(bufferCurrent) if bc == nil { - b.lines = append(b.lines, line) + bufferPushLine(b, line) return } @@ -642,11 +796,11 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) { display := (!m.IsUnimportant || !bc.hideUnimportant) && (b.bufferName == bufferCurrent || m.LeakToActive) toBottom := display && bufferAtBottom() - visible := display && toBottom && inForeground // && log not visible + visible := display && toBottom && inForeground && !wLog.Visible() separate := display && !visible && bc.newMessages == 0 && bc.newUnimportantMessages == 0 - b.lines = append(b.lines, line) + bufferPushLine(b, line) if !(visible || m.LeakToActive) || b.newMessages != 0 || b.newUnimportantMessages != 0 { if line.isUnimportant || m.LeakToActive { @@ -659,7 +813,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) { if m.LeakToActive { leakedLine := line leakedLine.leaked = true - bc.lines = append(bc.lines, leakedLine) + bufferPushLine(bc, leakedLine) if !visible || bc.newMessages != 0 || bc.newUnimportantMessages != 0 { if line.isUnimportant { @@ -682,6 +836,7 @@ func relayProcessBufferLine(b *buffer, m *RelayEventDataBufferLine) { } // 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() @@ -699,7 +854,7 @@ func relayProcessCallbacks( commandSeq uint32, err string, response *RelayResponseData) { if handler, ok := commandCallbacks[commandSeq]; !ok { if *debug { - log.Printf("unawaited response: %+v\n", *response) + log.Printf("Unawaited response: %+v\n", *response) } } else { delete(commandCallbacks, commandSeq) @@ -788,11 +943,11 @@ func relayProcessMessage(m *RelayEventMessage) { b.bufferName = data.New - refreshBufferList() if data.BufferName == bufferCurrent { bufferCurrent = data.New refreshStatus() } + refreshBufferList() if data.BufferName == bufferLast { bufferLast = data.New } @@ -818,15 +973,21 @@ func relayProcessMessage(m *RelayEventMessage) { old.highlighted = false old.input = wEntry.Text - // TODO(p): At least store Cursor{Row,Column}. + 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) } - // TODO(p): Hide the log if visible. - b.highlighted = false + if wLog.Visible() { + bufferToggleLog() + } + if inForeground { + b.highlighted = false + } + for i := range buffers { if buffers[i].bufferName == bufferCurrent { wBufferList.Select(widget.ListItemID(i)) @@ -841,8 +1002,10 @@ func relayProcessMessage(m *RelayEventMessage) { refreshPrompt() refreshStatus() - // TODO(p): At least set Cursor{Row,Column}, and apply it. wEntry.SetText(b.input) + wEntry.CursorRow = b.inputRow + wEntry.CursorColumn = b.inputColumn + wEntry.Refresh() wWindow.Canvas().Focus(wEntry) case *RelayEventDataBufferInput: b := bufferByName(data.BufferName) @@ -916,19 +1079,42 @@ func relayMakeReceiver( 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() { - // TODO(p): Maybe reset state, and indicate in the UI that we're connecting. + fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress) + backendLock.Lock() + + fyne.DoAndWait(func() { + relayResetState() + }) backendContext, backendCancel = context.WithCancel(context.Background()) defer backendCancel() - var err error - backendLock.Lock() backendConn, err = net.Dial("tcp", backendAddress) + backendLock.Unlock() if err != nil { - log.Println("Connection failed: " + err.Error()) - // TODO(p): Display errors to the user. + fyne.DoAndWait(func() { + wConnect.Show() + showErrorMessage("Connection failed: " + err.Error()) + }) return } defer backendConn.Close() @@ -941,23 +1127,30 @@ func relayRun() { }, nil) relayMessages := relayMakeReceiver(backendContext, backendConn) +Loop: for { select { case m, ok := <-relayMessages: if !ok { - break + break Loop } - relayProcessMessage(&m) - default: - break + fyne.DoAndWait(func() { + relayProcessMessage(&m) + }) } } - - // TODO(p): Indicate in the UI that we're no longer connected. + 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 { @@ -966,7 +1159,7 @@ func inputSubmit(text string) bool { b.history = append(b.history, text) b.historyAt = len(b.history) - wEntry.SetText("") + inputSetContents("") relaySend(RelayCommandData{Variant: &RelayCommandDataBufferInput{ BufferName: b.bufferName, @@ -975,67 +1168,156 @@ func inputSubmit(text string) bool { 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 customEntry struct { +type inputEntry struct { widget.Entry // selectKeyDown is a hack to exactly invert widget.Entry's behaviour, // which groups both Shift keys together. selectKeyDown bool - - down map[fyne.KeyName]bool } -func newCustomEntry() *customEntry { - e := &customEntry{} +func newInputEntry() *inputEntry { + e := &inputEntry{} e.MultiLine = true e.Wrapping = fyne.TextWrap(fyne.TextTruncateClip) - e.down = make(map[fyne.KeyName]bool) e.ExtendBaseWidget(e) return e } -func (e *customEntry) FocusLost() { - e.down = make(map[fyne.KeyName]bool) +func (e *inputEntry) FocusLost() { e.selectKeyDown = false e.Entry.FocusLost() } -func (e *customEntry) KeyDown(key *fyne.KeyEvent) { - e.down[key.Name] = true +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 *customEntry) KeyUp(key *fyne.KeyEvent) { - delete(e.down, key.Name) +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 *customEntry) TypedKey(key *fyne.KeyEvent) { +func (e *inputEntry) TypedKey(key *fyne.KeyEvent) { if e.Disabled() { return } - /* - modified := false - for _, name := range []fyne.KeyName{ - desktop.KeyAltLeft, desktop.KeyAltRight, - desktop.KeyControlLeft, desktop.KeyControlRight, - desktop.KeySuperLeft, desktop.KeySuperRight, - } { - if e.down[name] { - modified = true - } - } - */ - // Invert the Shift key behaviour here. // Notice that this will never work on mobile. shift := &fyne.KeyEvent{Name: desktop.KeyShiftLeft} @@ -1049,15 +1331,173 @@ func (e *customEntry) TypedKey(key *fyne.KeyEvent) { e.OnSubmitted(e.Text) } case fyne.KeyTab: - // TODO(p): Just do e.Append() if state matches. - log.Println("completion") + 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(), @@ -1071,18 +1511,26 @@ func main() { 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 - if b := bufferByName(bufferCurrent); b != nil { - b.highlighted = false - refreshIcon() - } + recheckHighlighted() }) a.Lifecycle().SetOnExitedForeground(func() { inForeground = false @@ -1119,60 +1567,84 @@ func main() { 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 = newCustomEntry() - // TODO(p): Rather respond to all keypresses/similar activity. - wEntry.OnChanged = func(text string) { - relaySend(RelayCommandData{Variant: &RelayCommandDataActive{}}, nil) - } - wEntry.OnSubmitted = func(text string) { - inputSubmit(text) - } + 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, wStatus), + container.NewBorder(nil, nil, + wPrompt, container.NewHBox(wDown, wStatus)), wEntry, ) - split := container.NewHSplit(wBufferList, wRichScroll) - split.SetOffset(0.25) 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 } - connectForm := dialog.NewForm("Connect to relay", "Connect", "Exit", + + // 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 { - a.Quit() - } else { + 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 flag.NArg() >= 1 { - backendAddress = flag.Arg(0) - connectAddress.SetText(backendAddress) + if connect { go relayRun() } else { - connectForm.Show() + wConnect.Show() } wWindow.ShowAndRun() @@ -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)]), 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/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) { |