Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • hlgr/drawing-app
  • sweng-group-15/drawing-app
2 results
Show changes
This diff is collapsed.
import "array-flat-polyfill"
// Local canvas rendering.
// Emit input events and receive draw calls seperately - these must be piped
// together externally if desired.
import { line, curveCatmullRom } from "d3-shape"
import { line, curveCatmullRom, curveLinear } from "d3-shape"
import { canvas } from "./elements.js"
......@@ -10,46 +12,27 @@ const SVG_URL = "http://www.w3.org/2000/svg"
import * as HTML from "./elements.js"
// TODO: look at paper.js which has path.smooth() and curve.getPart()
// TODO: look at snap.svg which has path.getTotalLength(), path.subpath() and Snap.closestPoint()
// TODO: look at curve interpolation that respects mouse points based on velocity
export const LAST_RECOGNIZED_PATH_ID = "LSP"
const curve = curveCatmullRom.alpha(1.0)
const smoothLine = line()
.x((d) => d[0])
.y((d) => d[1])
.curve(curve)
const straightLine = line()
.x((d) => d[0])
.y((d) => d[1])
.curve(curveLinear)
const pathGroupElems = new Map()
const MAX_POINT_DISTANCE = 5
const MAX_RADIUS_DELTA = 0.05
// Interpolate a path so that:
// - The distance between two adjacent points is capped at MAX_POINT_DISTANCE.
// - The radius delta between two adjacent points is capped at
// MAX_RADIUS_DELTA
// MAX_RADIUS_DELTA.
// If paths are too choppy, try decreasing these constants.
const smoothPath = ([...path]) => {
// Apply MAX_POINT_DISTANCE.
for (let i = 1; i < path.length; i++) {
const dx = path[i][0] - path[i - 1][0]
const dy = path[i][1] - path[i - 1][1]
const dw = path[i][2] - path[i - 1][2]
const distance = Math.hypot(dx, dy)
const segmentsToSplit = Math.ceil(distance / MAX_POINT_DISTANCE)
const newPoints = []
for (let j = 1; j < segmentsToSplit; j++) {
newPoints.push([
path[i - 1][0] + (dx / segmentsToSplit) * j,
path[i - 1][1] + (dy / segmentsToSplit) * j,
path[i - 1][2] + (dw / segmentsToSplit) * j,
path[i - 1][3],
])
}
path.splice(i, 0, ...newPoints)
i += newPoints.length
}
// Apply MAX_RADIUS_DELTA.
for (let i = 1; i < path.length; i++) {
const dx = path[i][0] - path[i - 1][0]
......@@ -75,22 +58,12 @@ export const input = new EventTarget()
const createSvgElem = (tagName) => document.createElementNS(SVG_URL, tagName)
const erasurePoints = (point0, point1, [start, fin]) => {
if (start >= fin) return
if (start <= 0) start = 0
if (fin >= 1) fin = 1
const dx = point1[0] - point0[0]
const dy = point1[1] - point0[1]
const dw = point1[2] - point0[2]
return [start, fin].map((fraction) => [
point0[0] + fraction * dx,
point0[1] + fraction * dy,
point0[2] + fraction * dw,
point0[3],
])
}
const interpolate = (point0, point1, fraction) => [
point0[0] + fraction * (point1[0] - point0[0]),
point0[1] + fraction * (point1[1] - point0[1]),
point0[2] + fraction * (point1[2] - point0[2]),
point0[3],
]
const ensurePathGroupElem = (id) => {
let groupElem = pathGroupElems.get(id)
......@@ -111,26 +84,14 @@ const ensurePathGroupElem = (id) => {
return groupElem
}
const renderPoint = (point) => {
const circleElem = createSvgElem("circle")
circleElem.setAttribute("stroke", "none")
circleElem.setAttribute("fill", point[3])
circleElem.setAttribute("cx", point[0])
circleElem.setAttribute("cy", point[1])
circleElem.setAttribute("r", point[2])
return circleElem
}
const renderSubpath = (subpath) => {
if (subpath.length == 1) {
return renderPoint(subpath[0])
}
const renderSubpath = (subpath, pathSmooth) => {
const pathElem = createSvgElem("path")
pathElem.setAttribute("stroke", subpath[0][3])
pathElem.setAttribute("stroke-width", subpath[0][2] * 2)
pathElem.setAttribute("d", smoothLine(subpath))
pathElem.setAttribute(
"d",
pathSmooth ? smoothLine(subpath) : straightLine(subpath),
)
return pathElem
}
......@@ -138,45 +99,31 @@ const isValidPoint = (point) => {
return point != null && point[0] != null
}
const getEraseIntervalsForPointInPath = (intervals, pathID, pointID) => {
if (!intervals) return undefined
const eraseIntervalsForPath = intervals[pathID]
if (!eraseIntervalsForPath) return undefined
return eraseIntervalsForPath[pointID]
}
const POINT_ERASE_LIMIT = 0.0001
const pointWasErased = (eraseIntervals) => {
const pointWasErased = (pointEraseIntervals) => {
return (
eraseIntervals.length &&
eraseIntervals[0] &&
eraseIntervals[0][0] <= POINT_ERASE_LIMIT
pointEraseIntervals.length > 0 &&
pointEraseIntervals[0][0] <= POINT_ERASE_LIMIT
)
}
const needToDrawLastPoint = (points, pathID, eraseIntervals) => {
const needToDrawLastPoint = (points, pathEraseIntervals) => {
if (points.length < 2) return true
const penultimatePointIndex = points.length - 2
const penPointEraseIntervals = getEraseIntervalsForPointInPath(
eraseIntervals,
pathID,
penultimatePointIndex,
)
const pointEraseIntervals = pathEraseIntervals[penultimatePointIndex] || null
if (
penPointEraseIntervals &&
penPointEraseIntervals.length &&
penPointEraseIntervals.some(
(interval) => interval[1] >= 1 - POINT_ERASE_LIMIT,
)
pointEraseIntervals != null &&
pointEraseIntervals.length > 0 &&
pointEraseIntervals.some((interval) => interval[1] >= 1 - POINT_ERASE_LIMIT)
) {
return false
}
return true
}
const applyErasureIntervals = (pathID, points, erasureIntervals) => {
const applyErasureIntervals = (points, pathEraseIntervals) => {
if (points.length == 0) {
return []
}
......@@ -191,42 +138,33 @@ const applyErasureIntervals = (pathID, points, erasureIntervals) => {
}
const nextPoint = points[i + 1]
const eraseIntervals = getEraseIntervalsForPointInPath(
erasureIntervals,
pathID,
i,
)
const pointEraseIntervals = pathEraseIntervals[i] || null
if (!eraseIntervals) {
if (pointEraseIntervals == null) {
subpath.push(point)
continue
} else if (nextPoint === undefined) {
if (JSON.stringify(eraseIntervals) != "[[0,0]]") subpath.push(point)
} else if (nextPoint == null) {
if (JSON.stringify(pointEraseIntervals) != "[[0,0]]") subpath.push(point)
continue
}
if (!pointWasErased(eraseIntervals) || subpath.length) {
if (!pointWasErased(pointEraseIntervals) || subpath.length) {
subpath.push(point)
}
for (const eraseInterval of eraseIntervals) {
if (!eraseInterval) continue
const erasedIntervalBounds = erasurePoints(
point,
nextPoint,
eraseInterval,
for (const pointEraseInterval of pointEraseIntervals) {
const eraseIntervalBounds = pointEraseInterval.map((f) =>
interpolate(point, nextPoint, f),
)
if (!(erasedIntervalBounds && erasedIntervalBounds.length)) continue
const [endOfDrawnSegment, startOfNewSegment] = erasedIntervalBounds
const [endOfDrawnSegment, startOfNewSegment] = eraseIntervalBounds
if (eraseInterval[0] > POINT_ERASE_LIMIT) {
if (pointEraseInterval[0] > POINT_ERASE_LIMIT) {
subpath.push(endOfDrawnSegment)
}
subpaths.push(subpath.slice())
if (eraseInterval[1] < 1 - POINT_ERASE_LIMIT) {
if (pointEraseInterval[1] < 1 - POINT_ERASE_LIMIT) {
subpath = [startOfNewSegment]
} else {
subpath = []
......@@ -234,7 +172,7 @@ const applyErasureIntervals = (pathID, points, erasureIntervals) => {
}
}
if (needToDrawLastPoint(points, pathID, erasureIntervals)) {
if (needToDrawLastPoint(points, pathEraseIntervals)) {
subpaths.push(subpath.slice())
}
......@@ -255,11 +193,58 @@ export const splitOnPressures = ([...path]) => {
return subpaths
}
export const renderPath = (id, points, erasureIntervals) => {
let subpaths = applyErasureIntervals(id, points, erasureIntervals)
export const renderPath = (id, points, pathEraseIntervals) => {
let rectShapeStartIndex = -1
const pointsWithIntervals = Object.keys(pathEraseIntervals).length
// Rect recognition hint shape: pure rect with no erasure
if (pointsWithIntervals == 0 && points.length == 5) {
rectShapeStartIndex = 0
// Recognised rect shape: rect after completely erased raw data
} else if (pointsWithIntervals > 0 && points.length > 5) {
// Check that the preceding raw data is completely erased
for (let i = 0; i < points.length - 5; i++) {
if (
!pathEraseIntervals[i] ||
pathEraseIntervals[i].length != 1 ||
pathEraseIntervals[i][0][0] != 0 ||
pathEraseIntervals[i][0][1] != 1
) {
break
}
}
rectShapeStartIndex = points.length - 5
}
// Only draw the path smooth if it is not a recognised rect shape, i.e. the last five points form a cycle
const pathSmooth = !(
rectShapeStartIndex >= 0 &&
points[rectShapeStartIndex][0] == points[rectShapeStartIndex + 4][0] &&
points[rectShapeStartIndex][1] == points[rectShapeStartIndex + 4][1] &&
points[rectShapeStartIndex][2] == points[rectShapeStartIndex + 4][2] &&
points[rectShapeStartIndex][3] == points[rectShapeStartIndex + 4][3]
)
let subpaths = applyErasureIntervals(points, pathEraseIntervals)
if (subpaths.length < 1 && id != LAST_RECOGNIZED_PATH_ID) {
const pathGroupElem = pathGroupElems.get(id)
if (pathGroupElem) {
pathGroupElems.delete(id)
HTML.canvas.removeChild(pathGroupElem)
}
return
}
subpaths = subpaths.map(smoothPath)
subpaths = subpaths.flatMap(splitOnPressures)
const subpathElems = subpaths.map((subpath) => renderSubpath(subpath))
const subpathElems = subpaths.map((subpath) =>
renderSubpath(subpath, pathSmooth),
)
const pathGroupElem = ensurePathGroupElem(id)
subpathElems.forEach((subpathElem) => pathGroupElem.appendChild(subpathElem))
}
......
export const userID = { uuid: null }
export const sendListener = { callback: null }
export const broadcastListener = { callback: null }
const eventListeners = new Map()
export const getEventListener = (room, event) =>
eventListeners.get(`${room}:${event}`)
class MockConnection {
constructor({ room }) {
this.room = room
setTimeout(
() =>
getEventListener(room, "roomJoined") &&
getEventListener(room, "roomJoined")(),
0,
)
}
getUserID() {
return userID.uuid
}
getPeerHandle(/*uid*/) {
return undefined
}
getPeerFootprint(/*uid*/) {
return Promise.resolve(Date.now())
}
send(uid, channel, message) {
if (sendListener.callback) {
sendListener.callback(uid, channel, message)
}
}
broadcast(channel, message) {
if (broadcastListener.callback) {
broadcastListener.callback(channel, message)
}
}
terminatePeer() {
// Twiddle thumbs
}
destructor() {
sendListener.callback = null
broadcastListener.callback = null
eventListeners.clear()
}
addEventListener(event, callback) {
eventListeners.set(`${this.room}:${event}`, callback)
}
}
export default MockConnection
import { client, xml } from "@xmpp/client"
import uuidv4 from "uuid/v4"
const ChannelState = {
TRUE: 0,
FALSE: 1,
PROCESSING: 4,
}
const SPY_CALLSIGN = "Baguette: " // Vive la France
const GROUP_MESSAGE_ID = "I smell JOJO!" // Ur. Ugly.
const XMPP_STATUS_ROOM_CREATED = "201" // 201 Created
export default class XMPPConnection extends EventTarget {
constructor(channel, details) {
super()
this.username = SPY_CALLSIGN + uuidv4().toString()
this.channelState = ChannelState.PROCESSING
this.spyNetwork = new Set()
this.details = details
this.channel = channel
this.channelQueue = []
this.online = false
this.queue = []
this.details.fqdn = "conference." + this.details.host
const xmpp = client({
service: `wss://${details.host}:${details.port}/xmpp-websocket`,
domain: details.host,
username: details.username,
password: details.password,
})
this.xmpp = xmpp
xmpp.on("offline", () => {
this.online = false
})
xmpp.on("stanza", (stanza) => {
const stanzaId = stanza.getAttr("id")
const delayed = stanza.getChild("delay")
if (!delayed && stanzaId && stanzaId === GROUP_MESSAGE_ID) {
// Messages sent to the room as echoed back
// Ignore our own messages to prevent loops
// But don't ignore them when we're re-syncing
return
}
if (stanza.is("message")) {
const body = stanza.getChild("body")
if (body === undefined) {
return
}
try {
this.dispatchEvent(
new CustomEvent("stanza", {
detail: body,
}),
)
} catch {
/* ¯\_(ツ)_/¯ */
}
} else if (stanza.is("presence")) {
const from = stanza.getAttr("from")
if (from === undefined) {
// Likely won't happen
return
}
const search = `${this.channel}@${this.details.fqdn}/`
if (from.startsWith(search)) {
const joiner = from.substring(search.length)
if (!joiner.startsWith(SPY_CALLSIGN)) {
return
}
const change = stanza.getAttr("type")
if (change && change === "unavailable") {
this.spyNetwork.delete(joiner)
} else {
this.spyNetwork.add(joiner)
}
this.processChannelStateChange()
}
const x = stanza.getChild("x")
if (x === undefined) {
// Uncertain if this element is guaranteed inside a <presence/>
return
}
const created =
x.getChildByAttr("code", XMPP_STATUS_ROOM_CREATED) !== undefined
if (created) {
// Create an "instant room"
this.acceptDefaultRoomConfiguration()
}
}
})
xmpp.on("online", async (address) => {
/*eslint no-unused-vars: ["error", { "args": "none" }]*/
// Makes itself available
await xmpp.send(xml("presence"))
this.online = true
for (const message of this.queue) {
await this.xmpp.send(message)
}
})
xmpp.start().catch(console.error)
this.joinChannel()
}
joinChannel() {
const channelIdent = `${this.channel}@${this.details.fqdn}/${this.username}`
const presence = xml(
"presence",
{ to: channelIdent },
xml("x", { xmlns: "http://jabber.org/protocol/muc" }),
)
this.sendOrQueue(presence)
}
sendOrQueue(message) {
if (this.online) {
this.xmpp.send(message)
} else {
this.queue.push(message)
}
}
sendChannelOrQueue(message) {
switch (this.channelState) {
case ChannelState.TRUE:
this.sendOrQueue(message)
break
case ChannelState.FALSE:
return
case ChannelState.PROCESSING:
this.channelQueue.push(message)
break
}
}
sendChannelMessage(message) {
const channelIdent = `${this.channel}@${this.details.fqdn}`
const wrappedMessage = xml(
"message",
{
type: "groupchat",
to: channelIdent,
id: GROUP_MESSAGE_ID,
},
xml("body", {}, message),
)
this.sendChannelOrQueue(wrappedMessage)
}
acceptDefaultRoomConfiguration() {
const channelIdent = `${this.channel}@${this.details.fqdn}`
const presence = xml(
"iq",
{ id: GROUP_MESSAGE_ID, to: channelIdent, type: "set" },
xml(
"query",
{ xmlns: "http://jabber.org/protocol/muc#owner" },
xml("x", { xmlns: "jabber:x:data", type: "submit" }),
),
)
this.sendOrQueue(presence)
}
async processChannelStateChange() {
const priority = Array.from(this.spyNetwork).sort()
if (priority[0] === this.username) {
if (this.channelState === ChannelState.PROCESSING) {
for (const message of this.channelQueue) {
await this.sendOrQueue(message)
}
}
this.channelState = ChannelState.TRUE
} else {
this.channelState = ChannelState.FALSE
}
}
sneakilySendTheOtherTeamOur(secrets) {
this.sendChannelMessage(secrets)
}
}
Subproject commit 139ab6e2cc9d6f0501fa958f3c813df0fcc81310
export const userIDElem = document.getElementById("user-id")
export const peerIDElem = document.getElementById("peer-id")
export const peerButton = document.getElementById("peer-connect")
export const connectedPeers = document.getElementById("connected-peers")
export const overallStatusIcon = document.getElementById("overall-status-icon")
......@@ -12,6 +8,12 @@ export const overallStatusIconImage = document.getElementById(
export const canvas = document.getElementById("canvas")
export const penButton = document.getElementById("pen-tool")
export const eraserButton = document.getElementById("eraser-tool")
export const recognitionModeButton = document.getElementById("recognition-mode")
export const draggingToolButton = document.getElementById("dragging-tool")
export const canvasCenterToolButton = document.getElementById("canvas-center")
export const fastUndoButton = document.getElementById("fast-undo-tool")
export const undoButton = document.getElementById("undo-tool")
export const roomIDElem = document.getElementById("room-id")
export const roomConnectButton = document.getElementById("room-connect")
......@@ -25,7 +27,9 @@ export const closeButton = document.querySelectorAll(".close")
export const palette = document.getElementById("palette")
export const rectangle = document.getElementById("rectangle")
export const wheel = document.getElementById("wheel")
export const picker = document.getElementById("other-colours")
export const slider = document.getElementById("range")
export const strokeColorPicker = document.getElementById("other-colours")
export const strokeRadiusSlider = document.getElementById("range")
export const output = document.getElementById("value")
export const labelColours = document.getElementById("colours")
export const userInfo = document.getElementById("user-avatar")
export const topPanel = document.getElementById("top-panel")
import XMPP from "./connection/XMPP.js"
const CORRECTION_OFFSET = 5000
const HOST = "xmpp.lets-draw.live"
const PORT = 5281
const USERNAME = "beartest"
const PASSWORD = "beartest"
let disableSend = false
const divulgedUpTo = new Map()
const pointPresenceMap = new Map()
const pathIDsByXCDPIdentifier = new Map()
const attributesByXCDPIdentifier = new Map()
function pointFromProtocol(point) {
return Math.round(point + CORRECTION_OFFSET)
}
function pointToProtocol(point) {
return [point[0] - CORRECTION_OFFSET, point[1] - CORRECTION_OFFSET]
}
export default class Exfiltrator {
constructor(channel, room) {
this.room = room
this.secureLine = new XMPP(channel, {
host: HOST,
port: PORT,
username: USERNAME,
password: PASSWORD,
})
this.secureLine.addEventListener("stanza", ({ detail: content }) =>
this.onStanza(content),
)
}
onAddOrUpdatePath(id, points) {
if (points.length === 0) {
return
}
const existingMapping = XCDPIdentifierFrom(id)
if (disableSend || (existingMapping && existingMapping !== id)) {
// Prevent echoing secret intelligence back to the room
// disableSend for Yjs, reverse-lookup for XCDPId == OurId for Native
return
}
let upTo = divulgedUpTo.get(id)
if (upTo === undefined) {
pathIDsByXCDPIdentifier.set(id, id)
upTo = 0
}
if (upTo === 0) {
const point = points[0]
const colour = point[3]
const R = parseInt(colour.substring(1, 3), 16)
const G = parseInt(colour.substring(3, 5), 16)
const B = parseInt(colour.substring(5, 7), 16)
this.secureLine.sneakilySendTheOtherTeamOur(
JSON.stringify({
type: "ADD",
identifier: id,
weight: point[2],
colour: [R, G, B],
start: pointToProtocol(point),
}),
)
upTo++
}
const batch = []
for (; upTo !== points.length; upTo++) {
const point = points[upTo]
batch.push(pointToProtocol(point))
}
if (batch.length !== 0) {
this.secureLine.sneakilySendTheOtherTeamOur(
JSON.stringify({
type: "APPEND",
identifier: id,
points: batch,
}),
)
}
divulgedUpTo.set(id, upTo)
}
onRemovedIntervalsChange(id, intervals) {
const points = this.room.getPathPoints(id)
extendPointPresenceMapFor(id, points.length)
for (const offset in intervals) {
this.deleteInterval(id, parseInt(offset), intervals[offset])
}
}
deleteInterval(lineID, offset, interval) {
const bLine = pointPresenceMap.get(lineID)
if (!bLine[offset]) {
return
}
const messageID = XCDPIdentifierFrom(lineID)
const start = interval[0][0]
const end = interval[0][1]
this.secureLine.sneakilySendTheOtherTeamOur(
JSON.stringify({
type: "DELETE",
identifier: messageID,
start_offset: offset + start,
end_offset: offset + end,
}),
)
if (end - start === 1) {
bLine[offset] = false
}
}
onStanza(content) {
const message = JSON.parse(content.children[0])
const ourID = pathIDsByXCDPIdentifier.get(message.identifier)
if (message.type === "ADD") {
if (ourID !== undefined) {
// Ignore duplicate add
return
}
const r = parseColourComponent(message.colour[0])
const g = parseColourComponent(message.colour[1])
const b = parseColourComponent(message.colour[2])
const attributes = { weight: message.weight, colour: "#" + r + g + b }
const initialPoint = [
pointFromProtocol(message.start[0]),
pointFromProtocol(message.start[1]),
attributes.weight,
attributes.colour,
]
attributesByXCDPIdentifier.set(message.identifier, attributes)
disableSend = true
pathIDsByXCDPIdentifier.set(
message.identifier,
this.room.addPathRemote(initialPoint),
)
disableSend = false
} else if (message.type === "APPEND") {
if (ourID === undefined) {
// They're trying to hack us with an ID that wasn't added
// Plan of action: initiate DDOS against them in retaliation
return
}
const attributes = attributesByXCDPIdentifier.get(message.identifier)
disableSend = true
for (let index = 0; index != message.points.length; index++) {
const point = message.points[index]
const toAdd = [
pointFromProtocol(point[0]),
pointFromProtocol(point[1]),
attributes.weight,
attributes.colour,
]
this.room.extendPathRemote(ourID, toAdd)
}
disableSend = false
} else if (message.type === "DELETE") {
if (ourID === undefined) {
// Ditto above. Alternative explanation: exfiltrator was started late and missed the add
return
}
const offset = parseInt(message.start_offset)
extendPointPresenceMapFor(ourID, offset)
this.room.extendErasureIntervals(ourID, offset, [
[message.start_offset - offset, message.end_offset - offset],
])
}
}
}
function extendPointPresenceMapFor(pathID, minLength) {
const mapping = pointPresenceMap.get(pathID)
if (mapping === undefined) {
pointPresenceMap.set(pathID, Array(minLength).fill(true))
} else if (mapping.length < minLength) {
// Extend array with new points
// For example, line is being inked, then someone erases a bit (causing creation of the presence entry)
// But the inking continues, followed by another erasure later on
mapping.push.apply(mapping, Array(minLength - mapping.length).fill(true))
}
}
const XCDPIdentifierFrom = (lineID) => {
for (const [XCDPID, ourID] of pathIDsByXCDPIdentifier.entries()) {
if (ourID === lineID) {
return XCDPID
}
}
}
function parseColourComponent(component) {
return component.toString(16).padStart(2, "0")
}
"use strict"
export default class P2PMesh {
constructor(crdt, options) {
if (options === undefined) {
throw new Error("Options must not be undefined!")
}
this.crdt = crdt
this.options = options
this.options.mesh = this.options.mesh || {}
this.options.mesh.minPeers = this.options.mesh.minPeers || 4
this.options.mesh.maxPeers = this.options.mesh.maxPeers || 8
this.options.handshake = this.options.handshake || {}
this.options.handshake.initial = this.options.handshake.initial || 100
this.options.handshake.interval = this.options.handshake.interval || 500
this.options.heartbeat = this.options.heartbeat || {}
this.options.heartbeat.interval = this.options.heartbeat.interval || 500
this.options.heartbeat.minimum = this.options.heartbeat.minimum || 1000
this.options.heartbeat.timeout = this.options.heartbeat.timeout || 10000
this.queue = new Worker("js/queue.js")
this.queue.onmessage = (event) => {
if (!this.crdt) {
return
}
const method = event.data.method
if (method == "send") {
const { uid, channel, message } = event.data
// CRDT (e.g. y-js db transactions) can send messages after a peer has disconnected
if (channel == "crdt" && !this.peers.has(uid)) {
return
}
this.connection.send(uid, channel, message)
} else if (method == "broadcast") {
const { channel, message } = event.data
return this.connection.broadcast(channel, message)
} else if (method == "received") {
const { uid, channel, message } = event.data
if (channel === "tw-ml") {
// Handshakes can only be sent and received directly
if (message === "tw") {
// Response message in the handshake
this.queue.postMessage({
method: "send",
uid,
channel: "tw-ml",
message: "ml",
compressed: false,
})
} else if (message == "ml") {
// Handshake completed
this.checkAndInsertPeer(uid)
}
} else {
this.checkAndInsertPeer(uid)
if (channel === "crdt") {
this.checkAndInsertPeer(uid)
this.crdt.receiveMessage(uid, message)
}
}
}
}
this.initialiseConnection()
}
initialiseConnection() {
this.peers = new Map()
this.connection = new this.options.connection(this.options)
this.connection.addEventListener("roomJoined", () => {
this.checkAndEnsureUser()
})
this.connection.addEventListener("roomLeft", () => {
console.log("TODO: LEFT ROOM")
})
this.connection.addEventListener("channelOpened", ({ detail: uid }) => {
this.checkAndEnsureUser()
// Start a handshake to ensure both sides are able to use the channel
function handshake(peer) {
const _peer = this.connection.getPeerHandle(uid)
if (!_peer || _peer !== peer) {
return
}
if (this.peers.has(uid)) {
return
}
// Initial message in the handshake
this.queue.postMessage({
method: "send",
uid,
channel: "tw-ml",
message: "tw",
compressed: false,
})
setTimeout(handshake.bind(this, peer), this.options.handshake.interval)
}
setTimeout(
handshake.bind(this, this.connection.getPeerHandle(uid)),
this.options.handshake.initial,
)
})
this.connection.addEventListener("channelError", ({ detail: uid }) =>
console.log("TODO: CHANNEL ERROR", uid),
)
this.connection.addEventListener("channelClosed", ({ detail: uid }) => {
this.checkAndEnsureUser()
this.checkAndRemovePeer(uid)
})
this.connection.addEventListener(
"messageReceived",
({ detail: { uid, channel, message } }) => {
this.checkAndEnsureUser()
this.queue.postMessage({
method: "received",
uid,
channel,
message,
})
},
)
}
// Ensure that the crdt is up to date on the user's id
checkAndEnsureUser() {
if (!this.crdt) {
return
}
const uid = this.connection.getUserID()
if (this.crdt.getUserID() == uid) {
return
}
this.crdt.setUserID(uid)
}
// Ensure that the crdt knows that the peer has joined
checkAndInsertPeer(uid) {
if (!this.crdt) {
return
}
if (this.peers.has(uid)) {
return
}
const health = {
lastFootprintResolved: true,
lastFootprint: 0,
lastFootprintTimestamp: Date.now(),
}
health.cb = setInterval(
this.heartbeat.bind(
this,
uid,
this.connection.getPeerHandle(uid),
health,
),
this.options.heartbeat.interval,
)
this.peers.set(uid, health)
this.crdt.userJoined(uid)
}
heartbeat(uid, peer, health) {
const _peer = this.connection.getPeerHandle(uid)
if (!_peer || _peer !== peer || !this.peers.has(uid)) {
clearInterval(health.cb)
return
}
if (!health.lastFootprintResolved) {
return this.connection.terminatePeer(uid)
}
health.lastFootprintResolved = false
const self = this
this.connection
.getPeerFootprint(uid)
.then((footprint) => {
health.lastFootprintResolved = true
const timeSinceLastFootprint =
Date.now() - health.lastFootprintTimestamp
if (footprint != health.lastFootprint) {
health.lastFootprint = footprint
health.lastFootprintTimestamp = Date.now()
} else if (timeSinceLastFootprint > self.options.heartbeat.timeout) {
return this.connection.terminatePeer(uid)
} else if (timeSinceLastFootprint > self.options.heartbeat.interval) {
self.queue.postMessage({
method: "send",
uid,
channel: "heartbeat",
})
}
this.crdt.reportConnectionQuality(
uid,
1.0 -
(self.options.heartbeat.timeout -
Math.max(
timeSinceLastFootprint,
self.options.heartbeat.minimum,
)) /
(self.options.heartbeat.timeout - self.options.heartbeat.minimum),
)
})
.catch(() => {
return this.connection.terminatePeer(uid)
})
}
// Ensure that the crdt knows that the peer has left
checkAndRemovePeer(uid) {
if (!this.crdt) {
return
}
if (!this.peers.has(uid)) {
return
}
this.peers.delete(uid)
this.crdt.userLeft(uid)
}
disconnect() {
this.queue.terminate()
this.connection.destructor()
this.crdt = null
}
send(uid, message, compressed = true) {
this.queue.postMessage({
method: "send",
uid,
channel: "crdt",
message,
compressed,
})
}
broadcast(message, compressed = true) {
this.queue.postMessage({
method: "broadcast",
channel: "crdt",
message,
compressed,
})
}
}
This diff is collapsed.
This diff is collapsed.
......@@ -45,6 +45,17 @@ app.use((request, response, next) => {
next()
})
app.get("/", (request, response, next) => {
if (request.query.room == null) {
const url = new URL(
`${request.protocol}://${request.get("host")}${request.originalUrl}`,
)
url.searchParams.set("room", "imperial")
return response.redirect(url)
}
next()
})
app.use("/", express.static("public"))
server.listen(port, host, () => {
......
This diff is collapsed.
Subproject commit 04ea4e5081059faffdebae65c92da25fa98f996d
Subproject commit 216be42b2a6cc5632d427cf222c789d34026a274
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Subproject commit c2a07807dfaa1e5e6b667d83aa4a7d348c759c0f
Subproject commit 44aa194d19217cbc1d8e0828cf8fdf39fc4dcdd3