first commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 HellCon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,57 @@
|
||||
# HellCon
|
||||
|
||||
Peer-to-peer file sharing between two browsers. No server, no account, no
|
||||
upload. The page you host is only a delivery mechanism for the JavaScript —
|
||||
the files themselves travel directly from one browser to the other over
|
||||
WebRTC, end-to-end encrypted (DTLS-SRTP) by the browsers themselves.
|
||||
|
||||
Built with Vite + Vue 3, toolchain is `bun`.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Sender** picks a file, chooses a STUN provider, and clicks
|
||||
*Create code*. Their browser generates a WebRTC offer and the app presents
|
||||
it as one copyable string.
|
||||
2. The sender pastes that code to the receiver through **any channel they
|
||||
already trust** — a chat app, email, a piece of paper read over the phone.
|
||||
3. **Receiver** pastes the code; their browser answers and the app shows the
|
||||
answer string. They send it back the same way.
|
||||
4. The sender pastes the answer. Both browsers exchange the SDP fingerprints
|
||||
as a two-line security check — if both sides see the same codes, no one
|
||||
can have interposed on the exchange.
|
||||
5. The connection opens and the file is transferred in chunks with
|
||||
backpressure, reassembled on the receiver side and handed to the user as
|
||||
a download.
|
||||
|
||||
**STUN**: if both peers are on the same network nothing external is needed
|
||||
(pick "None"). For peers on different networks the browsers need one
|
||||
stateless UDP lookup against a public STUN server (Google or Cloudflare, or
|
||||
your own) to discover their public IP. That's the only third-party contact,
|
||||
and it never sees connection or file data. Remove the `iceServers` entry in
|
||||
`src/lib/webrtc.js` if you want to talk to nobody but your peers.
|
||||
|
||||
**MITM model**: the file data is end-to-end encrypted by the browsers and
|
||||
cannot be MITM'd by a passive attacker. The only theoretical attack is
|
||||
interposing on the *code exchange* itself; the built-in fingerprint
|
||||
comparison ("Security check" after signaling) closes it — compare the codes
|
||||
over a channel you trust.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
bun install
|
||||
bun run dev # dev server
|
||||
bun run build # static build -> dist/ (3 static files + favicon + robots.txt)
|
||||
bun run preview # serve the production build
|
||||
```
|
||||
|
||||
## Honest disclaimer
|
||||
|
||||
This is **vibe-coded throw-away software**. It was generated for fun, it
|
||||
worked on the machines it was tried on, and it is roughly audited rather
|
||||
than battle-tested. It may break on your browser, your NAT, or your use
|
||||
case. If it works for you, great. If not, fixing it is now your hobby.
|
||||
|
||||
## License
|
||||
|
||||
MIT — do whatever you want with this. No warranty, see `LICENSE`.
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "hellcon",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"vite": "^7.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.8", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.42", "", { "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.42", "@vue/compiler-dom": "3.5.42", "@vue/compiler-ssr": "3.5.42", "@vue/shared": "3.5.42", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.42", "", { "dependencies": { "@vue/shared": "3.5.42" } }, "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/runtime-core": "3.5.42", "@vue/shared": "3.5.42", "csstype": "^3.2.3" } }, "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.42", "", { "dependencies": { "@vue/compiler-ssr": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||
|
||||
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"vue": ["vue@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/server-renderer": "3.5.42", "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA=="],
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="googlebot" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<link rel="icon" type="image/png" href="./HellNet_alt.png" />
|
||||
<title>HellCon — P2P File Share</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "hellcon",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import SendPanel from './components/SendPanel.vue';
|
||||
import ReceivePanel from './components/ReceivePanel.vue';
|
||||
|
||||
const mode = ref('send');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>HellCon</h1>
|
||||
<p class="subtitle">
|
||||
Peer-to-peer file sharing. No server, no account — files go straight from
|
||||
browser to browser over WebRTC.
|
||||
</p>
|
||||
|
||||
<div class="tabs" style="width: 100%; max-width: 680px">
|
||||
<button :class="{ active: mode === 'send' }" @click="mode = 'send'">Send</button>
|
||||
<button :class="{ active: mode === 'receive' }" @click="mode = 'receive'">Receive</button>
|
||||
</div>
|
||||
|
||||
<SendPanel v-if="mode === 'send'" />
|
||||
<ReceivePanel v-else />
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
All transfers are end-to-end encrypted (WebRTC/DTLS) and never pass
|
||||
through this server — it only delivers this page. Verify the security
|
||||
check codes with your peer to exclude interception. You are responsible
|
||||
for what you send, and for whom you send it to.
|
||||
</p>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { ref, computed, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
createPeerConnection,
|
||||
createAnswerCode,
|
||||
getFingerprints,
|
||||
STUN_PROVIDERS,
|
||||
} from '../lib/webrtc';
|
||||
|
||||
const STATUS = {
|
||||
idle: 'Paste the sender\'s connection code to start.',
|
||||
'creating-answer': 'Gathering connection info…',
|
||||
'answer-ready': 'Send the code below back to the sender.',
|
||||
connecting: 'Waiting for the P2P connection…',
|
||||
receiving: 'Receiving…',
|
||||
done: 'File received!',
|
||||
closed: 'Connection closed.',
|
||||
};
|
||||
|
||||
const step = ref('idle');
|
||||
const offerInput = ref('');
|
||||
const answerCode = ref('');
|
||||
const progress = ref(0);
|
||||
const error = ref('');
|
||||
const copied = ref(false);
|
||||
const fps = ref(null);
|
||||
const stunChoice = ref('google');
|
||||
const customStun = ref('');
|
||||
|
||||
const fileName = ref('');
|
||||
const fileSize = ref(0);
|
||||
const downloadUrl = ref('');
|
||||
|
||||
let pc = null;
|
||||
|
||||
const statusText = computed(() => STATUS[step.value] ?? '');
|
||||
const resolvedStun = computed(() => {
|
||||
if (stunChoice.value === 'custom') {
|
||||
const raw = customStun.value.trim();
|
||||
return raw ? raw : null;
|
||||
}
|
||||
return STUN_PROVIDERS.find((p) => p.id === stunChoice.value)?.url ?? null;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pc?.close();
|
||||
if (downloadUrl.value) URL.revokeObjectURL(downloadUrl.value);
|
||||
});
|
||||
|
||||
async function acceptOffer() {
|
||||
error.value = '';
|
||||
if (downloadUrl.value) {
|
||||
URL.revokeObjectURL(downloadUrl.value);
|
||||
downloadUrl.value = '';
|
||||
}
|
||||
progress.value = 0;
|
||||
fps.value = null;
|
||||
|
||||
pc?.close();
|
||||
pc = createPeerConnection(resolvedStun.value);
|
||||
pc.ondatachannel = (e) => setupReceiver(e.channel);
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (['disconnected', 'failed', 'closed'].includes(pc.connectionState)) {
|
||||
if (step.value !== 'done') step.value = 'closed';
|
||||
}
|
||||
};
|
||||
|
||||
step.value = 'creating-answer';
|
||||
try {
|
||||
answerCode.value = await createAnswerCode(pc, offerInput.value);
|
||||
fps.value = getFingerprints(pc);
|
||||
step.value = 'answer-ready';
|
||||
} catch {
|
||||
step.value = 'idle';
|
||||
error.value = 'Invalid offer code — paste the full code from the sender.';
|
||||
}
|
||||
}
|
||||
|
||||
function setupReceiver(dc) {
|
||||
dc.binaryType = 'arraybuffer';
|
||||
let meta = null;
|
||||
let chunks = [];
|
||||
let received = 0;
|
||||
|
||||
dc.onopen = () => {
|
||||
if (step.value === 'answer-ready') step.value = 'connecting';
|
||||
};
|
||||
|
||||
dc.onmessage = (e) => {
|
||||
if (typeof e.data === 'string') {
|
||||
// Metadata frame
|
||||
meta = JSON.parse(e.data);
|
||||
fileName.value = meta.name;
|
||||
fileSize.value = meta.size;
|
||||
received = 0;
|
||||
if (meta.size === 0) finish(meta, []);
|
||||
else step.value = 'receiving';
|
||||
return;
|
||||
}
|
||||
chunks.push(e.data);
|
||||
received += e.data.byteLength;
|
||||
if (meta) {
|
||||
progress.value = Math.min(100, Math.floor((received / meta.size) * 100));
|
||||
if (received >= meta.size) finish(meta, chunks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function finish(meta, chunks) {
|
||||
if (downloadUrl.value) URL.revokeObjectURL(downloadUrl.value);
|
||||
const blob = new Blob(chunks, { type: meta.type || 'application/octet-stream' });
|
||||
downloadUrl.value = URL.createObjectURL(blob);
|
||||
progress.value = 100;
|
||||
step.value = 'done';
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = true;
|
||||
setTimeout(() => (copied.value = false), 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<label class="step">1 — Choose a STUN provider</label>
|
||||
<select v-model="stunChoice" class="stun-select">
|
||||
<option v-for="p in STUN_PROVIDERS" :key="p.id" :value="p.id">
|
||||
{{ p.label }}
|
||||
</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
<input
|
||||
v-if="stunChoice === 'custom'"
|
||||
v-model="customStun"
|
||||
class="stun-input"
|
||||
type="text"
|
||||
placeholder="stun:your.domain:3478"
|
||||
/>
|
||||
<p class="status stun-hint">
|
||||
STUN is only needed for peers on different networks — a stateless UDP
|
||||
lookup of your public IP; it never sees the connection or the file.
|
||||
Same-network or VPN peers work with "None".
|
||||
</p>
|
||||
|
||||
<label class="step">2 — Paste the sender's connection code</label>
|
||||
<textarea
|
||||
v-model="offerInput"
|
||||
placeholder="Paste the code from the sender…"
|
||||
/>
|
||||
<button class="primary" :disabled="!offerInput.trim()" @click="acceptOffer">
|
||||
Accept & create answer
|
||||
</button>
|
||||
|
||||
<template v-if="answerCode">
|
||||
<label class="step">3 — Send this answer code back to the sender</label>
|
||||
<textarea :value="answerCode" readonly @focus="$event.target.select()" />
|
||||
<button @click="copy(answerCode)">{{ copied ? 'Copied!' : 'Copy code' }}</button>
|
||||
</template>
|
||||
|
||||
<div class="progress" v-if="step === 'receiving' || step === 'done'">
|
||||
<div :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="fps" class="fingerprint">
|
||||
<label class="step">Security check</label>
|
||||
<div class="fp-row">
|
||||
<span>Your code</span>
|
||||
<code>{{ fps.local }}</code>
|
||||
</div>
|
||||
<div class="fp-row">
|
||||
<span>Their code</span>
|
||||
<code>{{ fps.remote }}</code>
|
||||
</div>
|
||||
<p class="status">
|
||||
To rule out an intercepted exchange, compare with your peer over a
|
||||
channel you trust: your "Your code" must equal their "Their code",
|
||||
and vice versa.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="status">{{ statusText }}</p>
|
||||
<p class="error" v-if="error">{{ error }}</p>
|
||||
|
||||
<div v-if="downloadUrl" class="file-chip">
|
||||
<span>{{ fileName }}</span>
|
||||
<a class="download" :href="downloadUrl" :download="fileName">Save file</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
createPeerConnection,
|
||||
createOfferCode,
|
||||
acceptAnswerCode,
|
||||
getFingerprints,
|
||||
STUN_PROVIDERS,
|
||||
CHUNK_SIZE,
|
||||
MAX_BUFFERED,
|
||||
waitForBufferDrain,
|
||||
} from '../lib/webrtc';
|
||||
|
||||
const STATUS = {
|
||||
idle: 'Pick a file to start.',
|
||||
'creating-offer': 'Gathering connection info…',
|
||||
'offer-ready': 'Send the code below to the receiver (any chat works).',
|
||||
connecting: 'Answer accepted — waiting for the P2P connection…',
|
||||
sending: 'Transferring…',
|
||||
done: 'Transfer complete!',
|
||||
closed: 'Connection closed.',
|
||||
};
|
||||
|
||||
const file = ref(null);
|
||||
const step = ref('idle');
|
||||
const offerCode = ref('');
|
||||
const answerInput = ref('');
|
||||
const progress = ref(0);
|
||||
const error = ref('');
|
||||
const copied = ref(false);
|
||||
const dragover = ref(false);
|
||||
const fps = ref(null);
|
||||
const stunChoice = ref('google');
|
||||
const customStun = ref('');
|
||||
|
||||
let pc = null;
|
||||
let dc = null;
|
||||
|
||||
const statusText = computed(() => STATUS[step.value] ?? '');
|
||||
const resolvedStun = computed(() => {
|
||||
if (stunChoice.value === 'custom') {
|
||||
const raw = customStun.value.trim();
|
||||
return raw ? raw : null;
|
||||
}
|
||||
return STUN_PROVIDERS.find((p) => p.id === stunChoice.value)?.url ?? null;
|
||||
});
|
||||
|
||||
function reset() {
|
||||
pc?.close();
|
||||
pc = null;
|
||||
dc = null;
|
||||
offerCode.value = '';
|
||||
answerInput.value = '';
|
||||
progress.value = 0;
|
||||
error.value = '';
|
||||
copied.value = false;
|
||||
fps.value = null;
|
||||
step.value = 'idle';
|
||||
}
|
||||
|
||||
function pickFiles(e) {
|
||||
const f = e.target?.files?.[0] ?? e.dataTransfer?.files?.[0];
|
||||
if (f) {
|
||||
reset();
|
||||
file.value = f;
|
||||
}
|
||||
}
|
||||
|
||||
async function makeOffer() {
|
||||
if (!file.value) return;
|
||||
error.value = '';
|
||||
step.value = 'creating-offer';
|
||||
|
||||
pc = createPeerConnection(resolvedStun.value);
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc && ['disconnected', 'failed', 'closed'].includes(pc.connectionState)) {
|
||||
step.value = 'closed';
|
||||
}
|
||||
};
|
||||
dc = pc.createDataChannel('file');
|
||||
dc.binaryType = 'arraybuffer';
|
||||
dc.onopen = () => sendFile();
|
||||
dc.onclose = () => {
|
||||
if (step.value === 'sending' && progress.value < 100) step.value = 'closed';
|
||||
};
|
||||
|
||||
try {
|
||||
offerCode.value = await createOfferCode(pc);
|
||||
step.value = 'offer-ready';
|
||||
} catch (err) {
|
||||
error.value = String(err);
|
||||
step.value = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
async function useAnswer() {
|
||||
if (!pc) return;
|
||||
error.value = '';
|
||||
try {
|
||||
await acceptAnswerCode(pc, answerInput.value);
|
||||
fps.value = getFingerprints(pc);
|
||||
step.value = 'connecting';
|
||||
} catch {
|
||||
error.value = 'Invalid answer code — paste the full code from the receiver.';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFile() {
|
||||
const f = file.value;
|
||||
if (!dc || !f) return;
|
||||
step.value = 'sending';
|
||||
progress.value = 0;
|
||||
|
||||
// First frame: metadata as JSON. Everything after is raw bytes.
|
||||
dc.send(JSON.stringify({ name: f.name, size: f.size, type: f.type }));
|
||||
|
||||
let sent = 0;
|
||||
try {
|
||||
for (let offset = 0; offset < f.size; offset += CHUNK_SIZE) {
|
||||
if (dc.readyState !== 'open') break;
|
||||
if (dc.bufferedAmount > MAX_BUFFERED) await waitForBufferDrain(dc);
|
||||
const buf = await f.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
|
||||
dc.send(buf);
|
||||
sent += buf.byteLength;
|
||||
progress.value = Math.floor((sent / Math.max(f.size, 1)) * 100);
|
||||
}
|
||||
if (dc.readyState === 'open') {
|
||||
progress.value = 100;
|
||||
step.value = 'done';
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = String(err);
|
||||
step.value = 'closed';
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied.value = true;
|
||||
setTimeout(() => (copied.value = false), 1500);
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n < 1024) return n + ' B';
|
||||
const u = ['KiB', 'MiB', 'GiB'];
|
||||
let v = n;
|
||||
let i = -1;
|
||||
do {
|
||||
v /= 1024;
|
||||
i++;
|
||||
} while (v >= 1024 && i < u.length - 1);
|
||||
return v.toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<label class="step">1 — Choose a file</label>
|
||||
|
||||
<div
|
||||
class="dropzone"
|
||||
:class="{ dragover }"
|
||||
@dragover.prevent="dragover = true"
|
||||
@dragleave="dragover = false"
|
||||
@drop.prevent="dragover = false; pickFiles($event)"
|
||||
@click="$refs.input.click()"
|
||||
>
|
||||
<span v-if="!file">Drop a file here or click to browse</span>
|
||||
<div v-else class="file-chip" @click.stop>
|
||||
<span>{{ file.name }}</span>
|
||||
<span class="status">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<input
|
||||
ref="input"
|
||||
type="file"
|
||||
style="display: none"
|
||||
@change="pickFiles"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="file">
|
||||
<label class="step">2 — Choose a STUN provider</label>
|
||||
<select v-model="stunChoice" class="stun-select">
|
||||
<option v-for="p in STUN_PROVIDERS" :key="p.id" :value="p.id">
|
||||
{{ p.label }}
|
||||
</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
<input
|
||||
v-if="stunChoice === 'custom'"
|
||||
v-model="customStun"
|
||||
class="stun-input"
|
||||
type="text"
|
||||
placeholder="stun:your.domain:3478"
|
||||
/>
|
||||
<p class="status stun-hint">
|
||||
STUN is only needed for peers on different networks — a stateless UDP
|
||||
lookup of your public IP; it never sees the connection or the file.
|
||||
Same-network or VPN peers work with "None".
|
||||
</p>
|
||||
|
||||
<label class="step">3 — Create your connection code</label>
|
||||
<button class="primary" :disabled="step !== 'idle'" @click="makeOffer">
|
||||
Create code
|
||||
</button>
|
||||
|
||||
<template v-if="offerCode">
|
||||
<textarea :value="offerCode" readonly @focus="$event.target.select()" />
|
||||
<button @click="copy(offerCode)">{{ copied ? 'Copied!' : 'Copy code' }}</button>
|
||||
</template>
|
||||
|
||||
<template v-if="offerCode">
|
||||
<label class="step">4 — Paste the receiver's answer code</label>
|
||||
<textarea
|
||||
v-model="answerInput"
|
||||
placeholder="Paste the code the receiver sends back…"
|
||||
/>
|
||||
<button class="primary" :disabled="!answerInput.trim()" @click="useAnswer">
|
||||
Connect
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="progress" v-if="step === 'sending' || step === 'done'">
|
||||
<div :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="fps" class="fingerprint">
|
||||
<label class="step">Security check</label>
|
||||
<div class="fp-row">
|
||||
<span>Your code</span>
|
||||
<code>{{ fps.local }}</code>
|
||||
</div>
|
||||
<div class="fp-row">
|
||||
<span>Their code</span>
|
||||
<code>{{ fps.remote }}</code>
|
||||
</div>
|
||||
<p class="status">
|
||||
To rule out an intercepted exchange, compare with your peer over a
|
||||
channel you trust: your "Your code" must equal their "Their code",
|
||||
and vice versa.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="status">{{ statusText }}</p>
|
||||
<p class="error" v-if="error">{{ error }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
// P2P with manual signaling: no server, no third party.
|
||||
// Peers exchange SDP blobs by copy/paste. ICE gathering runs to completion
|
||||
// so the full connection info fits in a single copyable string.
|
||||
|
||||
// Chunking / buffering for the data channel.
|
||||
export const CHUNK_SIZE = 16 * 1024; // 16 KiB per message
|
||||
export const MAX_BUFFERED = 1024 * 1024; // backpressure limit (bytes)
|
||||
|
||||
// Built-in public STUN providers. STUN is a stateless, unencrypted UDP
|
||||
// lookup of your public IP — the server never sees connection or file data.
|
||||
export const STUN_PROVIDERS = [
|
||||
{ id: 'google', label: 'Google', url: 'stun:stun.l.google.com:19302' },
|
||||
{ id: 'cloudflare', label: 'Cloudflare', url: 'stun:stun.cloudflare.com:3478' },
|
||||
{ id: 'none', label: 'None (same-network / VPN peers only)', url: null },
|
||||
];
|
||||
|
||||
// stunUrl: a URL from STUN_PROVIDERS, or a user-supplied "stun:host:port"
|
||||
// string, or null for host candidates only.
|
||||
export function createPeerConnection(stunUrl = null) {
|
||||
return new RTCPeerConnection({
|
||||
iceServers: stunUrl ? [{ urls: stunUrl }] : [],
|
||||
});
|
||||
}
|
||||
|
||||
function waitForIceComplete(pc) {
|
||||
if (pc.iceGatheringState === 'complete') return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const onChange = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.addEventListener('icegatheringstatechange', onChange);
|
||||
setTimeout(() => {
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
// Resolve anyway — partial candidate lists often still connect fine.
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Sender side: create an offer and return it as a copyable code.
|
||||
export async function createOfferCode(pc) {
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
await waitForIceComplete(pc);
|
||||
return encodeSignal(pc.localDescription.sdp);
|
||||
}
|
||||
|
||||
// Receiver side: accept an offer code, return the answer code to send back.
|
||||
export async function createAnswerCode(pc, offerText) {
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: decodeSignal(offerText) });
|
||||
await pc.setLocalDescription(await pc.createAnswer());
|
||||
await waitForIceComplete(pc);
|
||||
return encodeSignal(pc.localDescription.sdp);
|
||||
}
|
||||
|
||||
// Sender side: apply the answer code the receiver sent back.
|
||||
export function acceptAnswerCode(pc, answerText) {
|
||||
return pc.setRemoteDescription({ type: 'answer', sdp: decodeSignal(answerText) });
|
||||
}
|
||||
|
||||
// Anti-MITM check: extracts the DTLS certificate fingerprints from both
|
||||
// descriptions and shortens them for human comparison.
|
||||
// Rule: my "local" must equal the peer's "remote" (and vice versa).
|
||||
// If both match, nobody interposed on the code exchange.
|
||||
export function getFingerprints(pc) {
|
||||
const extract = (sdp) => {
|
||||
const m = sdp?.match(/a=fingerprint:(?:sha-256|sha-384|sha-512) ([0-9A-Fa-f:]+)/);
|
||||
return m ? m[1].toUpperCase() : null;
|
||||
};
|
||||
// The fingerprint is already a certificate hash — its first 5 bytes
|
||||
// (40 bits) are plenty for two humans to read to each other.
|
||||
const shorten = (fp) => (fp ? fp.split(':').slice(0, 5).join(' ') : '—');
|
||||
return {
|
||||
local: shorten(extract(pc.localDescription?.sdp)),
|
||||
remote: shorten(extract(pc.remoteDescription?.sdp)),
|
||||
};
|
||||
}
|
||||
|
||||
// SDP is ASCII plus CRLFs, so plain base64 is safe and compact enough (~2-4 KB).
|
||||
export function encodeSignal(sdp) {
|
||||
return btoa(sdp).replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function decodeSignal(text) {
|
||||
const clean = String(text).trim().replace(/\s+/g, '');
|
||||
const padded = clean + '='.repeat((4 - (clean.length % 4)) % 4);
|
||||
return atob(padded);
|
||||
}
|
||||
|
||||
// Waits until the channel's send buffer drains below MAX_BUFFERED.
|
||||
export function waitForBufferDrain(dc) {
|
||||
dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
|
||||
return new Promise((resolve) => {
|
||||
dc.addEventListener('bufferedamountlow', resolve, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1117;
|
||||
--panel: #181b24;
|
||||
--border: #2a2f3d;
|
||||
--accent: #e8452c;
|
||||
--accent-soft: rgba(232, 69, 44, 0.15);
|
||||
--text: #e8eaf0;
|
||||
--muted: #8b91a3;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-inline: 0.9rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 2rem 0 0.25rem;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
label.step {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 90px;
|
||||
resize: vertical;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropzone.dragover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 10px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress > div {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.15s linear;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b5e;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.file-chip {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
a.download {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fingerprint {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.fp-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fp-row code {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.fingerprint .status {
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: 680px;
|
||||
width: 100%;
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
padding: 0 0.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stun-select,
|
||||
.stun-input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.stun-input {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stun-select:focus,
|
||||
.stun-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
p.stun-hint {
|
||||
margin: -0.35rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
// base './' so the built app can be served from any static path (or opened from a subdirectory)
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
});
|
||||
Reference in New Issue
Block a user