From 1639235a48dbed75c2563c9a497b41c31a2a1bae Mon Sep 17 00:00:00 2001 From: Přemysl Eric Janouch
Date: Mon, 8 Aug 2022 04:39:20 +0200 Subject: Start X11 and web frontends for xC For this, we needed a wire protocol. After surveying available options, it was decided to implement an XDR-like protocol code generator in portable AWK. It now has two backends, per each of: - xF, the X11 frontend, is in C, and is meant to be the primary user interface in the future. - xP, the web frontend, relies on a protocol proxy written in Go, and is meant for use on-the-go (no pun intended). They are very much work-in-progress proofs of concept right now, and the relay protocol is certain to change. --- xP/.gitignore | 3 + xP/Makefile | 14 ++++ xP/go.mod | 5 ++ xP/go.sum | 2 + xP/public/xP.css | 109 +++++++++++++++++++++++++++++++ xP/public/xP.js | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++++ xP/xP.example.json | 2 + xP/xP.go | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 509 insertions(+) create mode 100644 xP/.gitignore create mode 100644 xP/Makefile create mode 100644 xP/go.mod create mode 100644 xP/go.sum create mode 100644 xP/public/xP.css create mode 100644 xP/public/xP.js create mode 100644 xP/xP.example.json create mode 100644 xP/xP.go (limited to 'xP') 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(` + +
+