diff options
author | Přemysl Eric Janouch <p@janouch.name> | 2022-08-08 04:39:20 +0200 |
---|---|---|
committer | Přemysl Eric Janouch <p@janouch.name> | 2022-09-05 14:26:00 +0200 |
commit | 1639235a48dbed75c2563c9a497b41c31a2a1bae (patch) | |
tree | 18193b72fa47e6bcac1358289ac9c36ed00c70ac /xP | |
parent | 2160d037943ef0a3adbf4c6e30a91ee0f205c3f3 (diff) | |
download | xK-1639235a48dbed75c2563c9a497b41c31a2a1bae.tar.gz xK-1639235a48dbed75c2563c9a497b41c31a2a1bae.tar.xz xK-1639235a48dbed75c2563c9a497b41c31a2a1bae.zip |
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.
Diffstat (limited to 'xP')
-rw-r--r-- | xP/.gitignore | 3 | ||||
-rw-r--r-- | xP/Makefile | 14 | ||||
-rw-r--r-- | xP/go.mod | 5 | ||||
-rw-r--r-- | xP/go.sum | 2 | ||||
-rw-r--r-- | xP/public/xP.css | 109 | ||||
-rw-r--r-- | xP/public/xP.js | 188 | ||||
-rw-r--r-- | xP/xP.example.json | 2 | ||||
-rw-r--r-- | xP/xP.go | 186 |
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()) +} |