diff --git a/backend/lua/framebuffer.lua b/backend/lua/framebuffer.lua new file mode 100644 index 0000000..f7143ea --- /dev/null +++ b/backend/lua/framebuffer.lua @@ -0,0 +1,294 @@ +--- https://github.com/SquidDev-CC/cloud-catcher/blob/master/src/host/framebuffer.lua +--- but slightly modified because this isn't cloud catcher + +--- Just another frame buffer, but this one is serialisable! + +local stringify = require("json").stringify + +local colour_lookup = {} +for i = 0, 15 do + colour_lookup[2 ^ i] = string.format("%x", i) +end + + +--- Create a buffer which can be converted to a string and transmitted. +local function buffer(original) + local text = {} + local text_colour = {} + local back_colour = {} + local palette = {} + local palette_24 = {} + + local cursor_x, cursor_y = 1, 1 + + local cursor_blink = false + local cur_text_colour = "0" + local cur_back_colour = "f" + + local sizeX, sizeY = original.getSize() + local color = original.isColor() + + local dirty = false + + local redirect = {} + + if original.getPaletteColour then + for i = 0, 15 do + local c = 2 ^ i + palette[c] = { original.getPaletteColour( c ) } + palette_24[colour_lookup[c]] = colours.rgb8(original.getPaletteColour( c )) + end + end + + function redirect.write(writeText) + writeText = tostring(writeText) + original.write(writeText) + dirty = true + + -- If we're off the screen then just emulate a write + if cursor_y > sizeY or cursor_y < 1 or cursor_x + #writeText <= 1 or cursor_x > sizeX then + cursor_x = cursor_x + #writeText + return + end + + -- Adjust text to fit on screen + if cursor_x < 1 then + writeText = writeText:sub(-cursor_x + 2) + cursor_x = 1 + elseif cursor_x + #writeText > sizeX then + writeText = writeText:sub(1, sizeX - cursor_x + 1) + end + + local lineText = text[cursor_y] + local lineColor = text_colour[cursor_y] + local lineBack = back_colour[cursor_y] + local preStop = cursor_x - 1 + local preStart = math.min(1, preStop) + local postStart = cursor_x + #writeText + local postStop = sizeX + local sub, rep = string.sub, string.rep + + text[cursor_y] = sub(lineText, preStart, preStop)..writeText..sub(lineText, postStart, postStop) + text_colour[cursor_y] = sub(lineColor, preStart, preStop)..rep(cur_text_colour, #writeText)..sub(lineColor, postStart, postStop) + back_colour[cursor_y] = sub(lineBack, preStart, preStop)..rep(cur_back_colour, #writeText)..sub(lineBack, postStart, postStop) + cursor_x = cursor_x + #writeText + end + + function redirect.blit(writeText, writeFore, writeBack) + original.blit(writeText, writeFore, writeBack) + dirty = true + + -- If we're off the screen then just emulate a write + if cursor_y > sizeY or cursor_y < 1 or cursor_x + #writeText <= 1 or cursor_x > sizeX then + cursor_x = cursor_x + #writeText + return + end + + if cursor_x < 1 then + --adjust text to fit on screen starting at one. + writeText = writeText:sub(-cursor_x + 2) + writeFore = writeFore:sub(-cursor_x + 2) + writeBack = writeBack:sub(-cursor_x + 2) + cursor_x = 1 + elseif cursor_x + #writeText > sizeX then + writeText = writeText:sub(1, sizeX - cursor_x + 1) + writeFore = writeFore:sub(1, sizeX - cursor_x + 1) + writeBack = writeBack:sub(1, sizeX - cursor_x + 1) + end + + local lineText = text[cursor_y] + local lineColor = text_colour[cursor_y] + local lineBack = back_colour[cursor_y] + local preStop = cursor_x - 1 + local preStart = math.min(1, preStop) + local postStart = cursor_x + #writeText + local postStop = sizeX + local sub = string.sub + + text[cursor_y] = sub(lineText, preStart, preStop)..writeText..sub(lineText, postStart, postStop) + text_colour[cursor_y] = sub(lineColor, preStart, preStop)..writeFore..sub(lineColor, postStart, postStop) + back_colour[cursor_y] = sub(lineBack, preStart, preStop)..writeBack..sub(lineBack, postStart, postStop) + cursor_x = cursor_x + #writeText + end + + function redirect.clear() + for i = 1, sizeY do + text[i] = string.rep(" ", sizeX) + text_colour[i] = string.rep(cur_text_colour, sizeX) + back_colour[i] = string.rep(cur_back_colour, sizeX) + end + + dirty = true + return original.clear() + end + + function redirect.clearLine() + -- If we're off the screen then just emulate a clearLine + if cursor_y > sizeY or cursor_y < 1 then + return + end + + text[cursor_y] = string.rep(" ", sizeX) + text_colour[cursor_y] = string.rep(cur_text_colour, sizeX) + back_colour[cursor_y] = string.rep(cur_back_colour, sizeX) + + dirty = true + return original.clearLine() + end + + function redirect.getCursorPos() + return cursor_x, cursor_y + end + + function redirect.setCursorPos(x, y) + if type(x) ~= "number" then error("bad argument #1 (expected number, got " .. type(x) .. ")", 2) end + if type(y) ~= "number" then error("bad argument #2 (expected number, got " .. type(y) .. ")", 2) end + + if x ~= cursor_x or y ~= cursor_y then + cursor_x = math.floor(x) + cursor_y = math.floor(y) + dirty = true + end + + return original.setCursorPos(x, y) + end + + function redirect.setCursorBlink(b) + if type(b) ~= "boolean" then error("bad argument #1 (expected boolean, got " .. type(b) .. ")", 2) end + + if cursor_blink ~= b then + cursor_blink = b + dirty = true + end + + return original.setCursorBlink(b) + end + + function redirect.getSize() + return sizeX, sizeY + end + + function redirect.scroll(n) + if type(n) ~= "number" then error("bad argument #1 (expected number, got " .. type(n) .. ")", 2) end + + local empty_text = string.rep(" ", sizeX) + local empty_text_colour = string.rep(cur_text_colour, sizeX) + local empty_back_colour = string.rep(cur_back_colour, sizeX) + if n > 0 then + for i = 1, sizeY do + text[i] = text[i + n] or empty_text + text_colour[i] = text_colour[i + n] or empty_text_colour + back_colour[i] = back_colour[i + n] or empty_back_colour + end + elseif n < 0 then + for i = sizeY, 1, -1 do + text[i] = text[i + n] or empty_text + text_colour[i] = text_colour[i + n] or empty_text_colour + back_colour[i] = back_colour[i + n] or empty_back_colour + end + end + + dirty = true + return original.scroll(n) + end + + function redirect.setTextColour(clr) + if type(clr) ~= "number" then error("bad argument #1 (expected number, got " .. type(clr) .. ")", 2) end + local new_colour = colour_lookup[clr] or error("Invalid colour (got " .. clr .. ")" , 2) + + if new_colour ~= cur_text_colour then + dirty = true + cur_text_colour = new_colour + end + + return original.setTextColour(clr) + end + redirect.setTextColor = redirect.setTextColour + + function redirect.setBackgroundColour(clr) + if type(clr) ~= "number" then error("bad argument #1 (expected number, got " .. type(clr) .. ")", 2) end + local new_colour = colour_lookup[clr] or error("Invalid colour (got " .. clr .. ")" , 2) + + if new_colour ~= cur_back_colour then + dirty = true + cur_back_colour = new_colour + end + + return original.setBackgroundColour(clr) + end + redirect.setBackgroundColor = redirect.setBackgroundColour + + function redirect.isColour() + return color == true + end + redirect.isColor = redirect.isColour + + function redirect.getTextColour() + return 2 ^ tonumber(cur_text_colour, 16) + end + redirect.getTextColor = redirect.getTextColour + + function redirect.getBackgroundColour() + return 2 ^ tonumber(cur_back_colour, 16) + end + redirect.getBackgroundColor = redirect.getBackgroundColour + + if original.getPaletteColour then + function redirect.setPaletteColour(colour, r, g, b) + local palcol = palette[colour] + if not palcol then error("Invalid colour (got " .. tostring(colour) .. ")", 2) end + + if type(r) == "number" and g == nil and b == nil then + palcol[1], palcol[2], palcol[3] = colours.rgb8(r) + palette_24[colour] = r + else + if type(r) ~= "number" then error("bad argument #2 (expected number, got " .. type(r) .. ")", 2) end + if type(g) ~= "number" then error("bad argument #3 (expected number, got " .. type(g) .. ")", 2) end + if type(b) ~= "number" then error("bad argument #4 (expected number, got " .. type(b ) .. ")", 2 ) end + + palcol[1], palcol[2], palcol[3] = r, g, b + palette_24[colour_lookup[colour]] = colours.rgb8(r, g, b) + end + + dirty = true + return original.setPaletteColour(colour, r, g, b) + end + redirect.setPaletteColor = redirect.setPaletteColour + + function redirect.getPaletteColour(colour) + local palcol = palette[colour] + if not palcol then error("Invalid colour (got " .. tostring(colour) .. ")", 2) end + return palcol[1], palcol[2], palcol[3] + end + redirect.getPaletteColor = redirect.getPaletteColour + end + + function redirect.is_dirty() return dirty end + function redirect.clear_dirty() dirty = false end + + function redirect.serialize() + local palette = {} + for i = 0, 15 do + palette[i+1] = string.format("#%06x", palette_24[string.format("%x", i)]) + end + + return stringify { framebuffer = { + width = sizeX, height = sizeY, + cursorX = cursor_x, cursorY = cursor_y, cursorBlink = cursor_blink, + -- curFg = cur_text_colour, curBg = cur_back_colour, + + palette = palette, + textBuf = text, fgBuf = text_colour, bgBuf = back_colour + }} + end + + -- Ensure we're in sync with the parent terminal + redirect.setCursorPos(1, 1) + redirect.setBackgroundColor(colours.black) + redirect.setTextColor(colours.white) + redirect.clear() + + return redirect +end + +return { buffer = buffer } diff --git a/backend/lua/json.lua b/backend/lua/json.lua new file mode 100644 index 0000000..913f3ab --- /dev/null +++ b/backend/lua/json.lua @@ -0,0 +1,169 @@ +local tonumber = tonumber + +local function skip_delim(str, pos, delim, err_if_missing) + pos = pos + #str:match('^%s*', pos) + if str:sub(pos, pos) ~= delim then + if err_if_missing then error('Expected ' .. delim) end + return pos, false + end + return pos + 1, true +end + +-- A table of JSON->Lua escape characters +local esc_map = { b = '\b', f = '\f', n = '\n', r = '\r', t = '\t' } + +local function parse_str_val(str, pos) + local out, n = {}, 0 + if pos > #str then error("Malformed JSON (in string)") end + + while true do + local c = str:sub(pos, pos) + if c == '"' then return table.concat(out, "", 1, n), pos + 1 end + + n = n + 1 + if c == '\\' then + local nextc = str:sub(pos + 1, pos + 1) + if not nextc then error("Malformed JSON (in string)") end + if nextc == "u" then + local num = tonumber(str:sub(pos + 2, pos + 5), 16) + if not num then error("Malformed JSON (in unicode string) ") end + if num <= 255 then + pos, out[n] = pos + 6, string.char(num) + else + pos, out[n] = pos + 6, "?" + end + else + pos, out[n] = pos + 2, esc_map[nextc] or nextc + end + else + pos, out[n] = pos + 1, c + end + end +end + +local function parse_num_val(str, pos) + local num_str = str:match('^-?%d+%.?%d*[eE]?[+-]?%d*', pos) + local val = tonumber(num_str) + if not val then error('Error parsing number at position ' .. pos .. '.') end + return val, pos + #num_str +end + +local null = {} +local literals = {['true'] = true, ['false'] = false, ['null'] = null } + +-- Build a table of Lua->JSON escape characters +local escapes = {} +for i = 0, 255 do + local c = string.char(i) + if i >= 32 and i <= 126 + then escapes[c] = c + else escapes[c] = ("\\u00%02x"):format(i) + end +end +escapes["\t"], escapes["\n"], escapes["\r"], escapes["\""], escapes["\\"] = "\\t", "\\n", "\\r", "\\\"", "\\\\" + +local function parse(str, pos, end_delim) + pos = pos or 1 + if pos > #str then error('Reached unexpected end of input.') end + local pos = pos + #str:match('^%s*', pos) + local first = str:sub(pos, pos) + if first == '{' then + local obj, key, delim_found = {}, true, true + pos = pos + 1 + while true do + key, pos = parse(str, pos, '}') + if key == nil then return obj, pos end + if not delim_found then error('Comma missing between object items.') end + pos = skip_delim(str, pos, ':', true) + obj[key], pos = parse(str, pos) + pos, delim_found = skip_delim(str, pos, ',') + end + elseif first == '[' then + local arr, val, delim_found = {}, true, true + pos = pos + 1 + while true do + val, pos = parse(str, pos, ']') + if val == nil then return arr, pos end + if not delim_found then error('Comma missing between array items.') end + arr[#arr + 1] = val + pos, delim_found = skip_delim(str, pos, ',') + end + elseif first == '"' then + return parse_str_val(str, pos + 1) + elseif first == '-' or first:match('%d') then + return parse_num_val(str, pos) + elseif first == end_delim then + return nil, pos + 1 + else + for lit_str, lit_val in pairs(literals) do + local lit_end = pos + #lit_str - 1 + if str:sub(pos, lit_end) == lit_str then return lit_val, lit_end + 1 end + end + local pos_info_str = 'position ' .. pos .. ': ' .. str:sub(pos, pos + 10) + error('Invalid json syntax starting at ' .. pos_info_str) + end +end + +local format, gsub, tostring, pairs, next, type, concat + = string.format, string.gsub, tostring, pairs, next, type, table.concat + +local function stringify_impl(t, out, n) + local ty = type(t) + if ty == "table" then + local first_ty = type(next(t)) + if first_ty == "nil" then + -- Assume empty tables are arrays + out[n], n = "{}", n + 1 + return n + elseif first_ty == "string" then + out[n], n = "{", n + 1 + local first = true + for k, v in pairs(t) do + if first then first = false else out[n], n = ",", n + 1 end + out[n] = format("\"%s\":", k) + n = stringify_impl(v, out, n + 1) + end + out[n], n = "}", n + 1 + return n + elseif first_ty == "number" then + out[n], n = "[", n + 1 + for i = 1, #t do + if i > 1 then out[n], n = ",", n + 1 end + n = stringify_impl(t[i], out, n) + end + out[n], n = "]", n + 1 + return n + else + error("Cannot serialize key " .. first_ty) + end + elseif ty == "string" then + if t:match("^[ -~]*$") then + out[n], n = gsub(format("%q", t), "\n", "n"), n + 1 + else + out[n], n = "\"" .. gsub(t, ".", escapes) .. "\"", n + 1 + end + return n + elseif ty == "number" or ty == "boolean" then + out[n],n = tostring(t), n + 1 + return n + else error("Cannot serialize type " .. ty) + end +end + +local function stringify(object) + local buffer = {} + local n = stringify_impl(object, buffer, 1) + return concat(buffer, "", 1, n - 1) +end + +local function try_parse(msg) + local ok, res = pcall(parse, msg) + if ok then return res else return nil, res end +end + +return { + stringify = stringify, + try_parse = try_parse, + parse = parse, + null = null +} diff --git a/backend/main.py b/backend/main.py index e7e97a2..d86cd46 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,22 @@ +from typing import * +import os.path +import uuid + from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel from settings import settings import auth from state import StateManager +from proto import Computer, ComputerType app = FastAPI() state_manager = StateManager() -@app.get("/api/{token}/validate") + +@app.get("/api/{token}/validate", tags=["frontend"]) async def validate_token(token: str): return {"success": auth.validate_frontend(token)} @@ -20,16 +28,51 @@ async def state_updates_websocket(websocket: WebSocket, token: str): await websocket.close() return - await websocket.accept() await state_manager.on_connect(websocket) - + try: while True: await websocket.receive_json() except WebSocketDisconnect: await state_manager.on_disconnect(websocket) + +@app.get("/install", tags=["computer"], response_class=PlainTextResponse) +async def serve_install_script(): + deploy_url = ( + f'{"https" if settings.deploy_tls else "http"}://{settings.delpoy_path}' + ) + return f""" + shell.run("wget {os.path.join(deploy_url, "lua/json.lua")}") + shell.run("wget {os.path.join(deploy_url, "lua/framebuffer.lua")}") + """ + + +class RegistrationRequest(BaseModel): + type: ComputerType + is_advanced: bool + label: Optional[str] + + +class RegistrationResponse(BaseModel): + token: str + + +@app.post("/computer/register", tags=["computer"], response_model=RegistrationResponse) +async def issue_new_token(data: RegistrationRequest): + + computer = Computer( + uuid=uuid.uuid4(), + group="default", + **data.dict(), + ) + + await state_manager.on_computer_register(computer) + + return {"token": auth.encode({"type": "computer", "uuid": str(computer.uuid)})} + + if settings.dev_mode: print("Starting in development mode.") diff --git a/backend/proto.py b/backend/proto.py index ff25fdf..d7ad05f 100644 --- a/backend/proto.py +++ b/backend/proto.py @@ -1,12 +1,21 @@ from typing import * from uuid import UUID +from enum import Enum from pydantic import BaseModel +class ComputerType(str, Enum): + COMPUTER = "computer" + TURTLE = "turtle" + POCKET = "pocket" + + class Computer(BaseModel): + type: ComputerType + is_advanced: bool uuid: UUID - label: str + label: Optional[str] group: str diff --git a/backend/settings.py b/backend/settings.py index caf9bc6..554b462 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -7,6 +7,9 @@ class Settings(BaseSettings): frontend_path: str = "frontend" database_path: str = "db.json" + + deploy_tls: bool = False + delpoy_path: str = "localhost:8000" secret_key: str diff --git a/backend/state.py b/backend/state.py index 77095f7..f810ec4 100644 --- a/backend/state.py +++ b/backend/state.py @@ -3,6 +3,7 @@ import asyncio from tinydb import TinyDB from settings import settings +from proto import Computer db = TinyDB(settings.database_path) computers = db.table("computers") @@ -19,7 +20,11 @@ class StateManager: self.current_state = {"computers": computers.all()} async def push_state(self, socket): - await socket.send_json(self.current_state) + try: + await socket.send_json(self.current_state) + except RuntimeError: + print("dead socket?") + self.websockets.remove(socket) async def on_connect(self, socket): self.websockets.add(socket) @@ -30,4 +35,13 @@ class StateManager: async def on_change(self): self.update_state() - await asyncio.gather(self.push_state(socket) for socket in self.websockets) + await asyncio.gather(*[self.push_state(socket) for socket in self.websockets]) + + async def on_computer_register(self, computer: Computer): + + # uUiD iS nOt JsOn SeRiAlIzAbLe + computer_data = computer.dict() + computer_data["uuid"] = str(computer_data["uuid"]) + + computers.insert(computer_data) + await self.on_change() diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index bbdcd49..49d3340 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -48,7 +48,7 @@ const Groups: React.FC<{ computers: Array }> = ({ computers }) => { return <> { - Array.from(groupMap.entries()).map((entry, index) => { + Array.from(groupMap.entries()).map((entry) => { const [group, computers] = entry; return

{group}:

@@ -63,13 +63,13 @@ const Groups: React.FC<{ computers: Array }> = ({ computers }) => { const CardList: React.FC<{ computers: Array }> = ({ computers }) => { return <> {computers.map( - (computer, index) => + (computer) => )} } const ComputerCard: React.FC<{ computer: Computer }> = ({ computer }) => { return
- {computer.label} + {computer.is_advanced ? "advanced " : ""}{computer.type} {computer.label || "(no label)"}
} \ No newline at end of file diff --git a/frontend/src/proto.ts b/frontend/src/proto.ts index 8dd2c7d..ed0d168 100644 --- a/frontend/src/proto.ts +++ b/frontend/src/proto.ts @@ -1,9 +1,16 @@ +export type ComputerType = + "computer" | + "turtle" | + "pocket" + export type Computer = { + type: ComputerType, + is_advanced: boolean, uuid: string, - label: string, + label: string | undefined, group: string, } export type State = { computers: Array -}; \ No newline at end of file +} \ No newline at end of file