From 51d87b528ca16905a834cd5bcfad83ab07fbc99f Mon Sep 17 00:00:00 2001 From: Benjamin Auder Date: Wed, 12 Feb 2020 13:31:48 +0100 Subject: [PATCH] Detect self multi-connect, some bug fixes --- client/src/translations/en.js | 1 + client/src/translations/es.js | 1 + client/src/translations/fr.js | 1 + client/src/views/Game.vue | 113 ++++++++++++++++------- client/src/views/Hall.vue | 165 ++++++++++++++++++++-------------- server/sockets.js | 43 +++++++-- 6 files changed, 220 insertions(+), 104 deletions(-) diff --git a/client/src/translations/en.js b/client/src/translations/en.js index e59723ea..66a4b9e9 100644 --- a/client/src/translations/en.js +++ b/client/src/translations/en.js @@ -52,6 +52,7 @@ export const translations = "My games": "My games", "Name": "Name", "Name or Email": "Name or Email", + "New connexion detected: tab now offline": "New connexion detected: tab now offline", "New correspondance game:": "New correspondance game:", "New game": "New game", "No subject. Send anyway?": "No subject. Send anyway?", diff --git a/client/src/translations/es.js b/client/src/translations/es.js index a2232ebe..d103124c 100644 --- a/client/src/translations/es.js +++ b/client/src/translations/es.js @@ -52,6 +52,7 @@ export const translations = "My games": "Mis partidas", "Name": "Nombre", "Name or Email": "Nombre o Email", + "New connexion detected: tab now offline": "Nueva conexión detectada : pestaña ahora desconectada", "New correspondance game:": "Nueva partida por correspondencia :", "New game": "Nueva partida", "No subject. Send anyway?": "Sin asunto. ¿ Enviar sin embargo ?", diff --git a/client/src/translations/fr.js b/client/src/translations/fr.js index 7fedc139..77a6a339 100644 --- a/client/src/translations/fr.js +++ b/client/src/translations/fr.js @@ -52,6 +52,7 @@ export const translations = "My games": "Mes parties", "Name": "Nom", "Name or Email": "Nom ou Email", + "New connexion detected: tab now offline": "Nouvelle connexion détectée : onglet désormais hors ligne", "New correspondance game:": "Nouvelle partie par corespondance :", "New game": "Nouvelle partie", "No subject. Send anyway?": "Pas de sujet. Envoyer quand-même ??", diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue index a7d26d7e..a61655a9 100644 --- a/client/src/views/Game.vue +++ b/client/src/views/Game.vue @@ -41,6 +41,7 @@ import { store } from "@/store"; import { GameStorage } from "@/utils/gameStorage"; import { ppt } from "@/utils/datetime"; import { extractTime } from "@/utils/timeControl"; +import { getRandString } from "@/utils/alea"; import { ArrayFun } from "@/utils/array"; import { processModalClick } from "@/utils/modalClick"; import { getScoreMessage } from "@/utils/scoring"; @@ -71,6 +72,10 @@ export default { repeat: {}, //detect position repetition newChat: "", conn: null, + connexionString: "", + // Related to (killing of) self multi-connects: + newConnect: {}, + killed: {}, }; }, watch: { @@ -116,18 +121,13 @@ export default { this.gameRef.id = this.$route.params["id"]; this.gameRef.rid = this.$route.query["rid"]; //may be undefined // Initialize connection - const connexionString = params.socketUrl + + this.connexionString = params.socketUrl + "/?sid=" + this.st.user.sid + "&tmpId=" + getRandString() + "&page=" + encodeURIComponent(this.$route.path); - this.conn = new WebSocket(connexionString); + this.conn = new WebSocket(this.connexionString); this.conn.onmessage = this.socketMessageListener; - const socketCloseListener = () => { - this.conn = new WebSocket(connexionString); - this.conn.addEventListener('message', this.socketMessageListener); - this.conn.addEventListener('close', socketCloseListener); - }; - this.conn.onclose = socketCloseListener; + this.conn.onclose = this.socketCloseListener; // Socket init required before loading remote game: const socketInit = (callback) => { if (!!this.conn && this.conn.readyState == 1) //1 == OPEN state @@ -160,12 +160,15 @@ export default { this.send("pollclients"); }, send: function(code, obj) { - this.conn.send(JSON.stringify( - Object.assign( - {code: code}, - obj, - ) - )); + if (!!this.conn) + { + this.conn.send(JSON.stringify( + Object.assign( + {code: code}, + obj, + ) + )); + } }, isConnected: function(index) { const player = this.game.players[index]; @@ -177,6 +180,8 @@ export default { Object.values(this.people).some(p => p.id == player.uid); }, socketMessageListener: function(msg) { + if (!this.conn) + return; const data = JSON.parse(msg.data); switch (data.code) { @@ -184,40 +189,76 @@ export default { data.sockIds.forEach(sid => { this.$set(this.people, sid, {id:0, name:""}); if (sid != this.st.user.sid) + { this.send("askidentity", {target:sid}); + // Ask potentially missed last state, if opponent and I play + if (!!this.game.mycolor + && this.game.type == "live" && this.game.score == "*" + && this.game.players.some(p => p.sid == sid)) + { + this.send("asklastate", {target:sid}); + } + } }); break; case "connect": - this.$set(this.people, data.from, {name:"", id:0}); - this.send("askidentity", {target:data.from}); + if (!this.people[data.from]) + this.$set(this.people, data.from, {name:"", id:0}); + if (!this.people[data.from].name) + { + this.newConnect[data.from] = true; //for self multi-connects tests + this.send("askidentity", {target:data.from}); + } break; case "disconnect": this.$delete(this.people, data.from); break; + case "killed": + // I logged in elsewhere: + alert(this.st.tr["New connexion detected: tab now offline"]); + // TODO: this fails. See https://github.com/websockets/ws/issues/489 + //this.conn.removeEventListener("message", this.socketMessageListener); + //this.conn.removeEventListener("close", this.socketCloseListener); + //this.conn.close(); + this.conn = null; + break; case "askidentity": - // Request for identification: reply if I'm not anonymous - if (this.st.user.id > 0) - { - const me = { - // NOTE: decompose to avoid revealing email - name: this.st.user.name, - sid: this.st.user.sid, - id: this.st.user.id, - }; - this.send("identity", {data:me, target:data.from}); - } + { + // Request for identification (TODO: anonymous shouldn't need to reply) + const me = { + // Decompose to avoid revealing email + name: this.st.user.name, + sid: this.st.user.sid, + id: this.st.user.id, + }; + this.send("identity", {data:me, target:data.from}); break; + } case "identity": { const user = data.data; - this.$set(this.people, user.sid, {id: user.id, name: user.name}); - // Ask potentially missed last state, if opponent and I play - if (!!this.game.mycolor - && this.game.type == "live" && this.game.score == "*" - && this.game.players.some(p => p.sid == user.sid)) + if (!!user.name) //otherwise anonymous { - this.send("asklastate", {target:user.sid}); + // If I multi-connect, kill current connexion if no mark (I'm older) + if (this.newConnect[user.sid] && user.id > 0 + && user.id == this.st.user.id && user.sid != this.st.user.sid) + { + if (!this.killed[this.st.user.sid]) + { + this.send("killme", {sid:this.st.user.sid}); + this.killed[this.st.user.sid] = true; + } + } + if (user.sid != this.st.user.sid) //I already know my identity... + { + this.$set(this.people, user.sid, + { + id: user.id, + name: user.name, + }); + } } + delete this.newConnect[user.sid]; break; } case "askgame": @@ -232,6 +273,7 @@ export default { vid: this.game.vid, cadence: this.game.cadence, score: this.game.score, + rid: this.st.user.sid, //useful in Hall if I'm an observer }; this.send("game", {data:myGame, target:data.from}); } @@ -306,6 +348,11 @@ export default { } } }, + socketCloseListener: function() { + this.conn = new WebSocket(this.connexionString); + this.conn.addEventListener('message', this.socketMessageListener); + this.conn.addEventListener('close', this.socketCloseListener); + }, // lastate was received, but maybe game wasn't ready yet: processLastate: function() { const data = this.lastate; diff --git a/client/src/views/Hall.vue b/client/src/views/Hall.vue index ab92925f..9c06c4bd 100644 --- a/client/src/views/Hall.vue +++ b/client/src/views/Hall.vue @@ -110,6 +110,10 @@ export default { }, newChat: "", conn: null, + connexionString: "", + // Related to (killing of) self multi-connects: + newConnect: {}, + killed: {}, }; }, watch: { @@ -198,19 +202,14 @@ export default { this.send("pollclientsandgamers"); }; // Initialize connection - const connexionString = params.socketUrl + + this.connexionString = params.socketUrl + "/?sid=" + this.st.user.sid + "&tmpId=" + getRandString() + "&page=" + encodeURIComponent(this.$route.path); - this.conn = new WebSocket(connexionString); + this.conn = new WebSocket(this.connexionString); this.conn.onopen = connectAndPoll; this.conn.onmessage = this.socketMessageListener; - const socketCloseListener = () => { - this.conn = new WebSocket(connexionString); - this.conn.addEventListener('message', this.socketMessageListener); - this.conn.addEventListener('close', socketCloseListener); - }; - this.conn.onclose = socketCloseListener; + this.conn.onclose = this.socketCloseListener; }, mounted: function() { [document.getElementById("infoDiv"),document.getElementById("newgameDiv")] @@ -227,12 +226,15 @@ export default { methods: { // Helpers: send: function(code, obj) { - this.conn.send(JSON.stringify( - Object.assign( - {code: code}, - obj, - ) - )); + if (!!this.conn) + { + this.conn.send(JSON.stringify( + Object.assign( + {code: code}, + obj, + ) + )); + } }, getVname: function(vid) { const variant = this.st.variants.find(v => v.id == vid); @@ -272,26 +274,15 @@ export default { { // In some game, maybe playing maybe not const gid = this.people[sid].page.match(/[a-zA-Z0-9]+$/)[0]; - this.showGame(this.games.find(g => g.id == gid)), sid; + this.showGame(this.games.find(g => g.id == gid)); } }, - showGame: function(g, obsId) { + showGame: function(g) { // NOTE: we are an observer, since only games I don't play are shown here // ==> Moves sent by connected remote player(s) if live game let url = "/game/" + g.id; if (g.type == "live") - { - let rids = []; - for (let i of [0,1]) - { - if (this.people[g.players[i].sid].pages.indexOf(url) >= 0) - rids.push(g.players[i].sid); - } - if (!!obsId) - rids.push(obsId); //observer can provide game too - const ridIdx = Math.floor(Math.random() * rids.length); - url += "?rid=" + rids[ridIdx]; - } + url += "?rid=" + g.rids[Math.floor(Math.random() * g.rids.length)]; this.$router.push(url); }, processChat: function(chat) { @@ -299,11 +290,15 @@ export default { }, // Messaging center: socketMessageListener: function(msg) { + if (!this.conn) + return; const data = JSON.parse(msg.data); switch (data.code) { case "pollclientsandgamers": { + // Since people can be both in Hall and Game, + // need to track "askIdentity" requests: let identityAsked = {}; data.sockIds.forEach(s => { if (s.sid != this.st.user.sid && !identityAsked[s.sid]) @@ -315,9 +310,9 @@ export default { this.$set(this.people, s.sid, {id:0, name:"", pages:[s.page || "/"]}); else if (!!s.page && this.people[s.sid].pages.indexOf(s.page) < 0) this.people[s.sid].pages.push(s.page); - if (!s.page) + if (!s.page) //peer is in Hall this.send("askchallenge", {target:s.sid}); - else + else //peer is in Game this.send("askgame", {target:s.sid}); }); break; @@ -342,62 +337,86 @@ export default { this.people[data.from].pages.push(data.page); } if (this.people[data.from].id == 0) + { + this.newConnect[data.from] = true; //for self multi-connects tests this.send("askidentity", {target:data.from}); + } break; case "disconnect": case "gdisconnect": // Disconnect means no more tmpIds: if (data.code == "disconnect") { - this.$delete(this.people, data.from); - // Also remove all challenges sent by this player: + // Remove the live challenge sent by this player: ArrayFun.remove(this.challenges, c => c.from.sid == data.from); } else { - const pidx = this.people[data.from].pages.indexOf(data.page); - this.people[data.from].pages.splice(pageIdx, 1); - if (this.people[data.from].pages.length == 0) + // Remove the matching live game if now unreachable + const gid = data.page.match(/[a-zA-Z0-9]+$/)[0]; + const gidx = this.games.findIndex(g => g.id == gid); + if (gidx >= 0) { - this.$delete(this.people, data.from); - // And all live games where he plays and no other opponent is online - ArrayFun.remove(this.games, g => - g.type == "live" && (g.players.every(p => p.sid == data.from - || !this.people[p.sid])), "all"); + const game = this.games[gidx]; + if (game.type == "live" && + game.rids.length == 1 && game.rids[0] == data.from) + { + this.games.splice(gidx, 1); + } } } + const page = data.page || "/"; + ArrayFun.remove(this.people[data.from].pages, p => p == page); + if (this.people[data.from].pages.length == 0) + this.$delete(this.people, data.from); + break; + case "killed": + // I logged in elsewhere: + alert(this.st.tr["New connexion detected: tab now offline"]); + // TODO: this fails. See https://github.com/websockets/ws/issues/489 + //this.conn.removeEventListener("message", this.socketMessageListener); + //this.conn.removeEventListener("close", this.socketCloseListener); + //this.conn.close(); + this.conn = null; break; case "askidentity": - // Request for identification: reply if I'm not anonymous - if (this.st.user.id > 0) - { - const me = { - // NOTE: decompose to avoid revealing email - name: this.st.user.name, - sid: this.st.user.sid, - id: this.st.user.id, - }; - this.send("identity", {data:me, target:data.from}); - } + { + // Request for identification (TODO: anonymous shouldn't need to reply) + const me = { + // Decompose to avoid revealing email + name: this.st.user.name, + sid: this.st.user.sid, + id: this.st.user.id, + }; + this.send("identity", {data:me, target:data.from}); break; + } case "identity": { const user = data.data; - this.$set(this.people, user.sid, + if (!!user.name) //otherwise anonymous + { + // If I multi-connect, kill current connexion if no mark (I'm older) + if (this.newConnect[user.sid] && user.id > 0 + && user.id == this.st.user.id && user.sid != this.st.user.sid) { - id: user.id, - name: user.name, - pages: this.people[user.sid].pages, - }); - -// // TODO: smarter, if multi-connect, send to all instances... (several sid's) -// // Or better: just prevent multi-connect. -// // Fix anomaly: if registered player multi-connect, should be left only one -// const anomalies = Object.keys(this.people).filter(sid => this.people[sid].id == user.id); -// if (anomalies.length == 2) -// this.$delete(this.people, anomalies[0]); -// // --> this isn't good, some sid's are just forgetted - + if (!this.killed[this.st.user.sid]) + { + this.send("killme", {sid:this.st.user.sid}); + this.killed[this.st.user.sid] = true; + } + } + if (user.sid != this.st.user.sid) //I already know my identity... + { + this.$set(this.people, user.sid, + { + id: user.id, + name: user.name, + pages: this.people[user.sid].pages, + }); + } + } + delete this.newConnect[user.sid]; break; } case "askchallenge": @@ -463,7 +482,8 @@ export default { { // NOTE: it may be live or correspondance const game = data.data; - if (this.games.findIndex(g => g.id == game.id) < 0) + let locGame = this.games.find(g => g.id == game.id); + if (!locGame) { let newGame = game; newGame.type = this.classifyObject(game); @@ -472,6 +492,12 @@ export default { newGame.score = "*"; this.games.push(newGame); } + else + { + // Append rid (if not already in list) + if (!locGame.rids.includes(game.rid)) + locGame.rids.push(game.rid); + } break; } case "startgame": @@ -496,6 +522,13 @@ export default { break; } }, + socketCloseListener: function() { + if (!this.conn) + return; + this.conn = new WebSocket(this.connexionString); + this.conn.addEventListener("message", this.socketMessageListener); + this.conn.addEventListener("close", this.socketCloseListener); + }, // Challenge lifecycle: newChallenge: async function() { if (this.newchallenge.vid == "") diff --git a/server/sockets.js b/server/sockets.js index fea0f49e..9d118b06 100644 --- a/server/sockets.js +++ b/server/sockets.js @@ -24,6 +24,8 @@ module.exports = function(wss) { const tmpId = query["tmpId"]; const page = query["page"]; const notifyRoom = (page,code,obj={}) => { + if (!clients[page]) + return; Object.keys(clients[page]).forEach(k => { Object.keys(clients[page][k]).forEach(x => { if (k == sid && x == tmpId) @@ -76,7 +78,7 @@ module.exports = function(wss) { case "disconnect": // When page changes: deleteConnexion(); - if (!clients[page][sid]) + if (!clients[page] || !clients[page][sid]) { // I effectively disconnected from this page: notifyRoom(page, "disconnect"); @@ -84,12 +86,43 @@ module.exports = function(wss) { notifyRoom("/", "gdisconnect", {page:page}); } break; + case "killme": + { + // Self multi-connect: manual removal + disconnect + const doKill = (pg) => { + Object.keys(clients[pg][obj.sid]).forEach(x => { + clients[pg][obj.sid][x].send(JSON.stringify({code: "killed"})); + }); + delete clients[pg][obj.sid]; + }; + const disconnectFromOtherConnexion = (pg,code,o={}) => { + Object.keys(clients[pg]).forEach(k => { + if (k != obj.sid) + { + Object.keys(clients[pg][k]).forEach(x => { + clients[pg][k][x].send(JSON.stringify(Object.assign( + {code:code, from:obj.sid}, o))); + }); + } + }); + }; + Object.keys(clients).forEach(pg => { + if (!!clients[pg][obj.sid]) + { + doKill(pg); + disconnectFromOtherConnexion(pg, "disconnect"); + if (pg.indexOf("/game/") >= 0) + disconnectFromOtherConnexion("/", "gdisconnect", {page:pg}); + } + }); + break; + } case "pollclients": //from Hall or Game { let sockIds = []; Object.keys(clients[page]).forEach(k => { - // Poll myself if I'm on at least another tab (same page) - if (k != sid || Object.keys(clients["/"][k]).length >= 2) + // Avoid polling myself: no new information to get + if (k != sid) sockIds.push(k); }); socket.send(JSON.stringify({code:"pollclients", sockIds:sockIds})); @@ -99,8 +132,8 @@ module.exports = function(wss) { { let sockIds = []; Object.keys(clients["/"]).forEach(k => { - // Poll myself if I'm on at least another tab (same page) - if (k != sid || Object.keys(clients["/"][k]).length >= 2) + // Avoid polling myself: no new information to get + if (k != sid) sockIds.push({sid:k}); }); // NOTE: a "gamer" could also just be an observer -- 2.44.0