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:
3
xP/.gitignore
vendored
Normal file
3
xP/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/xP
|
||||
/proto.go
|
||||
/public/mithril.js
|
||||
14
xP/Makefile
Normal file
14
xP/Makefile
Normal 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
5
xP/go.mod
Normal 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
2
xP/go.sum
Normal 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
109
xP/public/xP.css
Normal 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
188
xP/public/xP.js
Normal 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
2
xP/xP.example.json
Normal file
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
186
xP/xP.go
Normal file
186
xP/xP.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user