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 # Noctis
(needs a different name I think because I dont want to confuse it with wormhole.app)
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 ## Features
- E2E communication - E2EE communication
- P2P file sharing - P2P file sharing
- P2P chat - P2P chat

View File

@@ -2,6 +2,20 @@ import { WebSocketServer } from "ws";
import { Socket, WebSocketMessageType, type WebSocketMessage } from "../src/types/websocket"; import { Socket, WebSocketMessageType, type WebSocketMessage } from "../src/types/websocket";
import { LiveMap } from '../src/utils/liveMap.ts'; 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 { export class ServerRoom {
private clients: Socket[] = []; 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>(); const rooms = new LiveMap<string, ServerRoom>();
async function createRoom(socket: Socket): Promise<string> { async function createRoom(socket: Socket, roomName?: string): Promise<string> {
let roomId = Math.random().toString(36).substring(2, 10); if (!roomName) {
roomName = generateRoomName();
}
const num = Math.floor(Math.random() * 900) + 100;
const roomId = `${roomName}-${num}`;
let room = rooms.set(roomId, new ServerRoom()); let room = rooms.set(roomId, new ServerRoom());
socket.send({ type: WebSocketMessageType.ROOM_CREATED, data: room.key }); socket.send({ type: WebSocketMessageType.ROOM_CREATED, data: room.key });
try { try {
await joinRoom(room.key, socket); await joinRoom(room.key, socket, true);
} catch (e: any) { } catch (e: any) {
throw e; throw e;
} }
@@ -55,18 +81,18 @@ async function createRoom(socket: Socket): Promise<string> {
return roomId; 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); let room = rooms.get(roomId);
console.log(room?.length); console.log(room?.length);
// should be unreachable // should be unreachable
if (!room) { 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; return undefined;
} }
if (room.length == 2) { if (room.length == 2) {
socket.send({ type: WebSocketMessageType.ERROR, data: "Room is full" }); socket.send({ type: WebSocketMessageType.ERROR, data: errors.ROOM_FULL });
return undefined; 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)); 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 // TODO: consider letting rooms get larger than 2 clients
if (room.length == 2) { if (room.length == 2) {
room.forEachClient(client => client.send({ type: WebSocketMessageType.ROOM_READY, data: { isInitiator: client !== socket } })); 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 // should be unreachable
if (!room) { 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; return undefined;
} }
@@ -156,7 +185,7 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
if (message === undefined) { if (message === undefined) {
console.log("Received non-JSON message:", event); console.log("Received non-JSON message:", event);
// If the message is not JSON, send an error message // 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; return;
} }
@@ -166,7 +195,17 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
case WebSocketMessageType.CREATE_ROOM: case WebSocketMessageType.CREATE_ROOM:
// else, create a new room // else, create a new room
try { 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) { } catch (e: any) {
socket.send({ type: WebSocketMessageType.ERROR, data: e.message }); socket.send({ type: WebSocketMessageType.ERROR, data: e.message });
throw e; throw e;
@@ -174,29 +213,27 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
break; break;
case WebSocketMessageType.JOIN_ROOM: case WebSocketMessageType.JOIN_ROOM:
if (!message.roomId) { if (!message.roomId) {
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' }); socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE });
return; return;
} }
if (rooms.get(message.roomId) == undefined) { 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; return;
} }
room = await joinRoom(message.roomId, socket); room = await joinRoom(message.roomId, socket);
if (!room) return; 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; break;
case WebSocketMessageType.LEAVE_ROOM: case WebSocketMessageType.LEAVE_ROOM:
if (!message.roomId) { if (!message.roomId) {
socket.send({ type: WebSocketMessageType.ERROR, data: 'Invalid message' }); socket.send({ type: WebSocketMessageType.ERROR, data: errors.MALFORMED_MESSAGE });
return; return;
} }
if (rooms.get(message.roomId) == undefined) { 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; return;
} }
@@ -220,7 +257,7 @@ export function confgiureWebsocketServer(wss: WebSocketServer) {
break; break;
default: default:
console.warn(`Unknown message type: ${message.type}`); 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; break;
} }
}); });

View File

@@ -1,9 +1,51 @@
@import 'tailwindcss'; @import 'tailwindcss';
body, html { @font-face {
@apply bg-neutral-950 text-white font-sans min-h-screen; 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 { 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"> <script lang="ts">
import { derived, writable, type Writable } from "svelte/store"; import { derived, writable, type Writable } from "svelte/store";
// import { room } from "../stores/roomStore"; import { WebsocketConnectionState, ws } from "../stores/websocketStore";
import { webSocketConnected, ws } from "../stores/websocketStore";
import { import {
isRTCConnected, isRTCConnected,
dataChannelReady, dataChannelReady,
@@ -15,7 +14,7 @@
receivedOffers, receivedOffers,
} from "../stores/messageStore"; } from "../stores/messageStore";
import { WebRTCPacketType } from "../types/webrtc"; 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 { MessageType } from "../types/message";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { WebBuffer } from "../utils/buffer"; import { WebBuffer } from "../utils/buffer";
@@ -26,7 +25,7 @@
let initialConnectionCompleteCount = writable(0); let initialConnectionCompleteCount = writable(0);
let initialConnectionComplete = derived( let initialConnectionComplete = derived(
initialConnectionCompleteCount, initialConnectionCompleteCount,
(value) => value === 3, (value) => value >= 3,
); );
// TODO: is this the most elegant way to do this? // TODO: is this the most elegant way to do this?
@@ -224,21 +223,19 @@
<p> <p>
{$room?.id} {$room?.id}
({$room?.participants}) - {$room?.connectionState} - {$webSocketConnected} ({$room?.participants}) - {$room?.connectionState} - {$ws.status}
- Initial connection {$initialConnectionComplete - Initial connection {$initialConnectionComplete
? "complete" ? "complete"
: "incomplete"} : "incomplete"}
</p> </p>
<!-- If we are in a room, connected to the websocket server, and have been informed that we are connected to the room --> <!-- 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 <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 {#if !$initialConnectionComplete || $room.connectionState === RoomConnectionState.RECONNECTING || $room.participants !== 2 || $dataChannelReady === false || !$canCloseLoadingOverlay}
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}
<div <div
transition:fade={{ duration: 300 }} 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" 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> <p>Establishing data channel...</p>
{:else if !$keyExchangeDone} {:else if !$keyExchangeDone}
<p>Establishing a secure connection with the peer...</p> <p>Establishing a secure connection with the peer...</p>
{:else if $room.connectionState === ConnectionState.RECONNECTING} {:else if $room.connectionState === RoomConnectionState.RECONNECTING}
<p> <p>
Disconnect from peer, attempting to reconnecting... Disconnect from peer, attempting to reconnecting...
</p> </p>
{:else if $room.participants !== 2 || $dataChannelReady === false} {:else if $room.participants !== 2 || $dataChannelReady === false}
<p> <p>
Peer has disconnected, waiting for other peer to Peer has disconnected, waiting for other peer to
reconnect... <span>reconnect...</span>
</p> </p>
{:else} {:else}
<p> <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 Successfully established a secure connection to
peer! <span>peer!</span>
</p> </p>
{/if} {/if}
<div class="mt-2"> <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 --> <!-- loading spinner -->
<svg <svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
@@ -326,12 +324,12 @@
</p> </p>
{/if} {/if}
<div <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} {msg.data.fileName}
</h2> </h3>
<p class="text-sm"> <p class="text-sm text-paragraph-muted">
{msg.data.fileSize} bytes {msg.data.fileSize} bytes
</p> </p>
<!-- as the initiator, we cant send ourselves a file --> <!-- as the initiator, we cant send ourselves a file -->
@@ -339,7 +337,7 @@
<button <button
onclick={() => onclick={() =>
downloadFile(msg.data.id)} 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -373,12 +371,12 @@
/> />
<div class="flex gap-2 w-full flex-row"> <div class="flex gap-2 w-full flex-row">
<div <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} {#if $inputFile}
<div class="flex flex-row gap-2 p-2"> <div class="flex flex-row gap-2 p-2">
<div <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"> <div class="w-full flex justify-center">
<svg <svg
@@ -410,7 +408,7 @@
onclick={() => { onclick={() => {
$inputFile = null; $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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -429,11 +427,9 @@
</button> </button>
</div> </div>
</div> </div>
<hr class="border-gray-600" /> <hr class="border-[#2c3444]" />
{/if} {/if}
<div <div class="flex flex-row rounded">
class="flex flex-row focus-within:ring-2 focus-within:ring-blue-500 rounded"
>
<textarea <textarea
bind:value={$inputMessage} bind:value={$inputMessage}
cols="1" cols="1"
@@ -451,9 +447,9 @@
!$dataChannelReady || !$dataChannelReady ||
!$keyExchangeDone || !$keyExchangeDone ||
$room.connectionState === $room.connectionState ===
ConnectionState.RECONNECTING} RoomConnectionState.RECONNECTING}
placeholder="Type your message..." 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" focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed resize-none leading-8"
></textarea> ></textarea>
<div class="flex flex-row gap-2 p-2 h-fit mt-auto"> <div class="flex flex-row gap-2 p-2 h-fit mt-auto">
@@ -463,9 +459,9 @@
!$dataChannelReady || !$dataChannelReady ||
!$keyExchangeDone || !$keyExchangeDone ||
$room.connectionState === $room.connectionState ===
ConnectionState.RECONNECTING} RoomConnectionState.RECONNECTING}
aria-label="Pick file" 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -488,8 +484,8 @@
!$dataChannelReady || !$dataChannelReady ||
!$keyExchangeDone || !$keyExchangeDone ||
$room.connectionState === $room.connectionState ===
ConnectionState.RECONNECTING} RoomConnectionState.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" 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -512,9 +508,3 @@
</div> </div>
</div> </div>
{/if} {/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 { ws } from '../stores/websocketStore';
import { WebSocketMessageType } from '../types/websocket'; import { WebSocketMessageType } from '../types/websocket';
import { WebRTCPacketType, type WebRTCPeerCallbacks } from '../types/webrtc'; import { WebRTCPacketType, type WebRTCPeerCallbacks } from '../types/webrtc';
import { browser } from '$app/environment'; 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 { export class WebRTCPeer {
private peer: RTCPeerConnection | null = null; private peer: RTCPeerConnection | null = null;
@@ -35,7 +34,7 @@ export class WebRTCPeer {
} }
private sendIceCandidate(candidate: RTCIceCandidate) { private sendIceCandidate(candidate: RTCIceCandidate) {
get(ws).send({ ws.send({
type: WebSocketMessageType.WEBRTC_ICE_CANDIDATE, type: WebSocketMessageType.WEBRTC_ICE_CANDIDATE,
data: { data: {
roomId: this.roomId, roomId: this.roomId,
@@ -261,7 +260,7 @@ export class WebRTCPeer {
await this.peer.setLocalDescription(offer) await this.peer.setLocalDescription(offer)
get(ws).send({ ws.send({
type: WebSocketMessageType.WEBRTC_OFFER, type: WebSocketMessageType.WEBRTC_OFFER,
data: { data: {
roomId: this.roomId, roomId: this.roomId,
@@ -295,7 +294,7 @@ export class WebRTCPeer {
console.log("Sending answer", answer); console.log("Sending answer", answer);
get(ws).send({ ws.send({
type: WebSocketMessageType.WERTC_ANSWER, type: WebSocketMessageType.WERTC_ANSWER,
data: { data: {
roomId: this.roomId, roomId: this.roomId,

View File

@@ -1,12 +1,40 @@
<script lang="ts"> <script lang="ts">
import "../app.css"; import "../app.css";
import favicon from "$lib/assets/favicon.svg"; 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(); let { children } = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<link
rel="preload"
as="font"
type="font/woff2"
crossorigin="anonymous"
href="/fonts/InstrumentSans-VariableFont_wdth,wght.woff2"
/>
<script <script
src="https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js" src="https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js"
></script> ></script>
@@ -15,4 +43,23 @@
></script> ></script>
</svelte:head> </svelte:head>
<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?.()} {@render children?.()}
</main>

View File

@@ -1,62 +1,265 @@
<script lang="ts"> <script lang="ts">
import { ws, webSocketConnected } from "../stores/websocketStore"; import { ws } from "../stores/websocketStore";
import { WebSocketMessageType } from "../types/websocket"; import { WebSocketMessageType } from "../types/websocket";
import { room } from "../stores/roomStore"; import { writable, type Writable } from "svelte/store";
import { browser } from "$app/environment"; import LoadingSpinner from "../components/LoadingSpinner.svelte";
import { peer, handleMessage } from "../utils/webrtcUtil";
import { onDestroy, onMount } from "svelte";
import RtcMessage from "../components/RTCMessage.svelte";
import { ConnectionState } from "../types/websocket";
onMount(async () => { let roomName: Writable<string> = writable("");
$ws.addEventListener("message", handleMessage); let roomLoading: Writable<boolean> = writable(false);
});
onDestroy(() => { function createRoom() {
if ($ws) { roomLoading.set(true);
room.update((room) => ({ let roomId = $roomName.trim() === "" ? undefined : $roomName.trim();
...room,
connectionState: ConnectionState.DISCONNECTED, ws.send({
})); type: WebSocketMessageType.CREATE_ROOM,
$ws.removeEventListener("message", handleMessage); roomName: roomId,
}
if ($peer) {
$peer.close();
}
}); });
// todo: redirect to the room
console.log("Created room:", roomId);
}
let showRoomNameInput: Writable<boolean> = writable(false);
</script> </script>
<div class="p-4"> <section class="py-20 text-center">
<h1>Welcome to Wormhole!</h1> <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 <button
onclick={() => { onclick={createRoom}
// if we are in a room already, leave it 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 ($room.id) { >
$ws.send({ {#if $roomLoading}
type: WebSocketMessageType.LEAVE_ROOM, <span class="flex items-center"
roomId: $room.id, ><LoadingSpinner /> Creating Room...</span
});
$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
> >
{:else} {: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} {/if}
</button>
{#if $room.id && browser} <div
<p>Room created!</p> class="{$showRoomNameInput
<p>Share this link with your friend:</p> ? 'max-h-32'
<a href={`${location.origin}/${$room}`}>{location.origin}/{$room.id}</a> : 'max-h-0 opacity-0'} overflow-hidden transition-[max-height,_opacity] duration-700"
{/if} >
<label
<RtcMessage {room} /> 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> </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>
<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>
<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"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onMount } from "svelte";
import { room } from "../../stores/roomStore"; import { room } from "../../stores/roomStore";
import { error, handleMessage, peer } from "../../utils/webrtcUtil"; import { ws } from "../../stores/websocketStore";
import { ws, webSocketConnected } from "../../stores/websocketStore";
import { WebSocketMessageType } from "../../types/websocket"; import { WebSocketMessageType } from "../../types/websocket";
import { dataChannelReady, error } from "../../utils/webrtcUtil";
import { goto } from "$app/navigation";
import RtcMessage from "../../components/RTCMessage.svelte"; 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 }; export let data: { roomId: string };
const { roomId } = data; const { roomId } = data;
onMount(async () => { onMount(() => {
room.update((room) => ({ ...room, id: roomId })); error.set(null);
$ws.addEventListener("message", handleMessage); roomLink = `${window.location.origin}/${roomId}`;
});
webSocketConnected.subscribe((value) => { function handleCopyLink() {
if (value) { navigator.clipboard.writeText(roomLink).then(() => {
room.update((room) => ({ copyButtonText = "Copied!";
...room, setTimeout(() => {
connectionState: ConnectionState.CONNECTING, copyButtonText = "Copy Link";
})); }, 2000);
});
if ($room.id === null) {
throw new Error("Room ID not set");
} }
$ws.send({ function handleConfirmJoin() {
awaitingJoinConfirmation = false;
ws.send({
type: WebSocketMessageType.JOIN_ROOM, type: WebSocketMessageType.JOIN_ROOM,
roomId: $room.id, roomId: roomId,
}); });
} }
});
});
onDestroy(() => { function handleDeclineJoin() {
if ($ws) { // In a real app, this would close the connection and maybe redirect
room.update((room) => ({ alert("You have declined to join the room.");
...room, awaitingJoinConfirmation = false; // Hides the prompt
connectionState: ConnectionState.DISCONNECTED, goto("/");
})); }
$ws.close();
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> </script>
<div class="p-4"> <div class="max-w-6xl px-5 mx-auto flex flex-col items-center">
{#if $error} {#if $error}
<p>Hm. Something went wrong: {$error.toLocaleLowerCase()}</p> <h2 class="text-3xl font-bold text-white mb-2">
{:else if $room.connectionState !== ConnectionState.CONNECTED && $room.connectionState !== ConnectionState.RECONNECTING} Something went wrong: {$error.toLocaleLowerCase()}
<p>Connecting to server...</p> </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} {:else}
<RtcMessage {room} /> <RtcMessage {room} />
{/if} {/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> </div>

View File

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

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 { browser } from '$app/environment';
import { room } from './roomStore'; import { Socket, type WebSocketMessage } from '../types/websocket';
import { ConnectionState, Socket, WebSocketMessageType } from '../types/websocket'; import { handleMessage } from '../utils/webrtcUtil';
let socket: Socket | null = null; export enum WebsocketConnectionState {
export const webSocketConnected = writable(false); DISCONNECTED,
CONNECTING,
CONNECTED,
RECONNECTING
}
function createSocket(): Socket { 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) { 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 return;
// @ts-ignore
return null;
} }
if (socket) { const currentState = get({ subscribe });
return socket; 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 protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new Socket(new WebSocket(`${protocol}//${location.host}/`)); const socket = new Socket(new WebSocket(`${protocol}//${location.host}/`));
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
webSocketConnected.set(true);
console.log('Connected to websocket server'); console.log('Connected to websocket server');
reconnectAttempts = 0;
update(s => ({ ...s, status: WebsocketConnectionState.CONNECTED, socket }));
}); });
socket.addEventListener('message', messageHandler);
socket.addEventListener('close', () => { socket.addEventListener('close', () => {
// TODO: massively rework the reconnection logic, currently it only works if one client disconnects, if the console.log('Disconnected from websocket server,');
// TODO: other client disconnects after the other client has diconnected at least once, everything explodes update(s => ({ ...s, socket: null }));
if (get(webSocketConnected) && get(room)?.connectionState === ConnectionState.CONNECTED) {
room.update((room) => ({ ...room, connectionState: ConnectionState.RECONNECTING }));
setTimeout(() => { // exponential backoff
ws.set(createSocket()); const timeout = Math.min(Math.pow(2, reconnectAttempts) * 1000, 30000);
reconnectAttempts++;
// attempt to rejoin the room if we were previously connected console.log(`Reconnecting in ${timeout / 1000}s...`);
get(ws).addEventListener('open', () => { update(s => ({ ...s, status: WebsocketConnectionState.RECONNECTING }));
let oldRoomId = get(room)?.id;
if (oldRoomId) { reconnectTimeout = setTimeout(() => {
get(ws).send({ type: WebSocketMessageType.JOIN_ROOM, roomId: oldRoomId }); connect();
room.update((room) => ({ ...room, connectionState: ConnectionState.CONNECTED })); }, timeout);
}
});
}, 1000);
}
webSocketConnected.set(false);
socket = null;
console.log('Disconnected from websocket server, reconnecting...');
}); });
return socket; 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 = writable(createSocket()); export const ws = createWebSocketStore(handleMessage);

View File

@@ -1,4 +1,4 @@
export enum ConnectionState { export enum RoomConnectionState {
CONNECTING, CONNECTING,
RECONNECTING, RECONNECTING,
CONNECTED, CONNECTED,
@@ -8,7 +8,7 @@ export enum ConnectionState {
export interface Room { export interface Room {
id: string | null; id: string | null;
participants: number; participants: number;
connectionState: ConnectionState; connectionState: RoomConnectionState;
} }
export enum WebSocketMessageType { export enum WebSocketMessageType {
@@ -51,6 +51,7 @@ interface ErrorMessage {
interface CreateRoomMessage { interface CreateRoomMessage {
type: WebSocketMessageType.CREATE_ROOM; type: WebSocketMessageType.CREATE_ROOM;
roomName?: string;
} }
interface JoinRoomMessage { interface JoinRoomMessage {
@@ -129,11 +130,16 @@ export class Socket {
console.log("WebSocket opened"); console.log("WebSocket opened");
}); });
this.addEventListener = this.ws.addEventListener.bind(this.ws); this.addEventListener = this.ws.addEventListener.bind(this.ws);
this.removeEventListener = this.ws.removeEventListener.bind(this.ws); this.removeEventListener = this.ws.removeEventListener.bind(this.ws);
this.close = this.ws.close.bind(this.ws); this.close = this.ws.close.bind(this.ws);
} }
get readyState(): number {
return this.ws.readyState;
}
public send(message: WebSocketMessage) { public send(message: WebSocketMessage) {
console.log("Sending message:", message); 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 { WebRTCPeer } from "$lib/webrtc";
import { CHUNK_SIZE, WebRTCPacketType } from "../types/webrtc"; import { CHUNK_SIZE, WebRTCPacketType } from "../types/webrtc";
import { room } from "../stores/roomStore"; 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 { advertisedOffers, fileRequestIds, messages, receivedOffers } from "../stores/messageStore";
import { MessageType, type Message } from "../types/message"; import { MessageType, type Message } from "../types/message";
import { WebSocketMessageType, type WebSocketMessage } from "../types/websocket"; import { WebSocketMessageType, type WebSocketMessage } from "../types/websocket";
import { WebBuffer } from "./buffer"; import { WebBuffer } from "./buffer";
import { goto } from "$app/navigation";
export const error: Writable<string | null> = writable(null); export const error: Writable<string | null> = writable(null);
export let peer: Writable<WebRTCPeer | null> = writable(null); export let peer: Writable<WebRTCPeer | null> = writable(null);
@@ -255,7 +256,8 @@ export async function handleMessage(event: MessageEvent) {
switch (message.type) { switch (message.type) {
case WebSocketMessageType.ROOM_CREATED: case WebSocketMessageType.ROOM_CREATED:
console.log("Room created:", message.data); 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; return;
case WebSocketMessageType.JOIN_ROOM: case WebSocketMessageType.JOIN_ROOM:
console.log("new client joined room"); console.log("new client joined room");
@@ -263,7 +265,8 @@ export async function handleMessage(event: MessageEvent) {
return; return;
case WebSocketMessageType.ROOM_JOINED: case WebSocketMessageType.ROOM_JOINED:
// TODO: if a client disconnects, we need to resync the room state // 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"); console.log("Joined room");
return; return;
case WebSocketMessageType.ROOM_LEFT: case WebSocketMessageType.ROOM_LEFT:
@@ -282,6 +285,8 @@ export async function handleMessage(event: MessageEvent) {
return; return;
} }
room.update(r => ({ ...r, RTCConnectionReady: true }));
console.log("Creating peer"); console.log("Creating peer");
peer.set(new WebRTCPeer( peer.set(new WebRTCPeer(
roomId, roomId,