actual UI, tons of bug fixes, and rename

This commit is contained in:
Zoe
2025-09-15 03:05:30 -05:00
parent 4ddc5c526b
commit de96b33a41
15 changed files with 709 additions and 222 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 juls0730
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.

View File

@@ -1,10 +1,11 @@
# Wormhole
(needs a different name I think because I dont want to confuse it with wormhole.app)
# Noctis
A peer-to-peer encrypted file sharing app.
Noctis /ˈnɑktɪs/ *adjective* of the night
A peer-to-peer end-to-end encrypted chat app.
## Features
- E2E communication
- E2EE communication
- P2P file sharing
- P2P chat

View File

@@ -2,6 +2,20 @@ import { WebSocketServer } from "ws";
import { Socket, WebSocketMessageType, type WebSocketMessage } from "../src/types/websocket";
import { LiveMap } from '../src/utils/liveMap.ts';
const adjectives = ['swift', 'silent', 'hidden', 'clever', 'brave', 'sharp', 'shadow', 'crimson', 'bright', 'quiet', 'loud', 'happy', 'dark', 'evil', 'good', 'intelligent', 'lovely', 'mysterious', 'peaceful', 'powerful', 'pure', 'quiet', 'shiny', 'sleepy', 'strong', 'sweet', 'tall', 'warm', 'gentle', 'kind', 'nice', 'polite', 'rough', 'rude', 'scary', 'shy', 'silly', 'smart', 'strange', 'tough', 'ugly', 'vivid', 'wicked', 'wise', 'young', 'sleepy'];
const nouns = ['fox', 'river', 'stone', 'cipher', 'link', 'comet', 'falcon', 'signal', 'anchor', 'spark', 'stone', 'comet', 'rocket', 'snake', 'snail', 'shark', 'elephant', 'cat', 'dog', 'whale', 'orca', 'cactus', 'flower', 'frog', 'toad', 'apple', 'strawberry', 'raspberry', 'lemon', 'bot', 'gopher', 'dinosaur', 'racoon', 'penguin', 'chameleon', 'atom', 'particle', 'witch', 'wizard', 'warlock', 'deer']
enum ErrorCode {
ROOM_NOT_FOUND,
}
const errors = {
MALFORMED_MESSAGE: "Invalid message",
ROOM_NOT_FOUND: "Room does not exist",
ROOM_FULL: "Room is full",
UNKNOWN_MESSAGE_TYPE: "Unknown message type",
}
export class ServerRoom {
private clients: Socket[] = [];
@@ -38,16 +52,28 @@ export class ServerRoom {
}
}
function generateRoomName(): string {
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
return `${adj}-${noun}`;
}
const rooms = new LiveMap<string, ServerRoom>();
async function createRoom(socket: Socket): Promise<string> {
let roomId = Math.random().toString(36).substring(2, 10);
async function createRoom(socket: Socket, roomName?: string): Promise<string> {
if (!roomName) {
roomName = generateRoomName();
}
const num = Math.floor(Math.random() * 900) + 100;
const roomId = `${roomName}-${num}`;
let room = rooms.set(roomId, new ServerRoom());
socket.send({ type: WebSocketMessageType.ROOM_CREATED, data: room.key });
try {
await joinRoom(room.key, socket);
await joinRoom(room.key, socket, true);
} catch (e: any) {
throw e;
}
@@ -55,18 +81,18 @@ async function createRoom(socket: Socket): Promise<string> {
return roomId;
}
async function joinRoom(roomId: string, socket: Socket): Promise<ServerRoom | undefined> {
async function joinRoom(roomId: string, socket: Socket, initial?: boolean): Promise<ServerRoom | undefined> {
let room = rooms.get(roomId);
console.log(room?.length);
// should be unreachable
if (!room) {
socket.send({ type: WebSocketMessageType.ERROR, data: `Room ${roomId} does not exist` });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND });
return undefined;
}
if (room.length == 2) {
socket.send({ type: WebSocketMessageType.ERROR, data: "Room is full" });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_FULL });
return undefined;
}
@@ -93,6 +119,9 @@ async function joinRoom(roomId: string, socket: Socket): Promise<ServerRoom | un
room.set(room.filter(client => client.ws !== ev.target));
});
if (!initial) {
socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: roomId, participants: room.length });
}
// TODO: consider letting rooms get larger than 2 clients
if (room.length == 2) {
room.forEachClient(client => client.send({ type: WebSocketMessageType.ROOM_READY, data: { isInitiator: client !== socket } }));
@@ -109,7 +138,7 @@ function leaveRoom(roomId: string, socket: Socket): ServerRoom | undefined {
// should be unreachable
if (!room) {
socket.send({ type: WebSocketMessageType.ERROR, data: `Room ${roomId} does not exist` });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND });
return undefined;
}
@@ -156,7 +185,7 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
if (message === undefined) {
console.log("Received non-JSON message:", event);
// If the message is not JSON, send an error message
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE });
return;
}
@@ -166,7 +195,17 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
case WebSocketMessageType.CREATE_ROOM:
// else, create a new room
try {
await createRoom(socket);
if (message.roomName) {
// sanitize the room name
message.roomName = message.roomName.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w-]+/g, '') // Remove all non-word chars
.replace(/--+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, ''); // Trim - from end of text
}
await createRoom(socket, message.roomName);
} catch (e: any) {
socket.send({ type: WebSocketMessageType.ERROR, data: e.message });
throw e;
@@ -174,29 +213,27 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
break;
case WebSocketMessageType.JOIN_ROOM:
if (!message.roomId) {
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE });
return;
}
if (rooms.get(message.roomId) == undefined) {
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid roomId' });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND });
return;
}
room = await joinRoom(message.roomId, socket);
if (!room) return;
// the client is now in the room and the peer knows about it
socket.send({ type: WebSocketMessageType.ROOM_JOINED, roomId: message.roomId, participants: room.length });
break;
case WebSocketMessageType.LEAVE_ROOM:
if (!message.roomId) {
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE });
return;
}
if (rooms.get(message.roomId) == undefined) {
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid roomId' });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_NOT_FOUND });
return;
}
@@ -220,7 +257,7 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
break;
default:
console.warn(`Unknown message type: ${message.type}`);
socket.send({ type: WebSocketMessageType.ERROR, data: 'Unknown message type' });
socket.send({ type: WebSocketMessageType.ERROR, data: errors.UNKNOWN_MESSAGE_TYPE });
break;
}
});

View File

@@ -1,9 +1,51 @@
@import 'tailwindcss';
body, html {
@apply bg-neutral-950 text-white font-sans min-h-screen;
@font-face {
font-family: "Instrument Sans";
src: url("/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2");
font-display: swap;
}
:root {
--font-sans: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--test: #00060d;
}
@theme {
--color-accent: #00e0b8;
--color-surface: #0f1626;
--color-paragraph: #e0e0e0;
--color-paragraph-muted: #8a94a6;
--color-primary: #00060d;
}
body,
html {
@apply bg-primary font-sans min-h-screen text-paragraph;
}
h1,
h2,
h3 {
color: #FFF;
line-height: 1.2;
margin-bottom: 1rem;
}
h1 {
font-size: 3rem;
}
h2 {
font-size: 2.25rem;
text-align: center;
}
h3 {
font-size: 1.25rem;
font-weight: 600;
}
a {
@apply text-pink-600 underline hover:no-underline;
@apply text-accent underline hover:no-underline;
}

View File

@@ -0,0 +1,20 @@
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { derived, writable, type Writable } from "svelte/store";
// import { room } from "../stores/roomStore";
import { webSocketConnected, ws } from "../stores/websocketStore";
import { WebsocketConnectionState, ws } from "../stores/websocketStore";
import {
isRTCConnected,
dataChannelReady,
@@ -15,7 +14,7 @@
receivedOffers,
} from "../stores/messageStore";
import { WebRTCPacketType } from "../types/webrtc";
import { ConnectionState, type Room } from "../types/websocket";
import { RoomConnectionState, type Room } from "../types/websocket";
import { MessageType } from "../types/message";
import { fade } from "svelte/transition";
import { WebBuffer } from "../utils/buffer";
@@ -26,7 +25,7 @@
let initialConnectionCompleteCount = writable(0);
let initialConnectionComplete = derived(
initialConnectionCompleteCount,
(value) => value === 3,
(value) => value >= 3,
);
// TODO: is this the most elegant way to do this?
@@ -224,21 +223,19 @@
<p>
{$room?.id}
({$room?.participants}) - {$room?.connectionState} - {$webSocketConnected}
({$room?.participants}) - {$room?.connectionState} - {$ws.status}
- Initial connection {$initialConnectionComplete
? "complete"
: "incomplete"}
</p>
<!-- If we are in a room, connected to the websocket server, and have been informed that we are connected to the room -->
{#if ($room !== null && $webSocketConnected === true && $room.connectionState === ConnectionState.CONNECTED) || $room.connectionState === ConnectionState.RECONNECTING}
{#if ($room !== null && $ws.status === WebsocketConnectionState.CONNECTED && $room.connectionState === RoomConnectionState.CONNECTED) || $room.connectionState === RoomConnectionState.RECONNECTING}
<div class="flex flex-col w-full min-h-[calc(5/12_*_100vh)]">
<div
class="flex flex-col sm:max-w-4/5 lg:max-w-3/5 min-h-[calc(5/12_*_100vh)]"
class="flex-grow flex flex-col overflow-y-auto mb-4 p-2 bg-surface rounded relative whitespace-break-spaces wrap-anywhere"
>
<div
class="flex-grow flex flex-col overflow-y-auto mb-4 p-2 bg-gray-800 rounded relative whitespace-break-spaces wrap-anywhere"
>
{#if !$initialConnectionComplete || $room.connectionState === ConnectionState.RECONNECTING || $room.participants !== 2 || $dataChannelReady === false || !$canCloseLoadingOverlay}
{#if !$initialConnectionComplete || $room.connectionState === RoomConnectionState.RECONNECTING || $room.participants !== 2 || $dataChannelReady === false || !$canCloseLoadingOverlay}
<div
transition:fade={{ duration: 300 }}
class="absolute top-0 left-0 bottom-0 right-0 flex justify-center items-center flex-col bg-black/55 backdrop-blur-md z-10 text-center"
@@ -249,23 +246,24 @@
<p>Establishing data channel...</p>
{:else if !$keyExchangeDone}
<p>Establishing a secure connection with the peer...</p>
{:else if $room.connectionState === ConnectionState.RECONNECTING}
{:else if $room.connectionState === RoomConnectionState.RECONNECTING}
<p>
Disconnect from peer, attempting to reconnecting...
</p>
{:else if $room.participants !== 2 || $dataChannelReady === false}
<p>
Peer has disconnected, waiting for other peer to
reconnect...
<span>reconnect...</span>
</p>
{:else}
<p>
<!-- fucking completely stupid shit I have to do because svelte FORCES these to be broken into two lines, and for some reason it just puts all of the whitespace at the beginning of the line in the string, so it looks unbelievably stupid -->
Successfully established a secure connection to
peer!
<span>peer!</span>
</p>
{/if}
<div class="mt-2">
{#if !$keyExchangeDone || $room.participants !== 2 || $dataChannelReady === false || $room.connectionState === ConnectionState.RECONNECTING}
{#if !$keyExchangeDone || $room.participants !== 2 || $dataChannelReady === false || $room.connectionState === RoomConnectionState.RECONNECTING}
<!-- loading spinner -->
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
@@ -326,12 +324,12 @@
</p>
{/if}
<div
class="flex flex-col p-2 relative w-8/12 bg-gray-600 rounded"
class="flex flex-col p-2 relative w-8/12 bg-primary/50 rounded"
>
<h2 class="text-lg font-semibold my-1">
<h3 class="font-semibold">
{msg.data.fileName}
</h2>
<p class="text-sm">
</h3>
<p class="text-sm text-paragraph-muted">
{msg.data.fileSize} bytes
</p>
<!-- as the initiator, we cant send ourselves a file -->
@@ -339,7 +337,7 @@
<button
onclick={() =>
downloadFile(msg.data.id)}
class="absolute right-2 bottom-2 p-1 border border-gray-500 text-gray-100 hover:bg-gray-800/70 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
class="absolute right-2 bottom-2 p-1 border border-[#2c3444]/80 text-paragraph hover:bg-[#1D1C1F]/60 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -373,12 +371,12 @@
/>
<div class="flex gap-2 w-full flex-row">
<div
class="border rounded border-gray-600 flex-grow flex flex-col bg-gray-700"
class="border rounded border-[#2c3444] focus-within:border-[#404c63] transition-colors flex-grow flex flex-col bg-[#232b3e]"
>
{#if $inputFile}
<div class="flex flex-row gap-2 p-2">
<div
class="p-2 flex flex-col gap-2 w-48 border rounded-md border-gray-600 relative"
class="p-2 flex flex-col gap-2 w-48 border rounded-md border-[#2c3444] relative"
>
<div class="w-full flex justify-center">
<svg
@@ -410,7 +408,7 @@
onclick={() => {
$inputFile = null;
}}
class="absolute right-2 top-2 p-1 border border-gray-600 text-gray-100 hover:bg-gray-800/70 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
class="absolute right-2 top-2 p-1 border border-[#2c3444] text-paragraph hover:bg-surface/70 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -429,11 +427,9 @@
</button>
</div>
</div>
<hr class="border-gray-600" />
<hr class="border-[#2c3444]" />
{/if}
<div
class="flex flex-row focus-within:ring-2 focus-within:ring-blue-500 rounded"
>
<div class="flex flex-row rounded">
<textarea
bind:value={$inputMessage}
cols="1"
@@ -451,9 +447,9 @@
!$dataChannelReady ||
!$keyExchangeDone ||
$room.connectionState ===
ConnectionState.RECONNECTING}
RoomConnectionState.RECONNECTING}
placeholder="Type your message..."
class="flex-grow p-2 bg-gray-700 rounded text-gray-100 placeholder-gray-400 min-h-12
class="placeholder:text-paragraph-muted flex-grow p-2 bg-[#232b3e] rounded min-h-12
focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed resize-none leading-8"
></textarea>
<div class="flex flex-row gap-2 p-2 h-fit mt-auto">
@@ -463,9 +459,9 @@
!$dataChannelReady ||
!$keyExchangeDone ||
$room.connectionState ===
ConnectionState.RECONNECTING}
RoomConnectionState.RECONNECTING}
aria-label="Pick file"
class="not-disabled:hover:bg-gray-800/70 h-fit p-1 text-gray-100 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
class="not-disabled:hover:bg-primary/50 h-fit p-1 text-paragraph transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -488,8 +484,8 @@
!$dataChannelReady ||
!$keyExchangeDone ||
$room.connectionState ===
ConnectionState.RECONNECTING}
class="not-disabled:hover:bg-gray-800/70 h-fit p-1 text-gray-100 transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
RoomConnectionState.RECONNECTING}
class="not-disabled:hover:bg-primary/50 h-fit p-1 text-paragraph transition-colors rounded disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -512,9 +508,3 @@
</div>
</div>
{/if}
<button
onclick={() => {
$ws.close();
}}>Simulate disconnect</button
>

View File

@@ -1,10 +1,9 @@
import { get } from 'svelte/store';
import { ws } from '../stores/websocketStore';
import { WebSocketMessageType } from '../types/websocket';
import { WebRTCPacketType, type WebRTCPeerCallbacks } from '../types/webrtc';
import { browser } from '$app/environment';
import { createApplicationMessage, createCommit, createGroup, decodeMlsMessage, defaultCapabilities, defaultLifetime, emptyPskIndex, encodeMlsMessage, generateKeyPackage, getCiphersuiteFromName, getCiphersuiteImpl, joinGroup, processPrivateMessage, type CiphersuiteImpl, type ClientState, type Credential, type GroupContext, type KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls';
import { createApplicationMessage, createCommit, createGroup, decodeMlsMessage, defaultCapabilities, defaultLifetime, emptyPskIndex, encodeMlsMessage, generateKeyPackage, getCiphersuiteFromName, getCiphersuiteImpl, joinGroup, processPrivateMessage, type CiphersuiteImpl, type ClientState, type Credential, type KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls';
export class WebRTCPeer {
private peer: RTCPeerConnection | null = null;
@@ -35,7 +34,7 @@ export class WebRTCPeer {
}
private sendIceCandidate(candidate: RTCIceCandidate) {
get(ws).send({
ws.send({
type: WebSocketMessageType.WEBRTC_ICE_CANDIDATE,
data: {
roomId: this.roomId,
@@ -261,7 +260,7 @@ export class WebRTCPeer {
await this.peer.setLocalDescription(offer)
get(ws).send({
ws.send({
type: WebSocketMessageType.WEBRTC_OFFER,
data: {
roomId: this.roomId,
@@ -295,7 +294,7 @@ export class WebRTCPeer {
console.log("Sending answer", answer);
get(ws).send({
ws.send({
type: WebSocketMessageType.WERTC_ANSWER,
data: {
roomId: this.roomId,

View File

@@ -1,12 +1,40 @@
<script lang="ts">
import "../app.css";
import favicon from "$lib/assets/favicon.svg";
import { onDestroy, onMount } from "svelte";
import { WebsocketConnectionState, ws } from "../stores/websocketStore";
import { room } from "../stores/roomStore";
onMount(() => {
ws.connect();
});
onDestroy(() => {
ws.disconnect();
});
ws.subscribe((newWs) => {
if (newWs.status === WebsocketConnectionState.CONNECTED) {
console.log(
"Connected to websocket server, room id:",
$room.id,
"reconnecting",
);
}
});
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link
rel="preload"
as="font"
type="font/woff2"
crossorigin="anonymous"
href="/fonts/InstrumentSans-VariableFont_wdth,wght.woff2"
/>
<script
src="https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js"
></script>
@@ -15,4 +43,23 @@
></script>
</svelte:head>
{@render children?.()}
<header class="p-5">
<div class="flex justify-between items-center max-w-7xl px-5 mx-auto">
<div class="text-2xl font-bold text-white">
<a href="/" class="!text-white !no-underline"
>Noctis<span class="text-accent">.</span></a
>
</div>
<nav>
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer">GitHub</a
>
</nav>
</div>
</header>
<main>
{@render children?.()}
</main>

View File

@@ -1,62 +1,265 @@
<script lang="ts">
import { ws, webSocketConnected } from "../stores/websocketStore";
import { ws } from "../stores/websocketStore";
import { WebSocketMessageType } from "../types/websocket";
import { room } from "../stores/roomStore";
import { browser } from "$app/environment";
import { peer, handleMessage } from "../utils/webrtcUtil";
import { onDestroy, onMount } from "svelte";
import RtcMessage from "../components/RTCMessage.svelte";
import { ConnectionState } from "../types/websocket";
import { writable, type Writable } from "svelte/store";
import LoadingSpinner from "../components/LoadingSpinner.svelte";
onMount(async () => {
$ws.addEventListener("message", handleMessage);
});
let roomName: Writable<string> = writable("");
let roomLoading: Writable<boolean> = writable(false);
onDestroy(() => {
if ($ws) {
room.update((room) => ({
...room,
connectionState: ConnectionState.DISCONNECTED,
}));
$ws.removeEventListener("message", handleMessage);
}
if ($peer) {
$peer.close();
}
function createRoom() {
roomLoading.set(true);
let roomId = $roomName.trim() === "" ? undefined : $roomName.trim();
ws.send({
type: WebSocketMessageType.CREATE_ROOM,
roomName: roomId,
});
// todo: redirect to the room
console.log("Created room:", roomId);
}
let showRoomNameInput: Writable<boolean> = writable(false);
</script>
<div class="p-4">
<h1>Welcome to Wormhole!</h1>
<section class="py-20 text-center">
<div class="max-w-6xl px-5 mx-auto flex flex-col items-center">
<h1 class="font-bold">Your Private, Peer-to-Peer Chat Room</h1>
<p class="max-w-xl mx-8">
End-to-end encrypted. Peer-to-peer. No servers. No sign-ups. Just
chat.
</p>
{#if $webSocketConnected}
<div
class="bg-surface p-10 rounded-xl max-w-xl shadow-xl border border-[#21293b] mt-10 mr-auto ml-auto w-full"
>
<form class="flex flex-col gap-5" id="roomForm">
<button
onclick={() => {
// if we are in a room already, leave it
if ($room.id) {
$ws.send({
type: WebSocketMessageType.LEAVE_ROOM,
roomId: $room.id,
});
$peer?.close();
peer.set(null);
room.update((room) => ({
...room,
connectionState: ConnectionState.DISCONNECTED,
}));
}
$ws.send({ type: WebSocketMessageType.CREATE_ROOM }); // send a message when the button is clicked
}}>Create Room</button
onclick={createRoom}
class="py-4 px-8 text-xl font-semibold bg-accent text-[#121826] rounded-lg cursor-pointer transition-[background-color,_translate,_box-shadow] ease-out duration-200 w-full inline-flex justify-center items-center gap-2.5 hover:bg-[#00f0c8] hover:-translate-y-1 hover:shadow-md shadow-accent/20"
>
{#if $roomLoading}
<span class="flex items-center"
><LoadingSpinner /> Creating Room...</span
>
{:else}
<p>Connecting to server...</p>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="currentColor"
d="M12 2a5 5 0 0 1 5 5v3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m0 12a2 2 0 0 0-1.995 1.85L10 16a2 2 0 1 0 2-2m0-10a3 3 0 0 0-3 3v3h6V7a3 3 0 0 0-3-3"
/></svg
>
Create Secure Room
{/if}
</button>
<div
class="{$showRoomNameInput
? 'max-h-32'
: 'max-h-0 opacity-0'} overflow-hidden transition-[max-height,_opacity] duration-700"
>
<label
aria-hidden={!$showRoomNameInput}
for="roomNameInput"
class="text-paragraph block text-sm font-medium mb-2 text-left"
>Enter a custom room name</label
>
<input
type="text"
id="roomNameInput"
bind:value={$roomName}
class="placeholder:text-paragraph-muted w-full py-3 px-4 rounded-lg border border-[#2c3444] bg-[#232b3e] text-paragraph transition-[border-color,_box-shadow] duration-300 ease-in-out focus:outline-none focus:border-accent focus:shadow-sm shadow-accent/20"
placeholder="e.g., private-chat"
/>
</div>
<span
class="text-paragraph {$showRoomNameInput
? 'hidden'
: '-mt-5'}"
>or <button
id="showCustomNameLink"
class="cursor-pointer underline hover:no-underline text-accent"
onclick={() => showRoomNameInput.set(true)}
>choose a custom room name</button
></span
>
</form>
</div>
</div>
</section>
{#if $room.id && browser}
<p>Room created!</p>
<p>Share this link with your friend:</p>
<a href={`${location.origin}/${$room}`}>{location.origin}/{$room.id}</a>
{/if}
<section class="py-20 bg-surface">
<div class="max-w-6xl px-5 mx-auto">
<h2 class="font-semibold">How It Works</h2>
<div class="mt-10 flex justify-around gap-8 flex-wrap">
<div class="text-center max-w-3xs">
<div
class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5"
>
1
</div>
<h3>Create a Room</h3>
<p>
Click the button above to create a random room instantly, no
personal info required.
</p>
</div>
<div class="text-center max-w-3xs">
<div
class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5"
>
2
</div>
<h3>Share the Link</h3>
<p>
You'll get a unique link to your private room. Share this
link with anyone you want to chat with securely.
</p>
</div>
<div class="text-center max-w-3xs">
<div
class="text-2xl font-bold w-12 h-12 leading-12 rounded-full bg-primary text-accent mx-auto mb-5"
>
3
</div>
<h3>Chat Privately</h3>
<p>
Once they join, your messages are sent directly between your
devices, encrypted from end to end. Hidden from everyone
else.
</p>
</div>
</div>
</div>
</section>
<RtcMessage {room} />
</div>
<section class="py-20">
<div class="max-w-6xl px-10 mx-auto">
<h2 class="font-semibold">Security by Design</h2>
<div
class="mt-10 grid grid-cols-[repeat(auto-fit,_minmax(300px,_1fr))] gap-8"
>
<div
class="bg-surface p-8 rounded-xl border border-[#21293b] text-center"
>
<div
class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
><path
d="M5 13a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2z"
/><path
d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0-2 0m-3-5V7a4 4 0 1 1 8 0v4"
/></g
></svg
>
</div>
<h3 class="font-bold">End-to-End Encrypted</h3>
<p>
Only you and the people in your room can read the messages.
Your data is encrypted before its sent using the Message
Layer Security (MLS) protocol.
</p>
</div>
<div
class="bg-surface p-8 rounded-xl border border-[#21293b] text-center"
>
<div
class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 11V8a2 2 0 0 0-2-2h-6m0 0l3 3m-3-3l3-3M3 13.013v3a2 2 0 0 0 2 2h6m0 0l-3-3m3 3l-3 3m8-4.511a2 2 0 1 0 4.001-.001a2 2 0 0 0-4.001.001m-12-12a2 2 0 1 0 4.001-.001A2 2 0 0 0 4 4.502m17 16.997a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2m-6-12a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2"
/></svg
>
</div>
<h3 class="font-bold">Truly Peer-to-Peer</h3>
<p>
Your messages are sent directly from your device to the
recipient's. They never pass through a central server.
</p>
</div>
<div
class="bg-surface p-8 rounded-xl border border-[#21293b] text-center"
>
<div
class="mb-5 bg-accent/10 w-16 h-16 rounded-full inline-flex justify-center items-center text-paragraph"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m3 3l18 18M7 3h7l5 5v7m0 4a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5"
/></svg
>
</div>
<h3 class="font-bold">No Data Stored</h3>
<p>
We don't have accounts, and we don't store your messages.
Once you close the tab, the conversation is gone forever.
</p>
</div>
</div>
</div>
</section>
<footer class="px-20 text-center border-t border-[#21293b]">
<div class="max-w-6xl px-10 mx-auto">
<p>
&copy; {new Date().getFullYear()} Noctis - MIT License
<br />
Made with
<span class="text-accent"
><svg
class="inline-block"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE --><path
fill="currentColor"
d="M6.979 3.074a6 6 0 0 1 4.988 1.425l.037.033l.034-.03a6 6 0 0 1 4.733-1.44l.246.036a6 6 0 0 1 3.364 10.008l-.18.185l-.048.041l-7.45 7.379a1 1 0 0 1-1.313.082l-.094-.082l-7.493-7.422A6 6 0 0 1 6.979 3.074"
/></svg
></span
>
by
<a href="https://zoeissleeping.com">zoeissleeping</a>
</p>
</div>
</footer>
<style>
p {
margin-bottom: 1rem;
color: var(--color-paragraph-muted);
}
</style>

View File

@@ -1,58 +1,121 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { onMount } from "svelte";
import { room } from "../../stores/roomStore";
import { error, handleMessage, peer } from "../../utils/webrtcUtil";
import { ws, webSocketConnected } from "../../stores/websocketStore";
import { ws } from "../../stores/websocketStore";
import { WebSocketMessageType } from "../../types/websocket";
import { dataChannelReady, error } from "../../utils/webrtcUtil";
import { goto } from "$app/navigation";
import RtcMessage from "../../components/RTCMessage.svelte";
import { ConnectionState } from "../../types/websocket";
let isHost = $room.host === true;
let awaitingJoinConfirmation = !isHost;
let roomLink = "";
let copyButtonText = "Copy Link";
export let data: { roomId: string };
const { roomId } = data;
onMount(async () => {
room.update((room) => ({ ...room, id: roomId }));
onMount(() => {
error.set(null);
$ws.addEventListener("message", handleMessage);
roomLink = `${window.location.origin}/${roomId}`;
});
webSocketConnected.subscribe((value) => {
if (value) {
room.update((room) => ({
...room,
connectionState: ConnectionState.CONNECTING,
}));
if ($room.id === null) {
throw new Error("Room ID not set");
function handleCopyLink() {
navigator.clipboard.writeText(roomLink).then(() => {
copyButtonText = "Copied!";
setTimeout(() => {
copyButtonText = "Copy Link";
}, 2000);
});
}
$ws.send({
function handleConfirmJoin() {
awaitingJoinConfirmation = false;
ws.send({
type: WebSocketMessageType.JOIN_ROOM,
roomId: $room.id,
roomId: roomId,
});
}
});
});
onDestroy(() => {
if ($ws) {
room.update((room) => ({
...room,
connectionState: ConnectionState.DISCONNECTED,
}));
$ws.close();
function handleDeclineJoin() {
// In a real app, this would close the connection and maybe redirect
alert("You have declined to join the room.");
awaitingJoinConfirmation = false; // Hides the prompt
goto("/");
}
function handleLeave() {
if (
confirm(
"Are you sure you want to leave? The chat history will be deleted.",
)
) {
// In a real app, this would disconnect the P2P session and redirect.
window.location.href = "/";
}
if ($peer) {
$peer.close();
}
});
</script>
<div class="p-4">
<div class="max-w-6xl px-5 mx-auto flex flex-col items-center">
{#if $error}
<p>Hm. Something went wrong: {$error.toLocaleLowerCase()}</p>
{:else if $room.connectionState !== ConnectionState.CONNECTED && $room.connectionState !== ConnectionState.RECONNECTING}
<p>Connecting to server...</p>
<h2 class="text-3xl font-bold text-white mb-2">
Something went wrong: {$error.toLocaleLowerCase()}
</h2>
<p class="!text-paragraph">
click <a href="/">here</a> to go back to the homepage
</p>
{/if}
{#if !$error}
{#if isHost}
{#if !$room.RTCConnectionReady}
<h2 class="text-3xl font-bold text-white mb-2">
Your secure room is ready.
</h2>
<p class="text-gray-400 mb-6 text-center">
Share the link below to invite someone to chat directly with
you. Once they join, you will be connected automatically.
</p>
<div
class="bg-gray-900 rounded-lg p-4 flex items-center justify-between gap-4 border border-gray-600"
>
<span
class="text-accent font-mono text-sm overflow-x-auto whitespace-nowrap"
>{roomLink}</span
>
<button
onclick={handleCopyLink}
class="bg-accent hover:bg-accent/80 active:bg-accent/60 cursor-pointer text-gray-900 font-bold py-2 px-4 rounded-md transition-colors whitespace-nowrap"
>
{copyButtonText}
</button>
</div>
{:else}
<RtcMessage {room} />
{/if}
{:else if awaitingJoinConfirmation}
<h2 class="text-3xl font-bold text-white mb-2">
You're invited to chat.
</h2>
<div class="flex flex-row gap-2">
<button
onclick={handleConfirmJoin}
class="bg-accent hover:bg-accent/80 active:bg-accent/60 cursor-pointer text-gray-900 font-bold py-2 px-4 rounded-md transition-colors whitespace-nowrap"
>
Accept
</button>
<button
onclick={handleDeclineJoin}
class="bg-red-400 hover:bg-red-400/80 active:bg-red-400/60 cursor-pointer text-gray-900 font-bold py-2 px-4 rounded-md transition-colors whitespace-nowrap"
>
Decline
</button>
</div>
{:else}
<RtcMessage {room} />
{/if}
{/if}
</div>

View File

@@ -1,16 +1,19 @@
import { writable, type Writable } from 'svelte/store';
import { ConnectionState } from '../types/websocket';
import { RoomConnectionState } from '../types/websocket';
import { browser } from '$app/environment';
export interface Room {
id: string | null;
host: boolean | null;
RTCConnectionReady: boolean;
participants: number;
connectionState: ConnectionState;
connectionState: RoomConnectionState;
}
export const room: Writable<Room> = writable({
id: null,
host: null,
RTCConnectionReady: false,
participants: 0,
connectionState: ConnectionState.DISCONNECTED,
key: null,
connectionState: RoomConnectionState.DISCONNECTED,
});

View File

@@ -1,55 +1,105 @@
import { get, writable } from 'svelte/store';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import { browser } from '$app/environment';
import { room } from './roomStore';
import { ConnectionState, Socket, WebSocketMessageType } from '../types/websocket';
import { Socket, type WebSocketMessage } from '../types/websocket';
import { handleMessage } from '../utils/webrtcUtil';
let socket: Socket | null = null;
export const webSocketConnected = writable(false);
function createSocket(): Socket {
if (!browser) {
// this only occurs on the server, which we dont care about because its not a client that can actually connect to the websocket server
// @ts-ignore
return null;
}
if (socket) {
return socket;
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new Socket(new WebSocket(`${protocol}//${location.host}/`));
socket.addEventListener('open', () => {
webSocketConnected.set(true);
console.log('Connected to websocket server');
});
socket.addEventListener('close', () => {
// TODO: massively rework the reconnection logic, currently it only works if one client disconnects, if the
// TODO: other client disconnects after the other client has diconnected at least once, everything explodes
if (get(webSocketConnected) && get(room)?.connectionState === ConnectionState.CONNECTED) {
room.update((room) => ({ ...room, connectionState: ConnectionState.RECONNECTING }));
setTimeout(() => {
ws.set(createSocket());
// attempt to rejoin the room if we were previously connected
get(ws).addEventListener('open', () => {
let oldRoomId = get(room)?.id;
if (oldRoomId) {
get(ws).send({ type: WebSocketMessageType.JOIN_ROOM, roomId: oldRoomId });
room.update((room) => ({ ...room, connectionState: ConnectionState.CONNECTED }));
}
});
}, 1000);
}
webSocketConnected.set(false);
socket = null;
console.log('Disconnected from websocket server, reconnecting...');
});
return socket;
export enum WebsocketConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
RECONNECTING
}
export const ws = writable(createSocket());
interface WebSocketStoreValue {
status: WebsocketConnectionState;
socket: Socket | null;
}
export type MessageHandler = (event: MessageEvent) => void;
interface WebSocketStore extends Readable<WebSocketStoreValue> {
connect: () => void;
disconnect: () => void;
send: (message: WebSocketMessage) => void;
}
// TODO: handle reconnection logic to room elsewhere (not implemented here)
function createWebSocketStore(messageHandler: MessageHandler): WebSocketStore {
const { subscribe, set, update } = writable<WebSocketStoreValue>({ status: WebsocketConnectionState.DISCONNECTED, socket: null });
let reconnectTimeout: NodeJS.Timeout | null = null;
let reconnectAttempts = 0;
const send = (message: WebSocketMessage) => {
let currentState = get({ subscribe });
if (currentState.socket?.readyState === WebSocket.OPEN) {
currentState.socket.send(message);
} else {
console.error("Socket not connected");
}
};
const disconnect = () => {
let currentState = get({ subscribe });
if (currentState.socket) {
currentState.socket.close();
set({ status: WebsocketConnectionState.DISCONNECTED, socket: null });
}
};
const connect = () => {
if (!browser) {
return;
}
const currentState = get({ subscribe });
if (currentState.socket || currentState.status === WebsocketConnectionState.CONNECTING) {
// already connected/connecting
return;
}
update(s => ({ ...s, status: WebsocketConnectionState.CONNECTING }));
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new Socket(new WebSocket(`${protocol}//${location.host}/`));
socket.addEventListener('open', () => {
console.log('Connected to websocket server');
reconnectAttempts = 0;
update(s => ({ ...s, status: WebsocketConnectionState.CONNECTED, socket }));
});
socket.addEventListener('message', messageHandler);
socket.addEventListener('close', () => {
console.log('Disconnected from websocket server,');
update(s => ({ ...s, socket: null }));
// exponential backoff
const timeout = Math.min(Math.pow(2, reconnectAttempts) * 1000, 30000);
reconnectAttempts++;
console.log(`Reconnecting in ${timeout / 1000}s...`);
update(s => ({ ...s, status: WebsocketConnectionState.RECONNECTING }));
reconnectTimeout = setTimeout(() => {
connect();
}, timeout);
});
socket.addEventListener('error', () => {
console.error('Error connecting to websocket server');
socket.close();
// close will trigger a reconnect
});
};
return {
subscribe,
connect,
disconnect,
send,
};
}
export const ws = createWebSocketStore(handleMessage);

View File

@@ -1,4 +1,4 @@
export enum ConnectionState {
export enum RoomConnectionState {
CONNECTING,
RECONNECTING,
CONNECTED,
@@ -8,7 +8,7 @@ export enum ConnectionState {
export interface Room {
id: string | null;
participants: number;
connectionState: ConnectionState;
connectionState: RoomConnectionState;
}
export enum WebSocketMessageType {
@@ -51,6 +51,7 @@ interface ErrorMessage {
interface CreateRoomMessage {
type: WebSocketMessageType.CREATE_ROOM;
roomName?: string;
}
interface JoinRoomMessage {
@@ -129,11 +130,16 @@ export class Socket {
console.log("WebSocket opened");
});
this.addEventListener = this.ws.addEventListener.bind(this.ws);
this.removeEventListener = this.ws.removeEventListener.bind(this.ws);
this.close = this.ws.close.bind(this.ws);
}
get readyState(): number {
return this.ws.readyState;
}
public send(message: WebSocketMessage) {
console.log("Sending message:", message);

View File

@@ -2,11 +2,12 @@ import { writable, get, type Writable } from "svelte/store";
import { WebRTCPeer } from "$lib/webrtc";
import { CHUNK_SIZE, WebRTCPacketType } from "../types/webrtc";
import { room } from "../stores/roomStore";
import { ConnectionState, type Room } from "../types/websocket";
import { RoomConnectionState, type Room } from "../types/websocket";
import { advertisedOffers, fileRequestIds, messages, receivedOffers } from "../stores/messageStore";
import { MessageType, type Message } from "../types/message";
import { WebSocketMessageType, type WebSocketMessage } from "../types/websocket";
import { WebBuffer } from "./buffer";
import { goto } from "$app/navigation";
export const error: Writable<string | null> = writable(null);
export let peer: Writable<WebRTCPeer | null> = writable(null);
@@ -255,7 +256,8 @@ export async function handleMessage(event: MessageEvent) {
switch (message.type) {
case WebSocketMessageType.ROOM_CREATED:
console.log("Room created:", message.data);
room.update((room) => ({ ...room, id: message.data, connectionState: ConnectionState.CONNECTED, participants: 1 }));
room.set({ id: message.data, host: true, RTCConnectionReady: false, connectionState: RoomConnectionState.CONNECTED, participants: 1 });
goto(`/${message.data}`);
return;
case WebSocketMessageType.JOIN_ROOM:
console.log("new client joined room");
@@ -263,7 +265,8 @@ export async function handleMessage(event: MessageEvent) {
return;
case WebSocketMessageType.ROOM_JOINED:
// TODO: if a client disconnects, we need to resync the room state
room.update((room) => ({ ...room, connectionState: ConnectionState.CONNECTED, participants: message.participants }));
room.set({ host: false, id: message.roomId, RTCConnectionReady: false, connectionState: RoomConnectionState.CONNECTED, participants: message.participants });
console.log("Joined room");
return;
case WebSocketMessageType.ROOM_LEFT:
@@ -282,6 +285,8 @@ export async function handleMessage(event: MessageEvent) {
return;
}
room.update(r => ({ ...r, RTCConnectionReady: true }));
console.log("Creating peer");
peer.set(new WebRTCPeer(
roomId,