Update pages 🚀

This commit is contained in:
Dorian Zedler 2023-02-18 11:53:06 +00:00
commit 35ce6de561
20 changed files with 822 additions and 0 deletions

1
.domains Normal file
View File

@ -0,0 +1 @@
ok-ready-go.speedclimbing.org

79
index.css Normal file
View File

@ -0,0 +1,79 @@
@keyframes blinker {
50% {
opacity: 0.3;
}
}
.loading {
font-size: 2em;
font-weight: bold;
text-align: center;
line-height: 1.2;
}
.timer {
font-size: 8em;
font-weight: bold;
text-align: center;
line-height: 1.2;
}
.timer.over {
color: #0f0;
animation: blinker 2s ease infinite;
}
.timer.sending {
color: #ff0;
animation: blinker 2s ease infinite;
}
html {
height: 100%;
}
body {
height: 100%;
}
main {
padding-top: 0 !important;
height: 100%;
}
.timer-container-div {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.timer-div {
cursor: pointer;
}
.tap-area {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
cursor: pointer;
}
.hide-before-init {
display: none;
}
.remote-connection-card {
position: absolute;
bottom: 10px;
left: 0;
background-color: var(--background-color);
width: 100%;
}
details {
border-bottom: 0;
padding-bottom: 0;
}

104
index.html Normal file
View File

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
<script src="lib/crypto_helper.js"></script>
<script src="js/index.js"></script>
<script src="js/localState.js"></script>
<script src="js/mqtt.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css" />
<link rel="stylesheet" href="index.css" />
<audio id="sound-0" src="sound/0.mp3" preload="auto"></audio>
<audio id="sound-1" src="sound/1.mp3" preload="auto"></audio>
<audio id="sound-2" src="sound/2.mp3" preload="auto"></audio>
<audio id="sound-3" src="sound/3.mp3" preload="auto"></audio>
<audio id="sound-4" src="sound/4.mp3" preload="auto"></audio>
<audio id="sound-5" src="sound/5.mp3" preload="auto"></audio>
<audio id="sound-6" src="sound/6.mp3" preload="auto"></audio>
<audio id="sound-7" src="sound/7.mp3" preload="auto"></audio>
<audio id="sound-8" src="sound/8.mp3" preload="auto"></audio>
<audio id="sound-9" src="sound/9.mp3" preload="auto"></audio>
<audio id="sound-ok-ready-go" src="sound/ok-ready-go.mp3" preload="auto"></audio>
<audio id="sound-silence" src="sound/silence.mp3" preload="auto"></audio>
</head>
<body>
<div class="timer-container-div hide-after-init">
<h1 class="loading" aria-busy="true">Initializing...</h1>
</div>
<main class="container hide-before-init" x-data>
<div class="timer-container-div" x-show="$store.localState._state !== 5">
<div @click="$store.localState.next()" class="tap-area" x-show="$store.localState._state === 2"></div>
<div @click="$store.localState.next()" class="timer-div">
<hgroup>
<h1>OK! .. READY! ... GO!</h1>
<h2><span x-text="$store.localState.stateHint"></span> <b x-show="$store.mqtt.connected">(connected)</b></h2>
</hgroup>
<div x-data="Timer">
<p :class="'timer' + (over ? ($store.localState._state === 3 ? ' sending':' over'):'')"
x-text="time + 's'"></p>
</div>
</div>
</div>
<div x-show="$store.localState._state === 0" class="remote-connection-card">
<div class="container">
<details>
<div class="container">
<p>If you want to automatically transfer the time to a computer, follow these steps:</p>
<div x-show="!$store.mqtt.connected">
<ul>
<li>Enter a password and press tap connect</li>
<li>
<form x-data="PasswordForm()" @submit.prevent="submitForm">
<label for="passwordForm_password">Password:</label>
<input id="passwordForm_password" type="text" x-model="formData.password"
placeholder="Password" />
<small>Make sure, this is exactly the same on your computer!</small>
<button type="submit">Connect</button>
</form>
</li>
<li>Download the receiver tool from <a
href="https://itsblue.dev/dorian/ok-ready-go/releases/latest">here</a> to your computer
</li>
<li>Start the receiver tool on your computer</li>
<li>Enter the same password on the receiver tool and click connect</li>
<li>Done!</li>
</ul>
</div>
<div x-show="$store.mqtt.connected">
<ul>
<li>Download the receiver tool from <a
href="https://itsblue.dev/dorian/ok-ready-go/releases/latest">here</a> to your computer
</li>
<li>Start the receiver tool on your computer</li>
<li>Enter the password '<b x-text="$store.localState.password"></b>' on the receiver tool and click connect</li>
<li>Done!</li>
<li>
<p>If you no longer want to send the time, tap Disconnect</p>
<button @click="$store.localState.password = ''">Disconnect</button>
</li>
</ul>
</div>
</div>
<summary role="button">Remote connection</summary>
</details>
</div>
</div>
<div class="timer-container-div" x-show="$store.localState._state === 5">
<h1 class="loading" aria-busy="true">Connecting...</h1>
</div>
</main>
</body>

72
js/index.js Normal file
View File

@ -0,0 +1,72 @@
async function sayNumber(number) {
console.log(number);
number = number.toString();
number = number.replace(/[^0-9]/, "");
for (let i = 0; i < number.length; i++) {
await playAudio(document.getElementById(`sound-${number[i]}`));
}
}
function playAudio(audio) {
return new Promise((res) => {
audio.play();
audio.onended = res;
});
}
function uuidv4() {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16)
);
}
function PasswordForm() {
return {
formData: {
password: "",
},
submitForm() {
Alpine.store("localState").password = this.formData.password;
},
};
}
function Timer() {
return {
time: 0,
over: false,
init() {
setInterval(() => {
const startedAt = Alpine.store("localState").startedAt;
const resultTime = Alpine.store("localState").time;
let time;
if (!startedAt && !resultTime) {
time = 0;
this.over = false;
} else if (resultTime) {
time = resultTime / 1000;
this.over = true;
} else {
this.over = false;
time = (new Date().getTime() - startedAt) / 1000;
}
this.time = time.toFixed(2);
}, 10);
},
};
}
const event = new Event('wasm-loaded');
wasm_bindgen('lib/crypto_helper_bg.wasm').then(() => {
document.dispatchEvent(event);
});
document.addEventListener("alpine:init", () => {
document.getElementsByClassName("hide-after-init").item(0).outerHTML = ""
document.getElementsByClassName("hide-before-init").item(0).classList.remove("hide-before-init")
});

105
js/localState.js Normal file
View File

@ -0,0 +1,105 @@
document.addEventListener("alpine:init", () => {
Alpine.store("localState", {
_state: 0,
// 0: idle
// 1: starting
// 2: running
// 3: sending time to mqtt
// 4: stopped
// 5: connecting to mqtt
startedAt: null,
time: null,
stateHint: "",
password: null,
init() {
Alpine.effect(() => {
if (this.password == null) {
this.password = localStorage.getItem("password");
} else {
localStorage.setItem("password", this.password);
}
const mqtt = Alpine.store("mqtt");
if (!mqtt) return;
if (this.password == null || this.password == "") {
mqtt.disconnect();
} else {
mqtt.connect();
}
});
Alpine.effect(() => {
switch (this._state) {
case 0:
this.stateHint = "Tap here to start";
break;
case 1:
this.stateHint = "Get ready...";
break;
case 2:
this.stateHint = "Tap anywhere to stop";
break;
case 3:
this.stateHint = "Sending time to MQTT...";
break;
case 4:
this.stateHint = "Tap here to reset";
break;
}
});
},
next() {
playAudio(document.getElementById("sound-silence"));
if (this._state == 1 || this._state == 3) return;
this._setState((this._state + 1) % 5);
},
_setState(state) {
switch (state) {
case 0: {
this.startedAt = null;
this.time = null;
break;
}
case 1: {
playAudio(document.getElementById("sound-ok-ready-go")).then(() => {
Alpine.store("localState")._setState(2);
});
break;
}
case 2: {
this.startedAt = new Date().getTime() - 200;
break;
}
case 3: {
this.time =
Math.floor(((new Date().getTime() - this.startedAt) / 1000).toFixed(2) * 1000);
this.startedAt = null;
sayNumber(this.time / 10);
if (Alpine.store("mqtt").connected) {
Alpine.store("mqtt")
.sendTime(this.time)
.then(() => {
Alpine.store("localState")._setState(4);
});
break;
}
state = 4;
break;
}
default:
break;
}
this._state = state;
},
});
});

132
js/mqtt.js Normal file
View File

@ -0,0 +1,132 @@
let wasm_inited_resolve;
const wasm_inited = new Promise((resolve) => {
wasm_inited_resolve = resolve;
});
document.addEventListener("wasm-loaded", () => {
wasm_inited_resolve(wasm_bindgen);
});
document.addEventListener("alpine:init", () => {
Alpine.store("mqtt", {
connected: false,
_client: null,
_topics: null,
_c: null,
_pendingPromises: {},
sendTime(time) {
if (!this.connected) return null;
const id = uuidv4();
const promise = new Promise((resolve, reject) => {
this._pendingPromises[id] = [resolve, reject];
});
this._publish({
id: id, // used to prevent replay attacks and to identify confirm messages
kind: "Time", // can be "time" or "confirm"
time: time, // only used for "time"
});
return promise;
},
async connect() {
if (this.connected) return;
const password = Alpine.store("localState").password;
const that = this;
const brokerDomain = "broker.emqx.io";
const url = `wss://${brokerDomain}:8084/mqtt`;
if (!password) return false;
const { Crypto } = await wasm_inited;
Alpine.store("localState")._state = 5;
// derive key from password
this._c = new Crypto(password, brokerDomain);
console.log("Test", this._encrypt("test"));
this._topics = {
time: Crypto.sha256(
this._encrypt(`org.speedclimbing.ok-ready-go.${password}.time`)
).toString(),
confirmation: Crypto.sha256(
this._encrypt(
`org.speedclimbing.ok-ready-go.${password}.confirmation`
)
).toString(),
};
console.log("Connecting to MQTT broker...");
const options = {
// Clean session
clean: true,
connectTimeout: 4000,
};
this._client = mqtt.connect(url, options);
this._client.on("connect", () => {
Alpine.store("localState")._state = 0;
that._client.subscribe(that._topics.confirmation);
this.connected = true;
});
this._client.on("message", (topic, message) => {
// message is Buffer
message = that._decrypt(message.toString());
const data = JSON.parse(message);
if (
topic !== that._topics.confirmation ||
data.kind !== "Confirm" ||
Object.keys(this._pendingPromises).indexOf(data.id) === -1
)
return;
console.log("<<< ", data);
this._pendingPromises[data.id][0]();
});
},
disconnect() {
if (!this.connected) return;
this._client.end(true);
this._client = null;
this.connected = false;
this._topics = null;
for (const promiseId in this._pendingPromises) {
this._pendingPromises[promiseId][1]();
}
this._pendingPromises = {};
},
_publish(data) {
const encryptedData = this._encrypt(JSON.stringify(data));
console.log(">>> ", data);
this._client.publish(this._topics.time, encryptedData, {
qos: 1,
retain: false,
});
},
_encrypt(data) {
return this._c.encrypt(data);
},
_decrypt(data) {
return this._c.decrypt(data);
},
});
});

329
lib/crypto_helper.js Normal file
View File

@ -0,0 +1,329 @@
let wasm_bindgen;
(function() {
const __exports = {};
let script_src;
if (typeof document === 'undefined') {
script_src = location.href;
} else {
script_src = new URL(document.currentScript.src, location.href).toString();
}
let wasm;
const heap = new Array(128).fill(undefined);
heap.push(undefined, null, true, false);
function getObject(idx) { return heap[idx]; }
let heap_next = heap.length;
function dropObject(idx) {
if (idx < 132) return;
heap[idx] = heap_next;
heap_next = idx;
}
function takeObject(idx) {
const ret = getObject(idx);
dropObject(idx);
return ret;
}
const cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
let cachedUint8Memory0 = null;
function getUint8Memory0() {
if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) {
cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8Memory0;
}
function getStringFromWasm0(ptr, len) {
return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len));
}
let WASM_VECTOR_LEN = 0;
const cachedTextEncoder = new TextEncoder('utf-8');
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
? function (arg, view) {
return cachedTextEncoder.encodeInto(arg, view);
}
: function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
});
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length);
getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len);
const mem = getUint8Memory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3);
const view = getUint8Memory0().subarray(ptr + offset, ptr + len);
const ret = encodeString(arg, view);
offset += ret.written;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedInt32Memory0 = null;
function getInt32Memory0() {
if (cachedInt32Memory0 === null || cachedInt32Memory0.byteLength === 0) {
cachedInt32Memory0 = new Int32Array(wasm.memory.buffer);
}
return cachedInt32Memory0;
}
/**
*/
__exports.greet = function() {
wasm.greet();
};
function addHeapObject(obj) {
if (heap_next === heap.length) heap.push(heap.length + 1);
const idx = heap_next;
heap_next = heap[idx];
heap[idx] = obj;
return idx;
}
/**
*/
class Crypto {
static __wrap(ptr) {
const obj = Object.create(Crypto.prototype);
obj.ptr = ptr;
return obj;
}
__destroy_into_raw() {
const ptr = this.ptr;
this.ptr = 0;
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_crypto_free(ptr);
}
/**
* @param {string} password
* @param {string} salt
*/
constructor(password, salt) {
const ptr0 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(salt, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.crypto_new(ptr0, len0, ptr1, len1);
return Crypto.__wrap(ret);
}
/**
* @param {string} input
* @returns {string}
*/
static sha256(input) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.crypto_sha256(retptr, ptr0, len0);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_free(r0, r1);
}
}
/**
* @param {string} plaintext
* @returns {string}
*/
encrypt(plaintext) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(plaintext, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.crypto_encrypt(retptr, this.ptr, ptr0, len0);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_free(r0, r1);
}
}
/**
* @param {string} ciphertext
* @returns {string}
*/
decrypt(ciphertext) {
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(ciphertext, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.crypto_decrypt(retptr, this.ptr, ptr0, len0);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_free(r0, r1);
}
}
}
__exports.Crypto = Crypto;
async function load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
if (module.headers.get('Content-Type') != 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else {
throw e;
}
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
}
function getImports() {
const imports = {};
imports.wbg = {};
imports.wbg.__wbg_alert_0cc0cb8b17d72dde = function(arg0, arg1) {
alert(getStringFromWasm0(arg0, arg1));
};
imports.wbg.__wbg_new_abda76e883ba8a5f = function() {
const ret = new Error();
return addHeapObject(ret);
};
imports.wbg.__wbg_stack_658279fe44541cf6 = function(arg0, arg1) {
const ret = getObject(arg1).stack;
const ptr0 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
getInt32Memory0()[arg0 / 4 + 1] = len0;
getInt32Memory0()[arg0 / 4 + 0] = ptr0;
};
imports.wbg.__wbg_error_f851667af71bcfc6 = function(arg0, arg1) {
try {
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(arg0, arg1);
}
};
imports.wbg.__wbindgen_object_drop_ref = function(arg0) {
takeObject(arg0);
};
imports.wbg.__wbindgen_throw = function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};
return imports;
}
function initMemory(imports, maybe_memory) {
}
function finalizeInit(instance, module) {
wasm = instance.exports;
init.__wbindgen_wasm_module = module;
cachedInt32Memory0 = null;
cachedUint8Memory0 = null;
return wasm;
}
function initSync(module) {
const imports = getImports();
initMemory(imports);
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return finalizeInit(instance, module);
}
async function init(input) {
if (typeof input === 'undefined') {
input = script_src.replace(/\.js$/, '_bg.wasm');
}
const imports = getImports();
if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) {
input = fetch(input);
}
initMemory(imports);
const { instance, module } = await load(await input, imports);
return finalizeInit(instance, module);
}
wasm_bindgen = Object.assign(init, { initSync }, __exports);
})();

BIN
lib/crypto_helper_bg.wasm Normal file

Binary file not shown.

BIN
sound/0.mp3 Normal file

Binary file not shown.

BIN
sound/1.mp3 Normal file

Binary file not shown.

BIN
sound/2.mp3 Normal file

Binary file not shown.

BIN
sound/3.mp3 Normal file

Binary file not shown.

BIN
sound/4.mp3 Normal file

Binary file not shown.

BIN
sound/5.mp3 Normal file

Binary file not shown.

BIN
sound/6.mp3 Normal file

Binary file not shown.

BIN
sound/7.mp3 Normal file

Binary file not shown.

BIN
sound/8.mp3 Normal file

Binary file not shown.

BIN
sound/9.mp3 Normal file

Binary file not shown.

BIN
sound/ok-ready-go.mp3 Normal file

Binary file not shown.

BIN
sound/silence.mp3 Normal file

Binary file not shown.