actual UI, tons of bug fixes, and rename
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
48
src/app.css
48
src/app.css
@@ -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;
|
||||
}
|
||||
20
src/components/LoadingSpinner.svelte
Normal file
20
src/components/LoadingSpinner.svelte
Normal 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 |
@@ -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}
|
||||
<div
|
||||
class="flex flex-col sm:max-w-4/5 lg:max-w-3/5 min-h-[calc(5/12_*_100vh)]"
|
||||
>
|
||||
{#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-grow flex flex-col overflow-y-auto mb-4 p-2 bg-gray-800 rounded relative whitespace-break-spaces wrap-anywhere"
|
||||
class="flex-grow flex flex-col overflow-y-auto mb-4 p-2 bg-surface 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
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
<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
|
||||
<div
|
||||
class="bg-surface p-10 rounded-xl max-w-xl shadow-xl border border-[#21293b] mt-10 mr-auto ml-auto w-full"
|
||||
>
|
||||
{:else}
|
||||
<p>Connecting to server...</p>
|
||||
{/if}
|
||||
<form class="flex flex-col gap-5" id="roomForm">
|
||||
<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}
|
||||
<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>
|
||||
© {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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
$ws.send({
|
||||
type: WebSocketMessageType.JOIN_ROOM,
|
||||
roomId: $room.id,
|
||||
});
|
||||
}
|
||||
function handleCopyLink() {
|
||||
navigator.clipboard.writeText(roomLink).then(() => {
|
||||
copyButtonText = "Copied!";
|
||||
setTimeout(() => {
|
||||
copyButtonText = "Copy Link";
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($ws) {
|
||||
room.update((room) => ({
|
||||
...room,
|
||||
connectionState: ConnectionState.DISCONNECTED,
|
||||
}));
|
||||
$ws.close();
|
||||
function handleConfirmJoin() {
|
||||
awaitingJoinConfirmation = false;
|
||||
|
||||
ws.send({
|
||||
type: WebSocketMessageType.JOIN_ROOM,
|
||||
roomId: roomId,
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
{:else}
|
||||
<RtcMessage {room} />
|
||||
<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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
static/fonts/InstrumentSans-VariableFont_wdth,wght.woff2
Normal file
BIN
static/fonts/InstrumentSans-VariableFont_wdth,wght.woff2
Normal file
Binary file not shown.
Reference in New Issue
Block a user