init commit

This commit is contained in:
cloudwithax 2026-02-13 18:12:13 -05:00
commit 99e5a1d0ec
96 changed files with 16980 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.env
relay-server/
.vscode

428
bun.lock Normal file
View File

@ -0,0 +1,428 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "dusk-chat",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.0.0",
"@fontsource/space-grotesk": "^5.2.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"lucide-solid": "^0.469.0",
"motion": "^12.0.0",
"solid-js": "^1.9.3",
"solid-motionone": "^1.0.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vite-plugin-solid": "^2.11.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="],
"@fontsource/space-grotesk": ["@fontsource/space-grotesk@5.2.10", "", {}, "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
"@motionone/dom": ["@motionone/dom@10.18.0", "", { "dependencies": { "@motionone/animation": "^10.18.0", "@motionone/generators": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-bKLP7E0eyO4B2UaHBBN55tnppwRnaE3KFfh3Ps9HhnAkar3Cb69kUCJY9as8LrccVYKgHA+JY5dOQqJLOPhF5A=="],
"@motionone/easing": ["@motionone/easing@10.18.0", "", { "dependencies": { "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg=="],
"@motionone/generators": ["@motionone/generators@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg=="],
"@motionone/types": ["@motionone/types@10.17.1", "", {}, "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A=="],
"@motionone/utils": ["@motionone/utils@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],
"@solid-primitives/transition-group": ["@solid-primitives/transition-group@1.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-x64-msvc": "2.10.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.0", "", { "os": "linux", "cpu": "arm" }, "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.0", "", { "os": "linux", "cpu": "none" }, "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.0", "", { "os": "win32", "cpu": "x64" }, "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ=="],
"@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="],
"babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"framer-motion": ["framer-motion@12.34.0", "", { "dependencies": { "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-solid": ["lucide-solid@0.469.0", "", { "peerDependencies": { "solid-js": "^1.4.7" } }, "sha512-kBZl5AFg02g/wcwaapTwOwjHw0VvyyFmZm3BE6McKjs0GjiauWtjYbrhf2bCtDpScEtcinhIG/LpRExBlIV3fA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"motion": ["motion@12.34.0", "", { "dependencies": { "framer-motion": "^12.34.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-01Sfa/zgsD/di8zA/uFW5Eb7/SPXoGyUfy+uMRMW5Spa8j0z/UbfQewAYvPMYFCXRlyD6e5aLHh76TxeeJD+RA=="],
"motion-dom": ["motion-dom@12.34.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
"solid-motionone": ["solid-motionone@1.0.4", "", { "dependencies": { "@motionone/dom": "^10.17.0", "@motionone/utils": "^10.17.0", "@solid-primitives/props": "^3.1.11", "@solid-primitives/refs": "^1.0.8", "@solid-primitives/transition-group": "^1.0.5", "csstype": "^3.1.3" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-aqEjgecoO9raDFznu/dEci7ORSmA26Kjj9J4Cn1Gyr0GZuOVdvsNxdxClTL9J40Aq/uYFx4GLwC8n70fMLHiuA=="],
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
}
}

347
dusk-design-guidelines.md Executable file
View File

@ -0,0 +1,347 @@
Discord-like Chat App — Design Specification
1. Overview
This chat application embodies a minimalist, high-contrast aesthetic rooted in Dutch modernism and the systematic rigor of Wim Crouwel and De Stijl. The design employs a bold grotesque sans-serif for hierarchy paired with monospace type for technical details, creating a visual language that feels both contemporary and utilitarian.
International orange serves as the sole accent color—a deliberate, high-energy choice that activates the otherwise austere black-and-white foundation. The layout is governed by a strict modular grid, ensuring every element aligns to a mathematically consistent rhythm. Motion design follows Motion principles (https://motion.dev/): smooth, purposeful, and never gratuitous.
Navigation transforms the experience through a full-screen overlay menu, reinforcing the app's bold, confident spatial decisions. Every interaction is deliberate, every transition calculated, every whitespace meaningful.
2. Visual System
Typography Hierarchy
Primary Typeface: Space Grotesk (Bold Grotesque)
Weights: 400 (Regular), 500 (Medium), 700 (Bold)
Usage:
Display/Headers: 700 weight, tight letter-spacing (-0.02em)
Body text: 400 weight, normal spacing
UI labels: 500 weight, uppercase with +0.05em tracking
Interactive elements: 500 weight
Secondary Typeface: JetBrains Mono (Monospace)
Weights: 400 (Regular), 500 (Medium)
Usage:
Timestamps: 400 weight, 12px
User IDs / technical metadata: 400 weight
Code blocks or system messages: 400 weight
Status indicators: 500 weight, uppercase
Scale (Desktop):
Display: 48px / 56px line-height
H1: 32px / 40px
H2: 24px / 32px
H3: 20px / 28px
Body: 16px / 24px
Small: 14px / 20px
Micro: 12px / 16px
Scale (Mobile):
Display: 32px / 40px
H1: 24px / 32px
H2: 20px / 28px
Body: 16px / 24px
Small: 14px / 20px
Micro: 12px / 16px
Color Palette
Base Colors:
Pure Black: #000000 — primary background, text on light surfaces
Off White: #FAFAFA — light background variant, cards
True White: #FFFFFF — text on dark surfaces, highest contrast elements
Contrast Layers:
Gray 900: #0A0A0A — secondary dark surface
Gray 800: #1A1A1A — elevated dark surface
Gray 200: #E5E5E5 — borders on light mode
Gray 300: #D4D4D4 — subtle dividers
Accent:
International Orange: #FF4F00 — primary accent for CTAs, active states, highlights
Orange Hover: #E64500 — hover state darkening
Orange Muted: #FF4F0015 — 8% opacity backgrounds for subtle emphasis
Semantic Colors:
Success: #00FF00 — online status, success states
Warning: #FFFF00 — warnings
Error: #FF0000 — errors, destructive actions
Spacing + Grid Philosophy
Base Unit: 8px
Spacing Scale:
xs: 4px
sm: 8px
md: 16px
lg: 24px
xl: 32px
2xl: 48px
3xl: 64px
4xl: 96px
Grid System:
Desktop: 12-column grid, 24px gutter, 1440px max-width container
Tablet: 8-column grid, 16px gutter, 100% width
Mobile: 4-column grid, 16px gutter, 100% width
Modular Rhythm:
All components snap to 8px vertical rhythm. Section padding follows 64px (desktop) / 48px (tablet) / 32px (mobile) increments.
Iconography + Motifs
Icon System: Lucide Vue
Stroke Width: 2px (consistent with grotesque weight)
Size Scale: 16px, 20px, 24px, 32px
Style: Sharp, geometric, minimal
Geometric Motifs:
45° diagonal elements for dynamic composition
Perfect squares and circles for buttons/avatars
2px solid borders (never rounded corners on containers)
Right angles maintained throughout
Avatar System:
Perfect circles for user avatars (only curved element)
32px (small), 40px (medium), 48px (large), 64px (profile)
Status indicator: 8px circle, bottom-right overlap with 2px white border 3. Layout Design
Page Structure
Three-Column Layout (Desktop):
┌────────────┬─────────────────────────┬────────────┐
│ Server │ Main Chat Area │ Sidebar │
│ List │ │ (Users/ │
│ 64px │ Fluid │ Info) │
│ │ │ 280px │
└────────────┴─────────────────────────┴────────────┘
Two-Column Layout (Tablet):
┌────────────┬─────────────────────────┐
│ Channel │ Main Chat Area │
│ List │ │
│ 240px │ Fluid │
│ │ │
└────────────┴─────────────────────────┘
Single-Column Layout (Mobile):
Full-screen chat view
Hamburger menu reveals server/channel navigation as full-screen overlay
Swipe gestures for quick navigation
Component Designs
Server List (Left Sidebar - Desktop)
64px fixed width
Black background #000000
Server icons: 48px squares, 8px margin top/bottom
Active server: 4px international orange border-left
Hover: subtle scale to 1.05, 200ms ease-out
Channel List (Secondary Sidebar)
240px width on desktop, collapses on tablet
Background: #0A0A0A
Section headers: uppercase, 500 weight, 12px, #FFFFFF at 60% opacity
Channel items:
16px font, 400 weight
40px height, 8px padding-left
Hover: #1A1A1A background
Active: international orange left border (4px), #FFFFFF text
Unread indicator: 6px orange circle, right-aligned
Main Chat Area
Background: #000000
Messages:
Avatar: 40px circle, left-aligned
Username: 500 weight, 16px, #FFFFFF
Timestamp: JetBrains Mono, 12px, #FFFFFF at 50% opacity
Message text: 400 weight, 16px, #FFFFFF at 90% opacity
Spacing: 16px between messages, 8px between text lines
Hover: #0A0A0A background on entire message block
Message Input
Fixed bottom position
Height: 64px
Background: #1A1A1A
Border-top: 1px solid #FFFFFF at 10% opacity
Input field:
Background: #000000
Border: 2px solid #FFFFFF at 20% opacity
Focus: 2px solid international orange
Padding: 12px 16px
Placeholder: JetBrains Mono, #FFFFFF at 40% opacity
Buttons
Primary (Orange):
Background: #FF4F00
Text: #FFFFFF, 500 weight, uppercase, +0.05em tracking
Padding: 12px 24px
Height: 48px
Border: none
Hover: #E64500 background, scale 0.98
Active: scale 0.96
Secondary (Ghost):
Background: transparent
Border: 2px solid #FFFFFF
Text: #FFFFFF, 500 weight, uppercase
Hover: background #FFFFFF, text #000000
Icon Button:
40px × 40px square
Background: #1A1A1A
Hover: background #FF4F00, icon color #FFFFFF
Cards/Modals
Background: #0A0A0A
Border: 2px solid #FFFFFF at 20% opacity
Padding: 32px
Shadow: none (rely on borders for hierarchy)
Title: 24px, 700 weight, #FFFFFF
Dividers: 1px solid #FFFFFF at 10% opacity
Navigation Overlay (Full-Screen Menu)
Background: #000000
Z-index: 1000
Menu items:
48px font, 700 weight, #FFFFFF
Hover: international orange color, translate-x 16px
Stagger animation on entrance (100ms delay per item)
Close button: top-right, 48px × 48px, white X icon
Backdrop blur: none (solid black)
User Sidebar (Right - Desktop)
280px width
Background: #0A0A0A
Member list:
Role headers: uppercase, JetBrains Mono, 12px, orange
User items: 40px height, avatar + name
Status dot: 8px, green/yellow/gray
Hover: #1A1A1A background
Responsive Variations
Desktop (1440px+):
Full three-column layout
All interactions visible
Spacious padding (32px standard)
Tablet (768px - 1439px):
Two-column: channels + chat
Right sidebar accessible via icon toggle
Reduced padding (24px)
Mobile (< 768px):
Single column, full-screen chat
Hamburger menu triggers full-screen overlay for navigation
Server list becomes horizontal scrollable bar at top (48px height)
Swipe right: open channel list
Swipe left: open user list
Message input remains fixed bottom
Padding: 16px 4. Interaction + Motion
Entrance Animations (Motion)
Page Load:
Server list fades in from left: opacity 0 → 1, x: -20 → 0, 400ms ease-out
Channel list follows: 100ms delay, same animation
Chat messages stagger in: 50ms delay per message, opacity 0 → 1, y: 10 → 0, 300ms ease-out
Right sidebar: 200ms delay, fade + slide from right
New Message:
opacity 0 → 1, y: 20 → 0, 300ms ease-out
Scale in from 0.95 → 1.0 simultaneously
Modal/Overlay:
Background: opacity 0 → 1, 200ms
Content: scale 0.95 → 1, opacity 0 → 1, 300ms, 100ms delay after background
Hover States
All Interactive Elements:
Transition duration: 200ms
Easing: cubic-bezier(0.4, 0.0, 0.2, 1) [ease-out]
Specific Behaviors:
Buttons: scale 0.98, background color shift
Links/Text: color shift to orange, no underline
Cards: border color brightens to #FFFFFF at 40% opacity
Icons: rotate 90° (for settings/options icons), color shift to orange
Server icons: scale 1.05, subtle glow effect (box-shadow)
Transition Behavior
Route Changes:
Current view: fade out + scale down to 0.98, 250ms
New view: fade in + scale up from 0.98, 300ms, 100ms delay
Sidebar Toggle:
Slide animation: 400ms ease-in-out
Content reflow: 400ms synchronized with slide
Expandable Sections:
Height: auto-animate with max-height trick
Duration: 300ms ease-out
Icon rotation: 180° synchronized
Overlay Menu Behavior
Open Animation:
Background fills from top: 400ms ease-out
Menu items stagger in (100ms delay each): opacity 0 → 1, x: -30 → 0, 400ms ease-out
Close button fades in: 300ms, 200ms delay
Close Animation:
Menu items stagger out in reverse: opacity 1 → 0, x: 0 → -30, 300ms ease-in
Background fades: 300ms, begins after items start
Total duration: 600ms
Navigation Interaction:
Click item: orange color + translate-x 16px (200ms)
300ms delay before route transition initiates
Menu closes as new view loads
Microinteractions
Typing Indicator:
Three dots: 4px circles, international orange
Sequential scale animation: 0.8 → 1.2 → 0.8, 600ms loop, 150ms delay per dot
Unread Badge:
Pop in: scale 0 → 1 with bounce (spring physics)
Pulse every 3s: scale 1 → 1.1 → 1, opacity 1 → 0.8 → 1
Message Reactions:
Hover: scale 1.1, 150ms
Click: scale 0.9 → 1.2 with spring, add counter increment animation
Status Indicator:
State change: scale 0.8 → 1.2 → 1, color crossfade 300ms 5. Implementation Notes
Tailwind Configuration
Custom Theme Extension:
theme: {
extend: {
colors: {
'orange': '#FF4F00',
'orange-hover': '#E64500',
'orange-muted': '#FF4F0015',
'gray-900': '#0A0A0A',
'gray-800': '#1A1A1A',
},
fontFamily: {
'sans': ['Space Grotesk', 'system-ui', 'sans-serif'],
'mono': ['JetBrains Mono', 'monospace'],
},
spacing: {
// 8px base unit system already default in Tailwind
},
maxWidth: {
'container': '1440px',
},
},
}
CSS Custom Properties
:root {
--color-bg-primary: #000000;
--color-bg-secondary: #0A0A0A;
--color-bg-tertiary: #1A1A1A;
--color-text-primary: #FFFFFF;
--color-text-secondary: rgba(255, 255, 255, 0.6);
--color-accent: #FF4F00;
--color-accent-hover: #E64500;
--spacing-unit: 8px;
--transition-fast: 200ms cubic-bezier(0.4, 0.0, 0.2, 1);
--transition-base: 300ms cubic-bezier(0.4, 0.0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0.0, 0.2, 1);
}
Conclusion
This design system delivers a bold, systematic visual language rooted in Dutch modernism while maintaining contemporary usability standards. Every element serves the dual purpose of aesthetic clarity and functional efficiency. The international orange accent provides the necessary energy and wayfinding without compromising the minimalist foundation.
Implementation should prioritize: grid alignment, smooth Motion-powered animations (https://motion.dev/), strict typography hierarchy, and real-time functionality via WebSocket connections. The result will be a chat application that feels simultaneously timeless and cutting-edge—confident, fast, and unmistakably modern.

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>dusk</title>
</head>
<body>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "dusk-chat",
"version": "0.1.0",
"description": "peer-to-peer community platform",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 tauri"
},
"license": "MIT",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.0.0",
"@fontsource/space-grotesk": "^5.2.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"lucide-solid": "^0.469.0",
"motion": "^12.0.0",
"solid-js": "^1.9.3",
"solid-motionone": "^1.0.0"
},
"devDependencies": {
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vite-plugin-solid": "^2.11.0",
"@tauri-apps/cli": "^2",
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0"
}
}

7
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

7289
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

58
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,58 @@
[package]
name = "dusk-chat"
version = "0.1.0"
description = "peer-to-peer community platform"
authors = ["dusk"]
edition = "2021"
[lib]
name = "dusk_chat_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
log = "0.4"
env_logger = "0.11"
# p2p networking
libp2p = { version = "0.54", features = [
"gossipsub",
"kad",
"mdns",
"noise",
"yamux",
"tcp",
"tokio",
"identify",
"macros",
"request-response",
"cbor",
"relay",
"rendezvous",
"ping",
] }
# crdt engine
automerge = "0.5"
# identity and encoding
rand = "0.8"
bs58 = "0.5"
sha2 = "0.10"
hex = "0.4"
# data storage
directories = "5"
# env file support
dotenvy = "0.15"
# async utilities
futures = "0.3"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:event:allow-emit",
"core:event:allow-listen",
"core:event:allow-unlisten",
"shell:allow-open"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

Binary file not shown.

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

View File

@ -0,0 +1,244 @@
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::State;
use crate::node::gossip;
use crate::node::{self, NodeCommand};
use crate::protocol::messages::{ChatMessage, GossipMessage, ProfileAnnouncement, TypingIndicator};
use crate::AppState;
#[tauri::command]
pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity
.as_ref()
.ok_or("no identity loaded, create one first")?;
let handle = node::start(
id.keypair.clone(),
state.crdt_engine.clone(),
state.storage.clone(),
app,
)
.await?;
// capture profile info for announcement before dropping identity lock
let profile_announcement = ProfileAnnouncement {
peer_id: id.peer_id.to_string(),
display_name: id.display_name.clone(),
bio: id.bio.clone(),
public_key: hex::encode(id.keypair.public().encode_protobuf()),
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64,
};
drop(identity);
{
let mut node_handle = state.node_handle.lock().await;
*node_handle = Some(handle);
}
// subscribe to the global sync topic for document exchange
let sync_topic = gossip::topic_for_sync();
let directory_topic = gossip::topic_for_directory();
let handle_ref = state.node_handle.lock().await;
if let Some(ref handle) = *handle_ref {
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic: sync_topic })
.await;
// subscribe to the directory topic for peer profile announcements
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: directory_topic.clone(),
})
.await;
// announce our profile on the directory topic
let announce_msg = GossipMessage::ProfileAnnounce(profile_announcement);
if let Ok(data) = serde_json::to_vec(&announce_msg) {
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: directory_topic,
data,
})
.await;
}
}
// subscribe to all known community topics
let engine = state.crdt_engine.lock().await;
let community_ids = engine.community_ids();
drop(engine);
if let Some(ref handle) = *handle_ref {
for community_id in &community_ids {
let channels = {
let engine = state.crdt_engine.lock().await;
engine.get_channels(community_id).unwrap_or_default()
};
for channel in &channels {
let topic = gossip::topic_for_messages(community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic })
.await;
let typing_topic = gossip::topic_for_typing(community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: typing_topic,
})
.await;
}
let presence_topic = gossip::topic_for_presence(community_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: presence_topic,
})
.await;
// register on rendezvous for each community so other peers can find us
let namespace = format!("dusk/community/{}", community_id);
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous { namespace })
.await;
}
}
Ok(())
}
#[tauri::command]
pub async fn stop_node(state: State<'_, AppState>) -> Result<(), String> {
let mut node_handle = state.node_handle.lock().await;
if let Some(handle) = node_handle.take() {
let _ = handle.command_tx.send(NodeCommand::Shutdown).await;
let _ = handle.task.await;
}
Ok(())
}
#[tauri::command]
pub async fn send_message(
state: State<'_, AppState>,
channel_id: String,
content: String,
) -> Result<ChatMessage, String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let msg = ChatMessage {
id: format!("msg_{}_{}", id.peer_id, now),
channel_id: channel_id.clone(),
author_id: id.peer_id.to_string(),
author_name: id.display_name.clone(),
content,
timestamp: now,
edited: false,
};
// figure out which community this channel belongs to
let mut engine = state.crdt_engine.lock().await;
let community_id = find_community_for_channel(&engine, &channel_id)?;
engine.append_message(&community_id, &msg)?;
drop(engine);
// publish to gossipsub
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_messages(&community_id, &channel_id);
let data = serde_json::to_vec(&GossipMessage::Chat(msg.clone()))
.map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage { topic, data })
.await;
}
Ok(msg)
}
#[tauri::command]
pub async fn get_messages(
state: State<'_, AppState>,
channel_id: String,
before: Option<u64>,
limit: Option<usize>,
) -> Result<Vec<ChatMessage>, String> {
let engine = state.crdt_engine.lock().await;
let community_id = find_community_for_channel(&engine, &channel_id)?;
engine.get_messages(&community_id, &channel_id, before, limit.unwrap_or(50))
}
#[tauri::command]
pub async fn send_typing(state: State<'_, AppState>, channel_id: String) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let indicator = TypingIndicator {
peer_id: id.peer_id.to_string(),
channel_id: channel_id.clone(),
timestamp: now,
};
let engine = state.crdt_engine.lock().await;
let community_id = find_community_for_channel(&engine, &channel_id)?;
drop(engine);
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_typing(&community_id, &channel_id);
let data = serde_json::to_vec(&GossipMessage::Typing(indicator))
.map_err(|e| format!("serialize error: {}", e))?;
let _ = handle
.command_tx
.send(NodeCommand::SendMessage { topic, data })
.await;
}
Ok(())
}
// find which community a channel belongs to by checking all loaded documents
fn find_community_for_channel(
engine: &crate::crdt::CrdtEngine,
channel_id: &str,
) -> Result<String, String> {
for community_id in engine.community_ids() {
if let Ok(channels) = engine.get_channels(&community_id) {
if channels.iter().any(|ch| ch.id == channel_id) {
return Ok(community_id);
}
}
}
Err(format!(
"no community found containing channel {}",
channel_id
))
}

View File

@ -0,0 +1,444 @@
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use tauri::State;
use crate::node::gossip;
use crate::node::NodeCommand;
use crate::protocol::community::{ChannelKind, ChannelMeta, CommunityMeta, Member};
use crate::protocol::messages::PeerStatus;
use crate::AppState;
#[tauri::command]
pub async fn create_community(
state: State<'_, AppState>,
name: String,
description: String,
) -> Result<CommunityMeta, String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
// generate a deterministic community id from name + creator + timestamp
let mut hasher = Sha256::new();
hasher.update(name.as_bytes());
hasher.update(id.peer_id.to_bytes());
hasher.update(now.to_le_bytes());
let hash = hasher.finalize();
let community_id = format!("com_{}", &hex::encode(hash)[..16]);
let peer_id_str = id.peer_id.to_string();
drop(identity);
let mut engine = state.crdt_engine.lock().await;
engine.create_community(&community_id, &name, &description, &peer_id_str)?;
let meta = engine.get_community_meta(&community_id)?;
// save metadata cache
let _ = state.storage.save_community_meta(&meta);
drop(engine);
// subscribe to community topics on the p2p node
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let presence_topic = gossip::topic_for_presence(&community_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: presence_topic,
})
.await;
// subscribe to the default general channel
let engine = state.crdt_engine.lock().await;
if let Ok(channels) = engine.get_channels(&community_id) {
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: typing_topic,
})
.await;
}
}
// register on rendezvous so peers joining via invite can discover us
let namespace = format!("dusk/community/{}", community_id);
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous { namespace })
.await;
}
Ok(meta)
}
#[tauri::command]
pub async fn join_community(
state: State<'_, AppState>,
invite_code: String,
) -> Result<CommunityMeta, String> {
let invite = crate::protocol::community::InviteCode::decode(&invite_code)?;
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let peer_id_str = id.peer_id.to_string();
drop(identity);
// create a placeholder document that will be backfilled via crdt sync
// once we connect to existing community members through the relay
let mut engine = state.crdt_engine.lock().await;
if !engine.has_community(&invite.community_id) {
engine.create_community(
&invite.community_id,
&invite.community_name,
"",
&peer_id_str,
)?;
}
let meta = engine.get_community_meta(&invite.community_id)?;
let _ = state.storage.save_community_meta(&meta);
// subscribe to gossipsub topics so we receive messages
let channels = engine
.get_channels(&invite.community_id)
.unwrap_or_default();
drop(engine);
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
// subscribe to the community presence topic
let presence_topic = gossip::topic_for_presence(&invite.community_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: presence_topic,
})
.await;
// subscribe to all channel topics
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&invite.community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&invite.community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: typing_topic,
})
.await;
}
// register on rendezvous so existing members can find us
let namespace = format!("dusk/community/{}", invite.community_id);
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous {
namespace: namespace.clone(),
})
.await;
// discover existing members through rendezvous
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous { namespace })
.await;
}
Ok(meta)
}
#[tauri::command]
pub async fn leave_community(
state: State<'_, AppState>,
community_id: String,
) -> Result<(), String> {
// unsubscribe from all community topics
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let engine = state.crdt_engine.lock().await;
if let Ok(channels) = engine.get_channels(&community_id) {
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe {
topic: typing_topic,
})
.await;
}
}
let presence_topic = gossip::topic_for_presence(&community_id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe {
topic: presence_topic,
})
.await;
}
Ok(())
}
#[tauri::command]
pub async fn get_communities(state: State<'_, AppState>) -> Result<Vec<CommunityMeta>, String> {
let engine = state.crdt_engine.lock().await;
let mut communities = Vec::new();
for id in engine.community_ids() {
if let Ok(meta) = engine.get_community_meta(&id) {
communities.push(meta);
}
}
Ok(communities)
}
#[tauri::command]
pub async fn create_channel(
state: State<'_, AppState>,
community_id: String,
name: String,
topic: String,
) -> Result<ChannelMeta, String> {
let mut hasher = Sha256::new();
hasher.update(community_id.as_bytes());
hasher.update(name.as_bytes());
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
hasher.update(now.to_le_bytes());
let hash = hasher.finalize();
let channel_id = format!("ch_{}", &hex::encode(hash)[..12]);
let channel = ChannelMeta {
id: channel_id,
community_id: community_id.clone(),
name,
topic,
kind: ChannelKind::Text,
};
let mut engine = state.crdt_engine.lock().await;
engine.create_channel(&community_id, &channel)?;
drop(engine);
// subscribe to the new channel's topics
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: typing_topic,
})
.await;
}
Ok(channel)
}
#[tauri::command]
pub async fn get_channels(
state: State<'_, AppState>,
community_id: String,
) -> Result<Vec<ChannelMeta>, String> {
let engine = state.crdt_engine.lock().await;
engine.get_channels(&community_id)
}
#[tauri::command]
pub async fn get_members(
state: State<'_, AppState>,
community_id: String,
) -> Result<Vec<Member>, String> {
let engine = state.crdt_engine.lock().await;
let mut members = engine.get_members(&community_id)?;
drop(engine);
// overlay the local user's identity so their display name stays current
let identity = state.identity.lock().await;
if let Some(ref id) = *identity {
let local_peer = id.peer_id.to_string();
let found = members.iter_mut().find(|m| m.peer_id == local_peer);
if let Some(member) = found {
member.display_name = id.display_name.clone();
member.status = PeerStatus::Online;
} else {
// local user isn't in the doc yet (shouldn't happen, but be safe)
members.push(Member {
peer_id: local_peer,
display_name: id.display_name.clone(),
status: PeerStatus::Online,
roles: vec!["member".to_string()],
trust_level: 1.0,
joined_at: 0,
});
}
}
Ok(members)
}
#[tauri::command]
pub async fn delete_message(
state: State<'_, AppState>,
community_id: String,
message_id: String,
) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let peer_id_str = id.peer_id.to_string();
drop(identity);
// verify the user is the message author or has admin rights
let mut engine = state.crdt_engine.lock().await;
let message = engine
.get_message(&community_id, &message_id)?
.ok_or_else(|| format!("message {} not found", message_id))?;
// only allow deletion by the author
if message.author_id != peer_id_str {
return Err("not authorized to delete this message".to_string());
}
engine.delete_message(&community_id, &message_id)?;
drop(engine);
// broadcast the deletion to peers
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
// find the channel for this message to get the correct topic
let engine = state.crdt_engine.lock().await;
if let Ok(channels) = engine.get_channels(&community_id) {
for channel in &channels {
let topic = gossip::topic_for_messages(&community_id, &channel.id);
let deletion = crate::protocol::messages::GossipMessage::DeleteMessage {
message_id: message_id.clone(),
};
if let Ok(data) = serde_json::to_vec(&deletion) {
let _ = handle
.command_tx
.send(NodeCommand::SendMessage { topic, data })
.await;
}
}
}
}
Ok(())
}
#[tauri::command]
pub async fn kick_member(
state: State<'_, AppState>,
community_id: String,
member_peer_id: String,
) -> Result<(), String> {
let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let requester_id = id.peer_id.to_string();
drop(identity);
// verify the requester has admin rights
let engine = state.crdt_engine.lock().await;
let members = engine.get_members(&community_id)?;
let requester = members
.iter()
.find(|m| m.peer_id == requester_id)
.ok_or("requester not found in community")?;
let is_admin = requester.roles.iter().any(|r| r == "admin" || r == "owner");
if !is_admin {
return Err("not authorized to kick members".to_string());
}
// cannot kick the owner
let target = members
.iter()
.find(|m| m.peer_id == member_peer_id)
.ok_or("member not found")?;
if target.roles.iter().any(|r| r == "owner") {
return Err("cannot kick the community owner".to_string());
}
drop(engine);
// remove the member from the community
let mut engine = state.crdt_engine.lock().await;
engine.remove_member(&community_id, &member_peer_id)?;
drop(engine);
// broadcast the kick to peers
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let presence_topic = gossip::topic_for_presence(&community_id);
let kick_msg = crate::protocol::messages::GossipMessage::MemberKicked {
peer_id: member_peer_id.clone(),
};
if let Ok(data) = serde_json::to_vec(&kick_msg) {
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: presence_topic,
data,
})
.await;
}
}
Ok(())
}
#[tauri::command]
pub async fn generate_invite(
state: State<'_, AppState>,
community_id: String,
) -> Result<String, String> {
let engine = state.crdt_engine.lock().await;
let meta = engine.get_community_meta(&community_id)?;
drop(engine);
// invite contains only the community id and name
// no IP addresses or peer addresses are included
// peers discover each other through the relay's rendezvous protocol
let invite = crate::protocol::community::InviteCode {
community_id: meta.id.clone(),
community_name: meta.name.clone(),
};
Ok(invite.encode())
}

View File

@ -0,0 +1,248 @@
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::State;
use crate::node::gossip;
use crate::node::NodeCommand;
use crate::protocol::identity::{DirectoryEntry, DuskIdentity, PublicIdentity};
use crate::protocol::messages::{GossipMessage, ProfileRevocation};
use crate::storage::UserSettings;
use crate::AppState;
#[tauri::command]
pub async fn has_identity(state: State<'_, AppState>) -> Result<bool, String> {
Ok(state.storage.has_identity())
}
#[tauri::command]
pub async fn load_identity(state: State<'_, AppState>) -> Result<Option<PublicIdentity>, String> {
let mut identity = state.identity.lock().await;
if identity.is_some() {
return Ok(identity.as_ref().map(|id| id.public_identity()));
}
match DuskIdentity::load(&state.storage) {
Ok(loaded) => {
let public = loaded.public_identity();
*identity = Some(loaded);
Ok(Some(public))
}
Err(_) => Ok(None),
}
}
#[tauri::command]
pub async fn create_identity(
state: State<'_, AppState>,
display_name: String,
bio: Option<String>,
) -> Result<PublicIdentity, String> {
let new_identity = DuskIdentity::generate(&display_name, &bio.unwrap_or_default());
new_identity.save(&state.storage)?;
// also save initial settings with this display name so they're in sync
let mut settings = state.storage.load_settings().unwrap_or_default();
settings.display_name = display_name.clone();
state
.storage
.save_settings(&settings)
.map_err(|e| format!("failed to save initial settings: {}", e))?;
let public = new_identity.public_identity();
let mut identity = state.identity.lock().await;
*identity = Some(new_identity);
Ok(public)
}
#[tauri::command]
pub async fn update_display_name(state: State<'_, AppState>, name: String) -> Result<(), String> {
let mut identity = state.identity.lock().await;
let id = identity.as_mut().ok_or("no identity loaded")?;
id.display_name = name;
id.save(&state.storage)?;
Ok(())
}
#[tauri::command]
pub async fn update_profile(
state: State<'_, AppState>,
display_name: String,
bio: String,
) -> Result<PublicIdentity, String> {
let mut identity = state.identity.lock().await;
let id = identity.as_mut().ok_or("no identity loaded")?;
id.display_name = display_name;
id.bio = bio;
id.save(&state.storage)?;
Ok(id.public_identity())
}
#[tauri::command]
pub async fn load_settings(state: State<'_, AppState>) -> Result<UserSettings, String> {
state
.storage
.load_settings()
.map_err(|e| format!("failed to load settings: {}", e))
}
#[tauri::command]
pub async fn save_settings(
state: State<'_, AppState>,
settings: UserSettings,
) -> Result<(), String> {
// also update the identity display name if it changed
let mut identity = state.identity.lock().await;
if let Some(id) = identity.as_mut() {
if id.display_name != settings.display_name {
id.display_name = settings.display_name.clone();
id.save(&state.storage)?;
}
}
state
.storage
.save_settings(&settings)
.map_err(|e| format!("failed to save settings: {}", e))
}
// -- user directory commands --
#[tauri::command]
pub async fn get_known_peers(state: State<'_, AppState>) -> Result<Vec<DirectoryEntry>, String> {
let entries = state
.storage
.load_directory()
.map_err(|e| format!("failed to load directory: {}", e))?;
let mut peers: Vec<DirectoryEntry> = entries.into_values().collect();
// sort by last seen (most recent first)
peers.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
Ok(peers)
}
#[tauri::command]
pub async fn search_directory(
state: State<'_, AppState>,
query: String,
) -> Result<Vec<DirectoryEntry>, String> {
let entries = state
.storage
.load_directory()
.map_err(|e| format!("failed to load directory: {}", e))?;
let query_lower = query.to_lowercase();
let mut results: Vec<DirectoryEntry> = entries
.into_values()
.filter(|entry| {
entry.display_name.to_lowercase().contains(&query_lower)
|| entry.peer_id.to_lowercase().contains(&query_lower)
})
.collect();
results.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
Ok(results)
}
#[tauri::command]
pub async fn get_friends(state: State<'_, AppState>) -> Result<Vec<DirectoryEntry>, String> {
let entries = state
.storage
.load_directory()
.map_err(|e| format!("failed to load directory: {}", e))?;
let mut friends: Vec<DirectoryEntry> = entries
.into_values()
.filter(|entry| entry.is_friend)
.collect();
friends.sort_by(|a, b| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
});
Ok(friends)
}
#[tauri::command]
pub async fn add_friend(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
state
.storage
.set_friend_status(&peer_id, true)
.map_err(|e| format!("failed to add friend: {}", e))
}
#[tauri::command]
pub async fn remove_friend(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
state
.storage
.set_friend_status(&peer_id, false)
.map_err(|e| format!("failed to remove friend: {}", e))
}
// broadcast a revocation to all peers, stop the node, and wipe all local data
#[tauri::command]
pub async fn reset_identity(state: State<'_, AppState>) -> Result<(), String> {
let mut identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
// build the revocation message before we destroy the identity
let revocation = ProfileRevocation {
peer_id: id.peer_id.to_string(),
public_key: hex::encode(id.keypair.public().encode_protobuf()),
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64,
};
// broadcast revocation on the directory gossip topic
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let msg = GossipMessage::ProfileRevoke(revocation);
if let Ok(data) = serde_json::to_vec(&msg) {
let _ = handle
.command_tx
.send(NodeCommand::SendMessage {
topic: gossip::topic_for_directory(),
data,
})
.await;
}
// give the message a moment to propagate before shutting down
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
drop(node_handle);
// stop the p2p node
{
let mut node_handle = state.node_handle.lock().await;
if let Some(handle) = node_handle.take() {
let _ = handle.command_tx.send(NodeCommand::Shutdown).await;
let _ = handle.task.await;
}
}
// clear the crdt engine so no community data lingers in memory
{
let mut engine = state.crdt_engine.lock().await;
engine.clear();
}
// clear in-memory identity
*identity = None;
// wipe all data from disk
state
.storage
.wipe_all_data()
.map_err(|e| format!("failed to wipe data: {}", e))?;
Ok(())
}

View File

@ -0,0 +1,3 @@
pub mod chat;
pub mod community;
pub mod identity;

View File

@ -0,0 +1,436 @@
use automerge::{transaction::Transactable, AutoCommit, ObjType, ReadDoc, ROOT};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::protocol::community::{ChannelKind, ChannelMeta, CommunityMeta};
use crate::protocol::messages::ChatMessage;
// initialize a new community document with metadata and a default general channel
pub fn init_community_doc(
doc: &mut AutoCommit,
name: &str,
description: &str,
created_by: &str,
) -> Result<(), automerge::AutomergeError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
// create the top-level structure
let meta = doc.put_object(ROOT, "meta", ObjType::Map)?;
doc.put(&meta, "name", name)?;
doc.put(&meta, "description", description)?;
doc.put(&meta, "created_by", created_by)?;
doc.put(&meta, "created_at", now as i64)?;
let channels = doc.put_object(ROOT, "channels", ObjType::Map)?;
let members = doc.put_object(ROOT, "members", ObjType::Map)?;
let _roles = doc.put_object(ROOT, "roles", ObjType::Map)?;
// create a default general channel
let general_id = format!("ch_{}", &hex::encode(&sha2_hash(format!("{}_general", name).as_bytes()))[..12]);
let general = doc.put_object(&channels, &general_id, ObjType::Map)?;
doc.put(&general, "name", "general")?;
doc.put(&general, "topic", "general discussion")?;
doc.put(&general, "kind", "text")?;
let _messages = doc.put_object(&general, "messages", ObjType::List)?;
// add the creator as the first member with owner role
let member = doc.put_object(&members, created_by, ObjType::Map)?;
doc.put(&member, "display_name", "")?;
doc.put(&member, "joined_at", now as i64)?;
let roles = doc.put_object(&member, "roles", ObjType::List)?;
doc.insert(&roles, 0, "owner")?;
Ok(())
}
// add a new channel to the community document
pub fn add_channel(
doc: &mut AutoCommit,
channel: &ChannelMeta,
) -> Result<(), automerge::AutomergeError> {
let channels = doc
.get(ROOT, "channels")?
.map(|(_, id)| id)
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?;
let ch = doc.put_object(&channels, &channel.id, ObjType::Map)?;
doc.put(&ch, "name", channel.name.as_str())?;
doc.put(&ch, "topic", channel.topic.as_str())?;
doc.put(
&ch,
"kind",
match channel.kind {
ChannelKind::Text => "text",
ChannelKind::Voice => "voice",
},
)?;
let _messages = doc.put_object(&ch, "messages", ObjType::List)?;
Ok(())
}
// read all channels from the community document
pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelMeta>, String> {
let channels_obj = doc
.get(ROOT, "channels")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("channels key not found")?;
let mut result = Vec::new();
let keys = doc.keys(&channels_obj);
for key in keys {
let ch_obj = doc
.get(&channels_obj, &key)
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(ch_id) = ch_obj {
let name = get_str(doc, &ch_id, "name").unwrap_or_default();
let topic = get_str(doc, &ch_id, "topic").unwrap_or_default();
let kind_str = get_str(doc, &ch_id, "kind").unwrap_or_else(|| "text".to_string());
let kind = match kind_str.as_str() {
"voice" => ChannelKind::Voice,
_ => ChannelKind::Text,
};
result.push(ChannelMeta {
id: key.to_string(),
community_id: community_id.to_string(),
name,
topic,
kind,
});
}
}
Ok(result)
}
// append a message to a channel's message list
pub fn append_message(
doc: &mut AutoCommit,
channel_id: &str,
message: &ChatMessage,
) -> Result<(), automerge::AutomergeError> {
let channels = doc
.get(ROOT, "channels")?
.map(|(_, id)| id)
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?;
let channel = doc
.get(&channels, channel_id)?
.map(|(_, id)| id)
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channel not found".to_string()))?;
let messages = doc
.get(&channel, "messages")?
.map(|(_, id)| id)
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("messages not found".to_string()))?;
let len = doc.length(&messages);
let msg_obj = doc.insert_object(&messages, len, ObjType::Map)?;
doc.put(&msg_obj, "id", message.id.as_str())?;
doc.put(&msg_obj, "author_id", message.author_id.as_str())?;
doc.put(&msg_obj, "author_name", message.author_name.as_str())?;
doc.put(&msg_obj, "content", message.content.as_str())?;
doc.put(&msg_obj, "timestamp", message.timestamp as i64)?;
doc.put(&msg_obj, "edited", message.edited)?;
Ok(())
}
// read messages from a channel, optionally filtered and limited
pub fn get_messages(
doc: &AutoCommit,
channel_id: &str,
before: Option<u64>,
limit: usize,
) -> Result<Vec<ChatMessage>, String> {
let channels = doc
.get(ROOT, "channels")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("channels not found")?;
let channel = doc
.get(&channels, channel_id)
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("channel not found")?;
let messages = doc
.get(&channel, "messages")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("messages not found")?;
let len = doc.length(&messages);
let mut result = Vec::new();
// iterate backwards for most recent first, then reverse for chronological order
for i in (0..len).rev() {
let msg_obj = doc
.get(&messages, i)
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(msg_id) = msg_obj {
let timestamp = get_i64(doc, &msg_id, "timestamp").unwrap_or(0) as u64;
if let Some(before_ts) = before {
if timestamp >= before_ts {
continue;
}
}
let msg = ChatMessage {
id: get_str(doc, &msg_id, "id").unwrap_or_default(),
channel_id: channel_id.to_string(),
author_id: get_str(doc, &msg_id, "author_id").unwrap_or_default(),
author_name: get_str(doc, &msg_id, "author_name").unwrap_or_default(),
content: get_str(doc, &msg_id, "content").unwrap_or_default(),
timestamp,
edited: get_bool(doc, &msg_id, "edited").unwrap_or(false),
};
result.push(msg);
if result.len() >= limit {
break;
}
}
}
// reverse to get chronological order
result.reverse();
Ok(result)
}
// read community metadata from the document
pub fn get_community_meta(doc: &AutoCommit, community_id: &str) -> Result<CommunityMeta, String> {
let meta = doc
.get(ROOT, "meta")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("meta not found")?;
Ok(CommunityMeta {
id: community_id.to_string(),
name: get_str(doc, &meta, "name").unwrap_or_default(),
description: get_str(doc, &meta, "description").unwrap_or_default(),
created_by: get_str(doc, &meta, "created_by").unwrap_or_default(),
created_at: get_i64(doc, &meta, "created_at").unwrap_or(0) as u64,
})
}
// -- helpers for reading automerge values --
fn get_str(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option<String> {
doc.get(obj, key)
.ok()
.flatten()
.and_then(|(val, _)| val.into_string().ok())
}
fn get_i64(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option<i64> {
doc.get(obj, key)
.ok()
.flatten()
.and_then(|(val, _)| val.to_i64())
}
fn get_bool(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option<bool> {
doc.get(obj, key)
.ok()
.flatten()
.and_then(|(val, _)| val.to_bool())
}
// simple sha256 hash for generating deterministic ids
fn sha2_hash(data: &[u8]) -> Vec<u8> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().to_vec()
}
// get a specific message by id from any channel in the community
pub fn get_message_by_id(
doc: &AutoCommit,
message_id: &str,
) -> Result<Option<ChatMessage>, String> {
let channels_obj = doc
.get(ROOT, "channels")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("channels key not found")?;
let keys = doc.keys(&channels_obj);
for channel_key in keys {
let ch_obj = doc
.get(&channels_obj, &channel_key)
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(ch_id) = ch_obj {
let messages = doc
.get(&ch_id, "messages")
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(msgs_id) = messages {
let len = doc.length(&msgs_id);
for i in 0..len {
let msg_obj = doc
.get(&msgs_id, i)
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(msg_id) = msg_obj {
let id = get_str(doc, &msg_id, "id").unwrap_or_default();
if id == message_id {
let msg = ChatMessage {
id: id.clone(),
channel_id: channel_key.to_string(),
author_id: get_str(doc, &msg_id, "author_id").unwrap_or_default(),
author_name: get_str(doc, &msg_id, "author_name").unwrap_or_default(),
content: get_str(doc, &msg_id, "content").unwrap_or_default(),
timestamp: get_i64(doc, &msg_id, "timestamp").unwrap_or(0) as u64,
edited: get_bool(doc, &msg_id, "edited").unwrap_or(false),
};
return Ok(Some(msg));
}
}
}
}
}
}
Ok(None)
}
// delete a message by id from any channel in the community
pub fn delete_message_by_id(
doc: &mut AutoCommit,
message_id: &str,
) -> Result<(), String> {
let channels_obj = doc
.get(ROOT, "channels")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("channels key not found")?;
let keys: Vec<String> = doc.keys(&channels_obj).collect();
for channel_key in keys {
let ch_obj = doc
.get(&channels_obj, &channel_key)
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(ch_id) = ch_obj {
let messages = doc
.get(&ch_id, "messages")
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(msgs_id) = messages {
let len = doc.length(&msgs_id);
for i in 0..len {
let msg_obj = doc
.get(&msgs_id, i)
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(msg_obj_id) = msg_obj {
let id = get_str(doc, &msg_obj_id, "id").unwrap_or_default();
if id == message_id {
doc.delete(&msgs_id, i)
.map_err(|e| e.to_string())?;
return Ok(());
}
}
}
}
}
}
Err(format!("message {} not found", message_id))
}
// get all members from the community document
pub fn get_members(
doc: &AutoCommit,
) -> Result<Vec<crate::protocol::community::Member>, String> {
let members_obj = doc
.get(ROOT, "members")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("members key not found")?;
let mut result = Vec::new();
let keys = doc.keys(&members_obj);
for peer_id in keys {
let member_obj = doc
.get(&members_obj, &peer_id)
.map_err(|e| e.to_string())?
.map(|(_, id)| id);
if let Some(member_id) = member_obj {
let display_name = get_str(doc, &member_id, "display_name").unwrap_or_default();
let joined_at = get_i64(doc, &member_id, "joined_at").unwrap_or(0) as u64;
// get roles list
let roles: Vec<String> = doc
.get(&member_id, "roles")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.map(|roles_id| {
let len = doc.length(&roles_id);
(0..len)
.filter_map(|i| {
doc.get(&roles_id, i)
.ok()
.flatten()
.and_then(|(val, _)| val.into_string().ok())
})
.collect()
})
.unwrap_or_default();
result.push(crate::protocol::community::Member {
peer_id: peer_id.clone(),
display_name,
status: crate::protocol::messages::PeerStatus::Online,
roles,
trust_level: 1.0,
joined_at,
});
}
}
Ok(result)
}
// remove a member from the community
pub fn remove_member(
doc: &mut AutoCommit,
peer_id: &str,
) -> Result<(), String> {
let members_obj = doc
.get(ROOT, "members")
.map_err(|e| e.to_string())?
.map(|(_, id)| id)
.ok_or("members key not found")?;
doc.delete(&members_obj, peer_id)
.map_err(|e| e.to_string())?;
Ok(())
}

259
src-tauri/src/crdt/mod.rs Normal file
View File

@ -0,0 +1,259 @@
mod document;
pub mod sync;
use std::collections::HashMap;
use std::sync::Arc;
use automerge::AutoCommit;
use crate::protocol::community::{ChannelMeta, CommunityMeta};
use crate::protocol::messages::ChatMessage;
use crate::storage::DiskStorage;
// manages automerge documents for all joined communities
pub struct CrdtEngine {
documents: HashMap<String, AutoCommit>,
storage: Arc<DiskStorage>,
}
impl CrdtEngine {
pub fn new(storage: Arc<DiskStorage>) -> Self {
Self {
documents: HashMap::new(),
storage,
}
}
// load all persisted community documents from disk
pub fn load_all(&mut self) -> Result<(), String> {
let community_ids = self
.storage
.list_communities()
.map_err(|e| format!("failed to list communities: {}", e))?;
for id in community_ids {
if let Ok(bytes) = self.storage.load_document(&id) {
match AutoCommit::load(&bytes) {
Ok(doc) => {
self.documents.insert(id, doc);
}
Err(e) => {
log::warn!("failed to load document for community {}: {}", id, e);
}
}
}
}
Ok(())
}
// create a new community with a default general channel
pub fn create_community(
&mut self,
community_id: &str,
name: &str,
description: &str,
created_by: &str,
) -> Result<(), String> {
let mut doc = AutoCommit::new();
document::init_community_doc(&mut doc, name, description, created_by)
.map_err(|e| format!("failed to init community doc: {}", e))?;
self.documents.insert(community_id.to_string(), doc);
self.persist(community_id)?;
Ok(())
}
// add a channel to an existing community
pub fn create_channel(
&mut self,
community_id: &str,
channel: &ChannelMeta,
) -> Result<(), String> {
let doc = self
.documents
.get_mut(community_id)
.ok_or("community not found")?;
document::add_channel(doc, channel).map_err(|e| format!("failed to add channel: {}", e))?;
self.persist(community_id)?;
Ok(())
}
// get all channels in a community
pub fn get_channels(&self, community_id: &str) -> Result<Vec<ChannelMeta>, String> {
let doc = self
.documents
.get(community_id)
.ok_or("community not found")?;
document::get_channels(doc, community_id)
}
// append a message to a channel within a community
pub fn append_message(
&mut self,
community_id: &str,
message: &ChatMessage,
) -> Result<(), String> {
let doc = self
.documents
.get_mut(community_id)
.ok_or("community not found")?;
document::append_message(doc, &message.channel_id, message)
.map_err(|e| format!("failed to append message: {}", e))?;
self.persist(community_id)?;
Ok(())
}
// get messages for a channel, optionally paginated
pub fn get_messages(
&self,
community_id: &str,
channel_id: &str,
before: Option<u64>,
limit: usize,
) -> Result<Vec<ChatMessage>, String> {
let doc = self
.documents
.get(community_id)
.ok_or("community not found")?;
document::get_messages(doc, channel_id, before, limit)
}
// get community metadata
pub fn get_community_meta(&self, community_id: &str) -> Result<CommunityMeta, String> {
let doc = self
.documents
.get(community_id)
.ok_or("community not found")?;
document::get_community_meta(doc, community_id)
}
// get all community ids we have documents for
pub fn community_ids(&self) -> Vec<String> {
self.documents.keys().cloned().collect()
}
// check if we have a document for a community
pub fn has_community(&self, community_id: &str) -> bool {
self.documents.contains_key(community_id)
}
// save a document to disk
pub fn persist(&mut self, community_id: &str) -> Result<(), String> {
let doc = self
.documents
.get_mut(community_id)
.ok_or("community not found")?;
let bytes = doc.save();
self.storage
.save_document(community_id, &bytes)
.map_err(|e| format!("failed to persist document: {}", e))
}
// get a mutable reference to a document for sync operations
pub fn get_doc_mut(&mut self, community_id: &str) -> Option<&mut AutoCommit> {
self.documents.get_mut(community_id)
}
// get an immutable reference to a document
pub fn get_doc(&self, community_id: &str) -> Option<&AutoCommit> {
self.documents.get(community_id)
}
// insert or replace a document (used when receiving a full doc via sync)
pub fn insert_doc(&mut self, community_id: &str, doc: AutoCommit) {
self.documents.insert(community_id.to_string(), doc);
}
// get a specific message by id
pub fn get_message(
&self,
community_id: &str,
message_id: &str,
) -> Result<Option<ChatMessage>, String> {
let doc = self
.documents
.get(community_id)
.ok_or("community not found")?;
document::get_message_by_id(doc, message_id)
}
// delete a message by id
pub fn delete_message(&mut self, community_id: &str, message_id: &str) -> Result<(), String> {
let doc = self
.documents
.get_mut(community_id)
.ok_or("community not found")?;
document::delete_message_by_id(doc, message_id)?;
self.persist(community_id)?;
Ok(())
}
// get all members of a community
pub fn get_members(
&self,
community_id: &str,
) -> Result<Vec<crate::protocol::community::Member>, String> {
let doc = self
.documents
.get(community_id)
.ok_or("community not found")?;
document::get_members(doc)
}
// remove a member from a community
pub fn remove_member(&mut self, community_id: &str, peer_id: &str) -> Result<(), String> {
let doc = self
.documents
.get_mut(community_id)
.ok_or("community not found")?;
document::remove_member(doc, peer_id)?;
self.persist(community_id)?;
Ok(())
}
// merge a remote document snapshot into our local state
// if we don't have the community yet, insert it directly
// if we do, merge the remote changes into our existing doc
pub fn merge_remote_doc(
&mut self,
community_id: &str,
remote_bytes: &[u8],
) -> Result<(), String> {
let remote_doc = AutoCommit::load(remote_bytes)
.map_err(|e| format!("failed to load remote doc: {}", e))?;
if let Some(local_doc) = self.documents.get_mut(community_id) {
local_doc
.merge(&mut remote_doc.clone())
.map_err(|e| format!("failed to merge docs: {}", e))?;
} else {
self.documents.insert(community_id.to_string(), remote_doc);
}
self.persist(community_id)?;
Ok(())
}
// get the raw bytes of a document for sending to peers
pub fn get_doc_bytes(&mut self, community_id: &str) -> Option<Vec<u8>> {
self.documents.get_mut(community_id).map(|doc| doc.save())
}
// drop all in-memory documents (used during identity reset)
pub fn clear(&mut self) {
self.documents.clear();
}
}

View File

@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
// a full document snapshot sent over gossipsub for initial sync
// when a new peer discovers us, we broadcast our documents so they can merge
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentSnapshot {
pub community_id: String,
// raw automerge bytes, base64 encoded for json transport
pub doc_bytes: Vec<u8>,
}
// envelope for sync-related gossipsub messages
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SyncMessage {
// request all documents from peers (sent when a new peer joins)
RequestSync { peer_id: String },
// response containing a full document snapshot
DocumentOffer(DocumentSnapshot),
}

83
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,83 @@
mod commands;
mod crdt;
mod node;
mod protocol;
mod storage;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::crdt::CrdtEngine;
use crate::protocol::identity::DuskIdentity;
use crate::storage::DiskStorage;
// shared application state accessible from all tauri commands
pub struct AppState {
pub identity: Arc<Mutex<Option<DuskIdentity>>>,
pub crdt_engine: Arc<Mutex<CrdtEngine>>,
pub storage: Arc<DiskStorage>,
pub node_handle: Arc<Mutex<Option<node::NodeHandle>>>,
}
impl AppState {
pub fn new() -> Self {
let storage = Arc::new(DiskStorage::new().expect("failed to initialize storage"));
let mut engine = CrdtEngine::new(storage.clone());
// restore persisted communities from disk so data survives restarts
if let Err(e) = engine.load_all() {
log::warn!("failed to load persisted communities: {}", e);
}
let crdt_engine = Arc::new(Mutex::new(engine));
Self {
identity: Arc::new(Mutex::new(None)),
crdt_engine,
storage,
node_handle: Arc::new(Mutex::new(None)),
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// load .env from the project root so config like DUSK_RELAY_ADDR is available
dotenvy::dotenv().ok();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
commands::identity::has_identity,
commands::identity::load_identity,
commands::identity::create_identity,
commands::identity::update_display_name,
commands::identity::update_profile,
commands::identity::load_settings,
commands::identity::save_settings,
commands::identity::get_known_peers,
commands::identity::search_directory,
commands::identity::get_friends,
commands::identity::add_friend,
commands::identity::remove_friend,
commands::identity::reset_identity,
commands::chat::send_message,
commands::chat::get_messages,
commands::chat::send_typing,
commands::chat::start_node,
commands::chat::stop_node,
commands::community::create_community,
commands::community::join_community,
commands::community::leave_community,
commands::community::get_communities,
commands::community::create_channel,
commands::community::get_channels,
commands::community::get_members,
commands::community::delete_message,
commands::community::kick_member,
commands::community::generate_invite,
])
.run(tauri::generate_context!())
.expect("error while running dusk");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// prevents additional console window on windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
dusk_chat_lib::run()
}

View File

@ -0,0 +1,12 @@
use libp2p::{gossipsub, identify, kad, mdns, ping, relay, rendezvous, swarm::NetworkBehaviour};
#[derive(NetworkBehaviour)]
pub struct DuskBehaviour {
pub relay_client: relay::client::Behaviour,
pub rendezvous: rendezvous::client::Behaviour,
pub gossipsub: gossipsub::Behaviour,
pub kademlia: kad::Behaviour<kad::store::MemoryStore>,
pub mdns: mdns::tokio::Behaviour,
pub identify: identify::Behaviour,
pub ping: ping::Behaviour,
}

View File

@ -0,0 +1,11 @@
// peer discovery helpers for mdns and kademlia
// the actual discovery handling is in the node event loop (mod.rs)
// this module provides utility functions for discovery-related operations
use libp2p::Multiaddr;
// format a peer address for display
#[allow(dead_code)]
pub fn format_peer_addr(addr: &Multiaddr) -> String {
addr.to_string()
}

View File

@ -0,0 +1,34 @@
// gossipsub topic naming conventions for the dusk protocol
// topics encode the routing path for different message types
pub fn topic_for_messages(community_id: &str, channel_id: &str) -> String {
format!(
"dusk/community/{}/channel/{}/messages",
community_id, channel_id
)
}
pub fn topic_for_typing(community_id: &str, channel_id: &str) -> String {
format!(
"dusk/community/{}/channel/{}/typing",
community_id, channel_id
)
}
pub fn topic_for_presence(community_id: &str) -> String {
format!("dusk/community/{}/presence", community_id)
}
pub fn topic_for_meta(community_id: &str) -> String {
format!("dusk/community/{}/meta", community_id)
}
// global topic for user profile announcements and directory discovery
pub fn topic_for_directory() -> String {
"dusk/directory".to_string()
}
// global sync topic used to exchange full document snapshots between peers
pub fn topic_for_sync() -> String {
"dusk/sync".to_string()
}

710
src-tauri/src/node/mod.rs Normal file
View File

@ -0,0 +1,710 @@
pub mod behaviour;
pub mod discovery;
pub mod gossip;
pub mod swarm;
use std::collections::HashSet;
use std::sync::Arc;
use tauri::async_runtime::JoinHandle;
use tauri::Emitter;
use tokio::sync::Mutex;
use crate::crdt::CrdtEngine;
use crate::protocol::identity::DirectoryEntry;
// default relay address - override with DUSK_RELAY_ADDR env var
// format: /ip4/<ip>/tcp/<port>/p2p/<peer_id>
// left empty because 0.0.0.0 is a listen address, not a routable dial target.
// users must set DUSK_RELAY_ADDR to a reachable relay for WAN connectivity.
const DEFAULT_RELAY_ADDR: &str = "";
// relay reconnection parameters
const RELAY_INITIAL_BACKOFF_SECS: u64 = 2;
const RELAY_MAX_BACKOFF_SECS: u64 = 120;
const RELAY_BACKOFF_MULTIPLIER: u64 = 2;
// max time to hold pending rendezvous registrations before discarding (10 min)
const PENDING_QUEUE_TTL_SECS: u64 = 600;
// resolve the relay multiaddr from env or default
fn relay_addr() -> Option<libp2p::Multiaddr> {
let addr_str = std::env::var("DUSK_RELAY_ADDR")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
if DEFAULT_RELAY_ADDR.is_empty() {
None
} else {
Some(DEFAULT_RELAY_ADDR.to_string())
}
})?;
addr_str.parse().ok()
}
// extract the peer id from a multiaddr (the /p2p/<peer_id> component)
fn peer_id_from_multiaddr(addr: &libp2p::Multiaddr) -> Option<libp2p::PeerId> {
use libp2p::multiaddr::Protocol;
addr.iter().find_map(|p| match p {
Protocol::P2p(peer_id) => Some(peer_id),
_ => None,
})
}
// handle to the running p2p node, used to stop it
pub struct NodeHandle {
pub task: JoinHandle<()>,
// channel to send commands to the running node
pub command_tx: tokio::sync::mpsc::Sender<NodeCommand>,
}
// commands that can be sent to the running node
pub enum NodeCommand {
Shutdown,
SendMessage {
topic: String,
data: Vec<u8>,
},
Subscribe {
topic: String,
},
Unsubscribe {
topic: String,
},
// retrieve the swarm's external listen addresses for invite codes
GetListenAddrs {
reply: tokio::sync::oneshot::Sender<Vec<String>>,
},
// dial a specific multiaddr (used for relay connections)
Dial {
addr: libp2p::Multiaddr,
},
// register on rendezvous under a community namespace
RegisterRendezvous {
namespace: String,
},
// discover peers on rendezvous under a community namespace
DiscoverRendezvous {
namespace: String,
},
}
// events emitted from the node to the tauri frontend
#[derive(Debug, Clone, serde::Serialize)]
#[serde(tag = "kind", content = "payload")]
pub enum DuskEvent {
#[serde(rename = "message_received")]
MessageReceived(crate::protocol::messages::ChatMessage),
#[serde(rename = "message_deleted")]
MessageDeleted { message_id: String },
#[serde(rename = "member_kicked")]
MemberKicked { peer_id: String },
#[serde(rename = "peer_connected")]
PeerConnected { peer_id: String },
#[serde(rename = "peer_disconnected")]
PeerDisconnected { peer_id: String },
#[serde(rename = "typing")]
Typing { peer_id: String, channel_id: String },
#[serde(rename = "node_status")]
NodeStatus {
is_connected: bool,
peer_count: usize,
},
#[serde(rename = "sync_complete")]
SyncComplete { community_id: String },
#[serde(rename = "profile_received")]
ProfileReceived {
peer_id: String,
display_name: String,
bio: String,
},
#[serde(rename = "profile_revoked")]
ProfileRevoked { peer_id: String },
}
// extract the community id from a gossipsub topic string
fn community_id_from_topic(topic: &str) -> Option<&str> {
topic
.strip_prefix("dusk/community/")
.and_then(|rest| rest.split('/').next())
}
// start the p2p node on a background task
pub async fn start(
keypair: libp2p::identity::Keypair,
crdt_engine: Arc<Mutex<CrdtEngine>>,
storage: Arc<crate::storage::DiskStorage>,
app_handle: tauri::AppHandle,
) -> Result<NodeHandle, String> {
let mut swarm_instance =
swarm::build_swarm(&keypair).map_err(|e| format!("failed to build swarm: {}", e))?;
// listen on all interfaces for LAN peer discovery via mDNS
swarm_instance
.listen_on("/ip4/0.0.0.0/tcp/0".parse().unwrap())
.map_err(|e| format!("failed to listen: {}", e))?;
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel::<NodeCommand>(256);
// emit initial node status
let _ = app_handle.emit(
"dusk-event",
DuskEvent::NodeStatus {
is_connected: false,
peer_count: 0,
},
);
// resolve the relay address for WAN connectivity
let relay_multiaddr = relay_addr();
let relay_peer_id = relay_multiaddr.as_ref().and_then(peer_id_from_multiaddr);
// if a relay is configured, dial it immediately
if let Some(ref addr) = relay_multiaddr {
log::info!("dialing relay at {}", addr);
if let Err(e) = swarm_instance.dial(addr.clone()) {
log::warn!("failed to dial relay: {}", e);
}
}
let task = tauri::async_runtime::spawn(async move {
use futures::StreamExt;
// track connected peers for accurate count
let mut connected_peers: HashSet<String> = HashSet::new();
// track whether we have a relay reservation
let mut relay_reservation_active = false;
// track the relay peer id for rendezvous operations
let relay_peer = relay_peer_id;
// community namespaces we need to register on rendezvous
// queued until the relay connection is ready
let mut pending_registrations: Vec<String> = Vec::new();
let mut pending_discoveries: Vec<String> = Vec::new();
// timestamp when pending items were first queued (for TTL cleanup)
let mut pending_queued_at: Option<std::time::Instant> = None;
// rendezvous registration refresh interval (registrations expire)
let mut rendezvous_tick = tokio::time::interval(std::time::Duration::from_secs(120));
// all community namespaces we're registered under (for refresh)
let mut registered_namespaces: HashSet<String> = HashSet::new();
// relay reconnection state with exponential backoff
let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
// next instant at which we should attempt a relay reconnect
let mut relay_retry_at: Option<tokio::time::Instant> = if relay_multiaddr.is_some() {
// schedule initial retry in case the first dial failed synchronously
Some(
tokio::time::Instant::now()
+ std::time::Duration::from_secs(RELAY_INITIAL_BACKOFF_SECS),
)
} else {
None
};
loop {
tokio::select! {
event = swarm_instance.select_next_some() => {
match event {
// --- gossipsub messages ---
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Gossipsub(
libp2p::gossipsub::Event::Message { message, .. }
)) => {
let topic_str = message.topic.as_str().to_string();
// handle sync messages on the dedicated sync topic
if topic_str == gossip::topic_for_sync() {
if let Ok(sync_msg) = serde_json::from_slice::<crate::crdt::sync::SyncMessage>(&message.data) {
match sync_msg {
crate::crdt::sync::SyncMessage::RequestSync { .. } => {
let mut engine = crdt_engine.lock().await;
let ids = engine.community_ids();
for cid in ids {
if let Some(doc_bytes) = engine.get_doc_bytes(&cid) {
let snapshot = crate::crdt::sync::DocumentSnapshot {
community_id: cid.clone(),
doc_bytes,
};
let offer = crate::crdt::sync::SyncMessage::DocumentOffer(snapshot);
if let Ok(data) = serde_json::to_vec(&offer) {
let sync_topic = libp2p::gossipsub::IdentTopic::new(gossip::topic_for_sync());
let _ = swarm_instance.behaviour_mut().gossipsub.publish(sync_topic, data);
}
}
}
}
crate::crdt::sync::SyncMessage::DocumentOffer(snapshot) => {
let mut engine = crdt_engine.lock().await;
match engine.merge_remote_doc(&snapshot.community_id, &snapshot.doc_bytes) {
Ok(()) => {
let _ = app_handle.emit("dusk-event", DuskEvent::SyncComplete {
community_id: snapshot.community_id,
});
}
Err(e) => {
log::warn!("failed to merge remote doc for {}: {}", snapshot.community_id, e);
}
}
}
}
}
continue;
}
// handle regular gossip messages on community topics
if let Ok(gossip_msg) = serde_json::from_slice::<crate::protocol::messages::GossipMessage>(&message.data) {
match gossip_msg {
crate::protocol::messages::GossipMessage::Chat(chat_msg) => {
if let Some(community_id) = community_id_from_topic(&topic_str) {
let mut engine = crdt_engine.lock().await;
let _ = engine.append_message(community_id, &chat_msg);
}
let _ = app_handle.emit("dusk-event", DuskEvent::MessageReceived(chat_msg));
}
crate::protocol::messages::GossipMessage::Typing(indicator) => {
let _ = app_handle.emit("dusk-event", DuskEvent::Typing {
peer_id: indicator.peer_id,
channel_id: indicator.channel_id,
});
}
crate::protocol::messages::GossipMessage::DeleteMessage { message_id } => {
if let Some(community_id) = community_id_from_topic(&topic_str) {
let mut engine = crdt_engine.lock().await;
let _ = engine.delete_message(community_id, &message_id);
}
let _ = app_handle.emit("dusk-event", DuskEvent::MessageDeleted { message_id });
}
crate::protocol::messages::GossipMessage::MemberKicked { peer_id } => {
if let Some(community_id) = community_id_from_topic(&topic_str) {
let mut engine = crdt_engine.lock().await;
let _ = engine.remove_member(community_id, &peer_id);
}
let _ = app_handle.emit("dusk-event", DuskEvent::MemberKicked { peer_id });
}
crate::protocol::messages::GossipMessage::Presence(update) => {
match update.status {
crate::protocol::messages::PeerStatus::Online => {
let _ = app_handle.emit("dusk-event", DuskEvent::PeerConnected {
peer_id: update.peer_id,
});
}
crate::protocol::messages::PeerStatus::Offline => {
let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected {
peer_id: update.peer_id,
});
}
_ => {}
}
}
crate::protocol::messages::GossipMessage::MetaUpdate(meta) => {
let _ = app_handle.emit("dusk-event", DuskEvent::SyncComplete {
community_id: meta.id,
});
}
crate::protocol::messages::GossipMessage::ProfileAnnounce(profile) => {
// cache the peer profile in our local directory
let entry = DirectoryEntry {
peer_id: profile.peer_id.clone(),
display_name: profile.display_name.clone(),
bio: profile.bio.clone(),
public_key: profile.public_key.clone(),
last_seen: profile.timestamp,
is_friend: storage
.load_directory()
.ok()
.and_then(|d| d.get(&profile.peer_id).map(|e| e.is_friend))
.unwrap_or(false),
};
let _ = storage.save_directory_entry(&entry);
let _ = app_handle.emit("dusk-event", DuskEvent::ProfileReceived {
peer_id: profile.peer_id,
display_name: profile.display_name,
bio: profile.bio,
});
}
crate::protocol::messages::GossipMessage::ProfileRevoke(revocation) => {
// peer is revoking their identity, remove them from our directory
let _ = storage.remove_directory_entry(&revocation.peer_id);
let _ = app_handle.emit("dusk-event", DuskEvent::ProfileRevoked {
peer_id: revocation.peer_id,
});
}
}
}
}
// --- mDNS discovery (LAN) ---
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Mdns(
libp2p::mdns::Event::Discovered(peers)
)) => {
for (peer_id, addr) in &peers {
swarm_instance.behaviour_mut().gossipsub.add_explicit_peer(peer_id);
swarm_instance.behaviour_mut().kademlia.add_address(peer_id, addr.clone());
connected_peers.insert(peer_id.to_string());
let _ = app_handle.emit("dusk-event", DuskEvent::PeerConnected {
peer_id: peer_id.to_string(),
});
}
let _ = app_handle.emit("dusk-event", DuskEvent::NodeStatus {
is_connected: !connected_peers.is_empty(),
peer_count: connected_peers.len(),
});
// sync documents with newly discovered LAN peers
if !peers.is_empty() {
let local_peer_id = *swarm_instance.local_peer_id();
let request = crate::crdt::sync::SyncMessage::RequestSync {
peer_id: local_peer_id.to_string(),
};
if let Ok(data) = serde_json::to_vec(&request) {
let sync_topic = libp2p::gossipsub::IdentTopic::new(gossip::topic_for_sync());
let _ = swarm_instance.behaviour_mut().gossipsub.publish(sync_topic, data);
}
}
}
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Mdns(
libp2p::mdns::Event::Expired(peers)
)) => {
for (peer_id, _) in peers {
swarm_instance.behaviour_mut().gossipsub.remove_explicit_peer(&peer_id);
connected_peers.remove(&peer_id.to_string());
let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected {
peer_id: peer_id.to_string(),
});
}
let _ = app_handle.emit("dusk-event", DuskEvent::NodeStatus {
is_connected: !connected_peers.is_empty(),
peer_count: connected_peers.len(),
});
}
// --- relay client events ---
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::RelayClient(
libp2p::relay::client::Event::ReservationReqAccepted { relay_peer_id, .. }
)) => {
log::info!("relay reservation accepted by {}", relay_peer_id);
relay_reservation_active = true;
// now that we have a relay reservation, process any pending
// rendezvous registrations that were queued before the relay was ready
let queued = std::mem::take(&mut pending_registrations);
for ns in queued {
if let Some(rp) = relay_peer {
match libp2p::rendezvous::Namespace::new(ns.clone()) {
Ok(namespace) => {
if let Err(e) = swarm_instance.behaviour_mut().rendezvous.register(
namespace,
rp,
None,
) {
log::warn!("failed to register on rendezvous for {}: {:?}", ns, e);
} else {
registered_namespaces.insert(ns);
}
}
Err(e) => {
log::warn!("invalid rendezvous namespace '{}': {:?}", ns, e);
}
}
}
}
let queued = std::mem::take(&mut pending_discoveries);
for ns in queued {
if let Some(rp) = relay_peer {
swarm_instance.behaviour_mut().rendezvous.discover(
Some(libp2p::rendezvous::Namespace::new(ns.clone()).unwrap()),
None,
None,
rp,
);
}
}
// queues drained, reset the TTL tracker
pending_queued_at = None;
}
// --- rendezvous client events ---
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Rendezvous(
libp2p::rendezvous::client::Event::Registered { namespace, .. }
)) => {
log::info!("registered on rendezvous under namespace '{}'", namespace);
registered_namespaces.insert(namespace.to_string());
}
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Rendezvous(
libp2p::rendezvous::client::Event::Discovered { registrations, .. }
)) => {
// discovered peers on rendezvous, connect to them through the relay
for registration in registrations {
let discovered_peer = registration.record.peer_id();
let local_id = *swarm_instance.local_peer_id();
// don't connect to ourselves
if discovered_peer == local_id {
continue;
}
log::info!("discovered peer {} via rendezvous", discovered_peer);
// connect through the relay circuit so neither peer reveals their IP
if let Some(ref relay_addr) = relay_multiaddr {
let circuit_addr = relay_addr.clone()
.with(libp2p::multiaddr::Protocol::P2pCircuit)
.with(libp2p::multiaddr::Protocol::P2p(discovered_peer));
if let Err(e) = swarm_instance.dial(circuit_addr) {
log::warn!("failed to dial peer {} through relay: {}", discovered_peer, e);
}
}
}
}
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Rendezvous(
libp2p::rendezvous::client::Event::RegisterFailed { namespace, error, .. }
)) => {
log::warn!("rendezvous registration failed for '{}': {:?}", namespace, error);
}
// --- identify events ---
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Identify(
libp2p::identify::Event::Received { peer_id, info, .. }
)) => {
// add observed addresses to kademlia so peers can find each other
for addr in &info.listen_addrs {
swarm_instance.behaviour_mut().kademlia.add_address(&peer_id, addr.clone());
}
log::debug!("identified peer {}: {} addresses", peer_id, info.listen_addrs.len());
}
// --- outgoing dial failures ---
libp2p::swarm::SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => {
// if this was a failed dial to the relay, schedule a retry
if let Some(failed_peer) = peer_id {
if Some(failed_peer) == relay_peer {
log::warn!("failed to connect to relay: {}", error);
log::info!("scheduling relay reconnect in {}s", relay_backoff_secs);
relay_retry_at = Some(
tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs),
);
// exponential backoff capped at max
relay_backoff_secs = (relay_backoff_secs * RELAY_BACKOFF_MULTIPLIER)
.min(RELAY_MAX_BACKOFF_SECS);
}
}
}
// --- connection lifecycle ---
libp2p::swarm::SwarmEvent::ConnectionEstablished { peer_id, .. } => {
// add to gossipsub mesh for WAN peers (mDNS handles LAN peers)
swarm_instance.behaviour_mut().gossipsub.add_explicit_peer(&peer_id);
connected_peers.insert(peer_id.to_string());
let _ = app_handle.emit("dusk-event", DuskEvent::PeerConnected {
peer_id: peer_id.to_string(),
});
let _ = app_handle.emit("dusk-event", DuskEvent::NodeStatus {
is_connected: true,
peer_count: connected_peers.len(),
});
// if we just connected to the relay, make a reservation
// so other peers can reach us through it
if Some(peer_id) == relay_peer && !relay_reservation_active {
// reset backoff on successful connection
relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
// cancel any pending retry
relay_retry_at = None;
if let Some(ref addr) = relay_multiaddr {
let relay_circuit_addr = addr.clone()
.with(libp2p::multiaddr::Protocol::P2pCircuit);
log::info!("connected to relay, requesting reservation");
if let Err(e) = swarm_instance.listen_on(relay_circuit_addr) {
log::warn!("failed to listen on relay circuit: {}", e);
}
}
}
// request sync from newly connected peers
let local_peer_id = *swarm_instance.local_peer_id();
let request = crate::crdt::sync::SyncMessage::RequestSync {
peer_id: local_peer_id.to_string(),
};
if let Ok(data) = serde_json::to_vec(&request) {
let sync_topic = libp2p::gossipsub::IdentTopic::new(gossip::topic_for_sync());
let _ = swarm_instance.behaviour_mut().gossipsub.publish(sync_topic, data);
}
}
libp2p::swarm::SwarmEvent::ConnectionClosed { peer_id, num_established, .. } => {
if num_established == 0 {
connected_peers.remove(&peer_id.to_string());
let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected {
peer_id: peer_id.to_string(),
});
let _ = app_handle.emit("dusk-event", DuskEvent::NodeStatus {
is_connected: !connected_peers.is_empty(),
peer_count: connected_peers.len(),
});
// if we lost the relay connection, mark reservation as inactive
// and schedule a retry with backoff
if Some(peer_id) == relay_peer {
relay_reservation_active = false;
log::warn!("lost connection to relay, scheduling reconnect in {}s", relay_backoff_secs);
relay_retry_at = Some(
tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs),
);
relay_backoff_secs = (relay_backoff_secs * RELAY_BACKOFF_MULTIPLIER)
.min(RELAY_MAX_BACKOFF_SECS);
}
}
}
_ => {}
}
}
// periodic rendezvous re-registration (registrations expire on the server)
_ = rendezvous_tick.tick() => {
if relay_reservation_active {
if let Some(rp) = relay_peer {
for ns in registered_namespaces.clone() {
if let Err(e) = swarm_instance.behaviour_mut().rendezvous.register(
libp2p::rendezvous::Namespace::new(ns.clone()).unwrap(),
rp,
None,
) {
log::warn!("failed to refresh rendezvous registration for {}: {:?}", ns, e);
}
}
}
}
// clean up stale pending registrations/discoveries that have been
// queued too long without a relay connection
if let Some(queued_at) = pending_queued_at {
if queued_at.elapsed() > std::time::Duration::from_secs(PENDING_QUEUE_TTL_SECS) {
if !pending_registrations.is_empty() || !pending_discoveries.is_empty() {
log::warn!(
"discarding {} pending registrations and {} pending discoveries (relay unavailable for {}s)",
pending_registrations.len(),
pending_discoveries.len(),
PENDING_QUEUE_TTL_SECS,
);
pending_registrations.clear();
pending_discoveries.clear();
pending_queued_at = None;
}
}
}
}
// relay reconnection with exponential backoff
_ = tokio::time::sleep_until(
relay_retry_at.unwrap_or_else(|| tokio::time::Instant::now() + std::time::Duration::from_secs(86400))
), if relay_retry_at.is_some() => {
relay_retry_at = None;
if !relay_reservation_active {
if let Some(ref addr) = relay_multiaddr {
log::info!("attempting relay reconnect to {}", addr);
if let Err(e) = swarm_instance.dial(addr.clone()) {
log::warn!("failed to dial relay: {}", e);
// schedule another retry
relay_retry_at = Some(
tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs),
);
relay_backoff_secs = (relay_backoff_secs * RELAY_BACKOFF_MULTIPLIER)
.min(RELAY_MAX_BACKOFF_SECS);
}
}
}
}
cmd = command_rx.recv() => {
match cmd {
Some(NodeCommand::Shutdown) | None => break,
Some(NodeCommand::SendMessage { topic, data }) => {
let ident_topic = libp2p::gossipsub::IdentTopic::new(topic);
let _ = swarm_instance.behaviour_mut().gossipsub.publish(ident_topic, data);
}
Some(NodeCommand::Subscribe { topic }) => {
let ident_topic = libp2p::gossipsub::IdentTopic::new(topic);
let _ = swarm_instance.behaviour_mut().gossipsub.subscribe(&ident_topic);
}
Some(NodeCommand::Unsubscribe { topic }) => {
let ident_topic = libp2p::gossipsub::IdentTopic::new(topic);
let _ = swarm_instance.behaviour_mut().gossipsub.unsubscribe(&ident_topic);
}
Some(NodeCommand::GetListenAddrs { reply }) => {
let addrs: Vec<String> = swarm_instance
.listeners()
.map(|a| a.to_string())
.collect();
let _ = reply.send(addrs);
}
Some(NodeCommand::Dial { addr }) => {
if let Err(e) = swarm_instance.dial(addr.clone()) {
log::warn!("failed to dial {}: {}", addr, e);
}
}
Some(NodeCommand::RegisterRendezvous { namespace }) => {
if relay_reservation_active {
if let Some(rp) = relay_peer {
match libp2p::rendezvous::Namespace::new(namespace.clone()) {
Ok(ns) => {
if let Err(e) = swarm_instance.behaviour_mut().rendezvous.register(ns, rp, None) {
log::warn!("failed to register on rendezvous: {:?}", e);
} else {
registered_namespaces.insert(namespace);
}
}
Err(e) => log::warn!("invalid rendezvous namespace '{}': {:?}", namespace, e),
}
}
} else {
// queue for later once relay is ready
if pending_queued_at.is_none() {
pending_queued_at = Some(std::time::Instant::now());
}
pending_registrations.push(namespace);
}
}
Some(NodeCommand::DiscoverRendezvous { namespace }) => {
if relay_reservation_active {
if let Some(rp) = relay_peer {
match libp2p::rendezvous::Namespace::new(namespace.clone()) {
Ok(ns) => {
swarm_instance.behaviour_mut().rendezvous.discover(
Some(ns),
None,
None,
rp,
);
}
Err(e) => log::warn!("invalid rendezvous namespace '{}': {:?}", namespace, e),
}
}
} else {
// queue for later once relay is ready
if pending_queued_at.is_none() {
pending_queued_at = Some(std::time::Instant::now());
}
pending_discoveries.push(namespace);
}
}
}
}
}
}
log::info!("p2p node event loop exited");
});
Ok(NodeHandle { task, command_tx })
}

View File

@ -0,0 +1,81 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::Duration;
use libp2p::{
gossipsub, identify, identity, kad, mdns, noise, ping, rendezvous, tcp, yamux, Swarm,
SwarmBuilder,
};
use super::behaviour::DuskBehaviour;
pub fn build_swarm(
keypair: &identity::Keypair,
) -> Result<Swarm<DuskBehaviour>, Box<dyn std::error::Error>> {
// gossipsub config: content-addressed message deduplication
let message_id_fn = |message: &gossipsub::Message| {
let mut hasher = DefaultHasher::new();
message.data.hash(&mut hasher);
if let Some(ref source) = message.source {
source.hash(&mut hasher);
}
gossipsub::MessageId::from(hasher.finish().to_string())
};
let gossipsub_config = gossipsub::ConfigBuilder::default()
.heartbeat_interval(Duration::from_secs(1))
.validation_mode(gossipsub::ValidationMode::Strict)
.message_id_fn(message_id_fn)
.mesh_n(6)
.mesh_n_low(4)
.mesh_n_high(12)
.history_length(5)
.history_gossip(3)
.build()
.map_err(|e| format!("invalid gossipsub config: {}", e))?;
let swarm = SwarmBuilder::with_existing_identity(keypair.clone())
.with_tokio()
.with_tcp(
tcp::Config::default(),
noise::Config::new,
yamux::Config::default,
)?
// add relay client transport so we can connect through relay circuits
.with_relay_client(noise::Config::new, yamux::Config::default)?
.with_behaviour(|key, relay_client| {
let peer_id = key.public().to_peer_id();
let gossipsub = gossipsub::Behaviour::new(
gossipsub::MessageAuthenticity::Signed(key.clone()),
gossipsub_config,
)
.expect("valid gossipsub behaviour");
let kademlia = kad::Behaviour::new(peer_id, kad::store::MemoryStore::new(peer_id));
let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), peer_id)
.expect("valid mdns behaviour");
let identify = identify::Behaviour::new(identify::Config::new(
"/dusk/1.0.0".to_string(),
key.public(),
));
let rendezvous = rendezvous::client::Behaviour::new(key.clone());
DuskBehaviour {
relay_client,
rendezvous,
gossipsub,
kademlia,
mdns,
identify,
ping: ping::Behaviour::default(),
}
})?
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(60)))
.build();
Ok(swarm)
}

View File

@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommunityMeta {
pub id: String,
pub name: String,
pub description: String,
pub created_by: String,
pub created_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelMeta {
pub id: String,
pub community_id: String,
pub name: String,
pub topic: String,
pub kind: ChannelKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChannelKind {
Text,
Voice,
}
// invite codes encode the minimum information needed to join a community
// deliberately excludes IP addresses to protect peer privacy
// peers discover each other via the rendezvous protocol on the relay server
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InviteCode {
pub community_id: String,
pub community_name: String,
}
impl InviteCode {
// encode the invite as a base58 string for easy sharing
pub fn encode(&self) -> String {
let json = serde_json::to_vec(self).expect("failed to serialize invite code");
bs58::encode(json).into_string()
}
// decode a base58 invite string back into an InviteCode
pub fn decode(encoded: &str) -> Result<Self, String> {
let bytes = bs58::decode(encoded)
.into_vec()
.map_err(|e| format!("invalid invite code encoding: {}", e))?;
serde_json::from_slice(&bytes).map_err(|e| format!("invalid invite code format: {}", e))
}
}
// member within a community
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Member {
pub peer_id: String,
pub display_name: String,
pub status: super::messages::PeerStatus,
pub roles: Vec<String>,
pub trust_level: f64,
pub joined_at: u64,
}

View File

@ -0,0 +1,130 @@
use libp2p::identity;
use libp2p::PeerId;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::storage::DiskStorage;
pub struct DuskIdentity {
pub keypair: identity::Keypair,
pub peer_id: PeerId,
pub display_name: String,
pub bio: String,
pub created_at: u64,
}
impl DuskIdentity {
// generate a fresh ed25519 identity
pub fn generate(display_name: &str, bio: &str) -> Self {
let keypair = identity::Keypair::generate_ed25519();
let peer_id = PeerId::from(keypair.public());
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
Self {
keypair,
peer_id,
display_name: display_name.to_string(),
bio: bio.to_string(),
created_at,
}
}
// load an existing identity from disk
pub fn load(storage: &DiskStorage) -> Result<Self, String> {
let keypair_bytes = storage
.load_keypair()
.map_err(|e| format!("failed to load keypair: {}", e))?;
let keypair = identity::Keypair::from_protobuf_encoding(&keypair_bytes)
.map_err(|e| format!("invalid keypair data: {}", e))?;
let peer_id = PeerId::from(keypair.public());
let profile = storage.load_profile().unwrap_or_default();
Ok(Self {
keypair,
peer_id,
display_name: profile.display_name,
bio: profile.bio,
created_at: profile.created_at,
})
}
// persist identity to disk
pub fn save(&self, storage: &DiskStorage) -> Result<(), String> {
let keypair_bytes = self
.keypair
.to_protobuf_encoding()
.map_err(|e| format!("failed to encode keypair: {}", e))?;
storage
.save_keypair(&keypair_bytes)
.map_err(|e| format!("failed to save keypair: {}", e))?;
let profile = ProfileData {
display_name: self.display_name.clone(),
bio: self.bio.clone(),
created_at: self.created_at,
};
storage
.save_profile(&profile)
.map_err(|e| format!("failed to save profile: {}", e))?;
Ok(())
}
// public-facing identity info safe to share
pub fn public_identity(&self) -> PublicIdentity {
let public_key_bytes = self.keypair.public().encode_protobuf();
PublicIdentity {
peer_id: self.peer_id.to_string(),
display_name: self.display_name.clone(),
public_key: hex::encode(public_key_bytes),
bio: self.bio.clone(),
created_at: self.created_at,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicIdentity {
pub peer_id: String,
pub display_name: String,
pub public_key: String,
pub bio: String,
pub created_at: u64,
}
// profile data stored on disk alongside the keypair
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileData {
pub display_name: String,
pub bio: String,
pub created_at: u64,
}
impl Default for ProfileData {
fn default() -> Self {
Self {
display_name: "anonymous".to_string(),
bio: String::new(),
created_at: 0,
}
}
}
// a peer profile cached in the local user directory
// this is what other peers announce about themselves on the network
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryEntry {
pub peer_id: String,
pub display_name: String,
pub bio: String,
pub public_key: String,
pub last_seen: u64,
pub is_friend: bool,
}

View File

@ -0,0 +1,65 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub id: String,
pub channel_id: String,
pub author_id: String,
pub author_name: String,
pub content: String,
pub timestamp: u64,
pub edited: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypingIndicator {
pub peer_id: String,
pub channel_id: String,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresenceUpdate {
pub peer_id: String,
pub display_name: String,
pub status: PeerStatus,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PeerStatus {
Online,
Idle,
Offline,
}
// peer profile announcement broadcast on the directory topic
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileAnnouncement {
pub peer_id: String,
pub display_name: String,
pub bio: String,
pub public_key: String,
pub timestamp: u64,
}
// broadcast when a user resets their identity, tells peers to purge their data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileRevocation {
pub peer_id: String,
pub public_key: String,
pub timestamp: u64,
}
// envelope for all gossipsub-published messages
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GossipMessage {
Chat(ChatMessage),
Typing(TypingIndicator),
Presence(PresenceUpdate),
MetaUpdate(super::community::CommunityMeta),
DeleteMessage { message_id: String },
MemberKicked { peer_id: String },
ProfileAnnounce(ProfileAnnouncement),
ProfileRevoke(ProfileRevocation),
}

View File

@ -0,0 +1,3 @@
pub mod community;
pub mod identity;
pub mod messages;

View File

@ -0,0 +1,253 @@
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::PathBuf;
use crate::protocol::community::CommunityMeta;
use crate::protocol::identity::{DirectoryEntry, ProfileData};
// user settings that persist across sessions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSettings {
pub display_name: String,
pub status: String,
pub status_message: String,
pub enable_sounds: bool,
pub enable_desktop_notifications: bool,
pub enable_message_preview: bool,
pub show_online_status: bool,
pub allow_dms_from_anyone: bool,
pub message_display: String,
pub font_size: String,
}
impl Default for UserSettings {
fn default() -> Self {
Self {
display_name: "anonymous".to_string(),
status: "online".to_string(),
status_message: String::new(),
enable_sounds: true,
enable_desktop_notifications: true,
enable_message_preview: true,
show_online_status: true,
allow_dms_from_anyone: true,
message_display: "cozy".to_string(),
font_size: "default".to_string(),
}
}
}
// file-based persistence for identity, documents, and community metadata
pub struct DiskStorage {
base_dir: PathBuf,
}
impl DiskStorage {
pub fn new() -> Result<Self, io::Error> {
let project_dirs = ProjectDirs::from("app", "duskchat", "dusk")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no valid home directory"))?;
let base_dir = project_dirs.data_dir().to_path_buf();
// ensure the directory tree exists
fs::create_dir_all(base_dir.join("identity"))?;
fs::create_dir_all(base_dir.join("communities"))?;
fs::create_dir_all(base_dir.join("directory"))?;
Ok(Self { base_dir })
}
// -- identity --
pub fn save_keypair(&self, keypair_bytes: &[u8]) -> Result<(), io::Error> {
fs::write(self.base_dir.join("identity/keypair.bin"), keypair_bytes)
}
pub fn load_keypair(&self) -> Result<Vec<u8>, io::Error> {
fs::read(self.base_dir.join("identity/keypair.bin"))
}
pub fn save_display_name(&self, name: &str) -> Result<(), io::Error> {
let profile = serde_json::json!({ "display_name": name });
fs::write(
self.base_dir.join("identity/profile.json"),
serde_json::to_string_pretty(&profile).unwrap(),
)
}
pub fn load_display_name(&self) -> Result<String, io::Error> {
let data = fs::read_to_string(self.base_dir.join("identity/profile.json"))?;
let profile: serde_json::Value = serde_json::from_str(&data)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
profile["display_name"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing display_name"))
}
// full profile data with bio and created_at
pub fn save_profile(&self, profile: &ProfileData) -> Result<(), io::Error> {
let json = serde_json::to_string_pretty(profile)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(self.base_dir.join("identity/profile.json"), json)
}
pub fn load_profile(&self) -> Result<ProfileData, io::Error> {
let path = self.base_dir.join("identity/profile.json");
if !path.exists() {
return Ok(ProfileData::default());
}
let data = fs::read_to_string(path)?;
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
// check if identity exists without loading it
pub fn has_identity(&self) -> bool {
self.base_dir.join("identity/keypair.bin").exists()
}
// -- automerge documents --
pub fn save_document(&self, community_id: &str, doc_bytes: &[u8]) -> Result<(), io::Error> {
let dir = self.base_dir.join(format!("communities/{}", community_id));
fs::create_dir_all(&dir)?;
fs::write(dir.join("document.bin"), doc_bytes)
}
pub fn load_document(&self, community_id: &str) -> Result<Vec<u8>, io::Error> {
fs::read(
self.base_dir
.join(format!("communities/{}/document.bin", community_id)),
)
}
pub fn list_communities(&self) -> Result<Vec<String>, io::Error> {
let communities_dir = self.base_dir.join("communities");
if !communities_dir.exists() {
return Ok(Vec::new());
}
let mut ids = Vec::new();
for entry in fs::read_dir(communities_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
ids.push(name.to_string());
}
}
}
Ok(ids)
}
// -- community metadata cache --
pub fn save_community_meta(&self, meta: &CommunityMeta) -> Result<(), io::Error> {
let dir = self.base_dir.join(format!("communities/{}", meta.id));
fs::create_dir_all(&dir)?;
let json = serde_json::to_string_pretty(meta)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(dir.join("meta.json"), json)
}
pub fn load_community_meta(&self, community_id: &str) -> Result<CommunityMeta, io::Error> {
let data = fs::read_to_string(
self.base_dir
.join(format!("communities/{}/meta.json", community_id)),
)?;
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
// -- user settings --
pub fn save_settings(&self, settings: &UserSettings) -> Result<(), io::Error> {
let json = serde_json::to_string_pretty(settings)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(self.base_dir.join("identity/settings.json"), json)
}
pub fn load_settings(&self) -> Result<UserSettings, io::Error> {
let path = self.base_dir.join("identity/settings.json");
if !path.exists() {
return Ok(UserSettings::default());
}
let data = fs::read_to_string(path)?;
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
// -- peer directory --
// save a discovered peer to the local directory
pub fn save_directory_entry(&self, entry: &DirectoryEntry) -> Result<(), io::Error> {
let mut entries = self.load_directory().unwrap_or_default();
entries.insert(entry.peer_id.clone(), entry.clone());
let json = serde_json::to_string_pretty(&entries)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(self.base_dir.join("directory/peers.json"), json)
}
// load the entire peer directory
pub fn load_directory(&self) -> Result<HashMap<String, DirectoryEntry>, io::Error> {
let path = self.base_dir.join("directory/peers.json");
if !path.exists() {
return Ok(HashMap::new());
}
let data = fs::read_to_string(path)?;
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
// remove a peer from the directory
pub fn remove_directory_entry(&self, peer_id: &str) -> Result<(), io::Error> {
let mut entries = self.load_directory().unwrap_or_default();
entries.remove(peer_id);
let json = serde_json::to_string_pretty(&entries)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(self.base_dir.join("directory/peers.json"), json)
}
// toggle friend status for a peer
pub fn set_friend_status(&self, peer_id: &str, is_friend: bool) -> Result<(), io::Error> {
let mut entries = self.load_directory().unwrap_or_default();
if let Some(entry) = entries.get_mut(peer_id) {
entry.is_friend = is_friend;
let json = serde_json::to_string_pretty(&entries)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(self.base_dir.join("directory/peers.json"), json)
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
"peer not found in directory",
))
}
}
// wipe all user data - identity, communities, directory, settings
// used when resetting identity to leave no traces on this client
pub fn wipe_all_data(&self) -> Result<(), io::Error> {
let identity_dir = self.base_dir.join("identity");
if identity_dir.exists() {
fs::remove_dir_all(&identity_dir)?;
}
let communities_dir = self.base_dir.join("communities");
if communities_dir.exists() {
fs::remove_dir_all(&communities_dir)?;
}
let directory_dir = self.base_dir.join("directory");
if directory_dir.exists() {
fs::remove_dir_all(&directory_dir)?;
}
// recreate the directory tree so the app can still function
fs::create_dir_all(self.base_dir.join("identity"))?;
fs::create_dir_all(self.base_dir.join("communities"))?;
fs::create_dir_all(self.base_dir.join("directory"))?;
Ok(())
}
}

View File

@ -0,0 +1,4 @@
mod disk;
pub use disk::DiskStorage;
pub use disk::UserSettings;

38
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "dusk",
"version": "0.1.0",
"identifier": "app.duskchat.dusk",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "dusk",
"width": 1280,
"height": 800,
"minWidth": 400,
"minHeight": 600,
"decorations": true
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' asset: http://asset.localhost data:; connect-src ipc: http://ipc.localhost; worker-src 'none'; object-src 'none'; base-uri 'self'"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

984
src/App.tsx Normal file
View File

@ -0,0 +1,984 @@
import { Component, onMount, onCleanup, createSignal, Show } from "solid-js";
import AppLayout from "./components/layout/AppLayout";
import OverlayMenu from "./components/navigation/OverlayMenu";
import MobileNav from "./components/navigation/MobileNav";
import Modal from "./components/common/Modal";
import Button from "./components/common/Button";
import SettingsModal from "./components/settings/SettingsModal";
import SignUpScreen from "./components/auth/SignUpScreen";
import UserDirectoryModal from "./components/directory/UserDirectoryModal";
import {
overlayMenuOpen,
closeOverlay,
activeModal,
closeModal,
openModal,
initResponsive,
} from "./stores/ui";
import { setCurrentIdentity, identity } from "./stores/identity";
import { settings, updateSettings } from "./stores/settings";
import {
setCommunities,
setActiveCommunity,
activeCommunityId,
addCommunity,
} from "./stores/communities";
import {
setChannels,
setActiveChannel,
activeChannelId,
} from "./stores/channels";
import {
addMessage,
setMessages,
clearMessages,
removeMessage,
} from "./stores/messages";
import {
setMembers,
addTypingPeer,
setPeerOnline,
setPeerOffline,
removeMember,
} from "./stores/members";
import {
setPeerCount,
setNodeStatus,
setIsConnected,
} from "./stores/connection";
import {
setDMConversations,
activeDMPeerId,
addDMMessage,
setActiveDM,
updateDMLastMessage,
} from "./stores/dms";
import {
setKnownPeers,
setFriends,
updatePeerProfile,
removePeer,
clearDirectory,
} from "./stores/directory";
import * as tauri from "./lib/tauri";
import type { DuskEvent } from "./lib/types";
import { resetSettings } from "./stores/settings";
const App: Component = () => {
let cleanupResize: (() => void) | undefined;
let cleanupEvents: (() => void) | undefined;
const [tauriAvailable, setTauriAvailable] = createSignal(false);
const [needsSignUp, setNeedsSignUp] = createSignal(false);
const [appReady, setAppReady] = createSignal(false);
const [newCommunityName, setNewCommunityName] = createSignal("");
const [newCommunityDesc, setNewCommunityDesc] = createSignal("");
const [joinInviteCode, setJoinInviteCode] = createSignal("");
const [newChannelName, setNewChannelName] = createSignal("");
const [newChannelTopic, setNewChannelTopic] = createSignal("");
onMount(async () => {
cleanupResize = initResponsive();
// detect tauri environment via the injected runtime bridge
const isTauri =
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
console.log("tauri detection result:", isTauri);
setTauriAvailable(isTauri);
if (isTauri) {
// check if identity exists before loading
const hasExisting = await tauri.hasIdentity();
if (hasExisting) {
await initWithTauri();
setAppReady(true);
} else {
// show the signup screen
setNeedsSignUp(true);
}
} else {
loadDemoData();
setAppReady(true);
}
});
onCleanup(() => {
cleanupResize?.();
cleanupEvents?.();
});
async function initWithTauri() {
try {
const existing = await tauri.loadIdentity();
if (existing) {
setCurrentIdentity(existing);
// ensure settings display name matches identity
updateSettings({ display_name: existing.display_name });
}
// load user settings from disk
try {
const loadedSettings = await tauri.loadSettings();
// ensure identity display name takes precedence
if (existing) {
loadedSettings.display_name = existing.display_name;
}
updateSettings(loadedSettings);
} catch {
// settings not found, use defaults
}
// load the peer directory and friends list
try {
const peers = await tauri.getKnownPeers();
setKnownPeers(peers);
const friendsList = await tauri.getFriends();
setFriends(friendsList);
} catch {
// directory not populated yet, that's fine
}
const communities = await tauri.getCommunities();
setCommunities(communities);
// register the event listener before starting the node so we don't
// miss the initial NodeStatus event emitted during startup
const unlisten = await tauri.onDuskEvent(handleDuskEvent);
cleanupEvents = unlisten;
setNodeStatus("starting");
await tauri.startNode();
// node is running but connection status is determined by backend events.
// do not optimistically set isConnected here - the node_status event
// from the backend will set the accurate state once peers are found.
setNodeStatus("running");
if (communities.length > 0) {
setActiveCommunity(communities[0].id);
const channels = await tauri.getChannels(communities[0].id);
setChannels(channels);
if (channels.length > 0) {
setActiveChannel(channels[0].id);
const messages = await tauri.getMessages(channels[0].id);
setMessages(messages);
}
const members = await tauri.getMembers(communities[0].id);
setMembers(members);
}
} catch (e) {
console.error("initialization error:", e);
setNodeStatus("error");
}
}
function handleDuskEvent(event: DuskEvent) {
switch (event.kind) {
case "message_received":
if (event.payload.channel_id === activeChannelId()) {
addMessage(event.payload);
}
break;
case "message_deleted":
removeMessage(event.payload.message_id);
break;
case "member_kicked":
removeMember(event.payload.peer_id);
break;
case "peer_connected":
setPeerOnline(event.payload.peer_id);
break;
case "peer_disconnected":
setPeerOffline(event.payload.peer_id);
break;
case "typing":
if (event.payload.channel_id === activeChannelId()) {
addTypingPeer(event.payload.peer_id);
}
break;
case "node_status":
setIsConnected(event.payload.is_connected);
setPeerCount(event.payload.peer_count);
// the node is still running even with zero peers, only mark stopped
// if the node itself has shut down (handled by stop_node command)
break;
case "sync_complete":
if (event.payload.community_id === activeCommunityId()) {
reloadCurrentChannel();
}
break;
case "profile_received":
// update our local directory cache when a peer announces their profile
updatePeerProfile(
event.payload.peer_id,
event.payload.display_name,
event.payload.bio,
);
break;
case "profile_revoked":
// peer revoked their identity, remove them from our local directory
removePeer(event.payload.peer_id);
break;
}
}
async function reloadCurrentChannel() {
const channelId = activeChannelId();
if (!channelId || !tauriAvailable()) return;
try {
const msgs = await tauri.getMessages(channelId);
setMessages(msgs);
} catch (e) {
console.error("failed to reload messages:", e);
}
}
async function handleSendMessage(content: string) {
const channelId = activeChannelId();
if (!channelId) return;
if (tauriAvailable()) {
try {
const msg = await tauri.sendMessage(channelId, content);
addMessage(msg);
} catch (e) {
console.error("failed to send message:", e);
}
} else {
const id = identity();
addMessage({
id: `demo_${Date.now()}`,
channel_id: channelId,
author_id: id?.peer_id ?? "local",
author_name: id?.display_name ?? "you",
content,
timestamp: Date.now(),
edited: false,
});
}
}
function handleTyping() {
const channelId = activeChannelId();
if (!channelId || !tauriAvailable()) return;
tauri.sendTypingIndicator(channelId).catch(() => {});
}
function handleSendDM(content: string) {
const peerId = activeDMPeerId();
if (!peerId) return;
const id = identity();
const msg = {
id: `dm_${Date.now()}`,
channel_id: `dm_${peerId}`,
author_id: id?.peer_id ?? "local",
author_name: id?.display_name ?? "you",
content,
timestamp: Date.now(),
edited: false,
};
addDMMessage(msg);
updateDMLastMessage(peerId, content, msg.timestamp);
}
function handleOverlayNavigate(action: string) {
switch (action) {
case "create-community":
openModal("create-community");
break;
case "join-community":
openModal("join-community");
break;
case "settings":
openModal("settings");
break;
case "directory":
openModal("directory");
break;
case "home":
setActiveCommunity(null);
setActiveDM(null);
break;
}
}
async function handleCreateCommunity() {
const name = newCommunityName().trim();
const desc = newCommunityDesc().trim();
if (!name) return;
if (tauriAvailable()) {
try {
const community = await tauri.createCommunity(name, desc);
addCommunity(community);
setActiveCommunity(community.id);
const channels = await tauri.getChannels(community.id);
setChannels(channels);
if (channels.length > 0) {
setActiveChannel(channels[0].id);
clearMessages();
}
const members = await tauri.getMembers(community.id);
setMembers(members);
} catch (e) {
console.error("failed to create community:", e);
}
} else {
const id = `com_demo_${Date.now()}`;
const chId = `ch_general_${Date.now()}`;
addCommunity({
id,
name,
description: desc,
created_by: "local",
created_at: Date.now(),
});
setActiveCommunity(id);
setChannels([
{
id: chId,
community_id: id,
name: "general",
topic: "general discussion",
kind: "Text",
},
]);
setActiveChannel(chId);
clearMessages();
setMembers([
{
peer_id: "local",
display_name: identity()?.display_name ?? "you",
status: "Online",
roles: ["owner"],
trust_level: 1.0,
joined_at: Date.now(),
},
]);
}
setNewCommunityName("");
setNewCommunityDesc("");
closeModal();
}
async function handleJoinCommunity() {
const inviteCode = joinInviteCode().trim();
if (!inviteCode) return;
if (tauriAvailable()) {
try {
const community = await tauri.joinCommunity(inviteCode);
addCommunity(community);
setActiveCommunity(community.id);
const channels = await tauri.getChannels(community.id);
setChannels(channels);
if (channels.length > 0) {
setActiveChannel(channels[0].id);
clearMessages();
}
const members = await tauri.getMembers(community.id);
setMembers(members);
} catch (e) {
console.error("failed to join community:", e);
}
} else {
// demo mode - simulate joining
const id = `com_demo_joined_${Date.now()}`;
const chId = `ch_general_${Date.now()}`;
addCommunity({
id,
name: "joined community",
description: "a community you joined",
created_by: "remote",
created_at: Date.now(),
});
setActiveCommunity(id);
setChannels([
{
id: chId,
community_id: id,
name: "general",
topic: "general discussion",
kind: "Text",
},
]);
setActiveChannel(chId);
clearMessages();
setMembers([
{
peer_id: "local",
display_name: identity()?.display_name ?? "you",
status: "Online",
roles: ["member"],
trust_level: 0.5,
joined_at: Date.now(),
},
]);
}
setJoinInviteCode("");
closeModal();
}
async function handleCreateChannel() {
const name = newChannelName().trim();
const topic = newChannelTopic().trim();
const communityId = activeCommunityId();
if (!name || !communityId) return;
if (tauriAvailable()) {
try {
const channel = await tauri.createChannel(communityId, name, topic);
setChannels((prev) => [...prev, channel]);
setActiveChannel(channel.id);
clearMessages();
} catch (e) {
console.error("failed to create channel:", e);
}
} else {
// demo mode
const chId = `ch_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}`;
const channel = {
id: chId,
community_id: communityId,
name,
topic: topic || `${name} discussion`,
kind: "Text" as const,
};
setChannels((prev) => [...prev, channel]);
setActiveChannel(chId);
clearMessages();
}
setNewChannelName("");
setNewChannelTopic("");
closeModal();
}
async function handleSaveSettings() {
if (tauriAvailable()) {
try {
await tauri.saveSettings(settings());
// also update the identity with new display name
const current = settings();
if (identity()?.display_name !== current.display_name) {
await tauri.updateDisplayName(current.display_name);
setCurrentIdentity({
...identity()!,
display_name: current.display_name,
});
}
} catch (e) {
console.error("failed to save settings:", e);
}
}
closeModal();
}
async function handleSignUpComplete(displayName: string, bio: string) {
if (tauriAvailable()) {
try {
const created = await tauri.createIdentity(displayName, bio);
setCurrentIdentity(created);
updateSettings({ display_name: displayName });
setNeedsSignUp(false);
await initWithTauri();
setAppReady(true);
} catch (e) {
console.error("failed to create identity:", e);
}
} else {
// demo mode fallback
setCurrentIdentity({
peer_id: "12D3KooWDemo1234567890abcdef",
display_name: displayName,
public_key: "abcdef1234567890",
bio,
created_at: Date.now(),
});
updateSettings({ display_name: displayName });
setNeedsSignUp(false);
loadDemoData();
setAppReady(true);
}
}
async function handleResetIdentity() {
if (tauriAvailable()) {
try {
await tauri.resetIdentity();
} catch (e) {
console.error("failed to reset identity:", e);
}
}
// clear all in-memory state
setCurrentIdentity(null);
clearDirectory();
resetSettings();
setCommunities([]);
setActiveCommunity(null);
setChannels([]);
setActiveChannel(null);
clearMessages();
setMembers([]);
setDMConversations([]);
setActiveDM(null);
setPeerCount(0);
setIsConnected(false);
setNodeStatus("stopped");
localStorage.removeItem("dusk_user_settings");
// clean up event listener since the node is stopped
cleanupEvents?.();
cleanupEvents = undefined;
// return to signup screen
setAppReady(false);
setNeedsSignUp(true);
}
return (
<div class="h-screen w-screen overflow-hidden bg-black">
<Show when={needsSignUp()}>
<SignUpScreen onComplete={handleSignUpComplete} />
</Show>
<Show when={appReady()}>
<MobileNav />
<AppLayout
onSendMessage={handleSendMessage}
onTyping={handleTyping}
onSendDM={handleSendDM}
/>
<OverlayMenu
isOpen={overlayMenuOpen()}
onClose={closeOverlay}
onNavigate={handleOverlayNavigate}
/>
<Modal
isOpen={activeModal() === "create-community"}
onClose={closeModal}
title="create community"
>
<div class="flex flex-col gap-4">
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
name
</label>
<input
type="text"
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
placeholder="my community"
value={newCommunityName()}
onInput={(e) => setNewCommunityName(e.currentTarget.value)}
/>
</div>
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
description
</label>
<input
type="text"
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
placeholder="what's this community about?"
value={newCommunityDesc()}
onInput={(e) => setNewCommunityDesc(e.currentTarget.value)}
/>
</div>
<Button
variant="primary"
fullWidth
onClick={handleCreateCommunity}
disabled={!newCommunityName().trim()}
>
create
</Button>
</div>
</Modal>
<Modal
isOpen={activeModal() === "join-community"}
onClose={closeModal}
title="join community"
>
<div class="flex flex-col gap-4">
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
invite code
</label>
<input
type="text"
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
placeholder="paste your invite code here"
value={joinInviteCode()}
onInput={(e) => setJoinInviteCode(e.currentTarget.value)}
/>
</div>
<Button
variant="primary"
fullWidth
onClick={handleJoinCommunity}
disabled={!joinInviteCode().trim()}
>
join
</Button>
</div>
</Modal>
<Modal
isOpen={activeModal() === "create-channel"}
onClose={closeModal}
title="create channel"
>
<div class="flex flex-col gap-4">
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
name
</label>
<input
type="text"
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
placeholder="channel name"
value={newChannelName()}
onInput={(e) => setNewChannelName(e.currentTarget.value)}
/>
</div>
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
topic (optional)
</label>
<input
type="text"
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
placeholder="what's this channel about?"
value={newChannelTopic()}
onInput={(e) => setNewChannelTopic(e.currentTarget.value)}
/>
</div>
<Button
variant="primary"
fullWidth
onClick={handleCreateChannel}
disabled={!newChannelName().trim()}
>
create
</Button>
</div>
</Modal>
<SettingsModal
isOpen={activeModal() === "settings"}
onClose={closeModal}
onSave={handleSaveSettings}
onResetIdentity={handleResetIdentity}
/>
<UserDirectoryModal
isOpen={activeModal() === "directory"}
onClose={closeModal}
/>
</Show>
</div>
);
};
// populate stores with realistic static data for browser development
function loadDemoData() {
const now = Date.now();
setCurrentIdentity({
peer_id: "12D3KooWDemo1234567890abcdef",
display_name: "user",
public_key: "abcdef1234567890",
bio: "",
created_at: now - 86400000 * 30,
});
setCommunities([
{
id: "com_demo_001",
name: "dusk dev",
description: "development community for dusk",
created_by: "12D3KooWDemo1234567890abcdef",
created_at: now - 86400000 * 7,
},
{
id: "com_demo_002",
name: "rust p2p",
description: "peer-to-peer networking in rust",
created_by: "12D3KooWPeer_alice",
created_at: now - 86400000 * 14,
},
]);
setActiveCommunity("com_demo_001");
setChannels([
{
id: "ch_general_001",
community_id: "com_demo_001",
name: "general",
topic: "general discussion about dusk development",
kind: "Text",
},
{
id: "ch_design_001",
community_id: "com_demo_001",
name: "design",
topic: "UI/UX design discussion",
kind: "Text",
},
{
id: "ch_voice_001",
community_id: "com_demo_001",
name: "voice",
topic: "",
kind: "Voice",
},
]);
setActiveChannel("ch_general_001");
setMessages([
{
id: "msg_001",
channel_id: "ch_general_001",
author_id: "12D3KooWPeer_alice",
author_name: "alice",
content:
"just got the libp2p node running on my machine. peer discovery over mdns works perfectly on LAN.",
timestamp: now - 3600000 * 2,
edited: false,
},
{
id: "msg_002",
channel_id: "ch_general_001",
author_id: "12D3KooWPeer_bob",
author_name: "bob",
content: "nice! how's the gossipsub performance? any message drops?",
timestamp: now - 3600000 * 2 + 60000,
edited: false,
},
{
id: "msg_003",
channel_id: "ch_general_001",
author_id: "12D3KooWPeer_alice",
author_name: "alice",
content:
"zero drops so far with 3 peers on the mesh. the heartbeat interval at 1s keeps things responsive.",
timestamp: now - 3600000 * 2 + 120000,
edited: false,
},
{
id: "msg_004",
channel_id: "ch_general_001",
author_id: "12D3KooWPeer_alice",
author_name: "alice",
content:
"the automerge sync is also working well for catch-up after reconnection",
timestamp: now - 3600000 * 2 + 180000,
edited: false,
},
{
id: "msg_005",
channel_id: "ch_general_001",
author_id: "12D3KooWDemo1234567890abcdef",
author_name: "user",
content:
"this is looking great. the CRDT approach means we never have to worry about message ordering conflicts.",
timestamp: now - 3600000,
edited: false,
},
{
id: "msg_006",
channel_id: "ch_general_001",
author_id: "12D3KooWPeer_charlie",
author_name: "charlie",
content:
"been testing NAT traversal with hole punching. works about 70% of the time which matches the libp2p docs estimates.",
timestamp: now - 1800000,
edited: false,
},
{
id: "msg_007",
channel_id: "ch_general_001",
author_id: "12D3KooWPeer_bob",
author_name: "bob",
content:
"for the remaining 30% we'll need TURN relay fallback. but that's a phase 3 concern.",
timestamp: now - 1800000 + 30000,
edited: false,
},
{
id: "msg_008",
channel_id: "ch_general_001",
author_id: "12D3KooWPeer_charlie",
author_name: "charlie",
content:
"agreed. the three-tier topology design should handle that gracefully when we get there.",
timestamp: now - 1800000 + 60000,
edited: false,
},
]);
setMembers([
{
peer_id: "12D3KooWDemo1234567890abcdef",
display_name: "user",
status: "Online",
roles: ["owner"],
trust_level: 1.0,
joined_at: now - 86400000 * 7,
},
{
peer_id: "12D3KooWPeer_alice",
display_name: "alice",
status: "Online",
roles: ["admin"],
trust_level: 0.95,
joined_at: now - 86400000 * 6,
},
{
peer_id: "12D3KooWPeer_bob",
display_name: "bob",
status: "Idle",
roles: ["member"],
trust_level: 0.8,
joined_at: now - 86400000 * 5,
},
{
peer_id: "12D3KooWPeer_charlie",
display_name: "charlie",
status: "Online",
roles: ["member"],
trust_level: 0.75,
joined_at: now - 86400000 * 3,
},
{
peer_id: "12D3KooWPeer_diana",
display_name: "diana",
status: "Offline",
roles: ["member"],
trust_level: 0.6,
joined_at: now - 86400000 * 2,
},
]);
// seed dm conversations so the home screen has content
setDMConversations([
{
peer_id: "12D3KooWPeer_alice",
display_name: "alice",
status: "Online",
last_message: "the gossipsub refactor is merged, check it out",
last_message_time: now - 600000,
unread_count: 2,
},
{
peer_id: "12D3KooWPeer_bob",
display_name: "bob",
status: "Idle",
last_message: "sure, i'll review the PR tonight",
last_message_time: now - 3600000,
unread_count: 0,
},
{
peer_id: "12D3KooWPeer_charlie",
display_name: "charlie",
status: "Online",
last_message: "NAT traversal test results look promising",
last_message_time: now - 7200000,
unread_count: 1,
},
{
peer_id: "12D3KooWPeer_diana",
display_name: "diana",
status: "Offline",
last_message: "offline, will catch up tomorrow",
last_message_time: now - 86400000,
unread_count: 0,
},
]);
// seed the user directory with known peers
setKnownPeers([
{
peer_id: "12D3KooWPeer_alice",
display_name: "alice",
bio: "distributed systems engineer. libp2p contributor.",
public_key: "alice_pubkey_hex",
last_seen: now - 600000,
is_friend: true,
},
{
peer_id: "12D3KooWPeer_bob",
display_name: "bob",
bio: "rust developer, crdt enthusiast",
public_key: "bob_pubkey_hex",
last_seen: now - 3600000,
is_friend: true,
},
{
peer_id: "12D3KooWPeer_charlie",
display_name: "charlie",
bio: "networking and NAT traversal research",
public_key: "charlie_pubkey_hex",
last_seen: now - 7200000,
is_friend: false,
},
{
peer_id: "12D3KooWPeer_diana",
display_name: "diana",
bio: "",
public_key: "diana_pubkey_hex",
last_seen: now - 86400000,
is_friend: false,
},
{
peer_id: "12D3KooWPeer_eve",
display_name: "eve",
bio: "cryptography researcher, privacy advocate",
public_key: "eve_pubkey_hex",
last_seen: now - 172800000,
is_friend: false,
},
]);
// seed friends list
setFriends([
{
peer_id: "12D3KooWPeer_alice",
display_name: "alice",
bio: "distributed systems engineer. libp2p contributor.",
public_key: "alice_pubkey_hex",
last_seen: now - 600000,
is_friend: true,
},
{
peer_id: "12D3KooWPeer_bob",
display_name: "bob",
bio: "rust developer, crdt enthusiast",
public_key: "bob_pubkey_hex",
last_seen: now - 3600000,
is_friend: true,
},
]);
}
export default App;

View File

@ -0,0 +1,208 @@
import { Component, createSignal, Show } from "solid-js";
import { Key, User, ArrowRight, Shield } from "lucide-solid";
import Button from "../common/Button";
import Avatar from "../common/Avatar";
interface SignUpScreenProps {
onComplete: (displayName: string, bio: string) => void;
}
const SignUpScreen: Component<SignUpScreenProps> = (props) => {
const [displayName, setDisplayName] = createSignal("");
const [bio, setBio] = createSignal("");
const [step, setStep] = createSignal<"welcome" | "profile">("welcome");
const [isCreating, setIsCreating] = createSignal(false);
function handleBegin() {
setStep("profile");
}
async function handleCreate() {
const name = displayName().trim();
if (!name) return;
setIsCreating(true);
props.onComplete(name, bio().trim());
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (step() === "welcome") {
handleBegin();
} else if (displayName().trim()) {
handleCreate();
}
}
}
return (
<div
class="h-screen w-screen bg-black flex items-center justify-center overflow-hidden"
onKeyDown={handleKeyDown}
>
<Show
when={step() === "profile"}
fallback={
// welcome screen
<div class="max-w-[520px] w-full mx-4 animate-fade-in">
<div class="mb-12">
<h1 class="text-[48px] leading-[56px] font-bold text-white tracking-[-0.02em] mb-4">
dusk
</h1>
<p class="text-[20px] leading-[28px] text-white/60">
peer-to-peer communication. no servers, no surveillance, no
compromise.
</p>
</div>
<div class="flex flex-col gap-6 mb-12">
<div class="flex items-start gap-4">
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
<Key size={18} class="text-orange" />
</div>
<div>
<p class="text-[16px] font-medium text-white mb-1">
keypair identity
</p>
<p class="text-[14px] text-white/40">
your identity is a cryptographic keypair generated on your
device. no email, no phone number, no corporate account.
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
<Shield size={18} class="text-orange" />
</div>
<div>
<p class="text-[16px] font-medium text-white mb-1">
your data, your hardware
</p>
<p class="text-[14px] text-white/40">
everything is stored locally and synced directly between
peers. no central server ever touches your messages.
</p>
</div>
</div>
<div class="flex items-start gap-4">
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
<User size={18} class="text-orange" />
</div>
<div>
<p class="text-[16px] font-medium text-white mb-1">
portable identity
</p>
<p class="text-[14px] text-white/40">
take your identity anywhere. your keypair is yours forever
and works across any device running dusk.
</p>
</div>
</div>
</div>
<Button variant="primary" fullWidth onClick={handleBegin}>
<span class="flex items-center gap-2">
get started
<ArrowRight size={16} />
</span>
</Button>
</div>
}
>
{/* profile creation screen */}
<div class="max-w-[480px] w-full mx-4 animate-fade-in">
<h2 class="text-[32px] leading-[40px] font-bold text-white tracking-[-0.02em] mb-2">
create your identity
</h2>
<p class="text-[16px] text-white/40 mb-8">
choose a display name for the network. you can change this later.
</p>
{/* live preview */}
<div class="flex items-center gap-4 p-4 border-2 border-white/10 mb-8">
<Avatar
name={displayName() || "?"}
size="xl"
status="Online"
showStatus
/>
<div class="min-w-0 flex-1">
<p class="text-[20px] font-bold text-white truncate">
{displayName() || "your name"}
</p>
<Show when={bio()}>
<p class="text-[14px] text-white/40 truncate mt-1">{bio()}</p>
</Show>
<p class="text-[12px] font-mono text-white/20 mt-1">
peer id will be generated
</p>
</div>
</div>
<div class="flex flex-col gap-4 mb-8">
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
display name *
</label>
<input
type="text"
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
placeholder="what should people call you?"
value={displayName()}
onInput={(e) => setDisplayName(e.currentTarget.value)}
maxLength={32}
autofocus
/>
<p class="text-[12px] font-mono text-white/20 mt-1">
{displayName().length}/32
</p>
</div>
<div>
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
bio (optional)
</label>
<textarea
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200 resize-none"
placeholder="tell peers a bit about yourself"
value={bio()}
onInput={(e) => setBio(e.currentTarget.value)}
maxLength={160}
rows={3}
/>
<p class="text-[12px] font-mono text-white/20 mt-1">
{bio().length}/160
</p>
</div>
</div>
<Button
variant="primary"
fullWidth
onClick={handleCreate}
disabled={!displayName().trim() || isCreating()}
>
{isCreating() ? "generating keypair..." : "create identity"}
</Button>
<p class="text-[12px] font-mono text-white/20 text-center mt-4">
an ed25519 keypair will be generated and stored locally on your
device
</p>
</div>
</Show>
{/* subtle branding at bottom */}
<div class="fixed bottom-6 left-0 right-0 text-center">
<p class="text-[11px] font-mono text-white/10 uppercase tracking-[0.1em]">
dusk protocol v0.1.0
</p>
</div>
</div>
);
};
export default SignUpScreen;

View File

@ -0,0 +1,130 @@
import type { Component } from "solid-js";
import { Show, createSignal } from "solid-js";
import type { ChatMessage } from "../../lib/types";
import { formatTime, formatTimeShort } from "../../lib/utils";
import { removeMessage } from "../../stores/messages";
import { activeCommunityId } from "../../stores/communities";
import { identity } from "../../stores/identity";
import Avatar from "../common/Avatar";
import * as tauri from "../../lib/tauri";
interface MessageProps {
message: ChatMessage;
isGrouped: boolean;
isFirstInGroup: boolean;
}
const Message: Component<MessageProps> = (props) => {
const [contextMenu, setContextMenu] = createSignal<{ x: number; y: number } | null>(null);
const currentUser = () => identity();
const currentCommunityId = () => activeCommunityId();
const isOwner = () => {
const user = currentUser();
return user?.peer_id === props.message.author_id;
};
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
}
function closeContextMenu() {
setContextMenu(null);
}
async function handleDeleteMessage() {
const communityId = currentCommunityId();