Chess
+
+1. Arrange the chess board in any way you want.
+2. Upload a file (or do it before this) to be encrypted/decrypted with the board.
+
+---
+
+###
Location
+
+1. Zoom in very far and click anywhere on the map.
+2. Upload a file (or do it before this) to be encrypted/decrypted with the location.
+
+---
+
+###
Safe
+
+1. Spin the dial to make a unique sequence.
+2. Upload a file (or do it before this) to be encrypted/decrypted with the sequence.
+
+---
+
+###
Pattern
+
+1. Connect dots in any patterns to form a unique connection sequence
+2. To change the pattern, simply redo step 1 to make a new pattern. The old pattern will disappear automatically.
+3. Upload a file (or do it before this) to be encrypted/decrypted with the pattern.
+
+A 3x3 grid is the default, but you may choose to use a 4x4 or 6x6 grid.
+
+As the key is simply the sequence in of the connected dots based on their position, a 3x3 encrypted file can be decrypted from a 4x4 pattern.
+
+---
+
+###
Music
+
+1. Place down notes on the grid.
+2. Upload a file (or do it before this) to be encrypted/decrypted with the song.
+
+---
+
+###
Colour Picker
+
+1. Click and drag on scale of each red, green and blue to generate the desired colour as your encryption key. The colour and its hex representation will be shown at the bottom of the scale.
+2. Upload a file (or do it before this) to be encrypted/decrypted with the pattern.
+
+Simply telling the other person about the colour of key will give them a hard time getting the exact code for decryption.
+
+## Running locally
+
+1. [Install uv](https://docs.astral.sh/uv/getting-started/installation/)
+2. Clone our repository: `git clone https://github.com/xerif-wenghoe/code-jam-12`
+3. Change into the directory: `cd code-jam-12`
+4. Sync dependencies: `uv sync`
+5. Run the server: `uv run server.py`
+6. Access the tool at http://localhost:8000
+
+## Technical details
+
+- We use [Pyodide](https://pyodide.org/) for logic and DOM manipulation.
+- All methods eventually generate a 256-bit key for AES.
+- [We implemented AES ourselves](cj12/aes.py) using numpy, all in the browser!
+- Encrypted data is contained inside our [custom container format](cj12/container.py) that stores:
+ - Magic bytes (`SDET`)
+ - Method used
+ - Original filename
+ - Hash of decrypted data
+ - Encrypted data
+
+### [LINK](https://www.youtube.com/watch?v=MmZzPMagkXM) to presentation video
+
+## Contributions
+
+- [Xerif](https://github.com/xerif-wenghoe) ⭐:
+ - Pattern method
+ - Colour picker method
+ - Documentation
+- [interrrp](https://github.com/interrrp):
+ - Method framework
+ - Container format
+ - Location method
+ - Documentation
+- [MaxMinMedian](https://github.com/max-min-median):
+ - AES implementation
+ - Chess method
+ - Safe method
+ - Code documentation
+- [Atonement](https://github.com/cin-lawrence):
+ - Initial method framework
+ - Direction lock method
+- [greengas](https://github.com/greengas):
+ - Initial UI
+ - Music method
+- [Candy](https://discord.com/users/1329407643365802025):
+ - Location method idea
+
+## License
+
+This project is licensed under the [MIT license](LICENSE).
diff --git a/tubular-tulips/cj12/__init__.py b/tubular-tulips/cj12/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tubular-tulips/cj12/aes.py b/tubular-tulips/cj12/aes.py
new file mode 100644
index 00000000..79a21e7e
--- /dev/null
+++ b/tubular-tulips/cj12/aes.py
@@ -0,0 +1,263 @@
+import numpy as np
+
+__all__ = ["decrypt", "encrypt"]
+
+
+def encrypt(data: bytes, key: bytes) -> bytes:
+ return AES(key).encrypt(data)
+
+
+def decrypt(data: bytes, key: bytes) -> bytes:
+ return AES(key).decrypt(data)
+
+
+class AES:
+ """
+ Perform AES-128, -192 or -256 encryption and decryption.
+
+ Usage:
+ ```
+ aes = AES(key: bytes) # sets up an AES encryptor/decryptor object using key
+ ```
+ """
+
+ # Set up S-box.
+ # The S-box is a crucial step in the AES algorithm. Its purpose to act as a lookup
+ # table to replace bytes with other bytes. This introduces confusion.
+ # fmt: off
+ sbox = np.array([
+ 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5,
+ 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
+ 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0,
+ 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
+ 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc,
+ 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
+ 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a,
+ 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
+ 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0,
+ 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
+ 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b,
+ 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
+ 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85,
+ 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
+ 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5,
+ 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
+ 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17,
+ 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
+ 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88,
+ 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
+ 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c,
+ 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
+ 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9,
+ 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
+ 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6,
+ 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
+ 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e,
+ 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
+ 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94,
+ 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
+ 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68,
+ 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16,
+ ], dtype=np.uint8)
+ # fmt: on
+
+ # Set up inverse S-box for decryption
+ sbox_inv = np.empty(shape=sbox.shape, dtype=np.uint8)
+ for i in range(len(sbox)):
+ sbox_inv[sbox[i]] = i
+
+ # During the creation of the round keys, the Rcon array is used to add a certain
+ # value to the key each round, to produce the key for the next round.
+ Rcon = np.array(
+ [
+ [0x01, 0x00, 0x00, 0x00],
+ [0x02, 0x00, 0x00, 0x00],
+ [0x04, 0x00, 0x00, 0x00],
+ [0x08, 0x00, 0x00, 0x00],
+ [0x10, 0x00, 0x00, 0x00],
+ [0x20, 0x00, 0x00, 0x00],
+ [0x40, 0x00, 0x00, 0x00],
+ [0x80, 0x00, 0x00, 0x00],
+ [0x1B, 0x00, 0x00, 0x00],
+ [0x36, 0x00, 0x00, 0x00],
+ ],
+ dtype=np.uint8,
+ )
+
+ shift_idx = np.array(
+ [
+ [0, 1, 2, 3], # first row unshifted
+ [1, 2, 3, 0], # second row rolled left by 1
+ [2, 3, 0, 1], # third row rolled left by 2
+ [3, 0, 1, 2], # last row rolled left by 3
+ ],
+ )
+
+ unshift_idx = np.array(
+ [[0, 1, 2, 3], [3, 0, 1, 2], [2, 3, 0, 1], [1, 2, 3, 0]],
+ )
+
+ @staticmethod
+ def sub_bytes(arr: np.ndarray, sbox: np.ndarray) -> np.ndarray:
+ return sbox[arr]
+
+ # performs multiplication by 2 under GF(2^8).
+ @staticmethod
+ def mul2(x: int) -> int:
+ result = (x << 1) & 0xFF
+ if x & 0x80:
+ result ^= 0x1B
+ return result
+
+ # These 2 methods act on each separate column of the data array. Values are 'mixed'
+ # by multplying the matrix:
+ #
+ # [[2, 3, 1, 1],
+ # [1, 2, 3, 1], # noqa: ERA001
+ # [1, 1, 2, 3], # noqa: ERA001
+ # [3, 1, 1, 2]] (under GF(2^8))
+ @staticmethod
+ def mix_column(col: np.ndarray) -> np.ndarray:
+ x2 = AES.mul2
+ c0 = x2(col[0]) ^ (x2(col[1]) ^ col[1]) ^ col[2] ^ col[3]
+ c1 = col[0] ^ x2(col[1]) ^ (x2(col[2]) ^ col[2]) ^ col[3]
+ c2 = col[0] ^ col[1] ^ x2(col[2]) ^ x2(col[3]) ^ col[3]
+ c3 = x2(col[0]) ^ col[0] ^ col[1] ^ col[2] ^ x2(col[3])
+ return np.array([c0, c1, c2, c3], dtype=np.uint8)
+
+ @staticmethod
+ def mix_columns(grid: np.ndarray) -> None:
+ for col in range(4):
+ grid[:, col] = AES.mix_column(grid[:, col])
+
+ @staticmethod
+ def unmix_column(col: np.ndarray) -> np.ndarray:
+ x2 = AES.mul2
+ c02 = x2(col[0])
+ c04 = x2(c02)
+ c08 = x2(c04)
+ c12 = x2(col[1])
+ c14 = x2(c12)
+ c18 = x2(c14)
+ c22 = x2(col[2])
+ c24 = x2(c22)
+ c28 = x2(c24)
+ c32 = x2(col[3])
+ c34 = x2(c32)
+ c38 = x2(c34)
+ c0 = (
+ (c08 ^ c04 ^ c02)
+ ^ (c18 ^ c12 ^ col[1])
+ ^ (c28 ^ c24 ^ col[2])
+ ^ (c38 ^ col[3])
+ ) # [14, 11, 13, 9]
+ c1 = (
+ (c08 ^ col[0])
+ ^ (c18 ^ c14 ^ c12)
+ ^ (c28 ^ c22 ^ col[2])
+ ^ (c38 ^ c34 ^ col[3])
+ ) # [9, 14, 11, 13]
+ c2 = (
+ (c08 ^ c04 ^ col[0])
+ ^ (c18 ^ col[1])
+ ^ (c28 ^ c24 ^ c22)
+ ^ (c38 ^ c32 ^ col[3])
+ ) # [13, 9, 14, 11]
+ c3 = (
+ (c08 ^ c02 ^ col[0])
+ ^ (c18 ^ c14 ^ col[1])
+ ^ (c28 ^ col[2])
+ ^ (c38 ^ c34 ^ c32)
+ ) # [11, 13, 9, 14]
+ return np.array([c0, c1, c2, c3], dtype=np.uint8)
+
+ @staticmethod
+ def unmix_columns(grid: np.ndarray) -> None:
+ for col in range(4):
+ grid[:, col] = AES.unmix_column(grid[:, col])
+
+ @staticmethod
+ def shift_rows(arr: np.ndarray, shifter: np.ndarray) -> None:
+ arr[:] = arr[:, np.arange(4).reshape(4, 1), shifter]
+
+ def __init__(self, key: bytes) -> None:
+ if len(key) not in {16, 24, 32}:
+ msg = "Incorrect number of bits (should be 128, 192, or 256-bit)"
+ raise ValueError(msg)
+ self.key = np.frombuffer(key, dtype=np.uint8)
+ self.Nk = len(self.key) // 4 # No. of 32-bit words in `key`
+ self.Nr = self.Nk + 6 # No. of encryption rounds
+ self.Nb = 4 # No. of words in AES state
+ self.round_keys = self._key_expansion()
+
+ # The actual AES key is expanded into either 11, 13 or 15 round keys.
+ def _key_expansion(self) -> np.ndarray:
+ words = np.empty((self.Nb * (self.Nr + 1) * 4,), dtype=np.uint8)
+ words[: len(self.key)] = self.key
+ words = words.reshape(-1, 4)
+ rcon_iter = iter(AES.Rcon)
+ for i in range(self.Nk, len(words)):
+ if i % self.Nk == 0:
+ words[i] = (
+ AES.sub_bytes(np.roll(words[i - 1], -1), AES.sbox)
+ ^ next(rcon_iter)
+ ^ words[i - 4]
+ )
+ elif self.Nk == 8 and i % self.Nk == 4: # noqa: PLR2004
+ words[i] = AES.sub_bytes(words[i - 1], AES.sbox) ^ words[i - 4]
+ else:
+ words[i] = words[i - 1] ^ words[i - 4]
+ return words.reshape(-1, 4, 4).transpose(0, 2, 1)
+
+ def encrypt(self, data: bytes) -> bytes:
+ pad_length = 16 - len(data) % 16
+ padded = (
+ np.concat(
+ (np.frombuffer(data, dtype=np.uint8), np.full(pad_length, pad_length)),
+ )
+ .reshape(-1, 4, 4)
+ .transpose(0, 2, 1)
+ )
+
+ keys_iter = iter(self.round_keys)
+
+ # Pre-round: add round key
+ padded ^= next(keys_iter)
+
+ for round_num in range(self.Nr):
+ padded = AES.sub_bytes(padded, AES.sbox)
+
+ AES.shift_rows(padded, AES.shift_idx)
+
+ if round_num != self.Nr - 1:
+ for grid in padded:
+ AES.mix_columns(grid)
+
+ padded ^= next(keys_iter)
+
+ return padded.transpose(0, 2, 1).tobytes()
+
+ def decrypt(self, data: bytes) -> bytes:
+ encrypted = (
+ np.frombuffer(data, dtype=np.uint8)
+ .reshape(-1, 4, 4)
+ .transpose(0, 2, 1)
+ .copy()
+ )
+
+ keys_iter = reversed(self.round_keys)
+
+ # Pre-round: add round key
+ encrypted ^= next(keys_iter)
+
+ for round_num in range(self.Nr):
+ if round_num != 0:
+ for grid in encrypted:
+ AES.unmix_columns(grid)
+
+ AES.shift_rows(encrypted, AES.unshift_idx)
+ encrypted = AES.sub_bytes(encrypted, AES.sbox_inv)
+ encrypted ^= next(keys_iter)
+
+ encrypted = encrypted.transpose(0, 2, 1).tobytes()
+ return encrypted[: -encrypted[-1]]
diff --git a/tubular-tulips/cj12/app.py b/tubular-tulips/cj12/app.py
new file mode 100644
index 00000000..7e02c7f7
--- /dev/null
+++ b/tubular-tulips/cj12/app.py
@@ -0,0 +1,112 @@
+from contextlib import suppress
+from hashlib import sha256
+
+from js import URL, Blob, alert, document
+from pyodide.ffi import to_js
+
+from cj12.aes import decrypt, encrypt
+from cj12.container import Container, InvalidMagicError
+from cj12.dom import (
+ ButtonElement,
+ add_event_listener,
+ elem_by_id,
+ fetch_text,
+)
+from cj12.file import FileInput
+from cj12.methods.methods import Methods, methods
+
+
+class App:
+ async def start(self) -> None:
+ document.title = "Super Duper Encryption Tool"
+ document.body.innerHTML = await fetch_text("/ui.html")
+
+ self._data: bytes | None = None
+ self._key: bytes | None = None
+ self._container: Container | None = None
+
+ self._file_input = FileInput(self._on_data_received)
+ self._filename = ""
+
+ self._encrypt_button = elem_by_id("encrypt-button", ButtonElement)
+ self._decrypt_button = elem_by_id("decrypt-button", ButtonElement)
+ add_event_listener(self._encrypt_button, "click", self._on_encrypt_button)
+ add_event_listener(self._decrypt_button, "click", self._on_decrypt_button)
+
+ self._methods = Methods(self._on_key_received)
+ await self._methods.register_selections()
+
+ async def _on_data_received(self, data: bytes, filename: str) -> None:
+ self._data = data
+ self._filename = filename
+
+ with suppress(InvalidMagicError):
+ self._container = Container.from_bytes(data)
+
+ if self._container is not None:
+ for method in methods:
+ if method.byte == self._container.method:
+ await self._methods.go_to(method)
+
+ self._update_button_availability()
+
+ async def _on_key_received(self, key: bytes | None) -> None:
+ self._key = key
+ self._update_button_availability()
+
+ def _update_button_availability(self) -> None:
+ disabled = self._data is None or self._key is None
+ self._encrypt_button.disabled = disabled
+ self._decrypt_button.disabled = disabled
+
+ if self._container is None:
+ self._decrypt_button.disabled = True
+
+ async def _on_encrypt_button(self, _: object) -> None:
+ if self._methods.current is None:
+ return
+
+ data, key = self._ensure_data_and_key()
+
+ container = Container(
+ method=self._methods.current.byte,
+ original_filename=self._filename,
+ data_hash=sha256(data).digest(),
+ data=encrypt(data, sha256(key).digest()),
+ )
+
+ self._download_file(bytes(container), "encrypted_file.bin")
+
+ async def _on_decrypt_button(self, _: object) -> None:
+ if self._container is None:
+ return
+
+ _, key = self._ensure_data_and_key()
+
+ decrypted = decrypt(self._container.data, sha256(key).digest())
+ decrypted_hash = sha256(decrypted).digest()
+
+ if decrypted_hash != self._container.data_hash:
+ alert("Incorrect key")
+ return
+
+ self._download_file(decrypted, self._container.original_filename)
+
+ def _download_file(self, data: bytes, filename: str) -> None:
+ u8 = to_js(data, create_pyproxies=False)
+ blob = Blob.new([u8], {"type": "application/octet-stream"})
+ url = URL.createObjectURL(blob)
+ a = document.createElement("a")
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+
+ def _ensure_data_and_key(self) -> tuple[bytes, bytes]:
+ if self._data is None or self._key is None:
+ msg = "Data or key not set"
+ raise ValueError(msg)
+
+ return (self._data, self._key)
diff --git a/tubular-tulips/cj12/container.py b/tubular-tulips/cj12/container.py
new file mode 100644
index 00000000..ef3e466b
--- /dev/null
+++ b/tubular-tulips/cj12/container.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+import struct
+from dataclasses import dataclass
+
+
+class InvalidMagicError(Exception): ...
+
+
+MAGIC = b"SDET"
+
+
+@dataclass(frozen=True)
+class Container:
+ method: int
+ original_filename: str
+ data_hash: bytes
+ data: bytes
+
+ def __bytes__(self) -> bytes:
+ filename_bytes = self.original_filename.encode("utf-8")
+
+ return MAGIC + struct.pack(
+ f"
+ {method.description}
+ """ + + async def wrapper(_: object, method: Method = method) -> None: + await self.go_to(method) + + add_event_listener(btn, "click", wrapper) + selections_container.appendChild(btn) + + async def _on_back(self, _: object) -> None: + await self._on_key_received(None) + await self.register_selections() + + async def go_to(self, method: Method) -> None: + self.current = method + self._container.innerHTML = f""" + + {await self._get_cached_html(method)} + """ + method.on_key_received = self._on_key_received + add_event_listener(elem_by_id("back"), "click", self._on_back) + await method.setup() diff --git a/tubular-tulips/cj12/methods/music.py b/tubular-tulips/cj12/methods/music.py new file mode 100644 index 00000000..354803e7 --- /dev/null +++ b/tubular-tulips/cj12/methods/music.py @@ -0,0 +1,246 @@ +from collections.abc import Callable + +from js import Promise, clearTimeout, setTimeout, window +from pyodide.ffi import create_proxy + +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +# NOTES + +# IMPORTANT +# Refactor setTimeout +# new art + +# OTHER +# Refactor self.currentColumn and self.playing +# Refactor constants, note numbering system +# Refactor event loop to use dtime +# change camelCase to snake_case + +# LEARN +# look into BETTER ASYNC LOADING copied from chess +# look into intervalevents + +# DONE +# Implement key system using self.grid +# Change grid size based on screen width +# Refactor all canvas to account for dpi +# Refactor all canvas to account for subpixels + + +class MusicMethod: + byte = 0x07 + static_id = "music" + name = "Music" + description = "A song" + + on_key_received: KeyReceiveCallback | None = None + grid: list[list[str | None]] + + async def setup(self) -> None: + self.canvas = elem_by_id("instrument-canvas") + self.playButton = elem_by_id("play-button") + self.bpmSlider = elem_by_id("bpm-slider") + self.bpmDisplay = elem_by_id("bpm-display") + + self.ctx = self.canvas.getContext("2d") + self.ctx.imageSmoothingEnabled = False + + self.rows = 16 + self.columns = 32 + + # Create the grid data structure + # SELF.GRID IS WHAT THE KEY SHOULD BE + self.grid = [[-1] * self.rows for _ in range(self.columns)] + + self.currentColumn = None + self.playing = False + + self.bpm = 120 + self.interval = 60000 / self.bpm + + rect_canvas = self.canvas.getBoundingClientRect() + dpr = window.devicePixelRatio or 1 + self.canvas.width = rect_canvas.width * dpr + self.canvas.height = rect_canvas.height * dpr + self.ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + self.canvas.width = self.width = rect_canvas.width + self.canvas.height = self.height = rect_canvas.height + self.box_width = self.width / self.columns + self.box_height = self.height / self.rows + + self.timeout_calls = [] + + self._draw_grid() + + # Control buttons and handlers + add_event_listener(self.canvas, "click", self._update_on_click) + add_event_listener(self.playButton, "click", self._toggle_play) + add_event_listener(self.bpmSlider, "change", self._change_bpm) + add_event_listener(window, "resize", self._height_setup) + await self.load_notes() + + def resize_canvas(self, ratio: float) -> None: + self.canvas.width = window.screen.width * ratio + self.canvas.height = window.screen.height * ratio + + # LEARN BETTER LOADING copied from chess + async def load_notes(self) -> None: + def load_sound(src: str) -> object: + def executor( + resolve: Callable[[object], None], + _reject: Callable[[object], None], + ) -> None: + sound = window.Audio.new() + sound.onloadeddata = lambda *_, sound=sound: resolve(sound) + sound.src = src + + return Promise.new(executor) + + note_names = [f"Piano.{i}" for i in range(7, 23)] + + self.notes = {} + for note_name in note_names: + self.notes[note_name] = await load_sound( + f"/methods/music/audio/{note_name}.mp3", + ) + + self.tick_proxy = create_proxy( + self._tick, + ) # It only works with this for some reason instead of @create_proxy + + # Main event loop, calls self + # TODO: REFACTOR setTimeout + def _tick(self) -> None: + if not self.playing or not elem_by_id("instrument-canvas"): + return + self._play_notes(self.grid[self.currentColumn]) + self._draw_grid() + self.currentColumn = (self.currentColumn + 1) % self.columns + self.timeout_calls.append(setTimeout(self.tick_proxy, self.interval)) + + def _height_setup(self, _event: object) -> None: + rect_canvas = self.canvas.getBoundingClientRect() + dpr = window.devicePixelRatio or 1 + self.canvas.width = rect_canvas.width * dpr + self.canvas.height = rect_canvas.height * dpr + self.ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + self.canvas.width = self.width = rect_canvas.width + self.canvas.height = self.height = rect_canvas.height + self.box_width = self.width / self.columns + self.box_height = self.height / self.rows + self._draw_grid() + + def _play_note(self, note_name: str) -> None: + notefound = self.notes[f"Piano.{note_name}"] + notefound.currentTime = 0 + notefound.play() + + def _play_notes(self, grid_section: list) -> None: + for number, on in enumerate(grid_section): + if on == 1: + self._play_note(f"{self.rows - number + 7 - 1}") # make more clear + + async def _update_on_click(self, event: object) -> None: + rect_canvas = self.canvas.getBoundingClientRect() + click_x = event.clientX - rect_canvas.left + click_y = event.clientY - rect_canvas.top + + column_clicked = int(click_x / self.box_width) + row_clicked = int(click_y / self.box_height) + + self.grid[column_clicked][row_clicked] *= -1 + if self.grid[column_clicked][row_clicked] == 1: + self._play_note(f"{self.rows - row_clicked + 7 - 1}") + self._draw_grid() + key = self._flatten_list() + await self.on_key_received(key.encode()) + + def _flatten_list(self) -> str: + return "".join( + "1" if cell == 1 else "0" for column in self.grid for cell in column + ) + + # look into intervalevents + def _toggle_play(self, _event: object) -> None: + self.playing = not self.playing + for timeout_call in self.timeout_calls: + clearTimeout(timeout_call) + if self.playing: + self.playButton.innerText = "⏸" + self.currentColumn = 0 + self._tick() + else: + self.playButton.innerText = "▶" + + self._draw_grid() + + def _change_bpm(self, event: object) -> None: + self.bpm = int(event.target.value) + self.interval = 60000 / self.bpm + self.bpmDisplay.innerText = f"BPM: {self.bpm}" + + # Draw the music grid (should pr) + def _draw_grid(self) -> None: + color_dict = { + 0: "pink", + 1: "purple", + 2: "blue", + 3: "green", + 4: "yellow", + 5: "orange", + 6: "red", + } + + self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) + h = round(self.box_height) + w = round(self.box_width) + self.ctx.lineWidth = 1 + + # Draw the boxes + for col_idx, column in enumerate(self.grid): + for row_idx, row in enumerate(column): + if row == 1: + self.ctx.beginPath() + self.ctx.rect( + round(col_idx * self.box_width), + round(row_idx * self.box_height), + w, + h, + ) + self.ctx.fillStyle = color_dict[row_idx % 7] + self.ctx.fill() + elif col_idx == self.currentColumn and self.playing: + self.ctx.beginPath() + self.ctx.rect( + round(col_idx * self.box_width), + round(row_idx * self.box_height), + w, + h, + ) + self.ctx.fillStyle = "grey" + self.ctx.fill() + + # Draw the lines + for i in range(self.rows): + if i > 0: + self.ctx.beginPath() + self.ctx.strokeStyle = "grey" + self.ctx.moveTo(0, round(i * self.box_height)) + self.ctx.lineTo(self.canvas.width, round(i * self.box_height)) + self.ctx.stroke() + + for i in range(self.columns): + if i > 0: + self.ctx.beginPath() + if i % 2 == 0: + self.ctx.lineWidth = 1 + self.ctx.strokeStyle = "white" + else: + self.ctx.lineWidth = 1 + self.ctx.strokeStyle = "grey" + + self.ctx.moveTo(round(i * self.box_width), 0) + self.ctx.lineTo(round(i * self.box_width), self.canvas.height) + self.ctx.stroke() diff --git a/tubular-tulips/cj12/methods/password.py b/tubular-tulips/cj12/methods/password.py new file mode 100644 index 00000000..6e228b06 --- /dev/null +++ b/tubular-tulips/cj12/methods/password.py @@ -0,0 +1,30 @@ +from cj12.dom import InputElement, add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + + +class PasswordMethod: + byte = 0x01 + static_id = "password" + name = "Password" + description = "Plain password (deprecated)" + + on_key_received: KeyReceiveCallback | None = None + + async def setup(self) -> None: + self._input = [None] * 2 + self._warn_mismatch = elem_by_id("warn-mismatch") + for i in range(2): + self._input[i] = elem_by_id(f"password-input{i}", InputElement) + add_event_listener(self._input[i], "keydown", self._on_key_down) + + async def _on_key_down(self, _event: object) -> None: + if self.on_key_received is None: + return + if self._input[0].value != self._input[1].value: + self._warn_mismatch.style.color = "#FF0000" + self._warn_mismatch.innerText = "Passwords don't match!" + await self.on_key_received(None) + else: + self._warn_mismatch.style.color = "#00FF00" + self._warn_mismatch.innerText = "- OK -" + await self.on_key_received(self._input[0].value.encode()) diff --git a/tubular-tulips/cj12/methods/pattern_lock.py b/tubular-tulips/cj12/methods/pattern_lock.py new file mode 100644 index 00000000..acdb6841 --- /dev/null +++ b/tubular-tulips/cj12/methods/pattern_lock.py @@ -0,0 +1,175 @@ +from dataclasses import dataclass +from math import pi + +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +COLOUR_THEME = "#00ff00" + + +@dataclass +class Node: + x_coor: int + y_coor: int + connected: bool + + +class PatternLockMethod: + byte = 0x05 + static_id = "pattern_lock" + name = "Pattern Lock" + description = "A pattern traced lock" + + on_key_received: KeyReceiveCallback | None = None + + dot_radius: int = 15 + lock_grid_length: int = 300 + dimension: int = 3 # n by n dots + + async def setup(self) -> None: + self.init() + self.setup_event_listener() + + def init(self) -> None: + self.node_list: list[Node] = [] + self.last_node: Node | None = None + self.sequence: list[int] = [] + self.is_mouse_down: bool = False + self.connected_nodes: list[list[Node]] = [] + + self.canvas = elem_by_id("grid") + self.canvas.width = self.canvas.height = self.lock_grid_length + self.ctx = self.canvas.getContext("2d") + + self.generate_nodes() + self.draw_pattern() + + def generate_nodes(self) -> None: + """ + Generate all the dots + """ + node_length = self.lock_grid_length / self.dimension + for row in range(self.dimension): + for col in range(self.dimension): + self.node_list.append( + Node( + int(col * node_length + node_length / 2), + int(row * node_length + node_length / 2), + connected=False, + ), + ) + + def draw_pattern(self) -> None: + """ + Draw the dots and lines + """ + ctx = self.canvas.getContext("2d") + ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) + + for node in self.node_list: + ctx.beginPath() + ctx.arc(node.x_coor, node.y_coor, self.dot_radius, 0, 2 * pi) + ctx.lineWidth = 0 + if node.connected: + ctx.fillStyle = COLOUR_THEME + else: + ctx.fillStyle = "white" + + ctx.fill() + ctx.stroke() + + self.draw_line() + + def draw_line(self) -> None: + """ + Draw the lines for connected dots + """ + ctx = self.ctx + for node1, node2 in self.connected_nodes: + ctx.beginPath() + ctx.moveTo(node1.x_coor, node1.y_coor) + ctx.lineTo(node2.x_coor, node2.y_coor) + ctx.strokeStyle = COLOUR_THEME + ctx.lineWidth = 2 + ctx.stroke() + + def on_move(self, evt: object) -> None: + if not self.is_mouse_down: + return + + rect = self.canvas.getBoundingClientRect() + + if hasattr(evt, "touches") and evt.touches.length: + current_x = evt.touches[0].clientX - rect.left + current_y = evt.touches[0].clientY - rect.top + else: + current_x = evt.clientX - rect.left + current_y = evt.clientY - rect.top + + node = self.get_node(current_x, current_y) + + if node and not node.connected: + node.connected = True + self.sequence.append(self.node_list.index(node)) + + if self.last_node: # and self.last_node is not node: + self.connected_nodes.append([self.last_node, node]) + + self.last_node = node + + self.draw_pattern() + + if self.last_node: + ctx = self.ctx + ctx.beginPath() + ctx.moveTo(self.last_node.x_coor, self.last_node.y_coor) + ctx.lineTo(current_x, current_y) + ctx.strokeStyle = COLOUR_THEME + ctx.lineWidth = 2 + ctx.stroke() + + def get_node(self, x: int, y: int) -> Node | None: + """ + Get the node of a given x, y coordinate in the canvas + """ + for node in self.node_list: + if ( + (x - node.x_coor) ** 2 + (y - node.y_coor) ** 2 + ) ** 0.5 <= self.dot_radius: + return node + + return None + + def on_dimension_change(self, evt: object) -> None: + self.dimension = 3 + int(evt.target.value) + self.init() + + def setup_event_listener(self) -> None: + """ + Register all the event listener in the canvas + """ + + def mouse_down(_event: object) -> None: + self.is_mouse_down = True + + for node in self.node_list: + node.connected = False + + self.connected_nodes.clear() + self.last_node = None + self.sequence.clear() + + async def mouse_up(_event: object) -> None: + self.is_mouse_down = False + self.draw_pattern() + + if self.on_key_received is not None: + await self.on_key_received( + "".join([str(x) for x in self.sequence]).encode(), + ) + + self.dimension_selection = elem_by_id("dial-num") + add_event_listener(self.canvas, "mousedown", mouse_down) + add_event_listener(self.canvas, "mouseup", mouse_up) + add_event_listener(self.canvas, "mousemove", self.on_move) + add_event_listener(self.dimension_selection, "input", self.on_dimension_change) diff --git a/tubular-tulips/cj12/methods/safe.py b/tubular-tulips/cj12/methods/safe.py new file mode 100644 index 00000000..2aeea147 --- /dev/null +++ b/tubular-tulips/cj12/methods/safe.py @@ -0,0 +1,251 @@ +import math + +from js import document + +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +KNOB_RADIUS = 120 +OUTER_RADIUS = 200 +TICK_CHOICES = ( + (12, (1, 1, 1)), + (24, (3, 1, 1)), + (64, (8, 4, 1)), + (72, (6, 3, 1)), + (100, (10, 5, 1)), +) +TICKS, TICK_INTERVALS = TICK_CHOICES[2] +TICK_LENGTHS = (25, 20, 10) +TICK_WIDTHS = (3, 2, 1) +GREY_GRADIENT = (int("0x33", 16), int("0xAA", 16)) +KNOB_SLICES = 180 +TWO_PI = 2 * math.pi +MOUSE_DEADZONE_RADIUS = 7 + + +class SafeMethod: + byte = 0x04 + static_id = "safe" + name = "Safe" + description = "A safe combination" + + on_key_received: KeyReceiveCallback | None = None + + @staticmethod + def grey(frac: float) -> str: + return ( + "#" + + f"{int(frac * GREY_GRADIENT[1] + (1 - frac) * GREY_GRADIENT[0]):02x}" * 3 + ) + + async def setup(self) -> None: # noqa: PLR0915 + self.combination = [] + self.last_mousedown = None # angle at which the mouse was clicked + self.last_dial_value = 0 # value at which the dial was previously left at + self.prev_angle = None # angle at which the mouse was last detected + self.total_angle = None + + self.offscreen_canvas = document.createElement("canvas") + self.offscreen_canvas.width = 600 + self.offscreen_canvas.height = 400 + ctx = self.offscreen_canvas.getContext("2d") + ctx.fillStyle = "#FFFFFF" + ctx.translate(self.offscreen_canvas.width / 2, self.offscreen_canvas.height / 2) + + self.dial_canvas = elem_by_id("dial-canvas") + self.dial_canvas.style.zIndex = 1 + ctx = self.dial_canvas.getContext("2d") + ctx.fillStyle = "#FFFFFF" + ctx.translate(self.dial_canvas.width / 2, self.dial_canvas.height / 2) + + self.static_canvas = elem_by_id("static-canvas") + self.static_canvas.style.zIndex = 0 + ctx = self.static_canvas.getContext("2d") + ctx.fillRect(0, 0, self.static_canvas.width, self.static_canvas.height) + ctx.translate(self.static_canvas.width / 2, self.static_canvas.height / 2) + ctx.fillStyle = "#FFFFFF" + + self.dial_input_range = elem_by_id("dial-num") + + # draw outer dial + ctx.save() + radial_grad = ctx.createRadialGradient(0, 0, KNOB_RADIUS, 0, 0, OUTER_RADIUS) + radial_grad.addColorStop(0.0, "#222222") + radial_grad.addColorStop(0.7, "#000000") + radial_grad.addColorStop(0.85, "#202020") + radial_grad.addColorStop(0.96, "#444444") + radial_grad.addColorStop(1, "#000000") + ctx.beginPath() + ctx.moveTo(OUTER_RADIUS, 0) + ctx.arc(0, 0, OUTER_RADIUS, 0, TWO_PI) + ctx.fillStyle = radial_grad + ctx.fill() + ctx.restore() + + # draw knob + d_theta = TWO_PI / KNOB_SLICES + for slc in range(KNOB_SLICES): + theta = TWO_PI * slc / KNOB_SLICES + ctx.save() + ctx.rotate(theta) + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.lineTo(KNOB_RADIUS + (slc % 2) * 2, 0) + ctx.arc(0, 0, KNOB_RADIUS + (slc % 2) * 2, 0, d_theta * 1.005) + ctx.closePath() + sin2x, cos4x = math.sin(2 * theta), math.cos(4 * theta) + ctx.strokeStyle = ctx.fillStyle = SafeMethod.grey( + 1 - ((sin2x + cos4x) ** 2) / 4, + ) + ctx.stroke() + ctx.fill() + ctx.restore() + + ctx.beginPath() + ctx.moveTo(KNOB_RADIUS - 5, 0) + ctx.arc(0, 0, KNOB_RADIUS - 5, 0, TWO_PI) + ctx.moveTo(KNOB_RADIUS - 10, 0) + ctx.arc(0, 0, KNOB_RADIUS - 10, 0, TWO_PI) + ctx.strokeStyle = "#000000" + ctx.stroke() + + self.prerender_ticks() + self.draw_ticks() + self.output_div = elem_by_id("output") + + add_event_listener(self.dial_canvas, "mousedown", self.on_mouse_down) + add_event_listener(self.dial_canvas, "mousemove", self.on_mouse_move) + add_event_listener(self.dial_canvas, "mouseup", self.on_mouse_up) + add_event_listener(self.dial_input_range, "input", self.change_dial_type) + + self.btn_reset = elem_by_id("btn-reset") + add_event_listener(self.btn_reset, "click", self.reset_combination) + + def align_center(self) -> None: + self.div.style.alignItems = "center" + + def prerender_ticks(self) -> None: + ctx = self.offscreen_canvas.getContext("2d") + w, h = self.offscreen_canvas.width, self.offscreen_canvas.height + ctx.clearRect(-w / 2, -h / 2, w, h) + ctx.save() + for tick in range(TICKS): + ctx.save() + ctx.beginPath() + ctx.rotate(TWO_PI * tick / TICKS) + for t_type, interval in enumerate(TICK_INTERVALS): + if tick % interval != 0: + continue + ctx.roundRect( + -TICK_WIDTHS[t_type] / 2, + -OUTER_RADIUS + 4, + TICK_WIDTHS[t_type], + TICK_LENGTHS[t_type], + TICK_WIDTHS[t_type] / 2, + ) + break + ctx.fill() + ctx.restore() + + ctx.font = "24px sans-serif" + ctx.textAlign = "center" + ctx.textBaseline = "top" + for tick_numbering in range(0, TICKS, TICK_INTERVALS[0]): + ctx.save() + ctx.beginPath() + ctx.rotate(TWO_PI * tick_numbering / TICKS) + ctx.fillText( + str(tick_numbering), + 0, + -OUTER_RADIUS + 4 + TICK_LENGTHS[0] + 5, + ) + ctx.restore() + ctx.restore() + + def draw_ticks(self, angle: float = 0.0) -> None: + w, h = self.dial_canvas.width, self.dial_canvas.height + ctx = self.dial_canvas.getContext("2d") + ctx.clearRect(-w / 2, -h / 2, w, h) + ctx.save() + ctx.rotate(angle) + ctx.drawImage(self.offscreen_canvas, -w / 2, -h / 2) + ctx.restore() + ctx.beginPath() + ctx.moveTo(-5, -OUTER_RADIUS - 5) + ctx.lineTo(5, -OUTER_RADIUS - 5) + ctx.lineTo(0, -OUTER_RADIUS + 25) + ctx.closePath() + ctx.fillStyle = "#EE0000" + ctx.strokeStyle = "#660000" + ctx.fill() + ctx.stroke() + + def get_mouse_coords(self, event: object) -> None: + rect = self.dial_canvas.getBoundingClientRect() + mx = event.clientX - rect.left - rect.width // 2 + my = event.clientY - rect.top - rect.height // 2 + return mx, my + + async def on_mouse_down(self, event: object) -> None: + if self.total_angle is not None: + await self.register_knob_turn() + return + mx, my = self.get_mouse_coords(event) + if mx**2 + my**2 > OUTER_RADIUS**2: + return + self.total_angle = 0 + self.last_mousedown = ((mx, my), math.atan2(my, mx)) + + def on_mouse_move(self, event: object) -> None: + mx, my = self.get_mouse_coords(event) + if self.last_mousedown is None: + return + curr_angle = math.atan2(my, mx) + d_theta = curr_angle - self.last_mousedown[1] + diff = self.total_angle - d_theta + pi_diffs = abs(diff) // math.pi + if pi_diffs % 2 == 1: + pi_diffs += 1 + self.total_angle = d_theta + (-1 if diff < 0 else 1) * pi_diffs * math.pi + self.draw_ticks(self.total_angle + self.last_dial_value * TWO_PI / TICKS) + + async def on_mouse_up(self, event: object) -> None: + if self.last_mousedown is None: + return + mx, my = self.get_mouse_coords(event) + px, py = self.last_mousedown[0] + if (px - mx) ** 2 + (py - my) ** 2 > MOUSE_DEADZONE_RADIUS**2: + await self.register_knob_turn() + + async def change_dial_type(self, event: object) -> None: + global TICKS, TICK_INTERVALS + TICKS, TICK_INTERVALS = TICK_CHOICES[int(event.target.value)] + self.prerender_ticks() + await self.reset_combination(event) + + async def register_knob_turn(self) -> None: + val = (1 if self.total_angle >= 0 else -1) * round( + abs(self.total_angle) * TICKS / TWO_PI, + ) + self.combination.append(val) + self.last_mousedown = None + self.last_dial_value = (self.last_dial_value + val) % TICKS + self.draw_ticks(self.last_dial_value * TWO_PI / TICKS) + self.prev_angle = None + self.total_angle = None + self.output_div.innerText = " -> ".join( + f"{'+' if x > 0 else ''}{x}" for x in self.combination + ) + if self.on_key_received is not None: + await self.on_key_received(str(self.combination).encode()) + + async def reset_combination(self, _event: object) -> None: + self.last_mousedown = None + self.last_dial_value = 0 + self.draw_ticks() + self.prev_angle = None + self.total_angle = None + self.combination = [] + self.output_div.innerText = "" + if self.on_key_received is not None: + await self.on_key_received(str(self.combination).encode()) diff --git a/tubular-tulips/pyproject.toml b/tubular-tulips/pyproject.toml new file mode 100644 index 00000000..a7ef512e --- /dev/null +++ b/tubular-tulips/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "cj12" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = ["starlette>=0.47.2", "uvicorn>=0.35.0"] + +[dependency-groups] +dev = [ + "numpy>=2.3.2", + "pillow>=11.3.0", + "pre-commit>=4.2.0", + "pyodide-py>=0.28.1", + "pytest>=8.4.1", +] + +[tool.ruff] +lint.select = ["ALL"] +lint.ignore = ["D", "ANN401", "TD002", "TD003", "FIX002"] +lint.per-file-ignores = { "tests/**/*.py" = ["S101", "PLR2004"] } + +[tool.pyright] +typeCheckingMode = "off" diff --git a/tubular-tulips/server.py b/tubular-tulips/server.py new file mode 100644 index 00000000..729f0389 --- /dev/null +++ b/tubular-tulips/server.py @@ -0,0 +1,18 @@ +import os + +import uvicorn +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.staticfiles import StaticFiles + +app = Starlette( + routes=[ + Mount("/cj12", StaticFiles(directory="cj12")), + Mount("/", StaticFiles(directory="static", html=True)), + ], +) + +if __name__ == "__main__": + host: str = os.getenv("CJ12_HOST", "0.0.0.0") # noqa: S104 + port: int = os.getenv("CJ12_PORT", "8000") + uvicorn.run(app, host=host, port=int(port)) diff --git a/tubular-tulips/static/index.html b/tubular-tulips/static/index.html new file mode 100644 index 00000000..b8ade9a1 --- /dev/null +++ b/tubular-tulips/static/index.html @@ -0,0 +1,82 @@ + + + + + + + + + + + + +Loading...
+ + + diff --git a/tubular-tulips/static/methods/chess/img.png b/tubular-tulips/static/methods/chess/img.png new file mode 100644 index 00000000..91d32e7d Binary files /dev/null and b/tubular-tulips/static/methods/chess/img.png differ diff --git a/tubular-tulips/static/methods/chess/page.html b/tubular-tulips/static/methods/chess/page.html new file mode 100644 index 00000000..0cc8166f --- /dev/null +++ b/tubular-tulips/static/methods/chess/page.html @@ -0,0 +1,45 @@ + + + +Drag the chesspieces to create a unique board state. + SPACE clears a piece under the mouse. Press Q, K, R, B, N or P to instantly place a piece.
++ Drag the light blue knob in the center + to input directions. The knob will animate as you drag it, and the + sequence will be recorded below. +
+Enter a password:
+Now one more time:
+Everyone knows that using passwords is silly. It’s like guarding a bank vault with a sticky note that says “1234” on the door — the illusion of security wrapped in human laziness. Let's face it, how many of us even have more than 2 passwords? We really recommend trying something else.
+Enter a safe combination by spinning the safe wheel any other number of times clockwise or counterclockwise. + Use the slider to select a different dial. +
+