rewrite in rust
This commit is contained in:
parent
ca9d562ed5
commit
53bd9f15e7
39 changed files with 4893 additions and 1133 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
frontend/node_modules/
|
dist/
|
||||||
frontend/dist/
|
target/
|
||||||
__pycache__/
|
node_modules/
|
||||||
|
|
||||||
|
|
1740
Cargo.lock
generated
Normal file
1740
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
14
Cargo.toml
Normal file
14
Cargo.toml
Normal 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"] }
|
|
@ -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
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
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:
|
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:
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
70
capybara.py
70
capybara.py
|
@ -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()
|
|
13
constants.py
13
constants.py
|
@ -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
|
|
||||||
}
|
|
109
frontend/App.js
109
frontend/App.js
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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>
|
|
|
@ -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
15
frontend/index.html
Normal 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>
|
|
@ -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;
|
|
|
@ -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
1756
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal 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
137
frontend/src/app.css
Normal 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);
|
||||||
|
}
|
33
frontend/src/components/App.svelte
Normal file
33
frontend/src/components/App.svelte
Normal 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
8
frontend/src/components/ChipBar.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
declare interface ChipOption {
|
||||||
|
id: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
text?: string;
|
||||||
|
handle?: (event: Event) => void;
|
||||||
|
};
|
90
frontend/src/components/ChipBar.svelte
Normal file
90
frontend/src/components/ChipBar.svelte
Normal 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>
|
87
frontend/src/components/Keyboard.svelte
Normal file
87
frontend/src/components/Keyboard.svelte
Normal 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>
|
37
frontend/src/components/Toast.svelte
Normal file
37
frontend/src/components/Toast.svelte
Normal 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>
|
153
frontend/src/components/TouchArea.svelte
Normal file
153
frontend/src/components/TouchArea.svelte
Normal 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
196
frontend/src/gateway.ts
Normal 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
237
frontend/src/keyboard.ts
Normal 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
16
frontend/src/main.ts
Normal 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;
|
15
frontend/src/responsive.ts
Normal file
15
frontend/src/responsive.ts
Normal 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
2
frontend/src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
|
@ -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;
|
|
||||||
}
|
|
7
frontend/svelte.config.js
Normal file
7
frontend/svelte.config.js
Normal 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
29
frontend/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
14
frontend/tsconfig.node.json
Normal file
14
frontend/tsconfig.node.json
Normal 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
7
frontend/vite.config.ts
Normal 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()],
|
||||||
|
})
|
|
@ -1,2 +0,0 @@
|
||||||
pynput
|
|
||||||
sanic
|
|
274
src/main.rs
Normal file
274
src/main.rs
Normal 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);
|
||||||
|
}
|
Loading…
Reference in a new issue