From 789e5555ab4c44a1ae779eccf6ccf8340602cf22 Mon Sep 17 00:00:00 2001 From: Roland Reichwein Date: Thu, 26 Jan 2023 20:46:30 +0100 Subject: Websockets: Notify other clients of changes --- Makefile | 4 +- connectionregistry.cpp | 95 ++++++++++++ connectionregistry.h | 46 ++++++ debian/whiteboard.conf | 26 ++++ html/whiteboard.js | 409 +++++++++++++++---------------------------------- tests/Makefile | 5 +- whiteboard.conf | 2 +- whiteboard.cpp | 100 ++++++++---- whiteboard.h | 8 +- 9 files changed, 373 insertions(+), 322 deletions(-) create mode 100644 connectionregistry.cpp create mode 100644 connectionregistry.h create mode 100644 debian/whiteboard.conf diff --git a/Makefile b/Makefile index cb5ef3e..658e764 100755 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ VERSION=$(shell dpkg-parsechangelog --show-field Version) TGZNAME=$(PROJECTNAME)-$(VERSION).tar.xz INCLUDES=-I. -HEADERS=config.h qrcode.h storage.h whiteboard.h compiledsql.h +HEADERS=config.h qrcode.h storage.h whiteboard.h compiledsql.h connectionregistry.h SOURCES=$(HEADERS:.h=.cpp) OBJECTS=$(HEADERS:.h=.o) TARGETS=whiteboard @@ -21,7 +21,7 @@ TARGETS=whiteboard build: $(TARGETS) all: build - ./start.sh + ./whiteboard -c whiteboard.conf install: mkdir -p $(DESTDIR)/usr/bin diff --git a/connectionregistry.cpp b/connectionregistry.cpp new file mode 100644 index 0000000..1e48a96 --- /dev/null +++ b/connectionregistry.cpp @@ -0,0 +1,95 @@ +#include "connectionregistry.h" + +#include +#include + +void ConnectionRegistry::setId(ConnectionRegistry::connection c, const std::string& id) +{ + if (!m_connections.at(c).empty()) { + auto& connections_set{m_ids.at(id)}; + connections_set.erase(c); + if (connections_set.empty()) + m_ids.erase(id); + } + + m_connections.at(c) = id; + + if (!id.empty()) { + if (!m_ids.contains(id)) + m_ids[id] = {}; + m_ids.at(id).insert(c); + } + +} + +void ConnectionRegistry::addConnection(ConnectionRegistry::connection c) +{ + if (m_connections.contains(c)) + throw std::runtime_error("ConnectionRegistry::addConnection: connection already exists"); + + m_connections.emplace(c, ""); + + std::cout << "Info: Added connection, now " << m_connections.size() << std::endl; +} + +void ConnectionRegistry::delConnection(ConnectionRegistry::connection c) +{ + if (!m_connections.contains(c)) + throw std::runtime_error("ConnectionRegistry::delConnection: connection doesn't exist"); + + std::string id {m_connections.at(c)}; + + m_connections.erase(c); + + if (!id.empty()) { + auto& connections_set{m_ids.at(id)}; + connections_set.erase(c); + if (connections_set.empty()) + m_ids.erase(id); + } + + std::cout << "Info: Deleted connection, now " << m_connections.size() << std::endl; +} + +std::unordered_set::iterator ConnectionRegistry::begin(const std::string& id) +{ + return m_ids.at(id).begin(); +} + +std::unordered_set::iterator ConnectionRegistry::end(const std::string& id) +{ + return m_ids.at(id).end(); +} + +void ConnectionRegistry::dump() +{ + std::cout << "Connection Registry:" << std::endl; + + std::cout << "Connections: " << std::endl; + for (auto& i: m_connections) { + std::cout << " " << i.first << ": " << i.second << std::endl; + } + + std::cout << "IDs: " << std::endl; + for (auto& i: m_ids) { + std::cout << " " << i.first << ":"; + + for (auto& j: i.second) { + std::cout << " " << j; + } + std::cout << std::endl; + } +} + +ConnectionRegistry::RegistryGuard::RegistryGuard(ConnectionRegistry& registry, ConnectionRegistry::connection c): + m_registry{registry}, + m_connection{c} +{ + m_registry.addConnection(m_connection); +} + +ConnectionRegistry::RegistryGuard::~RegistryGuard() +{ + m_registry.delConnection(m_connection); +} + diff --git a/connectionregistry.h b/connectionregistry.h new file mode 100644 index 0000000..2b14553 --- /dev/null +++ b/connectionregistry.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +class ConnectionRegistry +{ +public: + using connection = std::shared_ptr>; + + ConnectionRegistry() = default; + ~ConnectionRegistry() = default; + + void setId(connection c, const std::string& id); + + // used via RegistryGuard + void addConnection(connection c); + void delConnection(connection c); + + // map connection to id + std::unordered_map m_connections; + // map id to list of related connections, used for iteration over connections to notify about changes + std::unordered_map> m_ids; + + std::unordered_set::iterator begin(const std::string& id); + std::unordered_set::iterator end(const std::string& id); + + void dump(); + + class RegistryGuard + { + public: + RegistryGuard(ConnectionRegistry& registry, connection c); + ~RegistryGuard(); + private: + ConnectionRegistry& m_registry; + connection m_connection; + }; + +}; + diff --git a/debian/whiteboard.conf b/debian/whiteboard.conf new file mode 100644 index 0000000..126bef5 --- /dev/null +++ b/debian/whiteboard.conf @@ -0,0 +1,26 @@ + + + /var/lib/whiteboard + + + ::1:8765 + + + 2592000 + + + 4 + diff --git a/html/whiteboard.js b/html/whiteboard.js index 7777d4e..9610468 100644 --- a/html/whiteboard.js +++ b/html/whiteboard.js @@ -3,145 +3,126 @@ function init() { init_board(); } -class AdjustingTimer { - constructor() { - this.update_counter = 0; // counting seconds since last counter reset - } +var revision; - fast_mode() { - if (this.update_counter < 5*60) - return true; - return false; - } +// helper for breaking feedback loop +var caretpos = 0; - // private method - // returns current interval in ms - current_update_interval() - { - if (this.fast_mode()) - return 2000; // 2s - else - return 5 * 60000; // 5min - }; +function showQRWindow() +{ + document.getElementById("qrwindow").style.display = 'block'; +} - // private - count() { - this.update_counter += this.current_update_interval() / 1000; - }; +function hideQRWindow() +{ + document.getElementById("qrwindow").style.display = 'none'; +} - // private - on_timeout() { - this.m_fn(); - this.count(); - var _this = this; - this.update_timer = setTimeout(function(){_this.on_timeout();}, this.current_update_interval()); - }; +var websocket; - // to be called once on startup - start(fn) { - this.m_fn = fn; - var _this = this; - this.update_timer = setTimeout(function(){_this.on_timeout();}, this.current_update_interval()); - }; +// +// Callbacks for websocket data of different types +// - // to be called on activity: - // * changes from remote - // * changes by ourselves - // * local activity, e.g. mouse move, or key presses - reset() { - if (!this.fast_mode()) { - this.update_counter = 0; - clearTimeout(this.update_timer); - var _this = this; - this.update_timer = setTimeout(function(){_this.on_timeout();}, this.current_update_interval()); - } else { - this.update_counter = 0; - } - }; +function on_getfile(data, rev, pos) +{ + var board = document.getElementById("board"); + if (board.value != data) { + board.value = data; + } + textAreaSetPos("board", pos); + revision = rev; } -var timer = new AdjustingTimer(); +function on_newid(id) +{ + var new_location = document.location.origin + document.location.pathname + '?id=' + id; + window.location.href = new_location; +} -function showQRWindow() +function on_qrcode(png) { - document.getElementById("qrwindow").style.display = 'block'; + var blob = new Blob([png], {type: 'image/png'}); + var url = URL.createObjectURL(blob); + var img = document.getElementById("qrcode"); + img.src = url; + showQRWindow(); } -function hideQRWindow() +function on_modify_ack(rev) { - document.getElementById("qrwindow").style.display = 'none'; + revision = rev; } -function init_board() { - var xhr = new XMLHttpRequest(); +function on_message(e) { + var parser = new DOMParser(); + var xmlDocument = parser.parseFromString(e.data, "text/xml"); - const searchParams = (new URL(document.location)).searchParams; - if (!searchParams.has('id')) { - redirect_to_new_page(); - return; - } - - // run on data received back - xhr.onreadystatechange = function() { - if (this.readyState == 3) { - //set_status("Please wait while downloading " + filename + " ..."); - return; - } - if (this.readyState != 4) { - return; - } - if (this.status != 200) { - //set_status("Server Error while retrieving " + filename + ", status: " + this.status + " " + this.statusText); - return; - } - - var file = new Blob([this.response]); - reader = new FileReader(); - reader.onload = function() { - var board = document.getElementById("board"); - var pos = reader.result.indexOf('\x01'); - if (pos == -1) { // not found - board.value = reader.result; - } else { - board.value = reader.result.substr(0, pos) + reader.result.substr(pos + 1); - } - textAreaSetPos("board", pos); - - // Initialization done. Now we can start modifying. - board.addEventListener("input", function() {on_modify(); }); - board.addEventListener("selectionchange", function() {on_modify(); }); - - // Initialization done. Now we can start modifying. - document.addEventListener("mousemove", function() {timer.reset(); }); - - timer.start(checkupdate); - } - - reader.readAsBinaryString(file); - - //set_status(""); // OK + var type = xmlDocument.getElementsByTagName("type")[0].textContent; + + if (type == "getfile") { + on_getfile(xmlDocument.getElementsByTagName("data")[0].textContent, + parseInt(xmlDocument.getElementsByTagName("revision")[0].textContent), + parseInt(xmlDocument.getElementsByTagName("cursorpos")[0].textContent)); + } else if (type == "modify") { + on_modify_ack(parseInt(xmlDocument.getElementsByTagName("revision")[0].textContent)); + } else if (type == "newid") { + on_newid(xmlDocument.getElementsByTagName("id")[0].textContent); + } else if (type == "qrcode") { + on_qrcode(xmlDocument.getElementsByTagName("png")[0].textContent); + } else if (type == "error") { + alert(xmlDocument.getElementsByTagName("message")[0].textContent); + } else { + alert("Unhandled message type: " + e.data + "|" + type); } +} - var parser = new DOMParser(); - var xmlDocument = parser.parseFromString("", "text/xml"); - - var requestElement = xmlDocument.getElementsByTagName("request")[0]; +function handleSelection() { + const activeElement = document.activeElement - var commandElement = xmlDocument.createElement("command"); - commandElement.appendChild(document.createTextNode("getfile")); - requestElement.appendChild(commandElement); + if (activeElement && activeElement.id === 'board') { + if (caretpos != activeElement.selectionStart) { + on_selectionchange(activeElement.selectionStart); + caretpos = activeElement.selectionStart; + } + } +} - var idElement = xmlDocument.createElement("id"); - idElement.appendChild(document.createTextNode(get_id())); - requestElement.appendChild(idElement); +function init_board() { + var newlocation = location.origin + location.pathname; + newlocation = newlocation.replace(/^http/, 'ws'); + if (newlocation.slice(-1) != "/") + newlocation += "/"; + newlocation += "websocket"; + websocket = new WebSocket(newlocation); + + websocket.onmessage = function(e) { on_message(e); }; + + websocket.onopen = function(e) { + const searchParams = (new URL(document.location)).searchParams; + if (!searchParams.has('id')) { + redirect_to_new_page(); + return; + } - xhr.open("POST", "whiteboard.fcgi", true); - xhr.setRequestHeader("Content-type", "text/xml"); - xhr.responseType = 'blob'; - xhr.send(xmlDocument); + var board = document.getElementById("board"); + board.addEventListener("input", function() {on_input(); }); + // Need this workaround (different from direct on_selectionchange) for Chrome. + // Otherwise, callback will not be called on Chrome. + document.addEventListener("selectionchange", handleSelection); + //board.addEventListener("selectionchange", function() {on_selectionchange(); }); + + websocket.send("getfile" + get_id() + ""); + }; + + websocket.onclose = function(e) { + alert("Server connection closed."); + }; + + websocket.onerror = function(e) { + alert("Error: Server connection closed."); + }; - //set_status("Please wait while server prepares " + filename + " ..."); - document.getElementById("qrwindow").onclick = function() { hideQRWindow(); } @@ -161,81 +142,20 @@ function get_id() return searchParams.get('id'); } +// from html function on_new_page() { - redirect_to_new_page(); + redirect_to_new_page(); } function redirect_to_new_page() { - var xhr = new XMLHttpRequest(); - - // run on data received back - xhr.onreadystatechange = function() { - if (this.readyState == 3) { - //set_status("Please wait while downloading " + filename + " ..."); - return; - } - if (this.readyState != 4) { - return; - } - if (this.status != 200) { - //set_status("Server Error while retrieving " + filename + ", status: " + this.status + " " + this.statusText); - return; - } - - var id = this.responseText; - //alert("location=" + document.location.href); - var new_location = document.location.href; - var pos = new_location.search("\\?"); - if (pos >= 0) - new_location = new_location.substring(0, pos); - new_location += '?id=' + id; - - window.location.href = new_location; - //set_status(""); // OK - } - - var parser = new DOMParser(); - var xmlDocument = parser.parseFromString("", "text/xml"); - - var requestElement = xmlDocument.getElementsByTagName("request")[0]; - - var commandElement = xmlDocument.createElement("command"); - commandElement.appendChild(document.createTextNode("newid")); - requestElement.appendChild(commandElement); - - xhr.open("POST", "whiteboard.fcgi", true); - xhr.setRequestHeader("Content-type", "text/xml"); - xhr.send(xmlDocument); - - //set_status("Please wait while server prepares " + filename + " ..."); + websocket.send("newid"); } // local change done -function on_modify() +function on_input() { - timer.reset(); - - var xhr = new XMLHttpRequest(); - - // run on data received back - xhr.onreadystatechange = function() { - if (this.readyState == 3) { - //set_status("Please wait while downloading " + filename + " ..."); - return; - } - if (this.readyState != 4) { - return; - } - if (this.status != 200) { - //set_status("Server Error while retrieving " + filename + ", status: " + this.status + " " + this.statusText); - return; - } - - //set_status(""); // OK - } - var parser = new DOMParser(); var xmlDocument = parser.parseFromString("", "text/xml"); @@ -250,131 +170,47 @@ function on_modify() requestElement.appendChild(idElement); var dataElement = xmlDocument.createElement("data"); - dataElement.appendChild(document.createTextNode(addPos(document.getElementById("board").value, document.getElementById("board").selectionStart))); + dataElement.appendChild(document.createTextNode(document.getElementById("board").value)); requestElement.appendChild(dataElement); - xhr.open("POST", "whiteboard.fcgi", true); - xhr.setRequestHeader("Content-type", "text/xml"); - xhr.responseType = 'blob'; - xhr.send(xmlDocument); - - //set_status("Please wait while server prepares " + filename + " ..."); -} - -// checksum of string -function checksum32(s) { - var result = 0; - for (var i = 0; i < s.length; i++) { - result = ((((result >>> 1) | ((result & 1) << 31)) | 0) ^ (s.charCodeAt(i) & 0xFF)) | 0; - } - return (result & 0x7FFFFFFF) | 0; + websocket.send(new XMLSerializer().serializeToString(xmlDocument)); } -function textAreaSetPos(id, pos) +// for cursor position +function on_selectionchange(pos) { - document.getElementById(id).selectionStart = pos; - document.getElementById(id).selectionEnd = pos; -} - -function addPos(s, pos) -{ - return s.substr(0, pos) + '\x01' + s.substr(pos); -} - -// gets called by regular polling -function checkupdate() { - var xhr = new XMLHttpRequest(); - - // run on data received back - xhr.onreadystatechange = function() { - if (this.readyState == 3) { - //set_status("Please wait while downloading " + filename + " ..."); - return; - } - if (this.readyState != 4) { - return; - } - if (this.status != 200) { - //set_status("Server Error while retrieving " + filename + ", status: " + this.status + " " + this.statusText); - return; - } - - // no change if response is text/plain - if (this.getResponseHeader("Content-Type") == "application/octet-stream") { - timer.reset(); - var file = new Blob([this.response]); - reader = new FileReader(); - reader.onload = function() { - var board = document.getElementById("board"); - var pos = reader.result.indexOf('\x01'); - if (pos == -1) { // not found - board.value = reader.result; - } else { - board.value = reader.result.substr(0, pos) + reader.result.substr(pos + 1); - } - textAreaSetPos("board", pos); - } - - reader.readAsBinaryString(file); - } - - //set_status(""); // OK - } - var parser = new DOMParser(); var xmlDocument = parser.parseFromString("", "text/xml"); var requestElement = xmlDocument.getElementsByTagName("request")[0]; var commandElement = xmlDocument.createElement("command"); - commandElement.appendChild(document.createTextNode("checkupdate")); + commandElement.appendChild(document.createTextNode("cursorpos")); requestElement.appendChild(commandElement); var idElement = xmlDocument.createElement("id"); idElement.appendChild(document.createTextNode(get_id())); requestElement.appendChild(idElement); - var checksumElement = xmlDocument.createElement("checksum"); - checksumElement.appendChild(document.createTextNode(checksum32(addPos(document.getElementById("board").value, document.getElementById("board").selectionStart)))); - requestElement.appendChild(checksumElement); - - xhr.open("POST", "whiteboard.fcgi", true); - xhr.setRequestHeader("Content-type", "text/xml"); - xhr.responseType = 'blob'; - xhr.send(xmlDocument); + var dataElement = xmlDocument.createElement("pos"); + dataElement.appendChild(document.createTextNode(pos)); + requestElement.appendChild(dataElement); - //set_status("Please wait while server prepares " + filename + " ..."); + websocket.send(new XMLSerializer().serializeToString(xmlDocument)); } -function on_qrcode() +function textAreaSetPos(id, pos) { - var xhr = new XMLHttpRequest(); - - // run on data received back - xhr.onreadystatechange = function() { - if (this.readyState == 3) { - //set_status("Please wait while downloading " + filename + " ..."); - return; - } - if (this.readyState != 4) { - return; - } - if (this.status != 200) { - //set_status("Server Error while retrieving " + filename + ", status: " + this.status + " " + this.statusText); - return; - } - - if (this.getResponseHeader("Content-Type") == "image/png") { - var blob = new Blob([this.response], {type: 'image/png'}); - var url = URL.createObjectURL(blob); - var img = document.getElementById("qrcode"); - img.src = url; - showQRWindow(); - } - - //set_status(""); // OK + if (document.getElementById(id).selectionStart != pos) { + document.getElementById(id).selectionStart = pos; + document.getElementById(id).selectionEnd = pos; + caretpos = pos; } +} +// HTML button +function on_qrcode() +{ var parser = new DOMParser(); var xmlDocument = parser.parseFromString("", "text/xml"); @@ -388,9 +224,6 @@ function on_qrcode() idElement.appendChild(document.createTextNode(document.location)); requestElement.appendChild(idElement); - xhr.open("POST", "whiteboard.fcgi", true); - xhr.setRequestHeader("Content-type", "text/xml"); - xhr.responseType = 'blob'; - xhr.send(xmlDocument); + websocket.send(new XMLSerializer().serializeToString(xmlDocument)); } diff --git a/tests/Makefile b/tests/Makefile index 778fd37..15e4106 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -11,7 +11,7 @@ CXXFLAGS+=--coverage LDFLAGS+=--coverage endif -UNITS=storage.cpp config.cpp compiledsql.cpp qrcode.cpp whiteboard.cpp +UNITS=storage.cpp config.cpp compiledsql.cpp qrcode.cpp whiteboard.cpp connectionregistry.cpp UNITTESTS=test-config.cpp \ test-storage.cpp \ @@ -49,6 +49,9 @@ unittests: libgmock.a $(UNITTESTS:.cpp=.o) $(UNITS:.cpp=.o) config.o: ../config.cpp $(CXX) $(CXXFLAGS) -o $@ -c $< +connectionregistry.o: ../connectionregistry.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + storage.o: ../storage.cpp $(CXX) $(CXXFLAGS) -o $@ -c $< diff --git a/whiteboard.conf b/whiteboard.conf index 126bef5..055e7ba 100644 --- a/whiteboard.conf +++ b/whiteboard.conf @@ -9,7 +9,7 @@ port: socket to listen on for websocket Example: ::1:8765 --> - ::1:8765 + ::1:9014