diff options
-rw-r--r-- | CMakeLists.txt | 5 | ||||
-rw-r--r-- | LICENSE | 2 | ||||
-rw-r--r-- | NEWS | 22 | ||||
-rw-r--r-- | README.adoc | 16 | ||||
m--------- | liberty | 0 | ||||
-rwxr-xr-x | test | 52 | ||||
-rw-r--r-- | test.lua | 72 | ||||
-rw-r--r-- | xA/go.mod | 49 | ||||
-rw-r--r-- | xA/go.sum | 677 | ||||
-rw-r--r-- | xA/xA.go | 192 | ||||
-rw-r--r-- | xC.c | 840 | ||||
-rw-r--r-- | xC.lxdr | 53 | ||||
-rw-r--r-- | xF.c | 1088 | ||||
-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/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 |
34 files changed, 4328 insertions, 1357 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,7 +1,29 @@ 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" diff --git a/README.adoc b/README.adoc index 172224a..1866b2a 100644 --- a/README.adoc +++ b/README.adoc @@ -33,10 +33,10 @@ including link:xC.adoc#_key_bindings[keyboard shortcuts]. image::xP.webp[align="center"] -<<<<<<< HEAD -xA, xF, xW, xM --------------- -The native frontends for 'xC'. Using them is not recommended. +xA, xT, xF, xW, xM +------------------ +Fyne, Qt Widgets, X11, Win32, Cocoa frontends for 'xC'. +Using them is not recommended. xD -- @@ -152,7 +152,13 @@ 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` first. +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 ~~ 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) @@ -1,41 +1,46 @@ module janouch.name/xK/xA -go 1.22 +go 1.23.0 + +toolchain go1.24.0 require ( - fyne.io/fyne/v2 v2.5.2 - github.com/ebitengine/oto/v3 v3.3.1 + 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.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.3.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect - github.com/stretchr/testify v1.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,659 +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/ebitengine/oto/v3 v3.3.1 h1:d4McwGQuXOT0GL7bA5g9ZnaUEIEjQvG3hafzMy+T3qE= -github.com/ebitengine/oto/v3 v3.3.1/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U= -github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE= -github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -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.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8= -github.com/rymdport/portal v0.3.0/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,4 +1,4 @@ -// 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 @@ -122,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 && @@ -281,7 +285,11 @@ func beep() { } go func() { <-otoReady - otoContext.NewPlayer(bytes.NewReader(beepSample)).Play() + p := otoContext.NewPlayer(bytes.NewReader(beepSample)) + p.Play() + for p.IsPlaying() { + time.Sleep(time.Second) + } }() } @@ -329,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. @@ -363,16 +376,18 @@ func bufferByName(name string) *buffer { return nil } -func bufferActivate(name string) { - relaySend(RelayCommandData{ - Variant: &RelayCommandDataBufferActivate{BufferName: name}, - }, nil) +func bufferAtBottom() bool { + return wRichScroll.Offset.Y >= + wRichScroll.Content.Size().Height-wRichScroll.Size().Height } -func bufferToggleUnimportant(name string) { - relaySend(RelayCommandData{ - Variant: &RelayCommandDataBufferToggleUnimportant{BufferName: name}, - }, nil) +func bufferScrollToBottom() { + // XXX: Doing it once is not reliable, something's amiss. + // (In particular, nothing happens when we switch from an empty buffer + // to a buffer than needs scrolling.) + wRichScroll.ScrollToBottom() + wRichScroll.ScrollToBottom() + refreshStatus() } func bufferPushLine(b *buffer, line bufferLine) { @@ -386,68 +401,17 @@ func bufferPushLine(b *buffer, line bufferLine) { } } -// --- Current buffer ---------------------------------------------------------- - -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("") - return - } - - name := bufferCurrent - relaySend(RelayCommandData{Variant: &RelayCommandDataBufferLog{ - BufferName: name, - }}, func(err string, response *RelayResponseData) { - if bufferCurrent == name { - bufferToggleLogFinish( - err, response.Variant.(*RelayResponseDataBufferLog)) - } - }) -} - -func bufferAtBottom() bool { - return wRichScroll.Offset.Y >= - wRichScroll.Content.Size().Height-wRichScroll.Size().Height -} - -func bufferScrollToBottom() { - // XXX: Doing it once is not reliable, something's amiss. - // (In particular, nothing happens when we switch from an empty buffer - // to a buffer than needs scrolling.) - wRichScroll.ScrollToBottom() - wRichScroll.ScrollToBottom() - refreshStatus() -} - // --- 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) { @@ -515,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{} } @@ -756,6 +777,7 @@ func refreshBuffer(b *buffer) { bufferPrintAndWatchTrailingDateChanges() wRichText.Refresh() bufferScrollToBottom() + recheckHighlighted() } // --- Event processing -------------------------------------------------------- @@ -921,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 } @@ -1078,7 +1100,10 @@ func relayRun() { fyne.CurrentApp().Preferences().SetString(preferenceAddress, backendAddress) backendLock.Lock() - relayResetState() + fyne.DoAndWait(func() { + relayResetState() + }) + backendContext, backendCancel = context.WithCancel(context.Background()) defer backendCancel() var err error @@ -1086,8 +1111,10 @@ func relayRun() { backendLock.Unlock() if err != nil { - wConnect.Show() - showErrorMessage("Connection failed: " + err.Error()) + fyne.DoAndWait(func() { + wConnect.Show() + showErrorMessage("Connection failed: " + err.Error()) + }) return } defer backendConn.Close() @@ -1107,12 +1134,15 @@ Loop: if !ok { break Loop } - relayProcessMessage(&m) + fyne.DoAndWait(func() { + relayProcessMessage(&m) + }) } } - - wConnect.Show() - showErrorMessage("Disconnected") + fyne.DoAndWait(func() { + wConnect.Show() + showErrorMessage("Disconnected") + }) } // --- Input line -------------------------------------------------------------- @@ -1374,6 +1404,9 @@ func (l *customLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { } if toBottom { bufferScrollToBottom() + } else { + recheckHighlighted() + refreshStatus() } } @@ -1490,16 +1523,14 @@ func main() { 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 @@ -1540,7 +1571,10 @@ func main() { wRichText = widget.NewRichText() wRichText.Wrapping = fyne.TextWrapWord wRichScroll = container.NewVScroll(wRichText) - wRichScroll.OnScrolled = func(position fyne.Position) { refreshStatus() } + wRichScroll.OnScrolled = func(position fyne.Position) { + recheckHighlighted() + refreshStatus() + } wLog = newLogEntry() wLog.Wrapping = fyne.TextWrapWord wLog.Hide() @@ -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,7 +1,7 @@ /* * xF.c: a toothless IRC client frontend * - * Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> + * Copyright (c) 2022 - 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. @@ -22,21 +22,556 @@ #include "common.c" #include "xC-proto.c" +#define LIBERTY_XDG_WANT_X11 +#define LIBERTY_XDG_WANT_ICONS +#include "liberty/liberty-xdg.c" + #include <X11/Xatom.h> #include <X11/Xlib.h> #include <X11/keysym.h> #include <X11/XKBlib.h> #include <X11/Xft/Xft.h> +// --- Frontend ---------------------------------------------------------------- + +// See: struct relay_event_data_buffer_line +struct buffer_line +{ + LIST_HEADER (struct buffer_line) + + /// Leaked from another buffer, but temporarily staying in another one. + bool leaked; + + bool is_unimportant; + bool is_highlight; + enum relay_rendition rendition; + uint64_t when; + char text[]; +}; + +// See: struct relay_event_data_buffer_update +struct buffer +{ + LIST_HEADER (struct buffer) + + char *buffer_name; + enum relay_buffer_kind kind; + char *server_name; + struct buffer_line *lines; + struct buffer_line *lines_tail; + + // Stats: + + uint32_t new_messages; + uint32_t new_unimportant_messages; + bool highlighted; +}; + +static void +buffer_destroy (struct buffer *self) +{ + free (self->buffer_name); + free (self->server_name); + LIST_FOR_EACH (struct buffer_line, iter, self->lines) + free (iter); + free (self); +} + +// See: struct relay_event_data_server_update +struct server +{ + enum relay_server_state state; + char *user; + char *user_modes; +}; + +static void +server_destroy (struct server *self) +{ + free (self->user); + free (self->user_modes); + free (self); +} + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +/// Wraps Xft fonts into a linked list with fallbacks. +struct x11_font_link +{ + struct x11_font_link *next; + XftFont *font; +}; + +enum +{ + X11_FONT_BOLD = 1 << 0, + X11_FONT_ITALIC = 1 << 1, + X11_FONT_MONOSPACE = 1 << 2, +}; + +struct x11_font +{ + struct x11_font *next; ///< Next in a linked list + + struct x11_font_link *list; ///< Fonts of varying Unicode coverage + unsigned style; ///< X11_FONT_* flags + FcPattern *pattern; ///< Original unsubstituted pattern + FcCharSet *unavailable; ///< Couldn't find a font for these +}; + static struct { - bool polling; + struct poller poller; ///< Poller + bool polling; ///< The event loop is running + + // Relay plumbing: + struct connector connector; - int socket; + int socket_fd; ///< Backend TCP socket + struct poller_fd socket_event; ///< The socket can be read/written to + struct str read_buffer; ///< Unprocessed input + struct str write_buffer; ///< Output yet to be sent out + + uint32_t command_seq; ///< Outgoing message counter + + // Relay state: + + struct buffer *buffers; ///< Ordered list of all buffers + struct buffer *buffers_tail; ///< The tail of all buffers + struct buffer *buffer_current; ///< The current buffer + struct buffer *buffer_last; ///< Last used buffer + + struct str_map servers; ///< All servers + + // User interface: + + int ui_width; ///< Window width + int ui_height; ///< Window height + bool ui_focused; ///< Whether the window has focus + + XIM x11_im; ///< Input method + XIC x11_ic; ///< Input method context + Display *dpy; ///< X display handle + struct poller_fd x11_event; ///< X11 events on wire + struct poller_idle xpending_event; ///< X11 events possibly in I/O queues + int xkb_base_event_code; ///< Xkb base event code + Window x11_window; ///< Application window + Pixmap x11_pixmap; ///< Off-screen bitmap + Region x11_clip; ///< Invalidated region + Picture x11_pixmap_picture; ///< XRender wrap for x11_pixmap + XftDraw *xft_draw; ///< Xft rendering context + struct x11_font *xft_fonts; ///< Font collection + char *x11_selection; ///< CLIPBOARD selection + + const char *x11_fontname; ///< Fontconfig font name + const char *x11_fontname_monospace; ///< Fontconfig monospace font name + + struct poller_idle refresh_event; ///< Refresh the window's contents + struct poller_idle flip_event; ///< Draw rendered widgets on screen +} +g = +{ + .x11_fontname = "sans\\-serif-11", + .x11_fontname_monospace = "monospace-11", +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +app_init_context (void) +{ + poller_init (&g.poller); + + g.socket_fd = -1; + g.read_buffer = str_make (); + g.write_buffer = str_make (); + + g.servers = str_map_make ((str_map_free_fn) server_destroy); + + // Presumably, although not necessarily; unsure if queryable at all + g.ui_focused = true; +} + +static void +app_quit (void) +{ + // So far there's nothing for us to wait on, so let's just stop looping. + g.polling = false; +} + +static void +app_invalidate (void) +{ + poller_idle_set (&g.refresh_event); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static struct buffer * +buffer_find (const char *name) +{ + LIST_FOR_EACH (struct buffer, buffer, g.buffers) + if (!strcmp (buffer->buffer_name, name)) + return buffer; + return NULL; +} + +static struct buffer_line * +buffer_line_new (struct relay_event_data_buffer_line *m) +{ + struct str s = str_make (); + for (uint32_t i = 0; i < m->items_len; i++) + if (m->items[i].kind == RELAY_ITEM_TEXT) + str_append_str (&s, &m->items[i].text.text); + + struct buffer_line *self = xcalloc (1, sizeof *self + s.len + 1); + memcpy (self->text, s.str, s.len + 1); + str_free (&s); + + self->is_unimportant = m->is_unimportant; + self->is_highlight = m->is_highlight; + self->rendition = m->rendition; + self->when = m->when; + return self; +} + +static void +relay_process_buffer_line (struct buffer *buffer, + struct relay_event_data_buffer_line *m) +{ + // Initial sync: skip all other processing, let highlights be. + if (!g.buffer_current) + { + struct buffer_line *line = buffer_line_new (m); + LIST_APPEND_WITH_TAIL (buffer->lines, buffer->lines_tail, line); + return; + } + + // TODO: Track window on-screen visibility. + bool visible = buffer == g.buffer_current || m->leak_to_active; + + // TODO: Port the rest from xP.js. + struct buffer_line *line = buffer_line_new (m); + LIST_APPEND_WITH_TAIL (buffer->lines, buffer->lines_tail, line); + if (!(visible || m->leak_to_active) + || buffer->new_messages || buffer->new_unimportant_messages) + { + if (line->is_unimportant || m->leak_to_active) + buffer->new_unimportant_messages++; + else + buffer->new_messages++; + } + + if (m->leak_to_active) + { + struct buffer *bc = g.buffer_current; + struct buffer_line *line = buffer_line_new (m); + line->leaked = true; + LIST_APPEND_WITH_TAIL (bc->lines, bc->lines_tail, line); + if (!visible || bc->new_messages || bc->new_unimportant_messages) + { + if (line->is_unimportant) + bc->new_unimportant_messages++; + else + bc->new_messages++; + } + } + + if (line->is_highlight || (!visible && !line->is_unimportant + && buffer->kind == RELAY_BUFFER_KIND_PRIVATE_MESSAGE)) + { + // TODO: Play a beep sample. + if (!visible) + buffer->highlighted = true; + } +} + +static const char * +relay_message_buffer_name (const struct relay_event_message *m) +{ + switch (m->data.event) + { + case RELAY_EVENT_BUFFER_LINE: + return m->data.buffer_line.buffer_name.str; + case RELAY_EVENT_BUFFER_UPDATE: + return m->data.buffer_update.buffer_name.str; + case RELAY_EVENT_BUFFER_STATS: + return m->data.buffer_stats.buffer_name.str; + case RELAY_EVENT_BUFFER_RENAME: + return m->data.buffer_rename.buffer_name.str; + case RELAY_EVENT_BUFFER_REMOVE: + return m->data.buffer_remove.buffer_name.str; + case RELAY_EVENT_BUFFER_ACTIVATE: + return m->data.buffer_activate.buffer_name.str; + case RELAY_EVENT_BUFFER_INPUT: + return m->data.buffer_input.buffer_name.str; + case RELAY_EVENT_BUFFER_CLEAR: + return m->data.buffer_clear.buffer_name.str; + default: + return NULL; + } +} + +static const char * +relay_message_server_name (const struct relay_event_message *m) +{ + switch (m->data.event) + { + case RELAY_EVENT_SERVER_UPDATE: + return m->data.server_update.server_name.str; + case RELAY_EVENT_SERVER_RENAME: + return m->data.server_rename.server_name.str; + case RELAY_EVENT_SERVER_REMOVE: + return m->data.server_remove.server_name.str; + default: + return NULL; + } +} + +static const char * +relay_buffer_update_server_name (const struct relay_event_data_buffer_update *e) +{ + switch (e->context.kind) + { + case RELAY_BUFFER_KIND_SERVER: + return e->context.server.server_name.str; + case RELAY_BUFFER_KIND_CHANNEL: + return e->context.channel.server_name.str; + case RELAY_BUFFER_KIND_PRIVATE_MESSAGE: + return e->context.private_message.server_name.str; + default: + return NULL; + } +} + +static bool +relay_process_message (struct msg_unpacker *r, struct relay_event_message *m) +{ + if (!relay_event_message_deserialize (m, r) + || msg_unpacker_get_available (r)) + { + print_error ("deserialization failed"); + return false; + } + + const char *buffer_name = relay_message_buffer_name (m); + struct buffer *buffer = NULL; + if (buffer_name && !(buffer = buffer_find (buffer_name))) + { + // TODO: Maybe handle BUFFER_ACTIVATE the way xP does. + if (m->data.event != RELAY_EVENT_BUFFER_UPDATE) + { + print_warning ("unknown buffer: %s", buffer_name); + return true; + } + + buffer = xcalloc (1, sizeof *buffer); + buffer->buffer_name = xstrdup (buffer_name); + LIST_APPEND_WITH_TAIL (g.buffers, g.buffers_tail, buffer); + } + + const char *server_name = relay_message_server_name (m); + struct server *server = NULL; + if (server_name && !(server = str_map_find (&g.servers, server_name))) + { + if (m->data.event != RELAY_EVENT_SERVER_UPDATE) + { + print_warning ("unknown server: %s", server_name); + return true; + } + + server = xcalloc (1, sizeof *server); + str_map_set (&g.servers, server_name, server); + } + + switch (m->data.event) + { + case RELAY_EVENT_PING: + // TODO: While not important, we should implement it. + return true; + + case RELAY_EVENT_BUFFER_LINE: + relay_process_buffer_line (buffer, &m->data.buffer_line); + break; + case RELAY_EVENT_BUFFER_UPDATE: + buffer->kind = m->data.buffer_update.context.kind; + + server_name = relay_buffer_update_server_name (&m->data.buffer_update); + cstr_set (&buffer->server_name, NULL); + if (server_name) + buffer->server_name = xstrdup (server_name); + + // TODO: More kind-dependent state. + break; + case RELAY_EVENT_BUFFER_STATS: + buffer->new_messages + = m->data.buffer_stats.new_messages; + buffer->new_unimportant_messages + = m->data.buffer_stats.new_unimportant_messages; + buffer->highlighted + = m->data.buffer_stats.highlighted; + break; + case RELAY_EVENT_BUFFER_RENAME: + free (buffer->buffer_name); + buffer->buffer_name = xstrdup (m->data.buffer_rename.new.str); + break; + case RELAY_EVENT_BUFFER_REMOVE: + LIST_UNLINK_WITH_TAIL (g.buffers, g.buffers_tail, buffer); + if (g.buffer_current == buffer) + g.buffer_current = NULL; + if (g.buffer_last == buffer) + g.buffer_last = NULL; + buffer_destroy (buffer); + break; + case RELAY_EVENT_BUFFER_ACTIVATE: + if (g.buffer_current) + { + g.buffer_current->new_messages = 0; + g.buffer_current->new_unimportant_messages = 0; + g.buffer_current->highlighted = false; + } + + g.buffer_last = g.buffer_current; + g.buffer_current = buffer; + // TODO: Switch the input line. + break; + case RELAY_EVENT_BUFFER_CLEAR: + LIST_FOR_EACH (struct buffer_line, iter, buffer->lines) + free (iter); + buffer->lines = NULL; + break; + + case RELAY_EVENT_SERVER_UPDATE: + server->state = m->data.server_update.data.state; + + cstr_set (&server->user, NULL); + cstr_set (&server->user_modes, NULL); + if (server->state == RELAY_SERVER_STATE_REGISTERED) + { + server->user = + xstrdup (m->data.server_update.data.registered.user.str); + server->user_modes = + xstrdup (m->data.server_update.data.registered.user_modes.str); + } + break; + case RELAY_EVENT_SERVER_RENAME: + str_map_set (&g.servers, m->data.server_rename.new.str, + str_map_steal (&g.servers, server_name)); + break; + case RELAY_EVENT_SERVER_REMOVE: + str_map_set (&g.servers, server_name, NULL); + break; + + default: + return true; + } + app_invalidate (); + return true; +} + +static bool +relay_process_buffer (void) +{ + struct str *buf = &g.read_buffer; + size_t offset = 0; + while (true) + { + uint32_t frame_len = 0; + struct msg_unpacker r = + msg_unpacker_make (buf->str + offset, buf->len - offset); + if (!msg_unpacker_u32 (&r, &frame_len)) + break; + + r.len = MIN (r.len, sizeof frame_len + frame_len); + if (msg_unpacker_get_available (&r) < frame_len) + break; + + struct relay_event_message m = {}; + bool ok = relay_process_message (&r, &m); + relay_event_message_free (&m); + if (!ok) + return false; + + offset += r.offset; + } + + str_remove_slice (buf, 0, offset); + return true; +} + +static bool +relay_try_read (void) +{ + struct str *buf = &g.read_buffer; + ssize_t n_read; + + while ((n_read = read (g.socket_fd, buf->str + buf->len, + buf->alloc - buf->len - 1 /* null byte */)) > 0) + { + buf->len += n_read; + if (!relay_process_buffer ()) + break; + str_reserve (buf, 512); + } + + if (n_read < 0) + { + if (errno == EAGAIN || errno == EINTR) + return true; + + print_debug ("%s: %s: %s", __func__, "read", strerror (errno)); + } + return false; } -g; + +static bool +relay_try_write (void) +{ + struct str *buf = &g.write_buffer; + ssize_t n_written; + + while (buf->len) + { + n_written = write (g.socket_fd, buf->str, buf->len); + if (n_written >= 0) + { + str_remove_slice (buf, 0, n_written); + continue; + } + if (errno == EAGAIN || errno == EINTR) + return true; + + print_debug ("%s: %s: %s", __func__, "write", strerror (errno)); + return false; + } + return true; +} + +static void +relay_update_poller (const struct pollfd *pfd) +{ + int new_events = POLLIN; + if (g.write_buffer.len) + new_events |= POLLOUT; + + hard_assert (new_events != 0); + if (!pfd || pfd->events != new_events) + poller_fd_set (&g.socket_event, new_events); +} + +static void +on_relay_ready (const struct pollfd *pfd, void *user_data) +{ + if (relay_try_read () && relay_try_write ()) + relay_update_poller (pfd); + else + { + // TODO: Probably autoreconnect. + exit_fatal ("disconnected"); + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - static void on_connector_connecting (void *user_data, const char *address) @@ -64,65 +599,526 @@ on_connector_connected (void *user_data, int socket, const char *hostname) { (void) user_data; (void) hostname; - g.polling = false; - g.socket = socket; + connector_free (&g.connector); + + set_blocking (socket, false); + set_cloexec (socket); + + // We already buffer our output, so reduce latencies. + int yes = 1; + soft_assert (setsockopt (socket, IPPROTO_TCP, TCP_NODELAY, + &yes, sizeof yes) != -1); + + g.socket_fd = socket; + g.socket_event = poller_fd_make (&g.poller, g.socket_fd); + g.socket_event.dispatcher = (poller_fd_fn) on_relay_ready; + + str_reset (&g.read_buffer); + str_reset (&g.write_buffer); + + struct str *s = &g.write_buffer; + str_pack_u32 (s, 0); + struct relay_command_message m = {}; + m.command_seq = ++g.command_seq; + m.data.hello.command = RELAY_COMMAND_HELLO; + m.data.hello.version = RELAY_VERSION; + if (!relay_command_message_serialize (&m, s)) + exit_fatal ("serialization failed"); + + uint32_t len = htonl (s->len - sizeof len); + memcpy (s->str, &len, sizeof len); + + relay_update_poller (NULL); } static void -protocol_test (const char *host, const char *port) +relay_connect (const char *host, const char *port) { - struct poller poller = {}; - poller_init (&poller); - - connector_init (&g.connector, &poller); + connector_init (&g.connector, &g.poller); g.connector.on_connecting = on_connector_connecting; g.connector.on_error = on_connector_error; g.connector.on_connected = on_connector_connected; g.connector.on_failure = on_connector_failure; connector_add_target (&g.connector, host, port); +} - g.polling = true; - while (g.polling) - poller_run (&poller); +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - connector_free (&g.connector); +static XRenderColor x11_default_fg = { .alpha = 0xffff }; +static XRenderColor x11_default_bg = { 0xffff, 0xffff, 0xffff, 0xffff }; +static XErrorHandler x11_default_error_handler; - struct str s = str_make (); - str_pack_u32 (&s, 0); - struct relay_command_message m = {}; - m.data.hello.command = RELAY_COMMAND_HELLO; - m.data.hello.version = RELAY_VERSION; - if (!relay_command_message_serialize (&m, &s)) - exit_fatal ("serialization failed"); +static struct x11_font_link * +x11_font_link_new (XftFont *font) +{ + struct x11_font_link *self = xcalloc (1, sizeof *self); + self->font = font; + return self; +} - uint32_t len = htonl (s.len - sizeof len); - memcpy (s.str, &len, sizeof len); - if (errno = 0, write (g.socket, s.str, s.len) != (ssize_t) s.len) - exit_fatal ("short send or error: %s", strerror (errno)); +static void +x11_font_link_destroy (struct x11_font_link *self) +{ + XftFontClose (g.dpy, self->font); + free (self); +} - char buf[1 << 20] = ""; - while (errno = 0, read (g.socket, &len, sizeof len) == sizeof len) +static struct x11_font_link * +x11_font_link_open (FcPattern *pattern) +{ + XftFont *font = XftFontOpenPattern (g.dpy, pattern); + if (!font) { - len = ntohl (len); - if (errno = 0, read (g.socket, buf, MIN (len, sizeof buf)) != len) - exit_fatal ("short read or error: %s", strerror (errno)); + FcPatternDestroy (pattern); + return NULL; + } + return x11_font_link_new (font); +} - struct msg_unpacker r = msg_unpacker_make (buf, len); - struct relay_event_message m = {}; - if (!relay_event_message_deserialize (&m, &r)) - exit_fatal ("deserialization failed"); - if (msg_unpacker_get_available (&r)) - exit_fatal ("trailing data"); +static struct x11_font * +x11_font_open (unsigned style) +{ + FcPattern *pattern = (style & X11_FONT_MONOSPACE) + ? FcNameParse ((const FcChar8 *) g.x11_fontname_monospace) + : FcNameParse ((const FcChar8 *) g.x11_fontname); + if (style & X11_FONT_BOLD) + FcPatternAdd (pattern, FC_STYLE, (FcValue) { + .type = FcTypeString, .u.s = (FcChar8 *) "Bold" }, FcFalse); + if (style & X11_FONT_ITALIC) + FcPatternAdd (pattern, FC_STYLE, (FcValue) { + .type = FcTypeString, .u.s = (FcChar8 *) "Italic" }, FcFalse); - printf ("event: %d\n", m.data.event); - relay_event_message_free (&m); + FcPattern *substituted = FcPatternDuplicate (pattern); + FcConfigSubstitute (NULL, substituted, FcMatchPattern); + + FcResult result = 0; + FcPattern *match = XftFontMatch (g.dpy, + DefaultScreen (g.dpy), substituted, &result); + FcPatternDestroy (substituted); + struct x11_font_link *link = NULL; + if (!match || !(link = x11_font_link_open (match))) + { + FcPatternDestroy (pattern); + return NULL; } - exit_fatal ("short read or error: %s", strerror (errno)); + + struct x11_font *self = xcalloc (1, sizeof *self); + self->list = link; + self->style = style; + self->pattern = pattern; + self->unavailable = FcCharSetCreate (); + return self; +} + +static void +x11_font_destroy (struct x11_font *self) +{ + FcPatternDestroy (self->pattern); + FcCharSetDestroy (self->unavailable); + LIST_FOR_EACH (struct x11_font_link, iter, self->list) + x11_font_link_destroy (iter); + free (self); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +static void +x11_init_pixmap (void) +{ + int screen = DefaultScreen (g.dpy); + g.x11_pixmap = XCreatePixmap (g.dpy, g.x11_window, + MAX (1, g.ui_width), MAX (1, g.ui_height), + DefaultDepth (g.dpy, screen)); + + Visual *visual = DefaultVisual (g.dpy, screen); + XRenderPictFormat *format = XRenderFindVisualFormat (g.dpy, visual); + g.x11_pixmap_picture + = XRenderCreatePicture (g.dpy, g.x11_pixmap, format, 0, NULL); +} + +static void +on_x11_selection_request (XSelectionRequestEvent *ev) +{ + Atom xa_targets = XInternAtom (g.dpy, "TARGETS", False); + Atom xa_compound_text = XInternAtom (g.dpy, "COMPOUND_TEXT", False); + Atom xa_utf8 = XInternAtom (g.dpy, "UTF8_STRING", False); + Atom targets[] = { xa_targets, XA_STRING, xa_compound_text, xa_utf8 }; + + XEvent response = {}; + bool ok = false; + Atom property = ev->property ? ev->property : ev->target; + if (!g.x11_selection) + goto out; + + XICCEncodingStyle style = 0; + if ((ok = ev->target == xa_targets)) + { + XChangeProperty (g.dpy, ev->requestor, property, + XA_ATOM, 32, PropModeReplace, + (const unsigned char *) targets, N_ELEMENTS (targets)); + goto out; + } + else if (ev->target == XA_STRING) + style = XStringStyle; + else if (ev->target == xa_compound_text) + style = XCompoundTextStyle; + else if (ev->target == xa_utf8) + style = XUTF8StringStyle; + else + goto out; + + // XXX: We let it crash us with BadLength, but we may, e.g., use INCR. + XTextProperty text = {}; + if ((ok = !Xutf8TextListToTextProperty + (g.dpy, &g.x11_selection, 1, style, &text))) + { + XChangeProperty (g.dpy, ev->requestor, property, + text.encoding, text.format, PropModeReplace, + text.value, text.nitems); + } + XFree (text.value); + +out: + response.xselection.type = SelectionNotify; + // XXX: We should check it against the event causing XSetSelectionOwner(). + response.xselection.time = ev->time; + response.xselection.requestor = ev->requestor; + response.xselection.selection = ev->selection; + response.xselection.target = ev->target; + response.xselection.property = ok ? property : None; + XSendEvent (g.dpy, ev->requestor, False, 0, &response); +} + +static bool +on_x11_input_event (XEvent *e) +{ + if (e->type != KeyPress) + return false; + + // A kibibyte long buffer will have to suffice for anyone. + XKeyEvent *ev = &e->xkey; + char buf[1 << 10] = {}, *p = buf; + KeySym keysym = None; + Status status = 0; + int len = Xutf8LookupString + (g.x11_ic, ev, buf, sizeof buf, &keysym, &status); + if (status == XBufferOverflow) + print_warning ("input method overflow"); + + // TODO: Implement an input line. + switch (keysym) + { + case XK_q: + app_quit (); + return true; + } + return false; +} + +static void +on_x11_event (XEvent *ev) +{ + switch (ev->type) + { + case Expose: + { + XRectangle r = { ev->xexpose.x, ev->xexpose.y, + ev->xexpose.width, ev->xexpose.height }; + XUnionRectWithRegion (&r, g.x11_clip, g.x11_clip); + poller_idle_set (&g.flip_event); + break; + } + case ConfigureNotify: + if (g.ui_width == ev->xconfigure.width + && g.ui_height == ev->xconfigure.height) + break; + + g.ui_width = ev->xconfigure.width; + g.ui_height = ev->xconfigure.height; + + XRenderFreePicture (g.dpy, g.x11_pixmap_picture); + XFreePixmap (g.dpy, g.x11_pixmap); + x11_init_pixmap (); + XftDrawChange (g.xft_draw, g.x11_pixmap); + app_invalidate (); + break; + case SelectionRequest: + on_x11_selection_request (&ev->xselectionrequest); + break; + case SelectionClear: + cstr_set (&g.x11_selection, NULL); + break; + // UnmapNotify can be received when restarting the window manager. + // Should this turn out to be unreliable (window not destroyed by WM + // upon closing), opt for the WM_DELETE_WINDOW protocol as well. + case DestroyNotify: + app_quit (); + break; + case FocusIn: + g.ui_focused = true; + app_invalidate (); + break; + case FocusOut: + g.ui_focused = false; + app_invalidate (); + break; + case KeyPress: + case ButtonPress: + case ButtonRelease: + case MotionNotify: + if (!on_x11_input_event (ev)) + XkbBell (g.dpy, ev->xany.window, 0, None); + } +} + +static void +on_x11_pending (void *user_data) +{ + (void) user_data; + + XkbEvent ev; + while (XPending (g.dpy)) + { + if (XNextEvent (g.dpy, &ev.core)) + exit_fatal ("XNextEvent returned non-zero"); + if (XFilterEvent (&ev.core, None)) + continue; + + on_x11_event (&ev.core); + } + + poller_idle_reset (&g.xpending_event); +} + +static void +on_x11_ready (const struct pollfd *pfd, void *user_data) +{ + (void) pfd; + on_x11_pending (user_data); +} + +static int +on_x11_error (Display *dpy, XErrorEvent *event) +{ + // Without opting for WM_DELETE_WINDOW, this window can become destroyed + // and hence invalid at any time. We don't use the Window much, + // so we should be fine ignoring these errors. + if ((event->error_code == BadWindow && event->resourceid == g.x11_window) + || (event->error_code == BadDrawable && event->resourceid == g.x11_window)) + return app_quit (), 0; + + // XXX: The simplest possible way of discarding selection management errors. + // XCB would be a small win here, but it is a curse at the same time. + if (event->error_code == BadWindow && event->resourceid != g.x11_window) + return 0; + + return x11_default_error_handler (dpy, event); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +x11_init (void) +{ + // https://tedyin.com/posts/a-brief-intro-to-linux-input-method-framework/ + if (!XSupportsLocale ()) + print_warning ("locale not supported by Xlib"); + XSetLocaleModifiers (""); + + if (!(g.dpy = XkbOpenDisplay + (NULL, &g.xkb_base_event_code, NULL, NULL, NULL, NULL))) + exit_fatal ("cannot open display"); + if (!XftDefaultHasRender (g.dpy)) + exit_fatal ("XRender is not supported"); + if (!(g.x11_im = XOpenIM (g.dpy, NULL, NULL, NULL))) + exit_fatal ("failed to open an input method"); + + x11_default_error_handler = XSetErrorHandler (on_x11_error); + + set_cloexec (ConnectionNumber (g.dpy)); + g.x11_event = poller_fd_make (&g.poller, ConnectionNumber (g.dpy)); + g.x11_event.dispatcher = on_x11_ready; + poller_fd_set (&g.x11_event, POLLIN); + + // Whenever something causes Xlib to read its socket, it can make + // the I/O event above fail to trigger for whatever might have ended up + // in its queue. So always use this instead of XSync: + g.xpending_event = poller_idle_make (&g.poller); + g.xpending_event.dispatcher = on_x11_pending; + poller_idle_set (&g.xpending_event); + + struct xdg_xsettings settings = xdg_xsettings_make (); + xdg_xsettings_update (&settings, g.dpy); + + if (!FcInit ()) + print_warning ("Fontconfig initialization failed"); + if (!(g.xft_fonts = x11_font_open (0))) + exit_fatal ("cannot open a font"); + + int screen = DefaultScreen (g.dpy); + Colormap cmap = DefaultColormap (g.dpy, screen); + XColor default_bg = + { + .red = x11_default_bg.red, + .green = x11_default_bg.green, + .blue = x11_default_bg.blue, + }; + if (!XAllocColor (g.dpy, cmap, &default_bg)) + exit_fatal ("X11 setup failed"); + + XSetWindowAttributes attrs = + { + .event_mask = StructureNotifyMask | ExposureMask | FocusChangeMask + | KeyPressMask | ButtonPressMask | ButtonReleaseMask + | Button1MotionMask, + .bit_gravity = NorthWestGravity, + .background_pixel = default_bg.pixel, + }; + + // Base the window's size on the regular font size. + // Roughly trying to match the 80x24 default dimensions of terminals. + g.ui_height = 24 * g.xft_fonts->list->font->height; + g.ui_width = g.ui_height * 4 / 3; + + long im_event_mask = 0; + if (!XGetIMValues (g.x11_im, XNFilterEvents, &im_event_mask, NULL)) + attrs.event_mask |= im_event_mask; + + Visual *visual = DefaultVisual (g.dpy, screen); + g.x11_window = XCreateWindow (g.dpy, RootWindow (g.dpy, screen), 100, 100, + g.ui_width, g.ui_height, 0, CopyFromParent, InputOutput, visual, + CWEventMask | CWBackPixel | CWBitGravity, &attrs); + g.x11_clip = XCreateRegion (); + + XTextProperty prop = {}; + char *name = PROGRAM_NAME; + if (!Xutf8TextListToTextProperty (g.dpy, &name, 1, XUTF8StringStyle, &prop)) + XSetWMName (g.dpy, g.x11_window, &prop); + XFree (prop.value); + + // This is a rather GNOME-centric mechanism, but it's better than nothing. + const char *icon_theme_name = NULL; + const struct xdg_xsettings_setting *setting = + str_map_find (&settings.settings, "Net/IconThemeName"); + if (setting != NULL && setting->type == XDG_XSETTINGS_STRING) + icon_theme_name = setting->string.str; + icon_theme_set_window_icon (g.dpy, g.x11_window, icon_theme_name, name); + xdg_xsettings_free (&settings); + + // TODO: It is possible to do, e.g., on-the-spot. + XIMStyle im_style = XIMPreeditNothing | XIMStatusNothing; + XIMStyles *im_styles = NULL; + bool im_style_found = false; + if (!XGetIMValues (g.x11_im, XNQueryInputStyle, &im_styles, NULL) + && im_styles) + { + for (unsigned i = 0; i < im_styles->count_styles; i++) + im_style_found |= im_styles->supported_styles[i] == im_style; + XFree (im_styles); + } + if (!im_style_found) + print_warning ("failed to find the desired input method style"); + if (!(g.x11_ic = XCreateIC (g.x11_im, + XNInputStyle, im_style, + XNClientWindow, g.x11_window, + NULL))) + exit_fatal ("failed to open an input context"); + + XSetICFocus (g.x11_ic); + + x11_init_pixmap (); + g.xft_draw = XftDrawCreate (g.dpy, g.x11_pixmap, visual, cmap); + + XMapWindow (g.dpy, g.x11_window); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +render_line (int *bottom, const char *text) +{ + XftFont *font = g.xft_fonts->list->font; + XftColor color = { .color = x11_default_fg }; + XftDrawStringUtf8 (g.xft_draw, &color, font, 0, *bottom - font->descent, + (const FcChar8 *) text, strlen (text)); + return (*bottom -= font->height) > 0; +} + +static void +x11_render (void) +{ + XRenderFillRectangle (g.dpy, PictOpSrc, g.x11_pixmap_picture, + &x11_default_bg, 0, 0, g.ui_width, g.ui_height); + + int bottom = g.ui_height; + render_line (&bottom, "xF"); + if (!g.buffer_current) + render_line (&bottom, "-"); + else + { + struct buffer *buffer = g.buffer_current; + render_line (&bottom, buffer->buffer_name); + struct buffer_line *line = buffer->lines_tail; + for (; line; line = line->prev) + if (!render_line (&bottom, line->text)) + break; + } + + XRectangle r = { 0, 0, g.ui_width, g.ui_height }; + XUnionRectWithRegion (&r, g.x11_clip, g.x11_clip); + poller_idle_set (&g.xpending_event); +} + +static void +x11_flip (void) +{ + // This exercise in futility doesn't seem to affect CPU usage much. + XRectangle r = {}; + XClipBox (g.x11_clip, &r); + XCopyArea (g.dpy, g.x11_pixmap, g.x11_window, + DefaultGC (g.dpy, DefaultScreen (g.dpy)), + r.x, r.y, r.width, r.height, r.x, r.y); + + XSubtractRegion (g.x11_clip, g.x11_clip, g.x11_clip); + poller_idle_set (&g.xpending_event); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +app_on_flip (void *user_data) +{ + (void) user_data; + poller_idle_reset (&g.flip_event); + + // Waste of time, and may cause X11 to render uninitialised pixmaps. + if (g.polling && !g.refresh_event.active) + x11_flip (); +} + +static void +app_on_refresh (void *user_data) +{ + (void) user_data; + poller_idle_reset (&g.refresh_event); + + x11_render (); + poller_idle_set (&g.flip_event); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +app_init_poller_events (void) +{ + g.refresh_event = poller_idle_make (&g.poller); + g.refresh_event.dispatcher = app_on_refresh; + + g.flip_event = poller_idle_make (&g.poller); + g.flip_event.dispatcher = app_on_flip; +} + int main (int argc, char *argv[]) { @@ -166,7 +1162,15 @@ main (int argc, char *argv[]) if (!port) exit_fatal ("missing port number/service name"); - // TODO: Actually implement an X11-based user interface. - protocol_test (host, port); + app_init_context (); + app_init_poller_events (); + x11_init (); + + relay_connect (host, port); + free (address); + + g.polling = true; + while (g.polling) + poller_run (&g.poller); return 0; } @@ -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) 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) { |