diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..02c2073 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.wasm32-unknown-unknown] +rustflags = [ + "-C", "target-feature=+bulk-memory", + "-C", "target-feature=+simd128", +] diff --git a/.gitignore b/.gitignore index ea8c4bf..8e1d546 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +/database diff --git a/Cargo.lock b/Cargo.lock index dd3b395..d44e999 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index de31203..887bf0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/Justfile b/Justfile index 18e5720..0efb6ee 100644 --- a/Justfile +++ b/Justfile @@ -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}} diff --git a/crates/canvane/Cargo.toml b/crates/canvane/Cargo.toml deleted file mode 100644 index 6dc648c..0000000 --- a/crates/canvane/Cargo.toml +++ /dev/null @@ -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"] } diff --git a/crates/haku-wasm/src/lib.rs b/crates/haku-wasm/src/lib.rs index 15257b0..aa9444a 100644 --- a/crates/haku-wasm/src/lib.rs +++ b/crates/haku-wasm/src/lib.rs @@ -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, +struct PixmapLock { + pixmap: Option, } #[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,29 +355,46 @@ unsafe extern "C" fn haku_render_brush( } }; - let bitmap_locked = (*bitmap) - .bitmap - .take() - .expect("bitmap is already being rendered to"); + let mut render = |pixmap: *mut PixmapLock| { + let pixmap_locked = (*pixmap) + .pixmap + .take() + .expect("pixmap is already being rendered to"); - let mut renderer = Renderer::new( - bitmap_locked, - &RendererLimits { - bitmap_stack_capacity: instance.limits.bitmap_stack_capacity, - transform_stack_capacity: instance.limits.transform_stack_capacity, - }, - ); - match renderer.render(&instance.vm, scribble) { - Ok(()) => (), - Err(exn) => { - instance.exception = Some(exn); - return StatusCode::RenderException; + let mut renderer = Renderer::new( + pixmap_locked, + &RendererLimits { + 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) => { + instance.exception = Some(exn); + return StatusCode::RenderException; + } + } + + 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, } } - let bitmap_locked = renderer.finish(); - - (*bitmap).bitmap = Some(bitmap_locked); instance.vm.restore_image(&instance.vm_image); StatusCode::Ok diff --git a/crates/haku/Cargo.toml b/crates/haku/Cargo.toml index 12dbb16..2e2ac3f 100644 --- a/crates/haku/Cargo.toml +++ b/crates/haku/Cargo.toml @@ -4,3 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] +tiny-skia = { version = "0.11.4", default-features = false, features = ["no-std-float"] } diff --git a/crates/haku/src/compiler.rs b/crates/haku/src/compiler.rs index fd8b080..4a0e65c 100644 --- a/crates/haku/src/compiler.rs +++ b/crates/haku/src/compiler.rs @@ -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, }; diff --git a/crates/haku/src/render.rs b/crates/haku/src/render.rs index 8068c5d..06b360f 100644 --- a/crates/haku/src/render.rs +++ b/crates/haku/src/render.rs @@ -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, -} - -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, - transform_stack: Vec, + pixmap_stack: Vec, + transform_stack: Vec, } 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() +} diff --git a/crates/haku/src/sexp.rs b/crates/haku/src/sexp.rs index 5444688..96892b3 100644 --- a/crates/haku/src/sexp.rs +++ b/crates/haku/src/sexp.rs @@ -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(); } } diff --git a/crates/haku/src/system.rs b/crates/haku/src/system.rs index 15bf591..052cb5c 100644 --- a/crates/haku/src/system.rs +++ b/crates/haku/src/system.rs @@ -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 { + fn to_shape(value: Value, vm: &Vm) -> Option { 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 { + 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 { if args.num() != 3 { return Err( diff --git a/crates/haku/src/value.rs b/crates/haku/src/value.rs index 834b56c..59672b7 100644 --- a/crates/haku/src/value.rs +++ b/crates/haku/src/value.rs @@ -146,6 +146,7 @@ pub struct Closure { #[derive(Debug, Clone)] pub enum Shape { Point(Vec4), + Line(Vec4, Vec4), } #[derive(Debug, Clone)] diff --git a/crates/rkgk/Cargo.toml b/crates/rkgk/Cargo.toml new file mode 100644 index 0000000..551fb9d --- /dev/null +++ b/crates/rkgk/Cargo.toml @@ -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"] } diff --git a/crates/rkgk/src/api.rs b/crates/rkgk/src/api.rs new file mode 100644 index 0000000..54695f5 --- /dev/null +++ b/crates/rkgk/src/api.rs @@ -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(dbs: Arc) -> Router { + 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>, params: Json) -> impl IntoResponse { + if !(1..=32).contains(¶ms.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(), + }), + ), + } +} diff --git a/crates/rkgk/src/api/wall.rs b/crates/rkgk/src/api/wall.rs new file mode 100644 index 0000000..9026e56 --- /dev/null +++ b/crates/rkgk/src/api/wall.rs @@ -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>, ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(|ws| websocket(dbs, ws)) +} + +fn to_message(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 +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 { + Ok(ws + .recv() + .await + .ok_or_eyre("connection closed unexpectedly")??) +} + +async fn websocket(dbs: Arc, 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, 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(()) +} diff --git a/crates/rkgk/src/api/wall/schema.rs b/crates/rkgk/src/api/wall/schema.rs new file mode 100644 index 0000000..ace585b --- /dev/null +++ b/crates/rkgk/src/api/wall/schema.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WallInfo { + pub chunk_size: u32, + pub online: Vec, +} + +#[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, +} diff --git a/crates/rkgk/src/binary.rs b/crates/rkgk/src/binary.rs new file mode 100644 index 0000000..532b004 --- /dev/null +++ b/crates/rkgk/src/binary.rs @@ -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 { + if !self.slice.is_empty() { + Ok(self.slice[0]) + } else { + Err(OutOfData) + } + } + + pub fn read_u16(&mut self) -> Result { + 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 { + 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 {} diff --git a/crates/rkgk/src/config.rs b/crates/rkgk/src/config.rs new file mode 100644 index 0000000..520ffcf --- /dev/null +++ b/crates/rkgk/src/config.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +use crate::wall; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Config { + pub wall: wall::Settings, +} diff --git a/crates/rkgk/src/id.rs b/crates/rkgk/src/id.rs new file mode 100644 index 0000000..2985b39 --- /dev/null +++ b/crates/rkgk/src/id.rs @@ -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) +} diff --git a/crates/canvane/src/live_reload.rs b/crates/rkgk/src/live_reload.rs similarity index 100% rename from crates/canvane/src/live_reload.rs rename to crates/rkgk/src/live_reload.rs diff --git a/crates/rkgk/src/login.rs b/crates/rkgk/src/login.rs new file mode 100644 index 0000000..efece57 --- /dev/null +++ b/crates/rkgk/src/login.rs @@ -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 { + id::deserialize(s, "user_") + .map(Self) + .map_err(|_| InvalidUserId) + } +} + +impl Serialize for UserId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for UserId { + fn deserialize(deserializer: D) -> Result + 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 {} diff --git a/crates/rkgk/src/login/database.rs b/crates/rkgk/src/login/database.rs new file mode 100644 index 0000000..f3ed062 --- /dev/null +++ b/crates/rkgk/src/login/database.rs @@ -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, +} + +pub enum LoginStatus { + ValidUser, + UserDoesNotExist, +} + +#[derive(Debug, Clone)] +pub struct UserInfo { + pub nickname: String, +} + +enum Command { + NewUser { + nickname: String, + reply: oneshot::Sender>, + }, + LogIn { + user_id: UserId, + reply: oneshot::Sender, + }, + UserInfo { + user_id: UserId, + reply: oneshot::Sender>>, + }, +} + +impl Database { + pub async fn new_user(&self, nickname: String) -> eyre::Result { + 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 { + 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> { + 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 { + 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 }) +} diff --git a/crates/canvane/src/main.rs b/crates/rkgk/src/main.rs similarity index 57% rename from crates/canvane/src/main.rs rename to crates/rkgk/src/main.rs index f76adfd..f07dbeb 100644 --- a/crates/canvane/src/main.rs +++ b/crates/rkgk/src/main.rs @@ -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 { + 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:?}"), + } } diff --git a/crates/rkgk/src/schema.rs b/crates/rkgk/src/schema.rs new file mode 100644 index 0000000..282dc0d --- /dev/null +++ b/crates/rkgk/src/schema.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Vec2 { + pub x: f32, + pub y: f32, +} diff --git a/crates/rkgk/src/serialization.rs b/crates/rkgk/src/serialization.rs new file mode 100644 index 0000000..c78e52c --- /dev/null +++ b/crates/rkgk/src/serialization.rs @@ -0,0 +1,36 @@ +use std::{fmt::Display, marker::PhantomData, str::FromStr}; + +use serde::de::{Error, Visitor}; + +pub struct DeserializeFromStr { + expecting: &'static str, + _phantom: PhantomData, +} + +impl DeserializeFromStr { + pub fn new(expecting: &'static str) -> Self { + Self { + expecting, + _phantom: PhantomData, + } + } +} + +impl<'de, T> Visitor<'de> for DeserializeFromStr +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(self, v: &str) -> Result + where + E: serde::de::Error, + { + T::from_str(v).map_err(|e| Error::custom(e)) + } +} diff --git a/crates/rkgk/src/wall.rs b/crates/rkgk/src/wall.rs new file mode 100644 index 0000000..4670c46 --- /dev/null +++ b/crates/rkgk/src/wall.rs @@ -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 { + id::deserialize(s, "wall_") + .map(WallId) + .map_err(|_| InvalidWallId) + } +} + +impl Serialize for WallId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for WallId { + fn deserialize(deserializer: D) -> Result + 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>>, + + sessions: DashMap, + session_id_counter: AtomicU32, + + event_sender: broadcast::Sender, +} + +pub struct Session { + pub user_id: UserId, + pub cursor: Option, +} + +pub struct SessionHandle { + pub wall: Weak, + pub event_receiver: broadcast::Receiver, + 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 }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Online { + pub session_id: SessionId, + pub user_id: UserId, + pub cursor: Option, +} + +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>> { + self.chunks.get(&at).map(|chunk| Arc::clone(&chunk)) + } + + pub fn get_or_create_chunk(&self, at: (i32, i32)) -> Arc> { + Arc::clone( + &self + .chunks + .entry(at) + .or_insert_with(|| Arc::new(Mutex::new(Chunk::new(self.settings.chunk_size)))), + ) + } + + pub fn join(self: &Arc, session: Session) -> Result { + 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 { + 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, +} diff --git a/crates/rkgk/src/wall/broker.rs b/crates/rkgk/src/wall/broker.rs new file mode 100644 index 0000000..a7bd3ac --- /dev/null +++ b/crates/rkgk/src/wall/broker.rs @@ -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, + rng: Mutex, +} + +struct OpenWall { + wall: Arc, +} + +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 { + Arc::clone( + &self + .open_walls + .entry(wall_id) + .or_insert_with(|| OpenWall { + wall: Arc::new(Wall::new(self.wall_settings)), + }) + .wall, + ) + } +} diff --git a/rkgk.toml b/rkgk.toml new file mode 100644 index 0000000..9bfd1af --- /dev/null +++ b/rkgk.toml @@ -0,0 +1,5 @@ +[wall] +max_chunks = 65536 +max_sessions = 128 +chunk_size = 168 + diff --git a/static/brush-editor.js b/static/brush-editor.js new file mode 100644 index 0000000..40baf88 --- /dev/null +++ b/static/brush-editor.js @@ -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); diff --git a/static/canvas-renderer.js b/static/canvas-renderer.js new file mode 100644 index 0000000..a36a4e2 --- /dev/null +++ b/static/canvas-renderer.js @@ -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); diff --git a/static/font/FiraCode-VariableFont_wght.ttf b/static/font/FiraCode-VariableFont_wght.ttf new file mode 100644 index 0000000..5655ed5 Binary files /dev/null and b/static/font/FiraCode-VariableFont_wght.ttf differ diff --git a/static/font/FiraSans-Black.ttf b/static/font/FiraSans-Black.ttf new file mode 100644 index 0000000..113cd3b Binary files /dev/null and b/static/font/FiraSans-Black.ttf differ diff --git a/static/font/FiraSans-BlackItalic.ttf b/static/font/FiraSans-BlackItalic.ttf new file mode 100644 index 0000000..1c49fb2 Binary files /dev/null and b/static/font/FiraSans-BlackItalic.ttf differ diff --git a/static/font/FiraSans-Bold.ttf b/static/font/FiraSans-Bold.ttf new file mode 100644 index 0000000..e3593fb Binary files /dev/null and b/static/font/FiraSans-Bold.ttf differ diff --git a/static/font/FiraSans-BoldItalic.ttf b/static/font/FiraSans-BoldItalic.ttf new file mode 100644 index 0000000..305b0b8 Binary files /dev/null and b/static/font/FiraSans-BoldItalic.ttf differ diff --git a/static/font/FiraSans-ExtraBold.ttf b/static/font/FiraSans-ExtraBold.ttf new file mode 100644 index 0000000..83744c1 Binary files /dev/null and b/static/font/FiraSans-ExtraBold.ttf differ diff --git a/static/font/FiraSans-ExtraBoldItalic.ttf b/static/font/FiraSans-ExtraBoldItalic.ttf new file mode 100644 index 0000000..54bcaca Binary files /dev/null and b/static/font/FiraSans-ExtraBoldItalic.ttf differ diff --git a/static/font/FiraSans-ExtraLight.ttf b/static/font/FiraSans-ExtraLight.ttf new file mode 100644 index 0000000..2d4e331 Binary files /dev/null and b/static/font/FiraSans-ExtraLight.ttf differ diff --git a/static/font/FiraSans-ExtraLightItalic.ttf b/static/font/FiraSans-ExtraLightItalic.ttf new file mode 100644 index 0000000..ef666ad Binary files /dev/null and b/static/font/FiraSans-ExtraLightItalic.ttf differ diff --git a/static/font/FiraSans-Italic.ttf b/static/font/FiraSans-Italic.ttf new file mode 100644 index 0000000..27d32ed Binary files /dev/null and b/static/font/FiraSans-Italic.ttf differ diff --git a/static/font/FiraSans-Light.ttf b/static/font/FiraSans-Light.ttf new file mode 100644 index 0000000..663d1de Binary files /dev/null and b/static/font/FiraSans-Light.ttf differ diff --git a/static/font/FiraSans-LightItalic.ttf b/static/font/FiraSans-LightItalic.ttf new file mode 100644 index 0000000..d1b1fc5 Binary files /dev/null and b/static/font/FiraSans-LightItalic.ttf differ diff --git a/static/font/FiraSans-Medium.ttf b/static/font/FiraSans-Medium.ttf new file mode 100644 index 0000000..001ebe7 Binary files /dev/null and b/static/font/FiraSans-Medium.ttf differ diff --git a/static/font/FiraSans-MediumItalic.ttf b/static/font/FiraSans-MediumItalic.ttf new file mode 100644 index 0000000..b7640be Binary files /dev/null and b/static/font/FiraSans-MediumItalic.ttf differ diff --git a/static/font/FiraSans-Regular.ttf b/static/font/FiraSans-Regular.ttf new file mode 100644 index 0000000..6f80647 Binary files /dev/null and b/static/font/FiraSans-Regular.ttf differ diff --git a/static/font/FiraSans-SemiBold.ttf b/static/font/FiraSans-SemiBold.ttf new file mode 100644 index 0000000..0c93b7e Binary files /dev/null and b/static/font/FiraSans-SemiBold.ttf differ diff --git a/static/font/FiraSans-SemiBoldItalic.ttf b/static/font/FiraSans-SemiBoldItalic.ttf new file mode 100644 index 0000000..e1a2989 Binary files /dev/null and b/static/font/FiraSans-SemiBoldItalic.ttf differ diff --git a/static/font/FiraSans-Thin.ttf b/static/font/FiraSans-Thin.ttf new file mode 100644 index 0000000..c925f94 Binary files /dev/null and b/static/font/FiraSans-Thin.ttf differ diff --git a/static/font/FiraSans-ThinItalic.ttf b/static/font/FiraSans-ThinItalic.ttf new file mode 100644 index 0000000..dd39092 Binary files /dev/null and b/static/font/FiraSans-ThinItalic.ttf differ diff --git a/static/framework.js b/static/framework.js new file mode 100644 index 0000000..13f3883 --- /dev/null +++ b/static/framework.js @@ -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); + } + }; +} diff --git a/static/haku.js b/static/haku.js new file mode 100644 index 0000000..144233b --- /dev/null +++ b/static/haku.js @@ -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" }; + } +} diff --git a/static/index.css b/static/index.css new file mode 100644 index 0000000..f732791 --- /dev/null +++ b/static/index.css @@ -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; + } + } +} diff --git a/static/index.html b/static/index.html index 84c237c..5860f18 100644 --- a/static/index.html +++ b/static/index.html @@ -2,17 +2,53 @@ - canvane - + rakugaki + + + + + + + + + + + + + +
- Please enable JavaScript -
- -

+ + + + + + +
+

+ My name is + +

+

This name will be visible to any friends drawing along with you, so choose something recognizable!
+ Keep in mind you can always change it later.

+ +
+ + +
+
+
+
diff --git a/static/index.js b/static/index.js index 5855207..258d3df 100644 --- a/static/index.js +++ b/static/index.js @@ -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); -} + 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); + } + }); -class Panic extends Error { - name = "Panic"; -} + let compileBrush = () => haku.setBrush(brushEditor.code); + compileBrush(); + brushEditor.addEventListener(".codeChanged", () => compileBrush()); -panicImpl = (length, pMessage) => { - throw new Panic(readString(length, pMessage)); -}; + let reportCursor = debounce(1000 / 60, (x, y) => session.reportCursor(x, y)); + canvasRenderer.addEventListener(".cursor", async (event) => { + reportCursor(event.x, event.y); + }); -logImpl = (level, length, pMessage) => { - console[level](readString(length, pMessage)); -}; + canvasRenderer.addEventListener(".paint", async (event) => { + painter.renderBrush(haku); + let imageBitmap = await painter.createImageBitmap(); -w.haku_init_logging(); + 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); + } + } + imageBitmap.close(); + }); -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}`); - } - - if (w.haku_num_diagnostics(pBrush) > 0) { - deallocEverything(); - return; - } - - 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; - } - - 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(); +})(); diff --git a/static/online-users.js b/static/online-users.js new file mode 100644 index 0000000..e364dac --- /dev/null +++ b/static/online-users.js @@ -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); + } +} diff --git a/static/painter.js b/static/painter.js new file mode 100644 index 0000000..1c25c0e --- /dev/null +++ b/static/painter.js @@ -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; + } +} diff --git a/static/reticle-renderer.js b/static/reticle-renderer.js new file mode 100644 index 0000000..a4d991f --- /dev/null +++ b/static/reticle-renderer.js @@ -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); diff --git a/static/session.js b/static/session.js new file mode 100644 index 0000000..d06faca --- /dev/null +++ b/static/session.js @@ -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; +} diff --git a/static/throbber.js b/static/throbber.js new file mode 100644 index 0000000..7bcdd54 --- /dev/null +++ b/static/throbber.js @@ -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); diff --git a/static/viewport.js b/static/viewport.js new file mode 100644 index 0000000..253b7c9 --- /dev/null +++ b/static/viewport.js @@ -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, + ]; + } +} diff --git a/static/wall.js b/static/wall.js new file mode 100644 index 0000000..f2f2dec --- /dev/null +++ b/static/wall.js @@ -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; + } + } +} diff --git a/static/welcome.js b/static/welcome.js new file mode 100644 index 0000000..663d144 --- /dev/null +++ b/static/welcome.js @@ -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);