diff --git a/InputController.py b/InputController.py
index 7ababb9..d580d62 100644
--- a/InputController.py
+++ b/InputController.py
@@ -77,4 +77,4 @@ class InputController():
print("got invalid code from parser (is this a bug with the MessageParser?)")
return False
- return True
\ No newline at end of file
+ return True
diff --git a/capybara.py b/capybara.py
index 7709e28..97805c4 100755
--- a/capybara.py
+++ b/capybara.py
@@ -1,22 +1,67 @@
+import sys
+from asyncio import wait_for, TimeoutError
+from base64 import b64encode
from sanic import Sanic
-from sanic.response import file
+from sanic.response import file, redirect
from InputController import InputController
+from MessageParser import MessageParser
-app = Sanic("capybara")
+app = Sanic(
+ "capybara",
+ load_env="CAPYBARA_"
+)
+input_controller = InputController()
+control_message_parser = MessageParser()
+
+# auth packet
+control_message_parser.add_handler("0", {
+ "auth_string": "str"
+})
-app.ctx.input_controller = InputController()
app.static("app", "frontend/dist/", 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 input packets
+
while True:
- app.ctx.input_controller.process_message(await ws.recv())
+ 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__":
diff --git a/frontend/src/App.js b/frontend/src/App.js
new file mode 100644
index 0000000..49473a4
--- /dev/null
+++ b/frontend/src/App.js
@@ -0,0 +1,70 @@
+import Connection from "./Connection";
+import KeyboardController from "./Keyboard";
+import { getAuth, setAuth } from "./LocalConfiguration";
+import LoginPrompt from "./LoginPrompt";
+import TouchpadController from "./Touchpad";
+
+class App {
+ constructor(mountElement) {
+ this.mountElement = mountElement;
+
+ this.connection = new Connection(`ws://${location.host}/gateway`);
+ this.touchpad = new TouchpadController(this.connection);
+ this.keyboard = new KeyboardController(this.connection);
+ this.loginPromptComponent = null;
+
+ this.connection.connect(getAuth());
+ this.connection.ws.addEventListener("close", ({code}) => {
+ this.unmountApp();
+ if (code === 4001) { // 4001 - code for bad auth
+ this.transitionTo("login");
+ }
+ });
+ this.connection.onHandshakeCompleted = () => {
+ this.transitionTo("app");
+ };
+ }
+
+ transitionTo(type) {
+ switch (type) {
+ case "login":
+ this.unmountApp();
+ this.mountLoginComponent();
+ break;
+ case "app":
+ this.unmountLoginComponent();
+ this.mountApp();
+ break;
+ default:
+ throw new Error(`transitionTo type ${type} is invalid`);
+ }
+ }
+
+ mountLoginComponent() {
+ if (!this.loginPromptComponent)
+ this.loginPromptComponent = new LoginPrompt(this.connection);
+
+ this.loginPromptComponent.mountOn(this.mountElement);
+ this.loginPromptComponent.onPasswordSubmitted = p => {
+ setAuth(p);
+ this.connection.connect(p);
+ };
+ }
+
+ 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;
diff --git a/frontend/src/Banner.js b/frontend/src/Banner.js
new file mode 100644
index 0000000..7395654
--- /dev/null
+++ b/frontend/src/Banner.js
@@ -0,0 +1,34 @@
+class Banner {
+ constructor() {
+ this.text = null;
+ }
+
+ updateText(text) {
+ this.text = text;
+ }
+
+ mountOn(target) {
+ if (this.element)
+ return; // Already mounted
+
+ this.element = document.createRange().createContextualFragment(`
+
+
Login
+
You need to enter the login code before you can start controlling your device.
+
+
+
+
+
+
+
+ `).children[0];
+
+ this.element.querySelector("#continue-button").addEventListener("click", () => {
+ if (this.onPasswordSubmitted)
+ this.onPasswordSubmitted(this.element.querySelector("#code-input").value);
+ });
+
+ target.appendChild(this.element);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/Connection.js b/frontend/src/Connection.js
index e08e6bc..ab74b0c 100644
--- a/frontend/src/Connection.js
+++ b/frontend/src/Connection.js
@@ -6,16 +6,37 @@ class Connection {
this.log = Logger(["Connection"], ["log"]).log;
this.messageLog = Logger(["Connection", "Message"], ["log"]).log;
this.url = url;
+ this.isReady = false;
}
- connect() {
+ formatAuthString(password) {
+ return `%auth%${btoa(password)}`;
+ }
+
+ connect(password) {
this.ws = new WebSocket(this.url);
this.ws.onerror = (e) => this.log("Error", e);
this.ws.onopen = () => {
this.log("Open");
+ this.log("Sending authentication packet");
+ this.ws.send(`0${this.formatAuthString(password)}`); // send auth packet
};
- this.ws.onclose = () => {
+ this.ws.onmessage = ({ data }) => {
+ if (data === "1") {
+ if (this.onHandshakeCompleted)
+ this.onHandshakeCompleted();
+
+ this.isReady = true;
+ this.log("Handshake complete");
+ }
+ };
+ this.ws.onclose = ({ code }) => {
+ if (code === 4001) {// code for bad auth
+ this.log("Closed due to bad auth - skipping reconnect");
+ return;
+ }
this.log("Closed - attempting to reconnect in 4000ms");
+ this.isReady = false;
setTimeout(() => this.connect(), 4000);
}
}
diff --git a/frontend/src/Keyboard.js b/frontend/src/Keyboard.js
index dec22b8..32435a9 100644
--- a/frontend/src/Keyboard.js
+++ b/frontend/src/Keyboard.js
@@ -4,14 +4,22 @@ class KeyboardController {
constructor(connection) {
this.connection = connection;
this.keyboard = null;
+ this.keyboardDiv = null;
}
_sendKeyPress(l) {
this.connection.sendMessage("k", [l]);
}
- bindTo(element) {
- this.keyboard = new SimpleKeyboard(element, {
+ mountOn(element) {
+ if (this.keyboardDiv)
+ return; // Already mounted;
+
+ this.keyboardDiv = document.createElement("div");
+ this.keyboardDiv.classList.add("keyboard");
+ element.appendChild(this.keyboardDiv);
+
+ this.keyboard = new SimpleKeyboard(this.keyboardDiv, {
onKeyPress: this.onKeyPress.bind(this),
mergeDisplay: true,
layoutName: "default",
@@ -50,6 +58,14 @@ class KeyboardController {
});
}
+ unmount() {
+ if (!this.keyboardDiv)
+ return; // Not mounted - don't do anything
+
+ this.keyboardDiv.parentElement.removeChild(this.keyboardDiv);
+ this.keyboardDiv = null;
+ }
+
onKeyPress(button) {
if (button === "{shift}" || button === "{lock}")
return this.handleShift();
diff --git a/frontend/src/LocalConfiguration.js b/frontend/src/LocalConfiguration.js
new file mode 100644
index 0000000..5911700
--- /dev/null
+++ b/frontend/src/LocalConfiguration.js
@@ -0,0 +1,7 @@
+export function getAuth() {
+ return localStorage.getItem("$auth");
+}
+
+export function setAuth(newValue) {
+ return localStorage.setItem("$auth", newValue);
+}
diff --git a/frontend/src/LoginPrompt.js b/frontend/src/LoginPrompt.js
new file mode 100644
index 0000000..72ab2c9
--- /dev/null
+++ b/frontend/src/LoginPrompt.js
@@ -0,0 +1,40 @@
+class LoginPrompt {
+ constructor() {
+ this.element = null;
+ }
+
+ mountOn(target) {
+ if (this.element)
+ return; // Already mounted
+
+ this.element = document.createRange().createContextualFragment(`
+
+
Login
+
You need to enter the login code before you can start controlling your device.
+
+
+
+
+
+
+
+ `).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;
diff --git a/frontend/src/Touchpad.js b/frontend/src/Touchpad.js
index d56fcfd..c7229b3 100644
--- a/frontend/src/Touchpad.js
+++ b/frontend/src/Touchpad.js
@@ -12,6 +12,7 @@ class TouchpadController {
this.isInHoldingMode = false;
this.ongoingTouches = {};
this.holdModeTimeout = null;
+ this.touchpadDiv = null;
this.connection = connection;
}
@@ -59,10 +60,25 @@ class TouchpadController {
this.connection.sendMessage("c", [this.getButtonCode(button)]);
}
- bindTo(element) {
- element.addEventListener("touchmove", this.onTouchMove.bind(this));
- element.addEventListener("touchend", this.onTouchEnd.bind(this));
- element.addEventListener("touchstart", this.onTouchStart.bind(this));
+ mountOn(element) {
+ if (this.touchpadDiv)
+ return; // Already mounted
+
+ this.touchpadDiv = document.createElement("div");
+ this.touchpadDiv.classList.add("touchpad");
+ element.appendChild(this.touchpadDiv);
+
+ this.touchpadDiv.addEventListener("touchmove", this.onTouchMove.bind(this));
+ this.touchpadDiv.addEventListener("touchend", this.onTouchEnd.bind(this));
+ this.touchpadDiv.addEventListener("touchstart", this.onTouchStart.bind(this));
+ }
+
+ unmount() {
+ if (!this.touchpadDiv)
+ return; // Not mounted - don't do anything
+
+ this.touchpadDiv.parentElement.removeChild(this.touchpadDiv);
+ this.touchpadDiv = null;
}
onTouchMove(event) {
diff --git a/frontend/src/index.js b/frontend/src/index.js
index bc13b11..7ad9d61 100644
--- a/frontend/src/index.js
+++ b/frontend/src/index.js
@@ -1,28 +1,5 @@
-import { domLog } from "./common/Logger";
-import Connection from "./Connection";
-import KeyboardController from "./Keyboard";
-import TouchpadController from "./Touchpad";
-
import "simple-keyboard/build/css/index.css";
import "./styles/main.css";
+import App from "./App";
-window.onerror = (error) => {
- domLog(`|| ERROR: ${error} ||`);
-};
-
-const touchpadDiv = document.createElement("div");
-touchpadDiv.classList.add("touchpad");
-document.body.appendChild(touchpadDiv);
-
-const keyboardDiv = document.createElement("div");
-keyboardDiv.classList.add("keyboard");
-document.body.appendChild(keyboardDiv);
-
-const connection = new Connection(`ws://${location.host}/gateway`);
-const controller = new TouchpadController(connection);
-const keyboardController = new KeyboardController(connection);
-
-connection.connect();
-
-controller.bindTo(touchpadDiv);
-keyboardController.bindTo(keyboardDiv);
+new App(document.body);
diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css
index d41bdf4..09a36ef 100644
--- a/frontend/src/styles/main.css
+++ b/frontend/src/styles/main.css
@@ -23,6 +23,87 @@ body {
padding: 8px;
}
+.card {
+ margin: 6px;
+ padding: 8px;
+ background: var(--accent-bg-color);
+ color: #000000;
+ border-radius: var(--card-border-radius);
+}
+
+.card.inner-card {
+ margin: 4px;
+ margin-bottom: 28px;
+ padding: 18px;
+ border: solid var(--accent-color) 1px;
+}
+
+.card.layout-card {
+ margin: 36px auto;
+ padding: 26px;
+ width: 80%;
+ box-shadow: 0 0 28px 3px rgba(0, 0, 0, 0.40);
+}
+
+.card.small-card {
+ margin: 36px auto;
+ padding: 26px;
+ width: 350px;
+ box-shadow: 0 0 28px 3px rgba(0, 0, 0, 0.40);
+}
+
+@media screen and (max-width: 768px) {
+ .card.small-card {
+ width: 80%;
+ }
+}
+
+.button-default {
+ display: block;
+ box-sizing: border-box;
+ padding: 12px;
+ margin: 4px;
+ text-decoration: none;
+ border: none;
+ background-color: var(--accent-bg-color);
+ border-radius: var(--button-border-radius);
+ font-size: 18px;
+ white-space: nowrap;
+ color: var(--button-accent-color);
+ cursor: pointer;
+ outline: none;
+ min-width: 50px;
+ text-align: center;
+}
+
+.full-width {
+ width: 100%;
+}
+
+.center-text {
+ 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 {
+ display: block;
+ box-sizing: border-box;
+ border: none;
+ outline: none;
+ border-radius: var(--button-border-radius);
+ margin: 4px;
+ padding: 12px;
+}
+
.touchpad {
height: clamp(5rem, 30rem, 55vh);
width: 100%;
diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js
index 14743d3..307437e 100644
--- a/frontend/webpack.config.js
+++ b/frontend/webpack.config.js
@@ -14,6 +14,7 @@ module.exports = {
plugins: [
new HtmlWebpackPlugin({
title: "Capybara",
+ filename: "app.html"
}),
new MiniCssExtractPlugin({
filename: "[name].[contenthash].css"