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.
This commit is contained in:
2022-08-08 04:39:20 +02:00
parent 2160d03794
commit 1639235a48
20 changed files with 2798 additions and 91 deletions

3
xP/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/xP
/proto.go
/public/mithril.js

14
xP/Makefile Normal file
View File

@@ -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)

5
xP/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module janouch.name/xK
go 1.18
require golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d

2
xP/go.sum Normal file
View File

@@ -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=

109
xP/public/xP.css Normal file
View File

@@ -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;
}

188
xP/public/xP.js Normal file
View File

@@ -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,
})
})

2
xP/xP.example.json Normal file
View File

@@ -0,0 +1,2 @@
{
}

186
xP/xP.go Normal file
View File

@@ -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())
}