From d642928ee7c7b2599138c1f9f419c5b77fb2f5cc Mon Sep 17 00:00:00 2001
From: Sjoerd <sjoerddal108@gmail.com>
Date: Sat, 5 Dec 2020 22:58:21 +0100
Subject: [PATCH 1/8] Support TURN server and custom STUN server

---
 src/renderer/App.tsx          |   6 +-
 src/renderer/Settings.tsx     | 111 +++++++++++++++++++++++++++++++---
 src/renderer/Voice.tsx        |  27 +++++++--
 src/renderer/css/settings.css |  21 ++++++-
 4 files changed, 148 insertions(+), 17 deletions(-)

diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index c572eee9..a0d2c1e4 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -39,7 +39,11 @@ function App() {
 			data: ''
 		},
 		hideCode: false,
-		stereoInLobby: true
+		stereoInLobby: true,
+		stunServerURL: 'stun:stun.l.google.com:19302',
+		turnServerURL: null,
+		turnUsername: null,
+		turnPassword: null
 	});
 
 	useEffect(() => {
diff --git a/src/renderer/Settings.tsx b/src/renderer/Settings.tsx
index 379ef587..a02872dc 100644
--- a/src/renderer/Settings.tsx
+++ b/src/renderer/Settings.tsx
@@ -1,5 +1,5 @@
 import Store from 'electron-store';
-import React, { useContext, useEffect, useReducer, useState } from "react";
+import React, { SVGProps, useContext, useEffect, useReducer, useState } from "react";
 import { SettingsContext } from "./App";
 import Ajv from 'ajv';
 import './css/settings.css';
@@ -83,6 +83,24 @@ const store = new Store<ISettings>({
 		stereoInLobby: {
 			type: 'boolean',
 			default: true
+		},
+		stunServerURL: {
+			type: ['string', 'null'],
+			format: 'uri',
+			default: 'stun:stun.l.google.com:19302'
+		},
+		turnServerURL: {
+			type: ['string', 'null'],
+			format: 'uri',
+			default: null
+		},
+		turnUsername: {
+			type: ['string', 'null'],
+			default: null
+		},
+		turnPassword: {
+			type: ['string', 'null'],
+			default: null
 		}
 	}
 });
@@ -106,6 +124,10 @@ export interface ISettings {
 	},
 	hideCode: boolean;
 	stereoInLobby: boolean;
+	stunServerURL: string | null;
+	turnServerURL: string | null;
+	turnUsername: string | null;
+	turnPassword: string | null;
 }
 export const settingsReducer = (state: ISettings, action: {
 	type: 'set' | 'setOne', action: [string, any] | ISettings
@@ -152,6 +174,32 @@ function URLInput({ initialURL, onValidURL }: URLInputProps) {
 	return <input className={isValidURL ? '' : 'input-error'} spellCheck={false} type="text" value={currentURL} onChange={onChange} />
 }
 
+function Arrow(props: SVGProps<SVGSVGElement>) {
+	return <svg viewBox="0 0 24 24" fill="#868686" width="20px" height="20px" {...props}>
+		<path d="M0 0h24v24H0z" fill="none" />
+		<path d="M11.67 3.87L9.9 2.1 0 12l9.9 9.9 1.77-1.77L3.54 12z" />
+	</svg>
+}
+
+type CollapsibleSettingsProps = {
+	entryName: string,
+	collapsibleContent: JSX.Element
+};
+
+function CollapsibleSettings({ collapsibleContent, entryName }: CollapsibleSettingsProps) {
+	const [collapsed, setCollapsed] = useState(true);
+
+	return <div className="collapsible-content-wrapper">
+		<label className="clickable" onClick={() => setCollapsed(!collapsed)}>
+			<Arrow className="inline-icon" transform={collapsed ? 'rotate(180)' : 'rotate(-90)'} width="16" height="16" />
+			{entryName}
+		</label>
+		<div className="collapsible-content">
+			{collapsed ? '' : collapsibleContent}
+		</div>
+	</div>;
+}
+
 export default function Settings({ open, onClose }: SettingsProps) {
 	const [settings, setSettings] = useContext(SettingsContext);
 	const [unsavedCount, setUnsavedCount] = useState(0);
@@ -165,7 +213,15 @@ export default function Settings({ open, onClose }: SettingsProps) {
 
 	useEffect(() => {
 		setUnsavedCount(s => s + 1);
-	}, [settings.microphone, settings.speaker, settings.serverURL]);
+	}, [
+		settings.microphone,
+		settings.speaker,
+		settings.serverURL,
+		settings.stunServerURL,
+		settings.turnServerURL,
+		settings.turnUsername,
+		settings.turnPassword
+	]);
 
 	const [devices, setDevices] = useState<MediaDevice[]>([]);
 	const [_, updateDevices] = useReducer((state) => state + 1, 0);
@@ -213,17 +269,14 @@ export default function Settings({ open, onClose }: SettingsProps) {
 	const speakers = devices.filter(d => d.kind === 'audiooutput');
 
 	return <div id="settings" style={{ transform: open ? 'translateX(0)' : 'translateX(-100%)' }}>
-		<svg className="titlebar-button back" viewBox="0 0 24 24" fill="#868686" width="20px" height="20px" onClick={() => {
+		<Arrow className="titlebar-button back" onClick={() => {
 			if (unsaved) {
 				onClose();
 				location.reload();
-			}
-			else
+			} else {
 				onClose();
-		}}>
-			<path d="M0 0h24v24H0z" fill="none" />
-			<path d="M11.67 3.87L9.9 2.1 0 12l9.9 9.9 1.77-1.77L3.54 12z" />
-		</svg>
+			}
+		}} />
 		{/* <div className="form-control m" style={{ color: '#e74c3c' }} onClick={() => {
 			ipcRenderer.send('alwaysOnTop', !settings.alwaysOnTop);
 			setSettings({
@@ -314,6 +367,46 @@ export default function Settings({ open, onClose }: SettingsProps) {
 				<input type="checkbox" checked={settings.stereoInLobby} style={{ color: '#fd79a8' }} readOnly />
 				<label>Stereo Audio in Lobbies</label>
 			</div>
+			<CollapsibleSettings entryName="Advanced Settings" collapsibleContent={
+				<div>
+					<div className="form-control l m" style={{ color: '#3498db' }}>
+						<label>STUN Server</label>
+						<URLInput initialURL={settings.stunServerURL || ''} onValidURL={(url: string) => {
+							setSettings({
+								type: 'setOne',
+								action: ['stunServerURL', url !== '' ? url : null]
+							})
+						}} />
+					</div>
+					<div className="form-control l m" style={{ color: '#3498db' }}>
+						<label>TURN Server</label>
+						<URLInput initialURL={settings.turnServerURL || ''} onValidURL={(url: string) => {
+							setSettings({
+								type: 'setOne',
+								action: ['turnServerURL', url !== '' ? url : null]
+							})
+						}} />
+					</div>
+					<div className="form-control l m" style={{ color: '#3498db' }}>
+						<label>TURN Username</label>
+						<input spellCheck={false} type="text" value={settings.turnUsername || ''} onChange={(e) => {
+							setSettings({
+								type: 'setOne',
+								action: ['turnUsername', e.target.value !== '' ? e.target.value : null]
+							})
+						}} />
+					</div>
+					<div className="form-control l m" style={{ color: '#3498db' }}>
+						<label>TURN Password</label>
+						<input spellCheck={false} type="password" value={settings.turnPassword || ''} onChange={(e) => {
+							setSettings({
+								type: 'setOne',
+								action: ['turnPassword', e.target.value !== '' ? e.target.value : null]
+							})
+						}} />
+					</div>
+				</div>
+			} />
 		</div>
 	</div>
 }
\ No newline at end of file
diff --git a/src/renderer/Voice.tsx b/src/renderer/Voice.tsx
index 8c2a874e..2f1f966c 100644
--- a/src/renderer/Voice.tsx
+++ b/src/renderer/Voice.tsx
@@ -234,15 +234,30 @@ export default function Voice() {
 			setConnect({ connect });
 			function createPeerConnection(peer: string, initiator: boolean) {
 				// console.log("Opening connection to ", peer, "Initiator: ", initiator);
+				const iceServers = [];
+
+				if (settings.stunServerURL) {
+					iceServers.push({
+						urls: settings.stunServerURL
+					})
+				}
+
+				if (settings.turnServerURL && settings.turnUsername && settings.turnPassword) {
+					iceServers.push({
+						urls: settings.turnServerURL,
+						username: settings.turnUsername,
+						credential: settings.turnPassword
+					})
+				}
+
 				const connection = new Peer({
-					stream, initiator, config: {
-						iceServers: [
-							{
-								'urls': 'stun:stun.l.google.com:19302'
-							}
-						]
+					stream,
+					initiator,
+					config: {
+						iceServers
 					}
 				});
+
 				peerConnections[peer] = connection;
 
 				connection.on('stream', (stream: MediaStream) => {
diff --git a/src/renderer/css/settings.css b/src/renderer/css/settings.css
index 6eb04eca..0e01315a 100644
--- a/src/renderer/css/settings.css
+++ b/src/renderer/css/settings.css
@@ -38,7 +38,7 @@
 	transform: translateX(-1px);
 }
 
-input[type="text"] {
+input[type="text"], input[type="password"] {
 	background: #1d1d1d;
 	border: 1px solid rgba(255, 255, 255, 0.5);
 	outline: none;
@@ -91,4 +91,23 @@ select {
 .test-speakers {
 	width: fit-content;
 	margin: 5px auto;
+}
+
+.inline-icon {
+	display: inline;
+	margin-right: 8px;
+	vertical-align: middle;
+}
+
+.clickable, .clickable label {
+	cursor: pointer;
+}
+
+.collapsible-content-wrapper {
+	width: 100%;
+	text-align: center;
+}
+
+.collapsible-content {
+	padding-top: 12px;
 }
\ No newline at end of file

From dd23ce66192b4c27b2fe510542ae7ea610933e22 Mon Sep 17 00:00:00 2001
From: Sjoerd <sjoerddal108@gmail.com>
Date: Sat, 5 Dec 2020 23:34:43 +0100
Subject: [PATCH 2/8] Small linting fix

---
 src/renderer/Voice.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/renderer/Voice.tsx b/src/renderer/Voice.tsx
index 2f1f966c..4a1456d1 100644
--- a/src/renderer/Voice.tsx
+++ b/src/renderer/Voice.tsx
@@ -239,7 +239,7 @@ export default function Voice() {
 				if (settings.stunServerURL) {
 					iceServers.push({
 						urls: settings.stunServerURL
-					})
+					});
 				}
 
 				if (settings.turnServerURL && settings.turnUsername && settings.turnPassword) {
@@ -247,7 +247,7 @@ export default function Voice() {
 						urls: settings.turnServerURL,
 						username: settings.turnUsername,
 						credential: settings.turnPassword
-					})
+					});
 				}
 
 				const connection = new Peer({

From 846628da2f9ecfe0452280a0a31f39cf8d6d2dad Mon Sep 17 00:00:00 2001
From: Sjoerd <sjoerddal108@gmail.com>
Date: Sun, 6 Dec 2020 19:40:15 +0100
Subject: [PATCH 3/8] Allow custom ICE config from server

---
 src/renderer/App.tsx          |   6 +-
 src/renderer/Settings.tsx     | 111 +++-------------------------------
 src/renderer/Voice.tsx        |  31 ++++------
 src/renderer/css/settings.css |   2 +-
 4 files changed, 23 insertions(+), 127 deletions(-)

diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index a0d2c1e4..c572eee9 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -39,11 +39,7 @@ function App() {
 			data: ''
 		},
 		hideCode: false,
-		stereoInLobby: true,
-		stunServerURL: 'stun:stun.l.google.com:19302',
-		turnServerURL: null,
-		turnUsername: null,
-		turnPassword: null
+		stereoInLobby: true
 	});
 
 	useEffect(() => {
diff --git a/src/renderer/Settings.tsx b/src/renderer/Settings.tsx
index a02872dc..379ef587 100644
--- a/src/renderer/Settings.tsx
+++ b/src/renderer/Settings.tsx
@@ -1,5 +1,5 @@
 import Store from 'electron-store';
-import React, { SVGProps, useContext, useEffect, useReducer, useState } from "react";
+import React, { useContext, useEffect, useReducer, useState } from "react";
 import { SettingsContext } from "./App";
 import Ajv from 'ajv';
 import './css/settings.css';
@@ -83,24 +83,6 @@ const store = new Store<ISettings>({
 		stereoInLobby: {
 			type: 'boolean',
 			default: true
-		},
-		stunServerURL: {
-			type: ['string', 'null'],
-			format: 'uri',
-			default: 'stun:stun.l.google.com:19302'
-		},
-		turnServerURL: {
-			type: ['string', 'null'],
-			format: 'uri',
-			default: null
-		},
-		turnUsername: {
-			type: ['string', 'null'],
-			default: null
-		},
-		turnPassword: {
-			type: ['string', 'null'],
-			default: null
 		}
 	}
 });
@@ -124,10 +106,6 @@ export interface ISettings {
 	},
 	hideCode: boolean;
 	stereoInLobby: boolean;
-	stunServerURL: string | null;
-	turnServerURL: string | null;
-	turnUsername: string | null;
-	turnPassword: string | null;
 }
 export const settingsReducer = (state: ISettings, action: {
 	type: 'set' | 'setOne', action: [string, any] | ISettings
@@ -174,32 +152,6 @@ function URLInput({ initialURL, onValidURL }: URLInputProps) {
 	return <input className={isValidURL ? '' : 'input-error'} spellCheck={false} type="text" value={currentURL} onChange={onChange} />
 }
 
-function Arrow(props: SVGProps<SVGSVGElement>) {
-	return <svg viewBox="0 0 24 24" fill="#868686" width="20px" height="20px" {...props}>
-		<path d="M0 0h24v24H0z" fill="none" />
-		<path d="M11.67 3.87L9.9 2.1 0 12l9.9 9.9 1.77-1.77L3.54 12z" />
-	</svg>
-}
-
-type CollapsibleSettingsProps = {
-	entryName: string,
-	collapsibleContent: JSX.Element
-};
-
-function CollapsibleSettings({ collapsibleContent, entryName }: CollapsibleSettingsProps) {
-	const [collapsed, setCollapsed] = useState(true);
-
-	return <div className="collapsible-content-wrapper">
-		<label className="clickable" onClick={() => setCollapsed(!collapsed)}>
-			<Arrow className="inline-icon" transform={collapsed ? 'rotate(180)' : 'rotate(-90)'} width="16" height="16" />
-			{entryName}
-		</label>
-		<div className="collapsible-content">
-			{collapsed ? '' : collapsibleContent}
-		</div>
-	</div>;
-}
-
 export default function Settings({ open, onClose }: SettingsProps) {
 	const [settings, setSettings] = useContext(SettingsContext);
 	const [unsavedCount, setUnsavedCount] = useState(0);
@@ -213,15 +165,7 @@ export default function Settings({ open, onClose }: SettingsProps) {
 
 	useEffect(() => {
 		setUnsavedCount(s => s + 1);
-	}, [
-		settings.microphone,
-		settings.speaker,
-		settings.serverURL,
-		settings.stunServerURL,
-		settings.turnServerURL,
-		settings.turnUsername,
-		settings.turnPassword
-	]);
+	}, [settings.microphone, settings.speaker, settings.serverURL]);
 
 	const [devices, setDevices] = useState<MediaDevice[]>([]);
 	const [_, updateDevices] = useReducer((state) => state + 1, 0);
@@ -269,14 +213,17 @@ export default function Settings({ open, onClose }: SettingsProps) {
 	const speakers = devices.filter(d => d.kind === 'audiooutput');
 
 	return <div id="settings" style={{ transform: open ? 'translateX(0)' : 'translateX(-100%)' }}>
-		<Arrow className="titlebar-button back" onClick={() => {
+		<svg className="titlebar-button back" viewBox="0 0 24 24" fill="#868686" width="20px" height="20px" onClick={() => {
 			if (unsaved) {
 				onClose();
 				location.reload();
-			} else {
-				onClose();
 			}
-		}} />
+			else
+				onClose();
+		}}>
+			<path d="M0 0h24v24H0z" fill="none" />
+			<path d="M11.67 3.87L9.9 2.1 0 12l9.9 9.9 1.77-1.77L3.54 12z" />
+		</svg>
 		{/* <div className="form-control m" style={{ color: '#e74c3c' }} onClick={() => {
 			ipcRenderer.send('alwaysOnTop', !settings.alwaysOnTop);
 			setSettings({
@@ -367,46 +314,6 @@ export default function Settings({ open, onClose }: SettingsProps) {
 				<input type="checkbox" checked={settings.stereoInLobby} style={{ color: '#fd79a8' }} readOnly />
 				<label>Stereo Audio in Lobbies</label>
 			</div>
-			<CollapsibleSettings entryName="Advanced Settings" collapsibleContent={
-				<div>
-					<div className="form-control l m" style={{ color: '#3498db' }}>
-						<label>STUN Server</label>
-						<URLInput initialURL={settings.stunServerURL || ''} onValidURL={(url: string) => {
-							setSettings({
-								type: 'setOne',
-								action: ['stunServerURL', url !== '' ? url : null]
-							})
-						}} />
-					</div>
-					<div className="form-control l m" style={{ color: '#3498db' }}>
-						<label>TURN Server</label>
-						<URLInput initialURL={settings.turnServerURL || ''} onValidURL={(url: string) => {
-							setSettings({
-								type: 'setOne',
-								action: ['turnServerURL', url !== '' ? url : null]
-							})
-						}} />
-					</div>
-					<div className="form-control l m" style={{ color: '#3498db' }}>
-						<label>TURN Username</label>
-						<input spellCheck={false} type="text" value={settings.turnUsername || ''} onChange={(e) => {
-							setSettings({
-								type: 'setOne',
-								action: ['turnUsername', e.target.value !== '' ? e.target.value : null]
-							})
-						}} />
-					</div>
-					<div className="form-control l m" style={{ color: '#3498db' }}>
-						<label>TURN Password</label>
-						<input spellCheck={false} type="password" value={settings.turnPassword || ''} onChange={(e) => {
-							setSettings({
-								type: 'setOne',
-								action: ['turnPassword', e.target.value !== '' ? e.target.value : null]
-							})
-						}} />
-					</div>
-				</div>
-			} />
 		</div>
 	</div>
 }
\ No newline at end of file
diff --git a/src/renderer/Voice.tsx b/src/renderer/Voice.tsx
index 4a1456d1..3f0e506d 100644
--- a/src/renderer/Voice.tsx
+++ b/src/renderer/Voice.tsx
@@ -44,6 +44,14 @@ interface OtherDead {
 	[playerId: number]: boolean; // isTalking
 }
 
+const DEFAULT_ICE_CONFIG: RTCConfiguration = {
+	iceServers: [
+		{
+			urls: 'stun:stun.l.google.com:19302'
+		}
+	]
+}
+
 // function clamp(number: number, min: number, max: number): number {
 // 	if (min > max) {
 // 		let tmp = max;
@@ -154,6 +162,7 @@ export default function Voice() {
 		socket.on('connect', () => {
 			setConnected(true);
 		});
+
 		socket.on('disconnect', () => {
 			setConnected(false);
 		});
@@ -162,6 +171,8 @@ export default function Voice() {
 		let audioListener: any;
 		let audio: boolean | MediaTrackConstraints = true;
 
+		let iceConfig : RTCConfiguration = DEFAULT_ICE_CONFIG;
+		socket.on('iceConfig', (newIceConfig: RTCConfiguration) => iceConfig = newIceConfig)
 
 		// Get microphone settings
 		if (settings.microphone.toLowerCase() !== 'default')
@@ -234,28 +245,10 @@ export default function Voice() {
 			setConnect({ connect });
 			function createPeerConnection(peer: string, initiator: boolean) {
 				// console.log("Opening connection to ", peer, "Initiator: ", initiator);
-				const iceServers = [];
-
-				if (settings.stunServerURL) {
-					iceServers.push({
-						urls: settings.stunServerURL
-					});
-				}
-
-				if (settings.turnServerURL && settings.turnUsername && settings.turnPassword) {
-					iceServers.push({
-						urls: settings.turnServerURL,
-						username: settings.turnUsername,
-						credential: settings.turnPassword
-					});
-				}
-
 				const connection = new Peer({
 					stream,
 					initiator,
-					config: {
-						iceServers
-					}
+					config: iceConfig
 				});
 
 				peerConnections[peer] = connection;
diff --git a/src/renderer/css/settings.css b/src/renderer/css/settings.css
index 0e01315a..d08b0ca2 100644
--- a/src/renderer/css/settings.css
+++ b/src/renderer/css/settings.css
@@ -38,7 +38,7 @@
 	transform: translateX(-1px);
 }
 
-input[type="text"], input[type="password"] {
+input[type="text"] {
 	background: #1d1d1d;
 	border: 1px solid rgba(255, 255, 255, 0.5);
 	outline: none;

From 42941d810d911c867f6d1f189e67d0454a1101b9 Mon Sep 17 00:00:00 2001
From: Sjoerd <sjoerddal108@gmail.com>
Date: Mon, 7 Dec 2020 01:31:35 +0100
Subject: [PATCH 4/8] Use a custom format for ICEServers and validate it

---
 src/renderer/Voice.tsx             | 43 +++++++++++++++++++++++++++---
 src/renderer/validatePeerConfig.ts | 33 +++++++++++++++++++++++
 2 files changed, 73 insertions(+), 3 deletions(-)
 create mode 100644 src/renderer/validatePeerConfig.ts

diff --git a/src/renderer/Voice.tsx b/src/renderer/Voice.tsx
index 3f0e506d..11e4f836 100644
--- a/src/renderer/Voice.tsx
+++ b/src/renderer/Voice.tsx
@@ -7,6 +7,7 @@ import Peer from 'simple-peer';
 import { ipcRenderer, remote } from 'electron';
 import VAD from './vad';
 import { ISettings } from './Settings';
+import { validatePeerConfig } from './validatePeerConfig';
 
 interface PeerConnections {
 	[peer: string]: Peer.Instance;
@@ -44,6 +45,18 @@ interface OtherDead {
 	[playerId: number]: boolean; // isTalking
 }
 
+interface ICEServer {
+	url: string,
+	username: string | undefined,
+	credential: string | undefined,
+}
+
+interface PeerConfig {
+	forceRelayOnly: Boolean,
+	stunServers: ICEServer[],
+	turnServers: ICEServer[]
+}
+
 const DEFAULT_ICE_CONFIG: RTCConfiguration = {
 	iceServers: [
 		{
@@ -167,13 +180,37 @@ export default function Voice() {
 			setConnected(false);
 		});
 
+		let iceConfig: RTCConfiguration = DEFAULT_ICE_CONFIG;
+		socket.on('peerConfig', (peerConfig: PeerConfig) => {
+			if (validatePeerConfig(peerConfig)) {
+				if (peerConfig.forceRelayOnly && !peerConfig.turnServers) {
+					alert(`Server has forced relay mode enabled but provides no relay servers. Default config will be used.`);
+					return;
+				}
+
+				iceConfig = {
+					iceTransportPolicy: peerConfig.forceRelayOnly ? 'relay' : 'all',
+					iceServers: [...(peerConfig.stunServers || []), ...(peerConfig.turnServers || [])]
+						.map((server) => {
+							return {
+								urls: server.url,
+								username: server.username,
+								credential: server.credential
+							}
+						})
+				};
+			} else {
+				alert(`Server sent a malformed peer config. Default config will be used.${
+					validatePeerConfig.errors ?
+						` See errors below:\n${validatePeerConfig.errors.map(error => error.dataPath + ' ' + error.message).join('\n')}` : ``
+				}`);
+			}
+		})
+
 		// Initialize variables
 		let audioListener: any;
 		let audio: boolean | MediaTrackConstraints = true;
 
-		let iceConfig : RTCConfiguration = DEFAULT_ICE_CONFIG;
-		socket.on('iceConfig', (newIceConfig: RTCConfiguration) => iceConfig = newIceConfig)
-
 		// Get microphone settings
 		if (settings.microphone.toLowerCase() !== 'default')
 			audio = { deviceId: settings.microphone };
diff --git a/src/renderer/validatePeerConfig.ts b/src/renderer/validatePeerConfig.ts
new file mode 100644
index 00000000..c7d76726
--- /dev/null
+++ b/src/renderer/validatePeerConfig.ts
@@ -0,0 +1,33 @@
+import Ajv from 'ajv';
+
+const ICE_SERVER_DEFINITION = {
+    type: 'array',
+    items: {
+        type: 'object',
+        properties: {
+            url: {
+                type: 'string',
+                format: 'uri'
+            },
+            username: {
+                type: 'string',
+            },
+            credential: {
+                type: 'string',
+            }
+        },
+        required: ['url']
+    }
+}
+
+export const validatePeerConfig = new Ajv({ format: 'full', allErrors: true }).compile({
+	type: 'object',
+	properties: {
+		forceRelayOnly: {
+			type: 'boolean'
+		},
+        stunServers: ICE_SERVER_DEFINITION,
+        turnServers: ICE_SERVER_DEFINITION,
+    },
+    required: ['forceRelayOnly']
+});

From a0a38a14d28f22f2d14103fc37b302869a41cc93 Mon Sep 17 00:00:00 2001
From: Sjoerd <sjoerddal108@gmail.com>
Date: Mon, 7 Dec 2020 16:20:51 +0100
Subject: [PATCH 5/8] Use return early pattern for peerconfig validation

---
 src/renderer/Voice.tsx | 42 +++++++++++++++++++++---------------------
 1 file changed, 21 insertions(+), 21 deletions(-)

diff --git a/src/renderer/Voice.tsx b/src/renderer/Voice.tsx
index 11e4f836..17465584 100644
--- a/src/renderer/Voice.tsx
+++ b/src/renderer/Voice.tsx
@@ -182,29 +182,29 @@ export default function Voice() {
 
 		let iceConfig: RTCConfiguration = DEFAULT_ICE_CONFIG;
 		socket.on('peerConfig', (peerConfig: PeerConfig) => {
-			if (validatePeerConfig(peerConfig)) {
-				if (peerConfig.forceRelayOnly && !peerConfig.turnServers) {
-					alert(`Server has forced relay mode enabled but provides no relay servers. Default config will be used.`);
-					return;
-				}
+			if (!validatePeerConfig(peerConfig)) {
+				alert(`Server sent a malformed peer config. Default config will be used.${validatePeerConfig.errors ?
+					` See errors below:\n${validatePeerConfig.errors.map(error => error.dataPath + ' ' + error.message).join('\n')}` : ``
+					}`);
+				return;
+			}
 
-				iceConfig = {
-					iceTransportPolicy: peerConfig.forceRelayOnly ? 'relay' : 'all',
-					iceServers: [...(peerConfig.stunServers || []), ...(peerConfig.turnServers || [])]
-						.map((server) => {
-							return {
-								urls: server.url,
-								username: server.username,
-								credential: server.credential
-							}
-						})
-				};
-			} else {
-				alert(`Server sent a malformed peer config. Default config will be used.${
-					validatePeerConfig.errors ?
-						` See errors below:\n${validatePeerConfig.errors.map(error => error.dataPath + ' ' + error.message).join('\n')}` : ``
-				}`);
+			if (peerConfig.forceRelayOnly && !peerConfig.turnServers) {
+				alert(`Server has forced relay mode enabled but provides no relay servers. Default config will be used.`);
+				return;
 			}
+
+			iceConfig = {
+				iceTransportPolicy: peerConfig.forceRelayOnly ? 'relay' : 'all',
+				iceServers: [...(peerConfig.stunServers || []), ...(peerConfig.turnServers || [])]
+					.map((server) => {
+						return {
+							urls: server.url,
+							username: server.username,
+							credential: server.credential
+						}
+					})
+			};
 		})
 
 		// Initialize variables

From 631756642464b8150ab20cc8382a2b5832412123 Mon Sep 17 00:00:00 2001
From: Sjoerd <sjoerddal108@gmail.com>
Date: Mon, 7 Dec 2020 16:23:19 +0100
Subject: [PATCH 6/8] Remove obsolete CSS

---
 src/renderer/css/settings.css | 19 -------------------
 1 file changed, 19 deletions(-)

diff --git a/src/renderer/css/settings.css b/src/renderer/css/settings.css
index d08b0ca2..2266c3cc 100644
--- a/src/renderer/css/settings.css
+++ b/src/renderer/css/settings.css
@@ -92,22 +92,3 @@ select {
 	width: fit-content;
 	margin: 5px auto;
 }
-
-.inline-icon {
-	display: inline;
-	margin-right: 8px;
-	vertical-align: middle;
-}
-
-.clickable, .clickable label {
-	cursor: pointer;
-}
-
-.collapsible-content-wrapper {
-	width: 100%;
-	text-align: center;
-}
-
-.collapsible-content {
-	padding-top: 12px;
-}
\ No newline at end of file

From 264f9013b8203864b59ca7d6ee3a9215b7c49e64 Mon Sep 17 00:00:00 2001
From: Sjoerd <sjoerddal108@gmail.com>
Date: Sun, 27 Dec 2020 20:27:21 +0100
Subject: [PATCH 7/8] Implement changes to peer config

---
 .vscode/settings.json                    |  5 +++
 src/renderer/Voice.tsx                   | 48 ++++++++++--------------
 src/renderer/validateClientPeerConfig.ts | 34 +++++++++++++++++
 src/renderer/validatePeerConfig.ts       | 33 ----------------
 4 files changed, 58 insertions(+), 62 deletions(-)
 create mode 100644 .vscode/settings.json
 create mode 100644 src/renderer/validateClientPeerConfig.ts
 delete mode 100644 src/renderer/validatePeerConfig.ts

diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..ef17c7d5
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+    "search.exclude": {
+        "**/node_modules": false
+    }
+}
\ No newline at end of file
diff --git a/src/renderer/Voice.tsx b/src/renderer/Voice.tsx
index c29898c8..44be0dd3 100644
--- a/src/renderer/Voice.tsx
+++ b/src/renderer/Voice.tsx
@@ -7,7 +7,7 @@ import Peer from 'simple-peer';
 import { ipcRenderer, remote } from 'electron';
 import VAD from './vad';
 import { ISettings } from '../common/ISettings';
-import { validatePeerConfig } from './validatePeerConfig';
+import { validateClientPeerConfig } from './validateClientPeerConfig';
 
 export interface ExtendedAudioElement extends HTMLAudioElement {
 	setSinkId: (sinkId: string) => Promise<void>;
@@ -44,16 +44,9 @@ interface OtherDead {
 	[playerId: number]: boolean; // isTalking
 }
 
-interface ICEServer {
-	url: string,
-	username: string | undefined,
-	credential: string | undefined,
-}
-
-interface PeerConfig {
-	forceRelayOnly: Boolean,
-	stunServers: ICEServer[],
-	turnServers: ICEServer[]
+interface ClientPeerConfig {
+	forceRelayOnly: boolean,
+	iceServers: RTCIceServer[]
 }
 
 const DEFAULT_ICE_CONFIG: RTCConfiguration = {
@@ -62,7 +55,7 @@ const DEFAULT_ICE_CONFIG: RTCConfiguration = {
 			urls: 'stun:stun.l.google.com:19302'
 		}
 	]
-}
+};
 
 function calculateVoiceAudio(state: AmongUsState, settings: ISettings, me: Player, other: Player, gain: GainNode, pan: PannerNode): void {
 	const audioContext = pan.context;
@@ -172,31 +165,27 @@ const Voice: React.FC = function () {
 		});
 
 		let iceConfig: RTCConfiguration = DEFAULT_ICE_CONFIG;
-		socket.on('peerConfig', (peerConfig: PeerConfig) => {
-			if (!validatePeerConfig(peerConfig)) {
-				alert(`Server sent a malformed peer config. Default config will be used.${validatePeerConfig.errors ?
-					` See errors below:\n${validatePeerConfig.errors.map(error => error.dataPath + ' ' + error.message).join('\n')}` : ``
-					}`);
+		socket.on('clientPeerConfig', (clientPeerConfig: ClientPeerConfig) => {
+			if (!validateClientPeerConfig(clientPeerConfig)) {
+				alert(`Server sent a malformed peer config. Default config will be used.${validateClientPeerConfig.errors ?
+					` See errors below:\n${validateClientPeerConfig.errors.map(error => error.dataPath + ' ' + error.message).join('\n')}` : ''
+				}`);
 				return;
 			}
 
-			if (peerConfig.forceRelayOnly && !peerConfig.turnServers) {
-				alert(`Server has forced relay mode enabled but provides no relay servers. Default config will be used.`);
+			if (
+				clientPeerConfig.forceRelayOnly &&
+				!clientPeerConfig.iceServers.some(server => server.urls.toString().includes('turn:'))
+			) {
+				alert('Server has forced relay mode enabled but provides no relay servers. Default config will be used.');
 				return;
 			}
 
 			iceConfig = {
-				iceTransportPolicy: peerConfig.forceRelayOnly ? 'relay' : 'all',
-				iceServers: [...(peerConfig.stunServers || []), ...(peerConfig.turnServers || [])]
-					.map((server) => {
-						return {
-							urls: server.url,
-							username: server.username,
-							credential: server.credential
-						}
-					})
+				iceTransportPolicy: clientPeerConfig.forceRelayOnly ? 'relay' : 'all',
+				iceServers: clientPeerConfig.iceServers
 			};
-		})
+		});
 
 		// Initialize variables
 		let audioListener: {
@@ -282,6 +271,7 @@ const Voice: React.FC = function () {
 			};
 			setConnect({ connect });
 			function createPeerConnection(peer: string, initiator: boolean) {
+				console.log(iceConfig);
 				const connection = new Peer({
 					stream,
 					initiator,
diff --git a/src/renderer/validateClientPeerConfig.ts b/src/renderer/validateClientPeerConfig.ts
new file mode 100644
index 00000000..867d76c6
--- /dev/null
+++ b/src/renderer/validateClientPeerConfig.ts
@@ -0,0 +1,34 @@
+import Ajv from 'ajv';
+
+export const validateClientPeerConfig = new Ajv({ format: 'full', allErrors: true }).compile({
+	type: 'object',
+	properties: {
+		forceRelayOnly: {
+			type: 'boolean'
+		},
+		iceServers: {
+			type: 'array',
+			items: {
+				type: 'object',
+				properties: {
+					urls: {
+						type: ['string', 'array'],
+						format: 'uri',
+						items: {
+							type: 'string',
+							format: 'uri'
+						}
+					},
+					username: {
+						type: 'string',
+					},
+					credential: {
+						type: 'string',
+					}
+				},
+				required: ['urls']
+			}
+		}
+	},
+	required: ['forceRelayOnly', 'iceServers']
+});
diff --git a/src/renderer/validatePeerConfig.ts b/src/renderer/validatePeerConfig.ts
deleted file mode 100644
index c7d76726..00000000
--- a/src/renderer/validatePeerConfig.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import Ajv from 'ajv';
-
-const ICE_SERVER_DEFINITION = {
-    type: 'array',
-    items: {
-        type: 'object',
-        properties: {
-            url: {
-                type: 'string',
-                format: 'uri'
-            },
-            username: {
-                type: 'string',
-            },
-            credential: {
-                type: 'string',
-            }
-        },
-        required: ['url']
-    }
-}
-
-export const validatePeerConfig = new Ajv({ format: 'full', allErrors: true }).compile({
-	type: 'object',
-	properties: {
-		forceRelayOnly: {
-			type: 'boolean'
-		},
-        stunServers: ICE_SERVER_DEFINITION,
-        turnServers: ICE_SERVER_DEFINITION,
-    },
-    required: ['forceRelayOnly']
-});

From 1c5e0bae4bd9f59771e890852bd531e9191181bc Mon Sep 17 00:00:00 2001
From: Sjoerd <sjoerddal108@gmail.com>
Date: Sun, 27 Dec 2020 22:03:40 +0100
Subject: [PATCH 8/8] Remove obsolete CSS file from fork

---
 src/renderer/css/settings.css | 110 ----------------------------------
 1 file changed, 110 deletions(-)
 delete mode 100644 src/renderer/css/settings.css

diff --git a/src/renderer/css/settings.css b/src/renderer/css/settings.css
deleted file mode 100644
index 0349ab92..00000000
--- a/src/renderer/css/settings.css
+++ /dev/null
@@ -1,110 +0,0 @@
-#settings {
-	transition: transform .2s ease-in;
-	width: 100vw;
-	height: 100vh;
-	background: #171717ad;
-	backdrop-filter: blur(4px);
-	position: absolute;
-	top: 0;
-	left: 0;
-	z-index: 10;
-	display: flex;
-	flex-direction: column;
-	justify-content: start;
-	align-items: center;
-	padding-top: 20px;
-}
-
-.form-control {
-	user-select: none;
-}
-
-.form-control.l {
-	display: flex;
-	flex-direction: column;
-	text-align: center;
-}
-
-.form-control.m {
-	margin-bottom: 10px;
-}
-
-.titlebar-button.back {
-	right: 2px;
-	transition: transform .1s linear;
-}
-
-.titlebar-button.back:hover {
-	transform: translateX(-1px);
-}
-
-input[type="text"] {
-	background: #1d1d1d;
-	border: 1px solid rgba(255, 255, 255, 0.5);
-	outline: none;
-	color: white;
-	padding: 5px;
-	border-radius: 5px;
-}
-
-.form-control.m .input-error {
-	border-color: #b00020
-}
-
-input[type="text"]:focus {
-	border-color: white;
-}
-
-select {
-	background: #1d1d1d;
-	border: 1px solid rgba(255, 255, 255, 0.5);
-	outline: none;
-	color: white;
-	padding: 5px;
-	border-radius: 5px;
-	max-width: 240px;
-}
-
-.settings-scroll {
-	overflow-y: auto;
-	height: calc(100vh - 50px);
-	display: flex;
-	flex-direction: column;
-	justify-content: start;
-	align-items: center;
-	margin-bottom: 30px;
-	width: 100%;
-}
-
-.microphone-bar {
-	width: 200px;
-	height: 10px;
-	background: #1d1d1d;
-	border: 1px solid rgba(255, 255, 255, 0.5);
-	border-radius: 5px;
-	margin: 5px auto;
-	overflow: hidden;
-}
-
-.microphone-bar-inner {
-	background: #e74c3c;
-	height: 10px;
-	border-radius: 5px;
-}
-
-.test-speakers {
-	width: fit-content;
-	margin: 5px auto;
-}
-
-.settings-alert {
-	background: #f1c40f;
-	color: black;
-	position: absolute;
-	bottom: 20px;
-	left: 0;
-	height: 30px;
-	justify-content: center;
-	align-items: center;
-	width: 100vw;
-}