Merge pull request #7 from recurse-chat/better-vc-ui-and-crypto
Better vc UI and added encryption
This commit is contained in:
Generated
+32
-3
@@ -10,6 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
|
"@noble/ciphers": "^2.4.0",
|
||||||
|
"@noble/curves": "^2.4.0",
|
||||||
"@noble/ed25519": "^3.1.0",
|
"@noble/ed25519": "^3.1.0",
|
||||||
"@noble/hashes": "^2.3.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@scure/base": "^2.3.0",
|
"@scure/base": "^2.3.0",
|
||||||
@@ -1306,6 +1308,33 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/ciphers": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/curves": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@noble/ed25519": {
|
"node_modules/@noble/ed25519": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
|
||||||
@@ -1316,9 +1345,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||||
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
|
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.0"
|
"node": ">= 20.19.0"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
|
"@noble/ciphers": "^2.4.0",
|
||||||
|
"@noble/curves": "^2.4.0",
|
||||||
"@noble/ed25519": "^3.1.0",
|
"@noble/ed25519": "^3.1.0",
|
||||||
"@noble/hashes": "^2.3.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@scure/base": "^2.3.0",
|
"@scure/base": "^2.3.0",
|
||||||
|
|||||||
Generated
+273
-10
@@ -8,6 +8,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.2.2",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.5"
|
version = "1.1.5"
|
||||||
@@ -229,6 +239,39 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audio-codec-algorithms"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1254ebf6529f3763c491acfb5ab6e960809e3c75a38584cc664f8c5667fb7107"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audioadapter"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1292ef9edf681b7426ed089004b021a42896492f58bd0c909ad44e5faec9ac4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audioadapter-buffers"
|
||||||
|
version = "5.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46289a81a3bfa26d0f8b2415f8ca9decde9a66308a150568af5c102381b3c8ce"
|
||||||
|
dependencies = [
|
||||||
|
"audioadapter",
|
||||||
|
"audioadapter-sample",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audioadapter-sample"
|
||||||
|
version = "5.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5258faecf4edbe35ec483bda9b1f7438e6b391891819269c6b5461aeaa0e8d7d"
|
||||||
|
dependencies = [
|
||||||
|
"audio-codec-algorithms",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -286,6 +329,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -475,6 +527,29 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20poly1305"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"chacha20",
|
||||||
|
"cipher",
|
||||||
|
"poly1305",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -487,6 +562,23 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.1",
|
||||||
|
"crypto-common 0.2.2",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmov"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -609,6 +701,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -643,6 +744,17 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"hybrid-array",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.36.0"
|
version = "0.36.0"
|
||||||
@@ -682,6 +794,15 @@ version = "0.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctutils"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||||
|
dependencies = [
|
||||||
|
"cmov",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@@ -800,8 +921,8 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -961,9 +1082,10 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
|||||||
name = "enclave"
|
name = "enclave"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chacha20poly1305",
|
||||||
"cpal",
|
"cpal",
|
||||||
"resampler",
|
|
||||||
"ringbuf",
|
"ringbuf",
|
||||||
|
"rubato",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -1359,6 +1481,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1594,6 +1717,15 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -1813,6 +1945,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -2239,6 +2380,15 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-complex"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2256,6 +2406,15 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2756,6 +2915,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "poly1305"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
|
||||||
|
dependencies = [
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -2792,6 +2961,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "primal-check"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -2884,12 +3062,27 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "realfft"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
|
||||||
|
dependencies = [
|
||||||
|
"rustfft",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -2993,12 +3186,6 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "resampler"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "28fdbea87ff02ebbfd904079d1e52138c0c7fbaa3aaddca4a1b9c7d3f85749f2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ringbuf"
|
name = "ringbuf"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3010,6 +3197,22 @@ dependencies = [
|
|||||||
"portable-atomic-util",
|
"portable-atomic-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rubato"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7cb1ffaf8738df50aab642a7f6465df81c6ba9e2818268053487165298114be"
|
||||||
|
dependencies = [
|
||||||
|
"audioadapter",
|
||||||
|
"audioadapter-buffers",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"realfft",
|
||||||
|
"visibility",
|
||||||
|
"windowfunctions",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -3025,6 +3228,20 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustfft"
|
||||||
|
version = "6.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||||
|
dependencies = [
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"primal-check",
|
||||||
|
"strength_reduce",
|
||||||
|
"transpose",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -3305,7 +3522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3429,6 +3646,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strength_reduce"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache"
|
name = "string_cache"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -4183,6 +4406,16 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "transpose"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"strength_reduce",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tray-icon"
|
name = "tray-icon"
|
||||||
version = "0.24.2"
|
version = "0.24.2"
|
||||||
@@ -4287,6 +4520,16 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common 0.2.2",
|
||||||
|
"ctutils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4342,6 +4585,17 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "visibility"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vswhom"
|
name = "vswhom"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4612,6 +4866,15 @@ dependencies = [
|
|||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windowfunctions"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ tauri-plugin-opener = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
cpal = "0.18.2"
|
cpal = "0.18.2"
|
||||||
resampler = "0.5.1"
|
|
||||||
ringbuf = "0.5.1"
|
ringbuf = "0.5.1"
|
||||||
|
rubato = "5.0.0"
|
||||||
|
chacha20poly1305 = "0.11.0"
|
||||||
|
|
||||||
|
|||||||
+208
-144
@@ -9,6 +9,7 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use chacha20poly1305::{aead::KeyInit, ChaCha20Poly1305, Key};
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
use ringbuf::{
|
use ringbuf::{
|
||||||
storage::Heap,
|
storage::Heap,
|
||||||
@@ -17,22 +18,17 @@ use ringbuf::{
|
|||||||
};
|
};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::commands::config::ConfigState;
|
use crate::{commands::config::ConfigState, crypto::SessionCipher};
|
||||||
|
|
||||||
const PACKET_SAMPLES: usize = 960; // 20ms @ 48kHz
|
const TARGET_SAMPLE_RATE: u32 = 48_000;
|
||||||
const HEADER_SIZE: usize = 4;
|
const PACKET_SAMPLES: usize = 960;
|
||||||
const MAX_PACKET_SIZE: usize = 4096;
|
const MAX_PACKET_SIZE: usize = 4096;
|
||||||
|
|
||||||
const INITIAL_PACKET_CUSHION: usize = 3; // ~60ms cushion
|
const INITIAL_PACKET_CUSHION: usize = 3;
|
||||||
|
|
||||||
// --- Voice Activity Detection (VAD) Settings ---
|
|
||||||
const VAD_THRESHOLD: f32 = 0.01;
|
const VAD_THRESHOLD: f32 = 0.01;
|
||||||
const VAD_HANGOVER_FRAMES: usize = 10;
|
const VAD_HANGOVER_FRAMES: usize = 10;
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// STATE & TYPES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type AudioProducer = Arc<Mutex<CachingProd<Arc<SharedRb<Heap<f32>>>>>>;
|
type AudioProducer = Arc<Mutex<CachingProd<Arc<SharedRb<Heap<f32>>>>>>;
|
||||||
type AudioConsumer = Arc<Mutex<CachingCons<Arc<SharedRb<Heap<f32>>>>>>;
|
type AudioConsumer = Arc<Mutex<CachingCons<Arc<SharedRb<Heap<f32>>>>>>;
|
||||||
|
|
||||||
@@ -60,6 +56,39 @@ pub struct VoiceSession {
|
|||||||
pub consumer_out: AudioConsumer,
|
pub consumer_out: AudioConsumer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple Linear Resampler for real-time audio conversion
|
||||||
|
struct LinearResampler {
|
||||||
|
phase: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinearResampler {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { phase: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resamples dynamic buffers from `src_rate` to `dst_rate`
|
||||||
|
fn process(&mut self, input: &[f32], src_rate: u32, dst_rate: u32, output: &mut Vec<f32>) {
|
||||||
|
if src_rate == dst_rate {
|
||||||
|
output.extend_from_slice(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratio = src_rate as f64 / dst_rate as f64;
|
||||||
|
while self.phase < input.len() as f64 {
|
||||||
|
let idx = self.phase as usize;
|
||||||
|
let frac = (self.phase - idx as f64) as f32;
|
||||||
|
let next_idx = (idx + 1).min(input.len() - 1);
|
||||||
|
|
||||||
|
let sample = input[idx] * (1.0 - frac) + input[next_idx] * frac;
|
||||||
|
output.push(sample);
|
||||||
|
|
||||||
|
self.phase += ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.phase -= input.len() as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl VoiceSession {
|
impl VoiceSession {
|
||||||
pub fn update_input_device(
|
pub fn update_input_device(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -89,32 +118,7 @@ impl VoiceSession {
|
|||||||
.config();
|
.config();
|
||||||
|
|
||||||
let producer = Arc::clone(&self.producer_in);
|
let producer = Arc::clone(&self.producer_in);
|
||||||
let inner_clone = Arc::clone(&state_inner);
|
let new_stream = build_input_stream(&device, input_config, producer, state_inner)?;
|
||||||
|
|
||||||
let new_stream = device
|
|
||||||
.build_input_stream(
|
|
||||||
input_config,
|
|
||||||
move |data: &[f32], _| {
|
|
||||||
if let Ok(mut prod) = producer.lock() {
|
|
||||||
let _ = prod.push_slice(data);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
move |err| {
|
|
||||||
eprintln!("[vc] Input error: {err}. Attempting input stream recovery...");
|
|
||||||
if let Ok(mut lock) = inner_clone.session.lock() {
|
|
||||||
if let Some(session) = lock.as_mut() {
|
|
||||||
let target_device = session.current_input_device.clone();
|
|
||||||
if let Err(e) =
|
|
||||||
session.update_input_device(target_device, Arc::clone(&inner_clone))
|
|
||||||
{
|
|
||||||
eprintln!("[vc] Input recovery failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
new_stream
|
new_stream
|
||||||
.play()
|
.play()
|
||||||
@@ -167,9 +171,136 @@ impl VoiceSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// Helper to build normalized Input Stream (Resampled & Downmixed to 48kHz Mono)
|
||||||
// DEVICES
|
fn build_input_stream(
|
||||||
// ============================================================================
|
device: &cpal::Device,
|
||||||
|
config: cpal::StreamConfig,
|
||||||
|
producer: AudioProducer,
|
||||||
|
state_inner: Arc<VoiceStateInner>,
|
||||||
|
) -> Result<cpal::Stream, String> {
|
||||||
|
let native_sample_rate = config.sample_rate;
|
||||||
|
let channels = config.channels as usize;
|
||||||
|
let mut resampler = LinearResampler::new();
|
||||||
|
let mut mono_buffer = Vec::with_capacity(2048);
|
||||||
|
let mut resampled_buffer = Vec::with_capacity(2048);
|
||||||
|
|
||||||
|
let inner_input_err = Arc::clone(&state_inner);
|
||||||
|
|
||||||
|
device
|
||||||
|
.build_input_stream(
|
||||||
|
config,
|
||||||
|
move |data: &[f32], _| {
|
||||||
|
mono_buffer.clear();
|
||||||
|
resampled_buffer.clear();
|
||||||
|
|
||||||
|
// Downmix channels to mono
|
||||||
|
for chunk in data.chunks_exact(channels) {
|
||||||
|
let sum: f32 = chunk.iter().sum();
|
||||||
|
mono_buffer.push(sum / channels as f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resample to 48kHz standard target
|
||||||
|
resampler.process(
|
||||||
|
&mono_buffer,
|
||||||
|
native_sample_rate,
|
||||||
|
TARGET_SAMPLE_RATE,
|
||||||
|
&mut resampled_buffer,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Ok(mut prod) = producer.lock() {
|
||||||
|
let _ = prod.push_slice(&resampled_buffer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
eprintln!("[vc] Input error: {err}. Attempting recovery...");
|
||||||
|
if let Ok(mut lock) = inner_input_err.session.lock() {
|
||||||
|
if let Some(session) = lock.as_mut() {
|
||||||
|
let target_device = session.current_input_device.clone();
|
||||||
|
let _ = session
|
||||||
|
.update_input_device(target_device, Arc::clone(&inner_input_err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to build normalized Output Stream (48kHz Mono -> Device Native Channels & Rate)
|
||||||
|
fn build_output_stream(
|
||||||
|
device: &cpal::Device,
|
||||||
|
config: cpal::StreamConfig,
|
||||||
|
consumer: AudioConsumer,
|
||||||
|
state_inner: Arc<VoiceStateInner>,
|
||||||
|
) -> Result<cpal::Stream, String> {
|
||||||
|
let native_sample_rate = config.sample_rate;
|
||||||
|
let channels = config.channels as usize;
|
||||||
|
let mut resampler = LinearResampler::new();
|
||||||
|
let mut raw_mono_samples = Vec::with_capacity(2048);
|
||||||
|
let mut resampled_mono = Vec::with_capacity(2048);
|
||||||
|
let mut last_sample = 0.0f32;
|
||||||
|
|
||||||
|
let inner_output_err = Arc::clone(&state_inner);
|
||||||
|
|
||||||
|
device
|
||||||
|
.build_output_stream(
|
||||||
|
config,
|
||||||
|
move |data: &mut [f32], _| {
|
||||||
|
let required_mono_samples = (data.len() / channels) * TARGET_SAMPLE_RATE as usize
|
||||||
|
/ native_sample_rate as usize;
|
||||||
|
|
||||||
|
raw_mono_samples.clear();
|
||||||
|
resampled_mono.clear();
|
||||||
|
|
||||||
|
if let Ok(mut cons) = consumer.lock() {
|
||||||
|
for _ in 0..required_mono_samples {
|
||||||
|
if let Some(s) = cons.try_pop() {
|
||||||
|
last_sample = s;
|
||||||
|
raw_mono_samples.push(s);
|
||||||
|
} else {
|
||||||
|
// Exponential decay to prevent clicking when underflowing
|
||||||
|
last_sample *= 0.92;
|
||||||
|
raw_mono_samples.push(last_sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resample from 48kHz mono to target native output rate
|
||||||
|
resampler.process(
|
||||||
|
&raw_mono_samples,
|
||||||
|
TARGET_SAMPLE_RATE,
|
||||||
|
native_sample_rate,
|
||||||
|
&mut resampled_mono,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Interleave mono into hardware channels
|
||||||
|
let mut res_idx = 0;
|
||||||
|
let mut out_idx = 0;
|
||||||
|
while out_idx < data.len() && res_idx < resampled_mono.len() {
|
||||||
|
let mono_val = resampled_mono[res_idx];
|
||||||
|
for ch in 0..channels {
|
||||||
|
if out_idx + ch < data.len() {
|
||||||
|
data[out_idx + ch] = mono_val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out_idx += channels;
|
||||||
|
res_idx += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
move |err| {
|
||||||
|
eprintln!("[vc] Output error: {err}. Attempting recovery...");
|
||||||
|
if let Ok(mut lock) = inner_output_err.session.lock() {
|
||||||
|
if let Some(session) = lock.as_mut() {
|
||||||
|
let target_device = session.current_output_device.clone();
|
||||||
|
let _ = session
|
||||||
|
.update_output_device(target_device, Arc::clone(&inner_output_err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_input_devices() -> Result<Vec<String>, String> {
|
pub fn list_input_devices() -> Result<Vec<String>, String> {
|
||||||
@@ -191,10 +322,6 @@ pub fn list_output_devices() -> Result<Vec<String>, String> {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// DISCONNECT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), String> {
|
pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), String> {
|
||||||
let mut lock = voice_state
|
let mut lock = voice_state
|
||||||
@@ -205,7 +332,6 @@ pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), Stri
|
|||||||
|
|
||||||
if let Some(session) = lock.take() {
|
if let Some(session) = lock.take() {
|
||||||
session.shutdown.store(true, Ordering::SeqCst);
|
session.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
let _ = session.input_stream.pause();
|
let _ = session.input_stream.pause();
|
||||||
if let Ok(output) = session.output_stream.lock() {
|
if let Ok(output) = session.output_stream.lock() {
|
||||||
let _ = output.pause();
|
let _ = output.pause();
|
||||||
@@ -215,19 +341,19 @@ pub fn disconnect_from_vc(voice_state: State<'_, VoiceState>) -> Result<(), Stri
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CONNECT
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn connect_to_vc(
|
pub fn connect_to_vc(
|
||||||
hostname: String,
|
hostname: String,
|
||||||
pin: u64,
|
pin: u64,
|
||||||
|
shared_secret: Vec<u8>, // output of js `x25519.getSharedSecret`
|
||||||
config_state: State<'_, ConfigState>,
|
config_state: State<'_, ConfigState>,
|
||||||
voice_state: State<'_, VoiceState>,
|
voice_state: State<'_, VoiceState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
disconnect_from_vc(voice_state.clone())?;
|
disconnect_from_vc(voice_state.clone())?;
|
||||||
|
|
||||||
|
let key = Key::try_from(shared_secret.as_slice()).map_err(|v| v.to_string())?;
|
||||||
|
let cipher = Arc::new(Mutex::new(SessionCipher::new(ChaCha20Poly1305::new(&key))));
|
||||||
|
|
||||||
let config = config_state.0.lock().unwrap().clone();
|
let config = config_state.0.lock().unwrap().clone();
|
||||||
let state_inner = Arc::clone(&voice_state.inner);
|
let state_inner = Arc::clone(&voice_state.inner);
|
||||||
|
|
||||||
@@ -280,7 +406,7 @@ pub fn connect_to_vc(
|
|||||||
|
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
// Output Ring Buffer setup
|
// Ring Buffer Setup
|
||||||
let rb_out = HeapRb::<f32>::new(19200);
|
let rb_out = HeapRb::<f32>::new(19200);
|
||||||
let (producer_out, consumer_out) = rb_out.split();
|
let (producer_out, consumer_out) = rb_out.split();
|
||||||
let mut producer_out = producer_out;
|
let mut producer_out = producer_out;
|
||||||
@@ -302,7 +428,6 @@ pub fn connect_to_vc(
|
|||||||
.map_err(|e| format!("Failed to start output: {e}"))?;
|
.map_err(|e| format!("Failed to start output: {e}"))?;
|
||||||
let output_stream = Arc::new(Mutex::new(output_stream));
|
let output_stream = Arc::new(Mutex::new(output_stream));
|
||||||
|
|
||||||
// Input Ring Buffer setup
|
|
||||||
let rb_in = HeapRb::<f32>::new(19200);
|
let rb_in = HeapRb::<f32>::new(19200);
|
||||||
let (producer_in, mut consumer_in) = rb_in.split();
|
let (producer_in, mut consumer_in) = rb_in.split();
|
||||||
let shared_producer_in = Arc::new(Mutex::new(producer_in));
|
let shared_producer_in = Arc::new(Mutex::new(producer_in));
|
||||||
@@ -312,42 +437,21 @@ pub fn connect_to_vc(
|
|||||||
.map_err(|e| format!("Failed to get default input config: {e}"))?
|
.map_err(|e| format!("Failed to get default input config: {e}"))?
|
||||||
.config();
|
.config();
|
||||||
|
|
||||||
let cb_producer = Arc::clone(&shared_producer_in);
|
let input_stream = build_input_stream(
|
||||||
let inner_input_err = Arc::clone(&state_inner);
|
&input_device,
|
||||||
|
|
||||||
let input_stream = input_device
|
|
||||||
.build_input_stream(
|
|
||||||
input_config,
|
input_config,
|
||||||
move |data: &[f32], _| {
|
Arc::clone(&shared_producer_in),
|
||||||
if let Ok(mut prod) = cb_producer.lock() {
|
Arc::clone(&state_inner),
|
||||||
let _ = prod.push_slice(data);
|
)?;
|
||||||
}
|
|
||||||
},
|
|
||||||
move |err| {
|
|
||||||
eprintln!("[vc] Input error: {err}. Attempting input stream recovery...");
|
|
||||||
if let Ok(mut lock) = inner_input_err.session.lock() {
|
|
||||||
if let Some(session) = lock.as_mut() {
|
|
||||||
let target_device = session.current_input_device.clone();
|
|
||||||
if let Err(e) =
|
|
||||||
session.update_input_device(target_device, Arc::clone(&inner_input_err))
|
|
||||||
{
|
|
||||||
eprintln!("[vc] Input recovery failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
// UDP Sender Thread
|
// Sender Thread
|
||||||
{
|
{
|
||||||
let input_socket = socket.clone();
|
let input_socket = socket.clone();
|
||||||
let shutdown = shutdown.clone();
|
let shutdown = shutdown.clone();
|
||||||
|
let cipher = cipher.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut sequence = 0u32;
|
let mut sequence = 0u32;
|
||||||
let mut net_packet = vec![0u8; HEADER_SIZE + PACKET_SAMPLES * 2];
|
|
||||||
let mut frame_buf = vec![0.0f32; PACKET_SAMPLES];
|
let mut frame_buf = vec![0.0f32; PACKET_SAMPLES];
|
||||||
let mut hangover_counter = 0;
|
let mut hangover_counter = 0;
|
||||||
|
|
||||||
@@ -369,13 +473,21 @@ pub fn connect_to_vc(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if is_speaking {
|
if is_speaking {
|
||||||
net_packet[0..4].copy_from_slice(&sequence.to_be_bytes());
|
// sequence goes INSIDE the plaintext now, prefixed before the PCM
|
||||||
for (i, sample) in frame_buf.iter().enumerate() {
|
let mut plaintext = Vec::with_capacity(4 + PACKET_SAMPLES * 2);
|
||||||
|
plaintext.extend_from_slice(&sequence.to_be_bytes());
|
||||||
|
|
||||||
|
for sample in frame_buf.iter() {
|
||||||
let pcm = (sample.clamp(-1.0, 1.0) * 32767.0) as i16;
|
let pcm = (sample.clamp(-1.0, 1.0) * 32767.0) as i16;
|
||||||
let offset = HEADER_SIZE + i * 2;
|
plaintext.extend_from_slice(&pcm.to_be_bytes());
|
||||||
net_packet[offset..offset + 2].copy_from_slice(&pcm.to_be_bytes());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let Ok(net_packet) = cipher.lock().unwrap().encrypt(&plaintext) else {
|
||||||
|
eprintln!("[vc] failed to encrypt outgoing packet");
|
||||||
|
sequence = sequence.wrapping_add(1);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let _ = input_socket.send(&net_packet);
|
let _ = input_socket.send(&net_packet);
|
||||||
sequence = sequence.wrapping_add(1);
|
sequence = sequence.wrapping_add(1);
|
||||||
}
|
}
|
||||||
@@ -386,10 +498,11 @@ pub fn connect_to_vc(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// UDP Receiver Thread
|
// Receiver Thread
|
||||||
{
|
{
|
||||||
let socket = socket.clone();
|
let socket = socket.clone();
|
||||||
let shutdown = shutdown.clone();
|
let shutdown = shutdown.clone();
|
||||||
|
let cipher = cipher.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut packets: BTreeMap<u32, Vec<f32>> = BTreeMap::new();
|
let mut packets: BTreeMap<u32, Vec<f32>> = BTreeMap::new();
|
||||||
@@ -401,9 +514,14 @@ pub fn connect_to_vc(
|
|||||||
|
|
||||||
while !shutdown.load(Ordering::Relaxed) {
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
if let Ok(len) = socket.recv(&mut udp_buffer) {
|
if let Ok(len) = socket.recv(&mut udp_buffer) {
|
||||||
if len > HEADER_SIZE {
|
match cipher.lock().unwrap().decrypt(&udp_buffer[..len]) {
|
||||||
let seq = u32::from_be_bytes(udp_buffer[0..4].try_into().unwrap());
|
Ok(plaintext) => {
|
||||||
let pcm = &udp_buffer[HEADER_SIZE..len];
|
if plaintext.len() < 4 {
|
||||||
|
eprintln!("[vc] dropped packet: too short after decrypt");
|
||||||
|
} else {
|
||||||
|
let seq = u32::from_be_bytes(plaintext[..4].try_into().unwrap());
|
||||||
|
let pcm = &plaintext[4..];
|
||||||
|
|
||||||
let samples: Vec<f32> = pcm
|
let samples: Vec<f32> = pcm
|
||||||
.chunks_exact(2)
|
.chunks_exact(2)
|
||||||
.map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0)
|
.map(|c| i16::from_be_bytes([c[0], c[1]]) as f32 / 32768.0)
|
||||||
@@ -412,6 +530,11 @@ pub fn connect_to_vc(
|
|||||||
packets.insert(seq, samples);
|
packets.insert(seq, samples);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("[vc] dropped packet: decryption failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if is_prebuffering {
|
if is_prebuffering {
|
||||||
if packets.len() >= INITIAL_PACKET_CUSHION {
|
if packets.len() >= INITIAL_PACKET_CUSHION {
|
||||||
@@ -477,65 +600,6 @@ pub fn connect_to_vc(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// AUDIO CALLBACK
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
fn build_output_stream(
|
|
||||||
device: &cpal::Device,
|
|
||||||
config: cpal::StreamConfig,
|
|
||||||
consumer: AudioConsumer,
|
|
||||||
state_inner: Arc<VoiceStateInner>,
|
|
||||||
) -> Result<cpal::Stream, String> {
|
|
||||||
let channels = config.channels as usize;
|
|
||||||
let mut last_sample = 0.0f32;
|
|
||||||
let inner_output_err = Arc::clone(&state_inner);
|
|
||||||
|
|
||||||
device
|
|
||||||
.build_output_stream(
|
|
||||||
config,
|
|
||||||
move |data: &mut [f32], _| {
|
|
||||||
let mut idx = 0;
|
|
||||||
let mut cons_guard = consumer.lock().ok();
|
|
||||||
|
|
||||||
while idx < data.len() {
|
|
||||||
let sample = cons_guard.as_mut().and_then(|c| c.try_pop());
|
|
||||||
if let Some(mono_sample) = sample {
|
|
||||||
last_sample = mono_sample;
|
|
||||||
for ch in 0..channels {
|
|
||||||
data[idx + ch] = mono_sample;
|
|
||||||
}
|
|
||||||
idx += channels;
|
|
||||||
} else {
|
|
||||||
while idx < data.len() {
|
|
||||||
last_sample *= 0.92;
|
|
||||||
for ch in 0..channels {
|
|
||||||
data[idx + ch] = last_sample;
|
|
||||||
}
|
|
||||||
idx += channels;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
move |err| {
|
|
||||||
eprintln!("[vc] Output error: {err}. Attempting output stream recovery...");
|
|
||||||
if let Ok(mut lock) = inner_output_err.session.lock() {
|
|
||||||
if let Some(session) = lock.as_mut() {
|
|
||||||
let target_device = session.current_output_device.clone();
|
|
||||||
if let Err(e) = session
|
|
||||||
.update_output_device(target_device, Arc::clone(&inner_output_err))
|
|
||||||
{
|
|
||||||
eprintln!("[vc] Output recovery failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for VoiceSession {
|
impl Drop for VoiceSession {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.shutdown.store(true, Ordering::SeqCst);
|
self.shutdown.store(true, Ordering::SeqCst);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use std::fs;
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
use crate::commands::audio::VoiceState;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -42,8 +44,33 @@ fn config_file_path(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_config(state: State<ConfigState>, config: Config) -> Result<(), String> {
|
pub fn update_config(
|
||||||
*state.0.lock().unwrap() = config;
|
state: State<ConfigState>,
|
||||||
|
voice_state: State<'_, VoiceState>,
|
||||||
|
mut config: Config,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut state_lock = state.0.lock().unwrap();
|
||||||
|
|
||||||
|
std::mem::swap(&mut *state_lock, &mut config);
|
||||||
|
|
||||||
|
let Some(sesh) = &mut *voice_state.inner.session.lock().unwrap() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if state_lock.input_device_name != config.input_device_name {
|
||||||
|
sesh.update_input_device(
|
||||||
|
state_lock.input_device_name.clone(),
|
||||||
|
voice_state.inner.clone(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if state_lock.output_device_name != config.output_device_name {
|
||||||
|
sesh.update_output_device(
|
||||||
|
state_lock.output_device_name.clone(),
|
||||||
|
voice_state.inner.clone(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use chacha20poly1305::{aead::Aead, ChaCha20Poly1305, Nonce};
|
||||||
|
|
||||||
|
pub struct SessionCipher {
|
||||||
|
cipher: ChaCha20Poly1305,
|
||||||
|
send_counter: u64,
|
||||||
|
recv_counter: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionCipher {
|
||||||
|
pub fn new(cipher: ChaCha20Poly1305) -> Self {
|
||||||
|
Self {
|
||||||
|
cipher,
|
||||||
|
send_counter: 0,
|
||||||
|
recv_counter: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_send_nonce(&mut self) -> [u8; 12] {
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
nonce[..8].copy_from_slice(&self.send_counter.to_be_bytes());
|
||||||
|
// top bit distinguishes "send" direction from "recv" direction,
|
||||||
|
// so client-send and server-send counters never collide even if
|
||||||
|
// both happened to reach the same numeric value
|
||||||
|
nonce[11] |= 0b1000_0000;
|
||||||
|
self.send_counter += 1;
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_recv_nonce(&mut self) -> [u8; 12] {
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
nonce[..8].copy_from_slice(&self.recv_counter.to_be_bytes());
|
||||||
|
self.recv_counter += 1;
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
let nonce_bytes = self.next_send_nonce();
|
||||||
|
let nonce = Nonce::try_from(nonce_bytes).map_err(|v| v.to_string())?;
|
||||||
|
|
||||||
|
let ciphertext = self
|
||||||
|
.cipher
|
||||||
|
.encrypt(&nonce, plaintext)
|
||||||
|
.map_err(|v| v.to_string())?;
|
||||||
|
|
||||||
|
// prepend the nonce so the other side can reconstruct it on decrypt
|
||||||
|
let mut out = nonce_bytes.to_vec();
|
||||||
|
out.extend(ciphertext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(&mut self, data: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
if data.len() < 12 {
|
||||||
|
return Err("message too short to contain a nonce".to_string());
|
||||||
|
}
|
||||||
|
let (nonce_bytes, ciphertext) = data.split_at(12);
|
||||||
|
let nonce = Nonce::try_from(nonce_bytes).map_err(|v| v.to_string())?;
|
||||||
|
|
||||||
|
self.cipher
|
||||||
|
.decrypt(&nonce, ciphertext)
|
||||||
|
.map_err(|v| v.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use std::sync::Mutex;
|
|||||||
use crate::commands::config::ConfigState;
|
use crate::commands::config::ConfigState;
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod crypto;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
|||||||
+7
-1
@@ -11,6 +11,7 @@ import Sidebar from "./components/view/Sidebar";
|
|||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import Page from "./components/page/PageView";
|
import Page from "./components/page/PageView";
|
||||||
import { SettingsDialog } from "./components/settings/SettingsDialog";
|
import { SettingsDialog } from "./components/settings/SettingsDialog";
|
||||||
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const appRef = useRef<Enclave | null>(null);
|
const appRef = useRef<Enclave | null>(null);
|
||||||
@@ -31,13 +32,17 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system">
|
<ThemeProvider attribute="class" defaultTheme="system">
|
||||||
|
<TooltipProvider>
|
||||||
<main className="size-screen flex select-none cursor-default">
|
<main className="size-screen flex select-none cursor-default">
|
||||||
{appRef.current?.accounts &&
|
{appRef.current?.accounts &&
|
||||||
appRef.current.accounts.accounts.length === 0 ? (
|
appRef.current.accounts.accounts.length === 0 ? (
|
||||||
<NewProfilePage appRef={appRef} />
|
<NewProfilePage appRef={appRef} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ResizablePanelGroup orientation="horizontal" className="h-screen">
|
<ResizablePanelGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="h-screen"
|
||||||
|
>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
defaultSize="600px"
|
defaultSize="600px"
|
||||||
maxSize="400px"
|
maxSize="400px"
|
||||||
@@ -60,6 +65,7 @@ export default function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-2
@@ -7,7 +7,10 @@ import { base58 } from "@scure/base";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Account, AccountsFile, getAccounts } from "@/lib/accounts";
|
import { Account, AccountsFile, getAccounts } from "@/lib/accounts";
|
||||||
import { Page } from "@/components/page/PageView";
|
import { Page } from "@/components/page/PageView";
|
||||||
|
import { Channel } from "@/lib/types";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { ClientMethod } from "./protocol";
|
import { ClientMethod } from "./protocol";
|
||||||
|
import { Config, getConfig } from "@/lib/config";
|
||||||
|
|
||||||
ed.hashes.sha512 = sha512;
|
ed.hashes.sha512 = sha512;
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ export default class Enclave<P = Page> {
|
|||||||
public isSettingsOpen: boolean;
|
public isSettingsOpen: boolean;
|
||||||
public forceRender: () => void;
|
public forceRender: () => void;
|
||||||
public page?: P;
|
public page?: P;
|
||||||
|
public config?: Config;
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
this.serverList = {};
|
this.serverList = {};
|
||||||
@@ -41,6 +45,7 @@ export default class Enclave<P = Page> {
|
|||||||
public async init() {
|
public async init() {
|
||||||
this.serverList = await getServerList();
|
this.serverList = await getServerList();
|
||||||
this.accounts = await getAccounts();
|
this.accounts = await getAccounts();
|
||||||
|
this.config = await getConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAccount(): Account | null {
|
public getAccount(): Account | null {
|
||||||
@@ -129,8 +134,19 @@ export default class Enclave<P = Page> {
|
|||||||
|
|
||||||
if (this.server.websocket) {
|
if (this.server.websocket) {
|
||||||
this.server.websocket.send({ method: "Meta", ...account.meta });
|
this.server.websocket.send({ method: "Meta", ...account.meta });
|
||||||
this.server.websocket.websocket.onmessage = (msg) => {
|
this.server.websocket.websocket.onmessage = async (msg) => {
|
||||||
this.onMessage(JSON.parse(msg.data));
|
let buffer: ArrayBuffer;
|
||||||
|
if (msg.data instanceof Blob) {
|
||||||
|
buffer = await msg.data.arrayBuffer();
|
||||||
|
} else {
|
||||||
|
buffer = msg.data as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = new Uint8Array(buffer);
|
||||||
|
const plaintext = this.server?.websocket?.decrypt(encrypted);
|
||||||
|
const data = JSON.parse(new TextDecoder().decode(plaintext));
|
||||||
|
|
||||||
|
this.onMessage(data);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +165,47 @@ export default class Enclave<P = Page> {
|
|||||||
this.forceRender();
|
this.forceRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public joinVoice(channel: Channel) {
|
||||||
|
const server = this.server;
|
||||||
|
|
||||||
|
if (!server || channel.kind !== "voice") return;
|
||||||
|
|
||||||
|
if (server.voiceChannelId) this.leaveVoice();
|
||||||
|
|
||||||
|
server.voiceJoin = (pin, channelId) => {
|
||||||
|
invoke("connect_to_vc", {
|
||||||
|
hostname: server.hostname,
|
||||||
|
pin,
|
||||||
|
sharedSecret: server.websocket?.sharedSecret,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
server.voiceChannelId = channelId;
|
||||||
|
this.forceRender();
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
server.voiceChannelId = channel.id;
|
||||||
|
|
||||||
|
server.websocket?.send({ method: "JoinVoice", channel_id: channel.id });
|
||||||
|
|
||||||
|
this.forceRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
public leaveVoice() {
|
||||||
|
const server = this.server;
|
||||||
|
|
||||||
|
if (!server || !server.voiceChannelId) return;
|
||||||
|
|
||||||
|
server.websocket?.send({ method: "LeaveVoice" });
|
||||||
|
invoke("disconnect_from_vc").catch(console.error);
|
||||||
|
|
||||||
|
server.voiceChannelId = undefined;
|
||||||
|
server.voiceChatSpeakers = {};
|
||||||
|
|
||||||
|
this.forceRender();
|
||||||
|
}
|
||||||
|
|
||||||
public async onMessage(msg: ClientMethod) {
|
public async onMessage(msg: ClientMethod) {
|
||||||
const server = this.server;
|
const server = this.server;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import EnclaveWebSocket from "./ws";
|
|||||||
import { sha512 } from "@noble/hashes/sha2.js";
|
import { sha512 } from "@noble/hashes/sha2.js";
|
||||||
import { getWSUrl } from "@/lib/serverList";
|
import { getWSUrl } from "@/lib/serverList";
|
||||||
import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types";
|
import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types";
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519.js";
|
||||||
|
|
||||||
ed.hashes.sha512 = sha512;
|
ed.hashes.sha512 = sha512;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export default class EnclaveServer {
|
|||||||
public voiceJoin?: (pin: bigint, channelId: string) => void;
|
public voiceJoin?: (pin: bigint, channelId: string) => void;
|
||||||
public voiceChatUsers: Record<string, string[]>;
|
public voiceChatUsers: Record<string, string[]>;
|
||||||
public voiceChatSpeakers: Record<string, NodeJS.Timeout>;
|
public voiceChatSpeakers: Record<string, NodeJS.Timeout>;
|
||||||
|
public voiceChannelId?: string;
|
||||||
|
|
||||||
public constructor(hostname: string, isSecure: boolean) {
|
public constructor(hostname: string, isSecure: boolean) {
|
||||||
this.hostname = hostname;
|
this.hostname = hostname;
|
||||||
@@ -57,6 +59,7 @@ export default class EnclaveServer {
|
|||||||
) {
|
) {
|
||||||
this.websocket = new EnclaveWebSocket(
|
this.websocket = new EnclaveWebSocket(
|
||||||
getWSUrl(this.hostname, this.isSecure),
|
getWSUrl(this.hostname, this.isSecure),
|
||||||
|
ed25519.utils.toMontgomerySecret(clientSecretKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
const publicKeyString = base58.encode(clientPublicKey);
|
const publicKeyString = base58.encode(clientPublicKey);
|
||||||
|
|||||||
+97
-7
@@ -1,46 +1,136 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { x25519 } from "@noble/curves/ed25519.js";
|
||||||
|
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||||
import { ClientMethod, ServerMethod } from "./protocol";
|
import { ClientMethod, ServerMethod } from "./protocol";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A protocol-aware wrapper around the browser `WebSocket`.
|
* A protocol-aware wrapper around the browser `WebSocket`.
|
||||||
*
|
*
|
||||||
* Exposes typed protocol methods (`ServerMethod` / `ClientMethod`) rather
|
* Handles the x25519 key exchange handshake and ChaCha20-Poly1305
|
||||||
* than raw WebSocket messages. Has no concept of channels, messages, or
|
* encryption/decryption of every message after it. Exposes typed protocol
|
||||||
* app state — `EnclaveServer` builds on top of this to add those.
|
* methods (`ServerMethod` / `ClientMethod`) rather than raw WebSocket
|
||||||
|
* messages. Has no concept of channels, messages, or app state —
|
||||||
|
* `EnclaveServer` builds on top of this to add those.
|
||||||
*/
|
*/
|
||||||
export default class EnclaveWebSocket {
|
export default class EnclaveWebSocket {
|
||||||
public websocket: WebSocket;
|
public websocket: WebSocket;
|
||||||
onOpenQueue: Array<() => void>;
|
onOpenQueue: Array<() => void>;
|
||||||
|
|
||||||
public constructor(hostname: string) {
|
private sendCounter = 0n;
|
||||||
|
public sharedSecret: Uint8Array | null = null;
|
||||||
|
|
||||||
|
private readonly handshakeReady: Promise<void>;
|
||||||
|
private resolveHandshake!: () => void;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
hostname: string,
|
||||||
|
private readonly myX25519PrivateKey: Uint8Array,
|
||||||
|
) {
|
||||||
this.onOpenQueue = new Array();
|
this.onOpenQueue = new Array();
|
||||||
|
|
||||||
|
this.handshakeReady = new Promise((resolve) => {
|
||||||
|
this.resolveHandshake = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
this.websocket = new WebSocket(hostname);
|
this.websocket = new WebSocket(hostname);
|
||||||
|
this.websocket.binaryType = "arraybuffer";
|
||||||
|
|
||||||
this.websocket.onclose = () => {
|
this.websocket.onclose = () => {
|
||||||
invoke("disconnect_from_vc");
|
invoke("disconnect_from_vc");
|
||||||
};
|
};
|
||||||
|
|
||||||
this.websocket.onopen = () => {
|
this.websocket.onopen = () => {
|
||||||
this.onOpenQueue.forEach((fun) => fun());
|
this.onOpenQueue.forEach((fun) => fun());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// First binary message is the server's raw x25519 pubkey — this
|
||||||
|
// one-time listener handles the handshake, then hands off to
|
||||||
|
// the normal encrypted read loop.
|
||||||
|
this.websocket.onmessage = (msg) => this.handleHandshakeMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleHandshakeMessage(msg: MessageEvent) {
|
||||||
|
const serverPubkey = new Uint8Array(msg.data as ArrayBuffer);
|
||||||
|
|
||||||
|
this.sharedSecret = x25519.getSharedSecret(
|
||||||
|
this.myX25519PrivateKey,
|
||||||
|
serverPubkey,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Respond with our own x25519 pubkey, in plaintext — this is the
|
||||||
|
// one message on either side that can't be encrypted yet, since
|
||||||
|
// the shared secret doesn't exist until both pubkeys are known.
|
||||||
|
const myPublicKey = x25519.getPublicKey(this.myX25519PrivateKey);
|
||||||
|
this.websocket.send(myPublicKey);
|
||||||
|
|
||||||
|
// From here on, every message is encrypted.
|
||||||
|
this.websocket.onmessage = null;
|
||||||
|
this.resolveHandshake();
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextSendNonce(): Uint8Array {
|
||||||
|
const nonce = new Uint8Array(12);
|
||||||
|
const view = new DataView(nonce.buffer);
|
||||||
|
view.setBigUint64(0, this.sendCounter, false); // big-endian, first 8 bytes
|
||||||
|
nonce[11] |= 0b1000_0000; // distinguishes client-send from server-send direction
|
||||||
|
this.sendCounter += 1n;
|
||||||
|
return nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
private encrypt(plaintext: Uint8Array): Uint8Array {
|
||||||
|
if (!this.sharedSecret) throw new Error("Handshake not complete");
|
||||||
|
|
||||||
|
const nonce = this.nextSendNonce();
|
||||||
|
const cipher = chacha20poly1305(this.sharedSecret, nonce);
|
||||||
|
const ciphertext = cipher.encrypt(plaintext);
|
||||||
|
|
||||||
|
const out = new Uint8Array(nonce.length + ciphertext.length);
|
||||||
|
out.set(nonce, 0);
|
||||||
|
out.set(ciphertext, nonce.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decrypt(data: Uint8Array): Uint8Array {
|
||||||
|
if (!this.sharedSecret) throw new Error("Handshake not complete");
|
||||||
|
if (data.length < 12)
|
||||||
|
throw new Error("Message too short to contain a nonce");
|
||||||
|
|
||||||
|
const nonce = data.slice(0, 12);
|
||||||
|
const ciphertext = data.slice(12);
|
||||||
|
|
||||||
|
const cipher = chacha20poly1305(this.sharedSecret, nonce);
|
||||||
|
return cipher.decrypt(ciphertext);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async send(method: ServerMethod) {
|
public async send(method: ServerMethod) {
|
||||||
|
await this.handshakeReady;
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
const plaintext = new TextEncoder().encode(JSON.stringify(method));
|
||||||
|
const encrypted = this.encrypt(plaintext);
|
||||||
|
this.websocket.send(encrypted);
|
||||||
|
};
|
||||||
|
|
||||||
if (this.websocket.readyState !== WebSocket.OPEN) {
|
if (this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
return new Promise<void>((ok) => {
|
return new Promise<void>((ok) => {
|
||||||
this.onOpenQueue.push(() => {
|
this.onOpenQueue.push(() => {
|
||||||
this.websocket.send(JSON.stringify(method));
|
send();
|
||||||
ok();
|
ok();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.websocket.send(JSON.stringify(method));
|
send();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(): Promise<ClientMethod> {
|
public async read(): Promise<ClientMethod> {
|
||||||
|
await this.handshakeReady;
|
||||||
|
|
||||||
return new Promise((ok) => {
|
return new Promise((ok) => {
|
||||||
this.websocket.onmessage = (msg) => {
|
this.websocket.onmessage = (msg) => {
|
||||||
const data = JSON.parse(msg.data);
|
const encrypted = new Uint8Array(msg.data as ArrayBuffer);
|
||||||
|
const plaintext = this.decrypt(encrypted);
|
||||||
|
const data = JSON.parse(new TextDecoder().decode(plaintext));
|
||||||
ok(data);
|
ok(data);
|
||||||
this.websocket.onmessage = null;
|
this.websocket.onmessage = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { Info, PlusIcon } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
// hostname[:port][/path] — explicitly no protocol/scheme allowed
|
// hostname[:port][/path] — explicitly no protocol/scheme allowed
|
||||||
const HOSTNAME_PATTERN =
|
const HOSTNAME_PATTERN =
|
||||||
@@ -38,7 +39,7 @@ type AddServerDialogProps = {
|
|||||||
export function AddServerDialog({ onAdd }: AddServerDialogProps) {
|
export function AddServerDialog({ onAdd }: AddServerDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [hostname, setHostname] = useState("");
|
const [hostname, setHostname] = useState("");
|
||||||
const [isSecure, setIsSecure] = useState(true);
|
const [isSecure, setIsSecure] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -106,9 +107,24 @@ export function AddServerDialog({ onAdd }: AddServerDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="secure-toggle" className="flex flex-col gap-1">
|
<Tooltip>
|
||||||
Secure connection
|
<TooltipTrigger>
|
||||||
|
<Label
|
||||||
|
htmlFor="secure-toggle"
|
||||||
|
className="flex items-center gap-1 cursor-help"
|
||||||
|
>
|
||||||
|
TLS
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</Label>
|
</Label>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="max-w-xs">
|
||||||
|
TLS is not recommended because there is a encryption layer
|
||||||
|
already built in to Enclave
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<Switch
|
<Switch
|
||||||
id="secure-toggle"
|
id="secure-toggle"
|
||||||
checked={isSecure}
|
checked={isSecure}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import Enclave from "@/app/app";
|
import Enclave from "@/app/app";
|
||||||
import { ChannelPageProps } from "../PageView";
|
import { ChannelPageProps } from "../PageView";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Phone, PhoneOff } from "lucide-react";
|
import { Phone, Volume2 } from "lucide-react";
|
||||||
|
|
||||||
export default function VoiceChannel({
|
export default function VoiceChannel({
|
||||||
appRef,
|
appRef,
|
||||||
@@ -13,58 +11,16 @@ export default function VoiceChannel({
|
|||||||
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
|
appRef: React.RefObject<Enclave<ChannelPageProps> | null>;
|
||||||
}) {
|
}) {
|
||||||
const channel = appRef.current?.page?.channel;
|
const channel = appRef.current?.page?.channel;
|
||||||
|
|
||||||
if (!channel) return null;
|
if (!channel) return null;
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const server = appRef.current?.server;
|
||||||
const lastChannelId = useRef<string | null>(null);
|
const isConnected = server?.voiceChannelId === channel.id;
|
||||||
|
|
||||||
// Leave current channel setup
|
|
||||||
const handleLeave = () => {
|
|
||||||
appRef.current?.server?.websocket?.send({ method: "LeaveVoice" });
|
|
||||||
invoke("disconnect_from_vc").catch(console.error);
|
|
||||||
setIsConnected(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Explicit action to join the voice channel
|
|
||||||
const handleJoin = () => {
|
|
||||||
const hostname = appRef.current?.server?.hostname;
|
|
||||||
if (!hostname || channel.kind !== "voice" || !appRef.current?.server)
|
|
||||||
return;
|
|
||||||
|
|
||||||
appRef.current.server.voiceJoin = (pin, channelId) => {
|
|
||||||
invoke("connect_to_vc", { hostname, pin, channelId })
|
|
||||||
.then(() => setIsConnected(true))
|
|
||||||
.catch(console.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
appRef.current.server.websocket?.send({
|
|
||||||
method: "JoinVoice",
|
|
||||||
channel_id: channel.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsConnected(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clean up when channel changes or component unmounts
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastChannelId.current !== channel.id) {
|
|
||||||
if (isConnected) {
|
|
||||||
invoke("disconnect_from_vc").catch(console.error);
|
|
||||||
setIsConnected(false);
|
|
||||||
}
|
|
||||||
lastChannelId.current = channel.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
invoke("disconnect_from_vc").catch(console.error);
|
|
||||||
};
|
|
||||||
}, [channel.id]);
|
|
||||||
|
|
||||||
const speakers =
|
const speakers =
|
||||||
appRef.current?.server?.voiceChatSpeakers &&
|
server?.voiceChatSpeakers && Object.keys(server.voiceChatSpeakers);
|
||||||
Object.keys(appRef.current?.server?.voiceChatSpeakers);
|
|
||||||
|
|
||||||
const users = appRef.current?.server?.voiceChatUsers[channel.id];
|
const users = server?.voiceChatUsers[channel.id];
|
||||||
const usersLength = users?.length || 0;
|
const usersLength = users?.length || 0;
|
||||||
|
|
||||||
// Dynamic grid column layout based on user count
|
// Dynamic grid column layout based on user count
|
||||||
@@ -82,25 +38,31 @@ export default function VoiceChannel({
|
|||||||
return "h-14 w-14 text-lg";
|
return "h-14 w-14 text-lg";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleJoinVoice = () => {
|
||||||
|
appRef.current?.joinVoice(channel);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen gap-2 pb-3">
|
<div className="relative flex flex-col h-screen gap-2 pb-3 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border flex justify-between items-center">
|
<header className="px-3 pt-3 pb-3 text-sm text-muted-foreground border-b border-b-border flex justify-between items-center z-10">
|
||||||
<h2>{channel.name}</h2>
|
<h2>{channel.name}</h2>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{usersLength} {usersLength === 1 ? "Participant" : "Participants"}
|
{usersLength} {usersLength === 1 ? "Participant" : "Participants"}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Grid */}
|
{/* Main Grid Container */}
|
||||||
|
<div className="relative flex-1 w-full min-h-0 overflow-hidden rounded-xl">
|
||||||
|
{/* Active Grid - Fully readable at all times */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid p-4 w-full gap-4 flex-1 auto-rows-fr bg-background/50 rounded-xl overflow-y-auto",
|
"grid p-4 w-full h-full gap-4 auto-rows-fr bg-background/40 rounded-xl overflow-y-auto transition-opacity duration-300",
|
||||||
getGridCols(),
|
getGridCols(),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{users?.map((pubkey) => {
|
{users?.map((pubkey) => {
|
||||||
const user = appRef.current?.server?.users[pubkey];
|
const user = server?.users[pubkey];
|
||||||
const isSpeaking = speakers?.includes(pubkey);
|
const isSpeaking = speakers?.includes(pubkey);
|
||||||
const displayName = user?.displayName || "Unknown User";
|
const displayName = user?.displayName || "Unknown User";
|
||||||
const fallbackLetter = displayName.charAt(0).toUpperCase();
|
const fallbackLetter = displayName.charAt(0).toUpperCase();
|
||||||
@@ -109,7 +71,7 @@ export default function VoiceChannel({
|
|||||||
<div
|
<div
|
||||||
key={pubkey}
|
key={pubkey}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col items-center justify-center p-4 rounded-xl bg-card/80 transition-all duration-150 overflow-hidden group h-full",
|
"relative flex flex-col items-center justify-center p-4 rounded-xl bg-card/90 transition-all duration-150 overflow-hidden group h-full",
|
||||||
isSpeaking
|
isSpeaking
|
||||||
? "ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.25)] bg-card"
|
? "ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.25)] bg-card"
|
||||||
: "ring-1 ring-border/60 hover:ring-border",
|
: "ring-1 ring-border/60 hover:ring-border",
|
||||||
@@ -118,7 +80,10 @@ export default function VoiceChannel({
|
|||||||
{/* Avatar Container */}
|
{/* Avatar Container */}
|
||||||
<div className="relative flex items-center justify-center">
|
<div className="relative flex items-center justify-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
className={cn("transition-all duration-200", getAvatarSize())}
|
className={cn(
|
||||||
|
"transition-all duration-200",
|
||||||
|
getAvatarSize(),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={user?.avatar}
|
src={user?.avatar}
|
||||||
@@ -140,28 +105,37 @@ export default function VoiceChannel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Control Bar */}
|
{/* Floating Join CTA Overlay */}
|
||||||
<footer className="flex items-center justify-center px-4 py-2 border-t border-border/40 bg-card/50 rounded-xl mx-2">
|
{!isConnected && (
|
||||||
{isConnected ? (
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/35 backdrop-brightness-75 pointer-events-auto transition-all animate-in fade-in duration-200">
|
||||||
|
<div className="flex flex-col items-center gap-4 p-6 sm:p-8 rounded-2xl text-center max-w-xs sm:max-w-sm mx-4 transform scale-100 transition-all">
|
||||||
|
<div className="p-3.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/20">
|
||||||
|
<Volume2 className="h-7 w-7 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-base sm:text-lg font-semibold text-foreground">
|
||||||
|
Ready to join {channel.name}?
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{usersLength > 0
|
||||||
|
? `${usersLength} ${usersLength === 1 ? "participant is" : "participants are"} currently connected.`
|
||||||
|
: "Channel is currently empty."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
onClick={handleJoinVoice}
|
||||||
onClick={handleLeave}
|
size="lg"
|
||||||
className="flex items-center gap-2 font-medium px-6"
|
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white font-medium shadow-lg shadow-emerald-600/20 transition-all"
|
||||||
>
|
>
|
||||||
<PhoneOff className="w-4 h-4" />
|
<Phone />
|
||||||
Disconnect
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={handleJoin}
|
|
||||||
className="flex items-center gap-2 font-medium px-6 bg-emerald-600 hover:bg-emerald-700 text-white"
|
|
||||||
>
|
|
||||||
<Phone className="w-4 h-4" />
|
|
||||||
Join Voice
|
Join Voice
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { Card } from "../ui/card";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
|
||||||
import { CopyIcon, Settings2Icon } from "lucide-react";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import Enclave from "@/app/app";
|
|
||||||
import { base58 } from "@scure/base";
|
|
||||||
import * as ed from "@noble/ed25519";
|
|
||||||
|
|
||||||
export default function AccountCard({
|
|
||||||
appRef,
|
|
||||||
}: {
|
|
||||||
appRef: React.RefObject<Enclave | null>;
|
|
||||||
}) {
|
|
||||||
const account = appRef.current?.getAccount();
|
|
||||||
|
|
||||||
if (!account) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute -left-16 right-16 bottom-0 h-20 w-[calc(100%+4rem-2rem)] z-10 ml-4 mb-4 @max-[150px]:hidden">
|
|
||||||
<Card className="px-3 py-3 h-full w-full flex flex-row">
|
|
||||||
<div className="flex gap-2.5 items-center w-full">
|
|
||||||
<Avatar className="h-full w-auto aspect-square">
|
|
||||||
<AvatarImage src={account.meta.avatar} />
|
|
||||||
<AvatarFallback>{account.meta.displayName[0]}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex flex-col w-full">
|
|
||||||
<span
|
|
||||||
className="flex items-center gap-1.5 cursor-pointer w-full"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(
|
|
||||||
base58.encode(
|
|
||||||
ed.getPublicKey(base58.decode(account.privateKey)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p>{account.meta.displayName}</p>
|
|
||||||
<CopyIcon className="text-muted-foreground size-3" />
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">Online</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-muted-foreground">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="size-10"
|
|
||||||
onClick={() => {
|
|
||||||
if (!appRef.current) return;
|
|
||||||
|
|
||||||
appRef.current.isSettingsOpen = !appRef.current?.isSettingsOpen;
|
|
||||||
appRef.current.forceRender();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings2Icon className="size-5.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,9 @@ import Enclave from "@/app/app";
|
|||||||
import { Channel, ChannelKind } from "@/lib/types";
|
import { Channel, ChannelKind } from "@/lib/types";
|
||||||
import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react";
|
import { ChevronDown, ChevronUp, HashIcon, Volume2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import AccountCard from "./AccountCard";
|
import StatusCard from "./StatusCard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||||
|
|
||||||
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
|
export function ChannelIcon({ kind }: { kind: ChannelKind["kind"] }) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
@@ -49,6 +50,64 @@ export function RenderCategory({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenderFeatures({
|
||||||
|
appRef,
|
||||||
|
channel,
|
||||||
|
}: {
|
||||||
|
appRef: React.RefObject<Enclave | null>;
|
||||||
|
channel: Channel;
|
||||||
|
}) {
|
||||||
|
switch (channel.kind) {
|
||||||
|
case "voice":
|
||||||
|
const users = appRef.current?.server?.voiceChatUsers[channel.id];
|
||||||
|
const isCurrentVc = channel.id === appRef.current?.server?.voiceChannelId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
users && (
|
||||||
|
<div className="flex flex-col gap-1.5 px-4 pt-0.5">
|
||||||
|
{(isCurrentVc ? users : users.slice(0, 15)).map((pubkey) => {
|
||||||
|
const user = appRef.current?.server?.users[pubkey];
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const isSpeaking =
|
||||||
|
appRef.current?.server?.voiceChatSpeakers[pubkey];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={pubkey} className="flex flex-row items-center gap-2">
|
||||||
|
<Avatar
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7",
|
||||||
|
isSpeaking
|
||||||
|
? "ring-2 ring-emerald-500 shadow-[0_0_15px_rgba(16,185,129,0.25)] bg-card"
|
||||||
|
: "ring-1 ring-border/60 hover:ring-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AvatarImage src={user.avatar} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{user?.displayName.slice(0, 1).toUpperCase() ||
|
||||||
|
pubkey.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{user.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isCurrentVc && users.length > 15 && (
|
||||||
|
<span className="text-xs text-muted-foreground pl-1">
|
||||||
|
and {users.length - 15} others
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function RenderChannels({
|
export function RenderChannels({
|
||||||
appRef,
|
appRef,
|
||||||
channels,
|
channels,
|
||||||
@@ -60,8 +119,8 @@ export function RenderChannels({
|
|||||||
channel.kind === "category" ? (
|
channel.kind === "category" ? (
|
||||||
<RenderCategory key={channel.id} appRef={appRef} channel={channel} />
|
<RenderCategory key={channel.id} appRef={appRef} channel={channel} />
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex flex-col" key={channel.id}>
|
||||||
<div
|
<div
|
||||||
key={channel.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full hover:bg-accent px-2.5 py-1.5 rounded-md flex gap-2.5 items-center select-none cursor-default text-sm text-muted-foreground",
|
"w-full hover:bg-accent px-2.5 py-1.5 rounded-md flex gap-2.5 items-center select-none cursor-default text-sm text-muted-foreground",
|
||||||
channel.id === appRef.current?.page?.channel.id,
|
channel.id === appRef.current?.page?.channel.id,
|
||||||
@@ -80,6 +139,8 @@ export function RenderChannels({
|
|||||||
<ChannelIcon kind={channel.kind} />
|
<ChannelIcon kind={channel.kind} />
|
||||||
{channel.name}
|
{channel.name}
|
||||||
</div>
|
</div>
|
||||||
|
<RenderFeatures appRef={appRef} channel={channel} />
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,7 +158,7 @@ export default function Sidebar({
|
|||||||
<h1>{appRef.current.server.meta.name}</h1>
|
<h1>{appRef.current.server.meta.name}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="px-1.5 pt-3.5 w-full flex flex-col gap-1">
|
<section className="px-1.5 pt-3.5 w-full h-full flex flex-col gap-1 overflow-y-scroll scrollbar-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden pb-55">
|
||||||
<RenderChannels
|
<RenderChannels
|
||||||
appRef={appRef}
|
appRef={appRef}
|
||||||
channels={appRef.current.server.meta.channels}
|
channels={appRef.current.server.meta.channels}
|
||||||
@@ -106,7 +167,7 @@ export default function Sidebar({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AccountCard appRef={appRef} />
|
<StatusCard appRef={appRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||||
|
import { CopyIcon, PhoneOff, Settings2Icon, Signal } from "lucide-react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import Enclave from "@/app/app";
|
||||||
|
import { base58 } from "@scure/base";
|
||||||
|
import * as ed from "@noble/ed25519";
|
||||||
|
import { Channel } from "@/lib/types";
|
||||||
|
|
||||||
|
function findChannel(channels: Channel[], id: string): Channel | undefined {
|
||||||
|
for (const channel of channels) {
|
||||||
|
if (channel.id === id) return channel;
|
||||||
|
|
||||||
|
if (channel.kind === "category") {
|
||||||
|
const found = findChannel(channel.channels, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusCard({
|
||||||
|
appRef,
|
||||||
|
}: {
|
||||||
|
appRef: React.RefObject<Enclave | null>;
|
||||||
|
}) {
|
||||||
|
const account = appRef.current?.getAccount();
|
||||||
|
const server = appRef.current?.server;
|
||||||
|
|
||||||
|
const voiceChannelId = server?.voiceChannelId;
|
||||||
|
const voiceChannel =
|
||||||
|
voiceChannelId && server?.meta
|
||||||
|
? findChannel(server.meta.channels, voiceChannelId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute -left-16 right-16 bottom-0 w-[calc(100%+4rem-2rem)] z-10 ml-4 mb-4 @max-[150px]:hidden">
|
||||||
|
<Card className="px-3 py-3 h-full w-full flex flex-col gap-0.5">
|
||||||
|
{voiceChannel && (
|
||||||
|
<div className="text-emerald-500 flex flex-row items-center gap-2 border-b pb-2.5">
|
||||||
|
<Signal className="h-5" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>Voice Connected</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{voiceChannel.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="ml-auto"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => appRef.current?.leaveVoice()}
|
||||||
|
>
|
||||||
|
<PhoneOff />
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="h-full w-full flex flex-row">
|
||||||
|
<div className="flex gap-2.5 items-center w-full">
|
||||||
|
<Avatar className="h-14 w-auto aspect-square">
|
||||||
|
<AvatarImage src={account.meta.avatar} />
|
||||||
|
<AvatarFallback>{account.meta.displayName[0]}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 cursor-pointer w-full"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
base58.encode(
|
||||||
|
ed.getPublicKey(base58.decode(account.privateKey)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p>{account.meta.displayName}</p>
|
||||||
|
<CopyIcon className="text-muted-foreground size-3" />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">Online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="size-10"
|
||||||
|
onClick={() => {
|
||||||
|
if (!appRef.current) return;
|
||||||
|
|
||||||
|
appRef.current.isSettingsOpen = !appRef.current?.isSettingsOpen;
|
||||||
|
appRef.current.forceRender();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings2Icon className="size-5.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user