summaryrefslogtreecommitdiff
path: root/xP
diff options
context:
space:
mode:
Diffstat (limited to 'xP')
-rw-r--r--xP/.gitignore3
-rw-r--r--xP/Makefile14
-rw-r--r--xP/go.mod5
-rw-r--r--xP/go.sum2
-rw-r--r--xP/public/xP.css109
-rw-r--r--xP/public/xP.js188
-rw-r--r--xP/xP.example.json2
-rw-r--r--xP/xP.go186
8 files changed, 509 insertions, 0 deletions
diff --git a/xP/.gitignore b/xP/.gitignore
new file mode 100644
index 0000000..68c09f0
--- /dev/null
+++ b/xP/.gitignore
@@ -0,0 +1,3 @@
+/xP
+/proto.go
+/public/mithril.js
diff --git a/xP/Makefile b/xP/Makefile
new file mode 100644
index 0000000..3c52146
--- /dev/null
+++ b/xP/Makefile
@@ -0,0 +1,14 @@
+.POSIX:
+.SUFFIXES:
+
+outputs = xP proto.go public/mithril.js
+all: $(outputs)
+
+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/mithril.js:
+ curl -Lo $@ https://unpkg.com/mithril/mithril.js
+clean:
+ rm -f $(outputs)
diff --git a/xP/go.mod b/xP/go.mod
new file mode 100644
index 0000000..7d4369e
--- /dev/null
+++ b/xP/go.mod
@@ -0,0 +1,5 @@
+module janouch.name/xK
+
+go 1.18
+
+require golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d
diff --git a/xP/go.sum b/xP/go.sum
new file mode 100644
index 0000000..cd5264f
--- /dev/null
+++ b/xP/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI=
+golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
diff --git a/xP/public/xP.css b/xP/public/xP.css
new file mode 100644
index 0000000..9a98c13
--- /dev/null
+++ b/xP/public/xP.css
@@ -0,0 +1,109 @@
+body {
+ margin: 0;
+ padding: 0;
+ font-family: sans-serif;
+}
+
+.xP {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.title, .status {
+ background: #f8f8f8;
+ border-bottom: 1px solid #ccc;
+ padding: .05rem .3rem;
+}
+
+.middle {
+ flex: auto;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+}
+
+.list {
+ overflow-y: auto;
+ border-right: 1px solid #ccc;
+ min-width: 10rem;
+}
+.item {
+ padding: .05rem .3rem;
+ cursor: default;
+}
+.item.active {
+ font-weight: bold;
+}
+
+/* Only Firefox currently supports align-content: safe end, thus this. */
+.buffer-container {
+ flex: auto;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.filler {
+ flex: auto;
+}
+.buffer {
+ display: grid;
+ grid-template-columns: max-content auto;
+ overflow-y: auto;
+}
+
+.date {
+ padding: .3rem;
+ grid-column: span 2;
+ font-weight: bold;
+}
+.time {
+ padding: .1rem .3rem;
+ background: #f8f8f8;
+ color: #bbb;
+ border-right: 1px solid #ccc;
+}
+.mark {
+ padding-right: .3rem;
+ text-align: center;
+ display: inline-block;
+ min-width: 2rem;
+}
+.mark.error {
+ color: red;
+}
+.mark.join {
+ color: green;
+}
+.mark.part {
+ color: red;
+}
+.content {
+ padding: .1rem .3rem;
+ white-space: pre-wrap;
+}
+.content span.b {
+ font-weight: bold;
+}
+.content span.i {
+ font-style: italic;
+}
+.content span.u {
+ text-decoration: underline;
+}
+.content span.s {
+ text-decoration: line-through;
+}
+.content span.m {
+ font-family: monospace;
+}
+
+.status {
+ border-top: 2px solid #fff;
+}
+
+textarea {
+ padding: .05rem .3rem;
+ font-family: inherit;
+}
diff --git a/xP/public/xP.js b/xP/public/xP.js
new file mode 100644
index 0000000..4eebb7a
--- /dev/null
+++ b/xP/public/xP.js
@@ -0,0 +1,188 @@
+// TODO: Probably reset state on disconnect, and indicate to user.
+let socket = new WebSocket(proxy)
+
+let commandSeq = 0
+function send(command) {
+ socket.send(JSON.stringify({commandSeq: ++commandSeq, data: command}))
+}
+
+socket.onopen = function(event) {
+ send({command: 'Hello', version: 1})
+}
+
+let buffers = new Map()
+let bufferCurrent = undefined
+
+socket.onmessage = function(event) {
+ console.log(event.data)
+
+ let e = JSON.parse(event.data).data
+ switch (e.event) {
+ case 'BufferUpdate':
+ {
+ let b = buffers.get(e.bufferName)
+ if (b === undefined) {
+ b = {lines: []}
+ buffers.set(e.bufferName, b)
+ }
+ // TODO: Update any buffer properties.
+ break
+ }
+ case 'BufferRename':
+ buffers.set(e.new, buffers.get(e.bufferName))
+ buffers.delete(e.bufferName)
+ break
+ case 'BufferRemove':
+ buffers.delete(e.bufferName)
+ break
+ case 'BufferActivate':
+ bufferCurrent = e.bufferName
+ // TODO: Somehow scroll to the end of it immediately.
+ // TODO: Focus the textarea.
+ break
+ case 'BufferLine':
+ {
+ let b = buffers.get(e.bufferName)
+ if (b !== undefined)
+ b.lines.push({when: e.when, rendition: e.rendition, items: e.items})
+ break
+ }
+ case 'BufferClear':
+ {
+ let b = buffers.get(e.bufferName)
+ if (b !== undefined)
+ b.lines.length = 0
+ break
+ }
+ }
+
+ m.redraw()
+}
+
+let BufferList = {
+ view: vnode => {
+ let items = []
+ buffers.forEach((b, name) => {
+ let attrs = {
+ onclick: e => {
+ send({command: 'BufferActivate', bufferName: name})
+ },
+ }
+ if (name == bufferCurrent)
+ attrs.class = 'active'
+ items.push(m('.item', attrs, name))
+ })
+ return m('.list', {}, items)
+ },
+}
+
+let Content = {
+ view: vnode => {
+ let line = vnode.children[0]
+ let content = []
+ switch (line.rendition) {
+ case 'Indent': content.push(m('span.mark', {}, '')); break
+ case 'Status': content.push(m('span.mark', {}, '–')); break
+ case 'Error': content.push(m('span.mark.error', {}, '⚠')); break
+ case 'Join': content.push(m('span.mark.join', {}, '→')); break
+ case 'Part': content.push(m('span.mark.part', {}, '←')); break
+ }
+
+ let classes = new Set()
+ let flip = c => {
+ if (classes.has(c))
+ classes.delete(c)
+ else
+ classes.add(c)
+ }
+ line.items.forEach(item => {
+ // TODO: Colours.
+ switch (item.kind) {
+ case 'Text':
+ // TODO: Detect and transform links.
+ content.push(m('span', {
+ class: Array.from(classes.keys()).join(' '),
+ }, item.text))
+ break
+ case 'Reset':
+ classes.clear()
+ break
+ case 'FlipBold': flip('b'); break
+ case 'FlipItalic': flip('i'); break
+ case 'FlipUnderline': flip('u'); break
+ case 'FlipInverse': flip('i'); break
+ case 'FlipCrossedOut': flip('s'); break
+ case 'FlipMonospace': flip('m'); break
+ }
+ })
+ return m('.content', {}, content)
+ },
+}
+
+let Buffer = {
+ view: vnode => {
+ let lines = []
+ let b = buffers.get(bufferCurrent)
+ if (b === undefined)
+ return
+
+ let lastDateMark = undefined
+ b.lines.forEach(line => {
+ let date = new Date(line.when * 1000)
+ let dateMark = date.toLocaleDateString()
+ if (dateMark !== lastDateMark) {
+ lines.push(m('.date', {}, dateMark))
+ lastDateMark = dateMark
+ }
+
+ lines.push(m('.time', {}, date.toLocaleTimeString()))
+ lines.push(m(Content, {}, line))
+ })
+ return m('.buffer-container', {}, [
+ m('.filler'),
+ m('.buffer', {}, lines),
+ ])
+ },
+}
+
+// TODO: This should be remembered across buffer switches,
+// and we'll probably have to intercept /all/ key presses.
+let Input = {
+ view: vnode => {
+ return m('textarea', {
+ rows: 1,
+ onkeydown: e => {
+ // TODO: And perhaps on other actions, too.
+ send({command: 'Active'})
+ if (e.keyCode !== 13)
+ return
+
+ send({
+ command: 'BufferInput',
+ bufferName: bufferCurrent,
+ text: e.currentTarget.value,
+ })
+ e.preventDefault()
+ e.currentTarget.value = ''
+ },
+ })
+ },
+}
+
+let Main = {
+ view: vnode => {
+ return m('.xP', {}, [
+ m('.title', {}, "xP"),
+ m('.middle', {}, [m(BufferList), m(Buffer)]),
+ m('.status', {}, bufferCurrent),
+ m(Input),
+ ])
+ },
+}
+
+// TODO: Buffer names should work as routes.
+window.addEventListener('load', () => {
+ m.route(document.body, '/', {
+ '/': Main,
+ })
+})
diff --git a/xP/xP.example.json b/xP/xP.example.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/xP/xP.example.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/xP/xP.go b/xP/xP.go
new file mode 100644
index 0000000..9b5df8f
--- /dev/null
+++ b/xP/xP.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+ "context"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "time"
+
+ "golang.org/x/net/websocket"
+)
+
+var (
+ addressBind string
+ addressConnect string
+)
+
+func clientToRelay(
+ ctx context.Context, ws *websocket.Conn, conn net.Conn) bool {
+ var j string
+ if err := websocket.Message.Receive(ws, &j); err != nil {
+ log.Println("Command receive failed: " + err.Error())
+ return false
+ }
+
+ log.Printf("?> %s\n", j)
+
+ var m RelayCommandMessage
+ if err := json.Unmarshal([]byte(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
+ }
+
+ log.Printf("-> %v\n", b)
+ return true
+}
+
+func relayToClient(
+ ctx context.Context, ws *websocket.Conn, conn net.Conn) bool {
+ var length uint32
+ if err := binary.Read(conn, binary.BigEndian, &length); err != nil {
+ log.Println("Event receive failed: " + err.Error())
+ return false
+ }
+ b := make([]byte, length)
+ if _, err := io.ReadFull(conn, b); err != nil {
+ log.Println("Event receive failed: " + err.Error())
+ return false
+ }
+
+ log.Printf("<? %v\n", b)
+
+ var m RelayEventMessage
+ if after, ok := m.ConsumeFrom(b); !ok {
+ log.Println("Event deserialization failed")
+ return false
+ } else if len(after) != 0 {
+ log.Println("Event deserialization failed: trailing data")
+ return false
+ }
+
+ j, err := json.Marshal(&m)
+ if err != nil {
+ log.Println("Event marshalling failed: " + err.Error())
+ return false
+ }
+ if err := websocket.Message.Send(ws, string(j)); err != nil {
+ log.Println("Event send failed: " + err.Error())
+ return false
+ }
+
+ log.Printf("<- %s\n", j)
+ return true
+}
+
+func errorToClient(ws *websocket.Conn, err error) bool {
+ j, err := json.Marshal(&RelayEventMessage{
+ EventSeq: 0,
+ Data: RelayEventData{
+ Interface: RelayEventDataError{
+ Event: RelayEventError,
+ CommandSeq: 0,
+ Error: err.Error(),
+ },
+ },
+ })
+ if err != nil {
+ log.Println("Event marshalling failed: " + err.Error())
+ return false
+ }
+ if err := websocket.Message.Send(ws, string(j)); err != nil {
+ log.Println("Event send failed: " + err.Error())
+ return false
+ }
+ return true
+}
+
+func handleWebSocket(ws *websocket.Conn) {
+ conn, err := net.Dial("tcp", addressConnect)
+ if err != nil {
+ errorToClient(ws, err)
+ return
+ }
+
+ // We don't need to intervene, so it's just two separate pipes so far.
+ ctx, cancel := context.WithCancel(ws.Request().Context())
+ go func() {
+ for clientToRelay(ctx, ws, conn) {
+ }
+ cancel()
+ }()
+ go func() {
+ for relayToClient(ctx, ws, conn) {
+ }
+ cancel()
+ }()
+ <-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" />
+ <link rel="stylesheet" href="xP.css" />
+</head>
+<body>
+ <script src="mithril.js">
+ </script>
+ <script>
+ let proxy = '{{ . }}'
+ </script>
+ <script src="xP.js">
+ </script>
+</body>
+</html>`))
+
+func handleDefault(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ staticHandler.ServeHTTP(w, r)
+ return
+ }
+
+ 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() {
+ if len(os.Args) != 3 {
+ log.Fatalf("usage: %s BIND CONNECT\n", os.Args[0])
+ }
+
+ addressBind, addressConnect = os.Args[1], os.Args[2]
+
+ http.Handle("/ws", websocket.Handler(handleWebSocket))
+ 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())
+}