├── .github ├── FUNDING.yml └── workflows │ ├── go.yml │ └── goreleaser.yml ├── .gitignore ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── assets ├── cert.pem └── key.pem ├── auth.go ├── client ├── canvasHandling.js ├── favicon.ico ├── favicon.png ├── glCanvas.js ├── index.html ├── main.js ├── recording.js ├── style.css ├── uiInteractions.js ├── utilities.js ├── worker_event_processing.js ├── worker_gesture_processing.js ├── worker_stream_processing.js └── workersHandling.js ├── docs ├── goMarkableStream.png ├── goMarkableStreamRecording.mp4 ├── goMarkableStreamRecording.webm ├── gorgoniaExample.png └── logo.png ├── go.mod ├── go.sum ├── gzip.go ├── http.go ├── ifaces.go ├── internal ├── eventhttphandler │ ├── gesture_handler.go │ └── pen_handler.go ├── events │ └── events.go ├── pubsub │ └── pubsub.go ├── remarkable │ ├── const.go │ ├── const_arm64.go │ ├── device.go │ ├── events.go │ ├── events_linux.go │ ├── fb.go │ ├── fb_rm.go │ ├── findpid.go │ ├── pointer.go │ └── pointer_arm64.go ├── rle │ └── rle.go └── stream │ ├── benchfetchandsend_test.go │ ├── handler.go │ ├── handler_test.go │ ├── mdw.go │ ├── mockreaderat_test.go │ ├── oneoftwo.go │ └── raw.go ├── listener.go ├── main.go ├── test └── main.go ├── testdata ├── .gitattributes ├── colorful.raw └── full_memory_region.raw ├── tests └── main.go ├── tools ├── qrcodepdf │ ├── GetIPAddresses.pdf │ ├── README.md │ ├── files.go │ ├── genIP.arm │ ├── go.mod │ ├── go.sum │ ├── goMarkableStreamQRCode.pdf │ ├── main.go │ ├── qrcode_10.11.99.3.png │ ├── qrcode_192.168.1.44.png │ ├── qrcode_2a01:cb0c:604:4700:1c71:fd5e:9a28:169e.png │ └── qrcode_2a01:cb0c:604:4700:d410:6831:4b86:91f.png ├── raw_client │ ├── README.md │ └── client.go ├── service │ ├── README.md │ └── goMarkableStream.service └── utils │ └── genSignature │ ├── README.md │ ├── getmarker.go │ └── tmpl.go ├── zstd.go └── zstd_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [owulveryck] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.24.0 19 | 20 | - name: Build 21 | run: go build -v ./... 22 | 23 | - name: Test 24 | run: go test -v ./... 25 | 26 | - name: GoGitOps Step 27 | id: gogitops 28 | uses: beaujr/gogitops-action@v0.2 29 | with: 30 | github-actions-user: owulveryck 31 | github-actions-token: ${{secrets.GITHUB_TOKEN}} 32 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.24.0 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v2 22 | with: 23 | version: latest 24 | args: release --clean 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | certs/certs.bin 3 | server/streamServerTLS.arm 4 | .gitignore 5 | client/client 6 | server/server 7 | .DS_Store 8 | utils/genSignature/utils 9 | .DS_Store 10 | .gitignore 11 | .gitignore 12 | .DS_Store 13 | misc/findIP/findIP 14 | .gitignore 15 | internal/client/client.test 16 | internal/client/memprofile.out 17 | internal/client/profile.out 18 | internal/client/benchs/** 19 | client/imageH4.bench 20 | client/goMarkableClient 21 | .gitignore 22 | internal/client/div.bench 23 | internal/certificate/config_carrier.go 24 | internal/client/imageH5.bench 25 | internal/client/imageH5.out 26 | internal/client/imageH6.bench 27 | internal/client/imageH6.out 28 | internal/client/initial.bench 29 | internal/client/memprofileImageH5.out 30 | internal/client/memprofileImageH6.out 31 | server/htdocs/assets/toEpub.wasm 32 | client/htdocs/assets/toEpub.wasm 33 | goMarkableStream 34 | testServer.arm 35 | testdata/36.raw 36 | ./dist 37 | ./testdata 38 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod download 6 | 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | id: "Remarkable Paper Pro" 11 | main: . 12 | binary: goMarkableStream 13 | goos: 14 | - linux 15 | goarch: 16 | - arm64 17 | goarm: 18 | - "7" 19 | - env: 20 | - CGO_ENABLED=0 21 | id: "Remarkable 2" 22 | main: . 23 | binary: goMarkableStream 24 | goos: 25 | - linux 26 | goarch: 27 | - arm 28 | goarm: 29 | - "7" 30 | 31 | archives: 32 | - name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}" 33 | wrap_in_directory: true 34 | format_overrides: 35 | - goos: windows 36 | format: zip 37 | 38 | checksum: 39 | name_template: "checksums.txt" 40 | 41 | changelog: 42 | sort: asc 43 | filters: 44 | exclude: 45 | - "^docs:" 46 | - "^test:" 47 | 48 | release: 49 | github: 50 | owner: owulveryck 51 | name: gomarkablestream 52 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | olivier dot wulveryck at gmail dot com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the goMarkableStream project 2 | 3 | Hi there! We're thrilled that you'd like to contribute to the goMarkableStream project/product. 4 | 5 | This project is, as of today (september 2021), a toy project that I (owulveryck) maintain on my free time. 6 | The CLI toEpub is part of my daily routine, and I am looking to make it more robust so others can use it safely. 7 | This takes some time, and, as an open source maintener, I am greatful that you are considering helping this project to give some time and some skills to the community. 8 | 9 | Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. 10 | 11 | ## Looking for support? 12 | 13 | If you want to contribute and your are lost in the code, you can fill an issue, and I will do my very best to help you. 14 | 15 | ## How to report a bug 16 | 17 | Think you found a bug? Please check [the list of open issues](https://github.com/owulveryck/goMarkableStream/issues) to see if your bug has already been reported. If it hasn't please [submit a new issue](https://github.com/owulveryck/goMarkableStream/issues/new). 18 | 19 | Here are a few tips for writing *great* bug reports: 20 | 21 | * Describe the specific problem (e.g., "widget doesn't turn clockwise" versus "getting an error") 22 | * Include the steps to reproduce the bug, what you expected to happen, and what happened instead 23 | * Check that you are using the latest version of the project and its dependencies 24 | * Include what version of the project your using, as well as any relevant dependencies 25 | * Only include one bug per issue. If you have discovered two bugs, please file two issues 26 | * Even if you don't know how to fix the bug, including a failing test may help others track it down 27 | 28 | **If you find a security vulnerability, do not open an issue. Please email olivier.wulveryck à gmail.com instead.** 29 | 30 | ## How to suggest a feature or enhancement 31 | 32 | If you find yourself wishing for a feature that doesn't exist in the project, you are probably not alone. There are bound to be others out there with similar needs. Many of the features that the Minimal theme has today have been added because our users saw the need. 33 | 34 | Feature requests are welcome. But take a moment to find out whether your idea fits with the scope and goals of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Please provide as much detail and context as possible, including describing the problem you're trying to solve. 35 | 36 | [Open an issue](https://github.com/owulveryck/goMarkableStream/issues/new) which describes the feature you would like to see, why you want it, how it should work, etc. 37 | 38 | ## Your first contribution 39 | 40 | We'd love for you to contribute to the project. Unsure where to begin contributing to the project? You can start by looking through these "good first issue" and "help wanted" issues: 41 | 42 | * [Good first issues](https://github.com/owulveryck/goMarkableStream/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) - issues which should only require a few lines of code and a test or two 43 | * [Help wanted issues](https://github.com/owulveryck/goMarkableStream/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) - issues which may be a bit more involved, but are specifically seeking community contributions 44 | 45 | *p.s. Feel free to ask for help; everyone is a beginner at first* :smiley_cat: 46 | 47 | ## How to propose changes 48 | 49 | Here's a few general guidelines for proposing changes: 50 | 51 | * Each pull request should implement **one** feature or bug fix. If you want to add or fix more than one thing, submit more than one pull request 52 | * Do not commit changes to files that are irrelevant to your feature or bug fix 53 | * Don't bump the version number in your pull request (it will be bumped prior to release) 54 | * Write [a good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 55 | 56 | At a high level, [the process for proposing changes](https://guides.github.com/introduction/flow/) is: 57 | 58 | 1. [Fork](https://github.com/pages-themes/minimal/fork) and clone the project 59 | 2. Make sure the tests pass on your machine 60 | 3. Create a new branch: `git checkout -b my-branch-name` 61 | 4. Make your change, add tests, and make sure the tests still pass 62 | 5. Push to your fork and [submit a pull request](https://github.com/owulveryck/goMarkableStream/compare) 63 | 6. Pat your self on the back and wait for your pull request to be reviewed and merged 64 | 65 | **Interesting in submitting your first Pull Request?** It's easy! You can learn how from this *free* series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) 66 | 67 | ## Bootstrapping your local development environment 68 | 69 | `GO111MODULES=off go get github.com/owulveryck/goMarkableStream` 70 | 71 | ## Running tests 72 | 73 | `go test ./...` 74 | 75 | ## Code of conduct 76 | 77 | This project is governed by [the Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. 78 | 79 | ## Additional Resources 80 | 81 | * [Contributing to Open Source on GitHub](https://guides.github.com/activities/contributing-to-open-source/) 82 | * [Using Pull Requests](https://help.github.com/articles/using-pull-requests/) 83 | * [GitHub Help](https://help.github.com) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Olivier Wulveryck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export GOOS ?= linux 2 | export CGO_ENABLED ?= 0 3 | export GOARCH ?= amd64 4 | 5 | .PHONY: build-remarkable-2 6 | build-remarkable-2: GOARCH=arm 7 | build-remarkable-2: build 8 | 9 | .PHONY: build-remarkable-paper-pro 10 | build-remarkable-paper-pro: GOARCH=arm64 11 | build-remarkable-paper-pro: build 12 | 13 | .PHONY: build 14 | build: 15 | @echo "Building for GOOS=${GOOS}, GOARCH=${GOARCH}, CGO_ENABLED=${CGO_ENABLED}" 16 | go build -v -trimpath -ldflags="-s -w" . 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go](https://github.com/owulveryck/goMarkableStream/actions/workflows/go.yml/badge.svg)](https://github.com/owulveryck/goMarkableStream/actions/workflows/go.yml) 2 | ![Static Badge](https://img.shields.io/badge/reMarkable-Compatible_with_FW_3.9-green) 3 | 4 | # goMarkableStream 5 | 6 | ![poster](docs/goMarkableStream.png) 7 | 8 | ## Overview 9 | 10 | The goMarkableStream is a lightweight and user-friendly application designed specifically for the reMarkable tablet. 11 | 12 | Its primary goal is to enable users to stream their reMarkable tablet screen to a web browser without the need for any hacks or modifications that could void the warranty. 13 | 14 | ## Device support 15 | 16 | - Remarkable 2 17 | - Remarkable Paper Pro (see notes below) 18 | 19 | ### Remarkable Paper Pro 20 | 21 | Remarkable Paper Pro is in initial support. The application is not yet fully tested on this device and some features may not work as expected. 22 | 23 | When running on the Remarkable Paper Pro, `RLE_COMPRESSION` environment variable must be set to `false` since it's not supported. 24 | 25 | ## Version support 26 | 27 | - reMarkable with firmware < 3.4 may use goMarkableStream version < 0.8.6 28 | - reMarkable with firmware >= 3.4 and < 3.6 may use version >= 0.8.6 and < 0.11.0 29 | - reMarkable with firmware >= 3.6 may use version >= 0.11.0 30 | 31 | ## Features 32 | 33 | - **No Warranty Voiding**: Operates within the reMarkable tablet's intended functionality without unauthorized modifications. 34 | - **No Subscription Required**: Completely free to use, with no subscription fees or recurring charges. 35 | - **No Client-Side Installation**: Access the screen streaming feature directly through a web browser, with no need for additional software or plugins. 36 | - **Color Support**: Enhanced streaming experience with color support. 37 | - **High Performance**: Utilizes WebGL for smooth and efficient performance. 38 | - **Laser Pointer**: Features a laser pointer that activates on hovering. 39 | - **Gesture Support**: Full integration with Reveal.js, allowing slide switching directly from the reMarkable. 40 | - **Overlay Feature**: Allows overlaying over existing websites that support iframe embedding. 41 | - **Live Parameter Tweaking**: Side menu for live adjustments, including screen rotation. 42 | - **Dark Mode**: Toggle between light and dark themes for comfortable viewing in any environment. 43 | - **Version API**: Check the current version via the `/version` endpoint. 44 | 45 | ## Quick Start 46 | 47 | ```bash 48 | localhost> ssh root@remarkable 49 | ``` 50 | 51 | For version >= 3.6 52 | 53 | ```bash 54 | export GORKVERSION=$(wget -q -O - https://api.github.com/repos/owulveryck/goMarkableStream/releases/latest | grep tag_name | awk -F\" '{print $4}') 55 | wget -q -O - https://github.com/owulveryck/goMarkableStream/releases/download/$GORKVERSION/goMarkableStream_${GORKVERSION//v}_linux_arm.tar.gz | tar xzvf - -O goMarkableStream_${GORKVERSION//v}_linux_arm/goMarkableStream > goMarkableStream 56 | chmod +x goMarkableStream 57 | ./goMarkableStream 58 | ``` 59 | 60 | for version < 3.6 61 | 62 | ```bash 63 | export GORKVERSION=$(curl -s https://api.github.com/repos/owulveryck/goMarkableStream/releases/latest | grep tag_name | awk -F\" '{print $4}') 64 | curl -L -s https://github.com/owulveryck/goMarkableStream/releases/download/$GORKVERSION/goMarkableStream_${GORKVERSION//v}_linux_arm.tar.gz | tar xzvf - -O goMarkableStream_${GORKVERSION//v}_linux_arm/goMarkableStream > goMarkableStream 65 | ~/chmod +x goMarkableStream 66 | ./goMarkableStream 67 | ``` 68 | 69 | then go to [https://remarkable.local.:2001](https://remarkable.local.:2001) and login with `admin`/`password` (can be changed through environment variables or disable authentication with `-unsafe`) 70 | 71 | _note_: _remarkable.local._ may work from apple devices (mDNS resolution). 72 | Please note the `.` at the end. 73 | If it does not work, you may need to replace `remarkable.local.` by the IP address of the tablet. 74 | 75 | _note 2_: you can use this to update to a new version (ensure that you killed the previous version before with `kill $(pidof goMarkableStream)`) 76 | 77 | ### Errors due to missing packages 78 | 79 | If you get errors such as `wget: note: TLS certificate validation not implemented` or `-sh: curl: command not found` when running through the process above, then some commands are missing on your Remarkable. To avoid having to install additional packages, you can first download goRemarkableStream to your local computer and then copy it over to your Remarkable as follows: 80 | 81 | 1. Run this on your local computer to download goRemarkableStream into the current directory: 82 | 83 | ```bash 84 | localhost> export GORKVERSION=$(wget -q -O - https://api.github.com/repos/owulveryck/goMarkableStream/releases/latest | grep tag_name | awk -F\" '{print $4}') 85 | wget -q -O - https://github.com/owulveryck/goMarkableStream/releases/download/$GORKVERSION/goMarkableStream_${GORKVERSION//v}_linux_arm.tar.gz | tar xzvf - -O goMarkableStream_${GORKVERSION//v}_linux_arm/goMarkableStream > goMarkableStream 86 | chmod +x goMarkableStream 87 | ``` 88 | 89 | 2. Copy it over to your Remarkable (`remarkable` is the ip of your Remarkable): 90 | ```bash 91 | localhost> scp ./goMarkableStream root@remarkable:/home/root/goMarkableStream 92 | ``` 93 | 94 | 3. Login into your Remarkable: 95 | ```bash 96 | localhost> ssh root@remarkable 97 | ``` 98 | 99 | 4. Start goRemarkableStream (to make it permanent, see section about turning goRemakableStream into a systemd service): 100 | ```bash 101 | ./goRemarkableStream 102 | ``` 103 | 104 | 105 | 106 | ## Setup goMarkableStream as a systemd service 107 | 108 | This section explains how to set up goMarkableStream as a system service that will stay running through 109 | device restart and sleep. Note, however, this setup script will need to be executed 110 | for any reMarkable system update/installation. 111 | 112 | First, we'll write the script, saving it to the home directory. Then, we'll execute the script which performs all 113 | setup necessary to register goMarkableStream as a system service. It can be executed after every system update. 114 | Note, this script assumes the goMarkableStream executable exists in the home directory. 115 | 116 | ```bash 117 | localhost> ssh root@remarkable 118 | ``` 119 | 120 | Create a bash script under the home directory: 121 | ```bash 122 | touch setupGoMarkableStream.sh 123 | chmod +x setupGoMarkableStream.sh 124 | ``` 125 | 126 | Then open the file in nano: 127 | ```bash 128 | nano setupGoMarkableStream.sh 129 | ``` 130 | 131 | Finally, paste (ctrl-shift-v) the following into the nano editor. Then save and quit (ctrl-X, Y, [enter]). 132 | ```bash 133 | pushd /etc/systemd/system 134 | touch goMarkableStream.service 135 | 136 | cat <goMarkableStream.service 137 | [Unit] 138 | Description=Go Remarkable Stream Server 139 | 140 | [Service] 141 | ExecStart=/home/root/goMarkableStream 142 | Restart=always 143 | 144 | [Install] 145 | WantedBy=multi-user.target 146 | EOF 147 | 148 | systemctl enable goMarkableStream.service 149 | systemctl start goMarkableStream.service 150 | systemctl status goMarkableStream.service 151 | popd 152 | ``` 153 | 154 | Executing setupGoMarkableStream.sh will register the goMarkableStream executable as a systemd service! 155 | 156 | ## Configurations 157 | 158 | ### Device Configuration 159 | Configure the application via environment variables: 160 | - `RK_SERVER_BIND_ADDR`: (String, default: `:2001`) Server bind address. 161 | - `RK_SERVER_USERNAME`: (String, default: `admin`) Username for server access. 162 | - `RK_SERVER_PASSWORD`: (String, default: `password`) Password for server access. 163 | - `RK_HTTPS`: (True/False, default: `true`) Enable or disable HTTPS. 164 | - `RK_COMPRESSION`: (True/False, default: `false`) Enable or disable compression. 165 | - `RK_DEV_MODE`: (True/False, default: `false`) Enable or disable developer mode. 166 | - `RLE_COMPRESSION`: (True/False, default: `true`) Enable or disable RLE compression. 167 | - `ZSTD_COMPRESSION`: (True/False, default: `false`) Enable or disable ZSTD compression. 168 | - `ZSTD_COMPRESSION_LEVEL`: (Integer, default: `3`) Set the ZSTD compression level. 169 | 170 | ### Endpoint Configuration 171 | Add query parameters to the URL (`?parameter=value&otherparameter=value`): 172 | - `color`: (true/false) Enable or disable color. 173 | - `portrait`: (true/false) Enable or disable portrait mode. 174 | - `rate`: (integer, 100-...) Set the frame rate. 175 | - `flip`: (true/false) Enable or disable flipping 180 degree. 176 | 177 | ### API Endpoints 178 | - `/`: Main web interface 179 | - `/stream`: The image data stream 180 | - `/events`: WebSocket endpoint for pen input events 181 | - `/gestures`: Endpoint for touch events 182 | - `/version`: Returns the current version of goMarkableStream 183 | 184 | ## Presentation Mode 185 | `goMarkableStream` introduces an innovative experimental feature that allows users to set a presentation or video in the background, enabling live annotations using a reMarkable tablet. 186 | This feature is ideal for enhancing presentations or educational content by allowing dynamic, real-time interaction. 187 | 188 | ### How It Works 189 | 190 | - To use this feature, append `?present=https://url-of-the-embedded-file` to your streaming URL. 191 | - This action will embed your chosen presentation or video in the stream's background. 192 | - You can then annotate or draw on the reMarkable tablet, with your input appearing over the embedded content in the stream. 193 | 194 | ### Usage Example 195 | 196 | - **Live Presentation Enhancement**: For instance, using Google Slides, you can leave spaces in your slides or use a blank slide to write additional content live. 197 | This feature is perfect for educators, presenters, and anyone looking to make their presentations more interactive and engaging. 198 | 199 | ![](docs/gorgoniaExample.png) 200 | 201 | ### Compatibility 202 | 203 | - The feature works with any content that can be embedded in an iframe. 204 | This includes a variety of presentation and video platforms. 205 | - Ensure that the content you wish to embed allows iframe integration. 206 | 207 | `goMarkableStream` is fully integrated with Reveal.js, making it a perfect tool for presentations. 208 | Switch slides or navigate through your presentation directly from your reMarkable tablet. 209 | This seamless integration enhances the experience of both presenting and viewing, making it ideal for educational and professional environments. 210 | 211 | How to: add the `?present=https://your-reveal-js-presentation` 212 | 213 | _note_: due to browser restrictions, the URL must be HTTPS. 214 | 215 | ### Limitations and Performance 216 | 217 | - **Screen Size**: Currently, the drawing screen size on the tablet is smaller than the presentations, which may affect how content is displayed. 218 | - **Control**: There is no way to control the underlying presentation directly from the tablet. 219 | Users must use the side menu for navigation and control. 220 | - This feature operates seamlessly, with no additional load on the reMarkable tablet, as all rendering is done in the client's browser. 221 | 222 | ### UI Features 223 | 224 | - **Dark Mode**: Toggle between light and dark themes using the sun/moon icon in the sidebar 225 | - **Modern Interface**: Improved UI with better typography and layout 226 | - **Tooltips**: Helpful tooltips on hover for all buttons 227 | - **Feedback Messages**: Visual feedback for user actions 228 | 229 | ## Technical Details 230 | 231 | This tool suits my need and is an ongoing development. You can find various informations about the journey on my blog: 232 | - [Streaming the reMarkable 2](https://blog.owulveryck.info/2021/03/30/streaming-the-remarkable-2.html) 233 | - [Evolving the Game: A clientless streaming tool for reMarkable 2](https://blog.owulveryck.info/2023/07/25/evolving-the-game-a-clientless-streaming-tool-for-remarkable-2.html) 234 | 235 | ### Remarkable HTTP Server 236 | 237 | This is a standalone application that runs directly on a Remarkable tablet. 238 | It does not have any dependencies on third-party libraries, making it a completely self-sufficient solution. 239 | This application exposes an HTTP server with several endpoints. 240 | 241 | ### Implementation 242 | 243 | The image data is read directly from the main process's memory as a byte array. 244 | A simple Run-Length Encoding (RLE) compression algorithm is applied on the tablet to reduce the amount of data transferred between the tablet and the browser. 245 | 246 | The CPU footprint is relatively low, using about 10% of the CPU for a frame every 200 ms. 247 | You can increase the frame rate, but it will consume slightly more CPU. 248 | 249 | On the client side, the streamed byte data is decoded and displayed on a canvas by addressing the backend array through WebGL. 250 | 251 | Additionally, the application features a side menu which allows users to rotate the displayed image. 252 | All image transformations utilize native browser implementations, providing optimized performance. 253 | 254 | ## Compilation 255 | 256 | ```bash 257 | GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -v -trimpath -ldflags="-s -w" . 258 | ``` 259 | 260 | to install and run, you can then execute: 261 | 262 | ```bash 263 | scp goMarkableStream root@remarkable: 264 | ssh root@remarkable ./goMarkableStream 265 | ``` 266 | 267 | ## Contributing 268 | 269 | I welcome contributions from the community to improve and enhance the reMarkable Screen Streaming Tool. 270 | If you have any ideas, bug reports, or feature requests, please submit them through the GitHub repository's issue tracker. 271 | 272 | ## License 273 | 274 | The reMarkable Screen Streaming Tool is released under the [MIT License](https://opensource.org/licenses/MIT) . 275 | Feel free to modify, distribute, and use the tool in accordance with the terms of the license. 276 | 277 | ## Tipping 278 | 279 | If you plan to buy a reMarkable 2, you can use my [referal program link](https://remarkable.com/referral/PY5B-PH8U). 280 | It will provide a discount for you and also for me. -------------------------------------------------------------------------------- /assets/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDazCCAlOgAwIBAgIUBAVfurBI2ze6o814cYTubdIgV/gwDQYJKoZIhvcNAQEL 3 | BQAwRTELMAkGA1UEBhMCRlIxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA2MjMwNzM1MzlaFw0zMzA2 5 | MjAwNzM1MzlaMEUxCzAJBgNVBAYTAkZSMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 6 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB 7 | AQUAA4IBDwAwggEKAoIBAQD2CZ/+7qzKTRk02kaPT+U5yu3oTuggkkwkcUUE5gIk 8 | CbhvsLQ4aIx2QHmol1rkfO1LgA7mfyVhO5D4QqwANBieEdO6x+B2KUCo5NQTblBY 9 | 17e+OpN6w4s2eNSoB1/BaO8zICqj3GaIcyKCIrXB5zxKXjRbAgiZ7nEsN3R1HKC9 10 | aL2l/j++X6kd4ET2LSsu5JHqYlYMipZ2TVK/FieBEoKdtb0Fm1es8P3j36uByY2v 11 | p0wvh4a6eGxmxFCrFDSUhk8mLteT+1doUrGj4LdmCw8MKdVmK+BbZSntdNP6/2Rd 12 | gB0MleQdLzB/YjgrXo4Au47YXAI8FNXkWADpJqWD/mnxAgMBAAGjUzBRMB0GA1Ud 13 | DgQWBBSMCi3QGHRzKz7CEsD2O0zX+LgnTzAfBgNVHSMEGDAWgBSMCi3QGHRzKz7C 14 | EsD2O0zX+LgnTzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQA4 15 | LYSX4UhGfHkCldTV0Ug0Fn6rDyBMOfEIYL1WJVvBjEB9CP8aQ8ooRuwzKUaQihbq 16 | KP13r27/uTQ9GrJ9+/Oroqv2iJJPfrSiZsPeKoymjaAT2cvi3qlMYtPVMjIJpSdE 17 | WapJ5VOG6rnCWJfuF2KWR7cxmJ2DOlZu/unOzr5nHfLRxCZaFJhubrCgMlZ8MkqO 18 | aE532ai+E9QRlDs9PS9D9Un7iUw3auvytDtlcxh0UkVHlELF5miJMwNFyMtI7xgm 19 | RSqRM43aEgC4sos413lMNnXqB7bbJsoKOqGTo/j4d+hfPRXi1jntccdtma6NyX7T 20 | vuMr9+DhDxVR/ZD+oci0 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /assets/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD2CZ/+7qzKTRk0 3 | 2kaPT+U5yu3oTuggkkwkcUUE5gIkCbhvsLQ4aIx2QHmol1rkfO1LgA7mfyVhO5D4 4 | QqwANBieEdO6x+B2KUCo5NQTblBY17e+OpN6w4s2eNSoB1/BaO8zICqj3GaIcyKC 5 | IrXB5zxKXjRbAgiZ7nEsN3R1HKC9aL2l/j++X6kd4ET2LSsu5JHqYlYMipZ2TVK/ 6 | FieBEoKdtb0Fm1es8P3j36uByY2vp0wvh4a6eGxmxFCrFDSUhk8mLteT+1doUrGj 7 | 4LdmCw8MKdVmK+BbZSntdNP6/2RdgB0MleQdLzB/YjgrXo4Au47YXAI8FNXkWADp 8 | JqWD/mnxAgMBAAECggEATnspfx9RHMLVHIfX5BT/MJ0roGOzJdik0ycSdgqMekRj 9 | gbUi969ZxsqwQ/frtWCoLOIvXpUGLmraxmY2CWaUx682E6l4Txi/mGBNydjxZjWB 10 | VfvHWWyQtpJ6exLHF6HKU0uabmR6jGH1iv9ZbP2+vJYqKzGN3tQxY4JY8pZsK55i 11 | Z4IDRD7lNdHdesqjkHt62pivHADakRr3U0GVxyDms6E2+F5Aq4GAiwmfEhcyYkJd 12 | XCOZLW0BdfWZsx7xObgaoXGQD0YXJJMmvCPt1L5Eus38zL1cQfsqaL58vbx1QZ26 13 | 0C+Tzl4UQ8IHPDryAWoDWTv8IaQNO6Nq5p8Bgl/AQQKBgQD+r8msJNRdz8WmjUnr 14 | M66zGKVczpCInA/rT8FteyBe6HHzhmDKNVW16KaRinAldyVLmcmoWUqiBf1hLQ/3 15 | 8e/VMWyVJMBN3RNi4tMbAOX2el4SqLv6euYYKOWCSnLav6OOH9cxDc1Kmf66T9EF 16 | KypTJSv8S5QLZXJGWm85mOnJ2QKBgQD3Tmtnc0Lzw0io3BIo1clX5LAuJeoBSepr 17 | tLq7/5HXCtor3GekNfRO3oEkgE3Hq/cKb8rPBeI13EnJb3wuUuZdO5lNms/U8JQL 18 | mBtpkQVJo0lY1CnaAapOB+azIJimGYIhJ1+V+hBSDbkUAEnMuJGgwxpnnfz3OK8O 19 | el1v8Uk52QKBgQCBRtWFjcRGQhq/qeQlgTxiKFZ3v1paHW1vMjKq0d7ijfaZeFJV 20 | EbGJ/qfeJHk8azgBIfTcgUaC66tr0iXS43mrq8TEB72dSGR4w04I3PHdpMtviTqx 21 | sARvqwKkmgmmw7PPhpYCjlDwVy6Xf8BHcVuwjKPBEtP43OuejnT6tYWmMQKBgQDo 22 | PB0CFawOyxjlcVwgOrqLrjZ/75yyvx3DLQGaX6ItpYRBYgV1oDEfCzWM+GuSEPu5 23 | MkfqZuUJnScxYV7lBXZMoRYSWUnH9m+f/6PmW4fyocLUBtCSZ7Ps+OB84CRY/mVE 24 | CvxpE13WIAroLGkhNUWUCQM3wJX39qP1XZV29MfF6QKBgE/ed/sp85QXmHbnXeqz 25 | CxLbhvtJFyfzYycCObGrxIXqzMGyNhANZImbcZmNX414BZ5PZCunaCrofgUZWez1 26 | agHB0TYGQQzapFzqBb08l1qVh2S987XzG+QzFCses2IHvJiEzCt83ngW4T10O0Lj 27 | wz4Ll8xUpwlZ0OCZT+UtOnjU 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // BasicAuthMiddleware is a middleware function that adds basic authentication to a handler 9 | func BasicAuthMiddleware(next http.Handler) http.Handler { 10 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 11 | // Check if the request is authenticated 12 | user, pass, ok := r.BasicAuth() 13 | if !ok || !checkCredentials(user, pass) { 14 | // Authentication failed, send a 401 Unauthorized response 15 | w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) 16 | w.WriteHeader(http.StatusUnauthorized) 17 | fmt.Fprintln(w, "Unauthorized") 18 | return 19 | } 20 | 21 | // Authentication succeeded, call the next handler 22 | next.ServeHTTP(w, r) 23 | }) 24 | } 25 | 26 | // checkCredentials is a dummy function to validate the username and password 27 | func checkCredentials(username, password string) bool { 28 | // Add your custom logic here to validate the credentials against your storage (e.g., database, file) 29 | // This is a basic example, so we're using hard-coded credentials for demonstration purposes. 30 | return username == c.Username && password == c.Password 31 | } 32 | -------------------------------------------------------------------------------- /client/canvasHandling.js: -------------------------------------------------------------------------------- 1 | // JavaScript code for working with the canvas element 2 | function resizeVisibleCanvas() { 3 | var container = document.getElementById("container"); 4 | 5 | if (portrait) { 6 | var aspectRatio = screenHeight / screenWidth; 7 | } else { 8 | var aspectRatio = screenWidth / screenHeight; 9 | } 10 | 11 | var containerWidth = container.offsetWidth; 12 | var containerHeight = container.offsetHeight; 13 | 14 | var containerAspectRatio = containerWidth / containerHeight; 15 | 16 | if (containerAspectRatio > aspectRatio) { 17 | // Canvas is relatively wider than container 18 | //canvas.style.width = '100vw'; 19 | //canvas.style.width = '100%'; 20 | //canvas.style.height = 'auto'; 21 | visibleCanvas.style.width = containerHeight * aspectRatio + "px"; 22 | visibleCanvas.style.height = containerHeight + "px"; 23 | } else { 24 | // Canvas is relatively taller than container 25 | //canvas.style.width = 'auto'; 26 | //canvas.style.height = '100vh'; 27 | //canvas.style.height = '100%'; 28 | visibleCanvas.style.width = containerWidth + "px"; 29 | visibleCanvas.style.height = containerWidth / aspectRatio + "px"; 30 | } 31 | 32 | if (flip) { 33 | visibleCanvas.style.transform = "rotate(180deg)"; 34 | } else { 35 | visibleCanvas.style.transform = "rotate(0deg)"; 36 | } 37 | } 38 | function waiting(message) { 39 | // Clear the canvas 40 | gl.clearColor(0, 0, 0, 1); // Set clear color (black, in this case) 41 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); 42 | // To show the message 43 | messageDiv.textContent = message; 44 | messageDiv.style.display = 'block'; 45 | } 46 | -------------------------------------------------------------------------------- /client/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/client/favicon.ico -------------------------------------------------------------------------------- /client/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/client/favicon.png -------------------------------------------------------------------------------- /client/glCanvas.js: -------------------------------------------------------------------------------- 1 | // WebGL initialization 2 | // Use -10,-10 as the default laser coordinate (off-screen) to hide the pointer initially 3 | let laserX = -10; 4 | let laserY = -10; 5 | const gl = canvas.getContext('webgl', { 6 | antialias: true, 7 | preserveDrawingBuffer: true, // Important for proper rendering 8 | alpha: true // Enable transparency 9 | }); 10 | 11 | 12 | if (!gl) { 13 | alert('WebGL not supported'); 14 | } 15 | 16 | // Vertex shader program 17 | const vsSource = ` 18 | attribute vec4 aVertexPosition; 19 | attribute vec2 aTextureCoord; 20 | uniform mat4 uRotationMatrix; 21 | uniform float uScaleFactor; 22 | varying highp vec2 vTextureCoord; 23 | 24 | void main(void) { 25 | // Apply scaling and rotation transformations 26 | gl_Position = uRotationMatrix * vec4(aVertexPosition.xy * uScaleFactor, aVertexPosition.zw); 27 | 28 | // Pass texture coordinates to fragment shader 29 | vTextureCoord = aTextureCoord; 30 | } 31 | `; 32 | 33 | // Fragment shader program 34 | const fsSource = ` 35 | precision highp float; 36 | 37 | varying highp vec2 vTextureCoord; 38 | uniform sampler2D uSampler; 39 | uniform float uLaserX; 40 | uniform float uLaserY; 41 | uniform bool uDarkMode; 42 | uniform float uContrastLevel; 43 | 44 | // Constants for laser pointer visualization 45 | const float LASER_RADIUS = 6.0; 46 | const float LASER_EDGE_SOFTNESS = 2.0; 47 | const vec3 LASER_COLOR = vec3(1.0, 0.0, 0.0); 48 | 49 | // Constants for image processing 50 | const float BRIGHTNESS = 0.05; // Slight brightness boost 51 | const float SHARPNESS = 0.5; // Sharpness level 52 | 53 | // Get texture color without any sharpening - better for handwriting 54 | vec4 getBaseTexture(sampler2D sampler, vec2 texCoord) { 55 | return texture2D(sampler, texCoord); 56 | } 57 | 58 | void main(void) { 59 | // Get base texture color directly - no sharpening for clearer handwriting 60 | vec4 texColor = getBaseTexture(uSampler, vTextureCoord); 61 | 62 | // Apply contrast adjustments based on the slider value 63 | vec3 adjusted = (texColor.rgb - 0.5) * uContrastLevel + 0.5; 64 | texColor.rgb = clamp(adjusted, 0.0, 1.0); 65 | 66 | // Calculate laser pointer effect 67 | float dx = gl_FragCoord.x - uLaserX; 68 | float dy = gl_FragCoord.y - uLaserY; 69 | float distance = sqrt(dx * dx + dy * dy); 70 | 71 | if (uDarkMode) { 72 | // Invert colors in dark mode, but preserve alpha 73 | texColor.rgb = 1.0 - texColor.rgb; 74 | } 75 | 76 | // Simple laser pointer - more reliable rendering 77 | if (distance < 8.0 && uLaserX > 0.0 && uLaserY > 0.0) { 78 | // Create solid circle with slight fade at edge 79 | float fade = 1.0 - smoothstep(6.0, 8.0, distance); 80 | gl_FragColor = vec4(1.0, 0.0, 0.0, fade); // Red with fade at edge 81 | } else { 82 | gl_FragColor = texColor; 83 | } 84 | } 85 | `; 86 | 87 | function makeRotationZMatrix(angleInDegrees) { 88 | var angleInRadians = angleInDegrees * Math.PI / 180; 89 | var s = Math.sin(angleInRadians); 90 | var c = Math.cos(angleInRadians); 91 | 92 | return [ 93 | c, -s, 0, 0, 94 | s, c, 0, 0, 95 | 0, 0, 1, 0, 96 | 0, 0, 0, 1 97 | ]; 98 | } 99 | 100 | // Initialize a shader program 101 | function initShaderProgram(gl, vsSource, fsSource) { 102 | const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); 103 | const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); 104 | 105 | const shaderProgram = gl.createProgram(); 106 | gl.attachShader(shaderProgram, vertexShader); 107 | gl.attachShader(shaderProgram, fragmentShader); 108 | gl.linkProgram(shaderProgram); 109 | 110 | if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { 111 | alert('Unable to initialize the shader program: ' + gl.getProgramInfoLog(shaderProgram)); 112 | return null; 113 | } 114 | 115 | return shaderProgram; 116 | } 117 | 118 | // Creates a shader of the given type, uploads the source and compiles it. 119 | function loadShader(gl, type, source) { 120 | const shader = gl.createShader(type); 121 | gl.shaderSource(shader, source); 122 | gl.compileShader(shader); 123 | 124 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { 125 | alert('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader)); 126 | gl.deleteShader(shader); 127 | return null; 128 | } 129 | 130 | return shader; 131 | } 132 | 133 | const shaderProgram = initShaderProgram(gl, vsSource, fsSource); 134 | 135 | // Collect all the info needed to use the shader program. 136 | // Look up locations of attributes and uniforms used by our shader 137 | const programInfo = { 138 | program: shaderProgram, 139 | attribLocations: { 140 | vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'), 141 | textureCoord: gl.getAttribLocation(shaderProgram, 'aTextureCoord'), 142 | }, 143 | uniformLocations: { 144 | uSampler: gl.getUniformLocation(shaderProgram, 'uSampler'), 145 | uLaserX: gl.getUniformLocation(shaderProgram, 'uLaserX'), 146 | uLaserY: gl.getUniformLocation(shaderProgram, 'uLaserY'), 147 | uDarkMode: gl.getUniformLocation(shaderProgram, 'uDarkMode'), 148 | uContrastLevel: gl.getUniformLocation(shaderProgram, 'uContrastLevel'), 149 | }, 150 | }; 151 | 152 | // Create a buffer for the square's positions. 153 | const positionBuffer = gl.createBuffer(); 154 | 155 | // Select the positionBuffer as the one to apply buffer operations to from here out. 156 | gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); 157 | 158 | // Now create an array of positions for the square. 159 | const positions = [ 160 | 1.0, 1.0, 161 | -1.0, 1.0, 162 | 1.0, -1.0, 163 | -1.0, -1.0, 164 | ]; 165 | 166 | // Pass the list of positions into WebGL to build the shape. 167 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW); 168 | 169 | // Set up texture coordinates for the rectangle 170 | const textureCoordBuffer = gl.createBuffer(); 171 | gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer); 172 | 173 | const textureCoordinates = getTextureCoordinates(); 174 | 175 | function getTextureCoordinates() { 176 | if (DeviceModel === "Remarkable2") { 177 | return [ 178 | 1.0, 1.0, 179 | 0.0, 1.0, 180 | 1.0, 0.0, 181 | 0.0, 0.0, 182 | ]; 183 | } else { 184 | return [ 185 | 1.0, 0.0, 186 | 0.0, 0.0, 187 | 1.0, 1.0, 188 | 0.0, 1.0, 189 | ]; 190 | }; 191 | }; 192 | 193 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureCoordinates), gl.STATIC_DRAW); 194 | 195 | // Create a texture. 196 | const texture = gl.createTexture(); 197 | gl.bindTexture(gl.TEXTURE_2D, texture); 198 | 199 | 200 | // Set the parameters so we can render any size image. 201 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 202 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 203 | // To apply a smoothing algorithm, you'll likely want to adjust the texture filtering parameters in your WebGL setup. 204 | // For smoothing, typically gl.LINEAR is used for both gl.TEXTURE_MIN_FILTER and gl.TEXTURE_MAG_FILTER 205 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 206 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 207 | // gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); 208 | // gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 209 | 210 | // Upload the image into the texture. 211 | let imageData = new ImageData(screenWidth, screenHeight); 212 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imageData); 213 | 214 | // Variables to track display state 215 | let isDarkMode = false; 216 | let contrastValue = 1.15; // Default contrast value 217 | 218 | // Draw the scene 219 | function drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture) { 220 | // Handle canvas resize for proper rendering 221 | if (resizeGLCanvas(gl.canvas)) { 222 | gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); 223 | } 224 | 225 | // Adjust background color based on dark mode 226 | const bgColor = isDarkMode 227 | ? [0.12, 0.12, 0.13, 0.25] // Darker, more neutral dark mode bg 228 | : [0.98, 0.98, 0.98, 0.25]; // Nearly white light mode bg 229 | gl.clearColor(bgColor[0], bgColor[1], bgColor[2], bgColor[3]); 230 | 231 | // Enable alpha blending for transparency 232 | gl.enable(gl.BLEND); 233 | gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); 234 | 235 | // Setup depth buffer 236 | gl.clearDepth(1.0); 237 | gl.enable(gl.DEPTH_TEST); 238 | gl.depthFunc(gl.LEQUAL); 239 | 240 | // Clear the canvas before we start drawing on it. 241 | gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); 242 | 243 | // Tell WebGL to use our program when drawing 244 | gl.useProgram(programInfo.program); 245 | 246 | // Set the shader attributes 247 | gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); 248 | gl.vertexAttribPointer(programInfo.attribLocations.vertexPosition, 2, gl.FLOAT, false, 0, 0); 249 | gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition); 250 | 251 | gl.bindBuffer(gl.ARRAY_BUFFER, textureCoordBuffer); 252 | gl.vertexAttribPointer(programInfo.attribLocations.textureCoord, 2, gl.FLOAT, false, 0, 0); 253 | gl.enableVertexAttribArray(programInfo.attribLocations.textureCoord); 254 | 255 | // Tell WebGL we want to affect texture unit 0 256 | gl.activeTexture(gl.TEXTURE0); 257 | 258 | // Bind the texture to texture unit 0 259 | gl.bindTexture(gl.TEXTURE_2D, texture); 260 | 261 | // Tell the shader we bound the texture to texture unit 0 262 | gl.uniform1i(programInfo.uniformLocations.uSampler, 0); 263 | 264 | // Set the laser coordinates 265 | gl.uniform1f(programInfo.uniformLocations.uLaserX, laserX); 266 | gl.uniform1f(programInfo.uniformLocations.uLaserY, laserY); 267 | 268 | // Set display flags 269 | gl.uniform1i(programInfo.uniformLocations.uDarkMode, isDarkMode ? 1 : 0); 270 | gl.uniform1f(programInfo.uniformLocations.uContrastLevel, contrastValue); 271 | 272 | gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); 273 | } 274 | 275 | drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture); 276 | 277 | // Update texture 278 | function updateTexture(newRawData, shouldRotate, scaleFactor) { 279 | if (useBGRA) { 280 | convertBGRAtoRGBA(newRawData); 281 | }; 282 | 283 | gl.bindTexture(gl.TEXTURE_2D, texture); 284 | gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, screenWidth, screenHeight, gl.RGBA, gl.UNSIGNED_BYTE, newRawData); 285 | 286 | // Set rotation 287 | const uRotationMatrixLocation = gl.getUniformLocation(shaderProgram, 'uRotationMatrix'); 288 | const rotationMatrix = shouldRotate ? makeRotationZMatrix(270) : makeRotationZMatrix(0); 289 | gl.uniformMatrix4fv(uRotationMatrixLocation, false, rotationMatrix); 290 | 291 | // Set scaling 292 | const uScaleFactorLocation = gl.getUniformLocation(shaderProgram, 'uScaleFactor'); 293 | gl.uniform1f(uScaleFactorLocation, scaleFactor); 294 | 295 | drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture); 296 | } 297 | 298 | function convertBGRAtoRGBA(data) { 299 | for (let i = 0; i < data.length; i += 4) { 300 | const b = data[i]; // Blue 301 | data[i] = data[i + 2]; // Swap Red and Blue 302 | data[i + 2] = b; 303 | } 304 | } 305 | 306 | // Call `updateTexture` with new data whenever you need to update the image 307 | 308 | // Let's create a function that resizes the canvas element. 309 | // 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. 310 | function resizeGLCanvas(canvas) { 311 | const displayWidth = canvas.clientWidth; 312 | const displayHeight = canvas.clientHeight; 313 | 314 | // Check if the canvas size is different from its display size 315 | if (canvas.width !== displayWidth || canvas.height !== displayHeight) { 316 | // Make the canvas the same size as its display size 317 | canvas.width = displayWidth; 318 | canvas.height = displayHeight; 319 | return true; // indicates that the size was changed 320 | } 321 | 322 | return false; // indicates no change in size 323 | } 324 | 325 | // Direct laser pointer position - no animation for more reliability 326 | function updateLaserPosition(x, y) { 327 | // If x and y are valid positive values 328 | if (x > 0 && y > 0) { 329 | // Position is now directly proportional to canvas size 330 | laserX = x * (gl.canvas.width / screenWidth); 331 | laserY = gl.canvas.height - (y * (gl.canvas.height / screenHeight)); 332 | } else { 333 | // Hide the pointer by moving it off-screen 334 | laserX = -10; 335 | laserY = -10; 336 | } 337 | 338 | // Redraw immediately 339 | drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture); 340 | } 341 | 342 | // Function to update dark mode state with transition effect 343 | let darkModeTransition = 0; // 0 = light mode, 1 = dark mode 344 | let transitionActive = false; 345 | 346 | function setDarkMode(darkModeEnabled) { 347 | isDarkMode = darkModeEnabled; 348 | 349 | // If not already transitioning, start a smooth transition 350 | if (!transitionActive) { 351 | transitionActive = true; 352 | const startTime = performance.now(); 353 | const duration = 300; // transition duration in ms 354 | 355 | function animateDarkModeTransition(timestamp) { 356 | const elapsed = timestamp - startTime; 357 | const progress = Math.min(elapsed / duration, 1); 358 | 359 | // Update transition value (0 to 1 for light to dark) 360 | darkModeTransition = darkModeEnabled ? progress : 1 - progress; 361 | 362 | // Render with current transition value 363 | drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture); 364 | 365 | // Continue animation if not complete 366 | if (progress < 1) { 367 | requestAnimationFrame(animateDarkModeTransition); 368 | } else { 369 | transitionActive = false; 370 | } 371 | } 372 | 373 | requestAnimationFrame(animateDarkModeTransition); 374 | } else { 375 | // Just update the scene if already transitioning 376 | drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture); 377 | } 378 | } 379 | 380 | // Function to set contrast level 381 | function setContrast(contrastLevel) { 382 | // Store the contrast value (between 1.0 and 3.0) 383 | contrastValue = parseFloat(contrastLevel); 384 | 385 | // If the value is valid, update rendering 386 | if (!isNaN(contrastValue) && contrastValue >= 1.0 && contrastValue <= 3.0) { 387 | // Update the scene with new contrast 388 | drawScene(gl, programInfo, positionBuffer, textureCoordBuffer, texture); 389 | 390 | // Save user preference to localStorage 391 | localStorage.setItem('contrastLevel', contrastValue); 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | goMarkableStream 5 | 6 | 7 | 8 | 16 | 17 | 18 | 77 |
78 | 79 |
80 | 81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | const rawCanvas = new OffscreenCanvas(screenWidth, screenHeight); // Define width and height as needed 2 | let portrait = getQueryParam('portrait'); 3 | portrait = portrait !== null ? portrait === 'true' : false; 4 | 5 | defaultFlip = false; 6 | // If this is the Paper Pro, we don't need to flip the image. 7 | if (DeviceModel === 'RemarkablePaperPro') { 8 | defaultFlip = false; 9 | } 10 | let flip = getBoolQueryParam('flip', defaultFlip); 11 | 12 | let withColor = getQueryParam('color', 'true'); 13 | withColor = withColor !== null ? withColor === 'true' : true; 14 | let rate = parseInt(getQueryParamOrDefault('rate', '200'), 10); 15 | 16 | // Remarkable Paper Pro uses BGRA format. 17 | let useBGRA = false; 18 | if (DeviceModel === 'RemarkablePaperPro') { 19 | useBGRA = true; 20 | }; 21 | 22 | //let portrait = false; 23 | // Get the 'present' parameter from the URL 24 | //const presentURL = getQueryParam('present');// Assuming rawCanvas is an OffscreenCanvas that's already been defined 25 | const ctx = rawCanvas.getContext('2d'); 26 | const visibleCanvas = document.getElementById("canvas"); 27 | const iFrame = document.getElementById("content"); 28 | const messageDiv = document.getElementById('message'); 29 | 30 | 31 | // Initialize the worker 32 | const streamWorker = new Worker('worker_stream_processing.js'); 33 | const eventWorker = new Worker('worker_event_processing.js'); 34 | const gestureWorker = new Worker('worker_gesture_processing.js'); 35 | function getQueryParamOrDefault(param, defaultValue) { 36 | const urlParams = new URLSearchParams(window.location.search); 37 | const value = urlParams.get(param); 38 | return value !== null ? value : defaultValue; 39 | } 40 | 41 | //let imageData = ctx.createImageData(screenWidth, screenHeight); // width and height of your canvas 42 | function getQueryParam(name) { 43 | const urlParams = new URLSearchParams(window.location.search); 44 | return urlParams.get(name); 45 | } 46 | 47 | function getBoolQueryParam(param, defaultValue = false) { 48 | value = getQueryParam(param); 49 | 50 | if (value === null) { 51 | return defaultValue; 52 | } 53 | 54 | return value === 'true'; 55 | } 56 | 57 | window.onload = async function() { 58 | // Function to get the value of a query parameter by name 59 | // Get the 'present' parameter from the URL 60 | const presentURL = getQueryParam('present'); 61 | 62 | // Set the iframe source if the URL is available 63 | if (presentURL) { 64 | document.getElementById('content').src = presentURL; 65 | } 66 | 67 | // Update version in the sidebar footer 68 | const version = await fetchVersion(); 69 | const versionElement = document.querySelector('.sidebar-footer small'); 70 | if (versionElement) { 71 | versionElement.textContent = `goMarkableStream ${version}`; 72 | } 73 | }; 74 | 75 | // Add an event listener for the 'beforeunload' event, which is triggered when the page is refreshed or closed 76 | window.addEventListener('beforeunload', () => { 77 | // Send a termination signal to the worker before the page is unloaded 78 | streamWorker.postMessage({ type: 'terminate' }); 79 | eventWorker.postMessage({ type: 'terminate' }); 80 | gestureWorker.postMessage({ type: 'terminate' }); 81 | }); 82 | -------------------------------------------------------------------------------- /client/recording.js: -------------------------------------------------------------------------------- 1 | let mediaRecorder; 2 | let recordedChunks = []; 3 | 4 | async function startRecording() { 5 | const tempCanvas = createTempCanvas(); // Create the temporary canvas 6 | 7 | console.log("recording in progress"); 8 | let videoStream = tempCanvas.captureStream(25); // 25 fps 9 | 10 | if (recordingWithSound) { 11 | // Capture audio stream from the user's microphone 12 | let audioStream; 13 | try { 14 | audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); 15 | } catch (err) { 16 | console.error("Error capturing audio:", err); 17 | return; 18 | } 19 | 20 | // Combine video and audio streams 21 | let combinedStream = new MediaStream([...videoStream.getTracks(), ...audioStream.getTracks()]); 22 | 23 | mediaRecorder = new MediaRecorder(combinedStream, { 24 | mimeType: 'video/webm;codecs=vp9' 25 | }); 26 | } else { 27 | mediaRecorder = new MediaRecorder(videoStream, { 28 | mimeType: 'video/webm;codecs=vp9' 29 | }); 30 | } 31 | 32 | mediaRecorder.ondataavailable = function(event) { 33 | if (event.data.size > 0) { 34 | recordedChunks.push(event.data); 35 | } 36 | }; 37 | 38 | mediaRecorder.onstop = function() { 39 | download(); 40 | }; 41 | 42 | mediaRecorder.start(); 43 | } 44 | 45 | function stopRecording() { 46 | mediaRecorder.stop(); 47 | removeTempCanvas(); // Remove the temporary canvas after recording 48 | 49 | // Stop updating tempCanvas 50 | if (animationFrameId) { 51 | cancelAnimationFrame(animationFrameId); 52 | } 53 | } 54 | 55 | function download() { 56 | let blob = new Blob(recordedChunks, { 57 | type: 'video/webm' 58 | }); 59 | 60 | let url = URL.createObjectURL(blob); 61 | let a = document.createElement('a'); 62 | a.style.display = 'none'; 63 | a.href = url; 64 | a.download = 'goMarkableStreamRecording.webm'; 65 | document.body.appendChild(a); 66 | a.click(); 67 | setTimeout(() => { 68 | document.body.removeChild(a); 69 | window.URL.revokeObjectURL(url); 70 | }, 100); 71 | } 72 | 73 | /* 74 | document.getElementById('startStopButtonWithSound').addEventListener('click', function() { 75 | let icon = document.getElementById('icon2'); 76 | let label = document.getElementById('label2'); 77 | 78 | 79 | if (label.textContent === 'Record with audio') { 80 | label.textContent = 'Stop'; 81 | icon.classList.add('recording'); 82 | recordingWithSound = true; 83 | startRecording(); 84 | } else { 85 | label.textContent = 'Record with audio'; 86 | icon.classList.remove('recording'); 87 | recordingWithSound = false; 88 | stopRecording(); 89 | } 90 | }); 91 | document.getElementById('startStopButton').addEventListener('click', function() { 92 | let icon = document.getElementById('icon'); 93 | let label = document.getElementById('label'); 94 | 95 | 96 | if (label.textContent === 'Record') { 97 | label.textContent = 'Stop'; 98 | icon.classList.add('recording'); 99 | startRecording(); 100 | } else { 101 | label.textContent = 'Record'; 102 | icon.classList.remove('recording'); 103 | stopRecording(); 104 | } 105 | }); 106 | */ 107 | // JavaScript file (stream.js) 108 | function createTempCanvas() { 109 | const tempCanvas = document.createElement('canvas'); 110 | tempCanvas.width = width; 111 | tempCanvas.height = height; 112 | tempCanvas.id = 'tempCanvas'; // Assign an ID for easy reference 113 | 114 | // Hide the tempCanvas 115 | tempCanvas.style.display = 'none'; 116 | 117 | // Start updating tempCanvas 118 | updateTempCanvas(tempCanvas); 119 | 120 | // Append tempCanvas to the body (or any other container) 121 | document.body.appendChild(tempCanvas); 122 | 123 | return tempCanvas; 124 | } 125 | function removeTempCanvas() { 126 | const tempCanvas = document.getElementById('tempCanvas'); 127 | if (tempCanvas) { 128 | tempCanvas.remove(); 129 | } 130 | } 131 | let animationFrameId; 132 | function updateTempCanvas(tempCanvas) { 133 | //renderCanvas(rawCanvas,tempCanvas); 134 | // Continue updating tempCanvas 135 | animationFrameId = requestAnimationFrame(() => updateTempCanvas(tempCanvas)); 136 | } 137 | 138 | 139 | -------------------------------------------------------------------------------- /client/style.css: -------------------------------------------------------------------------------- 1 | /* CSS styles for the layout */ 2 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); 3 | 4 | :root { 5 | /* Light Mode Colors */ 6 | --background-color: #f8f9fa; 7 | --container-bg: #ffffff; 8 | --sidebar-bg: #f0f4f8; 9 | --text-color: #333333; 10 | --text-secondary: #6c757d; 11 | --accent-color: #007AFF; 12 | --accent-hover: #0056b3; 13 | --border-color: rgba(0, 0, 0, 0.1); 14 | --shadow-color: rgba(0, 0, 0, 0.08); 15 | --toggle-bg: #e9ecef; 16 | --toggle-circle: #ffffff; 17 | --button-success: #34C759; 18 | --button-success-hover: #28a745; 19 | --tooltip-bg: rgba(0, 0, 0, 0.7); 20 | --tooltip-color: #ffffff; 21 | --menu-hover: rgba(0, 122, 255, 0.1); 22 | --sidebar-width: 200px; 23 | --sidebar-collapsed-width: 30px; 24 | } 25 | 26 | /* Dark Mode Colors */ 27 | .dark-mode { 28 | --background-color: #121212; 29 | --container-bg: #1e1e1e; 30 | --sidebar-bg: #1a1a1a; 31 | --text-color: #f0f0f0; 32 | --text-secondary: #adb5bd; 33 | --accent-color: #4dabf7; 34 | --accent-hover: #339af0; 35 | --border-color: rgba(255, 255, 255, 0.1); 36 | --shadow-color: rgba(0, 0, 0, 0.2); 37 | --toggle-bg: #495057; 38 | --toggle-circle: #343a40; 39 | --button-success: #2ebd5f; 40 | --button-success-hover: #249d4e; 41 | --tooltip-bg: rgba(255, 255, 255, 0.8); 42 | --tooltip-color: #1a1a1a; 43 | --menu-hover: rgba(77, 171, 247, 0.15); 44 | } 45 | 46 | body, html { 47 | margin: 0; 48 | padding: 0; 49 | height: 100%; 50 | background-color: var(--background-color); 51 | font-family: 'Inter', 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 52 | color: var(--text-color); 53 | transition: background-color 0.3s ease, color 0.3s ease; 54 | font-size: 16px; 55 | line-height: 1.5; 56 | } 57 | 58 | #container { 59 | width: 100%; 60 | height: 100vh; 61 | display: flex; 62 | align-items: center; 63 | justify-content: center; 64 | overflow: hidden; 65 | background-color: var(--container-bg); 66 | box-shadow: 0 0 20px var(--shadow-color); 67 | transition: background-color 0.3s ease, box-shadow 0.3s ease; 68 | position: relative; 69 | } 70 | 71 | #canvas { 72 | position: absolute; 73 | width: 100vw; /* 100% of the viewport width */ 74 | height: 100vh; /* 100% of the viewport height */ 75 | display: block; /* Remove extra space around the canvas */ 76 | z-index: 2; 77 | transition: filter 0.3s ease; 78 | } 79 | 80 | #content { 81 | position: absolute; 82 | z-index: 1; 83 | } 84 | 85 | canvas.hidden { 86 | display: none; 87 | } 88 | 89 | .sidebar { 90 | width: var(--sidebar-width); 91 | height: 100vh; 92 | background-color: var(--sidebar-bg); 93 | backdrop-filter: blur(10px); 94 | -webkit-backdrop-filter: blur(10px); 95 | border-right: 1px solid var(--border-color); 96 | box-shadow: 2px 0 15px var(--shadow-color); 97 | position: fixed; 98 | top: 0; 99 | left: calc(-1 * (var(--sidebar-width) - var(--sidebar-collapsed-width))); 100 | transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); 101 | z-index: 5; 102 | padding-top: 0; 103 | overflow-y: auto; 104 | overflow-x: hidden; 105 | display: flex; 106 | flex-direction: column; 107 | } 108 | 109 | .sidebar.active { 110 | left: 0; 111 | } 112 | 113 | .sidebar:after { 114 | content: ''; 115 | position: absolute; 116 | top: 0; 117 | right: 0; 118 | width: var(--sidebar-collapsed-width); 119 | height: 100%; 120 | background: linear-gradient(90deg, transparent, var(--sidebar-bg)); 121 | z-index: -1; 122 | opacity: 0.8; 123 | } 124 | 125 | .menu { 126 | list-style: none; 127 | padding: 0; 128 | margin: 10px 0; 129 | flex: 1; 130 | } 131 | 132 | .menu li { 133 | padding: 0; 134 | margin: 8px 15px; 135 | border-radius: 12px; 136 | transition: all 0.2s ease; 137 | position: relative; 138 | } 139 | 140 | .menu li:hover { 141 | background-color: var(--menu-hover); 142 | transform: translateY(-1px); 143 | } 144 | 145 | .menu li button { 146 | width: 100%; 147 | text-align: left; 148 | padding: 12px 16px; 149 | border-radius: inherit; 150 | } 151 | 152 | .menu li .button-tooltip { 153 | position: absolute; 154 | left: 100%; 155 | top: 50%; 156 | transform: translateY(-50%); 157 | background-color: var(--tooltip-bg); 158 | color: var(--tooltip-color); 159 | padding: 6px 12px; 160 | border-radius: 6px; 161 | font-size: 14px; 162 | white-space: nowrap; 163 | opacity: 0; 164 | visibility: hidden; 165 | transition: opacity 0.2s ease, visibility 0.2s ease; 166 | z-index: 10; 167 | margin-left: 10px; 168 | font-weight: 500; 169 | pointer-events: none; 170 | } 171 | 172 | .menu li .button-tooltip:after { 173 | content: ''; 174 | position: absolute; 175 | left: -5px; 176 | top: 50%; 177 | transform: translateY(-50%); 178 | border-width: 5px 5px 5px 0; 179 | border-style: solid; 180 | border-color: transparent var(--tooltip-bg) transparent transparent; 181 | } 182 | 183 | .menu li:hover .button-tooltip { 184 | opacity: 1; 185 | visibility: visible; 186 | } 187 | 188 | .menu li a { 189 | text-decoration: none; 190 | color: var(--text-color); 191 | font-weight: 500; 192 | transition: color 0.2s ease; 193 | display: block; 194 | padding: 10px 15px; 195 | } 196 | 197 | .menu li a:hover { 198 | color: var(--accent-color); 199 | } 200 | .my-button { 201 | background-color: var(--accent-color); 202 | border: none; 203 | color: white; 204 | padding: 12px 22px; 205 | text-align: center; 206 | text-decoration: none; 207 | display: inline-block; 208 | font-size: 15px; 209 | cursor: pointer; 210 | border-radius: 10px; 211 | transition: all 0.2s ease; 212 | box-shadow: 0 2px 5px var(--shadow-color); 213 | font-weight: 500; 214 | } 215 | 216 | .my-button:hover { 217 | background-color: var(--accent-hover); 218 | transform: translateY(-1px); 219 | box-shadow: 0 4px 8px var(--shadow-color); 220 | } 221 | 222 | .my-button:focus { 223 | outline: none; 224 | box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.25); 225 | } 226 | 227 | .my-button.toggled { 228 | background-color: var(--button-success); 229 | color: #ffffff; 230 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); 231 | } 232 | 233 | .icon { 234 | display: inline-block; 235 | width: 18px; 236 | height: 18px; 237 | margin-right: 8px; 238 | background-color: transparent; 239 | position: relative; 240 | border-radius: 50%; 241 | border: 2px solid white; 242 | background-color: red; 243 | box-shadow: 0 0 5px rgba(255, 0, 0, 0.5); 244 | } 245 | 246 | @keyframes fadeInOut { 247 | 0%, 100% { 248 | opacity: 0.5; 249 | transform: scale(1); 250 | } 251 | 50% { 252 | opacity: 1; 253 | transform: scale(1.1); 254 | } 255 | } 256 | 257 | .recording { 258 | animation: fadeInOut 1.2s ease-in-out infinite; 259 | } 260 | 261 | /* Apple-style button */ 262 | .apple-button { 263 | display: inline-flex; 264 | align-items: center; 265 | width: 100%; 266 | padding: 0; 267 | border-radius: 10px; 268 | border: none; 269 | font-family: 'Inter', 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif; 270 | font-size: 15px; 271 | font-weight: 500; 272 | cursor: pointer; 273 | transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); 274 | outline: none; 275 | background-color: transparent; 276 | color: var(--text-color); 277 | box-shadow: none; 278 | } 279 | 280 | .apple-button:hover { 281 | background-color: var(--menu-hover); 282 | transform: translateY(-1px); 283 | } 284 | 285 | .apple-button:active { 286 | transform: translateY(1px); 287 | } 288 | 289 | .button-icon { 290 | display: inline-flex; 291 | align-items: center; 292 | justify-content: center; 293 | margin-right: 12px; 294 | color: var(--accent-color); 295 | transition: transform 0.2s ease; 296 | } 297 | 298 | .apple-button:hover .button-icon { 299 | transform: scale(1.1); 300 | } 301 | 302 | .dark-mode .apple-button { 303 | color: var(--text-color); 304 | } 305 | 306 | .apple-button.toggled { 307 | background-color: var(--menu-hover); 308 | font-weight: 600; 309 | } 310 | 311 | .apple-button.toggled .button-icon { 312 | color: var(--button-success); 313 | } 314 | 315 | /* Base styles for the toggle button */ 316 | .toggle-button { 317 | display: inline-block; 318 | padding: 12px 24px; 319 | border-radius: 10px; 320 | border: none; 321 | font-family: 'Inter', 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif; 322 | font-size: 15px; 323 | font-weight: 500; 324 | cursor: pointer; 325 | transition: all 0.2s ease; 326 | outline: none; 327 | text-align: center; 328 | } 329 | 330 | /* Style for the "off" state (default) */ 331 | .toggle-button { 332 | background-color: var(--toggle-bg); 333 | color: var(--text-secondary); 334 | box-shadow: 0 1px 5px var(--shadow-color); 335 | } 336 | 337 | /* Style for the "on" state */ 338 | .toggle-button.active { 339 | background-color: var(--accent-color); 340 | color: #FFFFFF; 341 | box-shadow: 0 2px 8px var(--shadow-color); 342 | } 343 | 344 | /* Notification and message styling */ 345 | #message { 346 | position: absolute; 347 | top: 50%; 348 | left: 50%; 349 | transform: translate(-50%, -50%); 350 | font-family: 'Inter', 'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif; 351 | background-color: rgba(255, 255, 255, 0.92); 352 | padding: 16px 24px; 353 | border-radius: 16px; 354 | box-shadow: 0 10px 25px var(--shadow-color); 355 | color: #FF3B30; /* Apple's red */ 356 | font-weight: 500; 357 | backdrop-filter: blur(10px); 358 | -webkit-backdrop-filter: blur(10px); 359 | border: 1px solid rgba(255, 59, 48, 0.2); 360 | z-index: 10; 361 | max-width: 80%; 362 | text-align: center; 363 | opacity: 0; 364 | visibility: hidden; 365 | transition: opacity 0.3s ease, visibility 0.3s ease; 366 | } 367 | 368 | #message.visible { 369 | opacity: 1; 370 | visibility: visible; 371 | } 372 | 373 | .dark-mode #message { 374 | background-color: rgba(45, 45, 45, 0.92); 375 | border: 1px solid rgba(255, 59, 48, 0.2); 376 | } 377 | 378 | /* Sidebar header styling */ 379 | .sidebar-header { 380 | display: flex; 381 | align-items: center; 382 | padding: 24px 16px; 383 | border-bottom: 1px solid var(--border-color); 384 | margin-bottom: 15px; 385 | background: linear-gradient(to right, rgba(0, 122, 255, 0.15), rgba(0, 122, 255, 0.05)); 386 | } 387 | 388 | .sidebar-logo { 389 | width: 38px; 390 | height: 38px; 391 | margin-right: 12px; 392 | border-radius: 10px; 393 | box-shadow: 0 4px 10px var(--shadow-color); 394 | transition: transform 0.3s ease; 395 | } 396 | 397 | .sidebar-header:hover .sidebar-logo { 398 | transform: rotate(10deg); 399 | } 400 | 401 | .sidebar-header h3 { 402 | margin: 0; 403 | color: var(--text-color); 404 | font-size: 20px; 405 | font-weight: 600; 406 | letter-spacing: -0.2px; 407 | } 408 | 409 | /* Sidebar footer styling */ 410 | .sidebar-footer { 411 | position: relative; 412 | width: 100%; 413 | text-align: center; 414 | color: var(--text-secondary); 415 | font-size: 12px; 416 | padding: 12px 0; 417 | border-top: 1px solid var(--border-color); 418 | background-color: var(--sidebar-bg); 419 | margin-top: auto; 420 | } 421 | 422 | /* Pulse animation for notifications */ 423 | @keyframes pulse { 424 | 0% { 425 | box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.4); 426 | } 427 | 70% { 428 | box-shadow: 0 0 0 10px rgba(255, 59, 48, 0); 429 | } 430 | 100% { 431 | box-shadow: 0 0 0 0 rgba(255, 59, 48, 0); 432 | } 433 | } 434 | 435 | /* Theme toggle switch */ 436 | /* Slider container */ 437 | .slider-container { 438 | display: flex; 439 | flex-direction: column; 440 | padding: 12px 16px; 441 | position: relative; 442 | width: 100%; 443 | } 444 | 445 | .slider-label { 446 | display: flex; 447 | align-items: center; 448 | margin-bottom: 8px; 449 | cursor: pointer; 450 | } 451 | 452 | .slider-label span { 453 | margin-left: 8px; 454 | } 455 | 456 | .contrast-slider { 457 | width: 100%; 458 | height: 4px; 459 | -webkit-appearance: none; 460 | appearance: none; 461 | background: var(--toggle-bg); 462 | outline: none; 463 | border-radius: 2px; 464 | cursor: pointer; 465 | margin-top: 5px; 466 | } 467 | 468 | .contrast-slider::-webkit-slider-thumb { 469 | -webkit-appearance: none; 470 | appearance: none; 471 | width: 16px; 472 | height: 16px; 473 | background: var(--accent-color); 474 | border-radius: 50%; 475 | cursor: pointer; 476 | transition: background 0.2s; 477 | } 478 | 479 | .contrast-slider::-moz-range-thumb { 480 | width: 16px; 481 | height: 16px; 482 | background: var(--accent-color); 483 | border-radius: 50%; 484 | cursor: pointer; 485 | border: none; 486 | transition: background 0.2s; 487 | } 488 | 489 | .contrast-slider:hover::-webkit-slider-thumb { 490 | background: var(--accent-hover); 491 | } 492 | 493 | .contrast-slider:hover::-moz-range-thumb { 494 | background: var(--accent-hover); 495 | } 496 | 497 | .dark-mode .contrast-slider { 498 | background: #444; 499 | } 500 | 501 | .dark-mode .contrast-slider::-webkit-slider-thumb { 502 | background: var(--accent-color); 503 | } 504 | 505 | .dark-mode .contrast-slider::-moz-range-thumb { 506 | background: var(--accent-color); 507 | } 508 | 509 | .theme-switch-wrapper { 510 | display: flex; 511 | align-items: center; 512 | padding: 16px 20px; 513 | margin-top: 10px; 514 | border-top: 1px solid var(--border-color); 515 | background: linear-gradient(to right, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0)); 516 | transition: background-color 0.3s ease; 517 | } 518 | 519 | .dark-mode .theme-switch-wrapper { 520 | background: linear-gradient(to right, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0)); 521 | } 522 | 523 | .theme-switch { 524 | display: inline-block; 525 | height: 26px; 526 | position: relative; 527 | width: 50px; 528 | margin-right: 12px; 529 | } 530 | 531 | .theme-switch input { 532 | display: none; 533 | } 534 | 535 | .slider { 536 | background-color: var(--toggle-bg); 537 | bottom: 0; 538 | cursor: pointer; 539 | left: 0; 540 | position: absolute; 541 | right: 0; 542 | top: 0; 543 | transition: .4s; 544 | border-radius: 34px; 545 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); 546 | } 547 | 548 | .slider:before { 549 | background-color: var(--toggle-circle); 550 | bottom: 3px; 551 | content: ""; 552 | height: 20px; 553 | left: 3px; 554 | position: absolute; 555 | transition: .4s cubic-bezier(0.175, 0.885, 0.32, 1.275); 556 | width: 20px; 557 | border-radius: 50%; 558 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); 559 | } 560 | 561 | .slider:after { 562 | content: "☀️"; 563 | position: absolute; 564 | left: 7px; 565 | top: 5px; 566 | font-size: 12px; 567 | opacity: 1; 568 | transition: opacity 0.3s ease; 569 | } 570 | 571 | input:checked + .slider:after { 572 | content: "🌙"; 573 | left: 30px; 574 | } 575 | 576 | input:checked + .slider { 577 | background-color: var(--accent-color); 578 | } 579 | 580 | input:checked + .slider:before { 581 | transform: translateX(24px); 582 | } 583 | 584 | input:focus + .slider { 585 | box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.25); 586 | } 587 | 588 | .theme-switch-label { 589 | color: var(--text-color); 590 | font-size: 14px; 591 | font-weight: 500; 592 | transition: all 0.3s ease; 593 | } 594 | 595 | .theme-switch-wrapper:hover .theme-switch-label { 596 | color: var(--accent-color); 597 | } 598 | 599 | /* Tooltip styles */ 600 | [data-tooltip] { 601 | position: relative; 602 | cursor: help; 603 | } 604 | 605 | [data-tooltip]:before { 606 | content: attr(data-tooltip); 607 | position: absolute; 608 | bottom: 100%; 609 | left: 50%; 610 | transform: translateX(-50%); 611 | padding: 8px 12px; 612 | background-color: var(--tooltip-bg); 613 | color: var(--tooltip-color); 614 | border-radius: 6px; 615 | font-size: 12px; 616 | white-space: nowrap; 617 | opacity: 0; 618 | visibility: hidden; 619 | transition: opacity 0.3s ease, visibility 0.3s ease; 620 | z-index: 1000; 621 | } 622 | 623 | [data-tooltip]:hover:before { 624 | opacity: 1; 625 | visibility: visible; 626 | } 627 | 628 | /* Loading animation */ 629 | .loading-dots { 630 | display: inline-block; 631 | position: relative; 632 | width: 40px; 633 | height: 16px; 634 | } 635 | 636 | .loading-dots:after { 637 | content: '...'; 638 | position: absolute; 639 | left: 0; 640 | animation: loading 1.5s infinite; 641 | font-size: 20px; 642 | line-height: 10px; 643 | letter-spacing: 2px; 644 | } 645 | 646 | @keyframes loading { 647 | 0% { content: '.'; } 648 | 33% { content: '..'; } 649 | 66% { content: '...'; } 650 | } 651 | 652 | /* Toast notification styles */ 653 | .toast { 654 | position: fixed; 655 | bottom: 20px; 656 | right: 20px; 657 | padding: 12px 20px; 658 | background-color: var(--tooltip-bg); 659 | color: var(--tooltip-color); 660 | border-radius: 8px; 661 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 662 | transform: translateY(100px); 663 | opacity: 0; 664 | transition: transform 0.3s ease, opacity 0.3s ease; 665 | z-index: 1000; 666 | } 667 | 668 | .toast.visible { 669 | transform: translateY(0); 670 | opacity: 1; 671 | } 672 | 673 | -------------------------------------------------------------------------------- /client/uiInteractions.js: -------------------------------------------------------------------------------- 1 | // UI interactions module 2 | 3 | // Function to toggle dark mode 4 | function toggleDarkMode() { 5 | document.body.classList.toggle('dark-mode'); 6 | 7 | // Save user preference to localStorage 8 | const isDarkMode = document.body.classList.contains('dark-mode'); 9 | localStorage.setItem('darkMode', isDarkMode ? 'enabled' : 'disabled'); 10 | 11 | // Update the canvas to invert colors in dark mode 12 | if (typeof setDarkMode === 'function') { 13 | setDarkMode(isDarkMode); 14 | } 15 | } 16 | 17 | // Check user preference on page load 18 | document.addEventListener('DOMContentLoaded', function() { 19 | // Check for saved theme preference 20 | const savedTheme = localStorage.getItem('darkMode'); 21 | const checkbox = document.getElementById('checkbox'); 22 | 23 | // If user previously enabled dark mode 24 | if (savedTheme === 'enabled') { 25 | document.body.classList.add('dark-mode'); 26 | checkbox.checked = true; 27 | 28 | // Apply dark mode to canvas 29 | if (typeof setDarkMode === 'function') { 30 | setDarkMode(true); 31 | } 32 | } 33 | 34 | // Ensure colors button starts toggled since colors are on by default 35 | const colorsButton = document.getElementById('colors'); 36 | if (!colorsButton.classList.contains('toggled')) { 37 | colorsButton.classList.add('toggled'); 38 | } 39 | }); 40 | 41 | // Event listeners for dark mode toggle 42 | document.getElementById('checkbox').addEventListener('change', toggleDarkMode); 43 | 44 | // Rotate button functionality 45 | document.getElementById('rotate').addEventListener('click', function () { 46 | portrait = !portrait; 47 | this.classList.toggle('toggled'); 48 | eventWorker.postMessage({ type: 'portrait', portrait: portrait }); 49 | resizeVisibleCanvas(); 50 | 51 | // Show confirmation message 52 | showMessage(`Display ${portrait ? 'portrait' : 'landscape'} mode activated`, 2000); 53 | }); 54 | 55 | // Colors button functionality 56 | document.getElementById('colors').addEventListener('click', function () { 57 | withColor = !withColor; 58 | this.classList.toggle('toggled'); 59 | streamWorker.postMessage({ type: 'withColorChanged', withColor: withColor }); 60 | 61 | // Show confirmation message 62 | showMessage(`${withColor ? 'Color' : 'Grayscale'} mode enabled`, 2000); 63 | }); 64 | 65 | // Sidebar hover effect 66 | const sidebar = document.querySelector('.sidebar'); 67 | sidebar.addEventListener('mouseover', function () { 68 | sidebar.classList.add('active'); 69 | }); 70 | sidebar.addEventListener('mouseout', function () { 71 | sidebar.classList.remove('active'); 72 | }); 73 | 74 | // Resize the canvas whenever the window is resized 75 | window.addEventListener("resize", resizeVisibleCanvas); 76 | resizeVisibleCanvas(); 77 | 78 | // Mask drawing button functionality 79 | document.getElementById('switchOrderButton').addEventListener('click', function () { 80 | // Swap z-index values 81 | const isLayerSwitched = iFrame.style.zIndex != 1; 82 | 83 | if (isLayerSwitched) { 84 | iFrame.style.zIndex = 1; 85 | this.classList.remove('toggled'); 86 | showMessage('Drawing layer on top', 2000); 87 | } else { 88 | iFrame.style.zIndex = 4; 89 | this.classList.add('toggled'); 90 | showMessage('Content layer on top', 2000); 91 | } 92 | }); 93 | 94 | // Contrast slider functionality 95 | document.getElementById('contrastSlider').addEventListener('input', function() { 96 | // Get the slider value (between 1.0 and 3.0) 97 | const contrastLevel = this.value; 98 | 99 | // Update renderer if function exists 100 | if (typeof setContrast === 'function') { 101 | setContrast(contrastLevel); 102 | } 103 | 104 | // Show feedback when user stops moving the slider 105 | clearTimeout(this.timeout); 106 | this.timeout = setTimeout(() => { 107 | showMessage(`Contrast: ${parseFloat(contrastLevel).toFixed(1)}`, 1000); 108 | }, 500); 109 | }); 110 | 111 | // Load saved contrast value on initialization 112 | document.addEventListener('DOMContentLoaded', function() { 113 | // Check for saved contrast preference 114 | const savedContrast = localStorage.getItem('contrastLevel'); 115 | const contrastSlider = document.getElementById('contrastSlider'); 116 | 117 | if (savedContrast) { 118 | // Set the slider to the saved value 119 | contrastSlider.value = savedContrast; 120 | 121 | // Update the contrast setting 122 | if (typeof setContrast === 'function') { 123 | setContrast(savedContrast); 124 | } 125 | } 126 | }); 127 | 128 | 129 | -------------------------------------------------------------------------------- /client/utilities.js: -------------------------------------------------------------------------------- 1 | function downloadScreenshot(dataUrl) { 2 | // Use 'toDataURL' to capture the current canvas content 3 | // Create an 'a' element for downloading 4 | var link = document.getElementById("screenshot"); 5 | 6 | link.download = 'goMarkableScreenshot.png'; 7 | link.href = dataURL; 8 | link.click(); 9 | } 10 | 11 | // Function to show a message with auto-hide after specified duration 12 | function showMessage(message, duration = 3000) { 13 | const messageDiv = document.getElementById('message'); 14 | messageDiv.textContent = message; 15 | messageDiv.classList.add('visible'); 16 | 17 | // Auto-hide after specified duration 18 | setTimeout(() => { 19 | messageDiv.classList.remove('visible'); 20 | }, duration); 21 | } 22 | 23 | // Wait/loading message display 24 | function waiting(message) { 25 | const messageDiv = document.getElementById('message'); 26 | messageDiv.innerHTML = `${message} `; 27 | messageDiv.classList.add('visible'); 28 | } 29 | 30 | // Function to fetch app version from the server 31 | async function fetchVersion() { 32 | try { 33 | const response = await fetch('/version'); 34 | if (!response.ok) { 35 | throw new Error(`HTTP error! status: ${response.status}`); 36 | } 37 | const version = await response.text(); 38 | return version; 39 | } catch (error) { 40 | console.error('Error fetching version:', error); 41 | return 'unknown'; 42 | } 43 | } 44 | 45 | 46 | -------------------------------------------------------------------------------- /client/worker_event_processing.js: -------------------------------------------------------------------------------- 1 | let height; 2 | let width; 3 | let eventURL; 4 | let portrait; 5 | let draw; 6 | let latestX; 7 | let latestY; 8 | let maxXValue; 9 | let maxYValue; 10 | 11 | onmessage = (event) => { 12 | const data = event.data; 13 | 14 | switch (data.type) { 15 | case 'init': 16 | height = event.data.height; 17 | width = event.data.width; 18 | eventURL = event.data.eventURL; 19 | portrait = event.data.portrait; 20 | maxXValue = event.data.maxXValue; 21 | maxYValue = event.data.maxYValue; 22 | initiateEventsListener(); 23 | break; 24 | case 'portrait': 25 | portrait = event.data.portrait; 26 | // Handle the error, maybe show a user-friendly message or take some corrective action 27 | break; 28 | case 'terminate': 29 | console.log("terminating worker"); 30 | close(); 31 | break; 32 | } 33 | }; 34 | 35 | 36 | async function initiateEventsListener() { 37 | const eventSource = new EventSource(eventURL); 38 | draw = true; 39 | eventSource.onmessage = (event) => { 40 | const message = JSON.parse(event.data); 41 | if (message.Type === 3) { 42 | if (message.Code === 24) { 43 | draw = false; 44 | postMessage({ type: 'clear' }); 45 | // clearLaser(); 46 | } else if (message.Code === 25) { 47 | draw = true; 48 | 49 | } 50 | } 51 | if (message.Type === 3) { 52 | // Code 3: Update and draw laser pointer 53 | if (portrait) { 54 | if (message.Code === 1) { // Horizontal position 55 | latestX = scaleValue(message.Value, maxXValue, width); 56 | } else if (message.Code === 0) { // Vertical position 57 | latestY = height - scaleValue(message.Value, maxYValue, height); 58 | } 59 | } else { 60 | // wrong 61 | if (message.Code === 1) { // Horizontal position 62 | latestY = scaleValue(message.Value, maxYValue, height); 63 | } else if (message.Code === 0) { // Vertical position 64 | latestX = scaleValue(message.Value, maxXValue, width); 65 | } 66 | } 67 | if (draw) { 68 | postMessage({ type: 'update', X: latestX, Y: latestY }); 69 | } 70 | } 71 | } 72 | 73 | eventSource.onerror = () => { 74 | postMessage({ 75 | type: 'error', 76 | message: "EventSource error", 77 | }); 78 | console.error('EventSource error occurred.'); 79 | }; 80 | 81 | eventSource.onclose = () => { 82 | postMessage({ 83 | type: 'error', 84 | message: 'Connection closed' 85 | }); 86 | console.log('EventSource connection closed.'); 87 | }; 88 | } 89 | 90 | // Function to scale the incoming value to the canvas size 91 | function scaleValue(value, maxValue, canvasSize) { 92 | return (value / maxValue) * canvasSize; 93 | } -------------------------------------------------------------------------------- /client/worker_gesture_processing.js: -------------------------------------------------------------------------------- 1 | // Constants for the maximum values from the WebSocket messages 2 | const SWIPE_DISTANCE = 200; 3 | 4 | onmessage = (event) => { 5 | const data = event.data; 6 | 7 | switch (data.type) { 8 | case 'init': 9 | fetchStream(); 10 | break; 11 | case 'terminate': 12 | console.log("terminating worker"); 13 | close(); 14 | break; 15 | } 16 | }; 17 | 18 | async function fetchStream() { 19 | const response = await fetch('/gestures'); 20 | 21 | const reader = response.body.getReader(); 22 | const decoder = new TextDecoder('utf-8'); 23 | let buffer = ''; 24 | 25 | while (true) { 26 | const { value, done } = await reader.read(); 27 | if (done) break; 28 | 29 | buffer += decoder.decode(value, { stream: true }); 30 | 31 | while (buffer.includes('\n')) { 32 | const index = buffer.indexOf('\n'); 33 | const jsonStr = buffer.slice(0, index); 34 | buffer = buffer.slice(index + 1); 35 | 36 | try { 37 | const json = JSON.parse(jsonStr); 38 | let swipe = checkSwipeDirection(json); 39 | if (swipe != 'none') { 40 | postMessage({ type: 'gesture', value: swipe}) ; 41 | } 42 | } catch (e) { 43 | console.error('Error parsing JSON:', e); 44 | } 45 | } 46 | } 47 | } 48 | 49 | 50 | function checkSwipeDirection(json) { 51 | if (json.left > 400 && json.right < 100 && json.up < 100 && json.down < 100) { 52 | return 'left'; 53 | } else if (json.right > 400 && json.left < 100 && json.up < 100 && json.down < 100) { 54 | return 'right'; 55 | } else if (json.up > 400 && json.right < 100 && json.left < 100 && json.down < 100) { 56 | return 'up'; 57 | } else if (json.down > 400 && json.right < 100 && json.up < 100 && json.left < 100) { 58 | return 'down'; 59 | } else if (json.right > 600 && json.down > 600 && json.up < 50 && json.left < 50 ) { 60 | return 'topright-to-bottomleft' 61 | } else if (json.left > 600 && json.down > 600 && json.up < 50 && json.right < 50 ) { 62 | return 'topleft-to-bottomright' 63 | } else if (json.left > 600 && json.up > 600 && json.down < 50 && json.right < 50 ) { 64 | return 'bottomleft-to-topright' 65 | } else if (json.right > 600 && json.up > 600 && json.down < 50 && json.left < 50 ) { 66 | return 'bottomright-to-topleft' 67 | } else { 68 | return 'none'; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /client/worker_stream_processing.js: -------------------------------------------------------------------------------- 1 | let withColor=true; 2 | let height; 3 | let width; 4 | let rate; 5 | let useRLE; 6 | 7 | onmessage = (event) => { 8 | const data = event.data; 9 | 10 | switch (data.type) { 11 | case 'init': 12 | height = event.data.height; 13 | width = event.data.width; 14 | withColor = event.data.withColor; 15 | rate = event.data.rate; 16 | useRLE = event.data.useRLE; 17 | initiateStream(); 18 | break; 19 | case 'withColorChanged': 20 | withColor = event.data.withColor; 21 | // Handle the error, maybe show a user-friendly message or take some corrective action 22 | break; 23 | case 'terminate': 24 | console.log("terminating worker"); 25 | close(); 26 | break; 27 | 28 | } 29 | }; 30 | 31 | 32 | async function initiateStream() { 33 | const RETRY_DELAY_MS = 3000; // Delay before retrying the connection (in milliseconds) 34 | 35 | try { 36 | 37 | // Create a new ReadableStream instance from a fetch request 38 | const response = await fetch('/stream?rate='+rate); 39 | const stream = response.body; 40 | 41 | // Create a reader for the ReadableStream 42 | const reader = stream.getReader(); 43 | // Create an ImageData object with the byte array length 44 | const pixelDataSize = width * height * 4; 45 | const imageData = new Uint8ClampedArray(pixelDataSize); 46 | 47 | var offset = 0; 48 | var count = 0; 49 | 50 | // Define a function to process the chunks of data as they arrive 51 | const processData = async ({ done, value }) => { 52 | try { 53 | if (done) { 54 | postMessage({ 55 | type: 'error', 56 | message: "end of transmission" 57 | }); 58 | return; 59 | } 60 | 61 | const uint8Array = new Uint8Array(value); 62 | 63 | // Process the received data chunk and render if needed. 64 | if (useRLE) { 65 | ({ offset, count } = decodeRLE(imageData, uint8Array, offset, count, withColor, pixelDataSize)); 66 | } else { 67 | offset = decodeRaw(imageData, uint8Array, offset, pixelDataSize); 68 | } 69 | 70 | // Read the next chunk 71 | const nextChunk = await reader.read(); 72 | processData(nextChunk); 73 | } catch (error) { 74 | console.log(error) 75 | postMessage({ 76 | type: 'error', 77 | message: error.message 78 | }); 79 | } 80 | 81 | }; 82 | 83 | // Start reading the initial chunk of data 84 | const initialChunk = await reader.read(); 85 | processData(initialChunk); 86 | } catch (error) { 87 | console.error('Error:', error); 88 | // Handle the error and determine if a reconnection should be attempted 89 | // For example, you can check the error message or status code to decide 90 | 91 | // Retry the connection after the delay 92 | postMessage({ 93 | type: 'error', 94 | message: error.message 95 | }); 96 | } 97 | } 98 | 99 | 100 | function decodeRLE(imageData, chunkData, offset, count, withColor, pixelDataSize) { 101 | for (let i = 0; i < chunkData.length; i++) { 102 | if (count === 0) { 103 | // This byte represents how many times the next value will be repeated 104 | count = chunkData[i]; 105 | continue; 106 | } 107 | 108 | const value = chunkData[i]; 109 | for (let c = 0; c < count; c++) { 110 | offset += 4; 111 | if (withColor) { 112 | switch (value) { 113 | case 30: // Transparent 114 | imageData[offset+3] = 0; 115 | break; 116 | case 6: // Red 117 | imageData[offset] = 255; 118 | imageData[offset+1] = 0; 119 | imageData[offset+2] = 0; 120 | imageData[offset+3] = 255; 121 | break; 122 | case 8: // Red 123 | imageData[offset] = 255; 124 | imageData[offset+1] = 0; 125 | imageData[offset+2] = 0; 126 | imageData[offset+3] = 255; 127 | break; 128 | case 12: // Blue 129 | imageData[offset] = 0; 130 | imageData[offset+1] = 0; 131 | imageData[offset+2] = 255; 132 | imageData[offset+3] = 255; 133 | break; 134 | case 20: // Green 135 | imageData[offset] = 125; 136 | imageData[offset+1] = 184; 137 | imageData[offset+2] = 86; 138 | imageData[offset+3] = 255; 139 | break; 140 | case 24: // Yellow 141 | imageData[offset] = 255; 142 | imageData[offset+1] = 253; 143 | imageData[offset+2] = 84; 144 | imageData[offset+3] = 255; 145 | break; 146 | default: 147 | imageData[offset] = value * 10; 148 | imageData[offset+1] = value * 10; 149 | imageData[offset+2] = value * 10; 150 | imageData[offset+3] = 255; 151 | break; 152 | } 153 | } else { 154 | if (value === 30) { 155 | imageData[offset+3] = 0; 156 | } else { 157 | imageData[offset] = value * 10; 158 | imageData[offset+1] = value * 10; 159 | imageData[offset+2] = value * 10; 160 | imageData[offset+3] = 255; 161 | } 162 | } 163 | 164 | if (offset >= pixelDataSize) { 165 | break; 166 | } 167 | } 168 | 169 | // Reset count after processing this run 170 | count = 0; 171 | 172 | if (offset >= pixelDataSize) { 173 | // Send the frame 174 | postMessage({ type: 'update', data: imageData }); 175 | 176 | // Reset for next frame 177 | offset = 0; 178 | } 179 | } 180 | 181 | return { offset, count }; 182 | } 183 | 184 | function decodeRaw(imageData, chunkData, offset, pixelDataSize) { 185 | let start = 0; 186 | while (start < chunkData.length) { 187 | const bytesLeftInFrame = pixelDataSize - offset; 188 | const bytesToCopy = Math.min(chunkData.length - start, bytesLeftInFrame); 189 | imageData.set(chunkData.subarray(start, start + bytesToCopy), offset); 190 | 191 | offset += bytesToCopy; 192 | start += bytesToCopy; 193 | 194 | // If we've completed a full frame 195 | if (offset >= pixelDataSize) { 196 | // Send the frame 197 | postMessage({ type: 'update', data: imageData }); 198 | 199 | // Reset for next frame 200 | offset = 0; 201 | } 202 | } 203 | 204 | return offset; 205 | } 206 | 207 | function simpleSum(data) { 208 | return data.reduce((acc, val) => acc + val, 0); 209 | } 210 | -------------------------------------------------------------------------------- /client/workersHandling.js: -------------------------------------------------------------------------------- 1 | // Send the OffscreenCanvas to the worker for initialization 2 | streamWorker.postMessage({ 3 | type: 'init', 4 | width: screenWidth, 5 | height: screenHeight, 6 | rate: rate, 7 | withColor: withColor, 8 | useRLE: UseRLE, 9 | }); 10 | 11 | 12 | // Listen for updates from the worker 13 | streamWorker.onmessage = (event) => { 14 | // To hide the message (e.g., when you start drawing in WebGL again) 15 | messageDiv.style.display = 'none'; 16 | 17 | const data = event.data; 18 | 19 | switch (data.type) { 20 | case 'update': 21 | // Handle the update 22 | const data = event.data.data; 23 | updateTexture(data, portrait, 1); 24 | break; 25 | case 'error': 26 | console.error('Error from worker:', event.data.message); 27 | waiting(event.data.message) 28 | // Handle the error, maybe show a user-friendly message or take some corrective action 29 | break; 30 | // ... handle other message types as needed 31 | } 32 | }; 33 | 34 | 35 | // Determine the WebSocket protocol based on the current window protocol 36 | const eventURL = `/events`; 37 | // Send the OffscreenCanvas to the worker for initialization 38 | eventWorker.postMessage({ 39 | type: 'init', 40 | width: screenWidth, 41 | height: screenHeight, 42 | portrait: portrait, 43 | eventURL: eventURL, 44 | maxXValue: MaxXValue, 45 | maxYValue: MaxYValue, 46 | }); 47 | gestureWorker.postMessage({ 48 | type: 'init', 49 | }); 50 | 51 | gestureWorker.onmessage = (event) => { 52 | const data = event.data; 53 | 54 | switch (data.type) { 55 | case 'gesture': 56 | 57 | switch (event.data.value) { 58 | case 'left': 59 | document.getElementById('content').contentWindow.postMessage( JSON.stringify({ method: 'left' }), '*' ); 60 | break; 61 | case 'right': 62 | document.getElementById('content').contentWindow.postMessage( JSON.stringify({ method: 'right' }), '*' ); 63 | break; 64 | case 'topleft-to-bottomright': 65 | document.getElementById('content').contentWindow.postMessage( JSON.stringify({ method: 'right' }), '*' ); 66 | break; 67 | case 'topright-to-bottomleft': 68 | document.getElementById('content').contentWindow.postMessage( JSON.stringify({ method: 'left' }), '*' ); 69 | break; 70 | case 'bottomright-to-topleft': 71 | iFrame.style.zIndex = 1; 72 | break; 73 | case 'bottomleft-to-topright': 74 | iFrame.style.zIndex = 4; 75 | break; 76 | default: 77 | // Code to execute if none of the above cases match 78 | } 79 | break; 80 | case 'error': 81 | console.error('Error from worker:', event.data.message); 82 | break; 83 | } 84 | 85 | } 86 | 87 | let messageTimeout; 88 | 89 | function clearLaser() { 90 | // Function to call when no message is received for 300 ms 91 | updateLaserPosition(-10,-10); 92 | } 93 | // Listen for updates from the worker 94 | eventWorker.onmessage = (event) => { 95 | // Reset the timer every time a message is received 96 | clearTimeout(messageTimeout); 97 | messageTimeout = setTimeout(clearLaser, 300); 98 | 99 | // To hide the message (e.g., when you start drawing in WebGL again) 100 | messageDiv.style.display = 'none'; 101 | 102 | const data = event.data; 103 | 104 | switch (data.type) { 105 | case 'clear': 106 | updateLaserPosition(-10,-10); 107 | //clearLaser(); 108 | break; 109 | case 'update': 110 | // Handle the update 111 | const X = event.data.X; 112 | const Y = event.data.Y; 113 | updateLaserPosition(X,Y); 114 | break; 115 | case 'error': 116 | console.error('Error from worker:', event.data.message); 117 | waiting(event.data.message) 118 | // Handle the error, maybe show a user-friendly message or take some corrective action 119 | break; 120 | // ... handle other message types as needed 121 | } 122 | }; 123 | -------------------------------------------------------------------------------- /docs/goMarkableStream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/docs/goMarkableStream.png -------------------------------------------------------------------------------- /docs/goMarkableStreamRecording.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/docs/goMarkableStreamRecording.mp4 -------------------------------------------------------------------------------- /docs/goMarkableStreamRecording.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/docs/goMarkableStreamRecording.webm -------------------------------------------------------------------------------- /docs/gorgoniaExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/docs/gorgoniaExample.png -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/docs/logo.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/owulveryck/goMarkableStream 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/kelseyhightower/envconfig v1.4.0 7 | github.com/klauspost/compress v1.18.0 8 | golang.ngrok.com/ngrok v1.13.0 9 | ) 10 | 11 | require ( 12 | github.com/go-stack/stack v1.8.1 // indirect 13 | github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible // indirect 14 | github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect 15 | github.com/jpillora/backoff v1.0.0 // indirect 16 | github.com/mattn/go-colorable v0.1.14 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | go.uber.org/multierr v1.11.0 // indirect 19 | golang.ngrok.com/muxado/v2 v2.0.1 // indirect 20 | golang.org/x/net v0.35.0 // indirect 21 | golang.org/x/sync v0.11.0 // indirect 22 | golang.org/x/sys v0.30.0 // indirect 23 | golang.org/x/term v0.29.0 // indirect 24 | google.golang.org/protobuf v1.36.5 // indirect 25 | gopkg.in/yaml.v2 v2.4.0 // indirect 26 | gopkg.in/yaml.v3 v3.0.1 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= 4 | github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= 5 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 6 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 7 | github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= 8 | github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= 9 | github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible h1:VryeOTiaZfAzwx8xBcID1KlJCeoWSIpsNbSk+/D2LNk= 10 | github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= 11 | github.com/inconshreveable/log15/v3 v3.0.0-testing.5 h1:h4e0f3kjgg+RJBlKOabrohjHe47D3bbAB9BgMrc3DYA= 12 | github.com/inconshreveable/log15/v3 v3.0.0-testing.5/go.mod h1:3GQg1SVrLoWGfRv/kAZMsdyU5cp8eFc1P3cw+Wwku94= 13 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 14 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 15 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 16 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 17 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 18 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 19 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 20 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 21 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 24 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 25 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 26 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 27 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 28 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 29 | golang.ngrok.com/muxado/v2 v2.0.1 h1:jM9i6Pom6GGmnPrHKNR6OJRrUoHFkSZlJ3/S0zqdVpY= 30 | golang.ngrok.com/muxado/v2 v2.0.1/go.mod h1:wzxJYX4xiAtmwumzL+QsukVwFRXmPNv86vB8RPpOxyM= 31 | golang.ngrok.com/ngrok v1.13.0 h1:6SeOS+DAeIaHlkDmNH5waFHv0xjlavOV3wml0Z59/8k= 32 | golang.ngrok.com/ngrok v1.13.0/go.mod h1:BKOMdoZXfD4w6o3EtE7Cu9TVbaUWBqptrZRWnVcAuI4= 33 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 34 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 35 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 36 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 37 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 38 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 39 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 41 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 42 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 43 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 44 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 45 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 46 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 47 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 51 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 52 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 53 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 54 | -------------------------------------------------------------------------------- /gzip.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | type gzipResponseWriter struct { 11 | io.Writer 12 | http.ResponseWriter 13 | } 14 | 15 | func (w gzipResponseWriter) Write(b []byte) (int, error) { 16 | return w.Writer.Write(b) 17 | } 18 | 19 | func gzMiddleware(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { 22 | next.ServeHTTP(w, r) 23 | return 24 | } 25 | w.Header().Set("Content-Encoding", "gzip") 26 | gz, _ := gzip.NewWriterLevel(w, 1) 27 | defer gz.Close() 28 | gzr := gzipResponseWriter{Writer: gz, ResponseWriter: w} 29 | next.ServeHTTP(gzr, r) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "net" 9 | "net/http" 10 | "runtime/debug" 11 | 12 | "github.com/owulveryck/goMarkableStream/internal/eventhttphandler" 13 | "github.com/owulveryck/goMarkableStream/internal/pubsub" 14 | "github.com/owulveryck/goMarkableStream/internal/remarkable" 15 | "github.com/owulveryck/goMarkableStream/internal/stream" 16 | ) 17 | 18 | type stripFS struct { 19 | fs http.FileSystem 20 | } 21 | 22 | func (s stripFS) Open(name string) (http.File, error) { 23 | return s.fs.Open("client" + name) 24 | } 25 | 26 | func setMuxer(eventPublisher *pubsub.PubSub) *http.ServeMux { 27 | mux := http.NewServeMux() 28 | 29 | // Custom handler to serve index.html for root path 30 | mux.HandleFunc("/", newIndexHandler(stripFS{http.FS(assetsFS)})) 31 | 32 | streamHandler := stream.NewStreamHandler(file, pointerAddr, eventPublisher, c.RLECompression) 33 | if c.Compression { 34 | mux.Handle("/stream", gzMiddleware(stream.ThrottlingMiddleware(streamHandler))) 35 | } else if c.ZSTDCompression { 36 | mux.Handle("/stream", zstdMiddleware(stream.ThrottlingMiddleware(streamHandler), c.ZSTDCompressionLevel)) 37 | } else { 38 | mux.Handle("/stream", stream.ThrottlingMiddleware(streamHandler)) 39 | } 40 | 41 | wsHandler := eventhttphandler.NewEventHandler(eventPublisher) 42 | mux.Handle("/events", wsHandler) 43 | gestureHandler := eventhttphandler.NewGestureHandler(eventPublisher) 44 | mux.Handle("/gestures", gestureHandler) 45 | 46 | // Version endpoint 47 | mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { 48 | bi, ok := debug.ReadBuildInfo() 49 | if !ok { 50 | http.Error(w, "Unable to read build info", http.StatusInternalServerError) 51 | return 52 | } 53 | w.Header().Set("Content-Type", "text/plain") 54 | fmt.Fprintf(w, "%s", bi.Main.Version) 55 | }) 56 | 57 | if c.DevMode { 58 | rawHandler := stream.NewRawHandler(file, pointerAddr) 59 | mux.Handle("/raw", rawHandler) 60 | } 61 | return mux 62 | } 63 | 64 | func parseIndexTemplate(templatePath string) (*template.Template, error) { 65 | indexData, err := assetsFS.ReadFile(templatePath) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | tmpl, err := template.New("index.html").Parse(string(indexData)) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | return tmpl, nil 76 | } 77 | 78 | func newIndexHandler(fs http.FileSystem) http.HandlerFunc { 79 | tmpl, err := parseIndexTemplate("client/index.html") 80 | if err != nil { 81 | log.Fatalf("Error parsing index template: %v", err) 82 | panic(err) 83 | } 84 | 85 | staticFileServer := http.FileServer(fs) 86 | 87 | data := struct { 88 | ScreenWidth int 89 | ScreenHeight int 90 | MaxXValue int 91 | MaxYValue int 92 | UseRLE bool 93 | DeviceModel string 94 | }{ 95 | ScreenWidth: remarkable.ScreenWidth, 96 | ScreenHeight: remarkable.ScreenHeight, 97 | MaxXValue: remarkable.MaxXValue, 98 | MaxYValue: remarkable.MaxYValue, 99 | UseRLE: c.RLECompression, 100 | DeviceModel: remarkable.Model.String(), 101 | } 102 | 103 | return func(w http.ResponseWriter, r *http.Request) { 104 | if r.URL.Path == "/" { 105 | w.Header().Set("Content-Type", "text/html") 106 | if err := tmpl.Execute(w, data); err != nil { 107 | http.Error(w, "Error rendering template", http.StatusInternalServerError) 108 | log.Printf("Error rendering template: %v", err) 109 | } 110 | return 111 | } 112 | 113 | staticFileServer.ServeHTTP(w, r) 114 | } 115 | } 116 | 117 | func runTLS(l net.Listener, handler http.Handler) error { 118 | // Load the certificate and key from embedded files 119 | cert, err := tlsAssets.ReadFile("assets/cert.pem") 120 | if err != nil { 121 | log.Fatal("Error reading embedded certificate:", err) 122 | } 123 | 124 | key, err := tlsAssets.ReadFile("assets/key.pem") 125 | if err != nil { 126 | log.Fatal("Error reading embedded key:", err) 127 | } 128 | 129 | certPair, err := tls.X509KeyPair(cert, key) 130 | if err != nil { 131 | log.Fatal("Error creating X509 key pair:", err) 132 | } 133 | 134 | config := &tls.Config{ 135 | Certificates: []tls.Certificate{certPair}, 136 | InsecureSkipVerify: true, 137 | } 138 | 139 | tlsListener := tls.NewListener(l, config) 140 | 141 | // Start the server 142 | return http.Serve(tlsListener, handler) 143 | } 144 | -------------------------------------------------------------------------------- /ifaces.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | func ifaces() { 9 | // Get the list of network interfaces 10 | interfaces, err := net.Interfaces() 11 | if err != nil { 12 | fmt.Println("Failed to retrieve network interfaces:", err) 13 | return 14 | } 15 | 16 | // Iterate through the interfaces 17 | for _, iface := range interfaces { 18 | // Filter out loopback and non-up interfaces 19 | if iface.Flags&net.FlagLoopback == 0 && iface.Flags&net.FlagUp != 0 { 20 | addrs, err := iface.Addrs() 21 | if err != nil { 22 | fmt.Println("Failed to retrieve addresses for interface", iface.Name, ":", err) 23 | continue 24 | } 25 | 26 | // Iterate through the addresses 27 | for _, addr := range addrs { 28 | // Check if the address is an IP address 29 | if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 30 | if ipnet.IP.To4() != nil { 31 | fmt.Println("Local IP address:", ipnet.IP.String()) 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/eventhttphandler/gesture_handler.go: -------------------------------------------------------------------------------- 1 | package eventhttphandler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "syscall" 8 | "time" 9 | 10 | "github.com/owulveryck/goMarkableStream/internal/events" 11 | "github.com/owulveryck/goMarkableStream/internal/pubsub" 12 | ) 13 | 14 | // SwipeDirection ... 15 | type SwipeDirection string 16 | 17 | const ( 18 | // SwipeLeft ... 19 | SwipeLeft SwipeDirection = "Swipe Left" 20 | // SwipeRight ... 21 | SwipeRight SwipeDirection = "Swipe Right" 22 | ) 23 | 24 | // NewGestureHandler creates an event habdler that subscribes from the inputEvents 25 | func NewGestureHandler(inputEvents *pubsub.PubSub) *GestureHandler { 26 | return &GestureHandler{ 27 | inputEventBus: inputEvents, 28 | } 29 | } 30 | 31 | // GestureHandler is a http.Handler that detect touch gestures 32 | type GestureHandler struct { 33 | inputEventBus *pubsub.PubSub 34 | } 35 | 36 | type gesture struct { 37 | leftDistance, rightDistance, upDistance, downDistance int 38 | } 39 | 40 | func (g *gesture) MarshalJSON() ([]byte, error) { 41 | return []byte(fmt.Sprintf(`{ "left": %v, "right": %v, "up": %v, "down": %v}`+"\n", g.leftDistance, g.rightDistance, g.upDistance, g.downDistance)), nil 42 | } 43 | 44 | func (g *gesture) String() string { 45 | return fmt.Sprintf("Left: %v, Right: %v, Up: %v, Down: %v", g.leftDistance, g.rightDistance, g.upDistance, g.downDistance) 46 | } 47 | 48 | func (g *gesture) sum() int { 49 | return g.leftDistance + g.rightDistance + g.upDistance + g.downDistance 50 | } 51 | 52 | func (g *gesture) reset() { 53 | g.leftDistance = 0 54 | g.rightDistance = 0 55 | g.upDistance = 0 56 | g.downDistance = 0 57 | } 58 | 59 | // ServeHTTP implements http.Handler 60 | func (h *GestureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 61 | eventC := h.inputEventBus.Subscribe("eventListener") 62 | defer func() { 63 | h.inputEventBus.Unsubscribe(eventC) 64 | }() 65 | const ( 66 | codeXAxis uint16 = 54 67 | codeYAxis uint16 = 53 68 | maxStepDist int32 = 150 69 | // a gesture in a set of event separated by 100 millisecond 70 | gestureMaxInterval = 150 * time.Millisecond 71 | ) 72 | 73 | tick := time.NewTicker(gestureMaxInterval) 74 | defer tick.Stop() 75 | currentGesture := &gesture{} 76 | lastEventX := events.InputEventFromSource{} 77 | lastEventY := events.InputEventFromSource{} 78 | 79 | enc := json.NewEncoder(w) 80 | w.Header().Set("Content-Type", "application/x-ndjson") 81 | 82 | for { 83 | select { 84 | case <-r.Context().Done(): 85 | return 86 | case <-tick.C: 87 | // TODO send last event 88 | if currentGesture.sum() != 0 { 89 | err := enc.Encode(currentGesture) 90 | if err != nil { 91 | http.Error(w, "cannot send json encode the message "+err.Error(), http.StatusInternalServerError) 92 | return 93 | } 94 | if f, ok := w.(http.Flusher); ok { 95 | f.Flush() 96 | } 97 | } 98 | currentGesture.reset() 99 | lastEventX = events.InputEventFromSource{} 100 | lastEventY = events.InputEventFromSource{} 101 | case event := <-eventC: 102 | if event.Source != events.Touch { 103 | continue 104 | } 105 | if event.Type != events.EvAbs { 106 | continue 107 | } 108 | switch event.Code { 109 | case codeXAxis: 110 | // This is the initial event, do not compute the distance 111 | if lastEventX.Value == 0 { 112 | lastEventX = event 113 | continue 114 | } 115 | distance := event.Value - lastEventX.Value 116 | if distance < 0 { 117 | currentGesture.rightDistance += -int(distance) 118 | } else { 119 | currentGesture.leftDistance += int(distance) 120 | } 121 | lastEventX = event 122 | case codeYAxis: 123 | // This is the initial event, do not compute the distance 124 | if lastEventY.Value == 0 { 125 | lastEventY = event 126 | continue 127 | } 128 | distance := event.Value - lastEventY.Value 129 | if distance < 0 { 130 | currentGesture.upDistance += -int(distance) 131 | } else { 132 | currentGesture.downDistance += int(distance) 133 | } 134 | lastEventY = event 135 | } 136 | tick.Reset(gestureMaxInterval) 137 | } 138 | } 139 | } 140 | 141 | func abs(x int32) int32 { 142 | if x < 0 { 143 | return -x 144 | } 145 | return x 146 | } 147 | 148 | // timevalToTime converts syscall.Timeval to time.Time 149 | func timevalToTime(tv syscall.Timeval) time.Time { 150 | return time.Unix(int64(tv.Sec), int64(tv.Usec)*1000) 151 | } 152 | -------------------------------------------------------------------------------- /internal/eventhttphandler/pen_handler.go: -------------------------------------------------------------------------------- 1 | package eventhttphandler 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/owulveryck/goMarkableStream/internal/events" 9 | "github.com/owulveryck/goMarkableStream/internal/pubsub" 10 | ) 11 | 12 | // NewEventHandler creates an event habdler that subscribes from the inputEvents 13 | func NewEventHandler(inputEvents *pubsub.PubSub) *EventHandler { 14 | return &EventHandler{ 15 | inputEventBus: inputEvents, 16 | } 17 | } 18 | 19 | // EventHandler is a http.Handler that servers the input events over http via wabsockets 20 | type EventHandler struct { 21 | inputEventBus *pubsub.PubSub 22 | } 23 | 24 | // ServeHTTP implements http.Handler 25 | func (h *EventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 26 | eventC := h.inputEventBus.Subscribe("eventListener") 27 | defer func() { 28 | h.inputEventBus.Unsubscribe(eventC) 29 | }() 30 | // Set necessary headers to indicate a stream 31 | w.Header().Set("Content-Type", "text/event-stream") 32 | w.Header().Set("Cache-Control", "no-cache") 33 | w.Header().Set("Connection", "keep-alive") 34 | 35 | for { 36 | select { 37 | case <-r.Context().Done(): 38 | return 39 | case event := <-eventC: 40 | // Serialize the structure as JSON 41 | if event.Source != events.Pen { 42 | continue 43 | } 44 | if event.Type != events.EvAbs { 45 | continue 46 | } 47 | jsonMessage, err := json.Marshal(event) 48 | if err != nil { 49 | http.Error(w, "cannot send json encode the message "+err.Error(), http.StatusInternalServerError) 50 | return 51 | } 52 | // Send the event 53 | fmt.Fprintf(w, "data: %s\n\n", jsonMessage) 54 | w.(http.Flusher).Flush() // Ensure client receives the message immediately 55 | 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /internal/events/events.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "syscall" 4 | 5 | const ( 6 | // Input event types 7 | // see https://www.kernel.org/doc/Documentation/input/event-codes.txt 8 | 9 | // EvSyn is used as markers to separate events. Events may be separated in time or in 10 | // space, such as with the multitouch protocol. 11 | EvSyn = 0 12 | // EvKey is used to describe state changes of keyboards, buttons, or other key-like 13 | // devices. 14 | EvKey = 1 15 | // EvRel is used to describe relative axis value changes, e.g., moving the mouse 16 | // 5 units to the left. 17 | EvRel = 2 18 | // EvAbs is used to describe absolute axis value changes, e.g., describing the 19 | // coordinates of a touch on a touchscreen. 20 | EvAbs = 3 21 | // EvMsc is used to describe miscellaneous input data that do not fit into other types. 22 | EvMsc = 4 23 | // EvSw is used to describe binary state input switches. 24 | EvSw = 5 25 | // EvLed is used to turn LEDs on devices on and off. 26 | EvLed = 17 27 | // EvSnd is used to output sound to devices. 28 | EvSnd = 18 29 | // EvRep is used for autorepeating devices. 30 | EvRep = 20 31 | // EvFf is used to send force feedback commands to an input device. 32 | EvFf = 21 33 | // EvPwr is a special type for power button and switch input. 34 | EvPwr = 22 35 | // EvFfStatus is used to receive force feedback device status. 36 | EvFfStatus = 23 37 | ) 38 | 39 | const ( 40 | // Pen event 41 | Pen int = 1 42 | // Touch event 43 | Touch int = 2 44 | ) 45 | 46 | // InputEvent from the reMarkable 47 | type InputEvent struct { 48 | Time syscall.Timeval `json:"-"` 49 | Type uint16 50 | // Code holds the position of the mouse/touch 51 | // In case of an EV_ABS event, 52 | // 1 -> X-axis (vertical movement) | 0 < Value < 15725 if mouse 53 | // 0 -> Y-axis (horizontal movement) | 0 < Value < 20966 if mouse 54 | Code uint16 55 | Value int32 56 | } 57 | 58 | // InputEventFromSource add the source origin 59 | type InputEventFromSource struct { 60 | Source int 61 | InputEvent 62 | } 63 | -------------------------------------------------------------------------------- /internal/pubsub/pubsub.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/owulveryck/goMarkableStream/internal/events" 8 | ) 9 | 10 | // PubSub is a structure to hold publisher and subscribers to events 11 | type PubSub struct { 12 | subscribers map[chan events.InputEventFromSource]bool 13 | mu sync.Mutex 14 | } 15 | 16 | // NewPubSub creates a new pubsub 17 | func NewPubSub() *PubSub { 18 | return &PubSub{ 19 | subscribers: make(map[chan events.InputEventFromSource]bool), 20 | } 21 | } 22 | 23 | // Publish an event to all subscribers 24 | func (ps *PubSub) Publish(event events.InputEventFromSource) { 25 | // Create a ticker for the timeout 26 | ticker := time.NewTicker(100 * time.Millisecond) 27 | defer ticker.Stop() 28 | ps.mu.Lock() 29 | defer ps.mu.Unlock() 30 | 31 | for ch := range ps.subscribers { 32 | select { 33 | case ch <- event: 34 | case <-ticker.C: 35 | } 36 | } 37 | } 38 | 39 | // Subscribe to the topics to get the event published by the publishers 40 | func (ps *PubSub) Subscribe(name string) chan events.InputEventFromSource { 41 | eventChan := make(chan events.InputEventFromSource) 42 | 43 | ps.mu.Lock() 44 | 45 | ps.subscribers[eventChan] = true 46 | ps.mu.Unlock() 47 | 48 | return eventChan 49 | } 50 | 51 | // Unsubscribe from the events 52 | func (ps *PubSub) Unsubscribe(ch chan events.InputEventFromSource) { 53 | ps.mu.Lock() 54 | defer ps.mu.Unlock() 55 | 56 | if _, ok := ps.subscribers[ch]; ok { 57 | delete(ps.subscribers, ch) 58 | close(ch) // Close the channel to signal subscriber to exit. 59 | 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/remarkable/const.go: -------------------------------------------------------------------------------- 1 | //go:build !arm64 2 | 3 | package remarkable 4 | 5 | const ( 6 | // Model defines the current device model being used 7 | Model = Remarkable2 8 | 9 | // ScreenWidth of the remarkable 2 10 | ScreenWidth = 1872 11 | // ScreenHeight of the remarkable 2 12 | ScreenHeight = 1404 13 | 14 | // ScreenSizeBytes is the total memory size of the screen buffer in bytes 15 | ScreenSizeBytes = ScreenWidth * ScreenHeight * 2 16 | 17 | // MaxXValue represents the maximum X coordinate value from /dev/input/event1 (ABS_X) 18 | MaxXValue = 15725 19 | // MaxYValue represents the maximum Y coordinate value from /dev/input/event1 (ABS_Y) 20 | MaxYValue = 20966 21 | 22 | // PenInputDevice ... 23 | PenInputDevice = "/dev/input/event1" 24 | // TouchInputDevice ... 25 | TouchInputDevice = "/dev/input/event2" 26 | ) 27 | -------------------------------------------------------------------------------- /internal/remarkable/const_arm64.go: -------------------------------------------------------------------------------- 1 | //go:build arm64 2 | 3 | package remarkable 4 | 5 | const ( 6 | Model = RemarkablePaperPro 7 | 8 | // ScreenWidth of the remarkable paper pro 9 | ScreenWidth = 1624 10 | // ScreenHeight of the remarkable paper pro 11 | ScreenHeight = 2154 12 | 13 | ScreenSizeBytes = ScreenWidth * ScreenHeight * 4 14 | 15 | // These values are from Max values of /dev/input/event2 (ABS_X and ABS_Y) 16 | MaxXValue = 11180 17 | MaxYValue = 15340 18 | 19 | PenInputDevice = "/dev/input/event2" 20 | TouchInputDevice = "/dev/input/event3" 21 | ) 22 | -------------------------------------------------------------------------------- /internal/remarkable/device.go: -------------------------------------------------------------------------------- 1 | package remarkable 2 | 3 | // DeviceModel represents the type of reMarkable device being used 4 | type DeviceModel int 5 | 6 | const ( 7 | // UnknownDevice represents an unidentified reMarkable device 8 | UnknownDevice DeviceModel = iota 9 | // Remarkable2 represents the reMarkable 2 device 10 | Remarkable2 11 | // RemarkablePaperPro represents the reMarkable Paper Pro device 12 | RemarkablePaperPro 13 | ) 14 | 15 | func (d DeviceModel) String() string { 16 | switch d { 17 | case Remarkable2: 18 | return "Remarkable2" 19 | case RemarkablePaperPro: 20 | return "RemarkablePaperPro" 21 | default: 22 | return "UnknownDevice" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/remarkable/events.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | 3 | package remarkable 4 | 5 | import ( 6 | "os" 7 | "time" 8 | 9 | "context" 10 | 11 | "github.com/owulveryck/goMarkableStream/internal/events" 12 | "github.com/owulveryck/goMarkableStream/internal/pubsub" 13 | ) 14 | 15 | // EventScanner listens to events on input2 and 3 and sends them to the EventC 16 | type EventScanner struct { 17 | pen, touch *os.File 18 | } 19 | 20 | // NewEventScanner ... 21 | func NewEventScanner() *EventScanner { 22 | return &EventScanner{} 23 | } 24 | 25 | // StartAndPublish the event scanner and feed the EventC on movement. use the context to end the routine 26 | func (e *EventScanner) StartAndPublish(ctx context.Context, pubsub *pubsub.PubSub) { 27 | go func(ctx context.Context) { 28 | tick := time.NewTicker(500 * time.Millisecond) 29 | defer tick.Stop() 30 | for { 31 | select { 32 | case <-ctx.Done(): 33 | return 34 | case <-tick.C: 35 | pubsub.Publish(events.InputEventFromSource{ 36 | Source: 1, 37 | InputEvent: events.InputEvent{}, 38 | }) 39 | } 40 | } 41 | }(ctx) 42 | } 43 | -------------------------------------------------------------------------------- /internal/remarkable/events_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package remarkable 4 | 5 | import ( 6 | "log" 7 | "os" 8 | "unsafe" 9 | 10 | "context" 11 | 12 | "github.com/owulveryck/goMarkableStream/internal/events" 13 | "github.com/owulveryck/goMarkableStream/internal/pubsub" 14 | ) 15 | 16 | // EventScanner ... 17 | type EventScanner struct { 18 | pen, touch *os.File 19 | } 20 | 21 | // NewEventScanner ... 22 | func NewEventScanner() *EventScanner { 23 | pen, err := os.OpenFile(PenInputDevice, os.O_RDONLY, 0o644) 24 | if err != nil { 25 | log.Fatalf("failed to read pen position: %v", err) 26 | } 27 | touch, err := os.OpenFile(TouchInputDevice, os.O_RDONLY, 0o644) 28 | if err != nil { 29 | log.Fatalf("failed to read touch position: %v", err) 30 | } 31 | return &EventScanner{ 32 | pen: pen, 33 | touch: touch, 34 | } 35 | } 36 | 37 | // StartAndPublish ... 38 | func (e *EventScanner) StartAndPublish(ctx context.Context, pubsub *pubsub.PubSub) { 39 | // Start a goroutine to read events and send them on the channel 40 | go func(_ context.Context) { 41 | for { 42 | ev, err := readEvent(e.pen) 43 | if err != nil { 44 | log.Println(err) 45 | continue 46 | } 47 | pubsub.Publish(events.InputEventFromSource{ 48 | Source: events.Pen, 49 | InputEvent: ev, 50 | }) 51 | } 52 | }(ctx) 53 | // Start a goroutine to read events and send them on the channel 54 | go func(_ context.Context) { 55 | for { 56 | ev, err := readEvent(e.touch) 57 | if err != nil { 58 | log.Println(err) 59 | continue 60 | } 61 | pubsub.Publish(events.InputEventFromSource{ 62 | Source: events.Touch, 63 | InputEvent: ev, 64 | }) 65 | } 66 | }(ctx) 67 | } 68 | 69 | func readEvent(inputDevice *os.File) (events.InputEvent, error) { 70 | var ev events.InputEvent 71 | _, err := inputDevice.Read((*(*[unsafe.Sizeof(ev)]byte)(unsafe.Pointer(&ev)))[:]) 72 | return ev, err 73 | 74 | } 75 | -------------------------------------------------------------------------------- /internal/remarkable/fb.go: -------------------------------------------------------------------------------- 1 | //go:build !linux || (!arm && !arm64) 2 | 3 | package remarkable 4 | 5 | import ( 6 | "io" 7 | "os" 8 | ) 9 | 10 | // GetFileAndPointer finds the filedescriptor of the xochitl process and the pointer address of the virtual framebuffer 11 | func GetFileAndPointer() (io.ReaderAt, int64, error) { 12 | return &dummyPicture{}, 0, nil 13 | 14 | } 15 | 16 | type dummyPicture struct{} 17 | 18 | func (dummypicture *dummyPicture) ReadAt(p []byte, off int64) (n int, err error) { 19 | f, err := os.Open("./testdata/full_memory_region.raw") 20 | if err != nil { 21 | return 0, err 22 | } 23 | defer f.Close() 24 | return f.ReadAt(p, off) 25 | } 26 | -------------------------------------------------------------------------------- /internal/remarkable/fb_rm.go: -------------------------------------------------------------------------------- 1 | //go:build linux && (arm || arm64) 2 | 3 | package remarkable 4 | 5 | import ( 6 | "io" 7 | "os" 8 | ) 9 | 10 | // GetFileAndPointer returns the memory file handle and pointer address for the reMarkable framebuffer 11 | func GetFileAndPointer() (io.ReaderAt, int64, error) { 12 | pid := findXochitlPID() 13 | file, err := os.OpenFile("/proc/"+pid+"/mem", os.O_RDONLY, os.ModeDevice) 14 | if err != nil { 15 | return file, 0, err 16 | } 17 | pointerAddr, err := getFramePointer(pid) 18 | if err != nil { 19 | return file, 0, err 20 | } 21 | return file, pointerAddr, nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/remarkable/findpid.go: -------------------------------------------------------------------------------- 1 | package remarkable 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func findXochitlPID() string { 10 | base := "/proc" 11 | entries, err := os.ReadDir(base) 12 | if err != nil { 13 | log.Fatal(err) 14 | } 15 | 16 | for _, entry := range entries { 17 | pid := entry.Name() 18 | if !entry.IsDir() { 19 | continue 20 | } 21 | entries, err := os.ReadDir(filepath.Join(base, entry.Name())) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | for _, entry := range entries { 26 | info, err := entry.Info() 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | if info.Mode()&os.ModeSymlink != 0 { 31 | orig, err := os.Readlink(filepath.Join(base, pid, entry.Name())) 32 | if err != nil { 33 | continue 34 | } 35 | if orig == "/usr/bin/xochitl" { 36 | return pid 37 | } 38 | } 39 | } 40 | } 41 | return "" 42 | } 43 | -------------------------------------------------------------------------------- /internal/remarkable/pointer.go: -------------------------------------------------------------------------------- 1 | //go:build linux && !arm64 2 | 3 | package remarkable 4 | 5 | import ( 6 | "bufio" 7 | "log" 8 | "os" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | func getFramePointer(pid string) (int64, error) { 14 | file, err := os.OpenFile("/proc/"+pid+"/maps", os.O_RDONLY, os.ModeDevice) 15 | if err != nil { 16 | log.Fatal("cannot open file: ", err) 17 | } 18 | defer file.Close() 19 | scanner := bufio.NewScanner(file) 20 | scanner.Split(bufio.ScanWords) 21 | scanAddr := false 22 | var addr int64 23 | for scanner.Scan() { 24 | if scanAddr { 25 | hex := strings.Split(scanner.Text(), "-")[0] 26 | addr, err = strconv.ParseInt("0x"+hex, 0, 64) 27 | break 28 | } 29 | if scanner.Text() == `/dev/fb0` { 30 | scanAddr = true 31 | } 32 | } 33 | return addr + 8, err 34 | } 35 | -------------------------------------------------------------------------------- /internal/remarkable/pointer_arm64.go: -------------------------------------------------------------------------------- 1 | //go:build linux && arm64 2 | 3 | package remarkable 4 | 5 | import ( 6 | "bufio" 7 | "fmt" 8 | "os" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | func getFramePointer(pid string) (int64, error) { 14 | // Find the memory range for the framebuffer 15 | startAddress, err := getMemoryRange(pid) 16 | if err != nil { 17 | return 0, fmt.Errorf("failed to get memory range: %w", err) 18 | } 19 | 20 | // Calculate the correct starting address 21 | framePointer, err := calculateFramePointer(pid, startAddress) 22 | if err != nil { 23 | return 0, fmt.Errorf("failed to calculate frame pointer: %w", err) 24 | } 25 | 26 | return framePointer, nil 27 | } 28 | 29 | // getMemoryRange retrieves the end address of the last /dev/dri/card0 entry from /proc/[pid]/maps 30 | func getMemoryRange(pid string) (int64, error) { 31 | mapsFilePath := fmt.Sprintf("/proc/%s/maps", pid) 32 | file, err := os.Open(mapsFilePath) 33 | if err != nil { 34 | return 0, fmt.Errorf("cannot open maps file: %w", err) 35 | } 36 | defer file.Close() 37 | 38 | var memoryRange string 39 | scanner := bufio.NewScanner(file) 40 | 41 | // Find the last occurrence of /dev/dri/card0 42 | // We need the memory mapping for the display, which is located immediately 43 | // after the last /dev/dri/card0 mapping. Hence, we keep iterating through 44 | // the file and update memoryRange each time we encounter /dev/dri/card0. 45 | for scanner.Scan() { 46 | line := scanner.Text() 47 | if strings.Contains(line, "/dev/dri/card0") { 48 | memoryRange = line 49 | } 50 | } 51 | 52 | if err := scanner.Err(); err != nil { 53 | return 0, fmt.Errorf("error reading maps file: %w", err) 54 | } 55 | 56 | if memoryRange == "" { 57 | return 0, fmt.Errorf("no mapping found for /dev/dri/card0") 58 | } 59 | 60 | // Extract the end address of the last /dev/dri/card0 memory range 61 | // The range is in the format: "start-end permissions offset dev inode pathname" 62 | fields := strings.Fields(memoryRange) 63 | rangeField := fields[0] 64 | startEnd := strings.Split(rangeField, "-") 65 | if len(startEnd) != 2 { 66 | return 0, fmt.Errorf("invalid memory range format") 67 | } 68 | 69 | // We are interested in the end address because the next memory region, 70 | // starting from this end address, contains the frame buffer we need. 71 | end, err := strconv.ParseInt(startEnd[1], 16, 64) 72 | if err != nil { 73 | return 0, fmt.Errorf("failed to parse end address: %w", err) 74 | } 75 | 76 | return end, nil 77 | } 78 | 79 | // calculateFramePointer finds the frame pointer using the end address and memory file 80 | func calculateFramePointer(pid string, startAddress int64) (int64, error) { 81 | memFilePath := fmt.Sprintf("/proc/%s/mem", pid) 82 | file, err := os.Open(memFilePath) 83 | if err != nil { 84 | return 0, fmt.Errorf("cannot open memory file: %w", err) 85 | } 86 | defer file.Close() 87 | 88 | var offset int64 89 | length := 2 90 | 91 | // Iterate to calculate the correct offset within the frame buffer memory 92 | // The memory header contains a length field (4 bytes) which we use to determine 93 | // how much memory to skip. We dynamically calculate the offset until the 94 | // buffer size (width x height x 4 bytes per pixel) is reached. 95 | for length < ScreenSizeBytes { 96 | offset += int64(length - 2) 97 | 98 | // Seek to the start address plus offset and read the header 99 | // The header helps identify the size of the subsequent memory block. 100 | file.Seek(startAddress+offset+8, 0) 101 | header := make([]byte, 8) 102 | _, err := file.Read(header) 103 | if err != nil { 104 | return 0, fmt.Errorf("error reading memory header: %w", err) 105 | } 106 | 107 | // Extract the length from the header (4 bytes at the beginning of the header) 108 | length = int(int64(header[0]) | int64(header[1])<<8 | int64(header[2])<<16 | int64(header[3])<<24) 109 | } 110 | 111 | // Return the calculated frame pointer address 112 | return startAddress + offset, nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/rle/rle.go: -------------------------------------------------------------------------------- 1 | package rle 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "sync" 7 | 8 | "github.com/owulveryck/goMarkableStream/internal/remarkable" 9 | ) 10 | 11 | var encodedPool = sync.Pool{ 12 | New: func() interface{} { 13 | return new(bytes.Buffer) 14 | }, 15 | } 16 | 17 | var bufferPool = sync.Pool{ 18 | New: func() any { 19 | return make([]byte, 0, remarkable.ScreenSizeBytes) 20 | }, 21 | } 22 | 23 | // NewRLE creates a default RLE 24 | func NewRLE(w io.Writer) *RLE { 25 | return &RLE{ 26 | sub: w, 27 | } 28 | } 29 | 30 | // RLE implements an io.Writer that implements the Run Length Encoder 31 | type RLE struct { 32 | sub io.Writer 33 | } 34 | 35 | // Write encodes the data using run-length encoding (RLE) and writes the results to the subwriter. 36 | // 37 | // The data parameter is expected to be in the format []uint4, but is passed as []byte. 38 | // The result is packed before being written to the subwriter. The packing scheme 39 | // combines the count and value into a single uint8, with the count ranging from 0 to 15. 40 | // 41 | // Implements: io.Writer 42 | func (rlewriter *RLE) Write(data []byte) (int, error) { 43 | length := len(data) 44 | if length == 0 { 45 | return 0, nil 46 | } 47 | buf := bufferPool.Get().([]uint8) 48 | defer bufferPool.Put(buf) 49 | 50 | current := data[0] 51 | count := uint8(0) 52 | 53 | for i := 0; i < remarkable.ScreenSizeBytes; i += 2 { 54 | datum := data[i] 55 | if count < 254 && datum == current { 56 | count++ 57 | } else { 58 | buf = append(buf, count) 59 | buf = append(buf, current) 60 | current = datum 61 | count = 1 62 | } 63 | } 64 | /* 65 | for i := 0; i < remarkable.ScreenWidth*remarkable.ScreenHeight; i++ { 66 | datum := data[i*2] 67 | if count < 254 && datum == current { 68 | count++ 69 | } else { 70 | buf = append(buf, count) 71 | buf = append(buf, current) 72 | current = datum 73 | count = 1 74 | } 75 | } 76 | */ 77 | buf = append(buf, count) 78 | buf = append(buf, current) 79 | 80 | return rlewriter.sub.Write(buf) 81 | } 82 | -------------------------------------------------------------------------------- /internal/stream/benchfetchandsend_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/owulveryck/goMarkableStream/internal/rle" 7 | ) 8 | 9 | func BenchmarkFetchAndSend(b *testing.B) { 10 | // Setup: Create a large enough mockReaderAt to test performance 11 | width, height := 2872, 2404 // Example size; adjust based on your needs 12 | mockReader := NewMockReaderAt(width, height) // Using the mock from the previous example 13 | 14 | handler := StreamHandler{ 15 | file: mockReader, 16 | pointerAddr: 0, 17 | } 18 | 19 | mockWriter := NewMockResponseWriter() 20 | 21 | rleWriter := rle.NewRLE(mockWriter) 22 | 23 | data := make([]byte, width*height) // Adjust based on your payload size 24 | 25 | b.ResetTimer() // Start timing here 26 | for i := 0; i < b.N; i++ { 27 | handler.fetchAndSend(rleWriter, data) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/stream/handler.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net/http" 7 | "strconv" 8 | "sync" 9 | "time" 10 | 11 | "github.com/owulveryck/goMarkableStream/internal/events" 12 | "github.com/owulveryck/goMarkableStream/internal/pubsub" 13 | "github.com/owulveryck/goMarkableStream/internal/remarkable" 14 | "github.com/owulveryck/goMarkableStream/internal/rle" 15 | ) 16 | 17 | var ( 18 | rate time.Duration = 200 19 | ) 20 | 21 | var rawFrameBuffer = sync.Pool{ 22 | New: func() any { 23 | return make([]uint8, remarkable.ScreenSizeBytes) // Adjust the initial capacity as needed 24 | }, 25 | } 26 | 27 | // NewStreamHandler creates a new stream handler reading from file @pointerAddr 28 | func NewStreamHandler(file io.ReaderAt, pointerAddr int64, inputEvents *pubsub.PubSub, useRLE bool) *StreamHandler { 29 | return &StreamHandler{ 30 | file: file, 31 | pointerAddr: pointerAddr, 32 | inputEventsBus: inputEvents, 33 | useRLE: useRLE, 34 | } 35 | } 36 | 37 | // StreamHandler is an http.Handler that serves the stream of data to the client 38 | type StreamHandler struct { 39 | file io.ReaderAt 40 | pointerAddr int64 41 | inputEventsBus *pubsub.PubSub 42 | useRLE bool 43 | } 44 | 45 | // ServeHTTP implements http.Handler 46 | func (h *StreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 47 | // Parse query parameters 48 | query := r.URL.Query() 49 | rateStr := query.Get("rate") 50 | // If 'rate' parameter exists and is valid, use its value 51 | if rateStr != "" { 52 | var err error 53 | rateInt, err := strconv.Atoi(rateStr) 54 | if err != nil { 55 | // Handle error or keep the default value 56 | // For example, you can send a response with an error message 57 | http.Error(w, "Invalid 'rate' parameter", http.StatusBadRequest) 58 | return 59 | } 60 | rate = time.Duration(rateInt) 61 | } 62 | if rate < 100 { 63 | http.Error(w, "rate value is too low", http.StatusBadRequest) 64 | return 65 | } 66 | 67 | // Set CORS headers for the preflight request 68 | if r.Method == http.MethodOptions { 69 | w.Header().Set("Access-Control-Allow-Origin", "*") 70 | w.Header().Set("Access-Control-Allow-Methods", "GET") 71 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 72 | // Send response to preflight request 73 | w.WriteHeader(http.StatusOK) 74 | return 75 | } 76 | 77 | eventC := h.inputEventsBus.Subscribe("stream") 78 | defer h.inputEventsBus.Unsubscribe(eventC) 79 | ticker := time.NewTicker(rate * time.Millisecond) 80 | ticker.Reset(rate * time.Millisecond) 81 | defer ticker.Stop() 82 | 83 | rawData := rawFrameBuffer.Get().([]uint8) 84 | defer rawFrameBuffer.Put(rawData) // Return the slice to the pool when done 85 | // the informations are int4, therefore store it in a uint8array to reduce data transfer 86 | rleWriter := rle.NewRLE(w) 87 | writing := true 88 | stopWriting := time.NewTicker(2 * time.Second) 89 | defer stopWriting.Stop() 90 | 91 | w.Header().Set("Content-Type", "application/octet-stream") 92 | w.Header().Set("Connection", "close") 93 | w.Header().Set("Cache-Control", "no-cache") 94 | w.Header().Set("Transfer-Encoding", "chunked") 95 | 96 | for { 97 | select { 98 | case <-r.Context().Done(): 99 | return 100 | case event := <-eventC: 101 | if event.Code == 24 || event.Source == events.Touch { 102 | writing = true 103 | stopWriting.Reset(2000 * time.Millisecond) 104 | } 105 | case <-stopWriting.C: 106 | writing = false 107 | case <-ticker.C: 108 | if writing { 109 | if h.useRLE { 110 | h.fetchAndSend(rleWriter, rawData) 111 | } else { 112 | h.fetchAndSend(w, rawData) 113 | } 114 | } 115 | } 116 | } 117 | } 118 | 119 | func (h *StreamHandler) fetchAndSend(w io.Writer, rawData []uint8) { 120 | _, err := h.file.ReadAt(rawData, h.pointerAddr) 121 | if err != nil { 122 | log.Println(err) 123 | return 124 | } 125 | _, err = w.Write(rawData) 126 | if err != nil { 127 | log.Println("Error in writing", err) 128 | return 129 | } 130 | if w, ok := w.(http.Flusher); ok { 131 | w.Flush() 132 | } 133 | } 134 | 135 | func sum(d []uint8) int { 136 | val := 0 // Assuming `int` is large enough to avoid overflow 137 | // Manual loop unrolling could be done here, but it's typically not recommended 138 | // for readability and maintenance reasons unless profiling identifies this loop 139 | // as a significant bottleneck. 140 | for _, v := range d { 141 | val += int(v) 142 | } 143 | return val 144 | } 145 | -------------------------------------------------------------------------------- /internal/stream/handler_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "math/rand" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | "time" 12 | 13 | "github.com/owulveryck/goMarkableStream/internal/pubsub" 14 | ) 15 | 16 | // Assuming StreamHandler is defined somewhere in your package. 17 | // 18 | // type StreamHandler struct { 19 | // ... 20 | // } 21 | func getFileAndPointer() (io.ReaderAt, int64, error) { 22 | return &dummyPicture{}, 0, nil 23 | 24 | } 25 | 26 | type dummyPicture struct{} 27 | 28 | func (dummypicture *dummyPicture) ReadAt(p []byte, off int64) (n int, err error) { 29 | f, err := os.Open("../../testdata/full_memory_region.raw") 30 | if err != nil { 31 | return 0, err 32 | } 33 | defer f.Close() 34 | return f.ReadAt(p, off) 35 | } 36 | 37 | func TestStreamHandlerRaceCondition(t *testing.T) { 38 | // Initialize your StreamHandler here 39 | file, pointerAddr, err := getFileAndPointer() 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | eventPublisher := pubsub.NewPubSub() 44 | handler := NewStreamHandler(file, pointerAddr, eventPublisher, true) 45 | 46 | server := httptest.NewServer(handler) 47 | defer server.Close() 48 | 49 | // Simulate concurrent requests 50 | concurrentRequests := 100 51 | doneChan := make(chan struct{}, concurrentRequests) 52 | // Create a HTTP client with a timeout 53 | client := &http.Client{ 54 | Timeout: 10 * time.Millisecond, 55 | } 56 | 57 | for i := 0; i < concurrentRequests; i++ { 58 | go func() { 59 | // Introduce a random delay up to 1 second before starting the request 60 | delay := time.Duration(rand.Intn(50)) * time.Millisecond 61 | time.Sleep(delay) 62 | // Perform an HTTP request to the test server 63 | resp, err := client.Get(server.URL) 64 | if err == nil { 65 | defer resp.Body.Close() 66 | // Optionally read the response body 67 | io.ReadAll(resp.Body) 68 | } 69 | 70 | doneChan <- struct{}{} 71 | }() 72 | } 73 | 74 | // Wait for all goroutines to finish 75 | for i := 0; i < concurrentRequests; i++ { 76 | <-doneChan 77 | } 78 | } 79 | 80 | func TestStreamHandler_fetchAndSend(t *testing.T) { 81 | type fields struct { 82 | file io.ReaderAt 83 | pointerAddr int64 84 | inputEventsBus *pubsub.PubSub 85 | } 86 | type args struct { 87 | rawData []uint8 88 | } 89 | tests := []struct { 90 | name string 91 | fields fields 92 | args args 93 | wantW string 94 | }{ 95 | // TODO: Add test cases. 96 | } 97 | for _, tt := range tests { 98 | t.Run(tt.name, func(t *testing.T) { 99 | h := &StreamHandler{ 100 | file: tt.fields.file, 101 | pointerAddr: tt.fields.pointerAddr, 102 | inputEventsBus: tt.fields.inputEventsBus, 103 | } 104 | w := &bytes.Buffer{} 105 | h.fetchAndSend(w, tt.args.rawData) 106 | if gotW := w.String(); gotW != tt.wantW { 107 | t.Errorf("StreamHandler.fetchAndSend() = %v, want %v", gotW, tt.wantW) 108 | } 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/stream/mdw.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | ) 7 | 8 | var ( 9 | activeWriters int 10 | maxWriters = 1 // Maximum allowed writers 11 | mu sync.Mutex 12 | cond = sync.NewCond(&mu) 13 | ) 14 | 15 | // ThrottlingMiddleware to allow new connections only if there are no active writers or if max writers is exceeded. 16 | func ThrottlingMiddleware(next http.Handler) http.Handler { 17 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | mu.Lock() 19 | if activeWriters >= maxWriters { 20 | mu.Unlock() 21 | // If too many requests, send a 429 status code 22 | http.Error(w, "Too Many Requests", http.StatusTooManyRequests) 23 | return 24 | } 25 | 26 | for activeWriters > 0 { 27 | cond.Wait() // Wait for active writers to finish 28 | } 29 | activeWriters++ 30 | mu.Unlock() 31 | 32 | next.ServeHTTP(w, r) // Serve the request 33 | 34 | mu.Lock() 35 | activeWriters-- 36 | cond.Broadcast() // Notify waiting goroutines 37 | mu.Unlock() 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /internal/stream/mockreaderat_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "math/rand" 7 | "net/http" 8 | ) 9 | 10 | // MockReaderAt implements the io.ReaderAt interface. 11 | type MockReaderAt struct { 12 | width int 13 | height int 14 | data []byte 15 | } 16 | 17 | // NewMockReaderAt creates a new MockReaderAt with the specified dimensions and initializes its data. 18 | func NewMockReaderAt(width, height int) *MockReaderAt { 19 | size := width * height 20 | data := make([]byte, size) 21 | 22 | // Fill the slice with values where 70% are 0s and the rest are between 1 and 17. 23 | for i := 0; i < size; i++ { 24 | if rand.Float64() > 0.7 { 25 | data[i] = byte(rand.Intn(17) + 1) 26 | } 27 | } 28 | 29 | return &MockReaderAt{ 30 | width: width, 31 | height: height, 32 | data: data, 33 | } 34 | } 35 | 36 | // ReadAt reads len(p) bytes into p starting at offset off in the mock data. 37 | // It implements the io.ReaderAt interface. 38 | func (m *MockReaderAt) ReadAt(p []byte, off int64) (n int, err error) { 39 | if off >= int64(len(m.data)) { 40 | return 0, io.EOF 41 | } 42 | 43 | n = copy(p, m.data[off:]) 44 | if n < len(p) { 45 | err = io.EOF 46 | } 47 | 48 | return n, err 49 | } 50 | 51 | // mockResponseWriter simulates an http.ResponseWriter for benchmarking purposes. 52 | type mockResponseWriter struct { 53 | headerMap http.Header 54 | body *bytes.Buffer 55 | } 56 | 57 | func NewMockResponseWriter() *mockResponseWriter { 58 | return &mockResponseWriter{ 59 | headerMap: make(http.Header), 60 | body: new(bytes.Buffer), 61 | } 62 | } 63 | 64 | func (m *mockResponseWriter) Header() http.Header { 65 | return m.headerMap 66 | } 67 | 68 | func (m *mockResponseWriter) Write(data []byte) (int, error) { 69 | return m.body.Write(data) 70 | } 71 | 72 | func (m *mockResponseWriter) WriteHeader(statusCode int) { 73 | // For benchmarking, we might not need to simulate the status code. 74 | } 75 | 76 | func (m *mockResponseWriter) Flush() { 77 | // Simulate flushing the buffer if implementing http.Flusher. 78 | } 79 | -------------------------------------------------------------------------------- /internal/stream/oneoftwo.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | 7 | "github.com/owulveryck/goMarkableStream/internal/remarkable" 8 | ) 9 | 10 | var imagePool = sync.Pool{ 11 | New: func() any { 12 | return make([]uint8, remarkable.ScreenWidth*remarkable.ScreenHeight) // Adjust the initial capacity as needed 13 | }, 14 | } 15 | 16 | type oneOutOfTwo struct { 17 | w io.Writer 18 | } 19 | 20 | func (oneoutoftwo *oneOutOfTwo) Write(src []byte) (n int, err error) { 21 | imageData := imagePool.Get().([]uint8) 22 | defer imagePool.Put(imageData) // Return the slice to the pool when done 23 | for i := range imageData { 24 | imageData[i] = src[i*2] // Directly take the lower byte of each uint16 from src 25 | } 26 | 27 | /* 28 | for i := 0; i < remarkable.ScreenHeight*remarkable.ScreenWidth; i++ { 29 | imageData[i] = uint8(binary.LittleEndian.Uint16(src[i*2 : i*2+2])) 30 | } 31 | */ 32 | return oneoutoftwo.w.Write(imageData) 33 | } 34 | -------------------------------------------------------------------------------- /internal/stream/raw.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "io" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | // NewRawHandler creates a new stream handler reading from file @pointerAddr 10 | func NewRawHandler(file io.ReaderAt, pointerAddr int64) *RawHandler { 11 | return &RawHandler{ 12 | waitingQueue: make(chan struct{}, 1), 13 | file: file, 14 | pointerAddr: pointerAddr, 15 | } 16 | } 17 | 18 | // RawHandler is an http.Handler that serves the stream of data to the client 19 | type RawHandler struct { 20 | waitingQueue chan struct{} 21 | file io.ReaderAt 22 | pointerAddr int64 23 | } 24 | 25 | // ServeHTTP implements http.Handler 26 | func (h *RawHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 27 | imageData := rawFrameBuffer.Get().([]uint8) 28 | defer rawFrameBuffer.Put(imageData) // Return the slice to the pool when done 29 | _, err := h.file.ReadAt(imageData, h.pointerAddr) 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | w.Write(imageData) 34 | } 35 | -------------------------------------------------------------------------------- /listener.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "golang.ngrok.com/ngrok" 8 | "golang.ngrok.com/ngrok/config" 9 | ) 10 | 11 | func setupListener(ctx context.Context, s *configuration) (net.Listener, error) { 12 | if s.BindAddr == "ngrok" { 13 | l, err := ngrok.Listen(ctx, 14 | config.HTTPEndpoint(), 15 | ngrok.WithAuthtokenFromEnv(), 16 | ) 17 | s.BindAddr = l.Addr().String() 18 | c.TLS = false 19 | return l, err 20 | } 21 | return net.Listen("tcp", s.BindAddr) 22 | } 23 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "runtime/debug" 13 | 14 | "github.com/kelseyhightower/envconfig" 15 | 16 | "github.com/owulveryck/goMarkableStream/internal/pubsub" 17 | "github.com/owulveryck/goMarkableStream/internal/remarkable" 18 | ) 19 | 20 | type configuration struct { 21 | BindAddr string `envconfig:"SERVER_BIND_ADDR" default:":2001" required:"true" description:"The server bind address"` 22 | Username string `envconfig:"SERVER_USERNAME" default:"admin"` 23 | Password string `envconfig:"SERVER_PASSWORD" default:"password"` 24 | TLS bool `envconfig:"HTTPS" default:"true"` 25 | Compression bool `envconfig:"COMPRESSION" default:"false"` 26 | RLECompression bool `envconfig:"RLE_COMPRESSION" default:"true"` 27 | DevMode bool `envconfig:"DEV_MODE" default:"false"` 28 | ZSTDCompression bool `envconfig:"ZSTD_COMPRESSION" default:"false" description:"Enable zstd compression"` 29 | ZSTDCompressionLevel int `envconfig:"ZSTD_COMPRESSION_LEVEL" default:"3" description:"Zstd compression level (1-22, where 1 is fastest and 22 is maximum compression)"` 30 | } 31 | 32 | const ( 33 | // ConfigPrefix for environment variable based configuration 34 | ConfigPrefix = "RK" 35 | ) 36 | 37 | var ( 38 | pointerAddr int64 39 | file io.ReaderAt 40 | // Define the username and password for authentication 41 | c configuration 42 | 43 | //go:embed client/* 44 | assetsFS embed.FS 45 | //go:embed assets/cert.pem assets/key.pem 46 | tlsAssets embed.FS 47 | ) 48 | 49 | func validateConfiguration(c *configuration) error { 50 | if remarkable.Model == remarkable.RemarkablePaperPro { 51 | if c.RLECompression { 52 | return errors.New("RLE compression is not supported on the Remarkable Paper Pro. Disable it by setting RLE_COMPRESSION=false") 53 | } 54 | } 55 | 56 | return nil 57 | } 58 | 59 | func main() { 60 | bi, ok := debug.ReadBuildInfo() 61 | if !ok { 62 | fmt.Println("not ok") 63 | return 64 | } 65 | fmt.Printf("Version: %s\n", bi.Main.Version) 66 | var err error 67 | 68 | ifaces() 69 | help := flag.Bool("h", false, "print usage") 70 | unsafe := flag.Bool("unsafe", false, "disable authentication") 71 | flag.Parse() 72 | if *help { 73 | envconfig.Usage(ConfigPrefix, &c) 74 | return 75 | } 76 | if err := envconfig.Process(ConfigPrefix, &c); err != nil { 77 | envconfig.Usage(ConfigPrefix, &c) 78 | log.Fatal(err) 79 | } 80 | 81 | if err := validateConfiguration(&c); err != nil { 82 | panic(err) 83 | } 84 | 85 | file, pointerAddr, err = remarkable.GetFileAndPointer() 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | eventPublisher := pubsub.NewPubSub() 90 | eventScanner := remarkable.NewEventScanner() 91 | eventScanner.StartAndPublish(context.Background(), eventPublisher) 92 | 93 | mux := setMuxer(eventPublisher) 94 | 95 | // handler := BasicAuthMiddleware(gzMiddleware(mux)) 96 | var handler http.Handler 97 | handler = BasicAuthMiddleware(mux) 98 | if *unsafe { 99 | handler = mux 100 | } 101 | l, err := setupListener(context.Background(), &c) 102 | if err != nil { 103 | log.Fatal(err) 104 | } 105 | log.Printf("listening on %v", l.Addr()) 106 | if c.TLS { 107 | log.Fatal(runTLS(l, handler)) 108 | } 109 | log.Fatal(http.Serve(l, handler)) 110 | } 111 | -------------------------------------------------------------------------------- /test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "fmt" 7 | "log" 8 | ) 9 | 10 | type inputEvent struct { 11 | Type uint16 12 | Code uint16 13 | Value int32 14 | } 15 | 16 | func main() { 17 | event := inputEvent{Type: 1, Code: 2, Value: 3} 18 | 19 | buf := new(bytes.Buffer) 20 | err := binary.Write(buf, binary.LittleEndian, event) 21 | if err != nil { 22 | log.Fatalf("binary.Write failed: %v", err) 23 | } 24 | fmt.Printf("% x", buf.Bytes()) 25 | 26 | // Now buf.Bytes() contains the binary representation of the structure 27 | // You can send this data to a JavaScript environment 28 | } 29 | -------------------------------------------------------------------------------- /testdata/.gitattributes: -------------------------------------------------------------------------------- 1 | *.raw filter=lfs diff=lfs merge=lfs -text 2 | colorful.raw filter=lfs diff=lfs merge=lfs -text 3 | -------------------------------------------------------------------------------- /testdata/colorful.raw: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:63a2acf628b613b3832e2f0d5346db8adf1858ccb12631b1e149c4468234efb1 3 | size 22638592 4 | -------------------------------------------------------------------------------- /testdata/full_memory_region.raw: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4e313553d70aca2894670bd1aab959381520dfb10898a122e1f0c98901df2da3 3 | size 10518528 4 | -------------------------------------------------------------------------------- /tests/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/tls" 6 | "encoding/base64" 7 | "encoding/hex" 8 | "fmt" 9 | "image" 10 | "image/png" 11 | "io/ioutil" 12 | "log" 13 | "net/http" 14 | "os" 15 | 16 | "github.com/owulveryck/goMarkableStream/internal/remarkable" 17 | ) 18 | 19 | func decodeUint8(data []byte) []byte { 20 | decoded := make([]byte, 0, len(data)*15) 21 | for i := 0; i < len(data); i += 2 { 22 | count := data[i] 23 | item := data[i+1] 24 | for i := uint8(0); i < count+1; i++ { 25 | decoded = append(decoded, uint8(item)) 26 | } 27 | } 28 | return decoded 29 | } 30 | 31 | func decodePacked(data []byte) []byte { 32 | decoded := make([]uint8, 0, len(data)*255) 33 | total := 0 34 | for i := 0; i < len(data); i += 2 { 35 | count := data[i] 36 | item := data[i+1] 37 | total = total + int(count) + 1 38 | for i := 0; i < int(count)+1; i++ { 39 | decoded = append(decoded, uint8(item)) 40 | } 41 | } 42 | log.Println("Total count is:", total) 43 | log.Println("Decoded size is:", len(decoded)) 44 | return decoded 45 | } 46 | 47 | func main() { 48 | // Basic Authentication credentials 49 | username := "admin" 50 | password := "password" 51 | 52 | // Target host and port 53 | //host := "192.168.1.44" 54 | host := "192.168.1.47" 55 | port := "2001" // HTTPS default port 56 | 57 | // Construct Basic Auth header 58 | auth := fmt.Sprintf("%s:%s", username, password) 59 | authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) 60 | 61 | // URL to the /stream endpoint 62 | url := fmt.Sprintf("https://%s:%s/stream", host, port) 63 | 64 | // Disable SSL certificate verification (not recommended for production) 65 | tr := &http.Transport{ 66 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 67 | } 68 | 69 | client := &http.Client{Transport: tr} 70 | 71 | // Make a GET request 72 | req, err := http.NewRequest("GET", url, nil) 73 | if err != nil { 74 | fmt.Println("Error creating request:", err) 75 | return 76 | } 77 | 78 | // Set Basic Auth header 79 | req.Header.Add("Authorization", authHeader) 80 | 81 | // Send the request 82 | resp, err := client.Do(req) 83 | if err != nil { 84 | fmt.Println("Error sending request:", err) 85 | return 86 | } 87 | defer resp.Body.Close() 88 | 89 | // Read the response body into a byte array 90 | body, err := ioutil.ReadAll(resp.Body) 91 | if err != nil { 92 | fmt.Println("Error reading response:", err) 93 | return 94 | } 95 | 96 | log.Printf("Received %v bytes", len(body)) 97 | 98 | // Create an MD5 hash object 99 | hash := md5.New() 100 | 101 | // Write the byte array to the hash object 102 | hash.Write(body) 103 | 104 | // Get the MD5 hash as a byte slice 105 | hashBytes := hash.Sum(nil) 106 | 107 | // Convert the MD5 hash byte slice to a hexadecimal string 108 | md5String := hex.EncodeToString(hashBytes) 109 | 110 | log.Println(md5String) 111 | 112 | data := decodePacked(body) 113 | 114 | boundaries := image.Rectangle{ 115 | Min: image.Point{ 116 | X: 0, 117 | Y: 0, 118 | }, 119 | Max: image.Point{ 120 | X: remarkable.ScreenWidth, 121 | Y: remarkable.ScreenHeight, 122 | }, 123 | } 124 | img := image.NewGray(boundaries) 125 | for i := 0; i < len(img.Pix); i++ { 126 | img.Pix[i] = data[i] * 17 127 | } 128 | log.Println(len(data)) 129 | png.Encode(os.Stdout, img) 130 | 131 | } 132 | -------------------------------------------------------------------------------- /tools/qrcodepdf/GetIPAddresses.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/tools/qrcodepdf/GetIPAddresses.pdf -------------------------------------------------------------------------------- /tools/qrcodepdf/README.md: -------------------------------------------------------------------------------- 1 | # reMarkable IP Streamer QR Code Generator 2 | 3 | This tool is designed to fetch the IP addresses of your reMarkable device and generate a QR code containing these IP addresses in a PDF. The generated PDF can be displayed on the reMarkable and scanned with a mobile device to reveal the IP addresses. Please note that this feature is experimental. 4 | 5 | ## Installation 6 | 7 | To set up the tool, follow these steps: 8 | 9 | 1. **Upload the PDF File**: First, you need to upload the file `goMarkableStreamQRCode.pdf` to your reMarkable device. You can do this using the official reMarkable tool. 10 | 11 | 2. **Compile the Tool**: 12 | - On your computer, open a terminal and navigate to the directory containing the source code. 13 | - Run the following command to compile the tool: 14 | ```bash 15 | GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -o genIP.arm 16 | ``` 17 | 18 | 3. **Copy the Binary to reMarkable**: 19 | - Use `scp` or any other SSH tool to copy the `genIP.arm` binary to your reMarkable device. 20 | - Example: 21 | ```bash 22 | scp genIP.arm root@: 23 | ``` 24 | 25 | 4. **Launch the Tool**: 26 | - SSH into your reMarkable device: 27 | ```bash 28 | ssh root@ 29 | ``` 30 | - Run the tool: 31 | ```bash 32 | ./genIP.arm & 33 | ``` 34 | Adding `&` at the end of the command will make it run in the background, allowing it to continue running even after you disconnect. 35 | 36 | ## How it Works 37 | 38 | The utility runs in an endless loop, checking every minute to see if the network has changed. If a change in the network is detected, it updates the content of the `goMarkableStreamQRCode.pdf` file with the new IP addresses. 39 | 40 | ## Notes 41 | 42 | - This tool is experimental; use it at your own risk and feel free to contribute or report issues. 43 | 44 | -------------------------------------------------------------------------------- /tools/qrcodepdf/files.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | ) 9 | 10 | func searchContentFiles(dirPath string) []string { 11 | matchedFiles := make([]string, 0) 12 | files, err := ioutil.ReadDir(dirPath) 13 | if err != nil { 14 | fmt.Println("Error reading directory:", err) 15 | return matchedFiles 16 | } 17 | 18 | for _, file := range files { 19 | if file.IsDir() { 20 | continue // Skip directories 21 | } 22 | 23 | if filepath.Ext(file.Name()) == ".metadata" { 24 | fullPath := filepath.Join(dirPath, file.Name()) 25 | if checkFileForJSONContent(fullPath) { 26 | matchedFiles = append(matchedFiles, fullPath) 27 | } 28 | } 29 | } 30 | return matchedFiles 31 | } 32 | 33 | func checkFileForJSONContent(filePath string) bool { 34 | content, err := ioutil.ReadFile(filePath) 35 | if err != nil { 36 | fmt.Println("Error reading file:", err) 37 | return false 38 | } 39 | 40 | var jsonData map[string]interface{} 41 | if err := json.Unmarshal(content, &jsonData); err != nil { 42 | fmt.Println("Error decoding JSON in file:", filePath, err) 43 | return false 44 | } 45 | 46 | if visibleName, ok := jsonData["visibleName"]; ok { 47 | if visibleNameStr, ok := visibleName.(string); ok && visibleNameStr == qrPDFName { 48 | return true 49 | } 50 | } 51 | return false 52 | } 53 | -------------------------------------------------------------------------------- /tools/qrcodepdf/genIP.arm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/tools/qrcodepdf/genIP.arm -------------------------------------------------------------------------------- /tools/qrcodepdf/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/owulveryck/goMarkableStream/qrcodepdf 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/jung-kurt/gofpdf v1.16.2 7 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 8 | ) 9 | -------------------------------------------------------------------------------- /tools/qrcodepdf/go.sum: -------------------------------------------------------------------------------- 1 | github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= 4 | github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= 5 | github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= 6 | github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= 7 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= 10 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= 11 | github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 12 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 13 | golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 14 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 15 | -------------------------------------------------------------------------------- /tools/qrcodepdf/goMarkableStreamQRCode.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/tools/qrcodepdf/goMarkableStreamQRCode.pdf -------------------------------------------------------------------------------- /tools/qrcodepdf/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net" 8 | "os" 9 | "path/filepath" 10 | "time" 11 | 12 | "github.com/jung-kurt/gofpdf" 13 | "github.com/skip2/go-qrcode" 14 | ) 15 | 16 | var ( 17 | outputFile string 18 | qrPDFName string 19 | ) 20 | 21 | func init() { 22 | flag.StringVar(&qrPDFName, "output", "goMarkableStreamQRCode.pdf", "The output PDF file") 23 | } 24 | 25 | func main() { 26 | flag.Parse() 27 | matchedFiles := searchContentFiles(".local/share/remarkable/xochitl/") 28 | if len(matchedFiles) != 1 { 29 | log.Fatal("did not find the " + qrPDFName) 30 | } 31 | outputFile = matchedFiles[0] 32 | outputFile = outputFile[:len(outputFile)-len(filepath.Ext(outputFile))] + ".pdf" 33 | log.Println("using ", outputFile) 34 | 35 | // Fetch initial IP addresses 36 | ips, err := getIPAddresses() 37 | if err != nil { 38 | log.Println("Error fetching IP addresses:", err) 39 | return 40 | } 41 | 42 | // Generate initial PDF 43 | err = generatePDF(ips, outputFile) 44 | if err != nil { 45 | log.Println("Error generating PDF:", err) 46 | return 47 | } 48 | 49 | for { 50 | // Wait for a while before checking again 51 | time.Sleep(1 * time.Minute) 52 | 53 | // Fetch current IP addresses 54 | currentIPs, err := getIPAddresses() 55 | if err != nil { 56 | log.Println("Error fetching IP addresses:", err) 57 | continue 58 | } 59 | 60 | // Check if the IP addresses have changed 61 | if !isEqual(ips, currentIPs) { 62 | 63 | // IP addresses have changed, update the PDF 64 | err = generatePDF(currentIPs, outputFile) 65 | if err != nil { 66 | log.Println("Error generating PDF:", err) 67 | continue 68 | } 69 | 70 | // Update the known IP addresses 71 | ips = currentIPs 72 | } 73 | } 74 | } 75 | 76 | func generatePDF(ips []net.IP, outputFile string) error { 77 | // Initialize PDF 78 | pdf := gofpdf.New("P", "mm", "A4", "") 79 | pdf.SetTitle("goMarkableStream IP addresses", false) 80 | pdf.SetAuthor("The goMarkableStream authors", false) 81 | pdf.AddPage() 82 | pdf.SetFont("Arial", "", 12) 83 | 84 | // Define QR code and label positions 85 | x := 10.0 86 | y := 10.0 87 | width := 60.0 88 | height := 60.0 89 | labelHeight := 10.0 90 | 91 | for _, ip := range ips { 92 | // Format IP address as URL 93 | url := fmt.Sprintf("https://%s:2001/", ip) 94 | 95 | // Generate QR Code 96 | png, err := qrcode.Encode(url, qrcode.Medium, 256) 97 | if err != nil { 98 | return fmt.Errorf("error generating QR code for URL %s: %w", url, err) 99 | } 100 | 101 | // Save QR Code to file 102 | fileName := fmt.Sprintf("qrcode_%s.png", ip) 103 | err = os.WriteFile(fileName, png, 0644) 104 | if err != nil { 105 | return fmt.Errorf("error saving QR code file: %w", err) 106 | } 107 | 108 | // Add QR Code to PDF 109 | pdf.ImageOptions(fileName, x, y, width, height, false, gofpdf.ImageOptions{ImageType: "PNG", ReadDpi: true}, 0, "") 110 | 111 | // Add IP address label under QR Code 112 | pdf.SetXY(x, y+height) 113 | pdf.MultiCell(width, labelHeight, url, "0", "C", false) 114 | 115 | // Update x position for next QR code and label 116 | x += width + 10 117 | 118 | // Check if we need to create a new line 119 | if x+width > 210 { // 210mm is the width of an A4 page 120 | x = 10 121 | y += height + labelHeight + 10 122 | } 123 | } 124 | pdf.SetXY(x, y+height) 125 | pdf.MultiCell(width, labelHeight, time.Now().String(), "0", "C", false) 126 | 127 | // Save PDF to file 128 | err := pdf.OutputFileAndClose(outputFile) 129 | if err != nil { 130 | return fmt.Errorf("error saving PDF file: %w", err) 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func getIPAddresses() ([]net.IP, error) { 137 | ips := []net.IP{} 138 | 139 | interfaces, err := net.Interfaces() 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | for _, intf := range interfaces { 145 | addrs, err := intf.Addrs() 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | for _, addr := range addrs { 151 | var ip net.IP 152 | 153 | switch v := addr.(type) { 154 | case *net.IPNet: 155 | ip = v.IP 156 | case *net.IPAddr: 157 | ip = v.IP 158 | } 159 | 160 | if ip != nil && ip.IsGlobalUnicast() { 161 | ips = append(ips, ip) 162 | } 163 | } 164 | } 165 | 166 | return ips, nil 167 | } 168 | 169 | func isEqual(ips1, ips2 []net.IP) bool { 170 | if len(ips1) != len(ips2) { 171 | return false 172 | } 173 | 174 | for i := range ips1 { 175 | if !ips1[i].Equal(ips2[i]) { 176 | return false 177 | } 178 | } 179 | 180 | return true 181 | } 182 | -------------------------------------------------------------------------------- /tools/qrcodepdf/qrcode_10.11.99.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/tools/qrcodepdf/qrcode_10.11.99.3.png -------------------------------------------------------------------------------- /tools/qrcodepdf/qrcode_192.168.1.44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/tools/qrcodepdf/qrcode_192.168.1.44.png -------------------------------------------------------------------------------- /tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:1c71:fd5e:9a28:169e.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:1c71:fd5e:9a28:169e.png -------------------------------------------------------------------------------- /tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:d410:6831:4b86:91f.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owulveryck/goMarkableStream/ea209db6ed1637adfc127f201aa394948b8667e3/tools/qrcodepdf/qrcode_2a01:cb0c:604:4700:d410:6831:4b86:91f.png -------------------------------------------------------------------------------- /tools/raw_client/README.md: -------------------------------------------------------------------------------- 1 | # RAW client 2 | 3 | This tool connects to a server over HTTPS, fetches raw RGBA image data, and saves it as a PNG file. It supports basic authentication and allows configuration of server details, image dimensions, and output file. 4 | 5 | Run the server on your reMarkable: 6 | 7 | ``` 8 | RK_DEV_MODE=true ./goMarkableStream 9 | ``` 10 | 11 | Run the client: 12 | 13 | ``` 14 | go run tools/client/client.go --ip 10.0.0.1 # Replace with your device IP 15 | ``` 16 | -------------------------------------------------------------------------------- /tools/raw_client/client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "fmt" 7 | "image" 8 | "image/png" 9 | "io" 10 | "net/http" 11 | "os" 12 | ) 13 | 14 | func downloadRawImage(ip, port, username, password string) ([]byte, error) { 15 | url := fmt.Sprintf("https://%s:%s/raw", ip, port) 16 | client := &http.Client{ 17 | Transport: &http.Transport{ 18 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 19 | }, 20 | } 21 | 22 | // Create a request 23 | req, err := http.NewRequest("GET", url, nil) 24 | if err != nil { 25 | return nil, fmt.Errorf("error creating HTTP request: %w", err) 26 | } 27 | 28 | // Add Basic Auth 29 | req.SetBasicAuth(username, password) 30 | 31 | resp, err := client.Do(req) 32 | if err != nil { 33 | return nil, fmt.Errorf("error making HTTP request: %w", err) 34 | } 35 | defer resp.Body.Close() 36 | 37 | rawData, err := io.ReadAll(resp.Body) 38 | if err != nil { 39 | return nil, fmt.Errorf("error reading response body: %w", err) 40 | } 41 | 42 | return rawData, nil 43 | } 44 | 45 | func convertToImage(rawData []byte, width int, height int) *image.RGBA { 46 | img := image.NewRGBA(image.Rect(0, 0, width, height)) 47 | copy(img.Pix, rawData) 48 | return img 49 | } 50 | 51 | func saveImage(img *image.RGBA, output string) error { 52 | file, err := os.Create(output) 53 | if err != nil { 54 | return fmt.Errorf("error creating output file: %w", err) 55 | } 56 | defer file.Close() 57 | 58 | if err := png.Encode(file, img); err != nil { 59 | return fmt.Errorf("error encoding image to PNG: %w", err) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func main() { 66 | ip := flag.String("ip", "127.0.0.1", "Server IP address") 67 | port := flag.String("port", "2001", "Server port") 68 | width := flag.Int("width", 1624, "Image width") 69 | height := flag.Int("height", 2154, "Image height") 70 | output := flag.String("output", "screenshot.png", "Output image file") 71 | username := flag.String("username", "admin", "Basic auth username") 72 | password := flag.String("password", "password", "Basic auth password") 73 | flag.Parse() 74 | 75 | rawData, err := downloadRawImage(*ip, *port, *username, *password) 76 | if err != nil { 77 | fmt.Printf("Error: %v\n", err) 78 | return 79 | } 80 | 81 | img := convertToImage(rawData, *width, *height) 82 | 83 | if err := saveImage(img, *output); err != nil { 84 | fmt.Printf("Error: %v\n", err) 85 | return 86 | } 87 | 88 | fmt.Printf("Image saved to %s\n", *output) 89 | } 90 | -------------------------------------------------------------------------------- /tools/service/README.md: -------------------------------------------------------------------------------- 1 | This service allows auto-start and restart of the goMarkableStreaming service 2 | 3 | ## Installation 4 | 5 | ```shell 6 | cat > /etc/systemd/system/goMarkableStream.service << EOF 7 | [Unit] 8 | Description=goMarkableStream Service 9 | After=xochitl.service 10 | 11 | [Service] 12 | ExecStart=/home/root/goMarkableStream 13 | Restart=always 14 | RestartSec=5 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | EOF 19 | 20 | systemctl daemon-reload 21 | systemctl enable goMarkableStream.Service 22 | systemctl start goMarkableStream.service 23 | ``` 24 | 25 | The system will start automatically 26 | 27 | _note_ any updates on the reMarkable removes this service 28 | -------------------------------------------------------------------------------- /tools/service/goMarkableStream.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=goMarkableStream Service 3 | After=xochitl.service 4 | 5 | [Service] 6 | ExecStart=/home/root/goMarkableStream 7 | Restart=always 8 | RestartSec=5 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | -------------------------------------------------------------------------------- /tools/utils/genSignature/README.md: -------------------------------------------------------------------------------- 1 | # Find signature 2 | 3 | Simple tool to generate a signature in order to recognize the orientation. 4 | 5 | ## Left Handed 6 | 7 | ### Portrait 8 | 9 | ```shell 10 | > ( 11 | cat << \EOF 12 | dd if=/proc/$(pidof xochitl)/mem count=2628288 bs=1 skip=$((16#$(grep '/dev/fb0' /proc/$(pidof xochitl)/maps | sed 's/.*\-\([0-9a-f]*\) .*/\1/'))) 13 | EOF 14 | ) | ssh root@remarkable | go run . - Portrait 15 | Pseudo-terminal will not be allocated because stdin is not a terminal. 16 | 2628288+0 records in 17 | 2628288+0 records out 18 | ``` 19 | 20 | ```go 21 | package main 22 | 23 | import ( 24 | "crypto/md5" 25 | "fmt" 26 | ) 27 | 28 | func compareSig(src []byte, sig [16]byte) bool { 29 | if len(src) != 16 { 30 | return false 31 | } 32 | for i := 0; i < 16; i++ { 33 | if src[i] != sig[i] { 34 | return false 35 | } 36 | } 37 | return true 38 | } 39 | 40 | func isPortrait(content []byte) bool { 41 | sig := []byte{83, 234, 230, 173, 67, 108, 25, 219, 155, 106, 67, 4, 203, 188, 104, 255} 42 | return compareSig(sig, md5.Sum(content[2517769:2517807])) 43 | } 44 | ``` 45 | 46 | ### Landscape 47 | 48 | ```shell 49 | > ( 50 | cat << \EOF 51 | dd if=/proc/$(pidof xochitl)/mem count=2628288 bs=1 skip=$((16#$(grep '/dev/fb0' /proc/$(pidof xochitl)/maps | sed 's/.*\-\([0-9a-f]*\) .*/\1/'))) 52 | EOF 53 | ) | ssh root@remarkable | go run . - Landscape 54 | Pseudo-terminal will not be allocated because stdin is not a terminal. 55 | 2628288+0 records in 56 | 2628288+0 records out 57 | ``` 58 | 59 | ```go 60 | package main 61 | 62 | import ( 63 | "crypto/md5" 64 | "fmt" 65 | ) 66 | 67 | func compareSig(src []byte, sig [16]byte) bool { 68 | if len(src) != 16 { 69 | return false 70 | } 71 | for i := 0; i < 16; i++ { 72 | if src[i] != sig[i] { 73 | return false 74 | } 75 | } 76 | return true 77 | } 78 | 79 | func isLandscape(content []byte) bool { 80 | sig := []byte{27, 40, 215, 193, 32, 81, 169, 131, 14, 179, 31, 13, 229, 70, 130, 21} 81 | return compareSig(sig, md5.Sum(content[115992:116029])) 82 | } 83 | ``` 84 | 85 | ## Right handed 86 | 87 | ### Portrait 88 | 89 | ```shell 90 | > ( 91 | cat << \EOF 92 | dd if=/proc/$(pidof xochitl)/mem count=2628288 bs=1 skip=$((16#$(grep '/dev/fb0' /proc/$(pidof xochitl)/maps | sed 's/.*\-\([0-9a-f]*\) .*/\1/'))) 93 | EOF 94 | ) | ssh root@192.168.88.151 | go run . - PortraitRight 95 | Pseudo-terminal will not be allocated because stdin is not a terminal. 96 | 2628288+0 records in 97 | 2628288+0 records out 98 | ``` 99 | 100 | ```go 101 | package main 102 | 103 | import ( 104 | "crypto/md5" 105 | "fmt" 106 | ) 107 | 108 | func compareSig(src []byte, sig [16]byte) bool { 109 | if len(src) != 16 { 110 | return false 111 | } 112 | for i := 0; i < 16; i++ { 113 | if src[i] != sig[i] { 114 | return false 115 | } 116 | } 117 | return true 118 | } 119 | 120 | func isPortraitright(content []byte) bool { 121 | sig := []byte{5, 185, 165, 108, 82, 71, 18, 100, 38, 92, 191, 135, 173, 171, 224, 97} 122 | return compareSig(sig, md5.Sum(content[115993:116030])) 123 | } 124 | ``` 125 | 126 | ### Landscape 127 | 128 | ```shell 129 | ( 130 | cat << \EOF 131 | dd if=/proc/$(pidof xochitl)/mem count=2628288 bs=1 skip=$((16#$(grep '/dev/fb0' /proc/$(pidof xochitl)/maps | sed 's/.*\-\([0-9a-f]*\) .*/\1/'))) 132 | EOF 133 | ) | ssh root@192.168.88.151 | go run . - landscapeRight 134 | Pseudo-terminal will not be allocated because stdin is not a terminal. 135 | 2628288+0 records in 136 | 2628288+0 records out 137 | ``` 138 | 139 | ```go 140 | package main 141 | 142 | import ( 143 | "crypto/md5" 144 | "fmt" 145 | ) 146 | 147 | func compareSig(src []byte, sig [16]byte) bool { 148 | if len(src) != 16 { 149 | return false 150 | } 151 | for i := 0; i < 16; i++ { 152 | if src[i] != sig[i] { 153 | return false 154 | } 155 | } 156 | return true 157 | } 158 | 159 | func isLandscaperight(content []byte) bool { 160 | sig := []byte{218, 169, 170, 11, 85, 65, 69, 163, 162, 252, 246, 118, 194, 76, 176, 41} 161 | return compareSig(sig, md5.Sum(content[114241:114279])) 162 | } 163 | ``` 164 | -------------------------------------------------------------------------------- /tools/utils/genSignature/getmarker.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "fmt" 7 | "go/format" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "path" 13 | "path/filepath" 14 | "reflect" 15 | "sort" 16 | "strings" 17 | "text/template" 18 | ) 19 | 20 | func main() { 21 | var name string 22 | var file string 23 | switch len(os.Args) { 24 | case 3: 25 | name = os.Args[2] 26 | file = os.Args[1] 27 | case 2: 28 | file = os.Args[1] 29 | if file != "-" { 30 | name = file 31 | } else { 32 | name = "sample" 33 | } 34 | case 1: 35 | name = "sample" 36 | file = "-" 37 | default: 38 | log.Fatal("too many arguments") 39 | } 40 | var f io.Reader 41 | switch file { 42 | case "-": 43 | f = os.Stdin 44 | default: 45 | fic, err := os.Open(file) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | defer fic.Close() 50 | f = fic 51 | } 52 | /* 53 | content, err := ioutil.ReadAll(os.Stdin) 54 | portraitLeft := md5.Sum(content[2480343:2551487])) 55 | landscapeLeft := md5.Sum(content[93530:2551487])) 56 | sigPortraitLeft := []byte{198, 104, 128, 15, 244, 220, 201, 184, 37, 102, 121, 114, 251, 0, 76, 100} 57 | 58 | if compareSig(sigPortraitLeft, portraitLeft) { 59 | fmt.Println("portrait left") 60 | } 61 | */ 62 | b := make([]byte, 1) 63 | content, err := ioutil.ReadAll(f) 64 | rdr := bytes.NewReader(content) 65 | groups := make([][2]int, 0) 66 | window := 200 67 | var inGroup bool 68 | var startGroup int 69 | var lastOff int 70 | var off int 71 | for off = 0; err == nil; off++ { 72 | _, err = rdr.Read(b) 73 | if reflect.DeepEqual(b, []byte{0}) { 74 | if !inGroup { 75 | startGroup = off 76 | inGroup = true 77 | groups = append(groups, [2]int{startGroup, 0}) 78 | } 79 | if off-startGroup > window { 80 | groups[len(groups)-1][1] = lastOff 81 | inGroup = false 82 | } 83 | lastOff = off 84 | } 85 | } 86 | if groups[len(groups)-1][1] == 0 { 87 | groups = groups[:len(groups)-1] 88 | } 89 | sort.Sort(sort.Reverse(byGroupSize(groups))) 90 | displayCodeChecking(name, content, groups[0]) 91 | } 92 | 93 | type byGroupSize [][2]int 94 | 95 | func (b byGroupSize) Len() int { 96 | return len(b) 97 | } 98 | func (b byGroupSize) Swap(i, j int) { 99 | b[i], b[j] = b[j], b[i] 100 | } 101 | func (b byGroupSize) Less(i, j int) bool { 102 | return b[i][1]-b[i][0] < b[j][1]-b[j][0] 103 | } 104 | 105 | func displayCodeChecking(name string, content []byte, g [2]int) { 106 | name = path.Base(name) 107 | var extension = filepath.Ext(name) 108 | name = name[0 : len(name)-len(extension)] 109 | 110 | t := template.Must(template.New("sample").Parse(tmpl)) 111 | type data struct { 112 | Name string 113 | Sig string 114 | Start int 115 | End int 116 | } 117 | var b bytes.Buffer 118 | err := t.Execute(&b, data{ 119 | Name: strings.Title(strings.ToLower(name)), 120 | Sig: strings.Replace(strings.Trim(fmt.Sprint(md5.Sum(content[g[0]:g[1]])), "[]"), " ", ",", -1), 121 | Start: g[0], 122 | End: g[1], 123 | }) 124 | if err != nil { 125 | log.Fatal(err) 126 | } 127 | out, err := format.Source(b.Bytes()) 128 | if err != nil { 129 | log.Fatal(err) 130 | } 131 | fmt.Println(string(out)) 132 | } 133 | 134 | // portraitLeft := []int{2480343,2480344,2480345,2480346,2480347,2480348,2480349,2480350,2480351,2480352,2482212,2482213,2482214,2482215,2482216,2482217,2482218,2482219,2482220,2482221,2482222,2482223,2482224,2482225,2482226,2482227,2484082,2484083,2484084,2484085,2484086,2484087,2484088,2484089,2484090,2484091,2484092,2484093,2484094,2484095,2484096,2484097,2484098,2484099,2484100,2484101,2485953,2485954,2485955,2485956,2485957,2485958,2485959,2485968,2485969,2485970,2485971,2485972,2485973,2485974,2487823,2487824,2487825,2487826,2487827,2487828,2487843,2487844,2487845,2487846,2487847,2487848,2489694,2489695,2489696,2489697,2489698,2489717,2489718,2489719,2489720,2489721,2491565,2491566,2491567,2491568,2491569,2491590,2491591,2491592,2491593,2491594,2493437,2493438,2493439,2493440,2493463,2493464,2493465,2493466,2495308,2495309,2495310,2495311,2495336,2495337,2495338,2495339,2497179,2497180,2497181,2497182,2497209,2497210,2497211,2497212,2499051,2499052,2499053,2499061,2499062,2499063,2499064,2499082,2499083,2499084,2500922,2500923,2500924,2500925,2500931,2500932,2500933,2500934,2500935,2500936,2500937,2500938,2500954,2500955,2500956,2500957,2502794,2502795,2502796,2502803,2502804,2502805,2502806,2502807,2502808,2502809,2502810,2502827,2502828,2502829,2504665,2504666,2504667,2504668,2504675,2504676,2504677,2504678,2504679,2504680,2504681,2504682,2504699,2504700,2504701,2504702,2506537,2506538,2506539,2506546,2506547,2506548,2506549,2506550,2506551,2506552,2506553,2506554,2506555,2506572,2506573,2506574,2508409,2508410,2508411,2508419,2508420,2508421,2508422,2508423,2508424,2508425,2508426,2508444,2508445,2508446,2510281,2510282,2510283,2510291,2510292,2510293,2510294,2510295,2510296,2510297,2510298,2510316,2510317,2510318,2512153,2512154,2512155,2512164,2512165,2512166,2512167,2512168,2512169,2512188,2512189,2512190,2514024,2514025,2514026,2514027,2514037,2514038,2514039,2514040,2514060,2514061,2514062,2514063,2515896,2515897,2515898,2515899,2515932,2515933,2515934,2515935,2517768,2517769,2517770,2517771,2517804,2517805,2517806,2517807,2519641,2519642,2519643,2519676,2519677,2519678,2521513,2521514,2521515,2521548,2521549,2521550,2523385,2523386,2523387,2523420,2523421,2523422,2525257,2525258,2525259,2525292,2525293,2525294,2527129,2527130,2527131,2527132,2527163,2527164,2527165,2527166,2529002,2529003,2529004,2529035,2529036,2529037,2530874,2530875,2530876,2530877,2530906,2530907,2530908,2530909,2532747,2532748,2532749,2532778,2532779,2532780,2534619,2534620,2534621,2534622,2534649,2534650,2534651,2534652,2536492,2536493,2536494,2536495,2536520,2536521,2536522,2536523,2538365,2538366,2538367,2538368,2538391,2538392,2538393,2538394,2540238,2540239,2540240,2540241,2540262,2540263,2540264,2540265,2542111,2542112,2542113,2542114,2542115,2542132,2542133,2542134,2542135,2542136,2543984,2543985,2543986,2543987,2543988,2543989,2544002,2544003,2544004,2544005,2544006,2544007,2545857,2545858,2545859,2545860,2545861,2545862,2545863,2545864,2545871,2545872,2545873,2545874,2545875,2545876,2545877,2545878,2547731,2547732,2547733,2547734,2547735,2547736,2547737,2547738,2547739,2547740,2547741,2547742,2547743,2547744,2547745,2547746,2547747,2547748,2549605,2549606,2549607,2549608,2549609,2549610,2549611,2549612,2549613,2549614,2549615,2549616,2549617,2549618,2551480,2551481,2551482,2551483,2551484,2551485,2551486,2551487} 135 | // landsxcapeLeft := []int{76694,76695,76696,76697,76698,76699,76700,76701,76702,78563,78564,78565,78566,78567,78568,78569,78570,78571,78572,78573,78574,78575,78576,78577,80433,80434,80435,80436,80437,80438,80439,80440,80441,80442,80443,80444,80445,80446,80447,80448,80449,80450,80451,82304,82305,82306,82307,82308,82309,82310,82318,82319,82320,82321,82322,82323,82324,82325,84174,84175,84176,84177,84178,84179,84193,84194,84195,84196,84197,84198,86045,86046,86047,86048,86049,86067,86068,86069,86070,86071,87916,87917,87918,87919,87920,87941,87942,87943,87944,89787,89788,89789,89790,89791,89814,89815,89816,89817,91659,91660,91661,91662,91687,91688,91689,91690,93530,93531,93532,93533,93560,93561,93562,93563,95401,95402,95403,95404,95433,95434,95435,97273,97274,97275,97276,97305,97306,97307,97308,99145,99146,99147,99178,99179,99180,101016,101017,101018,101019,101050,101051,101052,102888,102889,102890,102922,102923,102924,102925,104760,104761,104762,104795,104796,104797,106632,106633,106634,106667,106668,106669,108503,108504,108505,108506,108539,108540,108541,110375,110376,110377,110411,110412,110413,112247,112248,112249,112283,112284,112285,114119,114120,114121,114132,114133,114134,114135,114155,114156,114157,115991,115992,115993,115994,116002,116003,116004,116005,116006,116007,116008,116027,116028,116029,117864,117865,117866,117874,117875,117876,117877,117878,117879,117880,117881,117899,117900,117901,119736,119737,119738,119745,119746,119747,119748,119749,119750,119751,119752,119753,119771,119772,119773,121608,121609,121610,121617,121618,121619,121620,121621,121622,121623,121624,121625,121642,121643,121644,121645,123480,123481,123482,123483,123489,123490,123491,123492,123493,123494,123495,123496,123497,123514,123515,123516,125353,125354,125355,125362,125363,125364,125365,125366,125367,125368,125369,125386,125387,125388,127225,127226,127227,127228,127234,127235,127236,127237,127238,127239,127240,127257,127258,127259,127260,129097,129098,129099,129100,129108,129109,129110,129111,129129,129130,129131,130970,130971,130972,130973,131000,131001,131002,131003,132843,132844,132845,132846,132871,132872,132873,132874,134715,134716,134717,134718,134719,134742,134743,134744,134745,136588,136589,136590,136591,136592,136613,136614,136615,136616,138461,138462,138463,138464,138465,138483,138484,138485,138486,138487,140334,140335,140336,140337,140338,140339,140353,140354,140355,140356,140357,140358,142208,142209,142210,142211,142212,142213,142214,142223,142224,142225,142226,142227,142228,142229,144081,144082,144083,144084,144085,144086,144087,144088,144089,144090,144091,144092,144093,144094,144095,144096,144097,144098,144099,144100,145955,145956,145957,145958,145959,145960,145961,145962,145963,145964,145965,145966,145967,145968,145969,145970,147830,147831,147832,147833,147834,147835,147836,147837,147838,147839} 136 | 137 | // 2480343:2551487 138 | -------------------------------------------------------------------------------- /tools/utils/genSignature/tmpl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const tmpl = ` 4 | package main 5 | 6 | import ( 7 | "crypto/md5" 8 | "fmt" 9 | ) 10 | 11 | func compareSig(src []byte, sig [16]byte) bool { 12 | if len(src) != 16 { 13 | return false 14 | } 15 | for i := 0; i < 16; i++ { 16 | if src[i] != sig[i] { 17 | return false 18 | } 19 | } 20 | return true 21 | } 22 | 23 | func is{{ .Name }}(content []byte) bool { 24 | sig := []byte{ {{ .Sig }} } 25 | return compareSig(sig, md5.Sum(content[{{ .Start }}:{{ .End }}])) 26 | } 27 | ` 28 | -------------------------------------------------------------------------------- /zstd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/klauspost/compress/zstd" 9 | ) 10 | 11 | type zstdResponseWriter struct { 12 | io.Writer 13 | http.ResponseWriter 14 | } 15 | 16 | func (w zstdResponseWriter) Write(b []byte) (int, error) { 17 | return w.Writer.Write(b) 18 | } 19 | 20 | // zstdMiddleware applies zstd compression to HTTP responses. 21 | func zstdMiddleware(next http.Handler, compressionLevel int) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | if !strings.Contains(r.Header.Get("Accept-Encoding"), "zstd") { 24 | next.ServeHTTP(w, r) 25 | return 26 | } 27 | 28 | w.Header().Set("Content-Encoding", "zstd") 29 | 30 | encoder, err := zstd.NewWriter(w, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(compressionLevel))) 31 | if err != nil { 32 | http.Error(w, "Failed to create zstd encoder", http.StatusInternalServerError) 33 | return 34 | } 35 | defer encoder.Close() 36 | 37 | compressedWriter := zstdResponseWriter{Writer: encoder, ResponseWriter: w} 38 | 39 | next.ServeHTTP(compressedWriter, r) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /zstd_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | // Test handler to wrap in the middleware 11 | func testHandler(w http.ResponseWriter, r *http.Request) { 12 | w.WriteHeader(http.StatusOK) 13 | _, err := w.Write([]byte("Hello, World!")) 14 | if err != nil { 15 | panic(err) 16 | } 17 | } 18 | 19 | func TestZstdMiddleware_NoCompression(t *testing.T) { 20 | handler := zstdMiddleware(http.HandlerFunc(testHandler), 3) 21 | 22 | req := httptest.NewRequest(http.MethodGet, "/", nil) 23 | rec := httptest.NewRecorder() 24 | 25 | handler.ServeHTTP(rec, req) 26 | 27 | res := rec.Result() 28 | defer res.Body.Close() 29 | 30 | if res.Header.Get("Content-Encoding") != "" { 31 | t.Errorf("expected no Content-Encoding, got %s", res.Header.Get("Content-Encoding")) 32 | } 33 | 34 | body, _ := io.ReadAll(res.Body) 35 | if string(body) != "Hello, World!" { 36 | t.Errorf("unexpected body: got %s, want %s", string(body), "Hello, World!") 37 | } 38 | } 39 | 40 | func TestZstdMiddleware_WithCompression(t *testing.T) { 41 | handler := zstdMiddleware(http.HandlerFunc(testHandler), 3) 42 | 43 | req := httptest.NewRequest(http.MethodGet, "/", nil) 44 | req.Header.Set("Accept-Encoding", "zstd") 45 | rec := httptest.NewRecorder() 46 | 47 | handler.ServeHTTP(rec, req) 48 | 49 | res := rec.Result() 50 | defer res.Body.Close() 51 | 52 | if res.Header.Get("Content-Encoding") != "zstd" { 53 | t.Errorf("expected Content-Encoding to be zstd, got %s", res.Header.Get("Content-Encoding")) 54 | } 55 | 56 | body, _ := io.ReadAll(res.Body) 57 | if string(body) == "Hello, World!" { 58 | t.Errorf("response should be compressed, found plaintext in body") 59 | } 60 | } 61 | --------------------------------------------------------------------------------