diff --git a/client/Signaler.js b/client/Signaler.js new file mode 100644 index 0000000000000000000000000000000000000000..00fa58c9e97ccabdfd570669ddbdd89cdf4952ad --- /dev/null +++ b/client/Signaler.js @@ -0,0 +1,245 @@ +export const baseURL = `ws://${document.location.hostname}:443/skribbl`; +/** + * @typedef {{connection:RTCPeerConnection,dataChannel:RTCDataChannel}} ConnectionData + */ +/** + * A connection to the signaling server used to start and join games. + */ +export default class Signaler { + + /** + * Starts a new game, returning a promise with its ID. After that, calls the given callback with a new RTCPeerConnection whenever someone joins the game. + * @param {(connectionData:ConnectionData)=>void} onConnect + */ + static async host(onConnect){ + const socket = new CustomSocket(baseURL+"/host"); + //await socket.waitUntilReady(); + let response = socket.next(message=>{ + console.log("possible response:",message); + return message.startsWith("started "); + }); + let id = (await response).split(" ")[1]; + socket.onMessage(async message=>{ + if (message.startsWith("join ")){ + let playerID = message.split(" ",2)[1]; + console.log("new player joined: ",playerID); + /** @type {((message:string)=>void)[]} */ + let callbacks = []; + socket.onMessage(message=>{ + if (message.startsWith("message "+playerID+" ")){ + let data = message.split(" ").slice(2).join(" "); + console.log("message from player "+playerID+": ",data); + callbacks.forEach(callback=>callback(data)); + } + }); + onConnect(await perfectNegotiation(callback=>callbacks.push(callback),message=>{ + console.log("message to player "+playerID+": ",message); + socket.send("message "+playerID+" "+message); + },false)); + } + }); + return id; + } + + /** + * Joins a new game, returning an RTCPeerConnection to its host. + * @param {string} id + */ + static async join(id){ + const socket = new CustomSocket(baseURL+"/join/"+id); + await socket.waitUntilReady(); + /** @type {((message:string)=>void)[]} */ + let callbacks = []; + socket.onMessage(message=>{ + console.log("new message: ",message); + callbacks.forEach(callback=>callback(message.split(" ").slice(1).join(" "))); + }); + return await perfectNegotiation(callback=>callbacks.push(callback),message=>{ + console.log("send message:",message); + socket.send("message "+message); + },true); + } +} +/** + * An implementation of "perfect negotiaton" as described [here](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation). + * @param {(callback:(message:string)=>void)=>void} onMessage + * @param {(message:string)=>void} sendMessage + * @param {boolean} polite + * @returns {Promise<ConnectionData>} + */ +export async function perfectNegotiation(onMessage,sendMessage,polite){ + const connection = new RTCPeerConnection(); + const dataChannel = connection.createDataChannel("channel",{id:0,negotiated:true}); + let makingOffer = false; + connection.onnegotiationneeded = async()=>{ + makingOffer = true; + // @ts-ignore + await connection.setLocalDescription(); + //console.log("local description:",connection.localDescription) + sendMessage(JSON.stringify({description:connection.localDescription})); + makingOffer = false; + }; + connection.onicecandidate = ({candidate})=>{ + //console.log("candidate:",candidate); + sendMessage(JSON.stringify({candidate})); + }; + let ignoreOffer = false; + onMessage(async message=>{ + console.log("message to negotiator:",message); + /** @type {{description:RTCSessionDescriptionInit,candidate:undefined}|{candidate:RTCIceCandidateInit,description:undefined}} */ + let data = JSON.parse(message); + if (data.description){ + let description = data.description; + ignoreOffer = !polite&&(description.type=="offer"&&(makingOffer||connection.signalingState!="stable")); + if(!ignoreOffer){ + await connection.setRemoteDescription(description); + if (description.type=="offer"){ + // @ts-ignore + await connection.setLocalDescription(); + sendMessage(JSON.stringify({description:connection.localDescription})); + } + } + }else if(data.candidate){ + try { + await connection.addIceCandidate(data.candidate); + }catch(e){ + if (!ignoreOffer){ + throw e; + } + } + } + }); + /** @type {Promise<ConnectionData>} */ + return new Promise(resolve=>{ + connection.onsignalingstatechange = ()=>{ + console.log("Signaling state changed to:",connection.signalingState); + if (connection.signalingState=="stable"){ + resolve({connection,dataChannel}); + } + }; + }); +} +/** + * Custom socket class to pass strings to and from a server. + * + * Originally, the idea was to let this pass json data instead, but since json is difficult to work with in java I've stuck with normal strings for now. + */ +export class CustomSocket { + /** + * @callback MessageCallback + * @param {string} message + * @param {()=>void} remove removes this callback again + * @returns {void} + */ + /** + * @callback ErrorCallback + * @param {Event} error + * @param {()=>void} remove removes this callback again + * @returns {void} + */ + /** + * Creates a new custom socket. + * @param {string} url + */ + constructor(url){ + /** @type {MessageCallback[]} */ + this._onMessageCallbacks = []; + /** @type {ErrorCallback[]} */ + this._onErrorCallbacks = []; + this._webSocket = new WebSocket(url); + /** @type {Promise<void>} */ + this._readyPromise = new Promise((resolve,reject)=>{ + this._webSocket.onopen = ()=>resolve(); + this._webSocket.addEventListener("error",reject,{once:true}); + }); + this._webSocket.onmessage = (e)=>{ + console.log(`onmessage event! e.data: "${e.data}", registered callbacks: ${this._onMessageCallbacks.length}`); + this._onMessageCallbacks.slice().forEach(callback=>callback(e.data+"",()=>this.removeMessageCallback(callback))); + }; + this._webSocket.onerror = (e)=>{ + this._onErrorCallbacks.slice().forEach(callback=>callback(e,()=>this.removeErrorCallback(callback))); + }; + } + + async waitUntilReady(){ + return this._readyPromise; + } + + /** + * Sends the given object as json to the server. + * @param {string} data + */ + send(data){ + this._webSocket.send(data); + } + + /** + * Registers a callback to be called whenever a message is received. + * @param {MessageCallback} callback + */ + onMessage(callback){ + this._onMessageCallbacks.push(callback); + } + + /** + * Removes a callback registered using `onMessage(callback)`. + * @param {MessageCallback} callback + */ + removeMessageCallback(callback){ + let index = this._onMessageCallbacks.indexOf(callback); + if (index!==-1){ + this._onMessageCallbacks.splice(index,1); + } + } + + /** + * Returns a Promise that resolves to the next message received that passen the given filter, or `null` if no such message is received in the specified timeframe. + * If an error occurs before, the promise will instead be rejected with that. + * @param {(message:string)=>boolean} filter + * @param {number} time time to wait before just returning `null`, in seconds. Defaults to 30. If the given number is smaller than or equal to 0, it gets treated as `Infinity`. + * @return {Promise<string>} + */ + async next(filter=null,time=30){ + return new Promise((resolve,reject)=>{ + this.onMessage((message,remove)=>{ + if (!filter||filter(message)){ + resolve(message); + remove(); + } + }); + this.onError((error,remove)=>{ + reject(error); + remove(); + }); + if (time>0){ + setTimeout(()=>resolve(null),time*1000); + } + }); + } + + /** + * Registers a callback to be called whenever there's an error. + * @param {ErrorCallback} callback + */ + onError(callback){ + this._onErrorCallbacks.push(callback); + } + + /** + * Removes a callback registered using `onError(callback)`. + * @param {ErrorCallback} callback + */ + removeErrorCallback(callback){ + let index = this._onErrorCallbacks.indexOf(callback); + if (index!==-1){ + this._onErrorCallbacks.splice(index,1); + } + } + + /** + * Closes the connection. + */ + close(){ + this._webSocket.close(); + } +} \ No newline at end of file diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000000000000000000000000000000000000..452c034b22eff17f258b86a7e54990cc9421d415 --- /dev/null +++ b/client/index.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <title>signaling server test</title> + <script type="module" src="script.js"></script> + <style> + html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + } + #button { + padding: 10px 30px; + font-size: 30px; + background: orange; + color: white; + border: none; + border-radius: 4px; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%,-50%); + cursor: pointer; + } + #button:disabled { + cursor: default; + background: #dfa580; + } + </style> + </head> + <body> + <button id="button">Host game</button> + </body> +</html> \ No newline at end of file diff --git a/client/jsconfig.json b/client/jsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..5e0ad8a2ba192134890b968f8734ab421a5a3fcb --- /dev/null +++ b/client/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "checkJs": true, + "target": "esnext", + "module": "esnext" + } +} \ No newline at end of file diff --git a/client/script.js b/client/script.js new file mode 100644 index 0000000000000000000000000000000000000000..7dfb1ddd05c64ee96f92bfe92ddee825fab68a1e --- /dev/null +++ b/client/script.js @@ -0,0 +1,39 @@ +import Signaler from "./Signaler.js"; + +document.addEventListener("DOMContentLoaded",async()=>{ + if (document.location.hash){ + document.body.innerHTML = ""; + const gameID = document.location.hash.substring(1); + const {connection,dataChannel} = await Signaler.join(gameID); + console.group("Joined game "+gameID+"!"); + console.log("connection:",connection); + console.log("dataChannel:",dataChannel); + console.groupEnd(); + dataChannel.onmessage = (e)=>{ + console.log("Message through DataChannel:",e.data); + dataChannel.send("Got ya message, many thanks <3"); + } + }else{ + /** @type {HTMLButtonElement} *///@ts-ignore + const button = document.getElementById("button"); + button.addEventListener("click",async e=>{ + button.disabled = true; + let id = await Signaler.host(async({connection,dataChannel})=>{ + console.group("New connection!"); + console.log("connection:",connection); + console.log("dataChannel:",dataChannel); + console.groupEnd(); + dataChannel.onmessage = (e)=>{ + console.log("Message through DataChannel:",e.data); + } + if (dataChannel.readyState!="open"){ + await new Promise(resolve=>{ + dataChannel.onopen = resolve; + }); + } + dataChannel.send("Oh hello there :3"); + }); + alert(document.location.host+document.location.pathname+"#"+id); + }); + } +}); \ No newline at end of file diff --git a/httpServer.bat b/httpServer.bat new file mode 100644 index 0000000000000000000000000000000000000000..1c0e725fde04e78b995d12914eadb78c40ab88f1 --- /dev/null +++ b/httpServer.bat @@ -0,0 +1,3 @@ +cd client +php -S localhost:8080 +pause \ No newline at end of file diff --git a/server/.classpath b/server/.classpath new file mode 100644 index 0000000000000000000000000000000000000000..31a486a6584a929f0a3981b2d6eff92e740b4581 --- /dev/null +++ b/server/.classpath @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<classpath> + <classpathentry kind="src" path="src"/> + <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"> + <attributes> + <attribute name="module" value="true"/> + </attributes> + </classpathentry> + <classpathentry kind="lib" path="lib/Java-WebSocket-1.5.1-with-dependencies.jar"> + <attributes> + <attribute name="javadoc_location" value="jar:file:/C:/Users/Ben%20Eltschig/Documents/Schule/Uni/Skribbl/server/lib/Java-WebSocket-1.5.1-javadoc.jar!/"/> + </attributes> + </classpathentry> + <classpathentry kind="output" path="bin"/> +</classpath> diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ae3c1726048cd06b9a143e0376ed46dd9b9a8d53 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1 @@ +/bin/ diff --git a/server/.project b/server/.project new file mode 100644 index 0000000000000000000000000000000000000000..8c0e3ce4074d87cae544aa093dcfb10de7385bbd --- /dev/null +++ b/server/.project @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<projectDescription> + <name>Mathe-Skribbl</name> + <comment></comment> + <projects> + </projects> + <buildSpec> + <buildCommand> + <name>org.eclipse.jdt.core.javabuilder</name> + <arguments> + </arguments> + </buildCommand> + </buildSpec> + <natures> + <nature>org.eclipse.jdt.core.javanature</nature> + </natures> +</projectDescription> diff --git a/server/lib/Java-WebSocket-1.5.1-javadoc.jar b/server/lib/Java-WebSocket-1.5.1-javadoc.jar new file mode 100644 index 0000000000000000000000000000000000000000..6048b4fe655dd9885b2c433d902f4fea6aaeba0f Binary files /dev/null and b/server/lib/Java-WebSocket-1.5.1-javadoc.jar differ diff --git a/server/lib/Java-WebSocket-1.5.1-with-dependencies.jar b/server/lib/Java-WebSocket-1.5.1-with-dependencies.jar new file mode 100644 index 0000000000000000000000000000000000000000..8fb33ad5dca94eb91369b2cd939bdf4d2aa71500 Binary files /dev/null and b/server/lib/Java-WebSocket-1.5.1-with-dependencies.jar differ diff --git a/server/src/Game.java b/server/src/Game.java new file mode 100644 index 0000000000000000000000000000000000000000..97e4a7dc3e137f8e314e1f64bcecf1027e69f9ba --- /dev/null +++ b/server/src/Game.java @@ -0,0 +1,68 @@ +import java.util.HashMap; +import java.util.Map; + +import org.java_websocket.WebSocket; + +public class Game { + + public final String id; + public final WebSocket host; + private final Map<String,WebSocket> clients; + + public Game(String id, WebSocket host) { + this.id = id; + this.host = host; + this.clients = new HashMap<String,WebSocket>(); + } + + void handleMessage(WebSocket socket, String message){ + if (socket==this.host) { + String[] parts = message.split(" ",3); + WebSocket client = clients.get(parts[1]); + client.send("message "+parts[2]); + System.out.println("message from host "+this.id+" to player "+parts[1]+": "+parts[2]); + }else { + this.host.send("message "+getClientID(socket)+" "+message.split(" ",2)[1]); + System.out.println("message from player "+getClientID(socket)+" to host "+this.id+": "+message.split(" ",2)[1]); + } + } + + boolean hasClient(WebSocket client){ + return clients.containsValue(client); + } + + void addClient(WebSocket client) { + String id = genID(6); + while (clients.containsKey(id)) { + id = genID(6); + } + clients.put(id,client); + this.host.send("join "+id); + } + + void removeClient(WebSocket client){ + clients.values().remove(client); + } + + private String getClientID(WebSocket client) { + for (String id:clients.keySet()) { + if (clients.get(id)==client) { + return id; + } + } + return null; + } + + static String genID() { + return genID(8); + } + + static String genID(int n) { + String id = ""; + for (int i=0;i<8;i++) { + char c = (char)(Math.random()*62); + id += (char)(c+(c<10?'0':(c<36?'a'-10:'A'-36))); + } + return id; + } +} diff --git a/server/src/SignalingServer.java b/server/src/SignalingServer.java new file mode 100644 index 0000000000000000000000000000000000000000..09eb76d9e8060afe64600a764ec6872266ee965b --- /dev/null +++ b/server/src/SignalingServer.java @@ -0,0 +1,108 @@ +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; + +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.server.WebSocketServer; + +public class SignalingServer extends WebSocketServer { + + private Map<String,Game> games = new HashMap<String,Game>(); + + public static void main(String[] args) { + InetSocketAddress address = new InetSocketAddress("localhost",443); + SignalingServer server = new SignalingServer(address); + server.run(); + } + + public SignalingServer(InetSocketAddress address) { + super(address); + } + + public void onStart() { + System.out.println("started server"); + } + + public void onOpen(WebSocket socket, ClientHandshake handshake) { + String path = socket.getResourceDescriptor(); + System.out.println("new connection to "+socket.getRemoteSocketAddress()+", path: "+path); + if (path.startsWith("/skribbl/host")) { + Game game = new Game(genUnusedID(),socket); + games.put(game.id,game); + socket.send("started "+game.id); + System.out.println("started game "+game.id); + }else if(path.startsWith("/skribbl/join/")) { + Game game = games.get(path.split("/",4)[3]); + if (game==null) { + socket.send("nope"); + socket.close(4000,"invalid game code"); + }else { + game.addClient(socket); + } + }else { + System.err.println("invalid connection attempt \""+path+"\""); + socket.send("nope"); + socket.close(4000,"invalid request path"); + } + } + + public void onClose(WebSocket socket, int code, String reason, boolean remote) { + System.out.println("closed " + socket.getRemoteSocketAddress() + " with exit code " + code + " additional info: " + reason); + Game game = gameWithClient(socket); + if (game!=null) { + if (game.host==socket) { + games.remove(game.id); + System.out.println("closed game "+game.id); + }else { + game.removeClient(socket); + } + } + } + + public void onMessage(WebSocket socket, String message) { + System.out.println("received message from "+socket.getRemoteSocketAddress()+": \""+message+"\""); + if(message.startsWith("message ")) { + Game game = gameWithClient(socket); + if (game==null) { + socket.close(4000,"not currently in any game"); + }else { + game.handleMessage(socket,message); + } + }else { + System.err.println("can't interpret message: \""+message+"\""); + } + } + + public void onMessage(WebSocket socket, ByteBuffer message) { + socket.close(1003); + } + + public void onError(WebSocket conn, Exception ex) { + if (conn==null) { + System.err.print("an error occured: "); + }else { + System.err.print("an error occurred on connection " + conn.getRemoteSocketAddress() + ": "); + } + ex.printStackTrace(); + } + + private Game gameWithClient(WebSocket socket) { + for (Game game:games.values()) { + if (game.host==socket||game.hasClient(socket)) { + return game; + } + } + return null; + } + + private String genUnusedID(){ + String id = Game.genID(); + while(games.containsKey(id)) { + id = Game.genID(); + } + return id; + } + +}