From c97df03ca15e78ca7163415f4732d26f47d35549 Mon Sep 17 00:00:00 2001 From: Satsuki Akiba Date: Fri, 21 Jul 2023 20:51:44 +0900 Subject: [PATCH] [WIP] Update around webrtc. --- src/Components/Excalidraw/RightTopUI.tsx | 4 +- .../Excalidraw/Sidebar/Collaboration.tsx | 77 ++++++++- src/utilities/clipboard.ts | 3 + src/utilities/url.ts | 25 +++ src/webrtc/collaboration.ts | 28 ++++ src/webrtc/connection.ts | 148 ++++++++++++++++++ src/webrtc/utilities.ts | 24 +++ 7 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 src/utilities/clipboard.ts create mode 100644 src/utilities/url.ts create mode 100644 src/webrtc/collaboration.ts create mode 100644 src/webrtc/connection.ts create mode 100644 src/webrtc/utilities.ts diff --git a/src/Components/Excalidraw/RightTopUI.tsx b/src/Components/Excalidraw/RightTopUI.tsx index c15014c..a0b5e62 100644 --- a/src/Components/Excalidraw/RightTopUI.tsx +++ b/src/Components/Excalidraw/RightTopUI.tsx @@ -5,13 +5,13 @@ import { SidebarVariant, SidebarVariantSchema } from './Sidebar'; import { z } from 'zod'; import { LiveCollaborationTrigger } from '@excalidraw/excalidraw'; -export const RightTopUIPropsScheme = z.object({ +export const RightTopUIPropsSchema = z.object({ setSidebarVariant: z.function().args(SidebarVariantSchema).returns(z.void()), setToggleState: z.function().args(z.boolean()).returns(z.void()), toggleState: z.boolean(), }); -export type RightTopUIProps = z.infer & { +export type RightTopUIProps = z.infer & { excalidrawAPI: ExcalidrawImperativeAPI; }; diff --git a/src/Components/Excalidraw/Sidebar/Collaboration.tsx b/src/Components/Excalidraw/Sidebar/Collaboration.tsx index fa67f86..2e75b94 100644 --- a/src/Components/Excalidraw/Sidebar/Collaboration.tsx +++ b/src/Components/Excalidraw/Sidebar/Collaboration.tsx @@ -1,7 +1,16 @@ -import { FC } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; import { Button, SidebarHeader } from '@/Components/Utilities'; import { z } from 'zod'; import SidebarProviderBase, { SidebarBasePropsSchema } from './Base'; +import { startCollaboration } from '@/webrtc/collaboration'; +import { + SignalingData, + decodeSignalingData, + encodeSignalingData, +} from '@/webrtc/utilities'; +import { Input, Link } from '@mui/material'; +import { fromUrl, toUrl } from '@/utilities/url'; +import { copyToClipboard } from '@/utilities/clipboard'; export type CollaborationSidebarProps = z.infer< typeof CollaborationSidebarPropsSchema @@ -12,10 +21,72 @@ export const CollaborationSidebarPropsSchema = z .merge(SidebarBasePropsSchema); const CollaborationSidebar: FC = (props) => { + const signalingDataCallback = useCallback((data: SignalingData) => { + const encodedData = encodeSignalingData(data); + const url = toUrl({ signalingData: encodedData }); + + setCollaborationUrl(url); + + console.log(data); + }, []); + + const [collaborationUrl, setCollaborationUrl] = useState(null); + const [isCollaborationStarted, setIsCollaborationStarted] = useState(false); + const [providedCollaborationUrl, setProvidedCollaborationUrl] = + useState(null); + + const onMessage = useCallback((event: MessageEvent) => { + console.log(event); + }, []); + + const startCollaborationWrapper = useCallback(async () => { + console.log('start collaboration'); + + const signalingData = providedCollaborationUrl + ? decodeSignalingData(fromUrl(providedCollaborationUrl).signalingData!) + : undefined; + + console.log(signalingData); + + await startCollaboration(signalingData, signalingDataCallback, { + onMessage, + }); + }, [signalingDataCallback, onMessage, providedCollaborationUrl]); + + const copyCollaborationUrl = useCallback(async () => { + console.log('copy collaboration url'); + console.log(collaborationUrl?.toString()); + await copyToClipboard(collaborationUrl ? collaborationUrl?.toString() : ''); + }, [collaborationUrl]); + + useEffect(() => { + if (isCollaborationStarted || providedCollaborationUrl) { + startCollaborationWrapper().catch((error) => console.error(error)); + } + }, [ + isCollaborationStarted, + startCollaborationWrapper, + providedCollaborationUrl, + ]); + return ( - Collaboration Settings - + Collaboration + + {collaborationUrl?.toString()} + + setProvidedCollaborationUrl(new URL(e.target.value))} + > ); }; diff --git a/src/utilities/clipboard.ts b/src/utilities/clipboard.ts new file mode 100644 index 0000000..7225f31 --- /dev/null +++ b/src/utilities/clipboard.ts @@ -0,0 +1,3 @@ +export const copyToClipboard = async (text: string) => { + await navigator.clipboard.writeText(text); +}; diff --git a/src/utilities/url.ts b/src/utilities/url.ts new file mode 100644 index 0000000..0171921 --- /dev/null +++ b/src/utilities/url.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const AvailableURLParametersSchema = z.object({ + signalingData: z.string().optional(), +}); + +export type AvailableURLParameters = z.infer< + typeof AvailableURLParametersSchema +>; + +export const toUrl = (params: AvailableURLParameters) => { + const url = new URL(window.location.href); + + Object.entries(params).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + + return url; +}; + +export const fromUrl = (url: URL): AvailableURLParameters => { + const params = Object.fromEntries(url.searchParams.entries()); + + return AvailableURLParametersSchema.parse(params); +}; diff --git a/src/webrtc/collaboration.ts b/src/webrtc/collaboration.ts new file mode 100644 index 0000000..32a782d --- /dev/null +++ b/src/webrtc/collaboration.ts @@ -0,0 +1,28 @@ +import establishConnection from './connection'; +import type { Connection, DataChannelCallbacks } from './connection'; +import type { SignalingData } from './utilities'; + +export type SignalingDataCallback = (signalingData: SignalingData) => void; + +export type OnAnswerCallback = (answer: RTCSessionDescription) => void; +export type OnOfferCallback = (offer: RTCSessionDescription) => void; + +export const startCollaboration = async ( + signalingData: SignalingData | undefined, + onSignalingData: SignalingDataCallback, + dataChannelCallbacks: DataChannelCallbacks +): Promise => { + let connection: Connection = { + connectionState: 'noConnection', + dataChannelCallbacks, + signalingData, + }; + + while (connection?.connectionState !== 'established') { + connection = await establishConnection(connection); + + if (connection.signalingData) { + onSignalingData(connection.signalingData); + } + } +}; diff --git a/src/webrtc/connection.ts b/src/webrtc/connection.ts new file mode 100644 index 0000000..4b2a89e --- /dev/null +++ b/src/webrtc/connection.ts @@ -0,0 +1,148 @@ +import { match } from 'ts-pattern'; +import { SignalingDataType, SignalingData } from './utilities'; +import { z } from 'zod'; + +export const PeerConfiguration: RTCConfiguration = { + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], +}; + +export const EstablishingConnectionStateSchema = z.enum([ + 'noConnection', + 'waiting', + 'established', +]); + +export type EstablishingConnectionState = z.infer< + typeof EstablishingConnectionStateSchema +>; + +export type Connection = { + connectionState: EstablishingConnectionState; + signalingData?: SignalingData; + dataChannelCallbacks?: DataChannelCallbacks; +}; + +export const establishConnection = async ({ + connectionState, + signalingData, + dataChannelCallbacks, +}: Connection): Promise => { + const peerConnection = createPeerConnection(); + + return await match(connectionState) + .with('noConnection', async () => { + const _signalingData = await createOffer(peerConnection); + + return { + connectionState: 'waiting', + signalingData: _signalingData, + dataChannelCallbacks, + } as Connection; + }) + .with('waiting', async () => { + if (!dataChannelCallbacks) { + throw new Error('Data channel callbacks are not set'); + } + if (!signalingData) { + throw new Error('Signaling data is not set'); + } + + await peerConnection.setRemoteDescription(signalingData.sdp); + + const connection: Connection = await match(signalingData.type) + .with('offer', async () => { + const answer = await createAnswer(peerConnection); + return { + connectionState: 'waiting', + signalingData: answer, + dataChannelCallbacks, + } as Connection; + }) + .with('answer', () => { + return { + connectionState: 'established', + signalingData: undefined, + dataChannelCallbacks, + } as Connection; + }) + .exhaustive(); + + dataChannelHandler( + signalingData.type, + peerConnection, + dataChannelCallbacks + ); + + return connection; + }) + .with('established', () => { + throw new Error( + "Connection can't be established because it is already established" + ); + }) + .exhaustive(); +}; + +export default establishConnection; + +export const createPeerConnection = () => { + const configuration = PeerConfiguration; + + return new RTCPeerConnection(configuration); +}; + +export const createOffer = async (peerConnection: RTCPeerConnection) => { + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + + if (peerConnection.localDescription) { + const signalingData: SignalingData = { + type: 'offer', + sdp: peerConnection.localDescription, + }; + + return signalingData; + } else { + throw new Error('Local description is not set'); + } +}; + +export type DataChannelCallbacks = { + onMessage: (event: MessageEvent) => void; +}; + +export const dataChannelHandler = ( + type: SignalingDataType, + peerConnection: RTCPeerConnection, + DataChannelCallbacks: DataChannelCallbacks +) => { + const { onMessage } = DataChannelCallbacks; + + match(type) + .with('offer', () => { + const dataChannel = peerConnection.createDataChannel('dataChannel'); + dataChannel.onmessage = onMessage; + }) + .with('answer', () => { + peerConnection.ondatachannel = (event) => { + event.channel.onmessage = onMessage; + }; + }) + .exhaustive(); +}; + +export const createAnswer = async (peerConnection: RTCPeerConnection) => { + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + + if (peerConnection.localDescription) { + const signalingData: SignalingData = { + type: 'answer', + sdp: peerConnection.localDescription, + }; + + return signalingData; + } else { + throw new Error('Local description is not set'); + } +}; diff --git a/src/webrtc/utilities.ts b/src/webrtc/utilities.ts new file mode 100644 index 0000000..074efb1 --- /dev/null +++ b/src/webrtc/utilities.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +export const encodeSignalingData = ( + data: SignalingData +): EncodedSignalingData => { + return encodeURIComponent(JSON.stringify(data)); +}; + +export const decodeSignalingData = (encodedData: string) => { + return JSON.parse(decodeURIComponent(encodedData)) as SignalingData; +}; + +export const SignanlingDataTypeSchema = z.enum(['offer', 'answer']); + +export type SignalingDataType = z.infer; + +export type SignalingData = { + type: SignalingDataType; + sdp: RTCSessionDescription; +}; + +export const EncodedSignalingDataSchema = z.string(); + +export type EncodedSignalingData = z.infer;