rewrite in rust

This commit is contained in:
hippoz 2024-09-13 22:19:09 +03:00
parent ca9d562ed5
commit 53bd9f15e7
Signed by: hippoz
GPG key ID: 56C4E02A85F2FBED
39 changed files with 4893 additions and 1133 deletions

7
.gitignore vendored
View file

@ -1,3 +1,4 @@
frontend/node_modules/
frontend/dist/
__pycache__/
dist/
target/
node_modules/

1740
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

14
Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "next"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
warp = "0.3"
ashpd = "0.9"
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
bytes = "1.7"
num-traits = "0.2"
num-derive = "0.4"
zbus = { version = "4", default-features = false, features = ["tokio"] }

View file

@ -1,80 +0,0 @@
from pynput.mouse import Controller as MouseController
from pynput.keyboard import Controller as KeyboardController
import constants as c
from MessageParser import MessageParser
class InputController():
def __init__(self):
self.mouse_controller = MouseController()
self.keyboard_controller = KeyboardController()
self.parser = MessageParser()
# Keyboard key press
self.parser.add_handler("k", {
"key": "str"
})
# Relative mouse movement
self.parser.add_handler("r", {
"x": "float",
"y": "float"
})
# Mouse relative scroll
self.parser.add_handler("s", {
"x": "float",
"y": "float"
})
# Mouse button down
self.parser.add_handler("d", {
"button": "int"
})
# Mouse button up
self.parser.add_handler("u", {
"button": "int"
})
# Mouse button click
self.parser.add_handler("c", {
"button": "int"
})
def button_code_to_object(self, button_code: int):
# HACK
obj = None
try:
obj = c.button_code_lookup[button_code]
except IndexError:
return c.button_code_lookup[0]
return obj
def deserialize_key(self, key: str):
obj = None
try:
obj = c.keyboard_lookup[key]
except KeyError:
if len(key) != 1:
return None
return key
return obj
def process_message(self, message: str) -> bool:
code, args = self.parser.parse(message)
if code == None:
print("error while parsing message:", args)
return False
elif code == "r":
self.mouse_controller.move(args["x"], args["y"])
elif code == "d":
self.mouse_controller.press(self.button_code_to_object(args["button"]))
elif code == "u":
self.mouse_controller.release(self.button_code_to_object(args["button"]))
elif code == "c":
self.mouse_controller.click(self.button_code_to_object(args["button"]))
elif code == "s":
self.mouse_controller.scroll(args["x"], args["y"])
elif code == "k":
key = self.deserialize_key(args["key"])
if key:
self.keyboard_controller.tap(key)
else:
print("got invalid code from parser (is this a bug with the MessageParser?)")
return False
return True

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 hippoz
Copyright (c) 2024 hippoz
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View file

@ -1,40 +0,0 @@
class MessageParser():
def __init__(self):
self.handlers = {}
def add_handler(self, code: str, args={}):
self.handlers[code] = {
"args": args
}
def parse(self, message: str):
if len(message) < 1:
return None, "Message is empty"
message_code = message[0]
if message_code not in self.handlers:
return None, "Message code is not handled"
message_arguments = message[1:].split(";")
handler = self.handlers[message_code]
decoded_arguments = {}
if len(handler["args"]) != len(message_arguments):
return None, "Got message with invalid argument count"
for i, argument_name in enumerate(handler["args"]):
argument_type = handler["args"][argument_name]
if argument_type == "int":
try:
decoded_arguments[argument_name] = int(message_arguments[i])
except ValueError:
return None, "Error parsing int argument for message (is it really an int?)"
elif argument_type == "float":
try:
decoded_arguments[argument_name] = float(message_arguments[i])
except ValueError:
return None, "Error parsing float argument for message (is it really a float?)"
elif argument_type == "str":
decoded_arguments[argument_name] = message_arguments[i]
elif argument_type == "char":
if len(message_arguments[i]) != 1:
return None, "Error parsing char argument due to invalid size"
decoded_arguments[argument_name] = message_arguments[i]
else:
raise ValueError("parse(): Message handler references an invalid argument type")
return message_code, decoded_arguments

View file

@ -1,70 +0,0 @@
import sys
from asyncio import wait_for, TimeoutError
from base64 import b64encode
from sanic import Sanic
from sanic.response import file, redirect, empty
from InputController import InputController
from MessageParser import MessageParser
app = Sanic(
"capybara",
env_prefix="CAPYBARA_"
)
input_controller = InputController()
control_message_parser = MessageParser()
# auth packet
control_message_parser.add_handler("0", {
"auth_string": "str"
})
app.static("app", "frontend/", resource_type="dir")
@app.get("/")
async def home(req):
return redirect("/app/app.html")
@app.get("/app")
async def app_route(req):
return redirect("/app/app.html")
@app.websocket("/gateway")
async def gateway(req, ws):
# Before the client is able to send any data, we must await an authorization packet
try:
auth_payload = await wait_for(ws.recv(), timeout=5)
except TimeoutError:
await ws.close(code=4001, reason="Invalid auth packet")
return
code, args = control_message_parser.parse(auth_payload)
if (
not code or
code != "0" or
args["auth_string"] != app.ctx.EXPECTED_AUTH_STRING
): # 0 is the code for the initial auth packet
await ws.close(code=4001, reason="Invalid auth packet")
return
await ws.send("1") # send a single `1` to let the client know the server is accepting input packets
while True:
input_controller.process_message(await ws.recv())
def main():
if not app.config.get("AUTH_PASSWORD"):
print(
"capybara: FATAL ERROR: Capybara is expecting the `CAPYBARA_AUTH_PASSWORD` environment variable to be set (to a password of your choice).\nYour users will use this password to authenticate to your server.\nEXITING...",
file=sys.stderr
)
exit(1)
return
app.ctx.EXPECTED_AUTH_STRING = "%auth%" + str(b64encode(app.config.AUTH_PASSWORD.encode("utf-8")), "utf-8")
app.run(host='0.0.0.0', port=4003, access_log=False)
if __name__ == "__main__":
main()

View file

@ -1,13 +0,0 @@
from pynput.keyboard import Key
from pynput.mouse import Button
button_code_lookup = [
Button.left,
Button.right
]
keyboard_lookup = {
"{space}": Key.space,
"{ent}": Key.enter,
"{backspace}": Key.backspace
}

View file

@ -1,109 +0,0 @@
import Banner from "./Banner.js";
import connection, { ConnectionEvent } from "./connection.js";
import KeyboardController from "./Keyboard.js";
import { setItem } from "./storage.js";
import LoginPrompt from "./LoginPrompt.js";
import TouchpadController from "./Touchpad.js";
class App {
constructor() {
this.connection = connection;
this.touchpad = new TouchpadController(this.connection);
this.keyboard = new KeyboardController(this.connection);
this.bannerComponent = new Banner();
this.loginPromptComponent = new LoginPrompt(this.connection);
this.connectionHandlers = [];
}
mountOn(mount, keepMountChildren=false) {
this.mountElement = mount;
if (!keepMountChildren) {
while (mount.firstChild) {
mount.removeChild(mount.lastChild);
}
}
this.connectionHandlers.push([ConnectionEvent.Closed, this.connection.subscribe(ConnectionEvent.Closed, (code) => {
this.unmountApp();
if (code === 4001) { // 4001 - code for bad auth
this.transitionTo("login");
} else {
this.transitionTo("reconnectingBanner");
}
})]);
this.connectionHandlers.push([ConnectionEvent.Ready, this.connection.subscribe(ConnectionEvent.Ready, () => {
this.transitionTo("app");
})]);
}
unmount() {
if (this.mountElement) {
this.unmountApp();
this.unmountBannerComponent();
this.unmountLoginComponent();
}
this.connectionHandlers.forEach(([ event, handler ]) => {
this.connection.unsubscribe(event, handler);
});
}
transitionTo(type) {
switch (type) {
case "login":
this.unmountBannerComponent();
this.unmountApp();
this.mountLoginComponent();
break;
case "app":
this.unmountBannerComponent();
this.unmountLoginComponent();
this.mountApp();
break;
case "reconnectingBanner":
this.unmountApp();
this.unmountLoginComponent();
this.mountBannerComponent("Connecting...", "Looks like you've lost connection. We're trying to reconnect you.");
break;
default:
throw new Error(`transitionTo type ${type} is invalid`);
}
}
mountBannerComponent(title, text) {
this.bannerComponent.mountOn(this.mountElement);
this.bannerComponent.updateTitle(title);
this.bannerComponent.updateText(text);
}
unmountBannerComponent() {
this.bannerComponent.unmount();
}
mountLoginComponent() {
this.loginPromptComponent.mountOn(this.mountElement);
this.loginPromptComponent.onPasswordSubmitted = (p) => {
setItem("auth:token", p);
this.connection.connect();
};
}
unmountLoginComponent() {
if (this.loginPromptComponent)
this.loginPromptComponent.unmount();
}
mountApp() {
this.touchpad.mountOn(this.mountElement);
this.keyboard.mountOn(this.mountElement);
}
unmountApp() {
this.touchpad.unmount();
this.keyboard.unmount();
}
}
export default App;

View file

@ -1,42 +0,0 @@
class Banner {
constructor() {
this.element = null;
this.title = "";
this.text = "";
}
updateText(newText) {
this.text = newText;
this.element.querySelector("#banner-text").innerText = this.text;
}
updateTitle(newTitle) {
this.title = newTitle;
this.element.querySelector("#banner-title").innerText = this.title;
}
mountOn(target) {
if (this.element)
return; // Already mounted
this.element = document.createRange().createContextualFragment(`
<div class="Card Card-ui-bottom Card-centered-text">
<h2 id="banner-title"></h2>
<p id="banner-text"></p>
</div>
`).children[0];
target.appendChild(this.element);
}
unmount() {
if (!this.element)
return; // Already unmounted
this.element.parentElement.removeChild(this.element);
this.element = null;
}
}
export default Banner;

View file

@ -1,126 +0,0 @@
class KeyboardController {
constructor(connection) {
this.connection = connection;
this.container = null;
this.layouts = {
default: [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
["🄰", "z", "x", "c", "v", "b", "n", "m", ".", "⌦"],
["🔢", " ", "↵"]
],
uppercase: [
["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
["🅰", "Z", "X", "C", "V", "B", "N", "M", ",", "⌦"],
["🔢", " ", "↵"]
],
symbols: [
["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
["[", "]", "{", "}", ";", ":", "'", "\"", ",", "<"],
[".", ">", "-", "_", "=", "+", "/", "?", "\\"],
["🄰", "|", "`", "~", "⌦"],
["🔤", " ", "↵"]
]
}
}
_sendKeyPress(l) {
this.connection.sendMessage("k", [l]);
}
makeKeyboardForLayout(layout) {
queueMicrotask(() => {
while (this.container.firstChild) {
this.container.removeChild(this.container.lastChild);
}
const rowsParent = document.createElement("div");
rowsParent.classList.add("Keyboard-rows");
const rowsFragment = document.createDocumentFragment();
for (let row = 0; row < layout.length; row++) {
const rowElement = document.createElement("div");
rowElement.classList.add("Keyboard-row");
for (let col = 0; col < layout[row].length; col++) {
const key = layout[row][col];
const colElement = document.createElement("button");
colElement.classList.add("Keyboard-button");
colElement.innerText = key;
colElement.addEventListener("click", () => {
this.handleKeypress(key);
});
rowElement.appendChild(colElement);
}
rowsFragment.appendChild(rowElement);
}
rowsParent.appendChild(rowsFragment);
this.container.appendChild(rowsParent);
});
}
handleKeypress(key) {
switch (key) {
case "🄰": {
this.makeKeyboardForLayout(this.layouts.uppercase);
break;
}
case "🅰": {
this.makeKeyboardForLayout(this.layouts.default);
break;
}
case "🔢": {
this.makeKeyboardForLayout(this.layouts.symbols);
break;
}
case "🔤": {
this.makeKeyboardForLayout(this.layouts.default);
break;
}
case " ": {
this._sendKeyPress("{space}");
break;
}
case "⌦": {
this._sendKeyPress("{backspace}");
break;
}
case "↵": {
this._sendKeyPress("{ent}");
break;
}
default: {
this._sendKeyPress(key);
break;
}
}
}
mountOn(element) {
if (this.container)
return; // Already mounted;
this.container = document.createElement("div");
this.container.classList.add("Keyboard");
element.appendChild(this.container);
this.makeKeyboardForLayout(this.layouts.default);
}
unmount() {
if (!this.container)
return; // Not mounted - don't do anything
this.container.parentElement.removeChild(this.container);
this.container = null;
}
}
export default KeyboardController;

View file

@ -1,35 +0,0 @@
class LoginPrompt {
constructor() {
this.element = null;
}
mountOn(target) {
if (this.element)
return; // Already mounted
this.element = document.createRange().createContextualFragment(`
<div class="Card Card-ui-bottom LoginPrompt">
<h2>Login</h2>
<input type="password" id="code-input" class="input full-width" placeholder="Code">
<button id="continue-button" class="button-default full-width">Continue</button>
</div>
`).children[0];
this.element.querySelector("#continue-button").addEventListener("click", () => {
if (this.onPasswordSubmitted)
this.onPasswordSubmitted(this.element.querySelector("#code-input").value);
});
target.appendChild(this.element);
}
unmount() {
if (!this.element)
return; // Already unmounted
this.element.parentElement.removeChild(this.element);
this.element = null;
}
}
export default LoginPrompt;

View file

@ -1,217 +0,0 @@
const HOLDING_THRESHOLD_MS = 300;
const SCROLL_X_DAMPENING = 0.02;
const SCROLL_Y_DAMPENING = 0.09;
const MOVE_X_MULTIPLIER = 1.64;
const MOVE_Y_MULTIPLIER = 1.64;
class TouchpadController {
constructor(connection) {
this.currentMoveX = 0;
this.currentMoveY = 0;
this.lastMoveX = 0;
this.lastMoveY = 0;
this.shouldResetLastMove = false;
this.isInHoldingMode = false;
this.ongoingTouches = {};
this.holdModeTimeout = null;
this.touchpadDiv = null;
this.connection = connection;
}
getButtonCode(button) {
let buttonCode = 0;
if (button === "right") buttonCode = 1;
return buttonCode;
}
// takes a gesture name and a touch list
// adds the gesture name to the touch and returns false if that touch already had that gesture
_gesture(gestureName, touchList) {
for (let i = 0; i < touchList.length; i++) {
const touch = this.ongoingTouches[touchList[i].identifier];
if (touch.gestured.includes(gestureName)) return false;
touch.gestured.push(gestureName);
}
return true;
}
_sendRelativeMouseMovement(dx, dy) {
if (dx === 0 && dy === 0)
return false;
this.connection.sendMessage("r", [dx, dy]);
return true;
}
_sendRelativeMouseScroll(dx, dy) {
if (dx === 0 && dy === 0)
return false;
this.connection.sendMessage("s", [dx, dy]);
return true;
}
_sendMouseButtonDown(button="left") {
this.connection.sendMessage("d", [this.getButtonCode(button)]);
}
_sendMouseButtonUp(button="left") {
this.connection.sendMessage("u", [this.getButtonCode(button)]);
}
_sendSingleClick(button="left") {
this.connection.sendMessage("c", [this.getButtonCode(button)]);
}
mountOn(element) {
if (this.touchpadDiv)
return; // Already mounted
this.touchpadDiv = document.createElement("div");
this.touchpadDiv.classList.add("Touchpad");
this.touchpadDiv.addEventListener("touchmove", this.onTouchMove.bind(this));
this.touchpadDiv.addEventListener("touchend", this.onTouchEnd.bind(this));
this.touchpadDiv.addEventListener("touchstart", this.onTouchStart.bind(this));
element.appendChild(this.touchpadDiv);
}
unmount() {
if (!this.touchpadDiv)
return; // Not mounted - don't do anything
this.touchpadDiv.removeEventListener("touchmove", this.onTouchMove.bind(this));
this.touchpadDiv.removeEventListener("touchend", this.onTouchEnd.bind(this));
this.touchpadDiv.removeEventListener("touchstart", this.onTouchStart.bind(this));
}
onTouchMove(event) {
const touches = event.changedTouches;
const targetTouch = touches[0];
this.currentMoveX = targetTouch.pageX;
this.currentMoveY = targetTouch.pageY;
// When ending a touch and starting a new one in another part of the touchpad,
// the cursor "rubber bands" to that position.
// To solve this, the "last move" parameters are reset when a touch is ended
// (see onTouchEnd())
if (this.shouldResetLastMove) {
this.shouldResetLastMove = false;
this.lastMoveX = this.currentMoveX;
this.lastMoveY = this.currentMoveY;
}
const deltaX = this.currentMoveX - this.lastMoveX;
const deltaY = this.currentMoveY - this.lastMoveY;
this.lastMoveX = this.currentMoveX;
this.lastMoveY = this.currentMoveY;
// if two touches moved at the same time, assume scrolling intent
// if _sendRelativeMouseScroll or _sendRelativeMouseMovement return true, it means that the delta values are non-zero and a packet has been sent to the server
// in that case, the touches will be marked as moved
let shouldMarkTouchesAsMoved = false;
if (touches.length === 2) {
if (
this._sendRelativeMouseScroll(deltaX * SCROLL_X_DAMPENING, deltaY * SCROLL_Y_DAMPENING)
) {
shouldMarkTouchesAsMoved = true;
}
} else if (touches.length === 3 && this._gesture("GESTURE_RIGHT_CLICK", touches)) {
shouldMarkTouchesAsMoved = true;
this._sendSingleClick("right");
} else {
if (
this._sendRelativeMouseMovement(deltaX * MOVE_X_MULTIPLIER, deltaY * MOVE_Y_MULTIPLIER)
) {
shouldMarkTouchesAsMoved = true;
}
}
if (shouldMarkTouchesAsMoved) {
for (let i = 0; i < touches.length; i++) {
const touch = this.ongoingTouches[touches[i].identifier];
touch.hasMoved = true;
if (touch.indicatorElement) {
touch.indicatorElement.style.top = `${targetTouch.pageY}px`;
touch.indicatorElement.style.left = `${targetTouch.pageX}px`;
}
}
}
}
onTouchEnd(event) {
const changedTouches = event.changedTouches;
this.shouldResetLastMove = true;
if (changedTouches.length === 1) {
// This is a single tap - left click
if (!this.ongoingTouches[changedTouches[0].identifier].hasMoved) {
this._sendSingleClick("left");
// We were in "holding mode" and now that touch event has ended,
// thus we have to stop holding the left click button
} else if (this.isInHoldingMode) {
this._sendMouseButtonUp("left");
this.isInHoldingMode = false;
}
}
// remove all ended touches
for (let i = 0; i < changedTouches.length; i++) {
const touch = changedTouches[i];
const knownTouch = this.ongoingTouches[changedTouches[0].identifier];
if (knownTouch && knownTouch.indicatorElement) {
this.touchpadDiv.removeChild(knownTouch.indicatorElement);
knownTouch.indicatorElement = null;
delete knownTouch.indicatorElement;
}
this.ongoingTouches[touch.identifier] = null;
delete this.ongoingTouches[touch.identifier];
}
}
onTouchStart(event) {
const changedTouches = event.changedTouches;
// Clear the hold mode time out if another touch begins
if (this.holdModeTimeout)
clearTimeout(this.holdModeTimeout);
// If the touch is still unmoved and held for a certain amount of time,
// we will enter "holding mode" which keeps the mouse button held while dragging
// allowing you to, for example, select text or drag a scrollbar
if (changedTouches.length === 1) {
const targetTouch = changedTouches[0];
this.holdModeTimeout = setTimeout(() => {
if (this.ongoingTouches[targetTouch.identifier] && !this.ongoingTouches[targetTouch.identifier].hasMoved) {
this.isInHoldingMode = true;
this._sendMouseButtonDown("left");
}
}, HOLDING_THRESHOLD_MS);
}
for (let i = 0; i < changedTouches.length; i++) {
const touch = changedTouches[i];
let indicatorElement = this.ongoingTouches[touch.identifier] ? this.ongoingTouches[touch.identifier].indicatorElement : null;
if (!indicatorElement) {
indicatorElement = document.createElement("div");
indicatorElement.classList.add("Touchpad-touch-indicator");
indicatorElement.style.top = `${touch.clientY}px`;
indicatorElement.style.left = `${touch.clientX}px`;
this.touchpadDiv.appendChild(indicatorElement);
}
this.ongoingTouches[touch.identifier] = {
identifier: touch.identifier,
clientX: touch.clientX,
clientY: touch.clientY,
hasMoved: false,
gestured: [],
indicatorElement
};
}
}
}
export default TouchpadController;

View file

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Capybara</title>
<link rel="stylesheet" href="./main.css">
<script type="module">
import App from "./App.js";
import connection from "./connection.js";
window.__capybara = {};
window.__capybara.app = new App();
window.__capybara.app.mountOn(document.body);
connection.connect();
</script>
</head>
<body>
<div class="Card Card-ui-bottom Card-centered-text">
<h2>Loading...</h2>
</div>
</body>
</html>

View file

@ -1,101 +0,0 @@
import logger from "./logger.js";
import { getItem } from "./storage.js";
export const ConnectionEvent = {
Open: 0,
Closed: 1,
Ready: 2,
};
export default {
ws: null,
log: logger(["Connection"], ["log"]).log,
messageLog: logger(["Connection", "Message"], ["log"]).log,
url: getItem("server:gatewayBase"),
isReady: false,
reconnectTimeout: 0,
handlers: new Map(),
formatAuthString(password) {
return `%auth%${btoa(password)}`;
},
connect(password=getItem("auth:token")) {
this.ws = new WebSocket(this.url);
this.ws.onerror = (e) => this.log("Error", e);
this.ws.onopen = () => {
this.log("Open");
this.dispatch(ConnectionEvent.Open, 1);
this.reconnectTimeout = 0;
this.log("Sending authentication packet");
this.ws.send(`0${this.formatAuthString(password)}`); // send auth packet
};
this.ws.onmessage = ({ data }) => {
if (data === "1") {
this.isReady = true;
this.dispatch(ConnectionEvent.Ready, 1);
this.log("Handshake complete");
}
};
this.ws.onclose = ({ code }) => {
this.dispatch(ConnectionEvent.Closed, code);
if (code === 4001) {// code for bad auth
this.log("Closed due to bad auth - skipping reconnect");
return;
}
this.isReady = false;
this.reconnectTimeout += 400;
if (this.reconnectTimeout >= 30000)
this.reconnectTimeout = 30000;
this.log(`Closed - will reconnect in ${this.reconnectTimeout}ms`);
setTimeout(() => this.connect(password), this.reconnectTimeout);
}
},
sendMessage(code, params=[]) {
let message = code;
for (let i = 0; i < params.length; i++) {
const param = params[i];
if (i == params.length - 1)
message += param;
else
message += param + ";";
}
this.ws.send(message);
},
disconnect() {
this.ws.close();
},
dispatch(event, data) {
const eventHandlers = this.handlers.get(event);
if (!eventHandlers)
return;
eventHandlers.forEach(e => e(data));
},
subscribe(event, handler) {
if (!this.handlers.get(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event).add(handler);
return handler; // can later be used for unsubscribe()
},
unsubscribe(event, handler) {
const eventHandlers = this.handlers.get(event);
if (!eventHandlers)
return;
eventHandlers.delete(handler);
if (eventHandlers.size < 1) {
this.handlers.delete(event);
}
},
};

15
frontend/index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Svelte + TS</title>
<script type="module" src="/src/main.ts"></script>
</head>
<body>
</body>
</html>

View file

@ -1,53 +0,0 @@
const loggerOfType = (components, type='log') => (...args) => {
let str = '%c';
const style = 'color: #5e81ac; font-weight: bold;';
for (const i in components) {
const v = components[i];
if (components[i+1] === undefined) {
str += `[${v}]`;
} else {
str += `[${v}] `;
}
}
switch (type) {
case 'log': {
console.log(str, style, ...args);
break;
}
case 'error': {
console.error(str, style, ...args);
break;
}
case 'warn': {
console.warn(str, style, ...args);
break;
}
case 'genmsg': {
return str;
}
default: {
return str;
}
}
};
function logger(components, types=['warn', 'error', 'log']) {
const loggerObj = {};
for (const type of types) {
loggerObj[type] = loggerOfType(components, type);
}
return loggerObj;
}
export function domLog(message) {
document.body.appendChild(document.createTextNode(message));
}
export default logger;

View file

@ -1,167 +0,0 @@
:root {
--body-bg-color: #2e2e2e;
--body-bg-color-accent: hsl(0, 0%, 25%);
--accent-bg-color: #d4d3d3;
--accent-color: #949494;
--grayed-text-color: #949494;
--button-accent-color: #3d3d3d;
--selected-bg-color: #2e2e2e;
--hover-bg-color: #4d4d4d;
--card-border-radius: 1em;
--button-border-radius: 0.5em;
--main-font-weight: 500;
--fonts-regular: "Noto Sans", "Liberation Sans", sans-serif;
}
* {
box-sizing: border-box;
}
html, body {
font-weight: var(--main-font-weight);
font-family: var(--fonts-regular);
background-color: var(--body-bg-color);
color: var(--accent-bg-color);
overflow: hidden;
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
background: var(--body-bg-color);
background-image: radial-gradient(var(--body-bg-color-accent) 1.4px, transparent 0);
background-size: 35px 35px;
}
/* util */
.full-width {
width: 100%;
}
/* Card */
.Keyboard,
.Card {
margin: 6px;
padding: 8px;
background: var(--accent-bg-color);
color: #000000;
border-radius: var(--card-border-radius);
}
.Keyboard,
.Card-ui-bottom {
padding: 14px;
width: 100%;
max-width: 610px;
box-shadow: 0 0 28px 3px rgba(0, 0, 0, 0.40);
margin: 0;
border-radius: var(--card-border-radius) var(--card-border-radius) 0 0;
padding-top: 16px;
}
.Card-centered-text {
text-align: center;
}
/* button */
.button-default {
display: block;
padding: 12px;
margin: 4px;
border: none;
background-color: var(--accent-bg-color);
border-radius: var(--button-border-radius);
color: var(--button-accent-color);
min-width: 50px;
text-align: center;
}
.button-default:hover:not(.button-selected) {
color: var(--accent-bg-color);
background-color: var(--hover-bg-color);
}
.button-selected {
color: var(--accent-bg-color);
background-color: var(--selected-bg-color);
}
/* input */
.input {
display: block;
box-sizing: border-box;
border: none;
outline: none;
border-radius: var(--button-border-radius);
margin: 4px;
padding: 12px;
}
/* LoginPrompt */
.LoginPrompt {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Touchpad */
.Touchpad {
width: 100%;
height: 100%;
}
.Touchpad-touch-indicator {
pointer-events: none;
position: absolute;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--accent-bg-color);
}
/* Keyboard */
.keyboard-rows {
border-radius: 12px;
display: flex;
flex-direction: column;
}
.Keyboard-row {
display: flex;
flex-direction: row;
flex-grow: 1;
}
.Keyboard-button {
font-family: var(--fonts-regular);
font-weight: var(--main-font-weight);
padding: 8px;
background-color: var(--accent-bg-color);
color: #000000;
flex-grow: 1;
min-height: 50px;
width: 20px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.Keyboard-button:active {
background-color: var(--accent-color);
}

1756
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
frontend/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "capybara-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tsconfig/svelte": "^5.0.4",
"svelte": "^4.2.18",
"svelte-check": "^3.8.5",
"tslib": "^2.6.3",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}

137
frontend/src/app.css Normal file
View file

@ -0,0 +1,137 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--space-unit: 1em;
--space-xxs: calc(0.25 * var(--space-unit));
--space-xs: calc(0.5 * var(--space-unit));
--space-xsplus: calc(0.65 * var(--space-unit));
--space-sm: calc(0.75 * var(--space-unit));
--space-smplus: calc(0.8 * var(--space-unit));
--space-norm: var(--space-unit);
--space-normplus: calc(var(--space-unit) + var(--space-sm));
--space-md: calc(1.25 * var(--space-unit));
--space-lg: calc(2 * var(--space-unit));
--space-xl: calc(3.25 * var(--space-unit));
--space-xxl: calc(5.25 * var(--space-unit));
--radius-unit: 0.5em;
--radius-xxs: calc(0.25 * var(--radius-unit));
--radius-xs: calc(0.5 * var(--radius-unit));
--radius-xsplus: calc(0.65 * var(--radius-unit));
--radius-sm: calc(0.75 * var(--radius-unit));
--radius-norm: var(--radius-unit);
--radius-md: calc(1.25 * var(--radius-unit));
--radius-mdplus: calc(1.5 * var(--radius-unit));
--radius-lg: calc(2 * var(--radius-unit));
--radius-xl: calc(3.25 * var(--radius-unit));
--radius-xxl: calc(5.25 * var(--sradius-unit));
--h1: 1.802rem;
--h2: 1.602rem;
--h3: 1.424rem;
--h4: 1.266rem;
--h5: 1.125rem;
--h6: 0.889rem;
--p: 1rem;
--viewportWidth: 100vw;
--viewportHeight: 100vh;
color-scheme: dark;
--background-color-0: hsl(0, 0%, 6%);
--background-color-1: hsl(0, 0%, 10%);
--background-color-2: hsl(0, 0%, 13%);
--background-color-3: hsl(0, 0%, 15%);
--foreground-color-1: hsl(210, 100%, 100%);
--foreground-color-2: hsl(63, 10%, 82%);
--foreground-color-3: hsl(63, 2%, 60%);
--foreground-color-4: hsl(63, 2%, 49%);
--purple-1: hsl(266, 63%, 64%);
--blue-1: hsl(200, 78%, 50%);
--green-1: hsl(140, 78%, 50%);
--yellow-1: hsl(50, 78%, 50%);
--red-1: hsl(2, 78%, 65%);
--purple-2: hsl(266, 62%, 58%);
--purple-2-highlight: hsla(266, 62%, 58%, 0.1);
--blue-2: hsl(200, 78%, 45%);
--green-2: hsl(140, 78%, 40%);
--yellow-2: hsl(50, 78%, 60%);
--red-2: hsl(2, 78%, 60%);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
overflow: hidden;
font-size: 100%;
line-height: 26px;
width: var(--viewportWidth);
height: var(--viewportHeight);
background-color: var(--background-color-0);
color: var(--foreground-color-1);
display: flex;
flex-direction: column;
padding: var(--space-sm);
gap: var(--space-sm);
}
/* buttons */
.button {
color: var(--foreground-color-1);
background: none;
text-align: center;
border: none;
padding: 0.85em;
padding-top: 0.65em;
padding-bottom: 0.65em;
border-radius: 9999px;
font: inherit;
user-select: none;
font-weight: 600;
}
.button:hover {
background-color: var(--background-color-2);
}
.button-accent {
color: var(--colored-element-text-color);
background-color: var(--purple-1);
}
.button-accent:hover {
background-color: var(--purple-2);
}
.button-accent:disabled {
background-color: var(--purple-1);
}
.button-danger {
color: var(--red-2);
}
.button-danger:hover {
color: var(--red-1);
}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { gatewayReady } from "../gateway";
import ChipBar from "./ChipBar.svelte";
import Keyboard from "./Keyboard.svelte";
import Toast from "./Toast.svelte";
import TouchArea from "./TouchArea.svelte";
let showKeyboard = false;
let chips: ChipOption[] = [
{
id: "KEYBOARD",
text: "Keyboard",
selected: showKeyboard,
handle() {
showKeyboard = !showKeyboard;
this.selected = showKeyboard;
chips = chips;
}
}
];
</script>
{#if !$gatewayReady}
<Toast overlay>Connecting...</Toast>
{/if}
<TouchArea />
<ChipBar options={chips} selectedOptionId={undefined} />
{#if showKeyboard}
<Keyboard />
{/if}

8
frontend/src/components/ChipBar.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
declare interface ChipOption {
id: string;
hidden?: boolean;
selected?: boolean;
icon?: string;
text?: string;
handle?: (event: Event) => void;
};

View file

@ -0,0 +1,90 @@
<script lang="ts">
export let options: ChipOption[] = [];
export let selectedOptionId: string | undefined;
export let onSelect = (_option: string | undefined): void => {};
export let smaller = false;
export let doHighlight = false;
if (selectedOptionId) {
onSelect(selectedOptionId);
}
const optionClick = (option: ChipOption, event: Event) => {
if (option) {
if (doHighlight) {
selectedOptionId = option.id;
}
onSelect(selectedOptionId);
if (option.handle) {
option.handle(event);
}
}
};
</script>
<style>
div {
display: flex;
flex-direction: row;
margin-bottom: var(--space-xs);
flex-wrap: wrap;
}
button {
display: flex;
justify-content: center;
align-items: center;
color: var(--foreground-color-2);
background-color: var(--background-color-2);
padding: 0.5em;
padding-left: 0.65em;
padding-right: 0.65em;
margin-top: var(--space-xs);
margin-right: var(--space-sm);
border-radius: 9999px;
font-weight: 500;
}
.smaller button {
font-size: 0.85em;
background-color: transparent;
border: 1px solid var(--background-color-3);
}
button:hover {
background-color: var(--background-color-3);
}
button.selected {
color: var(--background-color-2);
background-color: var(--foreground-color-2);
}
button.selected .material-icons-outlined {
color: var(--background-color-2);
}
.button:not(.has-text) {
aspect-ratio: 1 / 1;
}
.has-text .material-icons-outlined {
margin-right: var(--space-xxs);
}
</style>
<div class:smaller={smaller}>
{#each options as option (option.id)}
{#if !option.hidden}
<button class="button" class:selected={ selectedOptionId === option.id || option.selected } class:has-text={!!option.text} on:click|stopPropagation="{ e => optionClick(option, e) }">
{#if option.icon}
<span class="material-icons-outlined">{ option.icon }</span>
{/if}
{#if option.text}
{ option.text }
{/if}
</button>
{/if}
{/each}
</div>

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { keyboardLayouts, charToLinuxEventCode, KEY_Caps_Lock, KEY_Num_Lock } from "../keyboard";
import { sendFrame, FrameType } from "../gateway";
import { fly } from "svelte/transition";
import { quadInOut } from "svelte/easing";
let currentLayout = keyboardLayouts.default;
const sendKey = (key: string) => {
const code = charToLinuxEventCode.get(key);
if (!code) return false;
switch (code) {
case KEY_Caps_Lock: {
if (currentLayout === keyboardLayouts.uppercase) {
currentLayout = keyboardLayouts.default;
} else {
currentLayout = keyboardLayouts.uppercase;
}
break;
}
case KEY_Num_Lock: {
if (currentLayout === keyboardLayouts.symbols) {
currentLayout = keyboardLayouts.default;
} else {
currentLayout = keyboardLayouts.symbols;
}
break;
}
default: {
sendFrame(FrameType.KeyboardButtonTapped, code);
break;
}
}
};
</script>
<style>
.keyboard {
display: flex;
flex-direction: column;
background-color: transparent;
}
.row {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
}
button {
flex: 1;
min-height: 55px;
padding: 4px;
display: flex;
flex-direction: row;
border: none;
color: var(--foreground-color-2);
background-color: transparent;
}
button:active .key-inner {
background-color: var(--background-color-2);
}
.key-inner {
display: flex;
justify-content: center;
align-items: center;
flex-grow: 1;
padding: 6px;
border-radius: var(--radius-norm);
background-color: var(--background-color-1);
}
</style>
<div class="keyboard" transition:fly={{ duration: 250, easing: quadInOut, y: 45 }}>
{#each currentLayout as row}
<div class="row">
{#each row as key}
<button on:pointerdown={ () => sendKey(key) }><div class="key-inner">{key}</div></button>
{/each}
</div>
{/each}
</div>

View file

@ -0,0 +1,37 @@
<script lang="ts">
export let overlay: boolean = false;
</script>
<style>
div {
font-size: var(--h4);
padding: var(--space-md);
border-radius: var(--radius-norm);
background-color: var(--background-color-2);
}
.overlay {
position: fixed;
contain: strict;
user-select: none;
pointer-events: none;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 15;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
color: var(--foreground-color-2);
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(3px);
}
</style>
<div class:overlay><slot /></div>

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { FrameType, sendFrame } from "../gateway";
class Touch {
id: number = -1;
clientX: number = 0;
clientY: number = 0;
didMove: boolean = false;
constructor(id: number, clientX: number, clientY: number) {
this.id = id;
this.clientX = clientX;
this.clientY = clientY;
}
}
const LEFT_HOLD_TIMEOUT_MS = 300;
const SCROLL_X_MULTIPLIER = 0.8;
const SCROLL_Y_MULTIPLIER = 0.3;
const MOVE_X_MULTIPLIER = 3;
const MOVE_Y_MULTIPLIER = 3;
let leftHoldTimeout: null | number = null;
let holdingLeftButton = false;
let shouldResetMoveDelta = false;
let lastTouchX = 0;
let lastTouchY = 0;
let needsScrollEnd = false;
const idToTouch = new Map<number, Touch>();
function touchStart(event: TouchEvent) {
event.preventDefault();
event.stopPropagation();
const touches = event.changedTouches;
if (leftHoldTimeout !== null) {
clearInterval(leftHoldTimeout);
leftHoldTimeout = null;
}
// If the touch is still unmoved and held for a certain amount of time,
// we will enter "holding mode", which keeps the left mouse button held
// down until the touch stops.
if (touches.length === 1) {
const touchId = touches[0].identifier;
leftHoldTimeout = setTimeout(() => {
const foundTouch = idToTouch.get(touchId);
if (foundTouch && idToTouch.size === 1 && !foundTouch.didMove) {
holdingLeftButton = true;
sendFrame(FrameType.PointerButtonPressed, 0x110);
}
}, LEFT_HOLD_TIMEOUT_MS);
}
for (let i = 0; i < touches.length; i++) {
const touch = touches[i];
idToTouch.set(touch.identifier, new Touch(touch.identifier, touch.clientX, touch.clientY));
}
}
function touchEnd(event: TouchEvent) {
event.preventDefault();
event.stopPropagation();
const touches = event.changedTouches;
shouldResetMoveDelta = true;
if (touches.length === 1) {
const touch = idToTouch.get(touches[0].identifier);
if (touch && holdingLeftButton) {
// We were holding the left mouse button and now we released the touch, send button up
// TODO: this may interact poorly with multiple touches, we might want to just stop
// accepting any touches while holding the left mouse button.
holdingLeftButton = false;
sendFrame(FrameType.PointerButtonReleased, 0x110);
} else if (touch && !touch.didMove) {
// This is a single tap, send left click
sendFrame(FrameType.PointerButtonTapped, 0x110);
}
}
if (needsScrollEnd) {
needsScrollEnd = false;
sendFrame(FrameType.PointerAxisFinal, 0.1, 0.1);
}
for (let i = 0; i < touches.length; i++) {
idToTouch.delete(touches[i].identifier);
}
}
function touchMove(event: TouchEvent) {
event.preventDefault();
event.stopPropagation();
const touches = event.changedTouches;
if (!touches.length) return;
const moveTouch = touches[0];
const touchX = moveTouch.pageX;
const touchY = moveTouch.pageY;
if (shouldResetMoveDelta) {
shouldResetMoveDelta = false;
lastTouchX = touchX;
lastTouchY = touchY;
}
const deltaX = touchX - lastTouchX;
const deltaY = touchY - lastTouchY;
lastTouchX = touchX;
lastTouchY = touchY;
const scrollDeltaX = Math.ceil(deltaX * SCROLL_X_MULTIPLIER);
const scrollDeltaY = Math.ceil(deltaY * SCROLL_Y_MULTIPLIER);
const moveDeltaX = Math.ceil(deltaX * MOVE_X_MULTIPLIER);
const moveDeltaY = Math.ceil(deltaY * MOVE_Y_MULTIPLIER);
let markTouchesMoved = false;
if (touches.length === 2 && (scrollDeltaX || scrollDeltaY)) {
markTouchesMoved = true;
needsScrollEnd = true;
sendFrame(FrameType.PointerAxis, scrollDeltaX, scrollDeltaY);
} else if (moveDeltaX || moveDeltaY) {
markTouchesMoved = true;
sendFrame(FrameType.PointerMotion, moveDeltaX, moveDeltaY);
}
if (markTouchesMoved) {
for (let i = 0; i < touches.length; i++) {
const touch = idToTouch.get(touches[i].identifier);
if (touch) {
touch.didMove = true;
}
}
}
}
</script>
<style>
section {
flex-grow: 1;
border-radius: var(--radius-md);
border: 1px solid var(--background-color-2);
background-color: transparent;
}
</style>
<section on:touchstart={touchStart} on:touchend={touchEnd} on:touchmove={touchMove}></section>

196
frontend/src/gateway.ts Normal file
View file

@ -0,0 +1,196 @@
import { get, writable } from "svelte/store";
export enum FrameType {
None = 0,
// Client to server
Authenticate = 1,
// Server to client
Ready,
// Client to server
PointerMotion = 10,
PointerAxis,
PointerAxisFinal,
PointerButtonPressed,
PointerButtonReleased,
PointerButtonTapped,
KeyboardButtonPressed,
KeyboardButtonReleased,
KeyboardButtonTapped
};
let ws: null | WebSocket = null;
const sizeToDataView = new Map<number, DataView>();
let reconnectTimeout: number | null = null;
const RECONNECT_TIME_MS = 800;
export let gatewayReady = writable(false);
function isConnected(): boolean {
return ws !== null && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING;
}
function isReady() {
return isConnected() && get(gatewayReady);
}
function wantsConnection(): boolean {
return document.visibilityState === "visible";
}
function clearReconnect() {
if (reconnectTimeout !== null) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
}
function getDataView(size: number): DataView {
const existing = sizeToDataView.get(size);
if (existing) return existing;
const buffer = new ArrayBuffer(size);
const view = new DataView(buffer);
sizeToDataView.set(size, view);
return view;
}
function createFrame(type: FrameType, arg1: number, arg2: number): ArrayBuffer {
let view;
switch (type) {
case FrameType.Authenticate: {
view = getDataView(1);
view.setUint8(0, FrameType.Authenticate);
break;
}
case FrameType.PointerMotion: {
view = getDataView(9);
view.setUint8(0, FrameType.PointerMotion);
view.setFloat32(1, arg1);
view.setFloat32(5, arg2);
break;
}
case FrameType.PointerAxis: {
view = getDataView(9);
view.setUint8(0, FrameType.PointerAxis);
view.setFloat32(1, arg1);
view.setFloat32(5, arg2);
break;
}
case FrameType.PointerAxisFinal: {
view = getDataView(9);
view.setUint8(0, FrameType.PointerAxisFinal);
view.setFloat32(1, arg1);
view.setFloat32(5, arg2);
break;
}
case FrameType.PointerButtonPressed: {
view = getDataView(5);
view.setUint8(0, FrameType.PointerButtonPressed);
view.setInt32(1, arg1);
break;
}
case FrameType.PointerButtonReleased: {
view = getDataView(5);
view.setUint8(0, FrameType.PointerButtonReleased);
view.setInt32(1, arg1);
break;
}
case FrameType.PointerButtonTapped: {
view = getDataView(5);
view.setUint8(0, FrameType.PointerButtonTapped);
view.setInt32(1, arg1);
break;
}
case FrameType.KeyboardButtonPressed: {
view = getDataView(5);
view.setUint8(0, FrameType.KeyboardButtonPressed);
view.setInt32(1, arg1);
break;
}
case FrameType.KeyboardButtonReleased: {
view = getDataView(5);
view.setUint8(0, FrameType.KeyboardButtonReleased);
view.setInt32(1, arg1);
break;
}
case FrameType.KeyboardButtonTapped: {
view = getDataView(5);
view.setUint8(0, FrameType.KeyboardButtonTapped);
view.setInt32(1, arg1);
break;
}
default: {
throw new Error(`bad FrameType: ${type}`);
}
}
return view.buffer;
}
export function sendFrame(type: FrameType, arg1: number = 0, arg2: number = 0) {
if (!isReady() || !ws) {
return;
}
ws.send(createFrame(type, arg1, arg2));
}
export function connect() {
clearReconnect();
ws = null;
gatewayReady.set(false);
if (!wantsConnection()) return;
ws = new WebSocket(`ws://${location.host}/gateway`);
ws.binaryType = "arraybuffer";
ws.addEventListener("open", () => {
clearReconnect();
if (!ws) return;
ws.send(createFrame(FrameType.Authenticate, 0, 0));
});
ws.addEventListener("message", (event: MessageEvent) => {
if (event.data && typeof event.data === "object" && event.data instanceof ArrayBuffer && event.data.byteLength) {
const view = new DataView(event.data);
const frameType = view.getUint8(0) as FrameType;
switch (frameType) {
case FrameType.Ready: {
gatewayReady.set(true);
break;
}
default: {
console.error(`got bad FrameType from server: ${frameType}`);
}
}
}
});
ws.addEventListener("close", () => {
clearReconnect();
ws = null;
gatewayReady.set(false);
if (!wantsConnection()) return;
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
connect();
}, RECONNECT_TIME_MS);
});
}
export function initVisibilityHandlers() {
document.addEventListener("visibilitychange", () => {
if (!isConnected() && wantsConnection()) {
queueMicrotask(connect);
} else if (ws && isConnected() && !wantsConnection()) {
ws.close();
}
});
}

237
frontend/src/keyboard.ts Normal file
View file

@ -0,0 +1,237 @@
// XKB Keysyms. Taken from https://github.com/xkbcommon/libxkbcommon/blob/master/include/xkbcommon/xkbcommon-keysyms.h
const KEY_BackSpace = 0xff08; /* U+0008 BACKSPACE */
const KEY_Return = 0xff0d; /* U+000D CARRIAGE RETURN */
// Exported because they need special handling to change the keyboard layout.
export const KEY_Num_Lock = 0xff7f;
export const KEY_Caps_Lock = 0xffe5; /* Caps lock */
const KEY_space = 0x0020; /* U+0020 SPACE */
const KEY_exclam = 0x0021; /* U+0021 EXCLAMATION MARK */
const KEY_quotedbl = 0x0022; /* U+0022 QUOTATION MARK */
const KEY_numbersign = 0x0023; /* U+0023 NUMBER SIGN */
const KEY_dollar = 0x0024; /* U+0024 DOLLAR SIGN */
const KEY_percent = 0x0025; /* U+0025 PERCENT SIGN */
const KEY_ampersand = 0x0026; /* U+0026 AMPERSAND */
const KEY_apostrophe = 0x0027; /* U+0027 APOSTROPHE */
const KEY_parenleft = 0x0028; /* U+0028 LEFT PARENTHESIS */
const KEY_parenright = 0x0029; /* U+0029 RIGHT PARENTHESIS */
const KEY_plus = 0x002b; /* U+002B PLUS SIGN */
const KEY_comma = 0x002c; /* U+002C COMMA */
const KEY_minus = 0x002d; /* U+002D HYPHEN-MINUS */
const KEY_period = 0x002e; /* U+002E FULL STOP */
const KEY_slash = 0x002f; /* U+002F SOLIDUS */
const KEY_0 = 0x0030; /* U+0030 DIGIT ZERO */
const KEY_1 = 0x0031; /* U+0031 DIGIT ONE */
const KEY_2 = 0x0032; /* U+0032 DIGIT TWO */
const KEY_3 = 0x0033; /* U+0033 DIGIT THREE */
const KEY_4 = 0x0034; /* U+0034 DIGIT FOUR */
const KEY_5 = 0x0035; /* U+0035 DIGIT FIVE */
const KEY_6 = 0x0036; /* U+0036 DIGIT SIX */
const KEY_7 = 0x0037; /* U+0037 DIGIT SEVEN */
const KEY_8 = 0x0038; /* U+0038 DIGIT EIGHT */
const KEY_9 = 0x0039; /* U+0039 DIGIT NINE */
const KEY_colon = 0x003a; /* U+003A COLON */
const KEY_semicolon = 0x003b; /* U+003B SEMICOLON */
const KEY_less = 0x003c; /* U+003C LESS-THAN SIGN */
const KEY_equal = 0x003d; /* U+003D EQUALS SIGN */
const KEY_greater = 0x003e; /* U+003E GREATER-THAN SIGN */
const KEY_question = 0x003f; /* U+003F QUESTION MARK */
const KEY_at = 0x0040; /* U+0040 COMMERCIAL AT */
const KEY_A = 0x0041; /* U+0041 LATIN CAPITAL LETTER A */
const KEY_B = 0x0042; /* U+0042 LATIN CAPITAL LETTER B */
const KEY_C = 0x0043; /* U+0043 LATIN CAPITAL LETTER C */
const KEY_D = 0x0044; /* U+0044 LATIN CAPITAL LETTER D */
const KEY_E = 0x0045; /* U+0045 LATIN CAPITAL LETTER E */
const KEY_F = 0x0046; /* U+0046 LATIN CAPITAL LETTER F */
const KEY_G = 0x0047; /* U+0047 LATIN CAPITAL LETTER G */
const KEY_H = 0x0048; /* U+0048 LATIN CAPITAL LETTER H */
const KEY_I = 0x0049; /* U+0049 LATIN CAPITAL LETTER I */
const KEY_J = 0x004a; /* U+004A LATIN CAPITAL LETTER J */
const KEY_K = 0x004b; /* U+004B LATIN CAPITAL LETTER K */
const KEY_L = 0x004c; /* U+004C LATIN CAPITAL LETTER L */
const KEY_M = 0x004d; /* U+004D LATIN CAPITAL LETTER M */
const KEY_N = 0x004e; /* U+004E LATIN CAPITAL LETTER N */
const KEY_O = 0x004f; /* U+004F LATIN CAPITAL LETTER O */
const KEY_P = 0x0050; /* U+0050 LATIN CAPITAL LETTER P */
const KEY_Q = 0x0051; /* U+0051 LATIN CAPITAL LETTER Q */
const KEY_R = 0x0052; /* U+0052 LATIN CAPITAL LETTER R */
const KEY_S = 0x0053; /* U+0053 LATIN CAPITAL LETTER S */
const KEY_T = 0x0054; /* U+0054 LATIN CAPITAL LETTER T */
const KEY_U = 0x0055; /* U+0055 LATIN CAPITAL LETTER U */
const KEY_V = 0x0056; /* U+0056 LATIN CAPITAL LETTER V */
const KEY_W = 0x0057; /* U+0057 LATIN CAPITAL LETTER W */
const KEY_X = 0x0058; /* U+0058 LATIN CAPITAL LETTER X */
const KEY_Y = 0x0059; /* U+0059 LATIN CAPITAL LETTER Y */
const KEY_Z = 0x005a; /* U+005A LATIN CAPITAL LETTER Z */
const KEY_bracketleft = 0x005b; /* U+005B LEFT SQUARE BRACKET */
const KEY_backslash = 0x005c; /* U+005C REVERSE SOLIDUS */
const KEY_bracketright = 0x005d; /* U+005D RIGHT SQUARE BRACKET */
const KEY_asciicircum = 0x005e; /* U+005E CIRCUMFLEX ACCENT */
const KEY_underscore = 0x005f; /* U+005F LOW LINE */
const KEY_grave = 0x0060; /* U+0060 GRAVE ACCENT */
const KEY_quoteleft = 0x0060; /* deprecated */
const KEY_a = 0x0061; /* U+0061 LATIN SMALL LETTER A */
const KEY_b = 0x0062; /* U+0062 LATIN SMALL LETTER B */
const KEY_c = 0x0063; /* U+0063 LATIN SMALL LETTER C */
const KEY_d = 0x0064; /* U+0064 LATIN SMALL LETTER D */
const KEY_e = 0x0065; /* U+0065 LATIN SMALL LETTER E */
const KEY_f = 0x0066; /* U+0066 LATIN SMALL LETTER F */
const KEY_g = 0x0067; /* U+0067 LATIN SMALL LETTER G */
const KEY_h = 0x0068; /* U+0068 LATIN SMALL LETTER H */
const KEY_i = 0x0069; /* U+0069 LATIN SMALL LETTER I */
const KEY_j = 0x006a; /* U+006A LATIN SMALL LETTER J */
const KEY_k = 0x006b; /* U+006B LATIN SMALL LETTER K */
const KEY_l = 0x006c; /* U+006C LATIN SMALL LETTER L */
const KEY_m = 0x006d; /* U+006D LATIN SMALL LETTER M */
const KEY_n = 0x006e; /* U+006E LATIN SMALL LETTER N */
const KEY_o = 0x006f; /* U+006F LATIN SMALL LETTER O */
const KEY_p = 0x0070; /* U+0070 LATIN SMALL LETTER P */
const KEY_q = 0x0071; /* U+0071 LATIN SMALL LETTER Q */
const KEY_r = 0x0072; /* U+0072 LATIN SMALL LETTER R */
const KEY_s = 0x0073; /* U+0073 LATIN SMALL LETTER S */
const KEY_t = 0x0074; /* U+0074 LATIN SMALL LETTER T */
const KEY_u = 0x0075; /* U+0075 LATIN SMALL LETTER U */
const KEY_v = 0x0076; /* U+0076 LATIN SMALL LETTER V */
const KEY_w = 0x0077; /* U+0077 LATIN SMALL LETTER W */
const KEY_x = 0x0078; /* U+0078 LATIN SMALL LETTER X */
const KEY_y = 0x0079; /* U+0079 LATIN SMALL LETTER Y */
const KEY_z = 0x007a; /* U+007A LATIN SMALL LETTER Z */
const KEY_braceleft = 0x007b; /* U+007B LEFT CURLY BRACKET */
const KEY_bar = 0x007c; /* U+007C VERTICAL LINE */
const KEY_braceright = 0x007d; /* U+007D RIGHT CURLY BRACKET */
const KEY_asciitilde = 0x007e; /* U+007E TILDE */
const KEY_multiply = 0x00d7; /* U+00D7 MULTIPLICATION SIGN */
export const keyboardLayouts = {
"default": [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
["🄰", "z", "x", "c", "v", "b", "n", "m", ".", "⌦"],
["🔢", " ", "↵"]
],
"uppercase": [
["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
["🅰", "Z", "X", "C", "V", "B", "N", "M", ",", "⌦"],
["🔢", " ", "↵"]
],
"symbols": [
["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
["[", "]", "{", "}", ";", ":", "'", "\"", ",", "<"],
[".", ">", "-", "_", "=", "+", "/", "?", "\\"],
["🄰", "|", "`", "~", "⌦"],
["🔤", " ", "↵"]
],
};
export const charToLinuxEventCode = new Map<string, number>(Object.entries({
"1": KEY_1,
"2": KEY_2,
"3": KEY_3,
"4": KEY_4,
"5": KEY_5,
"6": KEY_6,
"7": KEY_7,
"8": KEY_8,
"9": KEY_9,
"0": KEY_0,
"q": KEY_q,
"w": KEY_w,
"e": KEY_e,
"r": KEY_r,
"t": KEY_t,
"y": KEY_y,
"u": KEY_u,
"i": KEY_i,
"o": KEY_o,
"p": KEY_p,
"a": KEY_a,
"s": KEY_s,
"d": KEY_d,
"f": KEY_f,
"g": KEY_g,
"h": KEY_h,
"j": KEY_j,
"k": KEY_k,
"l": KEY_l,
"🄰": KEY_Caps_Lock,
"z": KEY_z,
"x": KEY_x,
"c": KEY_c,
"v": KEY_v,
"b": KEY_b,
"n": KEY_n,
"m": KEY_m,
".": KEY_period,
"⌦": KEY_BackSpace,
"🔢": KEY_Num_Lock,
"🔤": KEY_Num_Lock,
" ": KEY_space,
"↵": KEY_Return,
"!": KEY_exclam,
"@": KEY_at,
"#": KEY_numbersign,
"$": KEY_dollar,
"%": KEY_percent,
"^": KEY_asciicircum,
"&": KEY_ampersand,
"*": KEY_multiply,
"(": KEY_parenleft,
")": KEY_parenright,
"Q": KEY_Q,
"W": KEY_W,
"E": KEY_E,
"R": KEY_R,
"T": KEY_T,
"Y": KEY_Y,
"U": KEY_U,
"I": KEY_I,
"O": KEY_O,
"P": KEY_P,
"A": KEY_A,
"S": KEY_S,
"D": KEY_D,
"F": KEY_F,
"G": KEY_G,
"H": KEY_H,
"J": KEY_J,
"K": KEY_K,
"L": KEY_L,
"🅰": KEY_Caps_Lock,
"Z": KEY_Z,
"X": KEY_X,
"C": KEY_C,
"V": KEY_V,
"B": KEY_B,
"N": KEY_N,
"M": KEY_M,
",": KEY_comma,
"[": KEY_bracketright,
"]": KEY_bracketleft,
"{": KEY_braceright,
"}": KEY_braceleft,
";": KEY_semicolon,
":": KEY_colon,
"'": KEY_apostrophe,
"\"": KEY_quotedbl,
"<": KEY_less,
">": KEY_greater,
"-": KEY_minus,
"_": KEY_underscore,
"=": KEY_equal,
"+": KEY_plus,
"/": KEY_slash,
"?": KEY_question,
"\\": KEY_backslash,
"|": KEY_bar,
"`": KEY_grave,
"~": KEY_asciitilde,
}));

16
frontend/src/main.ts Normal file
View file

@ -0,0 +1,16 @@
import './app.css';
import App from './components/App.svelte';
import { connect, initVisibilityHandlers } from './gateway';
import { initViewportExtents } from './responsive';
initViewportExtents();
connect();
initVisibilityHandlers();
const app = new App({
target: document.body
});
export default app;

View file

@ -0,0 +1,15 @@
export const initViewportExtents = () => {
const root = document.querySelector<HTMLElement>(':root');
if (CSS.supports("(width: 1dvw)") && root) {
root.style.setProperty("--viewportWidth", "100dvw");
root.style.setProperty("--viewportHeight", "100dvh");
} else if (root) {
const updateUnits = () => {
root.style.setProperty("--viewportWidth", `${window.innerWidth}px`);
root.style.setProperty("--viewportHeight", `${window.innerHeight}px`);
};
window.addEventListener("resize", updateUnits);
updateUnits();
}
};

2
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

View file

@ -1,47 +0,0 @@
const defaults = {
"server:gatewayBase": `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/gateway`,
"auth:token": "",
};
const store = new Map(Object.entries(defaults));
const persistentProvider = localStorage;
let didCacheProvider = false;
export function setItem(key, value) {
store.set(key, value);
if (persistentProvider) {
persistentProvider.setItem(key, typeof value === "string" ? value : JSON.stringify(value));
}
}
export function getItem(key) {
if (!didCacheProvider) {
init();
}
return store.get(key);
}
export function removeItem(key) {
store.delete(key);
if (persistentProvider) {
persistentProvider.removeItem(key);
}
}
export function init() {
if (!persistentProvider)
return;
store.forEach((defaultValue, key) => {
const override = persistentProvider.getItem(key);
if (override !== null) {
try {
store.set(key, typeof defaultValue === "string" ? override : JSON.parse(override));
} catch (o_O) {
console.warn("[Storage]", `init(): An exception was thrown while parsing the value of key "${key}" from persistentProvider. The key "${key}" will be removed from persistentProvider.`, o_O);
persistentProvider.removeItem(key);
}
}
});
didCacheProvider = true;
}

View file

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

29
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force"
},
"include": [
"src/**/*.ts",
"src/**/*.js",
"src/**/*.svelte"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": [
"vite.config.ts"
]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
})

View file

@ -1,2 +0,0 @@
pynput
sanic

274
src/main.rs Normal file
View file

@ -0,0 +1,274 @@
use std::{process::ExitCode, sync::Arc};
use ashpd::{
desktop::{
remote_desktop::{DeviceType, KeyState, RemoteDesktop},
PersistMode, Session,
},
WindowIdentifier,
};
use bytes::{Buf, BufMut, BytesMut};
use warp::{
filters::ws::Message, ws::WebSocket, Filter
};
use futures_util::{SinkExt, StreamExt};
use num_traits::FromPrimitive;
use num_derive::FromPrimitive;
use zbus::{proxy, zvariant::{DeserializeDict, OwnedValue, SerializeDict, Type, Value}, Connection, Result};
#[repr(u8)]
#[derive(FromPrimitive, Debug)]
enum FrameType {
None = 0,
// Client to server
Authenticate = 1,
// Server to client
Ready,
// Client to server
PointerMotion = 10,
PointerAxis,
PointerAxisFinal,
PointerButtonPressed,
PointerButtonReleased,
PointerButtonTapped,
KeyboardButtonPressed,
KeyboardButtonReleased,
KeyboardButtonTapped
}
struct RemoteDesktopSession<'a> {
proxy: RemoteDesktop<'a>,
session: Session<'a, RemoteDesktop<'a>>,
}
async fn create_remote_desktop_session<'a>() -> ashpd::Result<Arc<RemoteDesktopSession<'a>>> {
let proxy = RemoteDesktop::new().await?;
let session = proxy.create_session().await?;
proxy
.select_devices(
&session,
DeviceType::Keyboard | DeviceType::Pointer,
None,
PersistMode::DoNot,
)
.await?;
let response = proxy
.start(&session, &WindowIdentifier::default())
.await?
.response()?;
println!("{:#?}", response.devices());
Ok(Arc::new(RemoteDesktopSession { proxy, session }))
}
async fn handle_gateway_connection(ws: WebSocket, session: Arc<RemoteDesktopSession<'_>>) {
let (mut user_ws_tx, mut user_ws_rx) = ws.split();
let mut authenticated = false;
while let Some(result) = user_ws_rx.next().await {
let msg = match result {
Ok(msg) => msg,
Err(_e) => {
break;
}
};
if !msg.is_binary() {
continue;
}
let mut bytes = msg.as_bytes();
if bytes.remaining() < 1 {
continue;
}
let frame_type = FromPrimitive::from_u8(bytes.get_u8());
println!("frame: {:?}", frame_type);
match frame_type {
Some(FrameType::Authenticate) => {
if authenticated {
continue;
}
// Here we'd check a password or something
authenticated = true;
let mut out_bytes = BytesMut::with_capacity(1);
out_bytes.put_u8(FrameType::Ready as u8);
if let Err(_) = user_ws_tx.send(Message::binary(out_bytes)).await {
return;
}
},
Some(FrameType::PointerMotion) => {
if !authenticated {
continue;
}
// PointerMotion payload:
// f32 dx, f32 dy
if bytes.remaining() < 8 {
continue;
}
let dx = bytes.get_f32();
let dy = bytes.get_f32();
let _ = session.proxy.notify_pointer_motion(&session.session, dx.into(), dy.into()).await;
},
Some(FrameType::PointerAxis) => {
if !authenticated {
continue;
}
// PointerAxis payload:
// f32 dx, f32 dy
if bytes.remaining() < 8 {
continue;
}
let dx = bytes.get_f32();
let dy = bytes.get_f32();
let _ = session.proxy.notify_pointer_axis(&session.session, dx.into(), dy.into(), false).await;
},
Some(FrameType::PointerAxisFinal) => {
if !authenticated {
continue;
}
// PointerAxisFinal payload:
// f32 dx, f32 dy
if bytes.remaining() < 8 {
continue;
}
let dx = bytes.get_f32();
let dy = bytes.get_f32();
let _ = session.proxy.notify_pointer_axis(&session.session, dx.into(), dy.into(), true).await;
},
Some(FrameType::PointerButtonPressed) => {
if !authenticated {
continue;
}
// PointerButtonPressed payload:
// i32 button
if bytes.remaining() < 4 {
continue;
}
let button = bytes.get_i32();
let _ = session.proxy.notify_pointer_button(&session.session, button, KeyState::Pressed).await;
},
Some(FrameType::PointerButtonReleased) => {
if !authenticated {
continue;
}
// PointerButtonReleased payload:
// i32 button
if bytes.remaining() < 4 {
continue;
}
let button = bytes.get_i32();
let _ = session.proxy.notify_pointer_button(&session.session, button, KeyState::Released).await;
},
Some(FrameType::PointerButtonTapped) => {
if !authenticated {
continue;
}
// PointerButtonTapped payload:
// i32 button
if bytes.remaining() < 4 {
continue;
}
let button = bytes.get_i32();
let _ = session.proxy.notify_pointer_button(&session.session, button, KeyState::Pressed).await;
let _ = session.proxy.notify_pointer_button(&session.session, button, KeyState::Released).await;
},
Some(FrameType::KeyboardButtonPressed) => {
if !authenticated {
continue;
}
// KeyboardButtonPressed payload:
// i32 code
if bytes.remaining() < 4 {
continue;
}
let code = bytes.get_i32();
let _ = session.proxy.notify_keyboard_keysym(&session.session, code, KeyState::Pressed).await;
},
Some(FrameType::KeyboardButtonReleased) => {
if !authenticated {
continue;
}
// KeyboardButtonReleased payload:
// i32 code
if bytes.remaining() < 4 {
continue;
}
let code = bytes.get_i32();
let _ = session.proxy.notify_keyboard_keysym(&session.session, code, KeyState::Released).await;
},
Some(FrameType::KeyboardButtonTapped) => {
if !authenticated {
continue;
}
// KeyboardButtonTapped payload:
// i32 code
if bytes.remaining() < 4 {
continue;
}
let code = bytes.get_i32();
let _ = session.proxy.notify_keyboard_keysym(&session.session, code, KeyState::Pressed).await;
let _ = session.proxy.notify_keyboard_keysym(&session.session, code, KeyState::Released).await;
},
// Invalid frame types
None | Some(FrameType::None) | Some(FrameType::Ready) => {}
}
}
}
#[tokio::main]
async fn main() -> ExitCode {
let session = if let Ok(x) = create_remote_desktop_session().await { x } else { return ExitCode::from(1); };
let session = warp::any().map(move || session.clone());
let chat = warp::path("gateway")
.and(warp::ws())
.and(session)
.map(|ws: warp::ws::Ws, session| {
ws.on_upgrade(move |socket| handle_gateway_connection(socket, session))
});
let assets = warp::path("assets")
.and(warp::fs::dir("frontend/dist/assets"));
let root = warp::path::end()
.and(warp::fs::file("frontend/dist/index.html"))
.or(assets);
warp::serve(chat.or(root))
.run(([0, 0, 0, 0], 3030))
.await;
return ExitCode::from(0);
}