a whole load of work in progress

This commit is contained in:
liquidex 2024-08-10 23:13:20 +02:00
parent caec0b8ac9
commit 26ba098183
63 changed files with 3234 additions and 321 deletions

5
.cargo/config.toml Normal file
View file

@ -0,0 +1,5 @@
[target.wasm32-unknown-unknown]
rustflags = [
"-C", "target-feature=+bulk-memory",
"-C", "target-feature=+simd128",
]

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target
/database

738
Cargo.lock generated
View file

@ -17,6 +17,39 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "ahash"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "arrayref"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d151e35f61089500b617991b791fc8bfd237ae50cd5950803758a179b41e67a"
[[package]]
name = "arrayvec"
version = "0.7.4"
@ -48,6 +81,8 @@ checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf"
dependencies = [
"async-trait",
"axum-core",
"axum-macros",
"base64 0.21.7",
"bytes",
"futures-util",
"http",
@ -66,8 +101,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper 1.0.1",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@ -95,6 +132,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00c055ee2d014ae5981ce1016374e8213682aa14d9bf40e48ab48b5f3ef20eaa"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "backtrace"
version = "0.3.71"
@ -110,33 +159,57 @@ dependencies = [
"rustc-demangle",
]
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
[[package]]
name = "bytemuck"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "102087e286b4677862ea56cf8fc58bb2cdfa8725c40ffb80fe3a008eb7f2fc83"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
[[package]]
name = "canvane"
version = "0.1.0"
dependencies = [
"axum",
"color-eyre",
"copy_dir",
"eyre",
"haku",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "cc"
version = "1.1.8"
@ -149,6 +222,20 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-targets",
]
[[package]]
name = "color-eyre"
version = "0.6.3"
@ -185,6 +272,87 @@ dependencies = [
"walkdir",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
dependencies = [
"libc",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "dashmap"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804c8821570c3f8b70230c2ba75ffa5c0f9a4189b9a432b6656c536712acae28"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
[[package]]
name = "derive_more"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dlmalloc"
version = "0.2.6"
@ -196,6 +364,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "equivalent"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "eyre"
version = "0.6.12"
@ -206,6 +380,18 @@ dependencies = [
"once_cell",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fnv"
version = "1.0.7"
@ -255,9 +441,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [
"futures-core",
"futures-sink",
"futures-task",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
@ -269,6 +478,9 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
[[package]]
name = "haku"
version = "0.1.0"
dependencies = [
"tiny-skia",
]
[[package]]
name = "haku-cli"
@ -287,6 +499,30 @@ dependencies = [
"log",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.3.9"
@ -379,18 +615,70 @@ dependencies = [
"tokio",
]
[[package]]
name = "iana-time-zone"
version = "0.1.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "idna"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "indenter"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]]
name = "indexmap"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "js-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@ -403,6 +691,23 @@ version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "libm"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "lock_api"
version = "0.4.12"
@ -478,6 +783,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.32.2"
@ -566,6 +880,21 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "ppv-lite86"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.86"
@ -584,6 +913,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.5.3"
@ -593,6 +952,45 @@ dependencies = [
"bitflags",
]
[[package]]
name = "rkgk"
version = "0.1.0"
dependencies = [
"axum",
"base64 0.22.1",
"chrono",
"color-eyre",
"copy_dir",
"dashmap",
"derive_more",
"eyre",
"haku",
"rand",
"rand_chacha",
"rusqlite",
"serde",
"serde_json",
"tokio",
"toml",
"tower-http",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
@ -628,18 +1026,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.205"
version = "1.0.206"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e33aedb1a7135da52b7c21791455563facbbcc43d0f0f66165b42c21b3dfb150"
checksum = "5b3e4cd94123dd520a128bcd11e34d9e9e423e7e3e50425cb1b4b1e3549d0284"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.205"
version = "1.0.206"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692d6f5ac90220161d6774db30c662202721e64aed9058d2c394f451261420c1"
checksum = "fabfb6138d2383ea8208cf98ccf69cdfb1aff4088460681d84189aa259762f97"
dependencies = [
"proc-macro2",
"quote",
@ -648,9 +1046,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.122"
version = "1.0.124"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da"
checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d"
dependencies = [
"itoa",
"memchr",
@ -668,6 +1066,15 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -680,6 +1087,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@ -698,6 +1116,15 @@ dependencies = [
"libc",
]
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "smallvec"
version = "1.13.2"
@ -714,6 +1141,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "syn"
version = "2.0.72"
@ -737,6 +1170,26 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
[[package]]
name = "thiserror"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@ -747,6 +1200,47 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"libm",
"strict-num",
]
[[package]]
name = "tinyvec"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.39.2"
@ -776,6 +1270,18 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.11"
@ -789,6 +1295,40 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"winnow",
]
[[package]]
name = "tower"
version = "0.4.13"
@ -910,6 +1450,31 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
"url",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "unicase"
version = "2.7.0"
@ -919,18 +1484,56 @@ dependencies = [
"version_check",
]
[[package]]
name = "unicode-bidi"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "unicode-normalization"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
dependencies = [
"tinyvec",
]
[[package]]
name = "url"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "valuable"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@ -953,6 +1556,60 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "winapi"
version = "0.3.9"
@ -984,6 +1641,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -1056,3 +1722,33 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f"
dependencies = [
"memchr",
]
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View file

@ -9,6 +9,10 @@ log = "0.4.22"
[profile.wasm-dev]
inherits = "dev"
panic = "abort"
opt-level = 1
[profile.wasm-dev.package.tiny-skia]
opt-level = 3
[profile.wasm-release]
inherits = "release"

View file

@ -1,5 +1,5 @@
serve wasm_profile="wasm-dev": (wasm wasm_profile)
cargo run -p canvane
cargo run -p rkgk
wasm profile="wasm-dev":
cargo build -p haku-wasm --target wasm32-unknown-unknown --profile {{profile}}

View file

@ -1,15 +0,0 @@
[package]
name = "canvane"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7.5"
color-eyre = "0.6.3"
copy_dir = "0.1.3"
eyre = "0.6.12"
haku.workspace = true
tokio = { version = "1.39.2", features = ["full"] }
tower-http = { version = "0.5.2", features = ["fs"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View file

@ -2,16 +2,19 @@
extern crate alloc;
use core::{alloc::Layout, ffi::CStr, slice, str};
use core::{alloc::Layout, slice};
use alloc::{boxed::Box, vec::Vec};
use haku::{
bytecode::{Chunk, Defs, DefsImage},
compiler::{compile_expr, CompileError, Compiler, Diagnostic, Source},
render::{Bitmap, Renderer, RendererLimits},
sexp::{self, parse_toplevel, Ast, Parser},
render::{
tiny_skia::{Pixmap, PremultipliedColorU8},
Renderer, RendererLimits,
},
sexp::{parse_toplevel, Ast, Parser},
system::{ChunkId, System, SystemImage},
value::{BytecodeLoc, Closure, FunctionName, Ref, Value},
value::{BytecodeLoc, Closure, FunctionName, Ref},
vm::{Exception, Vm, VmImage, VmLimits},
};
use log::info;
@ -42,7 +45,7 @@ struct Limits {
call_stack_capacity: usize,
ref_capacity: usize,
fuel: usize,
bitmap_stack_capacity: usize,
pixmap_stack_capacity: usize,
transform_stack_capacity: usize,
}
@ -57,7 +60,7 @@ impl Default for Limits {
call_stack_capacity: 256,
ref_capacity: 2048,
fuel: 65536,
bitmap_stack_capacity: 4,
pixmap_stack_capacity: 4,
transform_stack_capacity: 16,
}
}
@ -115,6 +118,13 @@ unsafe extern "C" fn haku_instance_destroy(instance: *mut Instance) {
drop(Box::from_raw(instance));
}
#[no_mangle]
unsafe extern "C" fn haku_reset(instance: *mut Instance) {
let instance = &mut *instance;
instance.system.restore_image(&instance.system_image);
instance.defs.restore_image(&instance.defs_image);
}
#[no_mangle]
unsafe extern "C" fn haku_has_exception(instance: *mut Instance) -> bool {
(*instance).exception.is_some()
@ -147,6 +157,19 @@ extern "C" fn haku_is_ok(code: StatusCode) -> bool {
code == StatusCode::Ok
}
#[no_mangle]
extern "C" fn haku_is_diagnostics_emitted(code: StatusCode) -> bool {
code == StatusCode::DiagnosticsEmitted
}
#[no_mangle]
extern "C" fn haku_is_exception(code: StatusCode) -> bool {
matches!(
code,
StatusCode::EvalException | StatusCode::RenderException
)
}
#[no_mangle]
extern "C" fn haku_status_string(code: StatusCode) -> *const i8 {
match code {
@ -261,37 +284,49 @@ unsafe extern "C" fn haku_compile_brush(
StatusCode::Ok
}
struct BitmapLock {
bitmap: Option<Bitmap>,
struct PixmapLock {
pixmap: Option<Pixmap>,
}
#[no_mangle]
extern "C" fn haku_bitmap_new(width: u32, height: u32) -> *mut BitmapLock {
Box::leak(Box::new(BitmapLock {
bitmap: Some(Bitmap::new(width, height)),
extern "C" fn haku_pixmap_new(width: u32, height: u32) -> *mut PixmapLock {
Box::leak(Box::new(PixmapLock {
pixmap: Some(Pixmap::new(width, height).expect("invalid pixmap size")),
}))
}
#[no_mangle]
unsafe extern "C" fn haku_bitmap_destroy(bitmap: *mut BitmapLock) {
drop(Box::from_raw(bitmap))
unsafe extern "C" fn haku_pixmap_destroy(pixmap: *mut PixmapLock) {
drop(Box::from_raw(pixmap))
}
#[no_mangle]
unsafe extern "C" fn haku_bitmap_data(bitmap: *mut BitmapLock) -> *mut u8 {
let bitmap = (*bitmap)
.bitmap
unsafe extern "C" fn haku_pixmap_data(pixmap: *mut PixmapLock) -> *mut u8 {
let pixmap = (*pixmap)
.pixmap
.as_mut()
.expect("bitmap is already being rendered to");
.expect("pixmap is already being rendered to");
bitmap.pixels[..].as_mut_ptr() as *mut u8
pixmap.pixels_mut().as_mut_ptr() as *mut u8
}
#[no_mangle]
unsafe extern "C" fn haku_pixmap_clear(pixmap: *mut PixmapLock) {
let pixmap = (*pixmap)
.pixmap
.as_mut()
.expect("pixmap is already being rendered to");
pixmap.pixels_mut().fill(PremultipliedColorU8::TRANSPARENT);
}
#[no_mangle]
unsafe extern "C" fn haku_render_brush(
instance: *mut Instance,
brush: *const Brush,
bitmap: *mut BitmapLock,
pixmap_a: *mut PixmapLock,
pixmap_b: *mut PixmapLock,
translation_x: f32,
translation_y: f32,
) -> StatusCode {
let instance = &mut *instance;
let brush = &*brush;
@ -320,18 +355,20 @@ unsafe extern "C" fn haku_render_brush(
}
};
let bitmap_locked = (*bitmap)
.bitmap
let mut render = |pixmap: *mut PixmapLock| {
let pixmap_locked = (*pixmap)
.pixmap
.take()
.expect("bitmap is already being rendered to");
.expect("pixmap is already being rendered to");
let mut renderer = Renderer::new(
bitmap_locked,
pixmap_locked,
&RendererLimits {
bitmap_stack_capacity: instance.limits.bitmap_stack_capacity,
pixmap_stack_capacity: instance.limits.pixmap_stack_capacity,
transform_stack_capacity: instance.limits.transform_stack_capacity,
},
);
renderer.translate(translation_x, translation_y);
match renderer.render(&instance.vm, scribble) {
Ok(()) => (),
Err(exn) => {
@ -340,9 +377,24 @@ unsafe extern "C" fn haku_render_brush(
}
}
let bitmap_locked = renderer.finish();
let pixmap_locked = renderer.finish();
(*pixmap).pixmap = Some(pixmap_locked);
StatusCode::Ok
};
match render(pixmap_a) {
StatusCode::Ok => (),
other => return other,
}
if !pixmap_b.is_null() {
match render(pixmap_b) {
StatusCode::Ok => (),
other => return other,
}
}
(*bitmap).bitmap = Some(bitmap_locked);
instance.vm.restore_image(&instance.vm_image);
StatusCode::Ok

View file

@ -4,3 +4,4 @@ version = "0.1.0"
edition = "2021"
[dependencies]
tiny-skia = { version = "0.11.4", default-features = false, features = ["no-std-float"] }

View file

@ -6,7 +6,7 @@ use core::{
use alloc::vec::Vec;
use crate::{
bytecode::{Chunk, DefError, DefId, Defs, EmitError, Opcode, CAPTURE_CAPTURE, CAPTURE_LOCAL},
bytecode::{Chunk, DefError, Defs, EmitError, Opcode, CAPTURE_CAPTURE, CAPTURE_LOCAL},
sexp::{Ast, NodeId, NodeKind, Span},
system::System,
};

View file

@ -1,66 +1,38 @@
use core::iter;
use alloc::vec::Vec;
use tiny_skia::{
BlendMode, Color, LineCap, Paint, PathBuilder, Pixmap, Shader, Stroke as SStroke, Transform,
};
use crate::{
value::{Ref, Rgba, Scribble, Shape, Stroke, Value, Vec4},
value::{Ref, Rgba, Scribble, Shape, Stroke, Value},
vm::{Exception, Vm},
};
pub struct Bitmap {
pub width: u32,
pub height: u32,
pub pixels: Vec<Rgba>,
}
impl Bitmap {
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
pixels: Vec::from_iter(
iter::repeat(Rgba::default()).take(width as usize * height as usize),
),
}
}
pub fn pixel_index(&self, x: u32, y: u32) -> usize {
x as usize + y as usize * self.width as usize
}
pub fn get(&self, x: u32, y: u32) -> Rgba {
self.pixels[self.pixel_index(x, y)]
}
pub fn set(&mut self, x: u32, y: u32, rgba: Rgba) {
let index = self.pixel_index(x, y);
self.pixels[index] = rgba;
}
}
pub use tiny_skia;
pub struct RendererLimits {
pub bitmap_stack_capacity: usize,
pub pixmap_stack_capacity: usize,
pub transform_stack_capacity: usize,
}
pub struct Renderer {
bitmap_stack: Vec<Bitmap>,
transform_stack: Vec<Vec4>,
pixmap_stack: Vec<Pixmap>,
transform_stack: Vec<Transform>,
}
impl Renderer {
pub fn new(bitmap: Bitmap, limits: &RendererLimits) -> Self {
assert!(limits.bitmap_stack_capacity > 0);
pub fn new(pixmap: Pixmap, limits: &RendererLimits) -> Self {
assert!(limits.pixmap_stack_capacity > 0);
assert!(limits.transform_stack_capacity > 0);
let mut blend_stack = Vec::with_capacity(limits.bitmap_stack_capacity);
blend_stack.push(bitmap);
let mut blend_stack = Vec::with_capacity(limits.pixmap_stack_capacity);
blend_stack.push(pixmap);
let mut transform_stack = Vec::with_capacity(limits.transform_stack_capacity);
transform_stack.push(Vec4::default());
transform_stack.push(Transform::identity());
Self {
bitmap_stack: blend_stack,
pixmap_stack: blend_stack,
transform_stack,
}
}
@ -69,44 +41,21 @@ impl Renderer {
Exception { message }
}
fn transform(&self) -> &Vec4 {
self.transform_stack.last().unwrap()
fn transform(&self) -> Transform {
self.transform_stack.last().copied().unwrap()
}
fn transform_mut(&mut self) -> &mut Vec4 {
fn transform_mut(&mut self) -> &mut Transform {
self.transform_stack.last_mut().unwrap()
}
fn bitmap(&self) -> &Bitmap {
self.bitmap_stack.last().unwrap()
pub fn translate(&mut self, x: f32, y: f32) {
let translated = self.transform().post_translate(x, y);
*self.transform_mut() = translated;
}
fn bitmap_mut(&mut self) -> &mut Bitmap {
self.bitmap_stack.last_mut().unwrap()
}
pub fn translate(&mut self, translation: Vec4) {
let transform = self.transform_mut();
transform.x += translation.x;
transform.y += translation.y;
transform.z += translation.z;
transform.w += translation.w;
}
pub fn to_bitmap_coords(&self, point: Vec4) -> Option<(u32, u32)> {
let transform = self.transform();
let x = point.x + transform.x;
let y = point.y + transform.y;
if x >= 0.0 && y >= 0.0 {
let (x, y) = (x as u32, y as u32);
if x < self.bitmap().width && y < self.bitmap().height {
Some((x, y))
} else {
None
}
} else {
None
}
fn pixmap_mut(&mut self) -> &mut Pixmap {
self.pixmap_stack.last_mut().unwrap()
}
pub fn render(&mut self, vm: &Vm, value: Value) -> Result<(), Exception> {
@ -126,19 +75,75 @@ impl Renderer {
}
fn render_stroke(&mut self, _vm: &Vm, _value: Value, stroke: &Stroke) -> Result<(), Exception> {
let paint = Paint {
shader: Shader::SolidColor(tiny_skia_color(stroke.color)),
..default_paint()
};
let transform = self.transform();
match stroke.shape {
Shape::Point(vec) => {
if let Some((x, y)) = self.to_bitmap_coords(vec) {
// TODO: thickness
self.bitmap_mut().set(x, y, stroke.color);
let mut pb = PathBuilder::new();
pb.move_to(vec.x, vec.y);
pb.line_to(vec.x, vec.y);
let path = pb.finish().unwrap();
self.pixmap_mut().stroke_path(
&path,
&paint,
&SStroke {
width: stroke.thickness,
line_cap: LineCap::Square,
..Default::default()
},
transform,
None,
);
}
Shape::Line(start, end) => {
let mut pb = PathBuilder::new();
pb.move_to(start.x, start.y);
pb.line_to(end.x, end.y);
let path = pb.finish().unwrap();
self.pixmap_mut().stroke_path(
&path,
&paint,
&SStroke {
width: stroke.thickness,
line_cap: LineCap::Square,
..Default::default()
},
transform,
None,
);
}
}
Ok(())
}
pub fn finish(mut self) -> Bitmap {
self.bitmap_stack.drain(..).next().unwrap()
pub fn finish(mut self) -> Pixmap {
self.pixmap_stack.drain(..).next().unwrap()
}
}
fn default_paint() -> Paint<'static> {
Paint {
shader: Shader::SolidColor(Color::BLACK),
blend_mode: BlendMode::SourceOver,
anti_alias: false,
force_hq_pipeline: false,
}
}
fn tiny_skia_color(color: Rgba) -> Color {
Color::from_rgba(
color.r.clamp(0.0, 1.0),
color.g.clamp(0.0, 1.0),
color.b.clamp(0.0, 1.0),
color.a.clamp(0.0, 1.0),
)
.unwrap()
}

View file

@ -203,6 +203,7 @@ impl<'a> Parser<'a> {
}
}
#[track_caller]
pub fn current(&self) -> char {
assert_ne!(self.fuel.get(), 0, "parser is stuck");
self.fuel.set(self.fuel.get() - 1);
@ -228,7 +229,7 @@ pub fn skip_whitespace_and_comments(p: &mut Parser<'_>) {
continue;
}
';' => {
while p.current() != '\n' {
while p.current() != '\n' && p.current() != '\0' {
p.advance();
}
}

View file

@ -134,7 +134,8 @@ pub mod fns {
0x89 ".a" => rgba_a,
0xc0 "to-shape" => to_shape_f,
0xc1 "stroke" => stroke,
0xc1 "line" => line,
0xe0 "stroke" => stroke,
}
}
@ -388,14 +389,16 @@ pub mod fns {
Ok(Value::Number(rgba.r))
}
fn to_shape(value: Value, _vm: &Vm) -> Option<Shape> {
fn to_shape(value: Value, vm: &Vm) -> Option<Shape> {
match value {
Value::Nil
| Value::False
| Value::True
| Value::Number(_)
| Value::Rgba(_)
| Value::Ref(_) => None,
Value::Nil | Value::False | Value::True | Value::Number(_) | Value::Rgba(_) => None,
Value::Ref(id) => {
if let Ref::Shape(shape) = vm.get_ref(id) {
Some(shape.clone())
} else {
None
}
}
Value::Vec4(vec) => Some(Shape::Point(vec)),
}
}
@ -413,6 +416,19 @@ pub mod fns {
}
}
pub fn line(vm: &mut Vm, args: FnArgs) -> Result<Value, Exception> {
if args.num() != 2 {
return Err(vm.create_exception("(line) expects 2 arguments (line start end)"));
}
static ERROR: &str = "arguments to (line) must be (vec)";
let start = args.get_vec4(vm, 0, ERROR)?;
let end = args.get_vec4(vm, 1, ERROR)?;
let id = vm.create_ref(Ref::Shape(Shape::Line(start, end)))?;
Ok(Value::Ref(id))
}
pub fn stroke(vm: &mut Vm, args: FnArgs) -> Result<Value, Exception> {
if args.num() != 3 {
return Err(

View file

@ -146,6 +146,7 @@ pub struct Closure {
#[derive(Debug, Clone)]
pub enum Shape {
Point(Vec4),
Line(Vec4, Vec4),
}
#[derive(Debug, Clone)]

25
crates/rkgk/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "rkgk"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7.5", features = ["macros", "ws"] }
base64 = "0.22.1"
chrono = "0.4.38"
color-eyre = "0.6.3"
copy_dir = "0.1.3"
dashmap = "6.0.1"
derive_more = { version = "1.0.0", features = ["try_from"] }
eyre = "0.6.12"
haku.workspace = true
rand = "0.8.5"
rand_chacha = "0.3.1"
rusqlite = { version = "0.32.1", features = ["bundled"] }
serde = { version = "1.0.206", features = ["derive"] }
serde_json = "1.0.124"
tokio = { version = "1.39.2", features = ["full"] }
toml = "0.8.19"
tower-http = { version = "0.5.2", features = ["fs"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

62
crates/rkgk/src/api.rs Normal file
View file

@ -0,0 +1,62 @@
use std::sync::Arc;
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::Databases;
mod wall;
pub fn router<S>(dbs: Arc<Databases>) -> Router<S> {
Router::new()
.route("/login", post(login_new))
.route("/wall", get(wall::wall))
.with_state(dbs)
}
#[derive(Deserialize)]
struct NewUserParams {
nickname: String,
}
#[derive(Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
enum NewUserResponse {
#[serde(rename_all = "camelCase")]
Ok { user_id: String },
#[serde(rename_all = "camelCase")]
Error { message: String },
}
async fn login_new(dbs: State<Arc<Databases>>, params: Json<NewUserParams>) -> impl IntoResponse {
if !(1..=32).contains(&params.nickname.len()) {
return (
StatusCode::BAD_REQUEST,
Json(NewUserResponse::Error {
message: "nickname must be 1..=32 characters long".into(),
}),
);
}
match dbs.login.new_user(params.0.nickname).await {
Ok(user_id) => (
StatusCode::OK,
Json(NewUserResponse::Ok {
user_id: user_id.to_string(),
}),
),
Err(error) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(NewUserResponse::Error {
message: error.to_string(),
}),
),
}
}

155
crates/rkgk/src/api/wall.rs Normal file
View file

@ -0,0 +1,155 @@
use std::sync::Arc;
use axum::{
extract::{
ws::{Message, WebSocket},
State, WebSocketUpgrade,
},
response::Response,
};
use eyre::{bail, Context, OptionExt};
use schema::{Error, LoginRequest, LoginResponse, Online, Version, WallInfo};
use serde::{Deserialize, Serialize};
use tokio::select;
use tracing::{error, info};
use crate::{
login::database::LoginStatus,
wall::{Event, JoinError, Session},
Databases,
};
mod schema;
pub async fn wall(State(dbs): State<Arc<Databases>>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|ws| websocket(dbs, ws))
}
fn to_message<T>(value: &T) -> Message
where
T: Serialize,
{
Message::Text(serde_json::to_string(value).expect("cannot serialize response to JSON"))
}
fn from_message<'de, T>(message: &'de Message) -> eyre::Result<T>
where
T: Deserialize<'de>,
{
match message {
Message::Text(json) => {
serde_json::from_str(json).context("could not deserialize JSON text message")
}
_ => bail!("expected a text message"),
}
}
async fn recv_expect(ws: &mut WebSocket) -> eyre::Result<Message> {
Ok(ws
.recv()
.await
.ok_or_eyre("connection closed unexpectedly")??)
}
async fn websocket(dbs: Arc<Databases>, mut ws: WebSocket) {
match fallible_websocket(dbs, &mut ws).await {
Ok(()) => (),
Err(e) => {
_ = ws
.send(to_message(&Error {
error: format!("{e:?}"),
}))
.await
}
}
}
async fn fallible_websocket(dbs: Arc<Databases>, ws: &mut WebSocket) -> eyre::Result<()> {
#[cfg(debug_assertions)]
let version = format!("{}-dev", env!("CARGO_PKG_VERSION"));
#[cfg(not(debug_assertions))]
let version = format!("{}", env!("CARGO_PKG_VERSION"));
ws.send(to_message(&Version { version })).await?;
let login_request: LoginRequest = from_message(&recv_expect(ws).await?)?;
let user_id = *login_request.user_id();
match dbs
.login
.log_in(user_id)
.await
.context("error while logging in")?
{
LoginStatus::ValidUser => (),
LoginStatus::UserDoesNotExist => {
ws.send(to_message(&LoginResponse::UserDoesNotExist))
.await?;
return Ok(());
}
}
let wall_id = match login_request {
LoginRequest::New { .. } => dbs.wall_broker.generate_id().await,
LoginRequest::Join { wall, .. } => wall,
};
let wall = dbs.wall_broker.open(wall_id);
let mut session_handle = match wall.join(Session::new(user_id)) {
Ok(handle) => handle,
Err(error) => {
ws.send(to_message(&match error {
// NOTE: Respond with the same error code, because it doesn't matter to the user -
// either way the room is way too contended for them to join.
JoinError::TooManyCurrentSessions => LoginResponse::TooManySessions,
JoinError::IdsExhausted => LoginResponse::TooManySessions,
}))
.await?;
return Ok(());
}
};
let mut users_online = vec![];
for online in wall.online() {
let user_info = match dbs.login.user_info(online.user_id).await {
Ok(Some(user_info)) => user_info,
Ok(None) | Err(_) => {
error!(?online, "could not get info about online user");
continue;
}
};
users_online.push(Online {
session_id: online.session_id,
nickname: user_info.nickname,
cursor: online.cursor,
})
}
let users_online = users_online;
ws.send(to_message(&LoginResponse::LoggedIn {
wall: wall_id,
wall_info: WallInfo {
chunk_size: wall.settings().chunk_size,
online: users_online,
},
session_id: session_handle.session_id,
}))
.await?;
loop {
select! {
Some(message) = ws.recv() => {
let kind = from_message(&message?)?;
wall.event(Event { session_id: session_handle.session_id, kind });
}
Ok(event) = session_handle.event_receiver.recv() => {
ws.send(to_message(&event)).await?;
}
else => break,
}
}
Ok(())
}

View file

@ -0,0 +1,70 @@
use serde::{Deserialize, Serialize};
use crate::{
login::UserId,
schema::Vec2,
wall::{self, SessionId, WallId},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Version {
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Error {
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(
tag = "login",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum LoginRequest {
New { user: UserId },
Join { user: UserId, wall: WallId },
}
impl LoginRequest {
pub fn user_id(&self) -> &UserId {
match self {
LoginRequest::New { user } => user,
LoginRequest::Join { user, .. } => user,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Online {
pub session_id: SessionId,
pub nickname: String,
pub cursor: Option<Vec2>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WallInfo {
pub chunk_size: u32,
pub online: Vec<Online>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(
tag = "response",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum LoginResponse {
LoggedIn {
wall: WallId,
wall_info: WallInfo,
session_id: SessionId,
},
UserDoesNotExist,
TooManySessions,
}

51
crates/rkgk/src/binary.rs Normal file
View file

@ -0,0 +1,51 @@
use std::{error::Error, fmt};
pub struct Reader<'a> {
slice: &'a [u8],
}
impl<'a> Reader<'a> {
pub fn new(slice: &'a [u8]) -> Self {
Self { slice }
}
pub fn read_u8(&mut self) -> Result<u8, OutOfData> {
if !self.slice.is_empty() {
Ok(self.slice[0])
} else {
Err(OutOfData)
}
}
pub fn read_u16(&mut self) -> Result<u16, OutOfData> {
if self.slice.len() >= 2 {
Ok(u16::from_le_bytes([self.slice[0], self.slice[1]]))
} else {
Err(OutOfData)
}
}
pub fn read_u32(&mut self) -> Result<u32, OutOfData> {
if self.slice.len() >= 4 {
Ok(u32::from_le_bytes([
self.slice[0],
self.slice[1],
self.slice[2],
self.slice[3],
]))
} else {
Err(OutOfData)
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct OutOfData;
impl fmt::Display for OutOfData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("reader ran out of data")
}
}
impl Error for OutOfData {}

View file

@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
use crate::wall;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub wall: wall::Settings,
}

27
crates/rkgk/src/id.rs Normal file
View file

@ -0,0 +1,27 @@
use std::fmt;
use base64::Engine;
pub fn serialize(f: &mut fmt::Formatter<'_>, prefix: &str, bytes: &[u8; 32]) -> fmt::Result {
f.write_str(prefix)?;
let mut buffer = [b'0'; 43];
base64::engine::general_purpose::STANDARD_NO_PAD
.encode_slice(bytes, &mut buffer)
.unwrap();
f.write_str(std::str::from_utf8(&buffer).unwrap())?;
Ok(())
}
pub struct InvalidId;
pub fn deserialize(s: &str, prefix: &str) -> Result<[u8; 32], InvalidId> {
let mut bytes = [0; 32];
let b64 = s.strip_prefix(prefix).ok_or(InvalidId)?;
let decoded = base64::engine::general_purpose::STANDARD_NO_PAD
.decode_slice(b64, &mut bytes)
.map_err(|_| InvalidId)?;
if decoded != bytes.len() {
return Err(InvalidId);
}
Ok(bytes)
}

70
crates/rkgk/src/login.rs Normal file
View file

@ -0,0 +1,70 @@
use std::{
error::Error,
fmt::{self},
str::FromStr,
};
use rand::RngCore;
pub mod database;
pub use database::Database;
use serde::{Deserialize, Serialize};
use crate::{id, serialization::DeserializeFromStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UserId([u8; 32]);
impl UserId {
pub fn new(rng: &mut dyn RngCore) -> Self {
let mut bytes = [0; 32];
rng.fill_bytes(&mut bytes[..]);
Self(bytes)
}
}
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
id::serialize(f, "user_", &self.0)
}
}
impl FromStr for UserId {
type Err = InvalidUserId;
fn from_str(s: &str) -> Result<Self, Self::Err> {
id::deserialize(s, "user_")
.map(Self)
.map_err(|_| InvalidUserId)
}
}
impl Serialize for UserId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for UserId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_str(DeserializeFromStr::new("user ID"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidUserId;
impl fmt::Display for InvalidUserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("invalid user ID")
}
}
impl Error for InvalidUserId {}

View file

@ -0,0 +1,166 @@
use std::path::PathBuf;
use chrono::Utc;
use eyre::{eyre, Context};
use rand::SeedableRng;
use rusqlite::{Connection, OptionalExtension};
use tokio::sync::{mpsc, oneshot};
use tracing::instrument;
use super::UserId;
pub struct Settings {
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct Database {
command_tx: mpsc::Sender<Command>,
}
pub enum LoginStatus {
ValidUser,
UserDoesNotExist,
}
#[derive(Debug, Clone)]
pub struct UserInfo {
pub nickname: String,
}
enum Command {
NewUser {
nickname: String,
reply: oneshot::Sender<eyre::Result<UserId>>,
},
LogIn {
user_id: UserId,
reply: oneshot::Sender<LoginStatus>,
},
UserInfo {
user_id: UserId,
reply: oneshot::Sender<eyre::Result<Option<UserInfo>>>,
},
}
impl Database {
pub async fn new_user(&self, nickname: String) -> eyre::Result<UserId> {
let (tx, rx) = oneshot::channel();
self.command_tx
.send(Command::NewUser {
nickname,
reply: tx,
})
.await
.map_err(|_| eyre!("database is too contended"))?;
rx.await.map_err(|_| eyre!("database is not available"))?
}
pub async fn log_in(&self, user_id: UserId) -> eyre::Result<LoginStatus> {
let (tx, rx) = oneshot::channel();
self.command_tx
.send(Command::LogIn { user_id, reply: tx })
.await
.map_err(|_| eyre!("database is too contended"))?;
rx.await.map_err(|_| eyre!("database is not available"))
}
pub async fn user_info(&self, user_id: UserId) -> eyre::Result<Option<UserInfo>> {
let (tx, rx) = oneshot::channel();
self.command_tx
.send(Command::UserInfo { user_id, reply: tx })
.await
.map_err(|_| eyre!("database is too contended"))?;
rx.await.map_err(|_| eyre!("database is not available"))?
}
}
#[instrument(name = "login::database::start", skip(settings))]
pub fn start(settings: &Settings) -> eyre::Result<Database> {
let db = Connection::open(&settings.path).context("cannot open login database")?;
db.execute(
r#"
CREATE TABLE IF NOT EXISTS
t_users (
user_index INTEGER PRIMARY KEY,
long_user_id BLOB NOT NULL,
nickname TEXT NOT NULL,
last_login_timestamp INTEGER NOT NULL
);
"#,
(),
)?;
let (command_tx, mut command_rx) = mpsc::channel(8);
let mut user_id_rng = rand_chacha::ChaCha20Rng::from_entropy();
tokio::task::spawn_blocking(move || {
let mut s_insert_user = db
.prepare(
r#"
INSERT INTO t_users
(long_user_id, nickname, last_login_timestamp)
VALUES (?, ?, ?);
"#,
)
.unwrap();
let mut s_log_in = db
.prepare(
r#"
UPDATE OR ABORT t_users
SET last_login_timestamp = ?
WHERE long_user_id = ?;
"#,
)
.unwrap();
let mut s_user_info = db
.prepare(
r#"
SELECT nickname
FROM t_users
WHERE long_user_id = ?
LIMIT 1;
"#,
)
.unwrap();
while let Some(command) = command_rx.blocking_recv() {
match command {
Command::NewUser { nickname, reply } => {
let user_id = UserId::new(&mut user_id_rng);
let result = s_insert_user
.execute((user_id.0, nickname, Utc::now().timestamp()))
.context("could not execute query");
_ = reply.send(result.map(|_| user_id));
}
Command::LogIn { user_id, reply } => {
// TODO: User expiration.
let login_status = match s_log_in.execute((Utc::now().timestamp(), user_id.0)) {
Ok(_) => LoginStatus::ValidUser,
Err(_) => LoginStatus::UserDoesNotExist,
};
_ = reply.send(login_status);
}
Command::UserInfo { user_id, reply } => {
let result = s_user_info
.query_row((user_id.0,), |row| {
Ok(UserInfo {
nickname: row.get(0)?,
})
})
.optional()
.context("could not execute query");
_ = reply.send(result);
}
}
}
});
Ok(Database { command_tx })
}

View file

@ -1,21 +1,32 @@
use std::{
fs::{copy, create_dir_all, remove_dir_all},
path::Path,
sync::Arc,
};
use axum::Router;
use config::Config;
use copy_dir::copy_dir;
use eyre::Context;
use tokio::net::TcpListener;
use tokio::{fs, net::TcpListener};
use tower_http::services::{ServeDir, ServeFile};
use tracing::{info, info_span};
use tracing_subscriber::fmt::format::FmtSpan;
mod api;
mod binary;
mod config;
mod id;
#[cfg(debug_assertions)]
mod live_reload;
mod login;
pub mod schema;
mod serialization;
mod wall;
struct Paths<'a> {
target_dir: &'a Path,
database_dir: &'a Path,
}
fn build(paths: &Paths<'_>) -> eyre::Result<()> {
@ -36,28 +47,47 @@ fn build(paths: &Paths<'_>) -> eyre::Result<()> {
Ok(())
}
#[tokio::main]
async fn main() {
color_eyre::install().unwrap();
tracing_subscriber::fmt()
.with_span_events(FmtSpan::ACTIVE)
.init();
pub struct Databases {
pub login: login::Database,
pub wall_broker: wall::Broker,
}
fn database(config: &Config, paths: &Paths<'_>) -> eyre::Result<Databases> {
create_dir_all(paths.database_dir).context("cannot create directory for databases")?;
let login = login::database::start(&login::database::Settings {
path: paths.database_dir.join("login.db"),
})
.context("cannot start up login database")?;
let wall_broker = wall::Broker::new(config.wall);
Ok(Databases { login, wall_broker })
}
async fn fallible_main() -> eyre::Result<()> {
let paths = Paths {
target_dir: Path::new("target/site"),
database_dir: Path::new("database"),
};
match build(&paths) {
Ok(()) => (),
Err(error) => eprintln!("{error:?}"),
}
let config: Config = toml::from_str(
&fs::read_to_string("rkgk.toml")
.await
.context("cannot read config file")?,
)
.context("cannot deserialize config file")?;
build(&paths)?;
let dbs = Arc::new(database(&config, &paths)?);
let app = Router::new()
.route_service(
"/",
ServeFile::new(paths.target_dir.join("static/index.html")),
)
.nest_service("/static", ServeDir::new(paths.target_dir.join("static")));
.nest_service("/static", ServeDir::new(paths.target_dir.join("static")))
.nest("/api", api::router(dbs.clone()));
#[cfg(debug_assertions)]
let app = app.nest("/dev/live-reload", live_reload::router());
@ -67,4 +97,19 @@ async fn main() {
.expect("cannot bind to port");
info!("listening on port 8080");
axum::serve(listener, app).await.expect("cannot serve app");
Ok(())
}
#[tokio::main]
async fn main() {
color_eyre::install().unwrap();
tracing_subscriber::fmt()
.with_span_events(FmtSpan::ACTIVE)
.init();
match fallible_main().await {
Ok(_) => (),
Err(error) => println!("{error:?}"),
}
}

View file

@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Vec2 {
pub x: f32,
pub y: f32,
}

View file

@ -0,0 +1,36 @@
use std::{fmt::Display, marker::PhantomData, str::FromStr};
use serde::de::{Error, Visitor};
pub struct DeserializeFromStr<T> {
expecting: &'static str,
_phantom: PhantomData<T>,
}
impl<T> DeserializeFromStr<T> {
pub fn new(expecting: &'static str) -> Self {
Self {
expecting,
_phantom: PhantomData,
}
}
}
impl<'de, T> Visitor<'de> for DeserializeFromStr<T>
where
T: FromStr,
T::Err: Display,
{
type Value = T;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(self.expecting)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
T::from_str(v).map_err(|e| Error::custom(e))
}
}

246
crates/rkgk/src/wall.rs Normal file
View file

@ -0,0 +1,246 @@
use std::{
error::Error,
fmt,
str::FromStr,
sync::{
atomic::{self, AtomicU32},
Arc, Weak,
},
};
use dashmap::DashMap;
use haku::render::tiny_skia::Pixmap;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, Mutex};
use crate::{id, login::UserId, schema::Vec2, serialization::DeserializeFromStr};
pub mod broker;
pub use broker::Broker;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WallId([u8; 32]);
impl WallId {
pub fn new(rng: &mut dyn RngCore) -> Self {
let mut bytes = [0; 32];
rng.fill_bytes(&mut bytes);
Self(bytes)
}
}
impl fmt::Display for WallId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
id::serialize(f, "wall_", &self.0)
}
}
impl FromStr for WallId {
type Err = InvalidWallId;
fn from_str(s: &str) -> Result<Self, Self::Err> {
id::deserialize(s, "wall_")
.map(WallId)
.map_err(|_| InvalidWallId)
}
}
impl Serialize for WallId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for WallId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_str(DeserializeFromStr::new("wall ID"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct SessionId(u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidWallId;
impl fmt::Display for InvalidWallId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("invalid wall ID")
}
}
impl Error for InvalidWallId {}
pub struct Chunk {
pixmap: Pixmap,
}
impl Chunk {
pub fn new(size: u32) -> Self {
Self {
pixmap: Pixmap::new(size, size).unwrap(),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
pub struct Settings {
pub max_chunks: usize,
pub max_sessions: usize,
pub chunk_size: u32,
}
pub struct Wall {
settings: Settings,
chunks: DashMap<(i32, i32), Arc<Mutex<Chunk>>>,
sessions: DashMap<SessionId, Session>,
session_id_counter: AtomicU32,
event_sender: broadcast::Sender<Event>,
}
pub struct Session {
pub user_id: UserId,
pub cursor: Option<Vec2>,
}
pub struct SessionHandle {
pub wall: Weak<Wall>,
pub event_receiver: broadcast::Receiver<Event>,
pub session_id: SessionId,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Event {
pub session_id: SessionId,
pub kind: EventKind,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(
tag = "event",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum EventKind {
Cursor { position: Vec2 },
SetBrush { brush: String },
Plot { points: Vec<Vec2> },
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Online {
pub session_id: SessionId,
pub user_id: UserId,
pub cursor: Option<Vec2>,
}
impl Wall {
pub fn new(settings: Settings) -> Self {
Self {
settings,
chunks: DashMap::new(),
sessions: DashMap::new(),
session_id_counter: AtomicU32::new(0),
event_sender: broadcast::channel(16).0,
}
}
pub fn settings(&self) -> &Settings {
&self.settings
}
pub fn get_chunk(&self, at: (i32, i32)) -> Option<Arc<Mutex<Chunk>>> {
self.chunks.get(&at).map(|chunk| Arc::clone(&chunk))
}
pub fn get_or_create_chunk(&self, at: (i32, i32)) -> Arc<Mutex<Chunk>> {
Arc::clone(
&self
.chunks
.entry(at)
.or_insert_with(|| Arc::new(Mutex::new(Chunk::new(self.settings.chunk_size)))),
)
}
pub fn join(self: &Arc<Self>, session: Session) -> Result<SessionHandle, JoinError> {
let session_id = SessionId(
self.session_id_counter
.fetch_add(1, atomic::Ordering::Relaxed),
);
self.sessions.insert(session_id, session);
Ok(SessionHandle {
wall: Arc::downgrade(self),
event_receiver: self.event_sender.subscribe(),
session_id,
})
}
pub fn online(&self) -> Vec<Online> {
self.sessions
.iter()
.map(|r| Online {
session_id: *r.key(),
user_id: r.user_id,
cursor: r.value().cursor,
})
.collect()
}
pub fn event(&self, event: Event) {
if let Some(mut session) = self.sessions.get_mut(&event.session_id) {
match &event.kind {
EventKind::SetBrush { brush } => {}
EventKind::Cursor { position } => {
session.cursor = Some(*position);
}
EventKind::Plot { points } => {}
}
}
_ = self.event_sender.send(event);
}
}
impl Session {
pub fn new(user_id: UserId) -> Self {
Self {
user_id,
cursor: None,
}
}
}
impl Drop for SessionHandle {
fn drop(&mut self) {
if let Some(wall) = self.wall.upgrade() {
wall.sessions.remove(&self.session_id);
// After the session is removed, the wall will be garbage collected later.
}
}
}
pub enum JoinError {
TooManyCurrentSessions,
IdsExhausted,
}
pub enum EventError {
DeadSession,
}

View file

@ -0,0 +1,53 @@
use std::sync::Arc;
use dashmap::DashMap;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use tokio::sync::Mutex;
use tracing::info;
use super::{Settings, Wall, WallId};
/// The broker is the main way to access wall data.
///
/// It handles dynamically loading and unloading walls as they're needed.
/// It also handles database threads for each wall.
pub struct Broker {
wall_settings: Settings,
open_walls: DashMap<WallId, OpenWall>,
rng: Mutex<ChaCha20Rng>,
}
struct OpenWall {
wall: Arc<Wall>,
}
impl Broker {
pub fn new(wall_settings: Settings) -> Self {
info!(?wall_settings, "Broker::new");
Self {
wall_settings,
open_walls: DashMap::new(),
rng: Mutex::new(ChaCha20Rng::from_entropy()),
}
}
pub async fn generate_id(&self) -> WallId {
// TODO: Will lock contention be an issue with generating wall IDs?
// We only have one of these RNGs per rkgk instance.
let mut rng = self.rng.lock().await;
WallId::new(&mut *rng)
}
pub fn open(&self, wall_id: WallId) -> Arc<Wall> {
Arc::clone(
&self
.open_walls
.entry(wall_id)
.or_insert_with(|| OpenWall {
wall: Arc::new(Wall::new(self.wall_settings)),
})
.wall,
)
}
}

5
rkgk.toml Normal file
View file

@ -0,0 +1,5 @@
[wall]
max_chunks = 65536
max_sessions = 128
chunk_size = 168

37
static/brush-editor.js Normal file
View file

@ -0,0 +1,37 @@
const defaultBrush = `
; This is your brush.
; Feel free to edit it to your liking!
(stroke
8 ; thickness
(rgba 0.0 0.0 0.0 1.0) ; color
(vec)) ; position
`.trim();
export class BrushEditor extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.classList.add("rkgk-panel");
this.textArea = this.appendChild(document.createElement("pre"));
this.textArea.classList.add("text-area");
this.textArea.textContent = defaultBrush;
this.textArea.contentEditable = true;
this.textArea.spellcheck = false;
this.textArea.addEventListener("input", () => {
this.dispatchEvent(
Object.assign(new Event(".codeChanged"), {
newCode: this.textArea.value,
}),
);
});
}
get code() {
return this.textArea.textContent;
}
}
customElements.define("rkgk-brush-editor", BrushEditor);

154
static/canvas-renderer.js Normal file
View file

@ -0,0 +1,154 @@
import { listen } from "./framework.js";
import { Viewport } from "./viewport.js";
class CanvasRenderer extends HTMLElement {
viewport = new Viewport();
constructor() {
super();
}
connectedCallback() {
this.canvas = this.appendChild(document.createElement("canvas"));
this.ctx = this.canvas.getContext("2d");
let resizeObserver = new ResizeObserver(() => this.#updateSize());
resizeObserver.observe(this);
this.#cursorReportingBehaviour();
this.#draggingBehaviour();
this.#zoomingBehaviour();
this.#paintingBehaviour();
}
initialize(wall, painter) {
this.wall = wall;
this.painter = painter;
requestAnimationFrame(() => this.#render());
}
#updateSize() {
this.canvas.width = this.clientWidth;
this.canvas.height = this.clientHeight;
// Rerender immediately after the canvas is resized, as its contents have now been invalidated.
this.#render();
}
#render() {
// NOTE: We should probably render on-demand only when it's needed.
requestAnimationFrame(() => this.#render());
this.#renderWall();
}
#renderWall() {
if (this.wall == null) {
console.debug("wall is not available, skipping rendering");
return;
}
this.ctx.fillStyle = "white";
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.save();
this.ctx.translate(Math.floor(this.clientWidth / 2), Math.floor(this.clientHeight / 2));
this.ctx.scale(this.viewport.zoom, this.viewport.zoom);
this.ctx.translate(-this.viewport.panX, -this.viewport.panY);
let visibleRect = this.viewport.getVisibleRect({
width: this.clientWidth,
height: this.clientHeight,
});
let left = Math.floor(visibleRect.x / this.wall.chunkSize);
let top = Math.floor(visibleRect.y / this.wall.chunkSize);
let right = Math.ceil((visibleRect.x + visibleRect.width) / this.wall.chunkSize);
let bottom = Math.ceil((visibleRect.y + visibleRect.height) / this.wall.chunkSize);
for (let chunkY = top; chunkY < bottom; ++chunkY) {
for (let chunkX = left; chunkX < right; ++chunkX) {
let x = chunkX * this.wall.chunkSize;
let y = chunkY * this.wall.chunkSize;
let chunk = this.wall.getChunk(chunkX, chunkY);
if (chunk != null) {
this.ctx.drawImage(chunk.canvas, x, y);
}
}
}
this.ctx.restore();
if (this.ctx.brushPreview != null) {
this.ctx.drawImage(this.ctx.brushPreview, 0, 0);
}
}
async #cursorReportingBehaviour() {
while (true) {
let event = await listen([this, "mousemove"]);
let [x, y] = this.viewport.toViewportSpace(
event.clientX - this.clientLeft,
event.offsetY - this.clientTop,
{
width: this.clientWidth,
height: this.clientHeight,
},
);
this.dispatchEvent(Object.assign(new Event(".cursor"), { x, y }));
}
}
async #draggingBehaviour() {
while (true) {
let mouseDown = await listen([this, "mousedown"]);
if (mouseDown.button == 1) {
mouseDown.preventDefault();
while (true) {
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
this.viewport.panAround(event.movementX, event.movementY);
this.dispatchEvent(new Event(".viewportUpdate"));
} else if (event.type == "mouseup") {
break;
}
}
}
}
}
async #zoomingBehaviour() {
while (true) {
let event = await listen([this, "wheel"]);
// TODO: Touchpad zoom
this.viewport.zoomIn(event.deltaY > 0 ? -1 : 1);
this.dispatchEvent(new Event(".viewportUpdate"));
}
}
async #paintingBehaviour() {
const paint = (x, y) => {
let [wallX, wallY] = this.viewport.toViewportSpace(x, y, {
width: this.clientWidth,
height: this.clientHeight,
});
this.dispatchEvent(Object.assign(new Event(".paint"), { x: wallX, y: wallY }));
};
while (true) {
let mouseDown = await listen([this, "mousedown"]);
if (mouseDown.button == 0) {
paint(mouseDown.offsetX, mouseDown.offsetY);
while (true) {
let event = await listen([window, "mousemove"], [window, "mouseup"]);
if (event.type == "mousemove") {
paint(event.clientX - this.clientLeft, event.offsetY - this.clientTop);
} else if (event.type == "mouseup") {
break;
}
}
}
}
}
}
customElements.define("rkgk-canvas-renderer", CanvasRenderer);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

30
static/framework.js Normal file
View file

@ -0,0 +1,30 @@
export function listen(...listenerSpecs) {
return new Promise((resolve) => {
let removeAllEventListeners;
let listeners = listenerSpecs.map(([element, eventName]) => {
let listener = (event) => {
removeAllEventListeners();
resolve(event);
};
element.addEventListener(eventName, listener);
return { element, eventName, func: listener };
});
removeAllEventListeners = () => {
for (let listener of listeners) {
listener.element.removeEventListener(listener.eventName, listener.func);
}
};
});
}
export function debounce(time, fn) {
let timeout = null;
return (...args) => {
if (timeout == null) {
fn(...args);
timeout = setTimeout(() => (timeout = null), time);
}
};
}

203
static/haku.js Normal file
View file

@ -0,0 +1,203 @@
let panicImpl;
let logImpl;
function makeLogFunction(level) {
return (length, pMessage) => {
logImpl(level, length, pMessage);
};
}
let { instance: hakuInstance, module: hakuModule } = await WebAssembly.instantiateStreaming(
fetch(import.meta.resolve("./wasm/haku.wasm")),
{
env: {
panic(length, pMessage) {
panicImpl(length, pMessage);
},
trace: makeLogFunction("trace"),
debug: makeLogFunction("debug"),
info: makeLogFunction("info"),
warn: makeLogFunction("warn"),
error: makeLogFunction("error"),
},
},
);
let memory = hakuInstance.exports.memory;
let w = hakuInstance.exports;
let textEncoder = new TextEncoder();
function allocString(string) {
let size = string.length * 3;
let align = 1;
let pString = w.haku_alloc(size, align);
let buffer = new Uint8Array(memory.buffer, pString, size);
let result = textEncoder.encodeInto(string, buffer);
return {
ptr: pString,
length: result.written,
size,
align,
};
}
function freeString(alloc) {
w.haku_free(alloc.ptr, alloc.size, alloc.align);
}
let textDecoder = new TextDecoder();
function readString(size, pString) {
let buffer = new Uint8Array(memory.buffer, pString, size);
return textDecoder.decode(buffer);
}
function readCString(pCString) {
let memoryBuffer = new Uint8Array(memory.buffer);
let pCursor = pCString;
while (memoryBuffer[pCursor] != 0 && memoryBuffer[pCursor] != null) {
pCursor++;
}
let size = pCursor - pCString;
return readString(size, pCString);
}
class Panic extends Error {
name = "Panic";
}
panicImpl = (length, pMessage) => {
throw new Panic(readString(length, pMessage));
};
logImpl = (level, length, pMessage) => {
console[level](readString(length, pMessage));
};
w.haku_init_logging();
export class Pixmap {
#pPixmap = 0;
constructor(width, height) {
this.#pPixmap = w.haku_pixmap_new(width, height);
this.width = width;
this.height = height;
}
destroy() {
w.haku_pixmap_destroy(this.#pPixmap);
}
clear(r, g, b, a) {
w.haku_pixmap_clear(this.#pPixmap, r, g, b, a);
}
get ptr() {
return this.#pPixmap;
}
get imageData() {
return new ImageData(
new Uint8ClampedArray(
memory.buffer,
w.haku_pixmap_data(this.#pPixmap),
this.width * this.height * 4,
),
this.width,
this.height,
);
}
}
export class Haku {
#pInstance = 0;
#pBrush = 0;
#brushCode = null;
constructor() {
this.#pInstance = w.haku_instance_new();
this.#pBrush = w.haku_brush_new();
}
setBrush(code) {
w.haku_reset(this.#pInstance);
// NOTE: Brush is invalid at this point, because we reset removes all defs and registered chunks.
if (this.#brushCode != null) freeString(this.#brushCode);
this.#brushCode = allocString(code);
let statusCode = w.haku_compile_brush(
this.#pInstance,
this.#pBrush,
this.#brushCode.length,
this.#brushCode.ptr,
);
if (!w.haku_is_ok(statusCode)) {
if (w.haku_is_diagnostics_emitted(statusCode)) {
let diagnostics = [];
for (let i = 0; i < w.haku_num_diagnostics(this.#pBrush); ++i) {
diagnostics.push({
start: w.haku_diagnostic_start(this.#pBrush, i),
end: w.haku_diagnostic_end(this.#pBrush, i),
message: readString(
w.haku_diagnostic_message_len(this.#pBrush, i),
w.haku_diagnostic_message(this.#pBrush, i),
),
});
}
return {
status: "error",
errorKind: "diagnostics",
diagnostics,
};
} else {
return {
status: "error",
errorKind: "plain",
message: readCString(w.haku_status_string(statusCode)),
};
}
}
return { status: "ok" };
}
renderBrush(pixmap, translationX, translationY) {
let statusCode = w.haku_render_brush(
this.#pInstance,
this.#pBrush,
pixmap.ptr,
// If we ever want to detect which pixels were touched (USING A SHADER.), we can use
// this to rasterize the brush _twice_, and then we can detect which pixels are the same
// between the two pixmaps.
0,
translationX,
translationY,
);
if (!w.haku_is_ok(statusCode)) {
if (w.haku_is_exception(statusCode)) {
return {
status: "error",
errorKind: "exception",
description: readCString(w.haku_status_string(statusCode)),
message: readString(
w.haku_exception_message_len(this.#pInstance),
w.haku_exception_message(this.#pInstance),
),
};
} else {
return {
status: "error",
errorKind: "plain",
message: readCString(w.haku_status_string(statusCode)),
};
}
}
return { status: "ok" };
}
}

232
static/index.css Normal file
View file

@ -0,0 +1,232 @@
/* Variables */
:root {
--color-text: #111;
--color-error: #db344b;
--color-panel-border: rgba(0, 0, 0, 20%);
--color-panel-background: #fff;
--panel-border-radius: 16px;
--panel-box-shadow: 0 0 0 1px var(--color-panel-border);
--panel-padding: 12px;
--dialog-backdrop: rgba(255, 255, 255, 0.5);
}
/* Reset */
body {
margin: 0;
width: 100vw;
height: 100vh;
color: var(--color-text);
line-height: 1.4;
}
/* Fonts */
@font-face {
font-family: "Fira Sans";
src:
local("Fira Sans Regular"),
url("font/FiraSans-Regular.ttf");
font-weight: 400;
}
@font-face {
font-family: "Fira Sans";
src:
local("Fira Sans Bold"),
url("font/FiraSans-Bold.ttf");
font-weight: 700;
}
@font-face {
font-family: "Fira Code";
src:
local("Fira Code"),
url("font/FiraCode-VariableFont_wght.ttf");
font-weight: 400;
}
:root {
font-size: 87.5%;
font-family: "Fira Sans", sans-serif;
}
button, textarea, input {
font-size: inherit;
font-family: inherit;
}
/* Main container layout */
main {
width: 100%;
height: 100%;
position: relative;
&>rkgk-canvas-renderer {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
}
&>rkgk-reticle-renderer {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
overflow: hidden;
}
&>rkgk-brush-editor {
width: 384px;
position: absolute;
right: 0;
top: 0;
margin: 16px;
}
}
/* Buttons */
button {
border: 1px solid var(--color-panel-border);
border-radius: 9999px;
padding: 0.5rem 1.5rem;
background-color: var(--color-panel-background);
}
/* Text areas */
input {
border: none;
border-bottom: 1px solid var(--color-panel-border);
}
*[contenteditable]:focus, input:focus {
border-radius: 2px;
outline: 1px solid #40b1f4;
outline-offset: 4px;
}
/* Modal dialogs */
dialog:not([open]) {
/* Weird this doesn't seem to work by default. */
display: none;
}
dialog::backdrop {
background-color: var(--dialog-backdrop);
backdrop-filter: blur(8px);
}
/* Throbbers */
rkgk-throbber {
display: inline;
&.loading {
&::before {
/* This could use an entertaining animation. */
content: "Please wait...";
}
}
&.error {
/* This could use an icon. */
color: var(--color-error);
}
}
/* Panels */
.rkgk-panel {
display: block;
background: var(--color-panel-background);
padding: var(--panel-border-radius);
border: none;
border-radius: 16px;
box-shadow: var(--panel-box-shadow);
}
/* Canvas renderer */
rkgk-canvas-renderer {
display: block;
&>canvas {
display: block;
}
}
/* Reticle renderer */
rkgk-reticle-renderer {
display: block;
pointer-events: none;
&>.reticles {
position: relative;
}
}
rkgk-reticle {
--color: black;
position: absolute;
display: block;
&>.container {
&>.arrow {
width: 24px;
height: 24px;
background-color: var(--color);
clip-path: path("M 0,0 L 13,13 L 6,13 L 0,19 Z");
}
&>.nickname {
position: absolute;
top: 20px;
left: 8px;
color: white;
background-color: var(--color);
padding: 1px 6px;
border-radius: 9999px;
text-align: center;
font-weight: bold;
}
}
}
/* Brush editor */
rkgk-brush-editor {
&>.text-area {
width: 100%;
height: 100%;
margin: 0;
resize: none;
font-family: "Fira Code", monospace;
}
}
/* Welcome screen */
rkgk-welcome {
&>dialog {
h3 {
margin: 0.5rem 0;
font-size: 2rem;
font-weight: bold;
}
}
}

View file

@ -2,17 +2,53 @@
<html>
<head>
<title>canvane</title>
<script src="static/index.js" type="module"></script>
<title>rakugaki</title>
<link rel="stylesheet" href="static/index.css">
<script src="static/live-reload.js" type="module"></script>
<script src="static/brush-editor.js" type="module"></script>
<script src="static/canvas-renderer.js" type="module"></script>
<script src="static/framework.js" type="module"></script>
<script src="static/reticle-renderer.js" type="module"></script>
<script src="static/session.js" type="module"></script>
<script src="static/throbber.js" type="module"></script>
<script src="static/viewport.js" type="module"></script>
<script src="static/welcome.js" type="module"></script>
<script src="static/index.js" type="module"></script>
</head>
<body>
<main>
<canvas id="render" width="256" height="256">Please enable JavaScript</canvas>
<br>
<textarea id="code" cols="80" rows="25">(stroke 1 (rgba 0 0 0 255) (vec 32 32))</textarea>
<p id="output" style="white-space: pre-wrap;"></p>
<rkgk-canvas-renderer></rkgk-canvas-renderer>
<rkgk-reticle-renderer></rkgk-reticle-renderer>
<rkgk-brush-editor></rkgk-brush-editor>
<rkgk-welcome>
<dialog name="welcome-dialog" class="rkgk-panel">
<form method="dialog">
<h3>
My name is
<input
name="nickname"
type="text"
required minlength="1" maxlength="32"
placeholder="... (type here!)"
autocomplete="off"
autofocus></input>
</h3>
<p>This name will be visible to any friends drawing along with you, so choose something recognizable!<br>
Keep in mind you can always change it later.</p>
<div style="display: flex; flex-direction: row; align-items: center; justify-content: end; gap: 8px;">
<rkgk-throbber name="register-progress"></rkgk-throbber>
<button name="register">Register</button>
</div>
</form>
</dialog>
</rkgk-welcome>
</main>
</body>
</html>

View file

@ -1,154 +1,74 @@
let panicImpl;
let logImpl;
import { Painter } from "./painter.js";
import { Wall } from "./wall.js";
import { Haku } from "./haku.js";
import { getUserId, newSession, waitForLogin } from "./session.js";
import { debounce } from "./framework.js";
function makeLogFunction(level) {
return (length, pMessage) => {
logImpl(level, length, pMessage);
};
}
let main = document.querySelector("main");
let canvasRenderer = main.querySelector("rkgk-canvas-renderer");
let reticleRenderer = main.querySelector("rkgk-reticle-renderer");
let brushEditor = main.querySelector("rkgk-brush-editor");
let { instance: hakuInstance, module: hakuModule } = await WebAssembly.instantiateStreaming(
fetch(import.meta.resolve("./wasm/haku.wasm")),
{
env: {
panic(length, pMessage) {
panicImpl(length, pMessage);
},
trace: makeLogFunction("trace"),
debug: makeLogFunction("debug"),
info: makeLogFunction("info"),
warn: makeLogFunction("warn"),
error: makeLogFunction("error"),
},
},
);
let haku = new Haku();
let painter = new Painter(512);
let memory = hakuInstance.exports.memory;
let w = hakuInstance.exports;
reticleRenderer.connectViewport(canvasRenderer.viewport);
canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.updateTransform());
let textEncoder = new TextEncoder();
function allocString(string) {
let size = string.length * 3;
let align = 1;
let pString = w.haku_alloc(size, align);
// In the background, connect to the server.
(async () => {
await waitForLogin();
console.info("login ready! starting session");
let buffer = new Uint8Array(memory.buffer, pString, size);
let result = textEncoder.encodeInto(string, buffer);
let session = await newSession(getUserId(), localStorage.getItem("rkgk.mostRecentWallId"));
localStorage.setItem("rkgk.mostRecentWallId", session.wallId);
return {
ptr: pString,
length: result.written,
size,
align,
};
}
let wall = new Wall(session.wallInfo.chunkSize);
canvasRenderer.initialize(wall);
function freeString(alloc) {
w.haku_free(alloc.ptr, alloc.size, alloc.align);
}
let textDecoder = new TextDecoder();
function readString(size, pString) {
let buffer = new Uint8Array(memory.buffer, pString, size);
return textDecoder.decode(buffer);
}
function readCString(pCString) {
let memoryBuffer = new Uint8Array(memory.buffer);
let pCursor = pCString;
while (memoryBuffer[pCursor] != 0 && memoryBuffer[pCursor] != null) {
pCursor++;
for (let onlineUser of session.wallInfo.online) {
wall.onlineUsers.addUser(onlineUser.sessionId, { nickname: onlineUser.nickname });
}
let size = pCursor - pCString;
return readString(size, pCString);
}
class Panic extends Error {
name = "Panic";
}
panicImpl = (length, pMessage) => {
throw new Panic(readString(length, pMessage));
};
logImpl = (level, length, pMessage) => {
console[level](readString(length, pMessage));
};
w.haku_init_logging();
/* ------ */
let renderCanvas = document.getElementById("render");
let codeTextArea = document.getElementById("code");
let outputP = document.getElementById("output");
let ctx = renderCanvas.getContext("2d");
function rerender() {
console.log("rerender");
let width = renderCanvas.width;
let height = renderCanvas.height;
let logs = [];
let pInstance = w.haku_instance_new();
let pBrush = w.haku_brush_new();
let pBitmap = w.haku_bitmap_new(width, height);
let code = allocString(codeTextArea.value);
let deallocEverything = () => {
freeString(code);
w.haku_bitmap_destroy(pBitmap);
w.haku_brush_destroy(pBrush);
w.haku_instance_destroy(pInstance);
outputP.textContent = logs.join("\n");
};
let compileStatusCode = w.haku_compile_brush(pInstance, pBrush, code.length, code.ptr);
let pCompileStatusString = w.haku_status_string(compileStatusCode);
logs.push(`compile: ${readCString(pCompileStatusString)}`);
for (let i = 0; i < w.haku_num_diagnostics(pBrush); ++i) {
let start = w.haku_diagnostic_start(pBrush, i);
let end = w.haku_diagnostic_end(pBrush, i);
let length = w.haku_diagnostic_message_len(pBrush, i);
let pMessage = w.haku_diagnostic_message(pBrush, i);
let message = readString(length, pMessage);
logs.push(`${start}..${end}: ${message}`);
session.addEventListener("error", (event) => console.error(event));
session.addEventListener("action", (event) => {
if (event.kind.event == "cursor") {
let reticle = reticleRenderer.getOrAddReticle(wall.onlineUsers, event.sessionId);
let { x, y } = event.kind.position;
reticle.setCursor(x, y);
}
});
if (w.haku_num_diagnostics(pBrush) > 0) {
deallocEverything();
return;
let compileBrush = () => haku.setBrush(brushEditor.code);
compileBrush();
brushEditor.addEventListener(".codeChanged", () => compileBrush());
let reportCursor = debounce(1000 / 60, (x, y) => session.reportCursor(x, y));
canvasRenderer.addEventListener(".cursor", async (event) => {
reportCursor(event.x, event.y);
});
canvasRenderer.addEventListener(".paint", async (event) => {
painter.renderBrush(haku);
let imageBitmap = await painter.createImageBitmap();
let left = event.x - painter.paintArea / 2;
let top = event.y - painter.paintArea / 2;
let leftChunk = Math.floor(left / wall.chunkSize);
let topChunk = Math.floor(top / wall.chunkSize);
let rightChunk = Math.ceil((left + painter.paintArea) / wall.chunkSize);
let bottomChunk = Math.ceil((top + painter.paintArea) / wall.chunkSize);
for (let chunkY = topChunk; chunkY < bottomChunk; ++chunkY) {
for (let chunkX = leftChunk; chunkX < rightChunk; ++chunkX) {
let chunk = wall.getOrCreateChunk(chunkX, chunkY);
let x = Math.floor(-chunkX * wall.chunkSize + left);
let y = Math.floor(-chunkY * wall.chunkSize + top);
chunk.ctx.drawImage(imageBitmap, x, y);
}
let renderStatusCode = w.haku_render_brush(pInstance, pBrush, pBitmap);
let pRenderStatusString = w.haku_status_string(renderStatusCode);
logs.push(`render: ${readCString(pRenderStatusString)}`);
if (w.haku_has_exception(pInstance)) {
let length = w.haku_exception_message_len(pInstance);
let pMessage = w.haku_exception_message(pInstance);
let message = readString(length, pMessage);
logs.push(`exception: ${message}`);
deallocEverything();
return;
}
imageBitmap.close();
});
let pBitmapData = w.haku_bitmap_data(pBitmap);
let bitmapDataBuffer = new Float32Array(memory.buffer, pBitmapData, width * height * 4);
let imageData = new ImageData(width, height);
for (let i = 0; i < bitmapDataBuffer.length; ++i) {
imageData.data[i] = bitmapDataBuffer[i] * 255;
}
ctx.putImageData(imageData, 0, 0);
deallocEverything();
}
rerender();
codeTextArea.addEventListener("input", rerender);
session.eventLoop();
})();

19
static/online-users.js Normal file
View file

@ -0,0 +1,19 @@
export class OnlineUsers extends EventTarget {
#users = new Map();
constructor() {
super();
}
addUser(sessionId, userInfo) {
this.#users.set(sessionId, userInfo);
}
getUser(sessionId) {
return this.#users.get(sessionId);
}
removeUser(sessionId) {
this.#users.delete(sessionId);
}
}

22
static/painter.js Normal file
View file

@ -0,0 +1,22 @@
import { Pixmap } from "./haku.js";
export class Painter {
#pixmap;
imageBitmap;
constructor(paintArea) {
this.paintArea = paintArea;
this.#pixmap = new Pixmap(paintArea, paintArea);
}
async createImageBitmap() {
return await createImageBitmap(this.#pixmap.imageData);
}
renderBrush(haku) {
this.#pixmap.clear(0, 0, 0, 0);
let result = haku.renderBrush(this.#pixmap, this.paintArea / 2, this.paintArea / 2);
return result;
}
}

130
static/reticle-renderer.js Normal file
View file

@ -0,0 +1,130 @@
export class Reticle extends HTMLElement {
#kind = null;
#data = {};
#container;
constructor(nickname) {
super();
this.nickname = nickname;
}
connectedCallback() {
this.style.setProperty("--color", this.getColor());
this.#container = this.appendChild(document.createElement("div"));
this.#container.classList.add("container");
}
getColor() {
let hash = 5381;
for (let i = 0; i < this.nickname.length; ++i) {
hash <<= 5;
hash += hash;
hash += this.nickname.charCodeAt(i);
hash &= 0xffff;
}
return `oklch(70% 0.2 ${(hash / 0xffff) * 360}deg)`;
}
#update(kind, data) {
this.#data = data;
if (kind != this.#kind) {
this.classList = "";
this.#container.replaceChildren();
this.#kind = kind;
}
this.dispatchEvent(new Event(".update"));
}
setCursor(x, y) {
this.#update("cursor", { x, y });
}
render(viewport, windowSize) {
if (!this.rendered) {
if (this.#kind == "cursor") {
this.classList.add("cursor");
let arrow = this.#container.appendChild(document.createElement("div"));
arrow.classList.add("arrow");
let nickname = this.#container.appendChild(document.createElement("div"));
nickname.classList.add("nickname");
nickname.textContent = this.nickname;
}
}
if (this.#kind == "cursor") {
let { x, y } = this.#data;
let [viewportX, viewportY] = viewport.toScreenSpace(x, y, windowSize);
this.style.transform = `translate(${viewportX}px, ${viewportY}px)`;
}
this.rendered = true;
}
}
customElements.define("rkgk-reticle", Reticle);
export class ReticleRenderer extends HTMLElement {
#reticles = new Map();
#reticlesDiv;
connectedCallback() {
this.#reticlesDiv = this.appendChild(document.createElement("div"));
this.#reticlesDiv.classList.add("reticles");
this.updateTransform();
let resizeObserver = new ResizeObserver(() => this.updateTransform());
resizeObserver.observe(this);
}
connectViewport(viewport) {
this.viewport = viewport;
this.updateTransform();
}
getOrAddReticle(onlineUsers, sessionId) {
if (this.#reticles.has(sessionId)) {
return this.#reticles.get(sessionId);
} else {
let reticle = new Reticle(onlineUsers.getUser(sessionId).nickname);
reticle.addEventListener(".update", () => {
if (this.viewport != null) {
reticle.render(this.viewport, {
width: this.clientWidth,
height: this.clientHeight,
});
}
});
this.#reticles.set(sessionId, reticle);
this.#reticlesDiv.appendChild(reticle);
return reticle;
}
}
removeReticle(sessionId) {
if (this.#reticles.has(sessionId)) {
let reticle = this.#reticles.get(sessionId);
this.#reticles.delete(sessionId);
this.#reticlesDiv.removeChild(reticle);
}
}
updateTransform() {
if (this.viewport == null) {
console.debug("viewport is disconnected, skipping transform update");
return;
}
let windowSize = { width: this.clientWidth, height: this.clientHeight };
for (let [_, reticle] of this.#reticles) {
reticle.render(this.viewport, windowSize);
}
}
}
customElements.define("rkgk-reticle-renderer", ReticleRenderer);

200
static/session.js Normal file
View file

@ -0,0 +1,200 @@
import { listen } from "./framework.js";
let loginStorage = JSON.parse(localStorage.getItem("rkgk.login") ?? "{}");
function saveLoginStorage() {
localStorage.setItem("rkgk.login", JSON.stringify(loginStorage));
}
let resolveLoggedInPromise;
let loggedInPromise = new Promise((resolve) => (resolveLoggedInPromise = resolve));
export function isUserLoggedIn() {
return loginStorage.userId != null;
}
export function getUserId() {
return loginStorage.userId;
}
export function waitForLogin() {
return loggedInPromise;
}
if (isUserLoggedIn()) {
resolveLoggedInPromise();
}
export async function registerUser(nickname) {
try {
let response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ nickname }),
headers: {
"Content-Type": "application/json",
},
});
if (response.status == 500) {
console.error("login service returned 500 status", response);
return {
status: "error",
message:
"We're sorry, but we ran into some trouble registering your account. Please try again.",
};
}
let responseText = await response.text();
let responseJson = JSON.parse(responseText);
if (responseJson.status != "ok") {
console.error("registering user failed", responseJson);
return {
status: "error",
message: "Something seems to have gone wrong. Please try again.",
};
}
console.log(responseJson);
loginStorage.userId = responseJson.userId;
console.info("user registered", loginStorage.userId);
saveLoginStorage();
resolveLoggedInPromise();
return { status: "ok" };
} catch (error) {
console.error("registering user failed", error);
return {
status: "error",
message: "Something seems to have gone wrong. Please try again.",
};
}
}
class Session extends EventTarget {
constructor(userId) {
super();
this.userId = userId;
}
async #recvJson() {
let event = await listen([this.ws, "message"]);
if (typeof event.data == "string") {
return JSON.parse(event.data);
} else {
throw new Error("received a binary message where a JSON text message was expected");
}
}
#sendJson(object) {
console.debug("sendJson", object);
this.ws.send(JSON.stringify(object));
}
#dispatchError(source, kind, message) {
this.dispatchEvent(
Object.assign(new Event("error"), {
source,
errorKind: kind,
message,
}),
);
}
async join(wallId) {
console.info("joining wall", wallId);
this.wallId = wallId;
this.ws = new WebSocket("/api/wall");
this.ws.addEventListener("error", (event) => {
console.error("WebSocket connection error", error);
this.dispatchEvent(Object.assign(new Event("error"), event));
});
this.ws.addEventListener("message", (event) => {
if (typeof event.data == "string") {
let json = JSON.parse(event.data);
if (json.error != null) {
console.error("received error from server:", json.error);
this.#dispatchError(json, "protocol", json.error);
}
}
});
try {
await listen([this.ws, "open"]);
await this.joinInner();
} catch (error) {
this.#dispatchError(error, "connection", `communication failed: ${error.toString()}`);
}
}
async joinInner() {
let version = await this.#recvJson();
console.info("protocol version", version.version);
// TODO: This should probably verify that the version is compatible.
// We don't have a way of sending Rust stuff to JavaScript just yet, so we don't care about it.
if (this.wallId == null) {
this.#sendJson({ login: "new", user: this.userId });
} else {
this.#sendJson({ login: "join", user: this.userId, wall: this.wallId });
}
let loginResponse = await this.#recvJson();
if (loginResponse.response == "loggedIn") {
this.wallId = loginResponse.wall;
this.wallInfo = loginResponse.wallInfo;
this.sessionId = loginResponse.sessionId;
console.info("logged in", this.wallId, this.sessionId);
console.info("wall info:", this.wallInfo);
} else {
this.#dispatchError(
loginResponse,
loginResponse.response,
"login failed; check error kind for details",
);
return;
}
}
async eventLoop() {
try {
while (true) {
let event = await listen([this.ws, "message"]);
if (typeof event.data == "string") {
await this.#processEvent(JSON.parse(event.data));
} else {
console.warn("binary event not yet supported");
}
}
} catch (error) {
this.#dispatchError(error, "protocol", `error in event loop: ${error.toString()}`);
}
}
async #processEvent(event) {
if (event.kind != null) {
this.dispatchEvent(
Object.assign(new Event("action"), {
sessionId: event.sessionId,
kind: event.kind,
}),
);
}
}
async reportCursor(x, y) {
this.#sendJson({
event: "cursor",
position: { x, y },
});
}
}
export async function newSession(userId, wallId) {
let session = new Session(userId);
await session.join(wallId);
return session;
}

18
static/throbber.js Normal file
View file

@ -0,0 +1,18 @@
export class Throbber extends HTMLElement {
constructor() {
super();
}
connectedCallback() {}
beginLoading() {
this.className = "loading";
}
showError(message) {
this.className = "error";
this.textContent = message;
}
}
customElements.define("rkgk-throbber", Throbber);

47
static/viewport.js Normal file
View file

@ -0,0 +1,47 @@
export class Viewport {
constructor() {
this.panX = 0;
this.panY = 0;
this.zoomLevel = 0;
}
get zoom() {
return Math.pow(2, this.zoomLevel * 0.25);
}
panAround(x, y) {
this.panX -= x / this.zoom;
this.panY -= y / this.zoom;
}
zoomIn(delta) {
this.zoomLevel += delta;
this.zoomLevel = Math.max(-16, Math.min(20, this.zoomLevel));
}
getVisibleRect(windowSize) {
let invZoom = 1 / this.zoom;
let width = windowSize.width * invZoom;
let height = windowSize.height * invZoom;
return {
x: this.panX - width / 2,
y: this.panY - height / 2,
width,
height,
};
}
toViewportSpace(x, y, windowSize) {
return [
(x - windowSize.width / 2) / this.zoom + this.panX,
(y - windowSize.height / 2) / this.zoom + this.panY,
];
}
toScreenSpace(x, y, windowSize) {
return [
(x - this.panX) * this.zoom + windowSize.width / 2,
(y - this.panY) * this.zoom + windowSize.height / 2,
];
}
}

36
static/wall.js Normal file
View file

@ -0,0 +1,36 @@
import { OnlineUsers } from "./online-users.js";
export class Chunk {
constructor(size) {
this.canvas = new OffscreenCanvas(size, size);
this.ctx = this.canvas.getContext("2d");
}
}
export class Wall {
#chunks = new Map();
onlineUsers = new OnlineUsers();
constructor(chunkSize) {
this.chunkSize = chunkSize;
}
static chunkKey(x, y) {
return `(${x},${y})`;
}
getChunk(x, y) {
return this.#chunks.get(Wall.chunkKey(x, y));
}
getOrCreateChunk(x, y) {
let key = Wall.chunkKey(x, y);
if (this.#chunks.has(key)) {
return this.#chunks.get(key);
} else {
let chunk = new Chunk(this.chunkSize);
this.#chunks.set(key, chunk);
return chunk;
}
}
}

36
static/welcome.js Normal file
View file

@ -0,0 +1,36 @@
import { isUserLoggedIn, registerUser } from "./session.js";
export class Welcome extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.dialog = this.querySelector("dialog[name='welcome-dialog']");
this.form = this.dialog.querySelector("form");
this.nicknameField = this.querySelector("input[name='nickname']");
this.registerButton = this.querySelector("button[name='register']");
this.registerProgress = this.querySelector("rkgk-throbber[name='register-progress']");
if (!isUserLoggedIn()) {
this.dialog.showModal();
// Require an account to use the website.
this.dialog.addEventListener("close", (event) => event.preventDefault());
this.form.addEventListener("submit", async (event) => {
event.preventDefault();
this.registerProgress.beginLoading();
let response = await registerUser(this.nicknameField.value);
if (response.status != "ok") {
this.registerProgress.showError(response.message);
}
this.dialog.close();
});
}
}
}
customElements.define("rkgk-welcome", Welcome);