diff --git a/client/canvasHandling.js b/client/canvasHandling.js index 1cb8e92..a4c1c67 100644 --- a/client/canvasHandling.js +++ b/client/canvasHandling.js @@ -2,7 +2,7 @@ function resizeVisibleCanvas() { var container = document.getElementById("container"); - if (rotate) { + if (portrait) { var aspectRatio = 1404 / 1872; } else { var aspectRatio = 1872 / 1404; @@ -14,70 +14,28 @@ function resizeVisibleCanvas() { var containerAspectRatio = containerWidth / containerHeight; if (containerAspectRatio > aspectRatio) { + // Canvas is relatively wider than container + //canvas.style.width = '100vw'; + //canvas.style.width = '100%'; + //canvas.style.height = 'auto'; visibleCanvas.style.width = containerHeight * aspectRatio + "px"; visibleCanvas.style.height = containerHeight + "px"; } else { + // Canvas is relatively taller than container + //canvas.style.width = 'auto'; + //canvas.style.height = '100vh'; + //canvas.style.height = '100%'; visibleCanvas.style.width = containerWidth + "px"; visibleCanvas.style.height = containerWidth / aspectRatio + "px"; } - renderCanvas(rawCanvas,visibleCanvas); + canvasPresent.style.width = visibleCanvas.style.width; + canvasPresent.style.height = visibleCanvas.style.height; } function waiting(message) { - var ctx = visibleCanvas.getContext("2d"); - ctx.fillStyle = '#666666'; - ctx.fillRect(0, 0, visibleCanvas.width, visibleCanvas.height); - - var fontSize = 48; - var fontFamily = "Arial"; - var textColor = "red"; - - // Calculate the text dimensions - ctx.font = fontSize + "px " + fontFamily; - var textWidth = ctx.measureText(message).width; - var textHeight = fontSize; - - // Calculate the center position - var centerX = canvas.width / 2; - var centerY = canvas.height / 2; - - // Set the fill style and align the text in the center - ctx.fillStyle = textColor; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - // Draw the text at the center - ctx.fillText(message, centerX, centerY); + // Clear the canvas + gl.clearColor(0, 0, 0, 1); // Set clear color (black, in this case) + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + // To show the message + messageDiv.textContent = message; + messageDiv.style.display = 'block'; } - -function renderCanvas(srcCanvas, dstCanvas) { - let ctxSrc = srcCanvas.getContext('2d'); - let ctxDst = dstCanvas.getContext('2d'); - - let w = srcCanvas.width; - let h = srcCanvas.height; - - // Clear the destination canvas - ctxDst.clearRect(0, 0, w, h); - ctxDst.imageSmoothingEnabled = true; - - - if (rotate) { - // Swap width and height for dstCanvas to accommodate rotated content - dstCanvas.width = h; - dstCanvas.height = w; - ctxDst.translate(0,w); // Move the drawing origin to the right side of dstCanvas - ctxDst.rotate(-Math.PI / 2); // Rotate by 90 degrees - - - // Since the source canvas is now rotated, width and height are swapped - ctxDst.drawImage(srcCanvas, 0, 0); - } else { - dstCanvas.width = w; - dstCanvas.height = h; - ctxDst.drawImage(srcCanvas, 0, 0); - } - - // Reset transformations for future calls - ctxDst.setTransform(1, 0, 0, 1, 0, 0); -} - diff --git a/client/glCanvas.js b/client/glCanvas.js new file mode 100644 index 0000000..794270b --- /dev/null +++ b/client/glCanvas.js @@ -0,0 +1,222 @@ +// WebGL initialization +//const gl = visibleCanvas.getContext('webgl'); +//const gl = canvas.getContext('webgl', { antialias: true, preserveDrawingBuffer: true }); +const gl = canvas.getContext('webgl', { antialias: true }); + + +if (!gl) { + alert('WebGL not supported'); +} + +// Vertex shader program +const vsSource = ` +attribute vec4 aVertexPosition; +attribute vec2 aTextureCoord; +uniform mat4 uRotationMatrix; +uniform float uScaleFactor; +varying highp vec2 vTextureCoord; + +void main(void) { + gl_Position = uRotationMatrix * vec4(aVertexPosition.xy * uScaleFactor, aVertexPosition.zw); + vTextureCoord = aTextureCoord; +} +`; + +// Fragment shader program +const fsSource = ` +varying highp vec2 vTextureCoord; +uniform sampler2D uSampler; + +void main(void) { + gl_FragColor = texture2D(uSampler, vTextureCoord); +} +`; + +function makeRotationZMatrix(angleInDegrees) { + var angleInRadians = angleInDegrees * Math.PI / 180; + var s = Math.sin(angleInRadians); + var c = Math.cos(angleInRadians); + + return [ + c, -s, 0, 0, + s, c, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + ]; +} + +// Initialize a shader program +function initShaderProgram(gl, vsSource, fsSource) { + const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); + const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); + + const shaderProgram = gl.createProgram(); + gl.attachShader(shaderProgram, vertexShader); + gl.attachShader(shaderProgram, fragmentShader); + gl.linkProgram(shaderProgram); + + if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { + alert('Unable to initialize the shader program: ' + gl.getProgramInfoLog(shaderProgram)); + return null; + } + + return shaderProgram; +} + +// Creates a shader of the given type, uploads the source and compiles it. + function loadShader(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + alert('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + + return shader; + } + +const shaderProgram = initShaderProgram(gl, vsSource, fsSource); + +// Collect all the info needed to use the shader program. + // Look up locations of attributes and uniforms used by our shader +const programInfo = { + program: shaderProgram, + attribLocations: { + vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'), + textureCoord: gl.getAttribLocation(shaderProgram, 'aTextureCoord'), + }, + uniformLocations: { + uSampler: gl.getUniformLocation(shaderProgram, 'uSampler'), + }, +}; + +// Create a buffer for the square's positions. + const positionBuffer = gl.createBuffer(); + +// Select the positionBuffer as the one to apply buffer operations to from here out. + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + +// Now create an array of positions for the square. + const positions = [ + 1.0, 1.0, + -1.0, 1.0, + 1.0, -1.0, + -1.0, -1.0, + ]; + +// Pass the list of positions into WebGL to build the shape. + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW); + +// Set up texture coordinates for the rectangle +const textureCoordBuffer = gl.createBuffer(); +gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer); + +const textureCoordinates = [ +// 1.0, 0.0, // Bottom right +// 0.0, 0.0, // Bottom left +// 1.0, 1.0, // Top right +// 0.0, 1.0, // Top left + 1.0, 1.0, + 0.0, 1.0, + 1.0, 0.0, + 0.0, 0.0, +]; + +gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoordinates), gl.STATIC_DRAW); + +// Create a texture. + const texture = gl.createTexture(); +gl.bindTexture(gl.TEXTURE_2D, texture); + + +// Set the parameters so we can render any size image. +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); +// To apply a smoothing algorithm, you'll likely want to adjust the texture filtering parameters in your WebGL setup. +// For smoothing, typically gl.LINEAR is used for both gl.TEXTURE_MIN_FILTER and gl.TEXTURE_MAG_FILTER +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); +gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); +// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); +// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + +// Upload the image into the texture. +let imageData = new ImageData(width, height); +gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imageData); + +// Draw the scene +function drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture) { + if (resizeGLCanvas(gl.canvas)) { + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + } + gl.clearColor(0.5, 0.5, 0.5, 0.25); // Gray with 75% transparency + gl.clearDepth(1.0); // Clear everything + gl.enable(gl.DEPTH_TEST); // Enable depth testing + gl.depthFunc(gl.LEQUAL); // Near things obscure far things + + // Clear the canvas before we start drawing on it. + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + // Tell WebGL to use our program when drawing + gl.useProgram(programInfo.program); + + // Set the shader attributes + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.vertexAttribPointer(programInfo.attribLocations.vertexPosition, 2, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition); + + gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer); + gl.vertexAttribPointer(programInfo.attribLocations.textureCoord, 2, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(programInfo.attribLocations.textureCoord); + + // Tell WebGL we want to affect texture unit 0 + gl.activeTexture(gl.TEXTURE0); + + // Bind the texture to texture unit 0 + gl.bindTexture(gl.TEXTURE_2D, texture); + + // Tell the shader we bound the texture to texture unit 0 + gl.uniform1i(programInfo.uniformLocations.uSampler, 0); + + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); +} + +drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture); + +// Update texture +function updateTexture(newRawData, shouldRotate, scaleFactor) { + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, newRawData); + + // Set rotation + const uRotationMatrixLocation = gl.getUniformLocation(shaderProgram, 'uRotationMatrix'); + const rotationMatrix = shouldRotate ? makeRotationZMatrix(270) : makeRotationZMatrix(0); + gl.uniformMatrix4fv(uRotationMatrixLocation, false, rotationMatrix); + + // Set scaling + const uScaleFactorLocation = gl.getUniformLocation(shaderProgram, 'uScaleFactor'); + gl.uniform1f(uScaleFactorLocation, scaleFactor); + + drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture); +} + +// Call `updateTexture` with new data whenever you need to update the image + +// Let's create a function that resizes the canvas element. +// This function will adjust the canvas's width and height attributes based on its display size, which can be set using CSS or directly in JavaScript. +function resizeGLCanvas(canvas) { + const displayWidth = canvas.clientWidth; + const displayHeight = canvas.clientHeight; + + // Check if the canvas size is different from its display size + if (canvas.width !== displayWidth || canvas.height !== displayHeight) { + // Make the canvas the same size as its display size + canvas.width = displayWidth; + canvas.height = displayHeight; + return true; // indicates that the size was changed + } + + return false; // indicates no change in size +} diff --git a/client/index.html b/client/index.html index 15e5349..1042ff6 100644 --- a/client/index.html +++ b/client/index.html @@ -3,8 +3,8 @@ goMarkableStream - - + +
- - + +
+ +
- + + + diff --git a/client/main.js b/client/main.js index 1e85886..6a81fb1 100644 --- a/client/main.js +++ b/client/main.js @@ -2,59 +2,52 @@ const width = 1872; const height = 1404; const rawCanvas = new OffscreenCanvas(width, height); // Define width and height as needed +let portrait = getQueryParam('portrait'); +portrait = portrait !== null ? portrait === 'true' : false; +let withColor = getQueryParam('color', 'true'); +withColor = withColor !== null ? withColor === 'true' : true; +let rate = parseInt(getQueryParamOrDefault('rate', '200'), 10); + + +//let portrait = false; +// Get the 'present' parameter from the URL +//const presentURL = getQueryParam('present');// Assuming rawCanvas is an OffscreenCanvas that's already been defined +const ctx = rawCanvas.getContext('2d'); const visibleCanvas = document.getElementById("canvas"); +const canvasPresent = document.getElementById("canvasPresent"); +const iFrame = document.getElementById("content"); +const messageDiv = document.getElementById('message'); -// Initialize the worker -const worker = new Worker('worker_stream_processing.js'); -// Send the OffscreenCanvas to the worker for initialization -worker.postMessage({ - type: 'init', - width: width, - height: height -}); +// Initialize the worker +const streamWorker = new Worker('worker_stream_processing.js'); +const eventWorker = new Worker('worker_event_processing.js'); +function getQueryParamOrDefault(param, defaultValue) { + const urlParams = new URLSearchParams(window.location.search); + const value = urlParams.get(param); + return value !== null ? value : defaultValue; +} +//let imageData = ctx.createImageData(width, height); // width and height of your canvas +function getQueryParam(name) { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get(name); +} -// Listen for updates from the worker -worker.onmessage = (event) => { - const data = event.data; - - switch (data.type) { - case 'update': - // Handle the update - const data = event.data.data; - // Assuming rawCanvas is an OffscreenCanvas that's already been defined - const ctx = rawCanvas.getContext('2d'); - - // Create an ImageData object with the provided Uint8ClampedArray - const imageData = new ImageData(data, width, height); - - // Draw the ImageData onto the OffscreenCanvas - ctx.putImageData(imageData, 0, 0); - renderCanvas(rawCanvas,visibleCanvas); - //resizeVisibleCanvas(); - break; - case 'error': - console.error('Error from worker:', event.data.message); - waiting(event.data.message) - // Handle the error, maybe show a user-friendly message or take some corrective action - break; - // ... handle other message types as needed - } -}; window.onload = function() { - // Function to get the value of a query parameter by name - function getQueryParam(name) { - const urlParams = new URLSearchParams(window.location.search); - return urlParams.get(name); - } - - // Get the 'present' parameter from the URL - const presentURL = getQueryParam('present'); - - // Set the iframe source if the URL is available - if (presentURL) { - document.getElementById('content').src = presentURL; - } + // Function to get the value of a query parameter by name + // Get the 'present' parameter from the URL + const presentURL = getQueryParam('present'); + + // Set the iframe source if the URL is available + if (presentURL) { + document.getElementById('content').src = presentURL; + } }; +// Add an event listener for the 'beforeunload' event, which is triggered when the page is refreshed or closed +window.addEventListener('beforeunload', () => { + // Send a termination signal to the worker before the page is unloaded + streamWorker.postMessage({ type: 'terminate' }); + eventWorker.postMessage({ type: 'terminate' }); +}); diff --git a/client/pointer.js b/client/pointer.js new file mode 100644 index 0000000..b047d77 --- /dev/null +++ b/client/pointer.js @@ -0,0 +1,24 @@ +const ctxCanvasPresent = canvasPresent.getContext('2d'); + +// Variables to store the latest positions +let latestX = canvasPresent.width / 2; +let latestY = canvasPresent.height / 2; +let ws; +// Constants for the maximum values from the WebSocket messages +const MAX_X_VALUE = 15725; +const MAX_Y_VALUE = 20966; + +// Function to draw the laser pointer +function drawLaser(x, y) { + ctxCanvasPresent.clearRect(0, 0, canvasPresent.width, canvasPresent.height); // Clear the canvasPresent + ctxCanvasPresent.beginPath(); + ctxCanvasPresent.arc(x, y, 10, 0, 2 * Math.PI, false); // Draw a circle for the laser pointer + ctxCanvasPresent.fillStyle = 'red'; + ctxCanvasPresent.fill(); +} + +// Function to clear the laser pointer +function clearLaser() { + ctxCanvasPresent.clearRect(0, 0, canvasPresent.width, canvasPresent.height); // Clear the canvasPresent +} + diff --git a/client/recording.js b/client/recording.js index a1a1443..f40b641 100644 --- a/client/recording.js +++ b/client/recording.js @@ -70,6 +70,7 @@ function download() { }, 100); } +/* document.getElementById('startStopButtonWithSound').addEventListener('click', function() { let icon = document.getElementById('icon2'); let label = document.getElementById('label2'); @@ -102,6 +103,7 @@ document.getElementById('startStopButton').addEventListener('click', function() stopRecording(); } }); +*/ // JavaScript file (stream.js) function createTempCanvas() { const tempCanvas = document.createElement('canvas'); @@ -128,7 +130,7 @@ function removeTempCanvas() { } let animationFrameId; function updateTempCanvas(tempCanvas) { - renderCanvas(rawCanvas,tempCanvas); + //renderCanvas(rawCanvas,tempCanvas); // Continue updating tempCanvas animationFrameId = requestAnimationFrame(() => updateTempCanvas(tempCanvas)); } diff --git a/client/style.css b/client/style.css index 7159995..ef678bb 100644 --- a/client/style.css +++ b/client/style.css @@ -7,7 +7,7 @@ body, html { #container { width: 100%; - height: 100%; + height: 100vh; display: flex; align-items: center; justify-content: center; @@ -15,25 +15,43 @@ body, html { } #canvas { - position: fixed; - max-width: 100%; - max-height: 100%; + position: absolute; + /* + * max-width: 100vh; + max-height: 100vw; + */ + width: 100vw; /* 100% of the viewport width */ + height: 100vh; /* 100% of the viewport height */ + display: block; /* Remove extra space around the canvas */ + + z-index: 2; } +#content { + position: absolute; + z-index: 1; +} canvas.hidden { display: none; } +#canvasPresent { + position: fixed; + max-width: 100%; + max-height: 100%; + background-color: rgba(0, 0, 0, 0); /* Transparent background */ + z-index: 3; +} .sidebar { width: 150px; height: 100vh; - background-color: #f5f5f5; /* Light gray */ - border-right: 1px solid #e0e0e0; /* Slight border to separate from the main content */ - box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1); + background-color: #f5f5f5; /* Light gray */ + border-right: 1px solid #e0e0e0; /* Slight border to separate from the main content */ + box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1); position: fixed; top: 0; left: -140px; transition: left 0.3s; - z-index: 2; + z-index: 5; } @@ -104,16 +122,16 @@ canvas.hidden { /* Reset button styles to avoid browser defaults */ .apple-button { display: inline-block; - width: 120px; /* Fixed width */ - line-height: 30px; /* Centers the text vertically */ - padding: 0; /* Since we've set fixed width and height, padding is set to 0 */ - border-radius: 8px; - border: none; - font-family: 'San Francisco', 'Helvetica Neue', sans-serif; - font-size: 8px; - cursor: pointer; - transition: background-color 0.3s; - outline: none; + width: 120px; /* Fixed width */ + line-height: 30px; /* Centers the text vertically */ + padding: 0; /* Since we've set fixed width and height, padding is set to 0 */ + border-radius: 8px; + border: none; + font-family: 'San Francisco', 'Helvetica Neue', sans-serif; + font-size: 8px; + cursor: pointer; + transition: background-color 0.3s; + outline: none; } /* Main button styles */ @@ -145,26 +163,26 @@ canvas.hidden { /* Base styles for the toggle button */ .toggle-button { - display: inline-block; - padding: 10px 20px; - border-radius: 8px; - border: none; - font-family: 'San Francisco', 'Helvetica Neue', sans-serif; - font-size: 16px; - cursor: pointer; - transition: background-color 0.3s; - outline: none; + display: inline-block; + padding: 10px 20px; + border-radius: 8px; + border: none; + font-family: 'San Francisco', 'Helvetica Neue', sans-serif; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; + outline: none; } /* Style for the "off" state (default) */ .toggle-button { - background-color: #D1D1D1; /* Gray background */ - color: #6B6B6B; /* Dark gray text */ + background-color: #D1D1D1; /* Gray background */ + color: #6B6B6B; /* Dark gray text */ } /* Style for the "on" state */ .toggle-button.active { - background-color: #007AFF; /* Blue background (like our Apple button) */ - color: #FFFFFF; /* White text */ + background-color: #007AFF; /* Blue background (like our Apple button) */ + color: #FFFFFF; /* White text */ } diff --git a/client/uiInteractions.js b/client/uiInteractions.js index 6e71829..4bc81fa 100644 --- a/client/uiInteractions.js +++ b/client/uiInteractions.js @@ -1,27 +1,48 @@ -let rotate = true; -let withColor = true; let recordingWithSound = false; -document.getElementById('rotate').addEventListener('click', function() { - rotate = !rotate; +document.getElementById('rotate').addEventListener('click', function () { + portrait = !portrait; this.classList.toggle('toggled'); + //visibleCanvas.style.transform = "portrait(270deg)"; + //visibleCanvas.style.transformOrigin = "center center"; + eventWorker.postMessage({ type: 'portrait', portrait: portrait }); + resizeVisibleCanvas(); }); +document.getElementById('pointerButton').addEventListener('click', function () { + if (isWebSocketConnected(ws)) { + stopWebSocket(); + } else { + connectWebSocket(); + } +}); -document.getElementById('colors').addEventListener('click', function() { + +document.getElementById('colors').addEventListener('click', function () { withColor = !withColor; this.classList.toggle('toggled'); - worker.postMessage({ type: 'withColorChanged', withColor: withColor }); + streamWorker.postMessage({ type: 'withColorChanged', withColor: withColor }); }); const sidebar = document.querySelector('.sidebar'); -sidebar.addEventListener('mouseover', function() { +sidebar.addEventListener('mouseover', function () { sidebar.classList.add('active'); }); -sidebar.addEventListener('mouseout', function() { +sidebar.addEventListener('mouseout', function () { sidebar.classList.remove('active'); }); // Resize the canvas whenever the window is resized window.addEventListener("resize", resizeVisibleCanvas); resizeVisibleCanvas(); + +document.getElementById('switchOrderButton').addEventListener('click', function () { + // Swap z-index values + if (iFrame.style.zIndex == 1) { + iFrame.style.zIndex = 4; + return; + } + iFrame.style.zIndex = 1; +}); + + diff --git a/client/utilities.js b/client/utilities.js index c169338..ad754d3 100644 --- a/client/utilities.js +++ b/client/utilities.js @@ -1,15 +1,10 @@ -screenshotButton.addEventListener("click", function() { - const tempCanvas = createTempCanvas(); // Create the temporary canvas - var screenshotDataUrl = tempCanvas.toDataURL("image/png"); - downloadScreenshot(screenshotDataUrl); - removeTempCanvas(); -}); - function downloadScreenshot(dataUrl) { + // Use 'toDataURL' to capture the current canvas content + // Create an 'a' element for downloading var link = document.getElementById("screenshot"); - //var link = document.createElement("a"); - link.href = dataUrl; - link.download = "reMarkable.png"; + + link.download = 'goMarkableScreenshot.png'; + link.href = dataURL; link.click(); } diff --git a/client/worker_event_processing.js b/client/worker_event_processing.js new file mode 100644 index 0000000..edcce28 --- /dev/null +++ b/client/worker_event_processing.js @@ -0,0 +1,96 @@ +let height; +let width; +let wsURL; +let portrait; +let draw; +let latestX; +let latestY; +// Constants for the maximum values from the WebSocket messages +const MAX_X_VALUE = 15725; +const MAX_Y_VALUE = 20966; + +onmessage = (event) => { + const data = event.data; + + switch (data.type) { + case 'init': + height = event.data.height; + width = event.data.width; + wsURL = event.data.wsURL; + portrait = event.data.portrait; + initiateEventsListener(); + break; + case 'portrait': + portrait = event.data.portrait; + // Handle the error, maybe show a user-friendly message or take some corrective action + break; + case 'terminate': + console.log("terminating worker"); + close(); + break; + } +}; + + +async function initiateEventsListener() { + const RETRY_DELAY_MS = 3000; // Delay before retrying the connection (in milliseconds) + ws = new WebSocket(wsURL); + draw = true; + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.Type === 3) { + if (message.Code === 24) { + draw = false; + postMessage({ type: 'clear' }); + // clearLaser(); + } else if (message.Code === 25) { + draw = true; + + } + } + if (message.Type === 3) { + // Code 3: Update and draw laser pointer + if (portrait) { + if (message.Code === 1) { // Horizontal position + latestX = scaleValue(message.Value, MAX_X_VALUE, width); + } else if (message.Code === 0) { // Vertical position + latestY = height - scaleValue(message.Value, MAX_Y_VALUE, height); + } + } else { + // wrong + if (message.Code === 1) { // Horizontal position + latestY = scaleValue(message.Value, MAX_X_VALUE, height); + } else if (message.Code === 0) { // Vertical position + latestX = scaleValue(message.Value, MAX_Y_VALUE, width); + } + } + if (draw) { + postMessage({ type: 'update', X: latestX, Y: latestY }); + } + } + } + + ws.onerror = () => { + postMessage({ + type: 'error', + message: "websocket error", + }); + console.error('WebSocket error occurred. Attempting to reconnect...'); + //setTimeout(connectWebSocket, 3000); // Reconnect after 3 seconds + }; + + ws.onclose = () => { + postMessage({ + type: 'error', + message: 'closed connection' + }); + console.log('WebSocket connection closed. Attempting to reconnect...'); + //setTimeout(connectWebSocket, 3000); // Reconnect after 3 seconds + }; +} + +// Function to scale the incoming value to the canvas size +function scaleValue(value, maxValue, canvasSize) { + return (value / maxValue) * canvasSize; +} + diff --git a/client/worker_stream_processing.js b/client/worker_stream_processing.js index b956078..56623a1 100644 --- a/client/worker_stream_processing.js +++ b/client/worker_stream_processing.js @@ -1,6 +1,7 @@ let withColor=true; let height; let width; +let rate; onmessage = (event) => { const data = event.data; @@ -9,12 +10,19 @@ onmessage = (event) => { case 'init': height = event.data.height; width = event.data.width; + withColor = event.data.withColor; + rate = event.data.rate; initiateStream(); break; case 'withColorChanged': withColor = event.data.withColor; // Handle the error, maybe show a user-friendly message or take some corrective action break; + case 'terminate': + console.log("terminating worker"); + close(); + break; + } }; @@ -25,7 +33,7 @@ async function initiateStream() { try { // Create a new ReadableStream instance from a fetch request - const response = await fetch('/stream'); + const response = await fetch('/stream?rate='+rate); const stream = response.body; // Create a reader for the ReadableStream @@ -38,7 +46,6 @@ async function initiateStream() { var offset = 0; var count = 0; - var value = 0; var lastSum = 0; @@ -125,7 +132,7 @@ async function initiateStream() { // Instead of calling copyCanvasContent(), send the OffscreenCanvas to the main thread postMessage({ type: 'update', data: imageData }); - //} + //} //lastSum = currentSum; } diff --git a/client/workersHandling.js b/client/workersHandling.js new file mode 100644 index 0000000..6a0bf04 --- /dev/null +++ b/client/workersHandling.js @@ -0,0 +1,71 @@ +// Send the OffscreenCanvas to the worker for initialization +streamWorker.postMessage({ + type: 'init', + width: width, + height: height, + rate: rate, + withColor: withColor +}); + + +// Listen for updates from the worker +streamWorker.onmessage = (event) => { + // To hide the message (e.g., when you start drawing in WebGL again) + messageDiv.style.display = 'none'; + + const data = event.data; + + switch (data.type) { + case 'update': + // Handle the update + const data = event.data.data; + updateTexture(data, portrait, 1); + break; + case 'error': + console.error('Error from worker:', event.data.message); + waiting(event.data.message) + // Handle the error, maybe show a user-friendly message or take some corrective action + break; + // ... handle other message types as needed + } +}; + + +// Determine the WebSocket protocol based on the current window protocol +const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; +const wsURL = `${wsProtocol}//${window.location.host}/events`; +// Send the OffscreenCanvas to the worker for initialization +eventWorker.postMessage({ + type: 'init', + width: width, + height: height, + portrait: portrait, + wsURL: wsURL +}); + +// Listen for updates from the worker +eventWorker.onmessage = (event) => { + // To hide the message (e.g., when you start drawing in WebGL again) + messageDiv.style.display = 'none'; + + const data = event.data; + + switch (data.type) { + case 'clear': + clearLaser(); + break; + case 'update': + // Handle the update + const X = event.data.X; + const Y = event.data.Y; + drawLaser(X,Y); + + break; + case 'error': + console.error('Error from worker:', event.data.message); + waiting(event.data.message) + // Handle the error, maybe show a user-friendly message or take some corrective action + break; + // ... handle other message types as needed + } +}; diff --git a/exploration/main.go b/exploration/main.go index 5ed7e77..6b5ce19 100644 --- a/exploration/main.go +++ b/exploration/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/binary" "image" "image/png" "log" @@ -12,8 +13,8 @@ import ( func main() { palette := make(map[uint8]int64) spectre := make(map[uint8]int64) - //testdata := "../testdata/full_memory_region.raw" - testdata := "../testdata/multi.raw" + testdata := "../testdata/full_memory_region.raw" + //testdata := "../testdata/multi.raw" stats, _ := os.Stat(testdata) f, err := os.Open(testdata) if err != nil { @@ -39,9 +40,13 @@ func main() { }, } img := image.NewGray(boundaries) - w := remarkable.ScreenWidth - h := remarkable.ScreenHeight - unflipAndExtract(picture, img.Pix, w, h) + // w := remarkable.ScreenWidth + // h := remarkable.ScreenHeight + + for i := 0; i < len(img.Pix); i++ { + img.Pix[i] = uint8(binary.LittleEndian.Uint16(picture[i*2 : i*2+2])) + } + // unflipAndExtract(picture, img.Pix, w, h) for i := 0; i < len(picture); i += 2 { spectre[picture[i]]++ } @@ -54,11 +59,7 @@ func main() { png.Encode(os.Stdout, img) } func unflipAndExtract(src, dst []uint8, w, h int) { - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - srcIndex := (y*w + x) * 2 // every second byte is useful - dstIndex := (h-y-1)*w + x // unflip position - dst[dstIndex] = src[srcIndex] * 17 - } + for i := 0; i < len(src)-2; i += 2 { + dst[i%2] = uint8(binary.LittleEndian.Uint16(src[i : i+2])) } } diff --git a/exploration/test.png b/exploration/test.png index 338bd0e..ff20948 100644 Binary files a/exploration/test.png and b/exploration/test.png differ diff --git a/go.mod b/go.mod index 0376aea..15faa58 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,15 @@ module github.com/owulveryck/goMarkableStream go 1.20 require ( + github.com/gobwas/ws v1.3.1 github.com/kelseyhightower/envconfig v1.4.0 golang.ngrok.com/ngrok v1.4.1 ) require ( github.com/go-stack/stack v1.8.1 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect github.com/inconshreveable/log15 v3.0.0-testing.3+incompatible // indirect github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect github.com/jpillora/backoff v1.0.0 // indirect diff --git a/go.sum b/go.sum index f830656..f36e2b4 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU= +github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= @@ -29,6 +35,7 @@ golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= diff --git a/gzip.go b/gzip.go new file mode 100644 index 0000000..aafd7fa --- /dev/null +++ b/gzip.go @@ -0,0 +1,31 @@ +package main + +import ( + "compress/gzip" + "io" + "net/http" + "strings" +) + +type gzipResponseWriter struct { + io.Writer + http.ResponseWriter +} + +func (w gzipResponseWriter) Write(b []byte) (int, error) { + return w.Writer.Write(b) +} + +func gzMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Encoding", "gzip") + gz, _ := gzip.NewWriterLevel(w, 1) + defer gz.Close() + gzr := gzipResponseWriter{Writer: gz, ResponseWriter: w} + next.ServeHTTP(gzr, r) + }) +} diff --git a/http.go b/http.go index 4025ff2..339c569 100644 --- a/http.go +++ b/http.go @@ -8,6 +8,8 @@ import ( "net" "net/http" + "github.com/owulveryck/goMarkableStream/internal/eventhttphandler" + "github.com/owulveryck/goMarkableStream/internal/pubsub" "github.com/owulveryck/goMarkableStream/internal/stream" ) @@ -19,7 +21,7 @@ func (s stripFS) Open(name string) (http.File, error) { return s.fs.Open("client" + name) } -func setMux() *http.ServeMux { +func setMuxer(eventPublisher *pubsub.PubSub) *http.ServeMux { mux := http.NewServeMux() fs := http.FileServer(stripFS{http.FS(assetsFS)}) @@ -37,8 +39,16 @@ func setMux() *http.ServeMux { fs.ServeHTTP(w, r) }) - streanHandler := stream.NewStreamHandler(file, pointerAddr) - mux.Handle("/stream", streanHandler) + streamHandler := stream.NewStreamHandler(file, pointerAddr, eventPublisher) + if c.Compression { + mux.Handle("/stream", gzMiddleware(stream.ThrottlingMiddleware(streamHandler))) + } else { + mux.Handle("/stream", stream.ThrottlingMiddleware(streamHandler)) + } + + wsHandler := eventhttphandler.NewEventHandler(eventPublisher) + mux.Handle("/events", wsHandler) + if c.DevMode { rawHandler := stream.NewRawHandler(file, pointerAddr) mux.Handle("/raw", rawHandler) diff --git a/internal/eventhttphandler/handler.go b/internal/eventhttphandler/handler.go new file mode 100644 index 0000000..40de7b1 --- /dev/null +++ b/internal/eventhttphandler/handler.go @@ -0,0 +1,59 @@ +package eventhttphandler + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/gobwas/ws" + "github.com/gobwas/ws/wsutil" + + "github.com/owulveryck/goMarkableStream/internal/events" + "github.com/owulveryck/goMarkableStream/internal/pubsub" +) + +// NewEventHandler creates an event habdler that subscribes from the inputEvents +func NewEventHandler(inputEvents *pubsub.PubSub) *EventHandler { + return &EventHandler{ + inputEventBus: inputEvents, + } +} + +// EventHandler is a http.Handler that servers the input events over http via wabsockets +type EventHandler struct { + inputEventBus *pubsub.PubSub +} + +// ServeHTTP implements http.Handler +func (h *EventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, _, _, err := ws.UpgradeHTTP(r, w) + if err != nil { + http.Error(w, "cannot upgrade connection "+err.Error(), http.StatusInternalServerError) + return + } + eventC := h.inputEventBus.Subscribe("eventListener") + defer func() { + h.inputEventBus.Unsubscribe(eventC) + }() + + for event := range eventC { + // Serialize the structure as JSON + if event.Source != events.Pen { + continue + } + if event.Type != events.EvAbs { + continue + } + jsonMessage, err := json.Marshal(event) + if err != nil { + http.Error(w, "cannot send json encode the message "+err.Error(), http.StatusInternalServerError) + return + } + // Send the JSON message to the WebSocket client + err = wsutil.WriteServerText(conn, jsonMessage) + if err != nil { + log.Println(err) + return + } + } +} diff --git a/internal/events/events.go b/internal/events/events.go new file mode 100644 index 0000000..5ca7ad0 --- /dev/null +++ b/internal/events/events.go @@ -0,0 +1,62 @@ +package events + +import "syscall" + +const ( + // Input event types + // see https://www.kernel.org/doc/Documentation/input/event-codes.txt + + // EvSyn is used as markers to separate events. Events may be separated in time or in + // space, such as with the multitouch protocol. + EvSyn = 0 + // EvKey is used to describe state changes of keyboards, buttons, or other key-like + // devices. + EvKey = 1 + // EvRel is used to describe relative axis value changes, e.g., moving the mouse + // 5 units to the left. + EvRel = 2 + // EvAbs is used to describe absolute axis value changes, e.g., describing the + // coordinates of a touch on a touchscreen. + EvAbs = 3 + // EvMsc is used to describe miscellaneous input data that do not fit into other types. + EvMsc = 4 + // EvSw is used to describe binary state input switches. + EvSw = 5 + // EvLed is used to turn LEDs on devices on and off. + EvLed = 17 + // EvSnd is used to output sound to devices. + EvSnd = 18 + // EvRep is used for autorepeating devices. + EvRep = 20 + // EvFf is used to send force feedback commands to an input device. + EvFf = 21 + // EvPwr is a special type for power button and switch input. + EvPwr = 22 + // EvFfStatus is used to receive force feedback device status. + EvFfStatus = 23 +) + +const ( + // Pen event + Pen int = 1 + // Touch event + Touch int = 2 +) + +// InputEvent from the reMarkable +type InputEvent struct { + Time syscall.Timeval `json:"-"` + Type uint16 + // Code holds the position of the mouse/touch + // In case of an EV_ABS event, + // 1 -> X-axis (vertical movement) | 0 < Value < 15725 if mouse + // 0 -> Y-axis (horizontal movement) | 0 < Value < 20966 if mouse + Code uint16 + Value int32 +} + +// InputEventFromSource add the source origin +type InputEventFromSource struct { + Source int + InputEvent +} diff --git a/internal/pubsub/pubsub.go b/internal/pubsub/pubsub.go new file mode 100644 index 0000000..8022d4c --- /dev/null +++ b/internal/pubsub/pubsub.go @@ -0,0 +1,61 @@ +package pubsub + +import ( + "sync" + "time" + + "github.com/owulveryck/goMarkableStream/internal/events" +) + +// PubSub is a structure to hold publisher and subscribers to events +type PubSub struct { + subscribers map[chan events.InputEventFromSource]bool + mu sync.Mutex +} + +// NewPubSub creates a new pubsub +func NewPubSub() *PubSub { + return &PubSub{ + subscribers: make(map[chan events.InputEventFromSource]bool), + } +} + +// Publish an event to all subscribers +func (ps *PubSub) Publish(event events.InputEventFromSource) { + // Create a ticker for the timeout + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + ps.mu.Lock() + defer ps.mu.Unlock() + + for ch := range ps.subscribers { + select { + case ch <- event: + case <-ticker.C: + } + } +} + +// Subscribe to the topics to get the event published by the publishers +func (ps *PubSub) Subscribe(name string) chan events.InputEventFromSource { + eventChan := make(chan events.InputEventFromSource) + + ps.mu.Lock() + + ps.subscribers[eventChan] = true + ps.mu.Unlock() + + return eventChan +} + +// Unsubscribe from the events +func (ps *PubSub) Unsubscribe(ch chan events.InputEventFromSource) { + ps.mu.Lock() + defer ps.mu.Unlock() + + if _, ok := ps.subscribers[ch]; ok { + delete(ps.subscribers, ch) + close(ch) // Close the channel to signal subscriber to exit. + + } +} diff --git a/internal/remarkable/cmd/main.go b/internal/remarkable/cmd/main.go deleted file mode 100644 index ab0cbac..0000000 --- a/internal/remarkable/cmd/main.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import ( - "context" - "fmt" - "time" - - "github.com/owulveryck/goMarkableStream/internal/remarkable" -) - -func main() { - es := remarkable.NewEventScanner() - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - es.Start(ctx) - - for range es.EventC { - } - fmt.Println("done") - time.Sleep(2 * time.Second) -} diff --git a/internal/remarkable/events.go b/internal/remarkable/events.go index e636d67..9c8d194 100644 --- a/internal/remarkable/events.go +++ b/internal/remarkable/events.go @@ -1,62 +1,41 @@ -//go:build !linux || !arm +//go:build !linux package remarkable import ( "os" - "syscall" "time" "context" -) -const ( - // Input event types - evKey = 1 - evRel = 2 - evAbs = 3 - evMsc = 4 - evSw = 5 - evLed = 17 - evSnd = 18 - evRep = 20 - evFf = 21 - evPwr = 22 - evFfSt = 23 + "github.com/owulveryck/goMarkableStream/internal/events" + "github.com/owulveryck/goMarkableStream/internal/pubsub" ) -// InputEvent is a representation of a mouse movement or a touch movement -type InputEvent struct { - Time syscall.Timeval - Type uint16 - Code uint16 - Value int32 -} - // EventScanner listens to events on input2 and 3 and sends them to the EventC type EventScanner struct { pen, touch *os.File - EventC chan InputEvent } // NewEventScanner ... func NewEventScanner() *EventScanner { - return &EventScanner{ - EventC: make(chan InputEvent), - } + return &EventScanner{} } -// Start the event scanner and feed the EventC on movement. use the context to end the routine -func (e *EventScanner) Start(ctx context.Context) { +// StartAndPublish the event scanner and feed the EventC on movement. use the context to end the routine +func (e *EventScanner) StartAndPublish(ctx context.Context, pubsub *pubsub.PubSub) { go func(ctx context.Context) { - tick := time.NewTicker(4000 * time.Millisecond) + tick := time.NewTicker(500 * time.Millisecond) defer tick.Stop() for { select { case <-ctx.Done(): return case <-tick.C: - e.EventC <- InputEvent{} + pubsub.Publish(events.InputEventFromSource{ + Source: 1, + InputEvent: events.InputEvent{}, + }) } } }(ctx) diff --git a/internal/remarkable/events_arm.go b/internal/remarkable/events_arm.go deleted file mode 100644 index 747832a..0000000 --- a/internal/remarkable/events_arm.go +++ /dev/null @@ -1,117 +0,0 @@ -//go:build linux && arm - -package remarkable - -import ( - "log" - "os" - "syscall" - "unsafe" - - "context" -) - -const ( - // Input event types - evKey = 1 - evRel = 2 - evAbs = 3 - evMsc = 4 - evSw = 5 - evLed = 17 - evSnd = 18 - evRep = 20 - evFf = 21 - evPwr = 22 - evFfSt = 23 -) - -type InputEvent struct { - Time syscall.Timeval - Type uint16 - Code uint16 - Value int32 -} - -type EventScanner struct { - pen, touch *os.File - EventC chan InputEvent -} - -func NewEventScanner() *EventScanner { - pen, err := os.OpenFile("/dev/input/event1", os.O_RDONLY, 0644) - if err != nil { - panic(err) - } - touch, err := os.OpenFile("/dev/input/event2", os.O_RDONLY, 0644) - if err != nil { - panic(err) - } - return &EventScanner{ - pen: pen, - touch: touch, - EventC: make(chan InputEvent), - } -} - -func (e *EventScanner) Start(ctx context.Context) { - penEvent := make(chan InputEvent) - touchEvent := make(chan InputEvent) - - go func(ctx context.Context) { - defer close(e.EventC) - ctx1, cancel := context.WithCancel(ctx) - defer cancel() - defer e.pen.Close() - defer e.touch.Close() - defer close(penEvent) - defer close(touchEvent) - - for { - select { - case <-ctx1.Done(): - return - case evt := <-penEvent: - e.EventC <- evt - case evt := <-touchEvent: - e.EventC <- evt - } - } - }(ctx) - - // Start a goroutine to read events and send them on the channel - go func(ctx context.Context) { - ctx1, cancel := context.WithCancel(ctx) - defer cancel() - for { - var ev InputEvent - _, err := e.pen.Read((*(*[unsafe.Sizeof(ev)]byte)(unsafe.Pointer(&ev)))[:]) - if err != nil { - log.Println(err) - return - } - select { - case <-ctx1.Done(): - return - case penEvent <- ev: - } - } - }(ctx) - go func(ctx context.Context) { - ctx1, cancel := context.WithCancel(ctx) - defer cancel() - for { - var ev InputEvent - _, err := e.touch.Read((*(*[unsafe.Sizeof(ev)]byte)(unsafe.Pointer(&ev)))[:]) - if err != nil { - log.Println(err) - return - } - select { - case <-ctx1.Done(): - return - case touchEvent <- ev: - } - } - }(ctx) -} diff --git a/internal/remarkable/events_linux.go b/internal/remarkable/events_linux.go new file mode 100644 index 0000000..86a6747 --- /dev/null +++ b/internal/remarkable/events_linux.go @@ -0,0 +1,74 @@ +//go:build linux + +package remarkable + +import ( + "log" + "os" + "unsafe" + + "context" + + "github.com/owulveryck/goMarkableStream/internal/events" + "github.com/owulveryck/goMarkableStream/internal/pubsub" +) + +// EventScanner ... +type EventScanner struct { + pen, touch *os.File +} + +// NewEventScanner ... +func NewEventScanner() *EventScanner { + pen, err := os.OpenFile("/dev/input/event1", os.O_RDONLY, 0644) + if err != nil { + panic(err) + } + touch, err := os.OpenFile("/dev/input/event2", os.O_RDONLY, 0644) + if err != nil { + panic(err) + } + return &EventScanner{ + pen: pen, + touch: touch, + } +} + +// StartAndPublish ... +func (e *EventScanner) StartAndPublish(ctx context.Context, pubsub *pubsub.PubSub) { + // Start a goroutine to read events and send them on the channel + go func(_ context.Context) { + for { + ev, err := readEvent(e.pen) + if err != nil { + log.Println(err) + continue + } + pubsub.Publish(events.InputEventFromSource{ + Source: events.Pen, + InputEvent: ev, + }) + } + }(ctx) + // Start a goroutine to read events and send them on the channel + go func(_ context.Context) { + for { + ev, err := readEvent(e.touch) + if err != nil { + log.Println(err) + continue + } + pubsub.Publish(events.InputEventFromSource{ + Source: events.Touch, + InputEvent: ev, + }) + } + }(ctx) +} + +func readEvent(inputDevice *os.File) (events.InputEvent, error) { + var ev events.InputEvent + _, err := inputDevice.Read((*(*[unsafe.Sizeof(ev)]byte)(unsafe.Pointer(&ev)))[:]) + return ev, err + +} diff --git a/internal/rle/rle.go b/internal/rle/rle.go index 735b765..2f60cfe 100644 --- a/internal/rle/rle.go +++ b/internal/rle/rle.go @@ -1,6 +1,7 @@ package rle import ( + "bytes" "io" "sync" @@ -32,7 +33,7 @@ type RLE struct { // combines the count and value into a single uint8, with the count ranging from 0 to 15. // // Implements: io.Writer -func (rlewriter *RLE) Write(data []byte) (n int, err error) { +func (rlewriter *RLE) Write(data []byte) (int, error) { length := len(data) if length == 0 { return 0, nil @@ -57,6 +58,6 @@ func (rlewriter *RLE) Write(data []byte) (n int, err error) { encoded = append(encoded, uint8(count)) encoded = append(encoded, uint8(current)) - n, err = rlewriter.sub.Write(encoded) - return + n, err := io.Copy(rlewriter.sub, bytes.NewBuffer(encoded)) + return int(n), err } diff --git a/internal/stream/handler.go b/internal/stream/handler.go index 4e0de26..17a69f8 100644 --- a/internal/stream/handler.go +++ b/internal/stream/handler.go @@ -1,19 +1,20 @@ package stream import ( - "context" "io" "log" "net/http" + "strconv" "sync" "time" + "github.com/owulveryck/goMarkableStream/internal/pubsub" "github.com/owulveryck/goMarkableStream/internal/remarkable" "github.com/owulveryck/goMarkableStream/internal/rle" ) -const ( - rate = 200 +var ( + rate time.Duration = 200 ) var rawFrameBuffer = sync.Pool{ @@ -23,76 +24,98 @@ var rawFrameBuffer = sync.Pool{ } // NewStreamHandler creates a new stream handler reading from file @pointerAddr -func NewStreamHandler(file io.ReaderAt, pointerAddr int64) *StreamHandler { +func NewStreamHandler(file io.ReaderAt, pointerAddr int64, inputEvents *pubsub.PubSub) *StreamHandler { return &StreamHandler{ - ticker: time.NewTicker(rate * time.Millisecond), - waitingQueue: make(chan struct{}, 1), - file: file, - pointerAddr: pointerAddr, + file: file, + pointerAddr: pointerAddr, + inputEventsBus: inputEvents, } } // StreamHandler is an http.Handler that serves the stream of data to the client type StreamHandler struct { - ticker *time.Ticker - waitingQueue chan struct{} - file io.ReaderAt - pointerAddr int64 - eventLoop *remarkable.EventScanner + file io.ReaderAt + pointerAddr int64 + inputEventsBus *pubsub.PubSub } // ServeHTTP implements http.Handler func (h *StreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - select { - case h.waitingQueue <- struct{}{}: - if h.eventLoop == nil { - h.eventLoop = remarkable.NewEventScanner() - defer func() { - h.eventLoop = nil - }() - h.eventLoop.Start(r.Context()) + // Parse query parameters + query := r.URL.Query() + rateStr := query.Get("rate") + // If 'rate' parameter exists and is valid, use its value + if rateStr != "" { + var err error + rateInt, err := strconv.Atoi(rateStr) + if err != nil { + // Handle error or keep the default value + // For example, you can send a response with an error message + http.Error(w, "Invalid 'rate' parameter", http.StatusBadRequest) + return } - defer func() { - <-h.waitingQueue - }() - //ctx, cancel := context.WithTimeout(r.Context(), 1*time.Hour) - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Hour) - defer cancel() - h.ticker.Reset(rate * time.Millisecond) - defer h.ticker.Stop() + rate = time.Duration(rateInt) + } + if rate < 100 { + http.Error(w, "rate value is too low", http.StatusBadRequest) + return + } + + // Set CORS headers for the preflight request + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + // Send response to preflight request + w.WriteHeader(http.StatusOK) + return + } + + eventC := h.inputEventsBus.Subscribe("stream") + defer h.inputEventsBus.Unsubscribe(eventC) + ticker := time.NewTicker(rate * time.Millisecond) + ticker.Reset(rate * time.Millisecond) + defer ticker.Stop() - rawData := rawFrameBuffer.Get().([]uint8) - defer rawFrameBuffer.Put(rawData) // Return the slice to the pool when done - // the informations are int4, therefore store it in a uint8array to reduce data transfer - rleWriter := rle.NewRLE(w) - extractor := &oneOutOfTwo{rleWriter} - writing := true - stopWriting := time.NewTicker(2 * time.Second) - defer stopWriting.Stop() + rawData := rawFrameBuffer.Get().([]uint8) + defer rawFrameBuffer.Put(rawData) // Return the slice to the pool when done + // the informations are int4, therefore store it in a uint8array to reduce data transfer + rleWriter := rle.NewRLE(w) + extractor := &oneOutOfTwo{rleWriter} + writing := true + stopWriting := time.NewTicker(2 * time.Second) + defer stopWriting.Stop() - w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Connection", "close") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Transfer-Encoding", "chunked") - for { - select { - case <-ctx.Done(): - return - case <-h.eventLoop.EventC: - writing = true - stopWriting.Reset(2 * time.Second) - case <-stopWriting.C: - writing = false - case <-h.ticker.C: - if writing { - _, err := h.file.ReadAt(rawData, h.pointerAddr) - if err != nil { - log.Fatal(err) - } - extractor.Write(rawData) + for { + select { + case <-r.Context().Done(): + return + case <-eventC: + writing = true + stopWriting.Reset(2 * time.Second) + case <-stopWriting.C: + writing = false + case <-ticker.C: + if writing { + _, err := h.file.ReadAt(rawData, h.pointerAddr) + if err != nil { + log.Println(err) + return + } + _, err = extractor.Write(rawData) + if err != nil { + log.Println("Error in writing", err) + return + } + if w, ok := w.(http.Flusher); ok { + w.Flush() } } } - default: - http.Error(w, "too many requests", http.StatusTooManyRequests) - return } } diff --git a/internal/stream/handler_test.go b/internal/stream/handler_test.go new file mode 100644 index 0000000..8f32705 --- /dev/null +++ b/internal/stream/handler_test.go @@ -0,0 +1,77 @@ +package stream + +import ( + "io" + "math/rand" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/owulveryck/goMarkableStream/internal/pubsub" +) + +// Assuming StreamHandler is defined somewhere in your package. +// +// type StreamHandler struct { +// ... +// } +func getFileAndPointer() (io.ReaderAt, int64, error) { + return &dummyPicture{}, 0, nil + +} + +type dummyPicture struct{} + +func (dummypicture *dummyPicture) ReadAt(p []byte, off int64) (n int, err error) { + f, err := os.Open("../../testdata/full_memory_region.raw") + if err != nil { + return 0, err + } + defer f.Close() + return f.ReadAt(p, off) +} + +func TestStreamHandlerRaceCondition(t *testing.T) { + // Initialize your StreamHandler here + file, pointerAddr, err := getFileAndPointer() + if err != nil { + t.Fatal(err) + } + eventPublisher := pubsub.NewPubSub() + handler := NewStreamHandler(file, pointerAddr, eventPublisher) + + server := httptest.NewServer(handler) + defer server.Close() + + // Simulate concurrent requests + concurrentRequests := 100 + doneChan := make(chan struct{}, concurrentRequests) + // Create a HTTP client with a timeout + client := &http.Client{ + Timeout: 10 * time.Millisecond, + } + + for i := 0; i < concurrentRequests; i++ { + go func() { + // Introduce a random delay up to 1 second before starting the request + delay := time.Duration(rand.Intn(50)) * time.Millisecond + time.Sleep(delay) + // Perform an HTTP request to the test server + resp, err := client.Get(server.URL) + if err == nil { + defer resp.Body.Close() + // Optionally read the response body + io.ReadAll(resp.Body) + } + + doneChan <- struct{}{} + }() + } + + // Wait for all goroutines to finish + for i := 0; i < concurrentRequests; i++ { + <-doneChan + } +} diff --git a/internal/stream/mdw.go b/internal/stream/mdw.go new file mode 100644 index 0000000..9ad3aae --- /dev/null +++ b/internal/stream/mdw.go @@ -0,0 +1,39 @@ +package stream + +import ( + "net/http" + "sync" +) + +var ( + activeWriters int + maxWriters = 1 // Maximum allowed writers + mu sync.Mutex + cond = sync.NewCond(&mu) +) + +// ThrottlingMiddleware to allow new connections only if there are no active writers or if max writers is exceeded. +func ThrottlingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + if activeWriters >= maxWriters { + mu.Unlock() + // If too many requests, send a 429 status code + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + + for activeWriters > 0 { + cond.Wait() // Wait for active writers to finish + } + activeWriters++ + mu.Unlock() + + next.ServeHTTP(w, r) // Serve the request + + mu.Lock() + activeWriters-- + cond.Broadcast() // Notify waiting goroutines + mu.Unlock() + }) +} diff --git a/internal/stream/oneoftwo.go b/internal/stream/oneoftwo.go index c10e9d7..ff98ad3 100644 --- a/internal/stream/oneoftwo.go +++ b/internal/stream/oneoftwo.go @@ -1,6 +1,7 @@ package stream import ( + "encoding/binary" "io" "sync" @@ -20,17 +21,8 @@ type oneOutOfTwo struct { func (oneoutoftwo *oneOutOfTwo) Write(src []byte) (n int, err error) { imageData := imagePool.Get().([]uint8) defer imagePool.Put(imageData) // Return the slice to the pool when done - unflipAndExtract(src, imageData, remarkable.ScreenWidth, remarkable.ScreenHeight) - n, err = oneoutoftwo.w.Write(imageData) - return -} - -func unflipAndExtract(src, dst []uint8, w, h int) { - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - srcIndex := (y*w + x) * 2 // every second byte is useful - dstIndex := (h-y-1)*w + x // unflip position - dst[dstIndex] = src[srcIndex] - } + for i := 0; i < remarkable.ScreenHeight*remarkable.ScreenWidth; i++ { + imageData[i] = uint8(binary.LittleEndian.Uint16(src[i*2 : i*2+2])) } + return oneoutoftwo.w.Write(imageData) } diff --git a/main.go b/main.go index 4b5a7cb..790ef5d 100644 --- a/main.go +++ b/main.go @@ -10,15 +10,17 @@ import ( "github.com/kelseyhightower/envconfig" + "github.com/owulveryck/goMarkableStream/internal/pubsub" "github.com/owulveryck/goMarkableStream/internal/remarkable" ) type configuration struct { - BindAddr string `envconfig:"SERVER_BIND_ADDR" default:":2001" required:"true" description:"The server bind address"` - Username string `envconfig:"SERVER_USERNAME" default:"admin"` - Password string `envconfig:"SERVER_PASSWORD" default:"password"` - TLS bool `envconfig:"HTTPS" default:"true"` - DevMode bool `envconfig:"DEV_MODE" default:"false"` + BindAddr string `envconfig:"SERVER_BIND_ADDR" default:":2001" required:"true" description:"The server bind address"` + Username string `envconfig:"SERVER_USERNAME" default:"admin"` + Password string `envconfig:"SERVER_PASSWORD" default:"password"` + TLS bool `envconfig:"HTTPS" default:"true"` + Compression bool `envconfig:"COMPRESSION" default:"false"` + DevMode bool `envconfig:"DEV_MODE" default:"false"` } const ( @@ -60,10 +62,15 @@ func main() { if err != nil { log.Fatal(err) } - mux := setMux() + eventPublisher := pubsub.NewPubSub() + eventScanner := remarkable.NewEventScanner() + eventScanner.StartAndPublish(context.Background(), eventPublisher) + + mux := setMuxer(eventPublisher) // handler := BasicAuthMiddleware(gzMiddleware(mux)) - handler := BasicAuthMiddleware(mux) + var handler http.Handler + handler = BasicAuthMiddleware(mux) if *unsafe { handler = mux } diff --git a/test/main.go b/test/main.go new file mode 100644 index 0000000..8706ce0 --- /dev/null +++ b/test/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "bytes" + "encoding/binary" + "fmt" + "log" +) + +type inputEvent struct { + Type uint16 + Code uint16 + Value int32 +} + +func main() { + event := inputEvent{Type: 1, Code: 2, Value: 3} + + buf := new(bytes.Buffer) + err := binary.Write(buf, binary.LittleEndian, event) + if err != nil { + log.Fatalf("binary.Write failed: %v", err) + } + fmt.Printf("% x", buf.Bytes()) + + // Now buf.Bytes() contains the binary representation of the structure + // You can send this data to a JavaScript environment +} diff --git a/testdata/test.raw b/testdata/test.raw new file mode 100644 index 0000000..6e87117 --- /dev/null +++ b/testdata/test.raw @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29621ce7087c345209915936b561254115ccb9d9403418a41b67e34c8e7a3713 +size 208896 diff --git a/tools/qrcodepdf/GetIPAddresses.pdf b/tools/qrcodepdf/GetIPAddresses.pdf new file mode 100644 index 0000000..b9b628b Binary files /dev/null and b/tools/qrcodepdf/GetIPAddresses.pdf differ diff --git a/tools/qrcodepdf/genIP.arm b/tools/qrcodepdf/genIP.arm new file mode 100755 index 0000000..b1db388 Binary files /dev/null and b/tools/qrcodepdf/genIP.arm differ diff --git a/tools/qrcodepdf/qrcode_10.11.99.3.png b/tools/qrcodepdf/qrcode_10.11.99.3.png new file mode 100644 index 0000000..bbb4868 Binary files /dev/null and b/tools/qrcodepdf/qrcode_10.11.99.3.png differ diff --git a/tools/qrcodepdf/qrcode_192.168.1.44.png b/tools/qrcodepdf/qrcode_192.168.1.44.png new file mode 100644 index 0000000..52911f6 Binary files /dev/null and b/tools/qrcodepdf/qrcode_192.168.1.44.png differ diff --git a/tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:1c71:fd5e:9a28:169e.png b/tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:1c71:fd5e:9a28:169e.png new file mode 100644 index 0000000..d9075ef Binary files /dev/null and b/tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:1c71:fd5e:9a28:169e.png differ diff --git a/tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:d410:6831:4b86:91f.png b/tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:d410:6831:4b86:91f.png new file mode 100644 index 0000000..49695f5 Binary files /dev/null and b/tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:d410:6831:4b86:91f.png differ