├── client ├── .prettierignore ├── .env.example ├── assets │ └── neko.png ├── .prettierrc ├── renderer │ ├── +onRenderClient.ts │ ├── +onRenderHtml.ts │ └── page.d.ts ├── src │ ├── vite-env.d.ts │ ├── components │ │ ├── Loading.tsx │ │ ├── Spinner.tsx │ │ ├── CheckboxGrid.tsx │ │ ├── Neko.tsx │ │ └── Header.tsx │ ├── style │ │ ├── markdown.css │ │ ├── fonts.css │ │ ├── loading.css │ │ ├── color-schemes.css │ │ └── style.css │ ├── App.tsx │ ├── bitmap.ts │ ├── utils.ts │ └── client.ts ├── eslint.config.js ├── tsconfig.json ├── pages │ ├── changelog │ │ └── +Page.ts │ ├── proto-docs │ │ └── +Page.ts │ └── index │ │ └── +Page.ts ├── vite.config.js ├── package.json └── .gitignore ├── CHANGELOG.md ├── server ├── src │ ├── lib.rs │ ├── common.rs │ ├── main.rs │ ├── config.rs │ ├── bitmap.rs │ ├── protocol.rs │ └── server.rs ├── config.toml.example ├── .gitignore ├── Cargo.toml └── Cargo.lock ├── .editorconfig ├── LICENSE ├── README.md └── PROTOCOL.md /client/.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | pnpm-lock.yaml -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | VITE_WEBSOCKET_URL=ws://localhost:2253 2 | -------------------------------------------------------------------------------- /client/assets/neko.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alula/bitmap/HEAD/client/assets/neko.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2024-09-01 (Protocol 1.0) 2 | 3 | Initial release of the "bitmap" project. 4 | -------------------------------------------------------------------------------- /server/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod bitmap; 2 | pub mod common; 3 | pub mod config; 4 | pub mod protocol; 5 | pub mod server; -------------------------------------------------------------------------------- /server/config.toml.example: -------------------------------------------------------------------------------- 1 | # bind_address = "[::1]:2253" 2 | # parse_proxy_headers = true 3 | # ws_permessage_deflate = false 4 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": true, 4 | "semi": true, 5 | "singleQuote": false, 6 | "jsxSingleQuote": false, 7 | "printWidth": 120 8 | } 9 | -------------------------------------------------------------------------------- /client/renderer/+onRenderClient.ts: -------------------------------------------------------------------------------- 1 | import { PageContext } from "vike/types"; 2 | 3 | export async function onRenderClient(pageContext: PageContext) { 4 | pageContext.Page.renderClient?.(); 5 | } 6 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_WEBSOCKET_URL: string; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/common.rs: -------------------------------------------------------------------------------- 1 | pub type PResult = Result>; 2 | 3 | pub const CONFIG_PATH: &str = "config.toml"; 4 | pub const STATE_PATH: &str = "state.bin"; 5 | pub const METRICS_PATH: &str = "metrics.json"; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_size = 4 7 | indent_style = tab 8 | 9 | [*.{json,yml}] 10 | charset = utf-8 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.rs] 15 | charset = utf-8 16 | indent_style = space 17 | indent_size = 4 18 | -------------------------------------------------------------------------------- /client/renderer/+onRenderHtml.ts: -------------------------------------------------------------------------------- 1 | import { dangerouslySkipEscape, escapeInject } from "vike/server"; 2 | 3 | export async function onRenderHtml(pageContext: Vike.PageContext) { 4 | const { Page } = pageContext; 5 | const pageHtml = Page.renderHTML(); 6 | return escapeInject`${dangerouslySkipEscape(pageHtml)}`; 7 | } 8 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | export default [ 6 | { files: ["**/*.{js,mjs,cjs,ts,tsx}"] }, 7 | { languageOptions: { globals: globals.browser } }, 8 | pluginJs.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | ]; 11 | -------------------------------------------------------------------------------- /client/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "inferno"; 2 | 3 | export class LoadingSpinner extends Component { 4 | render() { 5 | return ( 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/renderer/page.d.ts: -------------------------------------------------------------------------------- 1 | import type { PageContext } from "vike"; 2 | 3 | interface IPage { 4 | renderHTML: () => string; 5 | renderClient?: () => void; 6 | } 7 | 8 | declare global { 9 | namespace Vike { 10 | interface PageContext { 11 | Page: IPage; 12 | } 13 | 14 | interface Config { 15 | Page: IPage; 16 | } 17 | } 18 | } 19 | 20 | type PageContext_ = PageContext; 21 | 22 | declare module "*.md" { 23 | const content: string; 24 | export const html: string; 25 | export default content; 26 | } 27 | -------------------------------------------------------------------------------- /server/src/main.rs: -------------------------------------------------------------------------------- 1 | use checkboxes_server::{common::PResult, config::Settings, server::BitmapServer}; 2 | 3 | #[tokio::main] 4 | async fn main() -> PResult<()> { 5 | let log_level = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()); 6 | pretty_env_logger::formatted_timed_builder() 7 | .filter_level(log_level.parse()?) 8 | .try_init()?; 9 | 10 | log::info!("Starting server"); 11 | let settings = Settings::load_from_file_and_env()?; 12 | 13 | BitmapServer::new(settings).run().await?; 14 | 15 | Ok(()) 16 | } 17 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | # Created by https://www.toptal.com/developers/gitignore/api/rust 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=rust 4 | 5 | ### Rust ### 6 | # Generated by Cargo 7 | # will have compiled files and executables 8 | debug/ 9 | target/ 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | # MSVC Windows builds of rustc generate these, which store debugging information 15 | *.pdb 16 | 17 | # End of https://www.toptal.com/developers/gitignore/api/rust 18 | config.toml 19 | 20 | state.bin 21 | metrics.json 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Alula 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 1 billion checkboxes 2 | 3 | https://bitmap.alula.me/ 4 | 5 | ## Dev setup 6 | 7 | Client: 8 | 9 | ```bash 10 | cd client 11 | cp .env.example .env 12 | pnpm install 13 | pnpm dev 14 | ``` 15 | 16 | Server: 17 | 18 | ```bash 19 | cd server 20 | cp config.example.toml config.toml 21 | cargo run 22 | ``` 23 | 24 | ## Release build 25 | 26 | Client: 27 | 28 | ```bash 29 | cd client 30 | pnpm build 31 | # Static site is in `dist/client` directory 32 | ``` 33 | 34 | Server: 35 | 36 | ```bash 37 | cd server 38 | cargo build --release 39 | # Compiled binary is `target/release/checkboxes-server` 40 | ``` 41 | 42 | 43 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "baseUrl": "./", 22 | "typeRoots": ["./node_modules/@types", "./renderer/page.d.ts"] 23 | }, 24 | "include": ["src", "pages", "renderer", "../PROTOCOL.md"] 25 | } 26 | -------------------------------------------------------------------------------- /client/pages/changelog/+Page.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error broken md plugin 2 | import { html } from "../../../CHANGELOG.md"; 3 | 4 | import { applyThemeFromStorage } from "../../src/utils"; 5 | import "../../src/style/style.css"; 6 | import "../../src/style/markdown.css"; 7 | 8 | function renderHTML() { 9 | return ` 10 | 11 | 12 | 13 | 14 | 15 | 16 | Changelog 17 | 18 | 19 |
20 | ${html} 21 |
22 | 23 | 24 | `; 25 | } 26 | 27 | function renderClient() { 28 | applyThemeFromStorage(); 29 | } 30 | 31 | export default { 32 | renderHTML, 33 | renderClient, 34 | }; 35 | -------------------------------------------------------------------------------- /client/pages/proto-docs/+Page.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error broken md plugin 2 | import { html } from "../../../PROTOCOL.md"; 3 | 4 | import { applyThemeFromStorage } from "../../src/utils"; 5 | import "../../src/style/style.css"; 6 | import "../../src/style/markdown.css"; 7 | 8 | function renderHTML() { 9 | return ` 10 | 11 | 12 | 13 | 14 | 15 | 16 | Protocol Documentation 17 | 18 | 19 |
20 | ${html} 21 |
22 | 23 | 24 | `; 25 | } 26 | 27 | function renderClient() { 28 | applyThemeFromStorage(); 29 | } 30 | 31 | export default { 32 | renderHTML, 33 | renderClient, 34 | }; 35 | -------------------------------------------------------------------------------- /client/src/style/markdown.css: -------------------------------------------------------------------------------- 1 | #markdown { 2 | margin: 0 auto; 3 | max-width: 800px; 4 | } 5 | 6 | #markdown pre { 7 | background-color: var(--secondary-1); 8 | color: var(--text); 9 | padding: 1rem; 10 | border-radius: 0.5rem; 11 | box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); 12 | white-space: pre-wrap; 13 | } 14 | 15 | #markdown pre code { 16 | font-size: 0.9rem; 17 | } 18 | 19 | #markdown code { 20 | background-color: var(--secondary-1); 21 | } 22 | 23 | .token.keyword { 24 | color: var(--code-keyword); 25 | } 26 | 27 | .token.operator { 28 | color: var(--code-operator); 29 | } 30 | 31 | .token.punctuation { 32 | color: var(--code-punctuation); 33 | } 34 | 35 | .token.class-name { 36 | color: var(--code-class-name); 37 | } 38 | 39 | .token.number { 40 | color: var(--code-number); 41 | } 42 | 43 | .token.comment { 44 | color: var(--code-comment); 45 | } 46 | -------------------------------------------------------------------------------- /client/pages/index/+Page.ts: -------------------------------------------------------------------------------- 1 | import { renderApp } from "../../src/App"; 2 | import "../../src/style/style.css"; 3 | 4 | function renderHTML() { 5 | return ` 6 | 7 | 8 | 9 | 10 | 11 | 12 | 1 billion checkboxes 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | `; 23 | } 24 | 25 | function renderClient() { 26 | renderApp(); 27 | } 28 | 29 | export default { 30 | renderHTML, 31 | renderClient, 32 | }; 33 | -------------------------------------------------------------------------------- /client/src/style/fonts.css: -------------------------------------------------------------------------------- 1 | /* outfit-latin-wght-normal */ 2 | @font-face { 3 | font-family: "Outfit Variable"; 4 | font-style: normal; 5 | font-display: swap; 6 | font-weight: 100 900; 7 | src: url(@fontsource-variable/outfit/files/outfit-latin-wght-normal.woff2) format("woff2-variations"); 8 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, 9 | U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 10 | } 11 | 12 | /* outfit-latin-ext-wght-normal */ 13 | @font-face { 14 | font-family: "Outfit Variable"; 15 | font-style: normal; 16 | font-display: swap; 17 | font-weight: 100 900; 18 | src: url(@fontsource-variable/outfit/files/outfit-latin-ext-wght-normal.woff2) format("woff2-variations"); 19 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, 20 | U+2113, U+2C60-2C7F, U+A720-A7FF; 21 | } 22 | -------------------------------------------------------------------------------- /server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "checkboxes-server" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | bitvec = "1.0.1" 8 | config = { version = "0.14", default-features = false, features = ["toml"] } 9 | futures-util = "0.3" 10 | httparse = { version = "1.3", default-features = false, features = ["std"] } 11 | log = { version = "0.4", features = ["release_max_level_info"] } 12 | pretty_env_logger = "0.5" 13 | serde = { version = "1.0", features = ["derive"] } 14 | serde_json = "1.0" 15 | # soketto = { version = "0.8", features = ["deflate"] } 16 | soketto = { git = "https://github.com/alula/soketto.git", rev = "c51864b69445a38dbc700547b0c7185d3211fcf3", features = ["deflate"] } 17 | signal-hook = "0.3" 18 | signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] } 19 | tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } 20 | tokio-stream = { version = "0.1", features = ["net"] } 21 | tokio-util = { version = "0.7", features = ["compat"] } 22 | zerocopy = "0.7" 23 | zerocopy-derive = "0.7" 24 | -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import mdPlugin, { Mode } from "vite-plugin-markdown"; 3 | import babel from "vite-plugin-babel"; 4 | import vike from "vike/plugin"; 5 | import markdownIt from "markdown-it"; 6 | import markdownItPrism from "markdown-it-prism"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | build: { 11 | target: ["es2020", "edge88", "firefox78", "chrome87", "safari13"], 12 | minify: "terser", 13 | }, 14 | plugins: [ 15 | mdPlugin({ 16 | mode: [Mode.HTML], 17 | markdownIt: markdownIt({ html: true }).use(markdownItPrism), 18 | }), 19 | babel({ 20 | babelConfig: { 21 | babelrc: false, 22 | configFile: false, 23 | 24 | sourceMaps: true, 25 | 26 | parserOpts: { 27 | plugins: [ 28 | "importMeta", 29 | "topLevelAwait", 30 | "classProperties", 31 | "classPrivateProperties", 32 | "classPrivateMethods", 33 | "jsx", 34 | "typescript", 35 | ], 36 | sourceType: "module", 37 | allowAwaitOutsideFunction: true, 38 | }, 39 | 40 | generatorOpts: { 41 | decoratorsBeforeExport: true, 42 | }, 43 | 44 | plugins: ["@babel/plugin-transform-react-jsx", "babel-plugin-inferno"], 45 | }, 46 | exclude: "node_modules", 47 | filter: /\.[tj]sx?|html$/, 48 | }), 49 | vike({ 50 | prerender: true, 51 | }), 52 | ], 53 | }); 54 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alula/bitmap-client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "vite build", 10 | "serve": "vite preview", 11 | "lint": "tsc --noEmit && eslint src --fix", 12 | "format": "prettier --write ." 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "GPL-2.0-or-later", 17 | "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1", 18 | "dependencies": { 19 | "@fontsource-variable/outfit": "^5.0.14", 20 | "@tanstack/virtual-core": "^3.10.6", 21 | "inferno": "^8.2.3" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.25.2", 25 | "@babel/generator": "^7.25.6", 26 | "@babel/parser": "^7.25.6", 27 | "@babel/plugin-transform-react-jsx": "^7.25.2", 28 | "@babel/types": "^7", 29 | "@eslint/js": "^9.9.1", 30 | "@types/node": "^22.5.2", 31 | "babel-plugin-inferno": "^6.7.2", 32 | "eslint": "^9.9.1", 33 | "globals": "^15.9.0", 34 | "markdown-it": "^14.1.0", 35 | "markdown-it-prism": "^2.3.0", 36 | "prettier": "^3.3.3", 37 | "terser": "^5.31.6", 38 | "typescript": "^5.5.4", 39 | "typescript-eslint": "^8.3.0", 40 | "vike": "^0.4.193", 41 | "vite": "^5.4.2", 42 | "vite-plugin-babel": "^1.2.0", 43 | "vite-plugin-markdown": "^3.0.0-1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/src/config.rs: -------------------------------------------------------------------------------- 1 | use config::Config; 2 | use serde::Deserialize; 3 | 4 | use crate::common::{PResult, CONFIG_PATH}; 5 | 6 | #[derive(Debug, Deserialize)] 7 | pub struct Settings { 8 | #[serde(default = "Settings::default_bind_address")] 9 | /// The address to bind the server to. 10 | pub bind_address: String, 11 | 12 | /// Use CF-Connecting-IP and X-Forwarded-For headers to determine the client's IP address. 13 | #[serde(default = "Settings::default_parse_proxy_headers")] 14 | pub parse_proxy_headers: bool, 15 | 16 | /// Enable permessage-deflate WebSocket extension. 17 | /// Currently disabled by default, due to https://github.com/paritytech/soketto/issues/49 18 | #[serde(default)] 19 | pub ws_permessage_deflate: bool, 20 | } 21 | 22 | impl Settings { 23 | pub fn load_from_file_and_env() -> PResult { 24 | let file = config::File::with_name(CONFIG_PATH) 25 | .format(config::FileFormat::Toml) 26 | .required(false); 27 | 28 | let settings = Config::builder() 29 | .add_source(file) 30 | .add_source(config::Environment::with_prefix("CB_")) 31 | .build()?; 32 | 33 | let settings = settings.try_deserialize::()?; 34 | settings.sanity_check()?; 35 | Ok(settings) 36 | } 37 | 38 | fn sanity_check(&self) -> PResult<()> { 39 | Ok(()) 40 | } 41 | 42 | fn default_bind_address() -> String { 43 | "[::1]:2253".to_string() 44 | } 45 | 46 | fn default_parse_proxy_headers() -> bool { 47 | true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Component, render } from "inferno"; 2 | import { CheckboxGrid } from "./components/CheckboxGrid"; 3 | import { BitmapClient } from "./client"; 4 | import { Header } from "./components/Header"; 5 | import { applyThemeFromStorage, debug } from "./utils"; 6 | import { LoadingSpinner } from "./components/Loading"; 7 | import { NekoComponent } from "./components/Neko"; 8 | 9 | interface AppState { 10 | loading: boolean; 11 | } 12 | 13 | export class App extends Component { 14 | client: BitmapClient; 15 | 16 | constructor(props: object) { 17 | super(props); 18 | 19 | this.state = { 20 | loading: true, 21 | }; 22 | 23 | this.client = new BitmapClient(); 24 | this.client.loadingCallback = (loading: boolean) => this.setState({ loading }); 25 | } 26 | 27 | #onDebugChange = () => { 28 | this.forceUpdate(); 29 | }; 30 | 31 | componentDidMount(): void { 32 | debug.subscribe(this.#onDebugChange); 33 | } 34 | 35 | componentWillUnmount(): void { 36 | debug.unsubscribe(this.#onDebugChange); 37 | } 38 | 39 | render(_props: object, { loading }: AppState) { 40 | return ( 41 |
42 |
43 | 44 | {loading && ( 45 |
46 | 47 | Connecting 48 |
49 | )} 50 | {debug.value && } 51 |
52 | ); 53 | } 54 | } 55 | 56 | export const renderApp = () => { 57 | applyThemeFromStorage(); 58 | render(, document.getElementById("app")); 59 | }; 60 | -------------------------------------------------------------------------------- /client/src/style/loading.css: -------------------------------------------------------------------------------- 1 | .lds-default, 2 | .lds-default div { 3 | box-sizing: border-box; 4 | } 5 | .lds-default { 6 | display: inline-block; 7 | position: relative; 8 | width: 80px; 9 | height: 80px; 10 | } 11 | .lds-default div { 12 | position: absolute; 13 | width: 6.4px; 14 | height: 6.4px; 15 | background: currentColor; 16 | border-radius: 50%; 17 | animation: lds-default 1.2s linear infinite; 18 | } 19 | .lds-default div:nth-child(1) { 20 | animation-delay: 0s; 21 | top: 36.8px; 22 | left: 66.24px; 23 | } 24 | .lds-default div:nth-child(2) { 25 | animation-delay: -0.1s; 26 | top: 22.08px; 27 | left: 62.29579px; 28 | } 29 | .lds-default div:nth-child(3) { 30 | animation-delay: -0.2s; 31 | top: 11.30421px; 32 | left: 51.52px; 33 | } 34 | .lds-default div:nth-child(4) { 35 | animation-delay: -0.3s; 36 | top: 7.36px; 37 | left: 36.8px; 38 | } 39 | .lds-default div:nth-child(5) { 40 | animation-delay: -0.4s; 41 | top: 11.30421px; 42 | left: 22.08px; 43 | } 44 | .lds-default div:nth-child(6) { 45 | animation-delay: -0.5s; 46 | top: 22.08px; 47 | left: 11.30421px; 48 | } 49 | .lds-default div:nth-child(7) { 50 | animation-delay: -0.6s; 51 | top: 36.8px; 52 | left: 7.36px; 53 | } 54 | .lds-default div:nth-child(8) { 55 | animation-delay: -0.7s; 56 | top: 51.52px; 57 | left: 11.30421px; 58 | } 59 | .lds-default div:nth-child(9) { 60 | animation-delay: -0.8s; 61 | top: 62.29579px; 62 | left: 22.08px; 63 | } 64 | .lds-default div:nth-child(10) { 65 | animation-delay: -0.9s; 66 | top: 66.24px; 67 | left: 36.8px; 68 | } 69 | .lds-default div:nth-child(11) { 70 | animation-delay: -1s; 71 | top: 62.29579px; 72 | left: 51.52px; 73 | } 74 | .lds-default div:nth-child(12) { 75 | animation-delay: -1.1s; 76 | top: 51.52px; 77 | left: 62.29579px; 78 | } 79 | @keyframes lds-default { 80 | 0%, 81 | 20%, 82 | 80%, 83 | 100% { 84 | transform: scale(1); 85 | } 86 | 50% { 87 | transform: scale(1.5); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /client/src/style/color-schemes.css: -------------------------------------------------------------------------------- 1 | .style-ctp-mocha { 2 | --background: #1e1e2e; 3 | --secondary-1: #181825; 4 | --secondary-2: #11111b; 5 | --surface-0: #313244; 6 | --surface-1: #45475a; 7 | --surface-2: #585b70; 8 | --text: #cdd6f4; 9 | --subtext-1: #bac2de; 10 | --subtext-0: #a6adc8; 11 | --active: #fab387; 12 | --blue: #89b4fa; 13 | --red: #f38ba8; 14 | 15 | --code-keyword: #cba6f7; 16 | --code-operator: #89dceb; 17 | --code-class-name: #f9e2af; 18 | --code-number: #fab387; 19 | --code-comment: #9399b2; 20 | } 21 | 22 | .style-ctp-latte { 23 | --background: #eff1f5; 24 | --secondary-1: #e6e9ef; 25 | --secondary-2: #dce0e8; 26 | --surface-0: #ccd0da; 27 | --surface-1: #bcc0cc; 28 | --surface-2: #acb0be; 29 | --text: #4c4f69; 30 | --subtext-1: #5c5f77; 31 | --subtext-0: #6c6f85; 32 | --active: #fe640b; 33 | --blue: #1e66f5; 34 | --red: #d20f39; 35 | 36 | --code-keyword: #8839ef; 37 | --code-operator: #04a5e5; 38 | --code-class-name: #df8e1d; 39 | --code-number: #fe640b; 40 | --code-comment: #7c7f93; 41 | } 42 | 43 | .style-ctp-monochrome { 44 | --background: #cdcbcb; 45 | --secondary-1: #dbdbdb; 46 | --secondary-2: #dce0e8; 47 | --surface-0: #ccd0da; 48 | --surface-1: #bcc0cc; 49 | --surface-2: #acb0be; 50 | --text: #292929; 51 | --subtext-1: #5c5f77; 52 | --subtext-0: #6c6f85; 53 | --active: #1a1a1a; 54 | --blue: #1e66f5; 55 | --red: #d20f39; 56 | 57 | --code-keyword: #8839ef; 58 | --code-operator: #04a5e5; 59 | --code-class-name: #df8e1d; 60 | --code-number: #fe640b; 61 | --code-comment: #7c7f93; 62 | } 63 | 64 | .style-ctp-amoled { 65 | --background: #000000; 66 | --secondary-1: #0f0f0f; 67 | --secondary-2: #000000; 68 | --surface-0: #1a1a1a; 69 | --surface-1: #262626; 70 | --surface-2: #666666; 71 | --text: #ffffff; 72 | --subtext-1: #d6d6d6; 73 | --subtext-0: #808080; 74 | --active: #ffffff; 75 | --blue: #1e66f5; 76 | --red: #d20f39; 77 | 78 | --code-keyword: #8839ef; 79 | --code-operator: #04a5e5; 80 | --code-class-name: #df8e1d; 81 | --code-number: #fe640b; 82 | --code-comment: #7c7f93; 83 | } 84 | -------------------------------------------------------------------------------- /client/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Component, createRef, RefObject } from "inferno"; 2 | 3 | interface SpinnerProps { 4 | value: number; 5 | onChange: (value: number) => void; 6 | min?: number; 7 | max?: number; 8 | } 9 | 10 | interface SpinnerState { 11 | value: number; 12 | } 13 | 14 | export class Spinner extends Component { 15 | private inputRef: RefObject; 16 | 17 | constructor(props: SpinnerProps) { 18 | super(props); 19 | 20 | this.inputRef = createRef(); 21 | this.state = { 22 | value: props.value, 23 | }; 24 | } 25 | 26 | setValue(value: number) { 27 | if (isNaN(value)) { 28 | value = 0; 29 | } 30 | 31 | if (this.props.min !== undefined && value < this.props.min) { 32 | value = this.props.min; 33 | } 34 | if (this.props.max !== undefined && value > this.props.max) { 35 | value = this.props.max; 36 | } 37 | 38 | const el = this.inputRef.current; 39 | if (el) el.value = value.toString(); 40 | 41 | this.setState({ value }); 42 | this.props.onChange(value); 43 | } 44 | 45 | componentWillReceiveProps(nextProps: SpinnerProps): void { 46 | if (nextProps.value !== this.props.value) { 47 | this.setState({ value: nextProps.value }); 48 | } 49 | } 50 | 51 | render(props: SpinnerProps, state: SpinnerState) { 52 | return ( 53 |
54 | 61 | this.setValue(parseInt(e.currentTarget.value))} 68 | /> 69 | 76 |
77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /client/src/bitmap.ts: -------------------------------------------------------------------------------- 1 | // u8 -> number of 1s LUT 2 | const bitCountLUT = [...Array(256)].map((r = 0, a) => { 3 | for (; a; a >>= 1) r += 1 & a; 4 | return r; 5 | }); 6 | 7 | function countOnes(array: Uint8Array) { 8 | let count = 0; 9 | for (let i = 0; i < array.length; i++) { 10 | count += bitCountLUT[array[i]]; 11 | } 12 | return count; 13 | } 14 | 15 | type BitmapChangeCallback = (min: number, max: number) => void; 16 | 17 | export class Bitmap { 18 | checkedCount = 0; 19 | 20 | public bytes: Uint8Array; 21 | private subscribers: Set = new Set(); 22 | 23 | constructor(public bitCount: number) { 24 | const byteCount = Math.ceil(bitCount / 8); 25 | this.bytes = new Uint8Array(byteCount); 26 | } 27 | 28 | get(index: number) { 29 | const byteIndex = index >> 3; 30 | const bitIndex = index & 7; 31 | return (this.bytes[byteIndex] & (1 << bitIndex)) !== 0; 32 | } 33 | 34 | set(index: number, value: boolean) { 35 | const byteIndex = index >> 3; 36 | const bitIndex = index & 7; 37 | 38 | let b = this.bytes[byteIndex]; 39 | this.checkedCount -= bitCountLUT[b]; 40 | 41 | b &= ~(1 << bitIndex); 42 | 43 | if (value) { 44 | b |= 1 << bitIndex; 45 | } 46 | 47 | this.bytes[byteIndex] = b; 48 | this.checkedCount += bitCountLUT[b]; 49 | } 50 | 51 | fullStateUpdate(bitmap: Uint8Array) { 52 | this.bytes.set(bitmap); 53 | this.checkedCount = countOnes(bitmap); 54 | this.fireChange(); 55 | } 56 | 57 | partialStateUpdate(offset: number, chunk: Uint8Array) { 58 | for (let i = 0; i < chunk.length; i++) { 59 | const byteIndex = offset + i; 60 | const b = this.bytes[byteIndex]; 61 | this.checkedCount -= bitCountLUT[b]; 62 | this.bytes[byteIndex] = chunk[i]; 63 | this.checkedCount += bitCountLUT[chunk[i]]; 64 | } 65 | this.fireChange(offset * 8, (offset + chunk.length) * 8); 66 | } 67 | 68 | fireChange(rangeMin: number = 0, rangeMax: number = this.bitCount) { 69 | for (const subscriber of this.subscribers) { 70 | subscriber(rangeMin, rangeMax); 71 | } 72 | } 73 | 74 | subscribeToChanges(callback: BitmapChangeCallback) { 75 | this.subscribers.add(callback); 76 | } 77 | 78 | unsubscribeFromChanges(callback: BitmapChangeCallback) { 79 | this.subscribers.delete(callback); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | -------------------------------------------------------------------------------- /client/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { renderApp } from "./App"; 2 | 3 | export class Observable { 4 | #value: T; 5 | #listeners: Set<(value: T) => void>; 6 | 7 | constructor(value: T) { 8 | this.#value = value; 9 | this.#listeners = new Set(); 10 | } 11 | 12 | get value(): T { 13 | return this.#value; 14 | } 15 | 16 | set value(value: T) { 17 | this.#value = value; 18 | this.#listeners.forEach((listener) => listener(value)); 19 | } 20 | 21 | subscribe(listener: (value: T) => void): void { 22 | this.#listeners.add(listener); 23 | listener(this.#value); 24 | } 25 | 26 | unsubscribe(listener: (value: T) => void): void { 27 | this.#listeners.delete(listener); 28 | } 29 | } 30 | 31 | const themeStorageKey = "1bcb__theme"; 32 | const debugStorageKey = "1bcb__debug"; 33 | const preferencesStorageKey = "1bcb__preferences"; 34 | 35 | export const themes = [ 36 | { id: "ctp-mocha", label: "Catppuccin Dark" }, 37 | { id: "ctp-latte", label: "Catppuccin Light" }, 38 | { id: "ctp-monochrome", label: "Monochrome (Light)" }, 39 | { id: "ctp-amoled", label: "AMOLED" }, 40 | ]; 41 | 42 | const defaultTheme = themes[0].id; 43 | 44 | export function applyTheme(theme: string): void { 45 | const classList = document.querySelector("html")!.classList; 46 | classList.remove(...themes.map((t) => `style-${t.id}`)); 47 | classList.add(`style-${theme}`); 48 | 49 | localStorage.setItem(themeStorageKey, theme); 50 | } 51 | 52 | export function getCurrentTheme(): string { 53 | return localStorage.getItem(themeStorageKey) || defaultTheme; 54 | } 55 | 56 | export function applyThemeFromStorage(): void { 57 | applyTheme(getCurrentTheme()); 58 | } 59 | 60 | export function getAllPreferences(): { [key: string]: unknown } { 61 | const preferences = localStorage.getItem(preferencesStorageKey) || "{}"; 62 | return JSON.parse(preferences); 63 | } 64 | 65 | export function getPreference(key: string): unknown { 66 | 67 | const preferences = getAllPreferences(); 68 | const foundItem = preferences[key]; 69 | if (typeof foundItem === 'undefined') return null; 70 | return foundItem; 71 | } 72 | 73 | export function setPreference( key: string, value: unknown, renderImmediate: boolean = true ): void { 74 | const preferences = getAllPreferences(); 75 | preferences[key] = value; 76 | localStorage.setItem(preferencesStorageKey, JSON.stringify(preferences)); 77 | if (renderImmediate) renderApp(); 78 | } 79 | 80 | export function setCheckboxStylePreference(preference: string): void { 81 | setPreference('checkboxStyle', preference); 82 | } 83 | 84 | export function getCheckboxStylePreference(): string { 85 | const style = getPreference("checkboxStyle"); 86 | if (typeof style !== 'string' || style === '') return "default" 87 | return style; 88 | } 89 | 90 | export function setTickMarkBorderVisible(hasBorder: boolean): void { 91 | setPreference("tickMarkBorderVisible", hasBorder); 92 | } 93 | export function getTickMarkBorderVisible(): boolean { 94 | const visible = getPreference("tickMarkBorderVisible") 95 | if (typeof visible !== 'boolean') return true 96 | return visible; 97 | } 98 | 99 | export function setTickMarkVisible(hasTick: boolean): void { 100 | setPreference("tickMarkVisible", hasTick); 101 | } 102 | 103 | export function getTickMarkVisible(): boolean { 104 | const visible = getPreference("tickMarkVisible") 105 | if (typeof visible !== 'boolean') return true 106 | return visible; 107 | } 108 | 109 | export const debug = new Observable(typeof window !== "undefined" && localStorage.getItem(debugStorageKey) === "true"); 110 | 111 | if (typeof window !== "undefined") { 112 | debug.subscribe((value) => { 113 | if (value) { 114 | localStorage.setItem(debugStorageKey, "true"); 115 | } else { 116 | localStorage.removeItem(debugStorageKey); 117 | } 118 | }); 119 | } 120 | 121 | export function downloadUint8Array(data: Uint8Array, filename: string): void { 122 | const blob = new Blob([data], { type: "application/octet-stream" }); 123 | const url = URL.createObjectURL(blob); 124 | const a = document.createElement("a"); 125 | a.href = url; 126 | a.download = filename; 127 | 128 | document.body.appendChild(a); 129 | a.click(); 130 | document.body.removeChild(a); 131 | URL.revokeObjectURL(url); 132 | } 133 | -------------------------------------------------------------------------------- /client/src/client.ts: -------------------------------------------------------------------------------- 1 | import { Bitmap } from "./bitmap"; 2 | import { Observable } from "./utils"; 3 | 4 | export const PROTOCOL_VERSION = 1; 5 | export const CHUNK_SIZE = 64 * 64 * 64; 6 | export const CHUNK_SIZE_BYTES = CHUNK_SIZE / 8; 7 | export const CHUNK_COUNT = 64 * 64; 8 | export const BITMAP_SIZE = CHUNK_SIZE * CHUNK_COUNT; 9 | export const UPDATE_CHUNK_SIZE = 32; 10 | 11 | export const enum MessageType { 12 | Hello = 0x0, 13 | Stats = 0x1, 14 | ChunkFullStateRequest = 0x10, 15 | ChunkFullStateResponse = 0x11, 16 | PartialStateUpdate = 0x12, 17 | ToggleBit = 0x13, 18 | PartialStateSubscription = 0x14, 19 | } 20 | 21 | export interface HelloMessage { 22 | msg: MessageType.Hello; 23 | versionMajor: number; 24 | versionMinor: number; 25 | } 26 | 27 | export interface StatsMessage { 28 | msg: MessageType.Stats; 29 | currentClients: number; 30 | } 31 | 32 | export interface ChunkFullStateRequestMessage { 33 | msg: MessageType.ChunkFullStateRequest; 34 | chunkIndex: number; 35 | } 36 | 37 | export interface ChunkFullStateResponseMessage { 38 | msg: MessageType.ChunkFullStateResponse; 39 | chunkIndex: number; 40 | bitmap: Uint8Array; 41 | } 42 | 43 | export interface PartialStateUpdateMessage { 44 | msg: MessageType.PartialStateUpdate; 45 | offset: number; 46 | chunk: Uint8Array; 47 | } 48 | 49 | export interface ToggleBitMessage { 50 | msg: MessageType.ToggleBit; 51 | index: number; 52 | } 53 | 54 | export interface PartialStateSubscriptionMessage { 55 | msg: MessageType.PartialStateSubscription; 56 | chunkIndex: number; 57 | } 58 | 59 | export type ClientMessage = ChunkFullStateRequestMessage | ToggleBitMessage | PartialStateSubscriptionMessage; 60 | export type ServerMessage = HelloMessage | StatsMessage | ChunkFullStateResponseMessage | PartialStateUpdateMessage; 61 | 62 | export type Message = ClientMessage | ServerMessage; 63 | 64 | export class BitmapClient { 65 | public bitmap: Bitmap; 66 | public goToCheckboxCallback: (index: number) => void = () => {}; 67 | public loadingCallback: (loading: boolean) => void = () => {}; 68 | public highlightedIndex = -1; 69 | 70 | private websocket: WebSocket | null = null; 71 | currentChunkIndex = 0; 72 | currentClients = new Observable(1); 73 | checkedCount = new Observable(0); 74 | chunkLoaded = false; 75 | 76 | constructor() { 77 | this.bitmap = new Bitmap(CHUNK_SIZE); 78 | this.openWebSocket(); 79 | } 80 | 81 | public isChecked(globalIndex: number) { 82 | const localIndex = globalIndex % CHUNK_SIZE; 83 | return this.bitmap.get(localIndex); 84 | } 85 | 86 | public toggle(globalIndex: number) { 87 | const localIndex = globalIndex % CHUNK_SIZE; 88 | // console.log("Toggling", globalIndex); 89 | this.send({ msg: MessageType.ToggleBit, index: globalIndex }); 90 | this.bitmap.set(localIndex, !this.bitmap.get(localIndex)); 91 | } 92 | 93 | get chunkIndex() { 94 | return this.currentChunkIndex; 95 | } 96 | 97 | public setChunkIndex(chunkIndex: number) { 98 | this.currentChunkIndex = chunkIndex; 99 | this.chunkLoaded = false; 100 | this.loadingCallback(true); 101 | this.send({ msg: MessageType.PartialStateSubscription, chunkIndex }); 102 | this.send({ msg: MessageType.ChunkFullStateRequest, chunkIndex }); 103 | } 104 | 105 | public getUint8Array() { 106 | return this.bitmap.bytes; 107 | } 108 | 109 | private openWebSocket() { 110 | console.log("Connecting to server"); 111 | if (this.websocket) { 112 | this.websocket.close(); 113 | } 114 | 115 | const ws = new WebSocket(import.meta.env.VITE_WEBSOCKET_URL); 116 | ws.binaryType = "arraybuffer"; 117 | this.websocket = ws; 118 | 119 | ws.addEventListener("open", () => { 120 | console.log("Connected to server"); 121 | this.onOpen(); 122 | }); 123 | 124 | ws.addEventListener("message", (message) => { 125 | if (message.data instanceof ArrayBuffer) { 126 | const msg = this.deserialize(message.data); 127 | if (msg) this.onMessage(msg); 128 | } 129 | }); 130 | 131 | ws.addEventListener("close", () => { 132 | console.log("Disconnected from server"); 133 | this.websocket = null; 134 | setTimeout(() => this.openWebSocket(), 5000); 135 | }); 136 | 137 | ws.addEventListener("error", (err) => { 138 | console.error(err); 139 | }); 140 | } 141 | 142 | private onOpen() {} 143 | 144 | private onMessage(msg: ServerMessage) { 145 | // console.log("Received message", msg); 146 | 147 | if (msg.msg === MessageType.Hello) { 148 | if (msg.versionMajor !== PROTOCOL_VERSION) { 149 | this.websocket?.close(); 150 | alert("Incompatible protocol version"); 151 | } 152 | 153 | const chunkIndex = this.chunkIndex; 154 | 155 | this.send({ msg: MessageType.PartialStateSubscription, chunkIndex }); 156 | this.send({ msg: MessageType.ChunkFullStateRequest, chunkIndex }); 157 | } else if (msg.msg === MessageType.Stats) { 158 | const stats = msg as StatsMessage; 159 | console.log("Current clients", stats.currentClients); 160 | this.currentClients.value = stats.currentClients; 161 | } else if (msg.msg === MessageType.ChunkFullStateResponse) { 162 | const fullState = msg as ChunkFullStateResponseMessage; 163 | if (fullState.chunkIndex !== this.chunkIndex) return; 164 | 165 | this.bitmap.fullStateUpdate(fullState.bitmap); 166 | this.checkedCount.value = this.bitmap.checkedCount; 167 | this.chunkLoaded = true; 168 | this.loadingCallback(false); 169 | } else if (msg.msg === MessageType.PartialStateUpdate) { 170 | const partialState = msg as PartialStateUpdateMessage; 171 | // console.log("Partial state update", partialState); 172 | 173 | const chunkIndex = Math.floor(partialState.offset / CHUNK_SIZE_BYTES); 174 | if (chunkIndex !== this.chunkIndex) return; 175 | const byteOffset = partialState.offset % CHUNK_SIZE_BYTES; 176 | 177 | this.bitmap.partialStateUpdate(byteOffset, partialState.chunk); 178 | this.checkedCount.value = this.bitmap.checkedCount; 179 | } 180 | } 181 | 182 | private deserialize(data: ArrayBuffer): ServerMessage | undefined { 183 | const payload = new Uint8Array(data); 184 | const dataView = new DataView(data); 185 | 186 | const msg = payload[0]; 187 | 188 | if (msg === MessageType.Hello) { 189 | const versionMajor = dataView.getUint16(1, true); 190 | const versionMinor = dataView.getUint16(3, true); 191 | 192 | return { msg, versionMajor, versionMinor } as HelloMessage; 193 | } else if (msg === MessageType.Stats) { 194 | const currentClients = dataView.getUint32(1, true); 195 | 196 | return { msg, currentClients } as StatsMessage; 197 | } else if (msg === MessageType.ChunkFullStateResponse) { 198 | const chunkIndex = dataView.getUint16(1, true); 199 | const bitmap = payload.slice(3); 200 | 201 | return { msg, chunkIndex, bitmap } as ChunkFullStateResponseMessage; 202 | } else if (msg === MessageType.PartialStateUpdate) { 203 | const offset = dataView.getUint32(1, true); 204 | const chunk = payload.slice(5); 205 | 206 | return { msg, offset, chunk } as PartialStateUpdateMessage; 207 | } else { 208 | return undefined; 209 | } 210 | } 211 | 212 | private send(msg: ClientMessage) { 213 | if (!this.websocket) return; 214 | 215 | const data = this.serialize(msg); 216 | this.websocket.send(data); 217 | } 218 | 219 | private serialize(msg: ClientMessage) { 220 | if (msg.msg === MessageType.ChunkFullStateRequest || msg.msg === MessageType.PartialStateSubscription) { 221 | const data = new Uint8Array(3); 222 | data[0] = msg.msg; 223 | const view = new DataView(data.buffer); 224 | view.setUint16(1, msg.chunkIndex, true); 225 | 226 | return data; 227 | } else if (msg.msg === MessageType.ToggleBit) { 228 | const data = new Uint8Array(5); 229 | data[0] = msg.msg; 230 | const view = new DataView(data.buffer); 231 | view.setUint32(1, msg.index, true); 232 | 233 | return data; 234 | } else { 235 | throw new Error("Invalid message type"); 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /client/src/components/CheckboxGrid.tsx: -------------------------------------------------------------------------------- 1 | import { elementScroll, observeElementOffset, observeElementRect, Virtualizer } from "@tanstack/virtual-core"; 2 | import { Component, createRef, InfernoNode, RefObject } from "inferno"; 3 | import { BitmapClient, CHUNK_SIZE } from "../client"; 4 | import * as utils from "../utils"; 5 | 6 | const CHECKBOX_SIZE = 30; 7 | 8 | function dummy() {} 9 | 10 | interface CheckboxRowProps { 11 | index: number; 12 | count: number; 13 | itemsPerRow: number; 14 | checkboxSize: number; 15 | client: BitmapClient; 16 | checkboxStylePreference: string; 17 | hasTickMark: boolean; 18 | hasTickMarkBorders: boolean 19 | } 20 | 21 | class CheckboxRow extends Component { 22 | private checkboxRefs: Array>; 23 | 24 | constructor(props: CheckboxRowProps) { 25 | super(props); 26 | this.checkboxRefs = [...Array(props.count)].map(() => createRef()); 27 | this.bitmapChanged = this.bitmapChanged.bind(this); 28 | } 29 | 30 | componentWillReceiveProps(nextProps: CheckboxRowProps): void { 31 | if ( 32 | this.props.count !== nextProps.count || 33 | this.props.index !== nextProps.index || 34 | this.props.checkboxSize !== nextProps.checkboxSize 35 | ) { 36 | this.checkboxRefs = [...Array(nextProps.count)].map(() => createRef()); 37 | this.forceUpdate(); 38 | } 39 | } 40 | 41 | bitmapChanged(min: number, max: number): void { 42 | const localIdx = this.props.index * this.props.itemsPerRow; 43 | const globalIdx = this.props.client.chunkIndex * CHUNK_SIZE + localIdx; 44 | 45 | if (!(max >= localIdx && min <= localIdx + this.props.itemsPerRow)) { 46 | return; 47 | } 48 | 49 | this.checkboxRefs.forEach((ref, i) => { 50 | const idx = globalIdx + i; 51 | if (ref.current) { 52 | ref.current.checked = this.props.client.isChecked(idx); 53 | } 54 | }); 55 | 56 | // this.forceUpdate(); 57 | } 58 | 59 | componentDidMount(): void { 60 | this.props.client.bitmap.subscribeToChanges(this.bitmapChanged); 61 | } 62 | 63 | componentWillUnmount(): void { 64 | this.props.client.bitmap.unsubscribeFromChanges(this.bitmapChanged); 65 | } 66 | 67 | onChange(idx: number): void { 68 | this.props.client.toggle(idx); 69 | this.forceUpdate(); 70 | } 71 | 72 | render(props: CheckboxRowProps): InfernoNode { 73 | 74 | const baseIdx = props.client.chunkIndex * CHUNK_SIZE + props.index * props.itemsPerRow; 75 | const checkboxStyle= (props.checkboxStylePreference === "reduced") ? "reduced" : "default"; 76 | const isHighlighted = (idx: number) => props.client.highlightedIndex === idx ? "highlighted" : "" 77 | const tickMarkToggle = (props.hasTickMark) ? "" : "noTick"; 78 | const tickMarkBorderToggle = (props.hasTickMarkBorders) ? "" : "noBorder" 79 | 80 | return ( 81 |
89 | {[...Array(props.count)].map((_, i) => { 90 | const idx = baseIdx + i; 91 | return ( 92 |
93 | this.onChange(idx)} 99 | ref={this.checkboxRefs[i]} 100 | checked={props.client.isChecked(idx)} 101 | /> 102 | {/* 103 | {idx} 104 |
105 | {props.client.isChecked(idx).toString()} 106 |
*/} 107 |
108 | ); 109 | })} 110 |
111 | ); 112 | } 113 | } 114 | 115 | interface CheckboxGridProps { 116 | client: BitmapClient; 117 | } 118 | 119 | interface CheckboxGridState { 120 | itemsPerRow: number; 121 | checkboxSize: number; 122 | } 123 | 124 | export class CheckboxGrid extends Component { 125 | private ref: RefObject; 126 | private virtualizer: Virtualizer; 127 | private cleanup: () => void = dummy; 128 | private resizeObserver: ResizeObserver | null = null; 129 | 130 | constructor(props: CheckboxGridProps) { 131 | super(props); 132 | 133 | this.ref = createRef(); 134 | // this.itemsPerRow = 20; 135 | this.state = { 136 | itemsPerRow: Math.floor(window.innerWidth / CHECKBOX_SIZE), 137 | checkboxSize: CHECKBOX_SIZE, 138 | }; 139 | this.virtualizer = new Virtualizer({ 140 | count: Math.ceil(props.client.bitmap.bitCount / this.state.itemsPerRow), 141 | overscan: 10, 142 | estimateSize: () => this.state!.checkboxSize, 143 | getScrollElement: () => this.ref.current, 144 | observeElementOffset, 145 | observeElementRect, 146 | scrollToFn: elementScroll, 147 | onChange: () => { 148 | this.forceUpdate(); 149 | }, 150 | }); 151 | 152 | this.updateSize = this.updateSize.bind(this); 153 | } 154 | 155 | goToCheckbox(index: number): void { 156 | const chunkIndex = Math.floor(index / CHUNK_SIZE); 157 | const bitIndex = index % CHUNK_SIZE; 158 | 159 | const y = Math.floor(bitIndex / this.state!.itemsPerRow); 160 | 161 | this.virtualizer.scrollToIndex(y, { 162 | align: "center", 163 | behavior: "smooth", 164 | }); 165 | 166 | if (chunkIndex !== this.props.client.chunkIndex) { 167 | this.props.client.setChunkIndex(chunkIndex); 168 | } 169 | 170 | this.props.client.highlightedIndex = index; 171 | setTimeout(() => { 172 | this.props.client.highlightedIndex = -1; 173 | }, 5000); 174 | 175 | this.forceUpdate(); 176 | } 177 | 178 | componentDidMount(): void { 179 | this.updateSize(); 180 | this.resizeObserver = new ResizeObserver(this.updateSize); 181 | this.resizeObserver.observe(this.ref.current!); 182 | this.cleanup = this.virtualizer._didMount(); 183 | this.virtualizer._willUpdate(); 184 | this.props.client.goToCheckboxCallback = this.goToCheckbox.bind(this); 185 | } 186 | 187 | componentWillUpdate(): void { 188 | this.virtualizer._willUpdate(); 189 | } 190 | 191 | componentWillUnmount(): void { 192 | this.cleanup(); 193 | this.resizeObserver?.disconnect(); 194 | this.props.client.goToCheckboxCallback = dummy; 195 | } 196 | 197 | updateSize(): void { 198 | const element = this.ref.current; 199 | if (!element) return; 200 | 201 | const bitmap = this.props.client.bitmap; 202 | const width = element.clientWidth; 203 | 204 | const checkboxSize = Math.max(width / 60, 21); 205 | const itemsPerRow = Math.max(1, Math.floor(width / checkboxSize)); 206 | 207 | this.virtualizer.setOptions({ 208 | ...this.virtualizer.options, 209 | estimateSize: () => checkboxSize, 210 | count: Math.ceil(bitmap.bitCount / itemsPerRow), 211 | }); 212 | 213 | this.setState({ 214 | checkboxSize: checkboxSize, 215 | itemsPerRow: itemsPerRow, 216 | }); 217 | 218 | this.virtualizer.resizeItem(0, checkboxSize); 219 | } 220 | 221 | getCount(index: number): number { 222 | const bitmap = this.props.client.bitmap; 223 | index %= bitmap.bitCount; 224 | return Math.max(Math.min(this.state!.itemsPerRow, bitmap.bitCount - index), 0); 225 | } 226 | 227 | render(props: CheckboxGridProps, state: CheckboxGridState) { 228 | return ( 229 |
230 |
239 | {this.virtualizer.getVirtualItems().map((virtualItem) => { 240 | return ( 241 |
254 | 263 |
264 | ); 265 | })} 266 |
267 |
268 | ); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /PROTOCOL.md: -------------------------------------------------------------------------------- 1 | # Protocol documentation - version 1.1 2 | 3 | ## Introduction 4 | 5 | We use WebSockets to communicate between the server and the client. The protocol is binary in both 6 | directions, the message format is as follows: 7 | 8 | ```cpp 9 | using MessageType = uint8_t; 10 | 11 | struct Message { 12 | MessageType type; 13 | uint8_t data[VARIABLE]; 14 | }; 15 | ``` 16 | 17 | The message type is a single byte that indicates the type of the message. 18 | 19 | The message data length is variable, there's no length specifier, as that matter is handled by the 20 | WebSocket protocol. 21 | 22 | Every value larger than a byte is sent in little-endian order. 23 | 24 | There is no padding in the messages, so the data is packed as tightly as possible. Similar to 25 | `__attribute__(packed)` or `#pragma pack(1)` in C or `#[repr(packed)]` in Rust. 26 | 27 | ## Bitmap representation 28 | 29 | The bitmap is represented an array of bytes. The bits are stored in LSB order, so the first bit is 30 | the least significant bit of the first byte. 31 | 32 | The bit numbering starts from 0. 33 | 34 | For example, the following bitmap: 35 | 36 | ``` 37 | 00 01 80 38 | ``` 39 | 40 | Would be represented as: 41 | 42 | ``` 43 | MSB LSB MSB LSB MSB LSB 44 | 7 0 7 0 7 0 45 | | | | | | | 46 | 00000000 00000001 10000000 47 | ``` 48 | 49 | The size of entire bitmap is 1024³ bits, which is 1 Gibibit or 128 MiB. 50 | 51 | For performance reasons the full bitmap is not accessible at once, but, it's divided into 52 | 4096 (64²) chunks, each containing 262144 (64³) bits. The chunks are numbered from 0 to 4095. 53 | 54 | The constants are defined as follows: 55 | 56 | ```cpp 57 | // The size of a single chunk in bits 58 | const uint32_t CHUNK_SIZE = 64 * 64 * 64; 59 | 60 | // The size of a single chunk in bytes 61 | const uint32_t CHUNK_SIZE_BYTES = CHUNK_SIZE / 8; 62 | 63 | // The number of chunks 64 | const uint32_t CHUNK_COUNT = 64 * 64; 65 | 66 | // The size of the entire bitmap in bits 67 | const uint32_t BITMAP_SIZE = CHUNK_SIZE * CHUNK_COUNT; 68 | 69 | // The size of a single update chunk in bytes 70 | const uint32_t UPDATE_CHUNK_SIZE = 32; 71 | ``` 72 | 73 | ## Message types 74 | 75 | ### System messages 76 | 77 | #### 0x00 - Hello (Server->Client) 78 | 79 | ```c 80 | struct HelloMessage { 81 | MessageType type = 0x00; 82 | // Protocol version (expected to be 1) 83 | uint16_t versionMajor; 84 | // Minor protocol version 85 | uint16_t versionMinor; 86 | }; 87 | ``` 88 | 89 | The server sends a hello message to the client when the connection is established. The specified 90 | major protocol version is equivalent to the version of this document. 91 | 92 | Subsequent major protocol versions may introduce breaking changes, so the client should check the 93 | version and disconnect if it's not supported. 94 | 95 | #### 0x01 - Stats (Server->Client) 96 | 97 | ```c 98 | struct StatsMessage { 99 | MessageType type = 0x01; 100 | // Number of connected clients 101 | uint32_t currentClients; 102 | // Reserved for future use 103 | uint8_t reserved[60]; 104 | }; 105 | ``` 106 | 107 | The server periodically send the current statistics to the client. This message may add more fields 108 | in future protocol versions. It's guaranteed that the layout of the message will be backward 109 | compatible. Any new fields will be added in the `reserved` field. 110 | 111 | ### Bitmap messages 112 | 113 | #### 0x10 - Chunk Full State Request (Client->Server) 114 | 115 | ```c 116 | struct ChunkFullStateRequestMessage { 117 | MessageType type = 0x10; 118 | uint16_t chunkIndex; 119 | }; 120 | ``` 121 | 122 | The client requests the full state of a specified chunk from the server. 123 | 124 | The `chunkIndex` is a number from 0 to 4095. 125 | 126 | The server will respond with a `0x11 - Chunk Full State Response` message. 127 | 128 | This message is stateless, you can request the full state of any chunk at any time. 129 | 130 | #### 0x11 - Chunk Full State Response (Server->Client) 131 | 132 | ```c 133 | struct ChunkFullStateResponseMessage { 134 | MessageType type = 0x11; 135 | // Index of the chunk 136 | uint16_t chunkIndex; 137 | // Chunk bitmap data, represented as described previously in the document 138 | uint8_t bitmap[CHUNK_SIZE_BYTES]; 139 | }; 140 | ``` 141 | 142 | #### 0x12 - Partial State Update (Server->Client) 143 | 144 | ```c 145 | struct PartialStateUpdateMessage { 146 | MessageType type = 0x12; 147 | // Byte offset in the global byte array (chunk index * CHUNK_SIZE) 148 | uint32_t offset; 149 | // Updated bitmap data 150 | uint8_t chunk[UPDATE_CHUNK_SIZE]; 151 | }; 152 | ``` 153 | 154 | The server periodically sends partial updates of the subscribed chunk to the client. 155 | The client should update the local bitmap state starting from the offset with the provided data. 156 | 157 | The offset is the index of the byte in the bitmap viewed as a whole. The index and chunk index can 158 | be calculated as follows: 159 | 160 | ```cpp 161 | uint32_t chunkIndex = offset / CHUNK_SIZE_BYTES; 162 | uint32_t byteIndex = offset % CHUNK_SIZE_BYTES; 163 | ``` 164 | 165 | The bitmap data is updated as follows: 166 | 167 | ```cpp 168 | // getChunkDataAt is a function that returns a mutable byte array view of the chunk the client is 169 | // subscribed to 170 | uint8_t* chunkData = getChunkDataAt(chunkIndex); 171 | 172 | for (uint32_t i = 0; i < UPDATE_CHUNK_SIZE; i++) { 173 | chunkData[byteIndex + i] = message.chunk[i]; 174 | } 175 | ``` 176 | 177 | *If you think sending 32-byte updates is too much, consider the overhead of protocols that 178 | encapsulate this message :)* 179 | 180 | #### 0x13 - Toggle bit (Client->Server) 181 | 182 | ```c 183 | struct ToggleBitMessage { 184 | MessageType type = 0x13; 185 | // Bit index in the global bitmap 186 | uint32_t index; 187 | }; 188 | ``` 189 | 190 | Requests the server to toggle the specified bit in the global bitmap. 191 | 192 | The bit index is a number from 0 to 1024³, it's viewed as an index of the bit in the bitmap viewed 193 | as a whole. The indices can be calculated as follows: 194 | 195 | ```cpp 196 | uint32_t chunkIndex = index / CHUNK_SIZE; 197 | uint32_t bitIndexInChunk = index % CHUNK_SIZE; 198 | ``` 199 | 200 | #### 0x14 - Partial State Subscription (Client->Server) 201 | 202 | ```c 203 | struct PartialStateSubscriptionMessage { 204 | MessageType type = 0x14; 205 | // Chunk index 206 | uint16_t chunkIndex; 207 | }; 208 | ``` 209 | 210 | The client sends a message to the server to subscribe to partial updates of the specified chunk. 211 | 212 | The server does not send partial updates to the client if the client is not subscribed to the chunk. 213 | 214 | The client can only subscribe to a single chunk at a time. If the client sends another 215 | `0x14 - Partial State Subscription` message, the server will unsubscribe the client from the 216 | previous chunk and subscribe to the new one. 217 | 218 | #### 0x15 - Partial State Unsubscription (Client->Server) 219 | 220 | ```c 221 | struct PartialStateUnsubscriptionMessage { 222 | MessageType type = 0x15; 223 | }; 224 | ``` 225 | 226 | The client sends a message to the server to unsubscribe from currently subscribed chunk. 227 | 228 | ## Connection flow example 229 | 230 | ``` 231 | 232 | Client Server 233 | | | 234 | | WebSocket handshake | 235 | |-----------------------------> | 236 | | | 237 | | 0x00 Hello | 238 | | <-----------------------------| 239 | | | 240 | | 0x10 Chunk Full State Request | 241 | |-----------------------------> | 242 | | | 243 | | 0x14 Partial State Sub | 244 | |-----------------------------> | 245 | | | 246 | | 0x11 Chunk Full State Response| 247 | | <-----------------------------| 248 | | | 249 | | 0x12 Partial State Update | 250 | | <-----------------------------| 251 | | | 252 | | 0x12 Partial State Update | 253 | | <-----------------------------| 254 | | | 255 | | 0x01 Stats | 256 | | <-----------------------------| 257 | | | 258 | | 0x12 Partial State Update | 259 | | <-----------------------------| 260 | | | 261 | | 0x13 Toggle bit | 262 | |-----------------------------> | 263 | | | 264 | | 0x12 Partial State Update | 265 | | <-----------------------------| 266 | | | 267 | | 0x14 Partial State Sub | 268 | |-----------------------------> | 269 | | | 270 | | 0x10 Chunk Full State Request | 271 | |-----------------------------> | 272 | | | 273 | | 0x11 Chunk Full State Response| 274 | | <-----------------------------| 275 | | | 276 | | 0x12 Partial State Update | 277 | | <-----------------------------| 278 | 279 | ``` 280 | 281 | ## Changelog 282 | 283 | ### 1.1 284 | 285 | Backwards compatible with 1.0. 286 | 287 | - Added the `0x15 - Partial State Unsubscription` message. 288 | -------------------------------------------------------------------------------- /server/src/bitmap.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | io::{Read, Write}, 4 | sync::{ 5 | atomic::{AtomicUsize, Ordering}, 6 | Arc, 7 | }, 8 | }; 9 | 10 | use bitvec::{array::BitArray, order::BitOrder, view::BitViewSized}; 11 | use tokio::sync::broadcast; 12 | 13 | use crate::common::PResult; 14 | 15 | // The size of a single chunk in bits 16 | pub const CHUNK_SIZE: usize = 64 * 64 * 64; 17 | // The size of a single chunk in bytes 18 | pub const CHUNK_SIZE_BYTES: usize = CHUNK_SIZE / 8; 19 | // The number of chunks 20 | pub const CHUNK_COUNT: usize = 64 * 64; 21 | // The size of the entire bitmap in bits 22 | pub const BITMAP_SIZE: usize = CHUNK_SIZE * CHUNK_COUNT; 23 | // The size of a single update chunk in bytes 24 | pub const UPDATE_CHUNK_SIZE: usize = 32; 25 | // The size of a single update chunk in bits 26 | pub const UPDATE_CHUNK_SIZE_BITS: usize = UPDATE_CHUNK_SIZE * 8; 27 | 28 | type BitmapType = BitArray<[u8; CHUNK_SIZE_BYTES]>; 29 | 30 | pub struct Bitmap { 31 | pub data: Box<[BitmapType; CHUNK_COUNT]>, 32 | pub change_tracker: ChangeTracker, 33 | } 34 | 35 | impl Bitmap { 36 | pub fn new() -> Self { 37 | let data = vec![BitArray::default(); CHUNK_COUNT] 38 | .into_boxed_slice() 39 | .try_into() 40 | .unwrap(); 41 | let change_tracker = ChangeTracker::new(ChangeTrackerOptions::default()); 42 | 43 | Self { 44 | data, 45 | change_tracker, 46 | } 47 | } 48 | 49 | pub fn load_from_file(&mut self, path: &str) -> PResult<()> { 50 | let file = std::fs::OpenOptions::new().read(true).open(path)?; 51 | let mut reader = std::io::BufReader::new(file); 52 | for chunk in self.data.iter_mut() { 53 | reader.read_exact(&mut chunk.data)?; 54 | } 55 | 56 | Ok(()) 57 | } 58 | 59 | pub fn save_to_file(&self, path: &str) -> PResult<()> { 60 | let file = std::fs::OpenOptions::new() 61 | .write(true) 62 | .create(true) 63 | .truncate(true) 64 | .open(path)?; 65 | let mut writer = std::io::BufWriter::new(file); 66 | for chunk in self.data.iter() { 67 | writer.write_all(&chunk.data)?; 68 | } 69 | 70 | Ok(()) 71 | } 72 | 73 | pub fn count_ones(&self) -> usize { 74 | let mut count = 0; 75 | for chunk in self.data.iter() { 76 | count += chunk.count_ones(); 77 | } 78 | count 79 | } 80 | 81 | pub fn periodic_send_changes(&mut self) { 82 | self.change_tracker.send_changes(&self.data); 83 | self.change_tracker.clear(); 84 | } 85 | 86 | pub fn set(&mut self, index: usize, value: bool) { 87 | if index >= self.len() { 88 | return; 89 | } 90 | 91 | let chunk_index = index / CHUNK_SIZE; 92 | let bit_index = index % CHUNK_SIZE; 93 | let chunk = &mut self.data[chunk_index]; 94 | 95 | chunk.set(bit_index, value); 96 | self.change_tracker.mark_bit_changed(index); 97 | } 98 | 99 | pub fn toggle(&self, index: usize) -> i32 { 100 | if index >= self.len() { 101 | return 0; 102 | } 103 | 104 | let chunk_index = index / CHUNK_SIZE; 105 | let bit_index = index % CHUNK_SIZE; 106 | let curr = toggle_bit_atomic(&self.data[chunk_index], bit_index); 107 | 108 | self.change_tracker.mark_bit_changed(index); 109 | 110 | if curr { 111 | -1 112 | } else { 113 | 1 114 | } 115 | } 116 | 117 | pub fn get(&self, index: usize) -> bool { 118 | if index >= self.len() { 119 | return false; 120 | } 121 | 122 | let chunk_index = index / CHUNK_SIZE; 123 | let bit_index = index % CHUNK_SIZE; 124 | let chunk = &self.data[chunk_index]; 125 | 126 | chunk[bit_index] 127 | } 128 | 129 | pub const fn len(&self) -> usize { 130 | CHUNK_SIZE * CHUNK_COUNT 131 | } 132 | 133 | pub fn as_raw_slice(&self, chunk_index: usize) -> &[u8; CHUNK_SIZE_BYTES] { 134 | &self.data[chunk_index].data 135 | } 136 | 137 | pub fn subscribe(&mut self, chunk_index: usize) -> broadcast::Receiver { 138 | self.change_tracker.subscribe_chunk(chunk_index) 139 | } 140 | } 141 | 142 | pub struct ChangeData { 143 | /// The offset in global byte array (chunk_index * chunk_size) 144 | pub byte_array_offset: u32, 145 | /// The changed chunk data 146 | pub chunk_data: [u8; UPDATE_CHUNK_SIZE], 147 | } 148 | 149 | pub type Change = Arc; 150 | 151 | const CHANGE_MASK_SIZE: usize = 152 | (CHUNK_SIZE * CHUNK_COUNT) / (UPDATE_CHUNK_SIZE * size_of::()); 153 | 154 | pub struct ChangeTrackerOptions { 155 | /// The maximum number of changes that can be stored in the backlog for each receiver. 156 | pub backlog_capacity: usize, 157 | } 158 | 159 | impl Default for ChangeTrackerOptions { 160 | fn default() -> Self { 161 | Self { 162 | backlog_capacity: 128, 163 | } 164 | } 165 | } 166 | 167 | /// Tracks changes to a bitmap. 168 | /// The bitmap is divided into chunks of CHUNK_SIZE bytes. 169 | /// The change_mask stores a boolean for each chunk, indicating whether the chunk has been modified. 170 | /// The clients only receive the chunks that have been modified. 171 | pub struct ChangeTracker { 172 | pub change_mask: Box>, 173 | pub senders: HashMap>, 174 | pub options: ChangeTrackerOptions, 175 | } 176 | 177 | impl ChangeTracker { 178 | pub fn new(options: ChangeTrackerOptions) -> Self { 179 | let change_mask: Box<[usize; CHANGE_MASK_SIZE]> = vec![0usize; CHANGE_MASK_SIZE] 180 | .into_boxed_slice() 181 | .try_into() 182 | .unwrap(); 183 | // Safety: BitArray is #[repr(transparent)] 184 | let change_mask = unsafe { std::mem::transmute(change_mask) }; 185 | 186 | Self { 187 | change_mask, 188 | senders: HashMap::new(), 189 | options, 190 | } 191 | } 192 | 193 | pub fn mark_bit_changed(&self, bit_index: usize) { 194 | set_bit_atomic(&self.change_mask, bit_index / UPDATE_CHUNK_SIZE_BITS); 195 | } 196 | 197 | pub fn clear(&mut self) { 198 | self.change_mask.fill(false); 199 | } 200 | 201 | pub fn subscribe_chunk(&mut self, chunk_index: usize) -> broadcast::Receiver { 202 | let chunk_index = chunk_index as u32; 203 | if let Some(sender) = self.senders.get(&chunk_index) { 204 | return sender.subscribe(); 205 | } 206 | 207 | let (sender, receiver) = broadcast::channel(self.options.backlog_capacity); 208 | self.senders.insert(chunk_index, sender); 209 | 210 | receiver 211 | } 212 | 213 | pub fn send_changes(&self, chunks: &[BitmapType; CHUNK_COUNT]) { 214 | for i in self.change_mask.iter_ones() { 215 | let offset_in_bits = i * UPDATE_CHUNK_SIZE_BITS; 216 | let chunk_index = (offset_in_bits / CHUNK_SIZE) as u32; 217 | let offset_within_chunk = offset_in_bits % CHUNK_SIZE; 218 | 219 | let sender = if let Some(sender) = self.senders.get(&chunk_index) { 220 | sender 221 | } else { 222 | continue; 223 | }; 224 | 225 | let data = &chunks[chunk_index as usize]; 226 | let byte_offset = offset_within_chunk / 8; 227 | let range = byte_offset..byte_offset + UPDATE_CHUNK_SIZE; 228 | 229 | let chunk_data = data.as_raw_slice()[range].try_into(); 230 | let chunk_data = if let Ok(chunk_data) = chunk_data { 231 | chunk_data 232 | } else { 233 | debug_assert!(false, "Failed to convert slice to array"); 234 | break; 235 | }; 236 | 237 | let change_data = ChangeData { 238 | byte_array_offset: (offset_in_bits / 8) as u32, 239 | chunk_data, 240 | }; 241 | let change = Arc::new(change_data); 242 | let _ = sender.send(change); 243 | } 244 | } 245 | } 246 | 247 | fn set_bit_atomic(bit_slice: &BitArray, index: usize) 248 | where 249 | S: BitViewSized, 250 | O: BitOrder, 251 | { 252 | let slice = bit_slice.as_raw_slice(); 253 | 254 | unsafe { 255 | let ptr = slice.as_ptr().add(index / (size_of::() * 8)) as *mut usize; 256 | let mask = 1 << (index % (size_of::() * 8)); 257 | 258 | AtomicUsize::from_ptr(ptr).fetch_or(mask, Ordering::Relaxed); 259 | } 260 | } 261 | 262 | fn toggle_bit_atomic(bit_slice: &BitArray, index: usize) -> bool 263 | where 264 | S: BitViewSized, 265 | O: BitOrder, 266 | { 267 | let slice = bit_slice.as_raw_slice(); 268 | 269 | unsafe { 270 | let ptr = slice.as_ptr().add(index / (size_of::() * 8)) as *mut usize; 271 | let mask = 1 << (index % (size_of::() * 8)); 272 | 273 | let old = AtomicUsize::from_ptr(ptr).fetch_xor(mask, Ordering::Relaxed); 274 | old & mask != 0 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /client/src/components/Neko.tsx: -------------------------------------------------------------------------------- 1 | import { Component, createRef, RefObject } from "inferno"; 2 | 3 | import neko from "../../assets/neko.png"; 4 | 5 | // ported from https://github.com/tie/oneko/blob/master/oneko.c 6 | 7 | const enum NekoState { 8 | NEKO_STOP = 0, 9 | NEKO_JARE = 1, 10 | NEKO_KAKI = 2, 11 | NEKO_AKUBI = 3, 12 | NEKO_SLEEP = 4, 13 | NEKO_AWAKE = 5, 14 | NEKO_U_MOVE = 6, 15 | NEKO_D_MOVE = 7, 16 | NEKO_L_MOVE = 8, 17 | NEKO_R_MOVE = 9, 18 | NEKO_UL_MOVE = 10, 19 | NEKO_UR_MOVE = 11, 20 | NEKO_DL_MOVE = 12, 21 | NEKO_DR_MOVE = 13, 22 | NEKO_U_TOGI = 14, 23 | NEKO_D_TOGI = 15, 24 | NEKO_L_TOGI = 16, 25 | NEKO_R_TOGI = 17, 26 | } 27 | 28 | const awake = 0; 29 | const down1 = 1; 30 | const down2 = 2; 31 | const dtogi1 = 3; 32 | const dtogi2 = 4; 33 | const dwleft1 = 5; 34 | const dwleft2 = 6; 35 | const dwright1 = 7; 36 | const dwright2 = 8; 37 | const jare2 = 9; 38 | const kaki1 = 10; 39 | const kaki2 = 11; 40 | const left1 = 12; 41 | const left2 = 13; 42 | const ltogi1 = 14; 43 | const ltogi2 = 15; 44 | const mati2 = 16; 45 | const mati3 = 17; 46 | const right1 = 18; 47 | const right2 = 19; 48 | const rtogi1 = 20; 49 | const rtogi2 = 21; 50 | const sleep1 = 22; 51 | const sleep2 = 23; 52 | const up1 = 24; 53 | const up2 = 25; 54 | const upleft1 = 26; 55 | const upleft2 = 27; 56 | const upright1 = 28; 57 | const upright2 = 29; 58 | const utogi1 = 30; 59 | const utogi2 = 31; 60 | 61 | type SpriteType = 62 | | typeof awake 63 | | typeof down1 64 | | typeof down2 65 | | typeof dtogi1 66 | | typeof dtogi2 67 | | typeof dwleft1 68 | | typeof dwleft2 69 | | typeof dwright1 70 | | typeof dwright2 71 | | typeof jare2 72 | | typeof kaki1 73 | | typeof kaki2 74 | | typeof left1 75 | | typeof left2 76 | | typeof ltogi1 77 | | typeof ltogi2 78 | | typeof mati2 79 | | typeof mati3 80 | | typeof right1 81 | | typeof right2 82 | | typeof rtogi1 83 | | typeof rtogi2 84 | | typeof sleep1 85 | | typeof sleep2 86 | | typeof up1 87 | | typeof up2 88 | | typeof upleft1 89 | | typeof upleft2 90 | | typeof upright1 91 | | typeof upright2 92 | | typeof utogi1 93 | | typeof utogi2; 94 | 95 | const NEKO_STOP_TIME = 4; 96 | const NEKO_JARE_TIME = 10; 97 | const NEKO_KAKI_TIME = 4; 98 | const NEKO_AKUBI_TIME = 6; 99 | const NEKO_AWAKE_TIME = 3; 100 | const NEKO_TOGI_TIME = 10; 101 | 102 | const NEKO_SPEED = 13; 103 | const BITMAP_SIZE = 32; 104 | const MAX_TICK = 9999; 105 | 106 | const SIN_PI_PER_8 = Math.sin(Math.PI / 8); 107 | const SIN_PI_PER_8_TIMES_3 = Math.sin((Math.PI / 8) * 3); 108 | 109 | const patterns: Array<[SpriteType, SpriteType]> = [ 110 | [mati2, mati2], 111 | [jare2, mati2], 112 | [kaki1, kaki2], 113 | [mati3, mati3], 114 | [sleep1, sleep2], 115 | [awake, awake], 116 | [up1, up2], 117 | [down1, down2], 118 | [left1, left2], 119 | [right1, right2], 120 | [upleft1, upleft2], 121 | [upright1, upright2], 122 | [dwleft1, dwleft2], 123 | [dwright1, dwright2], 124 | [utogi1, utogi2], 125 | [dtogi1, dtogi2], 126 | [ltogi1, ltogi2], 127 | [rtogi1, rtogi2], 128 | ]; 129 | 130 | interface Point { 131 | x: number; 132 | y: number; 133 | } 134 | 135 | class Neko { 136 | #state: NekoState = NekoState.NEKO_STOP; 137 | #tickCount: number = 0; 138 | #stateCount: number = 0; 139 | 140 | sprite: SpriteType = mati2; 141 | pos: Point = { x: 0, y: 0 }; 142 | #moveDelta: Point = { x: 0, y: 0 }; 143 | 144 | #displaySize: Point = { x: 0, y: 0 }; 145 | #ptr: Point = { x: 0, y: 0 }; 146 | #ptrPrev: Point = { x: 0, y: 0 }; 147 | 148 | constructor(displaySize: Point) { 149 | this.#displaySize = displaySize; 150 | } 151 | 152 | mouseMoved(x: number, y: number) { 153 | this.#ptr.x = x; 154 | this.#ptr.y = y; 155 | } 156 | 157 | setDisplaySize(x: number, y: number) { 158 | this.#displaySize.x = x; 159 | this.#displaySize.y = y; 160 | } 161 | 162 | updateNeko() { 163 | this.#calcDxDy(); 164 | 165 | if (this.#state !== NekoState.NEKO_SLEEP) { 166 | this.sprite = patterns[this.#state][this.#tickCount & 1]; 167 | } else { 168 | this.sprite = patterns[this.#state][(this.#tickCount >> 2) & 1]; 169 | } 170 | 171 | if (++this.#tickCount >= MAX_TICK) { 172 | this.#tickCount = 0; 173 | } 174 | 175 | if (this.#tickCount % 2 === 0) { 176 | if (this.#stateCount < MAX_TICK) { 177 | this.#stateCount++; 178 | } 179 | } 180 | 181 | switch (this.#state) { 182 | case NekoState.NEKO_STOP: 183 | if (this.#checkAwake()) break; 184 | 185 | if (this.#stateCount < NEKO_STOP_TIME) { 186 | break; 187 | } 188 | 189 | if (this.#moveDelta.x < 0 && this.pos.x <= 0) { 190 | this.#setState(NekoState.NEKO_L_TOGI); 191 | } else if (this.#moveDelta.x > 0 && this.pos.x >= this.#displaySize.x - BITMAP_SIZE) { 192 | this.#setState(NekoState.NEKO_R_TOGI); 193 | } else if (this.#moveDelta.y < 0 && this.pos.y <= 0) { 194 | this.#setState(NekoState.NEKO_U_TOGI); 195 | } else if (this.#moveDelta.y > 0 && this.pos.y >= this.#displaySize.y - BITMAP_SIZE) { 196 | this.#setState(NekoState.NEKO_D_TOGI); 197 | } else { 198 | this.#setState(NekoState.NEKO_JARE); 199 | } 200 | break; 201 | case NekoState.NEKO_JARE: 202 | this.#preSleepState(NekoState.NEKO_KAKI, NEKO_JARE_TIME); 203 | break; 204 | case NekoState.NEKO_KAKI: 205 | this.#preSleepState(NekoState.NEKO_AKUBI, NEKO_KAKI_TIME); 206 | break; 207 | case NekoState.NEKO_AKUBI: 208 | this.#preSleepState(NekoState.NEKO_SLEEP, NEKO_AKUBI_TIME); 209 | break; 210 | case NekoState.NEKO_SLEEP: 211 | this.#checkAwake(); 212 | break; 213 | case NekoState.NEKO_AWAKE: 214 | if (this.#stateCount < NEKO_AWAKE_TIME) { 215 | break; 216 | } 217 | this.#nekoDirection(); 218 | break; 219 | case NekoState.NEKO_U_MOVE: 220 | case NekoState.NEKO_D_MOVE: 221 | case NekoState.NEKO_L_MOVE: 222 | case NekoState.NEKO_R_MOVE: 223 | case NekoState.NEKO_UL_MOVE: 224 | case NekoState.NEKO_UR_MOVE: 225 | case NekoState.NEKO_DL_MOVE: 226 | case NekoState.NEKO_DR_MOVE: 227 | this.pos.x += this.#moveDelta.x; 228 | this.pos.y += this.#moveDelta.y; 229 | this.#nekoDirection(); 230 | break; 231 | case NekoState.NEKO_U_TOGI: 232 | case NekoState.NEKO_D_TOGI: 233 | case NekoState.NEKO_L_TOGI: 234 | case NekoState.NEKO_R_TOGI: 235 | this.#preSleepState(NekoState.NEKO_KAKI, NEKO_TOGI_TIME); 236 | break; 237 | default: 238 | break; 239 | } 240 | 241 | this.#ptrPrev.x = this.#ptr.x; 242 | this.#ptrPrev.y = this.#ptr.y; 243 | } 244 | 245 | #checkAwake() { 246 | if (this.#isMoveStart()) { 247 | this.#setState(NekoState.NEKO_AWAKE); 248 | return true; 249 | } 250 | 251 | return false; 252 | } 253 | 254 | #preSleepState(s: NekoState, t: number) { 255 | if (this.#checkAwake()) return; 256 | 257 | if (this.#stateCount < t) { 258 | return; 259 | } 260 | 261 | this.#setState(s); 262 | } 263 | 264 | #setState(state: NekoState) { 265 | this.#state = state; 266 | this.#stateCount = 0; 267 | this.#tickCount = 0; 268 | } 269 | 270 | #nekoDirection() { 271 | let newState = NekoState.NEKO_STOP; 272 | 273 | if (this.#moveDelta.x !== 0 || this.#moveDelta.y !== 0) { 274 | const largeX = this.#moveDelta.x; 275 | const largeY = -this.#moveDelta.y; 276 | const length = Math.sqrt(largeX * largeX + largeY * largeY); 277 | const sinTheta = largeY / length; 278 | 279 | const right = largeX > 0; 280 | 281 | if (sinTheta > SIN_PI_PER_8_TIMES_3) { 282 | newState = NekoState.NEKO_U_MOVE; 283 | } else if (sinTheta <= SIN_PI_PER_8_TIMES_3 && sinTheta > SIN_PI_PER_8) { 284 | newState = right ? NekoState.NEKO_UR_MOVE : NekoState.NEKO_UL_MOVE; 285 | } else if (sinTheta <= SIN_PI_PER_8 && sinTheta > -SIN_PI_PER_8) { 286 | newState = right ? NekoState.NEKO_R_MOVE : NekoState.NEKO_L_MOVE; 287 | } else if (sinTheta <= -SIN_PI_PER_8 && sinTheta > -SIN_PI_PER_8_TIMES_3) { 288 | newState = right ? NekoState.NEKO_DR_MOVE : NekoState.NEKO_DL_MOVE; 289 | } else { 290 | newState = NekoState.NEKO_D_MOVE; 291 | } 292 | } 293 | 294 | if (this.#state !== newState) { 295 | this.#setState(newState); 296 | } 297 | } 298 | 299 | #calcDxDy() { 300 | const largeX = this.#ptr.x - this.pos.x - BITMAP_SIZE / 2; 301 | const largeY = this.#ptr.y - this.pos.y - BITMAP_SIZE; 302 | 303 | const lengthSq = largeX * largeX + largeY * largeY; 304 | 305 | if (lengthSq !== 0) { 306 | const length = Math.sqrt(lengthSq); 307 | 308 | if (length <= NEKO_SPEED) { 309 | this.#moveDelta.x = largeX | 0; 310 | this.#moveDelta.y = largeY | 0; 311 | } else { 312 | this.#moveDelta.x = ((largeX * NEKO_SPEED) / length) | 0; 313 | this.#moveDelta.y = ((largeY * NEKO_SPEED) / length) | 0; 314 | } 315 | } else { 316 | this.#moveDelta.x = 0; 317 | this.#moveDelta.y = 0; 318 | } 319 | } 320 | 321 | #isMoveStart(): boolean { 322 | const dx = Math.abs(this.#ptr.x - this.#ptrPrev.x); 323 | const dy = Math.abs(this.#ptr.y - this.#ptrPrev.y); 324 | const d = 13; 325 | 326 | return dx > d || dy > d; 327 | } 328 | } 329 | 330 | export class NekoComponent extends Component { 331 | neko: Neko; 332 | ref: RefObject; 333 | interval: NodeJS.Timeout | undefined = undefined; 334 | 335 | constructor() { 336 | super(); 337 | this.neko = new Neko({ 338 | x: window.innerWidth, 339 | y: window.innerHeight, 340 | }); 341 | this.ref = createRef(); 342 | } 343 | 344 | componentDidMount(): void { 345 | const dispWidth = window.innerWidth; 346 | const dispHeight = window.innerHeight; 347 | 348 | this.neko.setDisplaySize(dispWidth, dispHeight); 349 | this.neko.pos.x = (dispWidth / 2) | 0; 350 | this.neko.pos.y = (dispHeight / 2) | 0; 351 | 352 | const el = this.ref.current!; 353 | el.style.position = "fixed"; 354 | el.style.zIndex = "9999"; 355 | el.style.width = `${BITMAP_SIZE}px`; 356 | el.style.height = `${BITMAP_SIZE}px`; 357 | el.style.pointerEvents = "none"; 358 | el.style.backgroundImage = `url(${neko})`; 359 | 360 | window.addEventListener("mousemove", this.#onMouseMove); 361 | window.addEventListener("resize", this.#onResize); 362 | this.interval = setInterval(this.#updateNeko, 125); 363 | this.#updateNeko(); 364 | } 365 | 366 | componentWillUnmount(): void { 367 | window.removeEventListener("mousemove", this.#onMouseMove); 368 | window.removeEventListener("resize", this.#onResize); 369 | clearInterval(this.interval); 370 | this.interval = undefined; 371 | } 372 | 373 | #updateNeko = () => { 374 | this.neko.updateNeko(); 375 | 376 | const el = this.ref.current; 377 | if (el) { 378 | el.style.left = this.neko.pos.x + "px"; 379 | el.style.top = this.neko.pos.y + "px"; 380 | el.style.backgroundPosition = `-${this.neko.sprite * BITMAP_SIZE}px 0`; 381 | } 382 | }; 383 | 384 | #onMouseMove = (e: MouseEvent) => { 385 | this.neko.mouseMoved(e.clientX, e.clientY); 386 | }; 387 | 388 | #onResize = () => { 389 | this.neko.setDisplaySize(window.innerWidth, window.innerHeight); 390 | }; 391 | 392 | render() { 393 | return
; 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /client/src/style/style.css: -------------------------------------------------------------------------------- 1 | @import "./fonts.css"; 2 | @import "./color-schemes.css"; 3 | @import "./loading.css"; 4 | 5 | :root { 6 | --highlight-gradient: linear-gradient(90deg, #ffffff10, #ffffff20); 7 | } 8 | 9 | * { 10 | box-sizing: border-box; 11 | } 12 | 13 | body { 14 | background-color: var(--background); 15 | color: var(--text); 16 | margin: 0; 17 | padding: 0; 18 | } 19 | 20 | body, 21 | input, 22 | button, 23 | select { 24 | font-family: 25 | "Outfit Variable", 26 | system-ui, 27 | -apple-system, 28 | BlinkMacSystemFont, 29 | "Segoe UI", 30 | Roboto, 31 | Oxygen, 32 | Ubuntu, 33 | Cantarell, 34 | "Open Sans", 35 | "Helvetica Neue", 36 | sans-serif; 37 | } 38 | 39 | a { 40 | color: var(--blue); 41 | text-decoration: underline; 42 | } 43 | 44 | .secret { 45 | opacity: 0; 46 | transition: opacity 0.5s; 47 | } 48 | 49 | .secret:hover { 50 | opacity: 1; 51 | } 52 | 53 | main { 54 | display: flex; 55 | flex-direction: column; 56 | max-height: 100vh; 57 | } 58 | 59 | .checkbox { 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | } 64 | 65 | .checkbox input { 66 | appearance: none; 67 | background-color: var(--secondary-1); 68 | margin: 0.025em; 69 | 70 | font: inherit; 71 | color: currentColor; 72 | width: 0.95em; 73 | height: 0.95em; 74 | 75 | display: grid; 76 | place-content: center; 77 | } 78 | 79 | .checkbox input:not(.noBorder) { 80 | border: 0.08em solid var(--surface-2); 81 | border-radius: 0.12em; 82 | } 83 | 84 | .checkbox input.default { 85 | transition: 86 | background-color 0.2s, 87 | border-color 0.2s; 88 | } 89 | 90 | .checkbox input::before { 91 | content: ""; 92 | display: block; 93 | width: 0.7em; 94 | height: 0.7em; 95 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 96 | background-color: var(--secondary-2); 97 | } 98 | 99 | .checkbox input:checked { 100 | background-color: var(--active); 101 | border-color: var(--active); 102 | } 103 | 104 | .checkbox input::before.default { 105 | transition: transform 0.2s; 106 | } 107 | 108 | .checkbox input::before { 109 | transform: scale(0); 110 | } 111 | 112 | .checkbox input:checked::before { 113 | transform: scale(1); 114 | } 115 | 116 | .checkbox input.noTick:checked::before { 117 | display: none; 118 | } 119 | 120 | .checkbox input.noTick:not(:checked)::before { 121 | display: none; 122 | } 123 | 124 | .checkbox input:hover.default { 125 | background-color: var(--surface-1); 126 | } 127 | 128 | .checkbox input:checked:hover.default { 129 | background-color: var(--active); 130 | border-color: var(--text); 131 | } 132 | 133 | .checkbox input:focus.default { 134 | outline: none; 135 | box-shadow: 0 0 0 2px var(--subtext-0); 136 | } 137 | 138 | .checkbox input.highlighted.default { 139 | animation: highlight 5s ease-out; 140 | } 141 | 142 | .checkbox span { 143 | font-family: monospace; 144 | font-weight: bolder; 145 | font-size: 0.7rem; 146 | position: absolute; 147 | pointer-events: none; 148 | text-shadow: 149 | -1px -1px 0 #000, 150 | 1px -1px 0 #000, 151 | -1px 1px 0 #000, 152 | 1px 1px 0 #000; 153 | } 154 | 155 | @keyframes highlight { 156 | 0% { 157 | box-shadow: 0 0 5px 5px var(--active); 158 | } 159 | 100% { 160 | box-shadow: 0 0 1px 5px transparent; 161 | } 162 | } 163 | 164 | .btn, 165 | .select { 166 | background-color: var(--surface-1); 167 | color: var(--text); 168 | border: 1px solid #ffffff30; 169 | 170 | font-size: 0.9rem; 171 | font-weight: 500; 172 | border-radius: 8px; 173 | padding: 0.25rem 1.5rem; 174 | cursor: pointer; 175 | transition: 176 | background-color 0.2s, 177 | opacity 0.2s; 178 | } 179 | 180 | .btn-primary { 181 | background-color: var(--active); 182 | color: var(--background); 183 | } 184 | 185 | .btn:hover, 186 | .btn:focus, 187 | .btn:active .select:hover, 188 | .select:focus, 189 | .select:active { 190 | background-image: var(--highlight-gradient); 191 | } 192 | 193 | /* customized 52 | 55 | 56 |
57 | {state.error &&
{state.error}
} 58 | 59 | ); 60 | } 61 | } 62 | 63 | interface ThemePickerState { 64 | theme: string; 65 | } 66 | 67 | class ThemePicker extends Component { 68 | constructor(props: object) { 69 | super(props); 70 | 71 | this.state = { 72 | theme: utils.getCurrentTheme(), 73 | }; 74 | } 75 | 76 | setTheme(theme: string): void { 77 | utils.applyTheme(theme); 78 | this.setState({ theme }); 79 | } 80 | 81 | render(_props: object, state: ThemePickerState) { 82 | return ( 83 |
84 | Theme: 85 | 95 |
96 | ); 97 | } 98 | } 99 | 100 | interface CheckboxStylePreferenceState { 101 | preference: string; 102 | } 103 | 104 | class CheckboxStylePreference extends Component { 105 | constructor(props: object) { 106 | super(props); 107 | 108 | this.state = { 109 | preference: utils.getCheckboxStylePreference(), 110 | }; 111 | } 112 | 113 | setPreference(isChecked: boolean): void { 114 | 115 | const preference = (isChecked) ? "reduced" : "default"; 116 | utils.setCheckboxStylePreference( preference ); 117 | this.setState({ preference: preference }); 118 | } 119 | 120 | render(_props: object, state: CheckboxStylePreferenceState) { 121 | return ( 122 |
123 | Reduced checkbox style: 124 | this.setPreference( e.target.checked )} 128 | checked={this.state?.preference === "reduced"} 129 | $HasNonKeyedChildren 130 | > 131 | {utils.themes.map((theme) => ( 132 | 133 | ))} 134 | 135 |
136 | ); 137 | } 138 | } 139 | 140 | interface TickMarkVisibilityState { 141 | hasTick: boolean; 142 | } 143 | 144 | class TickMarkVisibility extends Component { 145 | constructor(props: object) { 146 | super(props); 147 | 148 | this.state = { 149 | hasTick: utils.getTickMarkVisible(), 150 | }; 151 | } 152 | 153 | setPreference(isChecked: boolean): void { 154 | 155 | const hasTick = !isChecked; 156 | utils.setTickMarkVisible( hasTick ); 157 | this.setState({ hasTick: hasTick }); 158 | } 159 | 160 | render(_props: object, state: TickMarkVisibilityState) { 161 | return ( 162 |
163 | Remove ticks (✔): 164 | this.setPreference( e.target.checked )} 168 | checked={!this.state?.hasTick} 169 | $HasNonKeyedChildren 170 | > 171 | {utils.themes.map((theme) => ( 172 | 173 | ))} 174 | 175 |
176 | ); 177 | } 178 | } 179 | 180 | interface TickMarkBorderVisibilityState { 181 | hasBorder: boolean; 182 | } 183 | 184 | class TickMarkBorderVisibility extends Component { 185 | constructor(props: object) { 186 | super(props); 187 | 188 | this.state = { 189 | hasBorder: utils.getTickMarkBorderVisible(), 190 | }; 191 | } 192 | 193 | setPreference(isChecked: boolean): void { 194 | 195 | const hasBorder = !isChecked; 196 | utils.setTickMarkBorderVisible( hasBorder ); 197 | this.setState({ hasBorder: hasBorder }); 198 | } 199 | 200 | render(_props: object, state: TickMarkBorderVisibilityState) { 201 | return ( 202 |
203 | Remove checkmark borders: 204 | this.setPreference( e.target.checked )} 208 | checked={!this.state?.hasBorder} 209 | $HasNonKeyedChildren 210 | > 211 | {utils.themes.map((theme) => ( 212 | 213 | ))} 214 | 215 |
216 | ); 217 | } 218 | } 219 | 220 | interface ElementVisibilityTogglerState { 221 | isVisible: boolean; 222 | } 223 | 224 | class ElementVisibilityToggler extends Component { 225 | 226 | constructor(props: object) { 227 | super(props); 228 | 229 | this.state = { 230 | isVisible: false, 231 | }; 232 | } 233 | 234 | toggleVisibility(): void { 235 | 236 | const isToggled = Boolean(this.state?.isVisible); 237 | this.setState({ isVisible: !isToggled }); 238 | } 239 | 240 | render(_props: object, state: ElementVisibilityTogglerState) { 241 | return ( 242 | <> 243 |
244 |
this.toggleVisibility()} style={{ cursor: 'pointer'}} className="flex gap-2 mb-2"> 245 | {state.isVisible ? "▼ Hide Preferences" : "▶ Show Preferences"} 246 |
247 | {state.isVisible && ( 248 |
249 | {this.props.children} 250 |
251 | )} 252 |
253 | 254 | 255 | ); 256 | } 257 | } 258 | 259 | interface OverlayProps { 260 | client: BitmapClient; 261 | close: () => void; 262 | } 263 | 264 | class Overlay extends Component { 265 | constructor(props: OverlayProps) { 266 | super(props); 267 | } 268 | 269 | #toggleDebug(): void { 270 | utils.debug.value = !utils.debug.value; 271 | } 272 | 273 | #downloadPage(): void { 274 | const data = this.props.client.getUint8Array(); 275 | const filename = `state-${this.props.client.chunkIndex}.bin`; 276 | utils.downloadUint8Array(data, filename); 277 | } 278 | 279 | #onDebugChange = () => { 280 | this.forceUpdate(); 281 | }; 282 | 283 | componentDidMount(): void { 284 | utils.debug.subscribe(this.#onDebugChange); 285 | } 286 | 287 | componentWillUnmount(): void { 288 | utils.debug.unsubscribe(this.#onDebugChange); 289 | } 290 | 291 | render(props: OverlayProps) { 292 | return ( 293 |
294 |
e.stopPropagation()}> 295 |
296 |

1 billion checkboxes

297 |
299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 |

313 | A stupid project by Alula 314 |

315 | 316 |

317 | Inspired by One Million Checkboxes by @itseieio, but with 318 | around 1073x more checkboxes! 319 |

320 | 321 |

322 | 323 | Source code 324 | 325 | {" | "} 326 | 327 | Protocol docs 328 | 329 | {" | "} 330 | 331 | Changelog 332 | 333 | 334 | {" | "} 335 | this.#toggleDebug()}> 336 | debug 337 | 338 | 339 |

340 | 341 | {utils.debug.value && ( 342 |
343 | you found the hidden debug menu! 344 | 347 |
348 | )} 349 |
350 |
351 | ); 352 | } 353 | } 354 | 355 | interface HeaderProps { 356 | client: BitmapClient; 357 | } 358 | 359 | interface HeaderState { 360 | menuOpen: boolean; 361 | currentClients: number; 362 | checkedCount: number; 363 | } 364 | 365 | export class Header extends Component { 366 | constructor(props: HeaderProps) { 367 | super(props); 368 | 369 | this.state = { 370 | menuOpen: false, 371 | currentClients: props.client.currentClients.value, 372 | checkedCount: props.client.checkedCount.value, 373 | }; 374 | } 375 | 376 | componentDidMount(): void { 377 | const client = this.props.client; 378 | client.currentClients.subscribe(this.#onCurrentClientsChange); 379 | client.checkedCount.subscribe(this.#onCheckedCountChange); 380 | } 381 | 382 | componentWillUnmount(): void { 383 | const client = this.props.client; 384 | client.currentClients.unsubscribe(this.#onCurrentClientsChange); 385 | client.checkedCount.unsubscribe(this.#onCheckedCountChange); 386 | } 387 | 388 | #onPageChange = (value: number): void => { 389 | value = value - 1; 390 | 391 | this.props.client.setChunkIndex(value); 392 | this.forceUpdate(); 393 | }; 394 | 395 | #onCurrentClientsChange = (currentClients: number): void => { 396 | this.setState({ currentClients }); 397 | }; 398 | 399 | #onCheckedCountChange = (checkedCount: number): void => { 400 | this.setState({ checkedCount }); 401 | }; 402 | 403 | #setOpen(menuOpen: boolean): void { 404 | this.setState({ menuOpen }); 405 | } 406 | 407 | render(props: HeaderProps, state: HeaderState) { 408 | const page = props.client.chunkIndex + 1; 409 | const start = props.client.chunkIndex * CHUNK_SIZE; 410 | const end = start + CHUNK_SIZE; 411 | 412 | return ( 413 |
414 |
415 |
416 |
1 billion checkboxes
417 |
1024³ (64⁵) checkboxes
418 |
419 | 420 |
421 | 424 | 425 | 426 | {state.currentClients} {state.currentClients === 1 ? "person" : "people"} online 427 | 428 | 429 | {state.checkedCount} checked on this page 430 |
431 | 432 |
433 | 434 | Page {page} of {CHUNK_COUNT} 435 | 436 | 437 | Checkboxes {start} to {end} 438 | 439 | 445 |
446 |
447 | {state.menuOpen && this.#setOpen(false)} />} 448 |
449 | ); 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /server/src/server.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | bitmap::{Bitmap, Change}, 3 | common::{PResult, METRICS_PATH, STATE_PATH}, 4 | config::Settings, 5 | protocol::{Message, MessageMut, MessageType, PROTOCOL_VERSION_MAJOR, PROTOCOL_VERSION_MINOR}, 6 | }; 7 | use futures_util::AsyncWriteExt; 8 | use serde::{Deserialize, Serialize}; 9 | use signal_hook::consts::{SIGINT, SIGQUIT, SIGTERM}; 10 | use signal_hook_tokio::Signals; 11 | use soketto::{ 12 | extension::deflate::Deflate, 13 | handshake::{server::Response, Server}, 14 | Data, 15 | }; 16 | use std::{ 17 | io, 18 | net::IpAddr, 19 | str::FromStr, 20 | sync::{ 21 | atomic::{AtomicU32, AtomicU64, Ordering}, 22 | Arc, 23 | }, 24 | }; 25 | use tokio::{ 26 | net::TcpStream, 27 | sync::{broadcast, mpsc, Mutex, RwLock}, 28 | task::{JoinHandle, JoinSet}, 29 | }; 30 | use tokio_stream::{wrappers::TcpListenerStream, StreamExt}; 31 | use tokio_util::compat::{Compat, TokioAsyncReadCompatExt}; 32 | 33 | pub struct BitmapServer { 34 | ctx: Arc, 35 | } 36 | 37 | struct SharedServerContext { 38 | settings: Settings, 39 | bitmap: RwLock, 40 | metrics: Arc, 41 | client_id_counter: AtomicU64, 42 | } 43 | 44 | #[derive(Debug, Clone, Copy)] 45 | enum ClientTaskMessage { 46 | Subscribe { chunk: u16 }, 47 | UnsubscribeAll, 48 | SendStats, 49 | } 50 | 51 | impl BitmapServer { 52 | pub fn new(settings: Settings) -> Box { 53 | let mut bitmap = Bitmap::new(); 54 | match bitmap.load_from_file(STATE_PATH) { 55 | Ok(_) => log::info!("Loaded bitmap state from file"), 56 | Err(e) => log::warn!("Failed to load bitmap state from file: {}", e), 57 | } 58 | 59 | let metrics = match Metrics::load_from_file(METRICS_PATH) { 60 | Ok(m) => Arc::new(m), 61 | Err(_) => Arc::new(Metrics::default()), 62 | }; 63 | 64 | metrics.set_checked_bits(bitmap.count_ones() as u32); 65 | 66 | let ctx = Arc::new(SharedServerContext { 67 | settings, 68 | bitmap: RwLock::new(bitmap), 69 | metrics, 70 | client_id_counter: AtomicU64::new(0), 71 | }); 72 | 73 | Box::new(Self { ctx }) 74 | } 75 | 76 | pub async fn run(&self) -> PResult<()> { 77 | let net_task = Self::net_task(self.ctx.clone()); 78 | let bitmap_task = Self::bitmap_task(self.ctx.clone()); 79 | let save_task = Self::save_task(self.ctx.clone()); 80 | 81 | let mut join_set = JoinSet::new(); 82 | join_set.spawn(async move { net_task.await }); 83 | join_set.spawn(async move { bitmap_task.await }); 84 | join_set.spawn(async move { save_task.await }); 85 | 86 | let ctx = self.ctx.clone(); 87 | tokio::spawn(async move { 88 | let mut signals = Signals::new(&[SIGINT, SIGTERM, SIGQUIT]).unwrap(); 89 | let handle = signals.handle(); 90 | 91 | while let Some(signal) = signals.next().await { 92 | log::info!("Quitting due to signal {}", signal); 93 | break; 94 | } 95 | 96 | handle.close(); 97 | 98 | Self::do_save(&ctx).await; 99 | 100 | std::process::exit(0); 101 | }); 102 | 103 | while let Some(result) = join_set.join_next().await { 104 | result??; 105 | } 106 | 107 | Ok(()) 108 | } 109 | 110 | async fn bitmap_task(ctx: Arc) -> PResult<()> { 111 | loop { 112 | tokio::time::sleep(std::time::Duration::from_millis(100)).await; 113 | { 114 | ctx.bitmap.write().await.periodic_send_changes(); 115 | } 116 | } 117 | } 118 | 119 | async fn save_task(ctx: Arc) -> PResult<()> { 120 | loop { 121 | tokio::time::sleep(std::time::Duration::from_secs(600)).await; 122 | Self::do_save(&ctx).await; 123 | } 124 | } 125 | 126 | async fn do_save(ctx: &Arc) { 127 | if let Err(e) = ctx.metrics.save_to_file(METRICS_PATH) { 128 | log::error!("Failed to save metrics: {}", e); 129 | } else { 130 | log::info!("Metrics saved."); 131 | } 132 | 133 | if let Err(e) = ctx.bitmap.write().await.save_to_file(STATE_PATH) { 134 | log::error!("Failed to save state: {}", e); 135 | } else { 136 | log::info!("State saved."); 137 | } 138 | } 139 | 140 | async fn net_task(ctx: Arc) -> PResult<()> { 141 | let listener = tokio::net::TcpListener::bind(&ctx.settings.bind_address).await?; 142 | log::info!("Server running on {}", listener.local_addr()?); 143 | 144 | let mut incoming = TcpListenerStream::new(listener); 145 | 146 | while let Some(socket) = incoming.next().await { 147 | let ctx = ctx.clone(); 148 | tokio::spawn(async move { 149 | let client_id = ctx.client_id_counter.fetch_add(1, Ordering::Relaxed); 150 | ctx.metrics.inc_clients(); 151 | 152 | let result = Self::client_task(client_id, socket, &ctx).await; 153 | if let Err(e) = result { 154 | log::error!("[Client{}] Task error: {}", client_id, e); 155 | } 156 | 157 | log::debug!("[Client{}] Task finished", client_id); 158 | 159 | ctx.metrics.dec_clients(); 160 | }); 161 | } 162 | 163 | Ok(()) 164 | } 165 | 166 | async fn client_task( 167 | client_id: u64, 168 | socket: io::Result, 169 | ctx: &Arc, 170 | ) -> PResult<()> { 171 | let socket = socket?; 172 | let peer_addr = socket.peer_addr().ok(); 173 | let mut server = Server::new(socket.compat()); 174 | 175 | if ctx.settings.ws_permessage_deflate { 176 | let mut deflate = Box::new(Deflate::new(soketto::Mode::Server)); 177 | deflate.set_max_buffer_size(512 * 1024); 178 | server.add_extension(deflate); 179 | } 180 | 181 | let websocket_key = { 182 | let req = server.receive_request().await; 183 | let req = match req { 184 | Ok(req) => req, 185 | Err(_) => { 186 | return Self::try_handle_as_http(ctx, server).await; 187 | } 188 | }; 189 | req.key() 190 | }; 191 | 192 | let ip = peer_addr.map(|addr| addr.ip()); 193 | let ip = if ctx.settings.parse_proxy_headers { 194 | Self::get_ip_from_proxy_headers(&mut server)?.or(ip) 195 | } else { 196 | ip 197 | }; 198 | 199 | if let Some(ip) = ip { 200 | log::info!("[Client{}] New connection from {}", client_id, ip); 201 | } 202 | 203 | let accept = Response::Accept { 204 | key: websocket_key, 205 | protocol: None, 206 | }; 207 | server.send_response(&accept).await?; 208 | 209 | let mut builder = server.into_builder(); 210 | builder.set_max_message_size(512 * 1024); 211 | builder.set_max_message_size(512 * 1024); 212 | let (mut sender, mut receiver) = builder.finish(); 213 | 214 | let mut send_data = Vec::new(); 215 | let mut recv_data = Vec::new(); 216 | 217 | { 218 | let hello = MessageMut::create_message(MessageType::Hello, &mut send_data)?; 219 | if let MessageMut::Hello(hello) = hello { 220 | hello.version_major = PROTOCOL_VERSION_MAJOR; 221 | hello.version_minor = PROTOCOL_VERSION_MINOR; 222 | } 223 | 224 | sender.send_binary(&send_data).await?; 225 | } 226 | 227 | let sender = Arc::new(Mutex::new(sender)); 228 | let (ctm_sender, mut ctm_receiver) = mpsc::channel::(8); 229 | 230 | let mut recv_task: JoinHandle> = { 231 | let ctx = ctx.clone(); 232 | let sender = sender.clone(); 233 | let ctm_sender = ctm_sender.clone(); 234 | tokio::spawn(async move { 235 | let mut send_data = Vec::new(); 236 | 237 | loop { 238 | let data_type = receiver.receive_data(&mut recv_data).await?; 239 | 240 | BitmapServer::client_task_receive( 241 | &ctx, 242 | data_type, 243 | &recv_data, 244 | &mut send_data, 245 | &ctm_sender, 246 | ) 247 | .await?; 248 | 249 | if !send_data.is_empty() { 250 | sender.lock().await.send_binary(&send_data).await?; 251 | } 252 | 253 | recv_data.clear(); 254 | } 255 | }) 256 | }; 257 | 258 | let mut stats_task: JoinHandle> = { 259 | let ctm_sender = ctm_sender.clone(); 260 | tokio::spawn(async move { 261 | loop { 262 | ctm_sender.send(ClientTaskMessage::SendStats).await?; 263 | tokio::time::sleep(std::time::Duration::from_secs(5)).await; 264 | } 265 | }) 266 | }; 267 | 268 | let mut update_receiver = None; 269 | 270 | async fn cond_recv_update( 271 | receiver: &mut Option>, 272 | ) -> Option { 273 | if let Some(receiver) = receiver { 274 | receiver.recv().await.ok() 275 | } else { 276 | std::future::pending().await 277 | } 278 | } 279 | 280 | loop { 281 | // log::info!("[Client{}] Client task loop", client_id); 282 | tokio::select! { 283 | res = &mut recv_task => { 284 | sender.lock().await.close().await?; 285 | return res?; 286 | } 287 | res = &mut stats_task => { 288 | return res?; 289 | } 290 | msg = cond_recv_update(&mut update_receiver) => { 291 | if let Some(msg) = msg { 292 | let psu = MessageMut::create_message(MessageType::PartialStateUpdate, &mut send_data)?; 293 | if let MessageMut::PartialStateUpdate(psu) = psu { 294 | psu.offset = msg.byte_array_offset; 295 | psu.chunk = msg.chunk_data; 296 | } 297 | 298 | sender.lock().await.send_binary(&send_data).await?; 299 | } 300 | } 301 | msg = ctm_receiver.recv() => { 302 | if let Some(ClientTaskMessage::Subscribe { chunk }) = msg { 303 | log::debug!("[Client{}] Received subscribe message for chunk {}", client_id, chunk); 304 | let mut bitmap = ctx.bitmap.write().await; 305 | update_receiver = Some(bitmap.subscribe(chunk as usize)); 306 | } else if let Some(ClientTaskMessage::UnsubscribeAll) = msg { 307 | log::debug!("[Client{}] Received unsubscribe all message", client_id); 308 | update_receiver = None; 309 | } else if let Some(ClientTaskMessage::SendStats) = msg { 310 | log::debug!("[Client{}] Received send stats message", client_id); 311 | let stats = MessageMut::create_message(MessageType::Stats, &mut send_data)?; 312 | if let MessageMut::Stats(stats) = stats { 313 | stats.current_clients = ctx.metrics.clients.load(Ordering::Relaxed); 314 | } 315 | 316 | sender.lock().await.send_binary(&send_data).await?; 317 | } 318 | } 319 | } 320 | } 321 | } 322 | 323 | async fn client_task_receive( 324 | ctx: &Arc, 325 | data_type: Data, 326 | recv_data: &Vec, 327 | send_data: &mut Vec, 328 | ctm_sender: &mpsc::Sender, 329 | ) -> PResult<()> { 330 | if !data_type.is_binary() { 331 | return Ok(()); 332 | } 333 | 334 | let message = Message::from_slice(&recv_data)?; 335 | if !message.id().is_client_message() { 336 | return Ok(()); 337 | } 338 | 339 | send_data.clear(); 340 | 341 | match message { 342 | Message::ChunkFullStateRequest(msg) => { 343 | let full_state = 344 | MessageMut::create_message(MessageType::ChunkFullStateResponse, send_data)?; 345 | 346 | if let MessageMut::ChunkFullStateResponse(full_state) = full_state { 347 | let bitmap = ctx.bitmap.read().await; 348 | full_state.chunk_index = msg.chunk_index; 349 | full_state 350 | .bitmap 351 | .copy_from_slice(bitmap.as_raw_slice(msg.chunk_index as usize)); 352 | } 353 | } 354 | Message::ToggleBit(msg) => { 355 | let idx = msg.index as usize; 356 | log::debug!("Received toggle bit: {}", idx); 357 | let addend = ctx.bitmap.read().await.toggle(idx); 358 | ctx.metrics.inc_checked_bits(addend as i32); 359 | ctx.metrics.inc_bit_toggles(); 360 | } 361 | Message::PartialStateSubscription(msg) => { 362 | ctm_sender 363 | .send(ClientTaskMessage::Subscribe { 364 | chunk: msg.chunk_index, 365 | }) 366 | .await?; 367 | } 368 | Message::PartialStateUnsubscription => { 369 | ctm_sender.send(ClientTaskMessage::UnsubscribeAll).await?; 370 | } 371 | _ => (), 372 | } 373 | 374 | Ok(()) 375 | } 376 | 377 | async fn try_handle_as_http( 378 | ctx: &Arc, 379 | mut server: Server<'_, Compat>, 380 | ) -> PResult<()> { 381 | let mut header_buf = [httparse::EMPTY_HEADER; 32]; 382 | let mut request = httparse::Request::new(&mut header_buf); 383 | 384 | let buffer = server.take_buffer(); 385 | 386 | match request.parse(buffer.as_ref()) { 387 | Ok(httparse::Status::Complete(_)) => (), 388 | _ => return Err(Box::new(BitmapError::InvalidHttp)), 389 | }; 390 | 391 | let mut stream = server.into_inner(); 392 | 393 | if let Some("GET") = request.method { 394 | if let Some("/metrics") = request.path { 395 | let metrics = ctx.metrics.to_prometheus(); 396 | 397 | let response = format!( 398 | "HTTP/1.1 200 OK\r\n\ 399 | Content-Type: text/plain; version=0.0.4\r\n\ 400 | Content-Length: {}\r\n\ 401 | Connection: close\r\n\ 402 | \r\n\ 403 | {}", 404 | metrics.len(), 405 | metrics 406 | ); 407 | 408 | stream.write_all(response.as_bytes()).await?; 409 | } else { 410 | let response = "HTTP/1.1 404 Not Found\r\n\r\nNot found"; 411 | stream.write_all(response.as_bytes()).await?; 412 | } 413 | } 414 | 415 | // let response = Self::handle_http_request(request.method, request.path); 416 | // server.send_response(&response).await?; 417 | 418 | Ok(()) 419 | } 420 | 421 | fn get_ip_from_proxy_headers( 422 | server: &mut Server<'_, Compat>, 423 | ) -> PResult> { 424 | let mut header_buf = [httparse::EMPTY_HEADER; 32]; 425 | let mut request = httparse::Request::new(&mut header_buf); 426 | 427 | let buffer = server.take_buffer(); 428 | 429 | match request.parse(buffer.as_ref()) { 430 | Ok(httparse::Status::Complete(_)) => (), 431 | _ => return Err(Box::new(BitmapError::InvalidHttp)), 432 | }; 433 | 434 | let ip = Self::parse_proxy_headers(request.headers); 435 | 436 | server.set_buffer(buffer); 437 | Ok(ip) 438 | } 439 | 440 | fn parse_proxy_headers(headers: &[httparse::Header<'_>]) -> Option { 441 | for header in headers { 442 | let value = if let Ok(value) = std::str::from_utf8(header.value) { 443 | value 444 | } else { 445 | continue; 446 | }; 447 | 448 | if let Some(ip) = Self::try_parse_header(header.name, value) { 449 | return Some(ip); 450 | } 451 | } 452 | 453 | None 454 | } 455 | 456 | fn try_parse_header(name: &str, value: &str) -> Option { 457 | if name.eq_ignore_ascii_case("CF-Connecting-IP") { 458 | IpAddr::from_str(value.trim()).ok() 459 | } else if name.eq_ignore_ascii_case("X-Forwarded-For") { 460 | let ip = value.split(',').next()?; 461 | let ip = IpAddr::from_str(ip.trim()).ok()?; 462 | 463 | Some(ip) 464 | } else { 465 | None 466 | } 467 | } 468 | } 469 | 470 | #[derive(Debug, Clone, Copy)] 471 | pub enum BitmapError { 472 | InvalidHttp, 473 | } 474 | 475 | impl std::fmt::Display for BitmapError { 476 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 477 | match self { 478 | BitmapError::InvalidHttp => write!(f, "Invalid HTTP request"), 479 | } 480 | } 481 | } 482 | 483 | impl std::error::Error for BitmapError {} 484 | 485 | /// Server statistics 486 | #[derive(Serialize, Deserialize, Default)] 487 | struct Metrics { 488 | #[serde(skip)] 489 | // Number of clients connected 490 | clients: AtomicU32, 491 | // Peak number of clients connected at the same time 492 | peak_clients: AtomicU32, 493 | // Number of currently checked bits 494 | checked_bits: AtomicU32, 495 | // Number of bit toggles 496 | bit_toggles: AtomicU64, 497 | } 498 | 499 | impl Metrics { 500 | pub fn save_to_file(&self, path: &str) -> std::io::Result<()> { 501 | let data = serde_json::to_string(self)?; 502 | std::fs::write(path, data)?; 503 | Ok(()) 504 | } 505 | 506 | pub fn load_from_file(path: &str) -> std::io::Result { 507 | let data = std::fs::read_to_string(path)?; 508 | let stats = serde_json::from_str(&data)?; 509 | Ok(stats) 510 | } 511 | 512 | pub fn inc_clients(&self) { 513 | let clients = self.clients.fetch_add(1, Ordering::Relaxed) + 1; 514 | self.peak_clients.fetch_max(clients, Ordering::Relaxed); 515 | } 516 | 517 | pub fn dec_clients(&self) { 518 | self.clients.fetch_sub(1, Ordering::Relaxed); 519 | } 520 | 521 | pub fn set_checked_bits(&self, value: u32) { 522 | self.checked_bits.store(value, Ordering::Relaxed); 523 | } 524 | 525 | pub fn inc_checked_bits(&self, amount: i32) { 526 | self.checked_bits 527 | .fetch_add(amount as u32, Ordering::Relaxed); 528 | } 529 | 530 | pub fn inc_bit_toggles(&self) { 531 | self.bit_toggles.fetch_add(1, Ordering::Relaxed); 532 | } 533 | 534 | pub fn to_prometheus(&self) -> String { 535 | format!( 536 | "# TYPE bitmap_clients gauge\n\ 537 | # HELP bitmap_clients Number of clients connected\n\ 538 | bitmap_clients {}\n\ 539 | # TYPE bitmap_peak_clients counter\n\ 540 | # HELP bitmap_peak_clients Peak number of clients connected at the same time\n\ 541 | bitmap_peak_clients {}\n\ 542 | # TYPE bitmap_checked_bits gauge\n\ 543 | # HELP bitmap_checked_bits Number of currently checked bits\n\ 544 | bitmap_checked_bits {}\n\ 545 | # TYPE bitmap_bit_toggles counter\n\ 546 | # HELP bitmap_bit_toggles Number of bit toggles\n\ 547 | bitmap_bit_toggles {}\n", 548 | self.clients.load(Ordering::Relaxed), 549 | self.peak_clients.load(Ordering::Relaxed), 550 | self.checked_bits.load(Ordering::Relaxed), 551 | self.bit_toggles.load(Ordering::Relaxed), 552 | ) 553 | } 554 | } 555 | -------------------------------------------------------------------------------- /server/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.22.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "adler2" 22 | version = "2.0.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 25 | 26 | [[package]] 27 | name = "aho-corasick" 28 | version = "1.1.3" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 31 | dependencies = [ 32 | "memchr", 33 | ] 34 | 35 | [[package]] 36 | name = "autocfg" 37 | version = "1.3.0" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" 40 | 41 | [[package]] 42 | name = "backtrace" 43 | version = "0.3.73" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" 46 | dependencies = [ 47 | "addr2line", 48 | "cc", 49 | "cfg-if", 50 | "libc", 51 | "miniz_oxide 0.7.4", 52 | "object", 53 | "rustc-demangle", 54 | ] 55 | 56 | [[package]] 57 | name = "base64" 58 | version = "0.22.1" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 61 | 62 | [[package]] 63 | name = "bitvec" 64 | version = "1.0.1" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" 67 | dependencies = [ 68 | "funty", 69 | "radium", 70 | "tap", 71 | "wyz", 72 | ] 73 | 74 | [[package]] 75 | name = "block-buffer" 76 | version = "0.10.4" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 79 | dependencies = [ 80 | "generic-array", 81 | ] 82 | 83 | [[package]] 84 | name = "byteorder" 85 | version = "1.5.0" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 88 | 89 | [[package]] 90 | name = "bytes" 91 | version = "1.7.1" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" 94 | 95 | [[package]] 96 | name = "cc" 97 | version = "1.1.15" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "57b6a275aa2903740dc87da01c62040406b8812552e97129a63ea8850a17c6e6" 100 | dependencies = [ 101 | "shlex", 102 | ] 103 | 104 | [[package]] 105 | name = "cfg-if" 106 | version = "1.0.0" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 109 | 110 | [[package]] 111 | name = "checkboxes-server" 112 | version = "0.1.0" 113 | dependencies = [ 114 | "bitvec", 115 | "config", 116 | "futures-util", 117 | "httparse", 118 | "log", 119 | "pretty_env_logger", 120 | "serde", 121 | "serde_json", 122 | "signal-hook", 123 | "signal-hook-tokio", 124 | "soketto", 125 | "tokio", 126 | "tokio-stream", 127 | "tokio-util", 128 | "zerocopy", 129 | "zerocopy-derive", 130 | ] 131 | 132 | [[package]] 133 | name = "config" 134 | version = "0.14.0" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "7328b20597b53c2454f0b1919720c25c7339051c02b72b7e05409e00b14132be" 137 | dependencies = [ 138 | "lazy_static", 139 | "nom", 140 | "pathdiff", 141 | "serde", 142 | "toml", 143 | ] 144 | 145 | [[package]] 146 | name = "cpufeatures" 147 | version = "0.2.13" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" 150 | dependencies = [ 151 | "libc", 152 | ] 153 | 154 | [[package]] 155 | name = "crc32fast" 156 | version = "1.4.2" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" 159 | dependencies = [ 160 | "cfg-if", 161 | ] 162 | 163 | [[package]] 164 | name = "crypto-common" 165 | version = "0.1.6" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 168 | dependencies = [ 169 | "generic-array", 170 | "typenum", 171 | ] 172 | 173 | [[package]] 174 | name = "digest" 175 | version = "0.10.7" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 178 | dependencies = [ 179 | "block-buffer", 180 | "crypto-common", 181 | ] 182 | 183 | [[package]] 184 | name = "env_logger" 185 | version = "0.10.2" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" 188 | dependencies = [ 189 | "humantime", 190 | "is-terminal", 191 | "log", 192 | "regex", 193 | "termcolor", 194 | ] 195 | 196 | [[package]] 197 | name = "equivalent" 198 | version = "1.0.1" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 201 | 202 | [[package]] 203 | name = "flate2" 204 | version = "1.0.33" 205 | source = "registry+https://github.com/rust-lang/crates.io-index" 206 | checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" 207 | dependencies = [ 208 | "crc32fast", 209 | "libz-sys", 210 | "miniz_oxide 0.8.0", 211 | ] 212 | 213 | [[package]] 214 | name = "funty" 215 | version = "2.0.0" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" 218 | 219 | [[package]] 220 | name = "futures" 221 | version = "0.3.30" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" 224 | dependencies = [ 225 | "futures-channel", 226 | "futures-core", 227 | "futures-io", 228 | "futures-sink", 229 | "futures-task", 230 | "futures-util", 231 | ] 232 | 233 | [[package]] 234 | name = "futures-channel" 235 | version = "0.3.30" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" 238 | dependencies = [ 239 | "futures-core", 240 | "futures-sink", 241 | ] 242 | 243 | [[package]] 244 | name = "futures-core" 245 | version = "0.3.30" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" 248 | 249 | [[package]] 250 | name = "futures-io" 251 | version = "0.3.30" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" 254 | 255 | [[package]] 256 | name = "futures-macro" 257 | version = "0.3.30" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" 260 | dependencies = [ 261 | "proc-macro2", 262 | "quote", 263 | "syn", 264 | ] 265 | 266 | [[package]] 267 | name = "futures-sink" 268 | version = "0.3.30" 269 | source = "registry+https://github.com/rust-lang/crates.io-index" 270 | checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" 271 | 272 | [[package]] 273 | name = "futures-task" 274 | version = "0.3.30" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" 277 | 278 | [[package]] 279 | name = "futures-util" 280 | version = "0.3.30" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" 283 | dependencies = [ 284 | "futures-channel", 285 | "futures-core", 286 | "futures-io", 287 | "futures-macro", 288 | "futures-sink", 289 | "futures-task", 290 | "memchr", 291 | "pin-project-lite", 292 | "pin-utils", 293 | "slab", 294 | ] 295 | 296 | [[package]] 297 | name = "generic-array" 298 | version = "0.14.7" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 301 | dependencies = [ 302 | "typenum", 303 | "version_check", 304 | ] 305 | 306 | [[package]] 307 | name = "getrandom" 308 | version = "0.2.15" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 311 | dependencies = [ 312 | "cfg-if", 313 | "libc", 314 | "wasi", 315 | ] 316 | 317 | [[package]] 318 | name = "gimli" 319 | version = "0.29.0" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" 322 | 323 | [[package]] 324 | name = "hashbrown" 325 | version = "0.14.5" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 328 | 329 | [[package]] 330 | name = "hermit-abi" 331 | version = "0.3.9" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 334 | 335 | [[package]] 336 | name = "hermit-abi" 337 | version = "0.4.0" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" 340 | 341 | [[package]] 342 | name = "httparse" 343 | version = "1.9.4" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" 346 | 347 | [[package]] 348 | name = "humantime" 349 | version = "2.1.0" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 352 | 353 | [[package]] 354 | name = "indexmap" 355 | version = "2.5.0" 356 | source = "registry+https://github.com/rust-lang/crates.io-index" 357 | checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" 358 | dependencies = [ 359 | "equivalent", 360 | "hashbrown", 361 | ] 362 | 363 | [[package]] 364 | name = "is-terminal" 365 | version = "0.4.13" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" 368 | dependencies = [ 369 | "hermit-abi 0.4.0", 370 | "libc", 371 | "windows-sys 0.52.0", 372 | ] 373 | 374 | [[package]] 375 | name = "itoa" 376 | version = "1.0.11" 377 | source = "registry+https://github.com/rust-lang/crates.io-index" 378 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 379 | 380 | [[package]] 381 | name = "lazy_static" 382 | version = "1.5.0" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 385 | 386 | [[package]] 387 | name = "libc" 388 | version = "0.2.158" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" 391 | 392 | [[package]] 393 | name = "libz-sys" 394 | version = "1.1.20" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" 397 | dependencies = [ 398 | "cc", 399 | "pkg-config", 400 | "vcpkg", 401 | ] 402 | 403 | [[package]] 404 | name = "log" 405 | version = "0.4.22" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 408 | 409 | [[package]] 410 | name = "memchr" 411 | version = "2.7.4" 412 | source = "registry+https://github.com/rust-lang/crates.io-index" 413 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 414 | 415 | [[package]] 416 | name = "minimal-lexical" 417 | version = "0.2.1" 418 | source = "registry+https://github.com/rust-lang/crates.io-index" 419 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 420 | 421 | [[package]] 422 | name = "miniz_oxide" 423 | version = "0.7.4" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" 426 | dependencies = [ 427 | "adler", 428 | ] 429 | 430 | [[package]] 431 | name = "miniz_oxide" 432 | version = "0.8.0" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" 435 | dependencies = [ 436 | "adler2", 437 | ] 438 | 439 | [[package]] 440 | name = "mio" 441 | version = "1.0.2" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" 444 | dependencies = [ 445 | "hermit-abi 0.3.9", 446 | "libc", 447 | "wasi", 448 | "windows-sys 0.52.0", 449 | ] 450 | 451 | [[package]] 452 | name = "nom" 453 | version = "7.1.3" 454 | source = "registry+https://github.com/rust-lang/crates.io-index" 455 | checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" 456 | dependencies = [ 457 | "memchr", 458 | "minimal-lexical", 459 | ] 460 | 461 | [[package]] 462 | name = "object" 463 | version = "0.36.4" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" 466 | dependencies = [ 467 | "memchr", 468 | ] 469 | 470 | [[package]] 471 | name = "pathdiff" 472 | version = "0.2.1" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" 475 | 476 | [[package]] 477 | name = "pin-project-lite" 478 | version = "0.2.14" 479 | source = "registry+https://github.com/rust-lang/crates.io-index" 480 | checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" 481 | 482 | [[package]] 483 | name = "pin-utils" 484 | version = "0.1.0" 485 | source = "registry+https://github.com/rust-lang/crates.io-index" 486 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 487 | 488 | [[package]] 489 | name = "pkg-config" 490 | version = "0.3.30" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" 493 | 494 | [[package]] 495 | name = "ppv-lite86" 496 | version = "0.2.20" 497 | source = "registry+https://github.com/rust-lang/crates.io-index" 498 | checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" 499 | dependencies = [ 500 | "zerocopy", 501 | ] 502 | 503 | [[package]] 504 | name = "pretty_env_logger" 505 | version = "0.5.0" 506 | source = "registry+https://github.com/rust-lang/crates.io-index" 507 | checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" 508 | dependencies = [ 509 | "env_logger", 510 | "log", 511 | ] 512 | 513 | [[package]] 514 | name = "proc-macro2" 515 | version = "1.0.86" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 518 | dependencies = [ 519 | "unicode-ident", 520 | ] 521 | 522 | [[package]] 523 | name = "quote" 524 | version = "1.0.37" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 527 | dependencies = [ 528 | "proc-macro2", 529 | ] 530 | 531 | [[package]] 532 | name = "radium" 533 | version = "0.7.0" 534 | source = "registry+https://github.com/rust-lang/crates.io-index" 535 | checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" 536 | 537 | [[package]] 538 | name = "rand" 539 | version = "0.8.5" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 542 | dependencies = [ 543 | "libc", 544 | "rand_chacha", 545 | "rand_core", 546 | ] 547 | 548 | [[package]] 549 | name = "rand_chacha" 550 | version = "0.3.1" 551 | source = "registry+https://github.com/rust-lang/crates.io-index" 552 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 553 | dependencies = [ 554 | "ppv-lite86", 555 | "rand_core", 556 | ] 557 | 558 | [[package]] 559 | name = "rand_core" 560 | version = "0.6.4" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 563 | dependencies = [ 564 | "getrandom", 565 | ] 566 | 567 | [[package]] 568 | name = "regex" 569 | version = "1.10.6" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" 572 | dependencies = [ 573 | "aho-corasick", 574 | "memchr", 575 | "regex-automata", 576 | "regex-syntax", 577 | ] 578 | 579 | [[package]] 580 | name = "regex-automata" 581 | version = "0.4.7" 582 | source = "registry+https://github.com/rust-lang/crates.io-index" 583 | checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" 584 | dependencies = [ 585 | "aho-corasick", 586 | "memchr", 587 | "regex-syntax", 588 | ] 589 | 590 | [[package]] 591 | name = "regex-syntax" 592 | version = "0.8.4" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" 595 | 596 | [[package]] 597 | name = "rustc-demangle" 598 | version = "0.1.24" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 601 | 602 | [[package]] 603 | name = "ryu" 604 | version = "1.0.18" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 607 | 608 | [[package]] 609 | name = "serde" 610 | version = "1.0.209" 611 | source = "registry+https://github.com/rust-lang/crates.io-index" 612 | checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" 613 | dependencies = [ 614 | "serde_derive", 615 | ] 616 | 617 | [[package]] 618 | name = "serde_derive" 619 | version = "1.0.209" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" 622 | dependencies = [ 623 | "proc-macro2", 624 | "quote", 625 | "syn", 626 | ] 627 | 628 | [[package]] 629 | name = "serde_json" 630 | version = "1.0.127" 631 | source = "registry+https://github.com/rust-lang/crates.io-index" 632 | checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad" 633 | dependencies = [ 634 | "itoa", 635 | "memchr", 636 | "ryu", 637 | "serde", 638 | ] 639 | 640 | [[package]] 641 | name = "serde_spanned" 642 | version = "0.6.7" 643 | source = "registry+https://github.com/rust-lang/crates.io-index" 644 | checksum = "eb5b1b31579f3811bf615c144393417496f152e12ac8b7663bf664f4a815306d" 645 | dependencies = [ 646 | "serde", 647 | ] 648 | 649 | [[package]] 650 | name = "sha1" 651 | version = "0.10.6" 652 | source = "registry+https://github.com/rust-lang/crates.io-index" 653 | checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 654 | dependencies = [ 655 | "cfg-if", 656 | "cpufeatures", 657 | "digest", 658 | ] 659 | 660 | [[package]] 661 | name = "shlex" 662 | version = "1.3.0" 663 | source = "registry+https://github.com/rust-lang/crates.io-index" 664 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 665 | 666 | [[package]] 667 | name = "signal-hook" 668 | version = "0.3.17" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 671 | dependencies = [ 672 | "libc", 673 | "signal-hook-registry", 674 | ] 675 | 676 | [[package]] 677 | name = "signal-hook-registry" 678 | version = "1.4.2" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 681 | dependencies = [ 682 | "libc", 683 | ] 684 | 685 | [[package]] 686 | name = "signal-hook-tokio" 687 | version = "0.3.1" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "213241f76fb1e37e27de3b6aa1b068a2c333233b59cca6634f634b80a27ecf1e" 690 | dependencies = [ 691 | "futures-core", 692 | "libc", 693 | "signal-hook", 694 | "tokio", 695 | ] 696 | 697 | [[package]] 698 | name = "slab" 699 | version = "0.4.9" 700 | source = "registry+https://github.com/rust-lang/crates.io-index" 701 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 702 | dependencies = [ 703 | "autocfg", 704 | ] 705 | 706 | [[package]] 707 | name = "socket2" 708 | version = "0.5.7" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" 711 | dependencies = [ 712 | "libc", 713 | "windows-sys 0.52.0", 714 | ] 715 | 716 | [[package]] 717 | name = "soketto" 718 | version = "0.8.0" 719 | source = "git+https://github.com/alula/soketto.git?rev=c51864b69445a38dbc700547b0c7185d3211fcf3#c51864b69445a38dbc700547b0c7185d3211fcf3" 720 | dependencies = [ 721 | "base64", 722 | "bytes", 723 | "flate2", 724 | "futures", 725 | "httparse", 726 | "log", 727 | "rand", 728 | "sha1", 729 | ] 730 | 731 | [[package]] 732 | name = "syn" 733 | version = "2.0.76" 734 | source = "registry+https://github.com/rust-lang/crates.io-index" 735 | checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525" 736 | dependencies = [ 737 | "proc-macro2", 738 | "quote", 739 | "unicode-ident", 740 | ] 741 | 742 | [[package]] 743 | name = "tap" 744 | version = "1.0.1" 745 | source = "registry+https://github.com/rust-lang/crates.io-index" 746 | checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" 747 | 748 | [[package]] 749 | name = "termcolor" 750 | version = "1.4.1" 751 | source = "registry+https://github.com/rust-lang/crates.io-index" 752 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" 753 | dependencies = [ 754 | "winapi-util", 755 | ] 756 | 757 | [[package]] 758 | name = "tokio" 759 | version = "1.40.0" 760 | source = "registry+https://github.com/rust-lang/crates.io-index" 761 | checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" 762 | dependencies = [ 763 | "backtrace", 764 | "libc", 765 | "mio", 766 | "pin-project-lite", 767 | "socket2", 768 | "tokio-macros", 769 | "windows-sys 0.52.0", 770 | ] 771 | 772 | [[package]] 773 | name = "tokio-macros" 774 | version = "2.4.0" 775 | source = "registry+https://github.com/rust-lang/crates.io-index" 776 | checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" 777 | dependencies = [ 778 | "proc-macro2", 779 | "quote", 780 | "syn", 781 | ] 782 | 783 | [[package]] 784 | name = "tokio-stream" 785 | version = "0.1.15" 786 | source = "registry+https://github.com/rust-lang/crates.io-index" 787 | checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" 788 | dependencies = [ 789 | "futures-core", 790 | "pin-project-lite", 791 | "tokio", 792 | ] 793 | 794 | [[package]] 795 | name = "tokio-util" 796 | version = "0.7.11" 797 | source = "registry+https://github.com/rust-lang/crates.io-index" 798 | checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" 799 | dependencies = [ 800 | "bytes", 801 | "futures-core", 802 | "futures-io", 803 | "futures-sink", 804 | "pin-project-lite", 805 | "tokio", 806 | ] 807 | 808 | [[package]] 809 | name = "toml" 810 | version = "0.8.19" 811 | source = "registry+https://github.com/rust-lang/crates.io-index" 812 | checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" 813 | dependencies = [ 814 | "serde", 815 | "serde_spanned", 816 | "toml_datetime", 817 | "toml_edit", 818 | ] 819 | 820 | [[package]] 821 | name = "toml_datetime" 822 | version = "0.6.8" 823 | source = "registry+https://github.com/rust-lang/crates.io-index" 824 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 825 | dependencies = [ 826 | "serde", 827 | ] 828 | 829 | [[package]] 830 | name = "toml_edit" 831 | version = "0.22.20" 832 | source = "registry+https://github.com/rust-lang/crates.io-index" 833 | checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d" 834 | dependencies = [ 835 | "indexmap", 836 | "serde", 837 | "serde_spanned", 838 | "toml_datetime", 839 | "winnow", 840 | ] 841 | 842 | [[package]] 843 | name = "typenum" 844 | version = "1.17.0" 845 | source = "registry+https://github.com/rust-lang/crates.io-index" 846 | checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 847 | 848 | [[package]] 849 | name = "unicode-ident" 850 | version = "1.0.12" 851 | source = "registry+https://github.com/rust-lang/crates.io-index" 852 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 853 | 854 | [[package]] 855 | name = "vcpkg" 856 | version = "0.2.15" 857 | source = "registry+https://github.com/rust-lang/crates.io-index" 858 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 859 | 860 | [[package]] 861 | name = "version_check" 862 | version = "0.9.5" 863 | source = "registry+https://github.com/rust-lang/crates.io-index" 864 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 865 | 866 | [[package]] 867 | name = "wasi" 868 | version = "0.11.0+wasi-snapshot-preview1" 869 | source = "registry+https://github.com/rust-lang/crates.io-index" 870 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 871 | 872 | [[package]] 873 | name = "winapi-util" 874 | version = "0.1.9" 875 | source = "registry+https://github.com/rust-lang/crates.io-index" 876 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 877 | dependencies = [ 878 | "windows-sys 0.59.0", 879 | ] 880 | 881 | [[package]] 882 | name = "windows-sys" 883 | version = "0.52.0" 884 | source = "registry+https://github.com/rust-lang/crates.io-index" 885 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 886 | dependencies = [ 887 | "windows-targets", 888 | ] 889 | 890 | [[package]] 891 | name = "windows-sys" 892 | version = "0.59.0" 893 | source = "registry+https://github.com/rust-lang/crates.io-index" 894 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 895 | dependencies = [ 896 | "windows-targets", 897 | ] 898 | 899 | [[package]] 900 | name = "windows-targets" 901 | version = "0.52.6" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 904 | dependencies = [ 905 | "windows_aarch64_gnullvm", 906 | "windows_aarch64_msvc", 907 | "windows_i686_gnu", 908 | "windows_i686_gnullvm", 909 | "windows_i686_msvc", 910 | "windows_x86_64_gnu", 911 | "windows_x86_64_gnullvm", 912 | "windows_x86_64_msvc", 913 | ] 914 | 915 | [[package]] 916 | name = "windows_aarch64_gnullvm" 917 | version = "0.52.6" 918 | source = "registry+https://github.com/rust-lang/crates.io-index" 919 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 920 | 921 | [[package]] 922 | name = "windows_aarch64_msvc" 923 | version = "0.52.6" 924 | source = "registry+https://github.com/rust-lang/crates.io-index" 925 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 926 | 927 | [[package]] 928 | name = "windows_i686_gnu" 929 | version = "0.52.6" 930 | source = "registry+https://github.com/rust-lang/crates.io-index" 931 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 932 | 933 | [[package]] 934 | name = "windows_i686_gnullvm" 935 | version = "0.52.6" 936 | source = "registry+https://github.com/rust-lang/crates.io-index" 937 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 938 | 939 | [[package]] 940 | name = "windows_i686_msvc" 941 | version = "0.52.6" 942 | source = "registry+https://github.com/rust-lang/crates.io-index" 943 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 944 | 945 | [[package]] 946 | name = "windows_x86_64_gnu" 947 | version = "0.52.6" 948 | source = "registry+https://github.com/rust-lang/crates.io-index" 949 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 950 | 951 | [[package]] 952 | name = "windows_x86_64_gnullvm" 953 | version = "0.52.6" 954 | source = "registry+https://github.com/rust-lang/crates.io-index" 955 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 956 | 957 | [[package]] 958 | name = "windows_x86_64_msvc" 959 | version = "0.52.6" 960 | source = "registry+https://github.com/rust-lang/crates.io-index" 961 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 962 | 963 | [[package]] 964 | name = "winnow" 965 | version = "0.6.18" 966 | source = "registry+https://github.com/rust-lang/crates.io-index" 967 | checksum = "68a9bda4691f099d435ad181000724da8e5899daa10713c2d432552b9ccd3a6f" 968 | dependencies = [ 969 | "memchr", 970 | ] 971 | 972 | [[package]] 973 | name = "wyz" 974 | version = "0.5.1" 975 | source = "registry+https://github.com/rust-lang/crates.io-index" 976 | checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" 977 | dependencies = [ 978 | "tap", 979 | ] 980 | 981 | [[package]] 982 | name = "zerocopy" 983 | version = "0.7.35" 984 | source = "registry+https://github.com/rust-lang/crates.io-index" 985 | checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 986 | dependencies = [ 987 | "byteorder", 988 | "zerocopy-derive", 989 | ] 990 | 991 | [[package]] 992 | name = "zerocopy-derive" 993 | version = "0.7.35" 994 | source = "registry+https://github.com/rust-lang/crates.io-index" 995 | checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 996 | dependencies = [ 997 | "proc-macro2", 998 | "quote", 999 | "syn", 1000 | ] 1001 | --------------------------------------------------------------------------------