diff options
Diffstat (limited to 'xP')
-rw-r--r-- | xP/.gitignore | 4 | ||||
-rw-r--r-- | xP/Makefile | 18 | ||||
-rw-r--r-- | xP/gen-ircfmt.awk | 89 | ||||
-rw-r--r-- | xP/go.mod | 10 | ||||
-rw-r--r-- | xP/go.sum | 62 | ||||
-rw-r--r-- | xP/public/ircfmt.woff2 | bin | 0 -> 1240 bytes | |||
-rw-r--r-- | xP/public/xP.css | 257 | ||||
-rw-r--r-- | xP/public/xP.js | 1108 | ||||
-rw-r--r-- | xP/xP.go | 299 |
9 files changed, 1847 insertions, 0 deletions
diff --git a/xP/.gitignore b/xP/.gitignore new file mode 100644 index 0000000..ba4d8c3 --- /dev/null +++ b/xP/.gitignore @@ -0,0 +1,4 @@ +/xP +/proto.go +/public/proto.js +/public/mithril.js diff --git a/xP/Makefile b/xP/Makefile new file mode 100644 index 0000000..34de55a --- /dev/null +++ b/xP/Makefile @@ -0,0 +1,18 @@ +.POSIX: +.SUFFIXES: + +outputs = xP proto.go public/proto.js public/mithril.js +all: $(outputs) public/ircfmt.woff2 + +xP: xP.go proto.go + go build -o $@ +proto.go: ../xC-gen-proto.awk ../xC-gen-proto-go.awk ../xC-proto + awk -f ../xC-gen-proto.awk -f ../xC-gen-proto-go.awk ../xC-proto > $@ +public/proto.js: ../xC-gen-proto.awk ../xC-gen-proto-js.awk ../xC-proto + awk -f ../xC-gen-proto.awk -f ../xC-gen-proto-js.awk ../xC-proto > $@ +public/ircfmt.woff2: gen-ircfmt.awk + awk -v Output=$@ -f gen-ircfmt.awk +public/mithril.js: + curl -Lo $@ https://unpkg.com/mithril/mithril.js +clean: + rm -f $(outputs) diff --git a/xP/gen-ircfmt.awk b/xP/gen-ircfmt.awk new file mode 100644 index 0000000..cc9a5a0 --- /dev/null +++ b/xP/gen-ircfmt.awk @@ -0,0 +1,89 @@ +# gen-ircfmt.awk: generate a supplementary font for IRC formatting characters +# +# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +# SPDX-License-Identifier: 0BSD +# +# Usage: awk -v Output=static/ircfmt.woff2 -f gen-ircfmt.awk +# Clean up SVG byproducts yourself. + +BEGIN { + if (!Output) { + print "Error: you must specify the output filename" + exit 1 + } +} + +function glyph(name, code, path, filename, actions, cmd) { + filename = Output "." name ".svg" + + # Inkscape still does a terrible job at the stroke-to-path conversion. + actions = \ + "select-by-id:group;" \ + "selection-ungroup;" \ + "select-clear;" \ + "select-by-id:path;" \ + "object-stroke-to-path;" \ + "select-by-id:clip;" \ + "path-intersection;" \ + "select-all;" \ + "path-combine;" \ + "export-overwrite;" \ + "export-filename:" filename ";" \ + "export-do" + + # These dimensions fit FontForge defaults, and happen to work well. + cmd = "inkscape --pipe --actions='" actions "'" + print "<?xml version='1.0' encoding='UTF-8' standalone='no'?>\n" \ + "<svg version='1.1' xmlns='http://www.w3.org/2000/svg'\n" \ + " width='1000' height='1000' viewBox='0 0 1000 1000'>\n" \ + " <rect x='0' y='0' width='1000' height='1000' />\n" \ + " <g id='group' transform='translate(200 200) scale(60, 60)'>\n" \ + " <rect id='clip' x='0' y='0' width='10' height='10' />\n" \ + " <path id='path' stroke-width='2' fill='none' stroke='#fff'\n" \ + " d='" path "' />\n" \ + " </g>\n" \ + "</svg>\n" | cmd + close(cmd) + + print "Select(0u" code ")\n" \ + "Import('" filename "')" | FontForge +} + +BEGIN { + FontForge = "fontforge -lang=ff -" + print "New()" | FontForge + + # Designed a 10x10 raster, going for maximum simplicity. + glyph("B", "02", "m 6,5 c 0,0 2,0 2,2 0,2 -2,2 -2,2 h -3 v -8 h 2.5 c 0,0 2,0 2,2 0,2 -2,2 -2,2 h -2 Z") + glyph("C", "03", "m 7.6,7 A 3,4 0 0 1 4.25,8.875 3,4 0 0 1 2,5 3,4 0 0 1 4.25,1.125 3,4 0 0 1 7.6,3") + glyph("I", "1D", "m 3,9 h 4 m 0,-8 h -4 m 2,-1 v 10") + glyph("M", "11", "m 2,10 v -10 l 3,6 3,-6 v 10") + glyph("O", "0F", "m 1,9 l 8,-8 M 2,5 a 3,3 0 1 0 6,0 3,3 0 1 0 -6,0 z") + #glyph("R", "0F", "m 3,10 v -9 h 2 c 0,0 2.5,0 2.5,2.5 0,2.5 -2.5,2.5 -2.5,2.5 h -2 2.5 l 2.5,4.5") + glyph("S", "1E", "m 7.5,3 c 0,-1 -1,-2 -2.5,-2 -1.5,0 -2.5,1 -2.5,2 0,3 5,1 5,4 0,1 -1,2 -2.5,2 -1.5,0 -2.5,-1 -2.5,-2") + glyph("U", "1F", "m 2.5,0 v 6.5 c 0,1.5 1,2.5 2.5,2.5 1.5,0 2.5,-1 2.5,-2.5 v -6.5") + glyph("V", "16", "m 2,-1 3,11 3,-11") + + # In practice, your typical browser font will overshoot its em box, + # so to make the display more cohesive, we need to do the same. + # Sadly, sf->use_typo_metrics can't be unset from FontForge script-- + # this is necessary to prevent the caret from jumping upon the first + # inserted non-formatting character in xP's textarea. + # https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align + print "SelectAll()\n" \ + "Scale(115, 115, 0, 0)\n" \ + "SetOS2Value('WinAscentIsOffset', 1)\n" \ + "SetOS2Value('WinDescentIsOffset', 1)\n" \ + "SetOS2Value('HHeadAscentIsOffset', 1)\n" \ + "SetOS2Value('HHeadDescentIsOffset', 1)\n" \ + "CorrectDirection()\n" \ + "AutoWidth(100)\n" \ + "AutoHint()\n" \ + "AddExtrema()\n" \ + "RoundToInt()\n" \ + "SetFontNames('IRCFormatting-Regular'," \ + " 'IRC Formatting', 'IRC Formatting Regular', 'Regular'," \ + " 'Copyright (c) 2022, Premysl Eric Janouch')\n" \ + "Generate('" Output "')\n" | FontForge + close(FontForge) +} diff --git a/xP/go.mod b/xP/go.mod new file mode 100644 index 0000000..70cc10d --- /dev/null +++ b/xP/go.mod @@ -0,0 +1,10 @@ +module janouch.name/xK/xP + +go 1.18 + +require nhooyr.io/websocket v1.8.7 + +require ( + github.com/klauspost/compress v1.15.9 // indirect + golang.org/x/sys v0.0.0-20210423082822-04245dca01da // indirect +) diff --git a/xP/go.sum b/xP/go.sum new file mode 100644 index 0000000..c94a673 --- /dev/null +++ b/xP/go.sum @@ -0,0 +1,62 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= +github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/xP/public/ircfmt.woff2 b/xP/public/ircfmt.woff2 Binary files differnew file mode 100644 index 0000000..d4262bc --- /dev/null +++ b/xP/public/ircfmt.woff2 diff --git a/xP/public/xP.css b/xP/public/xP.css new file mode 100644 index 0000000..e8b28f2 --- /dev/null +++ b/xP/public/xP.css @@ -0,0 +1,257 @@ +@font-face { + src: url('ircfmt.woff2') format('woff2'); + font-family: 'IRC Formatting'; + font-weight: normal; + font-style: normal; +} +body { + margin: 0; + padding: 0; + /* Firefox only renders C0 within the textarea, why? */ + font-family: 'IRC Formatting', sans-serif; + font-size: clamp(0.5rem, 2vw, 1rem); +} +.xP { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100vh; + /* https://caniuse.com/viewport-unit-variants */ + height: 100dvh; +} + +.overlay { + padding: .6em .9em; + background: #eee; + border: 1px outset #eee; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + z-index: 1; +} +.title, .status { + padding: .05em .3em; + background: #eee; + + display: flex; + justify-content: space-between; + align-items: baseline; + column-gap: .3em; + + position: relative; + border-top: 3px solid #ccc; + border-bottom: 2px solid #888; +} +.title { + /* To approximate right-aligned space-between. */ + flex-direction: row-reverse; +} +.title:before, .status:before { + content: " "; + position: absolute; + left: 0; + right: 0; + height: 2px; + top: -2px; + background: #fff; +} +.title:after, .status:after { + content: " "; + position: absolute; + left: 0; + right: 0; + height: 1px; + bottom: -1px; + background: #ccc; +} + +.toolbar { + display: flex; + align-items: baseline; + margin-right: -.3em; +} +.indicator { + margin: 0 .3em; +} +button { + font: inherit; + background: transparent; + border: 1px solid transparent; + padding: 0 .3em; +} +button:focus { + border: 1px dotted #000; +} +button:hover { + border-left: 1px solid #fff; + border-top: 1px solid #fff; + border-right: 1px solid #888; + border-bottom: 1px solid #888; +} +button:hover:active { + border-left: 1px solid #888; + border-top: 1px solid #888; + border-right: 1px solid #fff; + border-bottom: 1px solid #fff; +} + +.middle { + flex: auto; + display: flex; + flex-direction: row; + overflow: hidden; +} + +.list { + overflow-y: auto; + border-right: 2px solid #ccc; + min-width: 10em; + flex-shrink: 0; +} +.item { + padding: .05em .3em; + cursor: default; +} +.item.highlighted { + color: #ff5f00; +} +.item.activity { + font-weight: bold; +} +.item.current { + font-style: italic; + background: #eee; +} + +/* Only Firefox currently supports align-content: safe end, thus this. */ +.buffer-container { + flex: auto; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} +.filler { + flex: auto; +} +.buffer { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + overflow-y: auto; +} +.log { + font-family: monospace; + overflow-y: auto; +} +.log, .content, .completions { + /* Note: https://bugs.chromium.org/p/chromium/issues/detail?id=1261435 */ + white-space: break-spaces; + overflow-wrap: break-word; +} +.log, .buffer .content { + padding: .1em .3em; +} + +.leaked { + opacity: 50%; +} +.date { + padding: .3em; + grid-column: span 2; + font-weight: bold; +} +.unread { + grid-column: span 2; + border-top: 1px solid #ff5f00; +} +.time { + padding: .1em .3em; + background: #f8f8f8; + color: #bbb; + border-right: 1px solid #ccc; +} +.time.hidden:after { + border-top: .2em dotted #ccc; + display: block; + width: 50%; + margin: 0 auto; + content: ""; +} +.mark { + padding-right: .3em; + text-align: center; + display: inline-block; + min-width: 2em; +} +.mark.error { + color: red; +} +.mark.join { + color: green; +} +.mark.part { + color: red; +} +.mark.action { + color: darkred; +} +.content .b { + font-weight: bold; +} +.content .i { + font-style: italic; +} +.content .u { + text-decoration: underline; +} +.content .s { + text-decoration: line-through; +} +.content .m { + font-family: monospace; +} + +.completions { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: #fff; + padding: .05em .3em; + border-top: 1px solid #888; + + max-height: 50%; + display: flex; + flex-flow: column wrap; + column-gap: .6em; + overflow-x: auto; +} +.input { + flex-shrink: 0; + border: 2px inset #eee; + overflow: hidden; + resize: vertical; + display: flex; +} +.input:focus-within { + border-color: #ff5f00; +} +.prompt { + padding: .05em .3em; + border-right: 1px solid #ccc; + background: #f8f8f8; + font-weight: bold; +} +textarea { + font: inherit; + padding: .05em .3em; + margin: 0; + border: 0; + flex-grow: 1; + resize: none; +} +textarea:focus { + outline: none; +} diff --git a/xP/public/xP.js b/xP/public/xP.js new file mode 100644 index 0000000..3266063 --- /dev/null +++ b/xP/public/xP.js @@ -0,0 +1,1108 @@ +// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +// SPDX-License-Identifier: 0BSD +import * as Relay from './proto.js' + +// ---- RPC -------------------------------------------------------------------- + +class RelayRPC extends EventTarget { + constructor(url) { + super() + this.url = url + this.commandSeq = 0 + } + + connect() { + // We can't close the connection immediately, as that queues a task. + if (this.ws !== undefined) + throw "Already connecting or connected" + + return new Promise((resolve, reject) => { + let ws = this.ws = new WebSocket(this.url) + ws.onopen = event => { + this._initialize() + resolve() + } + // It's going to be code 1006 with no further info. + ws.onclose = event => { + this.ws = undefined + reject() + } + }) + } + + _initialize() { + this.ws.binaryType = 'arraybuffer' + this.ws.onopen = undefined + this.ws.onmessage = event => { + this._process(event.data) + } + this.ws.onerror = event => { + this.dispatchEvent(new CustomEvent('error')) + } + this.ws.onclose = event => { + let message = "Connection closed: " + + event.reason + " (" + event.code + ")" + for (const seq in this.promised) + this.promised[seq].reject(message) + + this.ws = undefined + this.dispatchEvent(new CustomEvent('close', { + detail: {message, code: event.code, reason: event.reason}, + })) + + // Now connect() can be called again. + } + + this.promised = {} + } + + _process(data) { + if (typeof data === 'string') + throw "JSON messages not supported" + + const r = new Relay.Reader(data) + while (!r.empty) + this._processOne(Relay.EventMessage.deserialize(r)) + } + + _processOne(message) { + let e = message.data + switch (e.event) { + case Relay.Event.Error: + if (this.promised[e.commandSeq] !== undefined) + this.promised[e.commandSeq].reject(e.error) + else + console.error(`Unawaited error: ${e.error}`) + break + case Relay.Event.Response: + if (this.promised[e.commandSeq] !== undefined) + this.promised[e.commandSeq].resolve(e.data) + else + console.error("Unawaited response") + break + default: + e.eventSeq = message.eventSeq + this.dispatchEvent(new CustomEvent('event', {detail: e})) + return + } + + delete this.promised[e.commandSeq] + for (const seq in this.promised) { + // We don't particularly care about wraparound issues. + if (seq >= e.commandSeq) + continue + + this.promised[seq].reject("No response") + delete this.promised[seq] + } + } + + send(params) { + if (this.ws === undefined) + throw "Not connected" + if (typeof params !== 'object') + throw "Method parameters must be an object" + + // Left shifts in Javascript convert to a 32-bit signed number. + let seq = ++this.commandSeq + if ((seq << 0) != seq) + seq = this.commandSeq = 0 + + this.ws.send(JSON.stringify({commandSeq: seq, data: params})) + + // Automagically detect if we want a result. + let data = undefined + const promise = new Promise( + (resolve, reject) => { data = {resolve, reject} }) + promise.then = (...args) => { + this.promised[seq] = data + return Promise.prototype.then.call(promise, ...args) + } + return promise + } +} + +// ---- Utilities -------------------------------------------------------------- + +function utf8Encode(s) { return new TextEncoder().encode(s) } +function utf8Decode(s) { return new TextDecoder().decode(s) } + +function hasShortcutModifiers(event) { + return (event.altKey || event.escapePrefix) && + !event.metaKey && !event.ctrlKey +} + +const audioContext = new AudioContext() + +function beep() { + let gain = audioContext.createGain() + gain.gain.value = 0.5 + gain.connect(audioContext.destination) + + let oscillator = audioContext.createOscillator() + oscillator.type = "triangle" + oscillator.frequency.value = 800 + oscillator.connect(gain) + oscillator.start(audioContext.currentTime) + oscillator.stop(audioContext.currentTime + 0.1) +} + +let iconLink = undefined +let iconState = undefined + +function updateIcon(highlighted) { + if (iconState === highlighted) + return + + iconState = highlighted + let canvas = document.createElement('canvas') + canvas.width = 32 + canvas.height = 32 + + let ctx = canvas.getContext('2d') + ctx.arc(16, 16, 12, 0, 2 * Math.PI) + ctx.fillStyle = '#000' + if (highlighted === true) + ctx.fillStyle = '#ff5f00' + if (highlighted === false) + ctx.fillStyle = '#ccc' + ctx.fill() + + if (iconLink === undefined) { + iconLink = document.createElement('link') + iconLink.type = 'image/png' + iconLink.rel = 'icon' + document.getElementsByTagName('head')[0].appendChild(iconLink) + } + + iconLink.href = canvas.toDataURL(); +} + +// ---- Event processing ------------------------------------------------------- + +let rpc = new RelayRPC(proxy) +let rpcEventHandlers = new Map() + +let buffers = new Map() +let bufferLast = undefined +let bufferCurrent = undefined +let bufferLog = undefined +let bufferAutoscroll = true + +let servers = new Map() + +function bufferResetStats(b) { + b.newMessages = 0 + b.newUnimportantMessages = 0 + b.highlighted = false +} + +function bufferActivate(name) { + rpc.send({command: 'BufferActivate', bufferName: name}) +} + +function bufferToggleUnimportant(name) { + rpc.send({command: 'BufferToggleUnimportant', bufferName: name}) +} + +function bufferToggleLog() { + // TODO: Try to restore the previous scroll offset. + if (bufferLog) { + setTimeout(() => + document.getElementById('input')?.focus()) + + bufferLog = undefined + m.redraw() + return + } + + let name = bufferCurrent + rpc.send({ + command: 'BufferLog', + bufferName: name, + }).then(resp => { + if (bufferCurrent !== name) + return + + bufferLog = utf8Decode(resp.log) + m.redraw() + }) +} + +let connecting = true +rpc.connect().then(result => { + buffers.clear() + bufferLast = undefined + bufferCurrent = undefined + bufferLog = undefined + bufferAutoscroll = true + + servers.clear() + + rpc.send({command: 'Hello', version: Relay.version}) + connecting = false + m.redraw() +}).catch(error => { + connecting = false + m.redraw() +}) + +rpc.addEventListener('close', event => { + m.redraw() +}) + +rpc.addEventListener('event', event => { + const handler = rpcEventHandlers.get(event.detail.event) + if (handler !== undefined) { + handler(event.detail) + if (bufferCurrent !== undefined || + event.detail.event !== Relay.Event.BufferLine) + m.redraw() + } +}) + +rpcEventHandlers.set(Relay.Event.Ping, e => { + rpc.send({command: 'PingResponse', eventSeq: e.eventSeq}) +}) + +// ~~~ Buffer events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +rpcEventHandlers.set(Relay.Event.BufferLine, e => { + let b = buffers.get(e.bufferName), line = {...e} + delete line.event + delete line.eventSeq + delete line.leakToActive + if (b === undefined) + return + + // Initial sync: skip all other processing, let highlights be. + if (bufferCurrent === undefined) { + b.lines.push(line) + return + } + + let visible = document.visibilityState !== 'hidden' && + bufferLog === undefined && + bufferAutoscroll && + (e.bufferName == bufferCurrent || e.leakToActive) + b.lines.push({...line}) + if (!(visible || e.leakToActive) || + b.newMessages || b.newUnimportantMessages) { + if (line.isUnimportant) + b.newUnimportantMessages++ + else + b.newMessages++ + } + + if (e.leakToActive) { + let bc = buffers.get(bufferCurrent) + bc.lines.push({...line, leaked: true}) + if (!visible || bc.newMessages || bc.newUnimportantMessages) { + if (line.isUnimportant) + bc.newUnimportantMessages++ + else + bc.newMessages++ + } + } + + if (line.isHighlight || (!visible && !line.isUnimportant && + b.kind === Relay.BufferKind.PrivateMessage)) { + beep() + if (!visible) + b.highlighted = true + } +}) + +rpcEventHandlers.set(Relay.Event.BufferUpdate, e => { + let b = buffers.get(e.bufferName) + if (b === undefined) { + buffers.set(e.bufferName, (b = { + lines: [], + history: [], + historyAt: 0, + })) + bufferResetStats(b) + } + + b.hideUnimportant = e.hideUnimportant + b.kind = e.context.kind + b.server = servers.get(e.context.serverName) + b.topic = e.context.topic + b.modes = e.context.modes +}) + +rpcEventHandlers.set(Relay.Event.BufferStats, e => { + let b = buffers.get(e.bufferName) + if (b === undefined) + return + + b.newMessages = e.newMessages, + b.newUnimportantMessages = e.newUnimportantMessages + b.highlighted = e.highlighted +}) + +rpcEventHandlers.set(Relay.Event.BufferRename, e => { + buffers.set(e.new, buffers.get(e.bufferName)) + buffers.delete(e.bufferName) +}) + +rpcEventHandlers.set(Relay.Event.BufferRemove, e => { + buffers.delete(e.bufferName) + if (e.bufferName === bufferLast) + bufferLast = undefined +}) + +rpcEventHandlers.set(Relay.Event.BufferActivate, e => { + let old = buffers.get(bufferCurrent) + if (old !== undefined) + bufferResetStats(old) + + bufferLast = bufferCurrent + let b = buffers.get(e.bufferName) + bufferCurrent = e.bufferName + bufferLog = undefined + bufferAutoscroll = true + if (b !== undefined && document.visibilityState !== 'hidden') + b.highlighted = false + + let textarea = document.getElementById('input') + if (textarea === null) + return + + textarea.focus() + if (old !== undefined) { + old.input = textarea.value + old.inputStart = textarea.selectionStart + old.inputEnd = textarea.selectionEnd + old.inputDirection = textarea.selectionDirection + // Note that we effectively overwrite the newest line + // with the current textarea contents, and jump there. + old.historyAt = old.history.length + } + + textarea.value = '' + if (b !== undefined && b.input !== undefined) { + textarea.value = b.input + textarea.setSelectionRange(b.inputStart, b.inputEnd, b.inputDirection) + } +}) + +rpcEventHandlers.set(Relay.Event.BufferClear, e => { + let b = buffers.get(e.bufferName) + if (b !== undefined) + b.lines.length = 0 +}) + +// ~~~ Server events ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +rpcEventHandlers.set(Relay.Event.ServerUpdate, e => { + let s = servers.get(e.serverName) + if (s === undefined) + servers.set(e.serverName, (s = {})) + s.data = e.data +}) + +rpcEventHandlers.set(Relay.Event.ServerRename, e => { + servers.set(e.new, servers.get(e.serverName)) + servers.delete(e.serverName) +}) + +rpcEventHandlers.set(Relay.Event.ServerRemove, e => { + servers.delete(e.serverName) +}) + +// --- Colours ----------------------------------------------------------------- + +let palette = [ + '#000', '#800', '#080', '#880', '#008', '#808', '#088', '#ccc', + '#888', '#f00', '#0f0', '#ff0', '#00f', '#f0f', '#0ff', '#fff', +] +palette.length = 256 +for (let i = 0; i < 216; i++) { + let r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6 + r = !r ? '00' : (55 + 40 * r).toString(16) + g = !g ? '00' : (55 + 40 * g).toString(16) + b = !b ? '00' : (55 + 40 * b).toString(16) + palette[16 + i] = `#${r}${g}${b}` +} +for (let i = 0; i < 24; i++) { + let g = ('0' + (8 + i * 10).toString(16)).slice(-2) + palette[232 + i] = `#${g}${g}${g}` +} + +// ---- UI --------------------------------------------------------------------- + +let linkRE = [ + /https?:\/\//, + /([^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+/, + /([^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\))/, +].map(r => r.source).join('') + +let BufferList = { + view: vnode => { + let highlighted = false + let items = Array.from(buffers, ([name, b]) => { + let classes = [], displayName = name + if (name == bufferCurrent) { + classes.push('current') + } else if (b.newMessages) { + classes.push('activity') + displayName += ` (${b.newMessages})` + } + if (b.highlighted) { + classes.push('highlighted') + highlighted = true + } + return m('.item', { + onclick: event => bufferActivate(name), + class: classes.join(' '), + }, displayName) + }) + + updateIcon(rpc.ws === undefined ? null : highlighted) + return m('.list', {}, items) + }, +} + +let Content = { + applyColor: (fg, bg, inverse) => { + if (inverse) + [fg, bg] = [bg >= 0 ? bg : 15, fg >= 0 ? fg : 0] + + let style = {} + if (fg >= 0) + style.color = palette[fg] + if (bg >= 0) + style.backgroundColor = palette[bg] + for (const _ in style) + return style + }, + + linkify: (text, attrs) => { + let re = new RegExp(linkRE, 'g'), a = [], end = 0, match + while ((match = re.exec(text)) !== null) { + if (end < match.index) + a.push(m('span', attrs, text.substring(end, match.index))) + a.push(m('a[target=_blank]', {href: match[0], ...attrs}, match[0])) + end = re.lastIndex + } + if (end < text.length) + a.push(m('span', attrs, text.substring(end))) + return a + }, + + makeMark: line => { + switch (line.rendition) { + case Relay.Rendition.Indent: return m('span.mark', {}, '') + case Relay.Rendition.Status: return m('span.mark', {}, '–') + case Relay.Rendition.Error: return m('span.mark.error', {}, '⚠') + case Relay.Rendition.Join: return m('span.mark.join', {}, '→') + case Relay.Rendition.Part: return m('span.mark.part', {}, '←') + case Relay.Rendition.Action: return m('span.mark.action', {}, '✶') + } + }, + + view: vnode => { + let line = vnode.children[0] + let classes = new Set() + let flip = c => { + if (classes.has(c)) + classes.delete(c) + else + classes.add(c) + } + + let fg = -1, bg = -1, inverse = false + return m('.content', vnode.attrs, [ + Content.makeMark(line), + line.items.flatMap(item => { + switch (item.kind) { + case Relay.Item.Text: + return Content.linkify(item.text, { + class: Array.from(classes.keys()).join(' '), + style: Content.applyColor(fg, bg, inverse), + }) + case Relay.Item.Reset: + classes.clear() + fg = bg = -1 + inverse = false + break + case Relay.Item.FgColor: + fg = item.color + break + case Relay.Item.BgColor: + bg = item.color + break + case Relay.Item.FlipInverse: + inverse = !inverse + break + case Relay.Item.FlipBold: + flip('b') + break + case Relay.Item.FlipItalic: + flip('i') + break + case Relay.Item.FlipUnderline: + flip('u') + break + case Relay.Item.FlipCrossedOut: + flip('s') + break + case Relay.Item.FlipMonospace: + flip('m') + break + } + }), + ]) + }, +} + +let Topic = { + view: vnode => { + let b = buffers.get(bufferCurrent) + if (b !== undefined && b.topic !== undefined) + return m(Content, {}, {items: b.topic}) + }, +} + +let Buffer = { + controller: new AbortController(), + + onbeforeremove: vnode => { + Buffer.controller.abort() + }, + + onupdate: vnode => { + if (bufferAutoscroll) + vnode.dom.scrollTop = vnode.dom.scrollHeight + }, + + oncreate: vnode => { + Buffer.onupdate(vnode) + window.addEventListener('resize', event => Buffer.onupdate(vnode), + {signal: Buffer.controller.signal}) + }, + + view: vnode => { + let lines = [] + let b = buffers.get(bufferCurrent) + if (b === undefined) + return m('.buffer') + + let lastDateMark = undefined + let squashing = false + let markBefore = b.lines.length + - b.newMessages - b.newUnimportantMessages + b.lines.forEach((line, i) => { + if (i == markBefore) + lines.push(m('.unread')) + + if (!line.isUnimportant || !b.hideUnimportant) { + squashing = false + } else if (squashing) { + return + } else { + squashing = true + } + + let date = new Date(line.when) + let dateMark = date.toLocaleDateString() + if (dateMark !== lastDateMark) { + lines.push(m('.date', {}, dateMark)) + lastDateMark = dateMark + } + if (squashing) { + lines.push(m('.time.hidden')) + lines.push(m('.content')) + return + } + + let attrs = {} + if (line.leaked) + attrs.class = 'leaked' + + lines.push(m('.time', {...attrs}, date.toLocaleTimeString())) + lines.push(m(Content, {...attrs}, line)) + }) + + let dateMark = new Date().toLocaleDateString() + if (dateMark !== lastDateMark && lastDateMark !== undefined) + lines.push(m('.date', {}, dateMark)) + return m('.buffer', {onscroll: event => { + const dom = event.target + bufferAutoscroll = + dom.scrollTop + dom.clientHeight + 1 >= dom.scrollHeight + }}, lines) + }, +} + +let Log = { + oncreate: vnode => { + vnode.dom.scrollTop = vnode.dom.scrollHeight + vnode.dom.focus() + }, + + linkify: text => { + let re = new RegExp(linkRE, 'g'), a = [], end = 0, match + while ((match = re.exec(text)) !== null) { + if (end < match.index) + a.push(text.substring(end, match.index)) + a.push(m('a[target=_blank]', {href: match[0]}, match[0])) + end = re.lastIndex + } + if (end < text.length) + a.push(text.substring(end)) + return a + }, + + view: vnode => { + return m(".log", {}, Log.linkify(bufferLog)) + }, +} + +let Completions = { + entries: [], + + reset: list => { + Completions.entries = list || [] + m.redraw() + }, + + view: vnode => { + if (!Completions.entries.length) + return + return m('.completions', {}, + Completions.entries.map(option => m('.completion', {}, option))) + }, +} + +let BufferContainer = { + view: vnode => { + return m('.buffer-container', {}, [ + m('.filler'), + bufferLog !== undefined ? m(Log) : m(Buffer), + m(Completions), + ]) + }, +} + +let Toolbar = { + format: formatting => { + let textarea = document.getElementById('input') + if (textarea !== null) + Input.format(textarea, formatting) + }, + + view: vnode => { + let indicators = [] + if (bufferLog === undefined && !bufferAutoscroll) + indicators.push(m('.indicator', {}, '⇩')) + if (Input.formatting) + indicators.push(m('.indicator', {}, '#')) + return m('.toolbar', {}, [ + indicators, + m('button', {onclick: event => Toolbar.format('\u0002')}, + m('b', {}, 'B')), + m('button', {onclick: event => Toolbar.format('\u001D')}, + m('i', {}, 'I')), + m('button', {onclick: event => Toolbar.format('\u001F')}, + m('u', {}, 'U')), + m('button', {onclick: event => bufferToggleLog()}, + bufferLog === undefined ? 'Log' : 'Hide log'), + ]) + }, +} + +let Status = { + view: vnode => { + let b = buffers.get(bufferCurrent) + if (b === undefined) + return m('.status', {}, 'Synchronizing...') + + let status = `${bufferCurrent}` + if (b.modes) + status += `(+${b.modes})` + if (b.hideUnimportant) + status += `<H>` + return m('.status', {}, [status, m(Toolbar)]) + }, +} + +let Prompt = { + view: vnode => { + let b = buffers.get(bufferCurrent) + if (b === undefined || b.server === undefined) + return + + if (b.server.data.user !== undefined) { + let user = b.server.data.user + if (b.server.data.userModes) + user += `(${b.server.data.userModes})` + return m('.prompt', {}, `${user}`) + } + + // This might certainly be done more systematically. + let state = b.server.data.state + for (const s in Relay.ServerState) + if (Relay.ServerState[s] == state) { + state = s + break + } + return m('.prompt', {}, `(${state})`) + }, +} + +let Input = { + counter: 0, + stamp: textarea => { + return [Input.counter, + textarea.selectionStart, textarea.selectionEnd, textarea.value] + }, + + complete: (b, textarea) => { + if (textarea.selectionStart !== textarea.selectionEnd) + return false + + // Cancel any previous autocomplete, and ensure applicability. + Input.counter++ + let state = Input.stamp(textarea) + rpc.send({ + command: 'BufferComplete', + bufferName: bufferCurrent, + text: textarea.value, + position: utf8Encode( + textarea.value.slice(0, textarea.selectionEnd)).length, + }).then(resp => { + if (!Input.stamp(textarea).every((v, k) => v === state[k])) + return + + let preceding = utf8Encode(textarea.value).slice(0, resp.start) + let start = utf8Decode(preceding).length + if (resp.completions.length > 0) { + textarea.setRangeText(resp.completions[0], + start, textarea.selectionEnd, 'end') + } + + if (resp.completions.length == 1) { + textarea.setRangeText(' ', + textarea.selectionStart, textarea.selectionEnd, 'end') + } else { + beep() + } + + if (resp.completions.length > 1) + Completions.reset(resp.completions.slice(1)) + }) + return true + }, + + submit: (b, textarea) => { + rpc.send({ + command: 'BufferInput', + bufferName: bufferCurrent, + text: textarea.value, + }) + + // b.history[b.history.length] is virtual, and is represented + // either by textarea contents when it's currently being edited, + // or by b.input in all other cases. + b.history.push(textarea.value) + b.historyAt = b.history.length + textarea.value = '' + return true + }, + + backward: textarea => { + if (textarea.selectionStart !== textarea.selectionEnd) + return false + + let point = textarea.selectionStart + if (point < 1) + return false + while (point && /\s/.test(textarea.value.charAt(--point))) {} + while (point-- && !/\s/.test(textarea.value.charAt(point))) {} + point++ + textarea.setSelectionRange(point, point) + return true + }, + + forward: textarea => { + if (textarea.selectionStart !== textarea.selectionEnd) + return false + + let point = textarea.selectionStart, len = textarea.value.length + if (point + 1 > len) + return false + while (point < len && /\s/.test(textarea.value.charAt(point))) point++ + while (point < len && !/\s/.test(textarea.value.charAt(point))) point++ + textarea.setSelectionRange(point, point) + return true + }, + + modifyWord: (textarea, cb) => { + let start = textarea.selectionStart + let end = textarea.selectionEnd + if (start === end) { + let len = textarea.value.length + while (start < len && /\s/.test(textarea.value.charAt(start))) + start++; + end = start + while (end < len && !/\s/.test(textarea.value.charAt(end))) + end++; + } + if (start === end) + return false + + const text = textarea.value, modified = cb(text.substring(start, end)) + textarea.value = text.slice(0, start) + modified + text.slice(end) + end = start + modified.length + textarea.setSelectionRange(end, end) + return true + }, + + downcase: textarea => { + return Input.modifyWord(textarea, text => text.toLowerCase()) + }, + + upcase: textarea => { + return Input.modifyWord(textarea, text => text.toUpperCase()) + }, + + capitalize: textarea => { + return Input.modifyWord(textarea, text => { + const cps = Array.from(text.toLowerCase()) + return cps[0].toUpperCase() + cps.slice(1).join('') + }) + }, + + first: (b, textarea) => { + if (b.historyAt <= 0) + return false + + if (b.historyAt == b.history.length) + b.input = textarea.value + textarea.value = b.history[(b.historyAt = 0)] + return true + }, + + last: (b, textarea) => { + if (b.historyAt >= b.history.length) + return false + + b.historyAt = b.history.length + textarea.value = b.input + return true + }, + + previous: (b, textarea) => { + if (b.historyAt <= 0) + return false + + if (b.historyAt == b.history.length) + b.input = textarea.value + textarea.value = b.history[--b.historyAt] + return true + }, + + next: (b, textarea) => { + if (b.historyAt >= b.history.length) + return false + + if (++b.historyAt == b.history.length) + textarea.value = b.input + else + textarea.value = b.history[b.historyAt] + return true + }, + + formatting: false, + + format: (textarea, formatting) => { + const [start, end] = [textarea.selectionStart, textarea.selectionEnd] + if (start === end) { + textarea.setRangeText(formatting) + textarea.setSelectionRange( + start + formatting.length, end + formatting.length) + } else { + textarea.setRangeText( + formatting + textarea.value.substr(start, end) + formatting) + } + textarea.focus() + }, + + onKeyDown: event => { + // TODO: And perhaps on other actions, too. + rpc.send({command: 'Active'}) + + let b = buffers.get(bufferCurrent) + if (b === undefined) + return + + let textarea = event.currentTarget + let handled = false + let success = true + if (Input.formatting) { + Input.formatting = false + + // Like process_formatting_escape() within xC. + handled = true + switch (event.key) { + case 'b': Input.format(textarea, '\u0002'); break + case 'c': Input.format(textarea, '\u0003'); break + case 'q': + case 'm': Input.format(textarea, '\u0011'); break + case 'v': Input.format(textarea, '\u0016'); break + case 'i': + case ']': Input.format(textarea, '\u001D'); break + case 's': + case 'x': + case '^': Input.format(textarea, '\u001E'); break + case 'u': + case '_': Input.format(textarea, '\u001F'); break + case 'r': + case 'o': Input.format(textarea, '\u000F'); break + default: success = false + } + } else if (hasShortcutModifiers(event)) { + handled = true + switch (event.key) { + case 'b': success = Input.backward(textarea); break + case 'f': success = Input.forward(textarea); break + case 'l': success = Input.downcase(textarea); break + case 'u': success = Input.upcase(textarea); break + case 'c': success = Input.capitalize(textarea); break + case '<': success = Input.first(b, textarea); break + case '>': success = Input.last(b, textarea); break + case 'p': success = Input.previous(b, textarea); break + case 'n': success = Input.next(b, textarea); break + case 'm': success = Input.formatting = true; break + default: handled = false + } + } else if (!event.altKey && !event.ctrlKey && !event.metaKey && + !event.shiftKey) { + handled = true + switch (event.keyCode) { + case 9: success = Input.complete(b, textarea); break + case 13: success = Input.submit(b, textarea); break + default: handled = false + } + } + if (!success) + beep() + if (handled) + event.preventDefault() + }, + + onStateChange: event => { + Completions.reset() + Input.formatting = false + }, + + view: vnode => { + return m('textarea#input', { + rows: 1, + onkeydown: Input.onKeyDown, + oninput: Input.onStateChange, + // Sadly only supported in Firefox as of writing. + onselectionchange: Input.onStateChange, + // The list of completions is scrollable without receiving focus. + onblur: Input.onStateChange, + }) + }, +} + +let Main = { + view: vnode => { + let overlay = undefined + if (connecting) + overlay = m('.overlay', {}, "Connecting...") + else if (rpc.ws === undefined) + overlay = m('.overlay', {}, [ + m('', {}, "Disconnected"), + m('', {}, m('small', {}, "Reload page to reconnect.")), + ]) + + return m('.xP', {}, [ + overlay, + m('.title', {}, [m('b', {}, `xP`), m(Topic)]), + m('.middle', {}, [m(BufferList), m(BufferContainer)]), + m(Status), + m('.input', {}, [m(Prompt), m(Input)]), + ]) + }, +} + +window.addEventListener('load', () => m.mount(document.body, Main)) + +document.addEventListener('visibilitychange', event => { + let b = buffers.get(bufferCurrent) + if (b !== undefined && document.visibilityState !== 'hidden') { + b.highlighted = false + m.redraw() + } +}) + +// On macOS, the Alt/Option key transforms characters, which basically breaks +// all event.altKey shortcuts, so implement Escape prefixing on that system. +// This method of detection only works with Blink browsers, as of writing. +let lastWasEscape = false +document.addEventListener('keydown', event => { + event.escapePrefix = lastWasEscape + if (lastWasEscape) { + lastWasEscape = false + } else if (event.code == 'Escape' && + navigator.userAgentData?.platform === 'macOS') { + event.preventDefault() + event.stopPropagation() + lastWasEscape = true + return + } + + if (rpc.ws == undefined || !hasShortcutModifiers(event)) + return + + // Rotate names so that the current buffer comes first. + let names = [...buffers.keys()] + names.push.apply(names, + names.splice(0, names.findIndex(name => name == bufferCurrent))) + + switch (event.key) { + case 'h': + bufferToggleLog() + break + case 'H': + if (bufferCurrent !== undefined) + bufferToggleUnimportant(bufferCurrent) + break + case 'a': + for (const name of names.slice(1)) + if (buffers.get(name).newMessages) { + bufferActivate(name) + break + } + break + case '!': + for (const name of names.slice(1)) + if (buffers.get(name).highlighted) { + bufferActivate(name) + break + } + break + case 'Tab': + if (bufferLast !== undefined) + bufferActivate(bufferLast) + break + case 'PageUp': + if (names.length > 1) + bufferActivate(names.at(-1)) + break + case 'PageDown': + if (names.length > 1) + bufferActivate(names.at(+1)) + break + default: + return + } + + event.preventDefault() +}, true) diff --git a/xP/xP.go b/xP/xP.go new file mode 100644 index 0000000..20117b2 --- /dev/null +++ b/xP/xP.go @@ -0,0 +1,299 @@ +// Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name> +// SPDX-License-Identifier: 0BSD + +package main + +import ( + "bufio" + "context" + "encoding/binary" + "encoding/json" + "flag" + "fmt" + "html/template" + "io" + "log" + "net" + "net/http" + "os" + "time" + + "nhooyr.io/websocket" +) + +var ( + debug = flag.Bool("debug", false, "enable debug output") + + addressBind string + addressConnect string + addressWS string +) + +// ----------------------------------------------------------------------------- + +func relayReadFrame(r io.Reader) []byte { + var length uint32 + if err := binary.Read(r, binary.BigEndian, &length); err != nil { + log.Println("Event receive failed: " + err.Error()) + return nil + } + b := make([]byte, length) + if _, err := io.ReadFull(r, b); err != nil { + log.Println("Event receive failed: " + err.Error()) + return nil + } + + if *debug { + log.Printf("<? %v\n", b) + + var m RelayEventMessage + if after, ok := m.ConsumeFrom(b); !ok { + log.Println("Event deserialization failed") + return nil + } else if len(after) != 0 { + log.Println("Event deserialization failed: trailing data") + return nil + } + + j, err := m.MarshalJSON() + if err != nil { + log.Println("Event marshalling failed: " + err.Error()) + return nil + } + + log.Printf("<- %s\n", j) + } + return b +} + +func relayMakeReceiver(ctx context.Context, conn net.Conn) <-chan []byte { + // The usual event message rarely gets above 1 kilobyte, + // thus this is set to buffer up at most 1 megabyte or so. + p := make(chan []byte, 1000) + r := bufio.NewReaderSize(conn, 65536) + go func() { + defer close(p) + for { + j := relayReadFrame(r) + if j == nil { + return + } + select { + case p <- j: + case <-ctx.Done(): + return + } + } + }() + return p +} + +func relayWriteJSON(conn net.Conn, j []byte) bool { + var m RelayCommandMessage + if err := json.Unmarshal(j, &m); err != nil { + log.Println("Command unmarshalling failed: " + err.Error()) + return false + } + + b, ok := m.AppendTo(make([]byte, 4)) + if !ok { + log.Println("Command serialization failed") + return false + } + binary.BigEndian.PutUint32(b[:4], uint32(len(b)-4)) + if _, err := conn.Write(b); err != nil { + log.Println("Command send failed: " + err.Error()) + return false + } + + if *debug { + log.Printf("-> %v\n", b) + } + return true +} + +// ----------------------------------------------------------------------------- + +func clientReadJSON(ctx context.Context, ws *websocket.Conn) []byte { + t, j, err := ws.Read(ctx) + if err != nil { + log.Println("Command receive failed: " + err.Error()) + return nil + } + if t != websocket.MessageText { + log.Println( + "Command receive failed: " + "binary messages are not supported") + return nil + } + + if *debug { + log.Printf("?> %s\n", j) + } + return j +} + +func clientWriteBinary(ctx context.Context, ws *websocket.Conn, b []byte) bool { + if err := ws.Write(ctx, websocket.MessageBinary, b); err != nil { + log.Println("Event send failed: " + err.Error()) + return false + } + return true +} + +func clientWriteError(ctx context.Context, ws *websocket.Conn, err error) bool { + b, ok := (&RelayEventMessage{ + EventSeq: 0, + Data: RelayEventData{ + Interface: RelayEventDataError{ + Event: RelayEventError, + CommandSeq: 0, + Error: err.Error(), + }, + }, + }).AppendTo(nil) + if ok { + log.Println("Event serialization failed") + return false + } + return clientWriteBinary(ctx, ws, b) +} + +func handleWS(w http.ResponseWriter, r *http.Request) { + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + // Note that Safari can be broken with compression. + CompressionMode: websocket.CompressionContextTakeover, + // This is for the payload; set higher to avoid overhead. + CompressionThreshold: 64 << 10, + }) + if err != nil { + log.Println("Client rejected: " + err.Error()) + return + } + defer ws.Close(websocket.StatusGoingAway, "Goodbye") + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + conn, err := net.Dial("tcp", addressConnect) + if err != nil { + log.Println("Connection failed: " + err.Error()) + clientWriteError(ctx, ws, err) + return + } + defer conn.Close() + + // To decrease latencies, events are received and decoded in parallel + // to their sending, and we try to batch them together. + relayFrames := relayMakeReceiver(ctx, conn) + batchFrames := func() []byte { + batch, ok := <-relayFrames + if !ok { + return nil + } + Batch: + for { + select { + case b, ok := <-relayFrames: + if !ok { + break Batch + } + batch = append(batch, b...) + default: + break Batch + } + } + return batch + } + + // We don't need to intervene, so it's just two separate pipes so far. + go func() { + defer cancel() + for { + j := clientReadJSON(ctx, ws) + if j == nil { + return + } + relayWriteJSON(conn, j) + } + }() + go func() { + defer cancel() + for { + b := batchFrames() + if b == nil { + return + } + clientWriteBinary(ctx, ws, b) + } + }() + <-ctx.Done() +} + +// ----------------------------------------------------------------------------- + +var staticHandler = http.FileServer(http.Dir(".")) + +var page = template.Must(template.New("/").Parse(`<!DOCTYPE html> +<html> +<head> + <title>xP</title> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="xP.css" /> +</head> +<body> + <script src="mithril.js"> + </script> + <script> + let proxy = '{{ . }}' + </script> + <script type="module" src="xP.js"> + </script> +</body> +</html>`)) + +func handleDefault(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + staticHandler.ServeHTTP(w, r) + return + } + + wsURI := addressWS + if wsURI == "" { + wsURI = fmt.Sprintf("ws://%s/ws", r.Host) + } + if err := page.Execute(w, wsURI); err != nil { + log.Println("Template execution failed: " + err.Error()) + } +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), + "Usage: %s [OPTION...] BIND CONNECT [WSURI]\n\n", os.Args[0]) + flag.PrintDefaults() + } + + flag.Parse() + if flag.NArg() < 2 || flag.NArg() > 3 { + flag.Usage() + os.Exit(1) + } + + addressBind, addressConnect = flag.Arg(0), flag.Arg(1) + if flag.NArg() > 2 { + addressWS = flag.Arg(2) + } + + http.Handle("/ws", http.HandlerFunc(handleWS)) + http.Handle("/", http.HandlerFunc(handleDefault)) + + s := &http.Server{ + Addr: addressBind, + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + MaxHeaderBytes: 32 << 10, + } + log.Fatal(s.ListenAndServe()) +} |