├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── demo ├── .gitignore ├── CHANGELOG.md ├── README.md ├── __integration__ │ ├── .eslintrc.js │ ├── counter │ │ ├── features │ │ │ └── connection.feature │ │ ├── shared.js │ │ └── step-definitions │ │ │ └── connection.steps.js │ └── helpers.js ├── accelerometer-3d │ ├── accelerometer-3d-remote.css │ ├── index.html │ └── js │ │ ├── App.jsx │ │ ├── DirectLinkToSource.jsx │ │ ├── Master.jsx │ │ ├── OpenRemote.jsx │ │ ├── Phone3D.jsx │ │ ├── Remote.jsx │ │ ├── RemotesList.jsx │ │ ├── accelerometer.helpers.js │ │ ├── color.js │ │ ├── demo.accelerometer-3d.jsx │ │ └── master.logic.js ├── babel.config.js ├── counter-react │ ├── index.html │ ├── js │ │ ├── App.jsx │ │ ├── DirectLinkToSource.jsx │ │ ├── Master.jsx │ │ ├── OpenRemote.jsx │ │ ├── Remote.jsx │ │ ├── RemoteCountControl.jsx │ │ ├── RemoteNameControl.jsx │ │ ├── RemotesList.jsx │ │ └── demo.react-counter.jsx │ └── llm.md ├── counter-vanilla │ ├── js │ │ ├── __tests__ │ │ │ ├── master.logic.test.js │ │ │ └── master.persistance.test.js │ │ ├── master.js │ │ ├── master.view.js │ │ ├── remote.js │ │ └── remote.view.js │ ├── master.html │ └── remote.html ├── counter-vue │ ├── index.html │ └── js │ │ ├── App.vue │ │ ├── DirectLinkToSource.vue │ │ ├── Master.vue │ │ ├── OpenRemote.vue │ │ ├── Remote.vue │ │ ├── RemoteCountControl.vue │ │ ├── RemoteNameControl.vue │ │ ├── common.js │ │ └── demo.vue-counter.js ├── favicon.svg ├── index.html ├── index.js ├── jest-puppeteer.config.js ├── jest.e2e.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── images │ │ └── WebRTC-Logo.jpeg ├── shared │ ├── css │ │ ├── assets │ │ │ ├── github-retina.png │ │ │ ├── javascript-logo.svg │ │ │ ├── twitter-retina.png │ │ │ └── vue-logo.svg │ │ ├── counter-master.css │ │ ├── counter-remote.css │ │ ├── counter.css │ │ ├── main.css │ │ └── network.css │ └── js │ │ ├── animate.js │ │ ├── common-peerjs.js │ │ ├── common.js │ │ ├── common.test.js │ │ ├── components │ │ ├── ConsoleDisplay.jsx │ │ ├── CounterDisplay.jsx │ │ ├── ErrorsDisplay.jsx │ │ ├── Footer.jsx │ │ ├── QrcodeDisplay.jsx │ │ ├── README.md │ │ ├── console-display.js │ │ ├── counter-display.js │ │ ├── errors-display.js │ │ ├── footer-display.js │ │ ├── qrcode-display.js │ │ ├── remotes-list.js │ │ └── twitter-button.js │ │ ├── counter.master.logic.js │ │ ├── counter.master.persistance.js │ │ ├── react-common.js │ │ └── react-useDeviceOrientation.js ├── test.helpers.js └── vite.config.js ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── core │ ├── CHANGELOG.md │ ├── README.md │ ├── babel.config.cjs │ ├── jest.config.js │ ├── master │ │ ├── package.json │ │ └── src │ │ │ ├── core.master.d.ts │ │ │ └── core.master.js │ ├── package-lock.json │ ├── package.json │ ├── remote │ │ ├── package.json │ │ └── src │ │ │ ├── core.remote.d.ts │ │ │ └── core.remote.js │ ├── shared │ │ ├── common.d.ts │ │ ├── common.js │ │ └── common.test.js │ ├── src │ │ ├── core.index.d.ts │ │ └── core.index.js │ └── test.helpers.js ├── react │ ├── CHANGELOG.md │ ├── README.md │ ├── package-lock.json │ ├── package.json │ └── src │ │ ├── Provider.d.ts │ │ ├── Provider.jsx │ │ ├── hooks.d.ts │ │ ├── hooks.js │ │ ├── react.d.ts │ │ └── react.jsx └── vue │ ├── CHANGELOG.md │ ├── README.md │ ├── package-lock.json │ ├── package.json │ └── src │ ├── Provider.d.ts │ ├── Provider.js │ ├── hooks.d.ts │ ├── hooks.js │ ├── vue.d.ts │ └── vue.js └── public └── star-network-topology.png /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignorePatterns: ["node_modules/*", "dist/*"], 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | jest: true, 7 | }, 8 | globals: { 9 | Peer: true, 10 | page: true, 11 | browser: true, 12 | }, 13 | extends: [ 14 | "airbnb-base", 15 | "plugin:prettier/recommended", 16 | "plugin:react/recommended", 17 | "plugin:react-hooks/recommended", 18 | "plugin:jsx-a11y/recommended", 19 | "plugin:react/jsx-runtime", 20 | ], 21 | parserOptions: { 22 | ecmaVersion: 13, 23 | sourceType: "module", 24 | ecmaFeatures: { 25 | jsx: true, 26 | }, 27 | }, 28 | rules: { 29 | "import/no-extraneous-dependencies": [ 30 | "error", 31 | { 32 | devDependencies: true, 33 | optionalDependencies: false, 34 | peerDependencies: false, 35 | }, 36 | ], 37 | "prettier/prettier": ["error", {}, { usePrettierrc: true }], 38 | "import/prefer-default-export": 0, 39 | "no-use-before-define": 0, 40 | // ignore 'React' is defined but never used 41 | "react/jsx-uses-react": 1, 42 | "no-restricted-syntax": 0, 43 | }, 44 | settings: { 45 | "import/resolver": { 46 | node: { 47 | extensions: [".js", ".jsx"], 48 | }, 49 | }, 50 | react: { 51 | // to avoid "Warning: React version not specified in eslint-plugin-react settings." - https://github.com/yannickcr/eslint-plugin-react/issues/1955 52 | version: "latest", 53 | }, 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push, pull_request] 3 | jobs: 4 | main: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Setup Node 🥣 8 | uses: actions/setup-node@v2 9 | with: 10 | node-version: 22 11 | - run: node -v 12 | - run: npm -v 13 | 14 | - name: Checkout 🛎 15 | uses: actions/checkout@v2 16 | 17 | - name: Install NPM dependencies 📦 18 | run: npm ci 19 | 20 | - name: Build 21 | # run: npm run build 22 | # Public peer server is down for the moment -> we use a local signaling server 23 | run: npm run build:peer-server 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | - name: Unit tests 29 | run: npm run test 30 | 31 | - name: End2end tests 32 | # run: JEST_TIMEOUT=30000 npm run test:e2e:start-server-and-test 33 | # Public peer server is down for the moment -> we use a local signaling server 34 | run: JEST_TIMEOUT=30000 npm run test:e2e:start-server-and-test:peer-server 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .tmp 4 | tsconfig.tsbuildinfo 5 | 6 | # tools 7 | .eslintcache 8 | 9 | # dependencies 10 | node_modules 11 | /.pnp 12 | .pnp.js 13 | 14 | # production 15 | build 16 | dist 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | *.log 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | *.local 35 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .git 2 | .eslintignore 3 | .gitignore 4 | .prettierignore 5 | .gitignore 6 | package-lock.json 7 | yarn.lock 8 | package.json 9 | build 10 | *.fixtures.json 11 | react-modules/* 12 | bin/yarn* 13 | src/generated/* 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://www.conventionalcommits.org) 4 | 5 | ## Prerequisites 6 | 7 | - Nodejs >=16 8 | - npm >=8 9 | 10 | ## Setup 11 | 12 | This project is organized as a monorepo, with lerna. The following will install dependencies for every submodules and wire everything up (via `lerna bootstrap`). 13 | 14 | ```sh 15 | npm install 16 | ``` 17 | 18 | ## Commonly used scripts 19 | 20 | - `npm run build`: builds all the packages + demo (you can use some more specific scripts) 21 | - `npm run build:peer-server`: same, preparing for using a local signaling server 22 | - `npm run dev`: build the packages + demo in watch mode and dev mode (you can use some more specific scripts) 23 | - `npm run dev:peer-server`: same, using a local signaling server 24 | - `npm run preview`: launch the built version 25 | - `npm run preview:peer-server`: same using a local signaling server 26 | - `npm run lint`: runs linter 27 | - `npm test`: runs unit tests (you can use some more specific scripts) 28 | - `npm run test:e2e`: run end-to-end test (you need to have your preview or dev server started) 29 | - `npm run test:e2e:watch`: same in watch mode 30 | - `npm run test:e2e:start-server-and-test`: launches preview server and runs e2e tests (you need to have built the project before) 31 | - `npm run test:e2e:start-server-and-test:peer-server`: same using a local signaling server 32 | 33 | ## Using the local peer server 34 | 35 | By default, you can use the signaling server of peerjs (no need to deploy your own). 36 | 37 | For some reason, you may want to run your code against a local signaling server. You can launch a signaling server with the following command in a tab: 38 | 39 | ```sh 40 | npm run peer-server 41 | ``` 42 | 43 | Then on an other tab, set the env var `VITE_USE_LOCAL_PEER_SERVER=true` 44 | 45 | ```sh 46 | VITE_USE_LOCAL_PEER_SERVER=true npm run dev # also works with npm run build 47 | ``` 48 | 49 | ## Adding dependencies 50 | 51 | If you need to add dependencies to one of the packages or the demo, dont use `npm` directly, use [@lerna/add](https://www.npmjs.com/package/@lerna/add). 52 | 53 | Examples: 54 | 55 | ```sh 56 | npx lerna add npm-run-all --scope=@webrtc-remote-control/react --dev 57 | npx lerna add prop-types --scope=@webrtc-remote-control/react 58 | npx lerna add react@>=16.8.0 --scope=@webrtc-remote-control/react --peer 59 | npx lerna add react vue --scope=@webrtc-remote-control/demo 60 | ``` 61 | 62 | ## Environment variables 63 | 64 | You can pass environment variables at build time **before publish** via microbundle - [see docs](https://github.com/developit/microbundle#defining-build-time-constants). 65 | 66 | This is currently used to create production (minified) and development (unminified) versions of the UMD builds. 67 | 68 | ## https 69 | 70 | WebRTC (and other APIs like the accelerometer) only work on secure origins (localhost is considered secure). The app will work in development if you test it on `localhost` (which is considered secure by browsers), on multiple tabs. 71 | 72 | However, if you try to access the app from your local ip (like 192.168.1.1) from your laptop or your mobile, it won't work, since the domain will be recognized as unsecure. 73 | 74 | So to test on multiple devices, you'll need to tunnel the app with a utility like [localhost.run](https://localhost.run/) that will open an ssh tunnel and forward traffic on https. 75 | 76 | Some tasks are available: 77 | 78 | - `npm run dev:forward`: same as `npm run dev` with forwarding 79 | - `npm run preview:forward`: same as `npm run preview` with forwarding (you have to build before) 80 | - `npm run demo:forward`: will forward `localhost:3000` 81 | 82 | The public https temporary address will be outputted on your terminal (keep in mind you won't access your website through your local network but through the internet, which can take longer - use that only to test WebRTC on mobile devices). 83 | 84 | ## e2e tests 85 | 86 | In the [demo](demo#readme), you'll find a version of the counter app for each implementation of webrtc-remote-control (vanilla, react, vue). The UI relies on the same web-components. 87 | 88 | The exact same [test suite](demo/__integration__/) runs on each counter app. If you want to contribute and add support for your framework of choice: 89 | 90 | - add the implementation of webrtc-remote-control for your framework 91 | - make a counter app (using the existing web-components) 92 | - ensure the tests pass 93 | 94 | ## PeerJS 95 | 96 | [PeerJS](https://peerjs.com/) is a wrapper around the WebRTC browser's APIs. It provides a signaling server for free (which means you don't have to setup any backend server). 97 | 98 | Thanks to PeerJS, you don't have to bother directly about: 99 | 100 | - the **signaling server** - you already have one for free which relies on websocket 101 | - issue and exchange **offers** and **answers** (SDP session description) 102 | - exchange ICE candidates through the signaling server 103 | 104 | > ICE stands for Interactive Connectivity Establishment , its a techniques used in NAT( network address translator ) for establishing communication for VOIP, peer-peer, instant-messaging, and other kind of interactive media. 105 | > Typically ice candidate provides the information about the ipaddress and port from where the data is going to be exchanged. 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Christophe Rosset 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webrtc-remote-control 2 | 3 | [![ci](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml/badge.svg)](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml) 4 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://www.conventionalcommits.org) 5 | [![Demo](https://img.shields.io/badge/demo-online-blue.svg)](http://webrtc-remote-control.vercel.app/) 6 | 7 | Implementations 8 | 9 | | Package | Version | 10 | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | 11 | | [@webrtc-remote-control/core](./packages/core#readme) | [![npm](https://img.shields.io/npm/v/@webrtc-remote-control/core?color=blue)](https://www.npmjs.com/package/@webrtc-remote-control/core) | 12 | | [@webrtc-remote-control/react](./packages/react#readme) | [![npm](https://img.shields.io/npm/v/@webrtc-remote-control/react?color=blue)](https://www.npmjs.com/package/@webrtc-remote-control/react) | 13 | | [@webrtc-remote-control/vue](./packages/vue#readme) | [![npm](https://img.shields.io/npm/v/@webrtc-remote-control/vue?color=blue)](https://www.npmjs.com/package/@webrtc-remote-control/vue) | 14 | 15 | - [Demo](./demo#readme) 16 | - [CONTRIBUTING](CONTRIBUTING.md) 17 | 18 | ## The problem 19 | 20 | [PeerJS](https://peerjs.com) is a great layer of abstraction above WebRTC with a simple API, though, you still need to: 21 | 22 | - track your connections 23 | - handle reconnects of peers when your page reloads 24 | 25 | You don't want to think about this kind of networking problems, you want to focus on your app logic. 26 | 27 | **webrtc-remote-control** handles all of that. 28 | 29 | ## The use case 30 | 31 | **webrtc-remote-control** was made to handle star topology network: 32 | 33 |

34 | 35 | You have: 36 | 37 | - One "master" page connected to 38 | - Multiple "remote" pages 39 | 40 | What you can do (through data-channel): 41 | 42 | - From "master" page, you can send data to any or all "remote" pages 43 | - From one "remote" page, you can send data to the master page 44 | 45 | When "master" page drops connection (the page closes or reloads), the "remote" pages are notified (and remote automatically reconnect when master retrieves connection). 46 | 47 | When a "remote" page drops connection (the page closes or reloads), the "master" page gets notified (and the remote reconnects to master as soon as it reloads). 48 | 49 | ## Genesis 50 | 51 | A few years ago I made [topheman/webrtc-experiments](https://github.com/topheman/webrtc-experiments), as a proof of concept for WebRTC data-channels relying on PeerJS. 52 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # @webrtc-remote-control/demo 2 | 3 | [![Demo](https://img.shields.io/badge/demo-online-blue.svg)](http://webrtc-remote-control.vercel.app/) 4 | 5 | Check the npm scripts to launch the app in [CONTRIBUTING.md](../CONTRIBUTING.md). 6 | -------------------------------------------------------------------------------- /demo/__integration__/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | "no-restricted-syntax": 0, 4 | "no-await-in-loop": 0, 5 | "no-plusplus": 0, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /demo/__integration__/counter/features/connection.feature: -------------------------------------------------------------------------------- 1 | Feature: Counter 2 | 3 | Background: Connecting multiple remotes 4 | Given I visit demo home page 5 | And I visit master page 6 | And [master] triggers open event 7 | Then I open a new remote from master, it should trigger an open event on remote 8 | And [master] should receive remote.connect event 9 | And [master] remote lists should be "[0]" 10 | Then I open a new remote from master, it should trigger an open event on remote 11 | And [master] should receive remote.connect event 12 | And [master] remote lists should be "[0,0]" 13 | Then I open a new remote from master, it should trigger an open event on remote 14 | And [master] should receive remote.connect event 15 | And [master] remote lists should be "[0,0,0]" 16 | 17 | Scenario: Basic 18 | Given I reset the sessionStorage of every pages 19 | And I close every pages 20 | 21 | Scenario: Send events 22 | Given I click on increment 3 times on remote 0 23 | And I click on increment 5 times on remote 1 24 | And I click on decrement 2 times on remote 2 25 | Then [master] remote lists should be "[3,5,-2]" 26 | Given I reset the sessionStorage of every pages 27 | And I close every pages 28 | 29 | Scenario: Reconnection 30 | Given I reload remote 1 then master should receive remote.disconnect/remote.connect event 31 | And I reload master then all remotes should receive remote.disconnect/remote.reconnect 32 | Given I reset the sessionStorage of every pages 33 | And I close every pages 34 | -------------------------------------------------------------------------------- /demo/__integration__/counter/step-definitions/connection.steps.js: -------------------------------------------------------------------------------- 1 | import { defineFeature, loadFeature } from "jest-cucumber"; 2 | // eslint-disable-next-line import/no-extraneous-dependencies 3 | import chalk from "chalk"; 4 | 5 | import { makeGetModes } from "../../helpers"; 6 | 7 | import { 8 | setupBackground, 9 | givenICloseEveryPages, 10 | givenIResetSessionStorage, 11 | giventIClickTimesOnRemote, 12 | givenRemoteListShouldContain, 13 | givenIReloadARemoteThenMasterShouldReceiveDisconnectEvent, 14 | givenIReloadMasterThenRemotesShouldReconnect, 15 | } from "../shared"; 16 | 17 | const feature = loadFeature(`${__dirname}/../features/connection.feature`); 18 | 19 | jest.setTimeout(Number(process.env.JEST_TIMEOUT) || 10000); 20 | 21 | /** 22 | * You can pass: 23 | * - `MODE=react npm run test:e2e` 24 | * - `MODE=vanilla,react npm run test:e2e` 25 | * By default, it runs all 26 | */ 27 | const getModes = makeGetModes("MODE", ["vanilla", "react", "vue"]); 28 | 29 | console.log(`Running tests for ${chalk.yellow(getModes().join(", "))}`); 30 | 31 | describe.each(getModes())("[%s]", (mode) => { 32 | defineFeature(feature, (test) => { 33 | jest.retryTimes(3); 34 | test("Basic", ({ given }) => { 35 | const api = setupBackground(given, mode); 36 | givenIResetSessionStorage(given, mode, api); 37 | givenICloseEveryPages(given, api); 38 | }); 39 | test("Send events", async ({ given }) => { 40 | const api = setupBackground(given, mode); 41 | giventIClickTimesOnRemote(given, api); 42 | giventIClickTimesOnRemote(given, api); 43 | giventIClickTimesOnRemote(given, api); 44 | givenRemoteListShouldContain(given, api); 45 | givenIResetSessionStorage(given, mode, api); 46 | givenICloseEveryPages(given, api); 47 | }); 48 | test("Reconnection", async ({ given }) => { 49 | const api = setupBackground(given, mode); 50 | givenIReloadARemoteThenMasterShouldReceiveDisconnectEvent(given, api); 51 | givenIReloadMasterThenRemotesShouldReconnect(given, api); 52 | givenIResetSessionStorage(given, mode, api); 53 | givenICloseEveryPages(given, api); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /demo/__integration__/helpers.js: -------------------------------------------------------------------------------- 1 | export function makeGetModes(processEnvKey, allowedModes) { 2 | return function getModes() { 3 | if (process.env[processEnvKey]) { 4 | const extractedModes = process.env[processEnvKey].split(","); 5 | for (const modeToCheck of extractedModes) { 6 | if (!allowedModes.includes(modeToCheck)) { 7 | throw new Error( 8 | `Unsupported ${processEnvKey} "${modeToCheck}", only accepts ${allowedModes.join( 9 | ", " 10 | )}` 11 | ); 12 | } 13 | } 14 | return extractedModes; 15 | } 16 | return allowedModes; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/accelerometer-3d-remote.css: -------------------------------------------------------------------------------- 1 | .request-permission-button-wrapper { 2 | text-align: center; 3 | } 4 | .request-permission-button { 5 | padding: 8px; 6 | font-size: 100%; 7 | border-radius: 8px; 8 | background-color: #900000; 9 | border: 1px solid #900000; 10 | color: white; 11 | cursor: pointer; 12 | } 13 | .request-permission-button:hover { 14 | background-color: white; 15 | color: #900000; 16 | } 17 | .deviceorientation-error { 18 | color: red; 19 | } 20 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | webrtc-remote-control / demo / accelerometer-3d 12 | 13 | 17 | 21 | 25 | 26 | 27 | 31 | 35 | 36 | 40 | 44 | 48 | 52 | 53 | 57 | 61 | 65 | 69 | 70 | 71 | 72 |
73 | 90 |

91 | webrtc-remote-control / demo / accelerometer-3d 94 |

95 |
96 |
97 |
98 | Loading ... 99 |
100 |
101 | 102 |
103 |
104 | 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-nested-ternary */ 2 | import React, { useState, useEffect } from "react"; 3 | 4 | import { WebRTCRemoteControlProvider } from "@webrtc-remote-control/react"; 5 | 6 | import { getPeerjsConfig } from "../../shared/js/common-peerjs"; 7 | 8 | import Master from "./Master"; 9 | import Remote from "./Remote"; 10 | import FooterDisplay from "../../shared/js/components/Footer"; 11 | 12 | export default function App() { 13 | console.log("App render"); 14 | const [mode, setMode] = useState(null); 15 | useEffect(() => { 16 | setMode(window.location.hash ? "remote" : "master"); 17 | }, []); 18 | return ( 19 | <> 20 | {mode ? ( 21 | 24 | new Peer( 25 | getPeerId(), 26 | // line bellow is optional - you can rely on the signaling server exposed by peerjs 27 | getPeerjsConfig() 28 | ) 29 | } 30 | masterPeerId={ 31 | (window.location.hash && window.location.hash.replace("#", "")) || 32 | null 33 | } 34 | sessionStorageKey="webrtc-remote-control-peer-id-accelerometer" 35 | > 36 | {mode === "remote" ? : } 37 | 38 | ) : ( 39 | "Loading ..." 40 | )} 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/DirectLinkToSource.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function DirectLinkToSourceCode({ mode }) { 5 | const target = mode.at(0).toUpperCase() + mode.slice(1); 6 | return ( 7 |

8 | Direct link to source code:{" "} 9 | 12 | {target}.jsx 13 | 14 | {" / "} 15 | 16 | App.jsx 17 | 18 |

19 | ); 20 | } 21 | 22 | DirectLinkToSourceCode.propTypes = { 23 | mode: PropTypes.oneOf(["master", "remote"]), 24 | }; 25 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/Master.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { usePeer } from "@webrtc-remote-control/react"; 3 | 4 | import RemotesList from "./RemotesList"; 5 | import ErrorsDisplay from "../../shared/js/components/ErrorsDisplay"; 6 | import QrcodeDisplay from "../../shared/js/components/QrcodeDisplay"; 7 | import OpenRemote from "./OpenRemote"; 8 | import DirectLinkToSourceCode from "./DirectLinkToSource"; 9 | 10 | import { remotesListReducer } from "./master.logic"; 11 | 12 | function makeRemotePeerUrl(peerId) { 13 | return `${ 14 | window.location.origin + 15 | window.location.pathname 16 | .replace(/\/$/, "") 17 | .split("/") 18 | .slice(0, -1) 19 | .join("/") 20 | }/index.html#${peerId}`; 21 | } 22 | 23 | export default function Master() { 24 | const [peerId, setPeerId] = useState(null); 25 | // eslint-disable-next-line no-unused-vars 26 | const [remotesList, setRemotesList] = useState([]); 27 | const [errors, setErrors] = useState(null); 28 | 29 | const { ready, api, peer, humanizeError } = usePeer(); 30 | 31 | const onRemoteConnect = ({ id }) => { 32 | console.log({ event: "remote.connect", payload: { id } }); 33 | setRemotesList((remotes) => [ 34 | ...remotes, 35 | { alpha: 0, beta: 0, gamma: 0, peerId: id }, 36 | ]); 37 | }; 38 | const onRemoteDisconnect = ({ id }) => { 39 | console.log({ event: "remote.disconnect", payload: { id } }); 40 | setRemotesList((remotes) => 41 | // eslint-disable-next-line no-shadow 42 | remotes.filter(({ peerId }) => peerId !== id) 43 | ); 44 | }; 45 | const onData = ({ id }, data) => { 46 | setRemotesList((remotes) => { 47 | const state = remotesListReducer(remotes, { data, id }); 48 | return state; 49 | }); 50 | }; 51 | const onPeerError = (error) => { 52 | setPeerId(null); 53 | console.error({ event: "error", error }); 54 | setErrors([humanizeError(error)]); 55 | }; 56 | 57 | useEffect(() => { 58 | if (peer) { 59 | peer.on("error", onPeerError); 60 | } 61 | return () => { 62 | if (peer) { 63 | peer.off("error", onPeerError); 64 | } 65 | }; 66 | // eslint-disable-next-line react-hooks/exhaustive-deps 67 | }, [peer]); 68 | 69 | useEffect(() => { 70 | if (ready) { 71 | setPeerId(peer.id); 72 | console.log({ 73 | event: "open", 74 | comment: "Master connected", 75 | payload: { id: peer.id }, 76 | }); 77 | api.on("remote.connect", onRemoteConnect); 78 | api.on("remote.disconnect", onRemoteDisconnect); 79 | api.on("data", onData); 80 | } 81 | return () => { 82 | console.log("Master.jsx.cleanup"); 83 | if (ready) { 84 | api.off("remote.connect", onRemoteConnect); 85 | api.off("remote.disconnect", onRemoteDisconnect); 86 | api.off("data", onData); 87 | } 88 | }; 89 | // eslint-disable-next-line react-hooks/exhaustive-deps 90 | }, [ready]); 91 | return ( 92 | <> 93 | 94 | {peerId ? : null} 95 | 96 | 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/OpenRemote.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function OpenRemote({ peerId }) { 5 | return ( 6 |

7 | 👆Snap the the QR code or{" "} 8 | 17 | click here 18 | {" "} 19 | to open an{" "} 20 | other window from where you will control this page (like 21 | with a remote). 22 |

23 | ); 24 | } 25 | 26 | OpenRemote.propTypes = { 27 | peerId: PropTypes.string, 28 | }; 29 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/Phone3D.jsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | import { Canvas } from "@react-three/fiber"; 3 | import PropTypes from "prop-types"; 4 | 5 | import { usePhoneColor } from "./color"; 6 | 7 | function Box({ color, ...props }) { 8 | // This reference gives us direct access to the THREE.Mesh object 9 | const ref = useRef(); 10 | // Return the view, these are regular Threejs elements expressed in JSX 11 | return ( 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | Box.defaultProps = { 20 | color: "#900000", 21 | }; 22 | 23 | Box.propTypes = { 24 | color: PropTypes.string, 25 | }; 26 | 27 | export default function Phone3D({ 28 | width, 29 | height, 30 | rotation, 31 | peerId, 32 | colorHover, 33 | scale, 34 | onPointerEnter, 35 | onPointerLeave, 36 | onPointerDown, 37 | onPointerUp, 38 | }) { 39 | const [, y, z] = rotation; 40 | const [hover, setHover] = useState(false); 41 | const phoneColor = usePhoneColor(peerId); 42 | return ( 43 |
54 | 55 | 56 | 57 | 58 | { 66 | if (colorHover) { 67 | setHover(true); 68 | } 69 | onPointerEnter?.(e); 70 | }} 71 | onPointerLeave={(e) => { 72 | if (colorHover) { 73 | setHover(false); 74 | } 75 | onPointerLeave?.(e); 76 | }} 77 | /> 78 | 79 |
80 | ); 81 | } 82 | 83 | Phone3D.defaultProps = { 84 | width: 150, 85 | height: 150, 86 | }; 87 | 88 | Phone3D.propTypes = { 89 | width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 90 | height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 91 | rotation: PropTypes.arrayOf(PropTypes.number), 92 | peerId: PropTypes.string, 93 | colorHover: PropTypes.string, 94 | scale: PropTypes.number, 95 | onPointerEnter: PropTypes.func, 96 | onPointerLeave: PropTypes.func, 97 | onPointerDown: PropTypes.func, 98 | onPointerUp: PropTypes.func, 99 | }; 100 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/Remote.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, lazy, Suspense } from "react"; 2 | import { usePeer } from "@webrtc-remote-control/react"; 3 | 4 | import ErrorsDisplay from "../../shared/js/components/ErrorsDisplay"; 5 | import DirectLinkToSourceCode from "./DirectLinkToSource"; 6 | 7 | import { useSessionStorage } from "../../shared/js/react-common"; 8 | import { useDeviceOrientation } from "../../shared/js/react-useDeviceOrientation"; 9 | import { orientationToRotation } from "./accelerometer.helpers"; 10 | 11 | const Phone3D = lazy(() => import("./Phone3D")); 12 | 13 | export default function Remote() { 14 | // eslint-disable-next-line no-unused-vars 15 | const [peerId, setPeerId] = useState(null); 16 | const [ 17 | name, 18 | // setName 19 | ] = useSessionStorage("remote-name", ""); 20 | const [errors, setErrors] = useState(null); 21 | const [phoneScale, setPhoneScale] = useState(1); 22 | 23 | const { ready, api, peer, humanizeError } = usePeer(); 24 | 25 | const { 26 | orientation, 27 | requestAccess: requestDeviceOrientationAccess, 28 | permissionState, 29 | } = useDeviceOrientation({ precision: 2, throttle: 16 }); 30 | 31 | const onRemoteDisconnect = (payload) => { 32 | console.log({ event: "remote.disconnect", payload }); 33 | }; 34 | const onRemoteReconnect = (payload) => { 35 | console.log({ event: "remote.reconnect", payload }); 36 | if (name) { 37 | api.send({ type: "REMOTE_SET_NAME", name }); 38 | } 39 | }; 40 | const onPeerError = (error) => { 41 | setPeerId(null); 42 | console.error({ event: "error", error }); 43 | setErrors([humanizeError(error)]); 44 | }; 45 | const onData = (_, data) => { 46 | console.log({ event: "data", data }); 47 | if (data.type === "PING") { 48 | window?.frameworkIconPlay(); 49 | } 50 | }; 51 | 52 | useEffect(() => { 53 | if (peer) { 54 | peer.on("error", onPeerError); 55 | } 56 | return () => { 57 | if (peer) { 58 | peer.off("error", onPeerError); 59 | } 60 | }; 61 | // eslint-disable-next-line react-hooks/exhaustive-deps 62 | }, [peer]); 63 | 64 | useEffect(() => { 65 | if (ready) { 66 | setPeerId(peer.id); 67 | console.log({ 68 | event: "open", 69 | comment: "Remote connected", 70 | payload: { id: peer.id }, 71 | }); 72 | api.on("remote.disconnect", onRemoteDisconnect); 73 | api.on("remote.reconnect", onRemoteReconnect); 74 | api.on("data", onData); 75 | if (name) { 76 | api.send({ type: "REMOTE_SET_NAME", name }); 77 | } 78 | } 79 | return () => { 80 | console.log("Remote.jsx.cleanup"); 81 | if (ready) { 82 | api.off("remote.disconnect", onRemoteDisconnect); 83 | api.off("remote.reconnect", onRemoteReconnect); 84 | api.off("data", onData); 85 | } 86 | }; 87 | }, [ready]); 88 | 89 | useEffect(() => { 90 | if (ready) { 91 | api.send({ type: "ORIENTATION", ...orientation }); 92 | } 93 | // eslint-disable-next-line react-hooks/exhaustive-deps 94 | }, [orientation]); 95 | return ( 96 | <> 97 | 98 | Loading 3D model ...}> 99 | { 107 | setPhoneScale(1.3); 108 | if (ready) { 109 | api.send({ type: "PING_DOWN" }); 110 | } 111 | }} 112 | onPointerUp={() => { 113 | setPhoneScale(1); 114 | if (ready) { 115 | api.send({ type: "PING_UP" }); 116 | } 117 | }} 118 | onPointerLeave={() => { 119 | setPhoneScale(1); 120 | if (ready) { 121 | api.send({ type: "PING_UP" }); 122 | } 123 | }} 124 | /> 125 | 126 | {!orientation ? ( 127 |

128 | 134 |

135 | ) : null} 136 | {permissionState === "denied" ? ( 137 |

138 | Request to access the device orientation was rejected, please grant it 139 | by clicking yes on the prompt. 140 |

141 | ) : null} 142 | {orientation ? ( 143 |
    144 |
  • alpha: {orientation.alpha}
  • 145 |
  • beta: {orientation.beta}
  • 146 |
  • gamma: {orientation.gamma}
  • 147 |
148 | ) : null} 149 | 150 | 151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/RemotesList.jsx: -------------------------------------------------------------------------------- 1 | import React, { lazy, Suspense } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { orientationToRotation } from "./accelerometer.helpers"; 5 | 6 | const Phone3D = lazy(() => import("./Phone3D")); 7 | 8 | export default function RemotesList({ list }) { 9 | if (list && list.length) { 10 | return ( 11 |
    12 | {list.map(({ peerId, alpha, beta, gamma, scale, color }) => ( 13 |
  • 14 | {peerId} 15 |
    16 | Loading 3D model ...
    }> 17 | 25 | 26 |
      27 |
    • alpha: {alpha}
    • 28 |
    • beta: {beta}
    • 29 |
    • gamma: {gamma}
    • 30 |
    31 | 32 |
  • 33 | ))} 34 |
35 | ); 36 | } 37 | return null; 38 | } 39 | 40 | RemotesList.propTypes = { 41 | list: PropTypes.arrayOf( 42 | PropTypes.exact({ 43 | peerId: PropTypes.string, 44 | alpha: PropTypes.number, 45 | beta: PropTypes.number, 46 | gamma: PropTypes.number, 47 | }) 48 | ), 49 | }; 50 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/accelerometer.helpers.js: -------------------------------------------------------------------------------- 1 | export function orientationToRotation(orientation, multiplier = 1) { 2 | if (orientation) { 3 | return [ 4 | (multiplier * orientation.alpha) / 360, 5 | (multiplier * orientation.beta) / 180, 6 | (multiplier * orientation.gamma) / 90, 7 | ]; 8 | } 9 | return [0, 0, 0]; 10 | } 11 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/color.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise,no-plusplus */ 2 | import { useEffect, useState } from "react"; 3 | 4 | // inspired by https://stackoverflow.com/questions/3426404/create-a-hexadecimal-colour-based-on-a-string-with-javascript 5 | function makeColor(str = "AZERTY") { 6 | let hash = 0; 7 | for (let i = 0; i < str.length; i++) { 8 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 9 | } 10 | let colour = "#"; 11 | for (let i = 0; i < 3; i++) { 12 | const value = (hash >> (i * 8)) & 0xff; 13 | colour += `00${value.toString(16)}`.substr(-2); 14 | } 15 | return colour; 16 | } 17 | 18 | export function usePhoneColor(peerId) { 19 | const [phoneColor, setPhoneColor] = useState("#900000"); 20 | useEffect(() => { 21 | setPhoneColor(makeColor(peerId || "")); 22 | }, [peerId]); 23 | return phoneColor; 24 | } 25 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/demo.accelerometer-3d.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import App from "./App"; 3 | import "../../shared/js/animate"; // todo 4 | 5 | const root = createRoot(document.getElementById("content")); 6 | root.render(); 7 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/master.logic.js: -------------------------------------------------------------------------------- 1 | export function remotesListReducer(state, { data, id }) { 2 | return state.reduce((acc, cur) => { 3 | if (cur.peerId === id) { 4 | switch (data.type) { 5 | case "ORIENTATION": 6 | acc.push({ 7 | ...cur, 8 | alpha: data.alpha, 9 | beta: data.beta, 10 | gamma: data.gamma, 11 | }); 12 | break; 13 | case "PING_DOWN": 14 | acc.push({ 15 | ...cur, 16 | scale: 1.1, 17 | color: "pink", 18 | }); 19 | break; 20 | case "PING_UP": 21 | acc.push({ 22 | ...cur, 23 | scale: 1, 24 | color: "#900000", 25 | }); 26 | break; 27 | case "REMOTE_SET_NAME": 28 | acc.push({ 29 | ...cur, 30 | name: data.name, 31 | }); 32 | break; 33 | default: 34 | acc.push(cur); 35 | break; 36 | } 37 | } else { 38 | acc.push(cur); 39 | } 40 | return acc; 41 | }, []); 42 | } 43 | -------------------------------------------------------------------------------- /demo/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [["@babel/preset-env", { targets: { node: "current" } }]], 3 | }; 4 | -------------------------------------------------------------------------------- /demo/counter-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | webrtc-remote-control / demo / react / counter 13 | 14 | 18 | 22 | 26 | 27 | 28 | 32 | 36 | 37 | 41 | 45 | 49 | 53 | 54 | 58 | 62 | 66 | 70 | 71 | 72 | 73 |
74 | 91 |

92 | webrtc-remote-control / demo / counter / react 95 | 98 |

99 |
100 |
101 |
102 | Loading ... 103 |
104 |
105 | 106 |
107 |
108 | 109 | 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /demo/counter-react/js/App.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-nested-ternary */ 2 | import React, { useState, useEffect } from "react"; 3 | 4 | import { WebRTCRemoteControlProvider } from "@webrtc-remote-control/react"; 5 | 6 | import { getPeerjsConfig } from "../../shared/js/common-peerjs"; 7 | 8 | import Master from "./Master"; 9 | import Remote from "./Remote"; 10 | import FooterDisplay from "../../shared/js/components/Footer"; 11 | 12 | export default function App() { 13 | console.log("App render"); 14 | const [mode, setMode] = useState(null); 15 | useEffect(() => { 16 | setMode(window.location.hash ? "remote" : "master"); 17 | }, []); 18 | return ( 19 | <> 20 | {mode ? ( 21 | 24 | new Peer( 25 | getPeerId(), 26 | // line bellow is optional - you can rely on the signaling server exposed by peerjs 27 | getPeerjsConfig() 28 | ) 29 | } 30 | masterPeerId={ 31 | (window.location.hash && window.location.hash.replace("#", "")) || 32 | null 33 | } 34 | sessionStorageKey="webrtc-remote-control-peer-id-react" 35 | > 36 | {mode === "remote" ? : } 37 | 38 | ) : ( 39 | "Loading ..." 40 | )} 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /demo/counter-react/js/DirectLinkToSource.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function DirectLinkToSourceCode({ mode }) { 5 | const target = mode.at(0).toUpperCase() + mode.slice(1); 6 | return ( 7 |

8 | Direct link to source code:{" "} 9 | 12 | {target}.jsx 13 | 14 | {" / "} 15 | 16 | App.jsx 17 | 18 |

19 | ); 20 | } 21 | 22 | DirectLinkToSourceCode.propTypes = { 23 | mode: PropTypes.oneOf(["master", "remote"]), 24 | }; 25 | -------------------------------------------------------------------------------- /demo/counter-react/js/Master.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { usePeer } from "@webrtc-remote-control/react"; 3 | 4 | import ErrorsDisplay from "../../shared/js/components/ErrorsDisplay"; 5 | import QrcodeDisplay from "../../shared/js/components/QrcodeDisplay"; 6 | import OpenRemote from "./OpenRemote"; 7 | import CounterDisplay from "../../shared/js/components/CounterDisplay"; 8 | import RemotesList from "./RemotesList"; 9 | import ConsoleDisplay from "../../shared/js/components/ConsoleDisplay"; 10 | import DirectLinkToSourceCode from "./DirectLinkToSource"; 11 | 12 | import { 13 | persistCountersToStorage, 14 | getCountersFromStorage, 15 | } from "../../shared/js/counter.master.persistance"; 16 | import { 17 | counterReducer, 18 | globalCount, 19 | } from "../../shared/js/counter.master.logic"; 20 | import { useLogger } from "../../shared/js/react-common"; 21 | 22 | function makeRemotePeerUrl(peerId) { 23 | return `${ 24 | window.location.origin + 25 | window.location.pathname 26 | .replace(/\/$/, "") 27 | .split("/") 28 | .slice(0, -1) 29 | .join("/") 30 | }/index.html#${peerId}`; 31 | } 32 | 33 | export default function Master() { 34 | const { logs, logger } = useLogger([]); 35 | const [peerId, setPeerId] = useState(null); 36 | const [remotesList, setRemotesList] = useState([]); 37 | const [errors, setErrors] = useState(null); 38 | 39 | const { ready, api, peer, humanizeError } = usePeer(); 40 | 41 | const onRemoteConnect = ({ id }) => { 42 | const countersFromStorage = getCountersFromStorage(); 43 | logger.log({ event: "remote.connect", payload: { id } }); 44 | setRemotesList((counters) => [ 45 | ...counters, 46 | { counter: countersFromStorage?.[id] ?? 0, peerId: id }, 47 | ]); 48 | }; 49 | const onRemoteDisconnect = ({ id }) => { 50 | logger.log({ event: "remote.disconnect", payload: { id } }); 51 | setRemotesList((counters) => 52 | // eslint-disable-next-line no-shadow 53 | counters.filter(({ peerId }) => peerId !== id) 54 | ); 55 | }; 56 | const onData = ({ id }, data) => { 57 | logger.log({ event: "data", data, id }); 58 | setRemotesList((counters) => { 59 | const state = counterReducer(counters, { data, id }); 60 | persistCountersToStorage(state); 61 | return state; 62 | }); 63 | }; 64 | const onPeerError = (error) => { 65 | setPeerId(null); 66 | logger.error({ event: "error", error }); 67 | setErrors([humanizeError(error)]); 68 | }; 69 | 70 | useEffect(() => { 71 | if (peer) { 72 | peer.on("error", onPeerError); 73 | } 74 | return () => { 75 | if (peer) { 76 | peer.off("error", onPeerError); 77 | } 78 | }; 79 | // eslint-disable-next-line react-hooks/exhaustive-deps 80 | }, [peer]); 81 | 82 | useEffect(() => { 83 | if (ready) { 84 | setPeerId(peer.id); 85 | logger.log({ 86 | event: "open", 87 | comment: "Master connected", 88 | payload: { id: peer.id }, 89 | }); 90 | api.on("remote.connect", onRemoteConnect); 91 | api.on("remote.disconnect", onRemoteDisconnect); 92 | api.on("data", onData); 93 | } 94 | return () => { 95 | console.log("Master.jsx.cleanup"); 96 | if (ready) { 97 | api.off("remote.connect", onRemoteConnect); 98 | api.off("remote.disconnect", onRemoteDisconnect); 99 | api.off("data", onData); 100 | } 101 | }; 102 | // eslint-disable-next-line react-hooks/exhaustive-deps 103 | }, [ready]); 104 | return ( 105 | <> 106 | 107 | {peerId ? : null} 108 | 109 |

110 | Global counter: 111 |

112 | { 115 | if (ready) { 116 | api.sendAll({ 117 | type: "PING", 118 | date: new Date(), 119 | }); 120 | } 121 | }} 122 | onPing={(id) => { 123 | if (ready) { 124 | api.sendTo(id, { 125 | type: "PING", 126 | date: new Date(), 127 | }); 128 | } 129 | }} 130 | /> 131 | 132 | 133 | 134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /demo/counter-react/js/OpenRemote.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function OpenRemote({ peerId }) { 5 | return ( 6 |

7 | 👆Snap the the QR code or{" "} 8 | 17 | click here 18 | {" "} 19 | to open an{" "} 20 | other window from where you will control the counters on{" "} 21 | this page (like with a remote). 22 |

23 | ); 24 | } 25 | 26 | OpenRemote.propTypes = { 27 | peerId: PropTypes.string, 28 | }; 29 | -------------------------------------------------------------------------------- /demo/counter-react/js/Remote.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { usePeer } from "@webrtc-remote-control/react"; 3 | 4 | import ErrorsDisplay from "../../shared/js/components/ErrorsDisplay"; 5 | import RemoteCountControl from "./RemoteCountControl"; 6 | import RemoteNameControl from "./RemoteNameControl"; 7 | import ConsoleDisplay from "../../shared/js/components/ConsoleDisplay"; 8 | import DirectLinkToSourceCode from "./DirectLinkToSource"; 9 | 10 | import { useLogger, useSessionStorage } from "../../shared/js/react-common"; 11 | 12 | export default function Remote() { 13 | const { logs, logger } = useLogger([]); 14 | const [peerId, setPeerId] = useState(null); 15 | const [name, setName] = useSessionStorage("remote-name", ""); 16 | const [errors, setErrors] = useState(null); 17 | 18 | const { ready, api, peer, humanizeError } = usePeer(); 19 | 20 | const onRemoteDisconnect = (payload) => { 21 | logger.log({ event: "remote.disconnect", payload }); 22 | }; 23 | const onRemoteReconnect = (payload) => { 24 | logger.log({ event: "remote.reconnect", payload }); 25 | if (name) { 26 | api.send({ type: "REMOTE_SET_NAME", name }); 27 | } 28 | }; 29 | const onPeerError = (error) => { 30 | setPeerId(null); 31 | logger.error({ event: "error", error }); 32 | setErrors([humanizeError(error)]); 33 | }; 34 | const onData = (_, data) => { 35 | logger.log({ event: "data", data }); 36 | if (data.type === "PING") { 37 | window?.frameworkIconPlay(); 38 | } 39 | }; 40 | 41 | useEffect(() => { 42 | if (peer) { 43 | peer.on("error", onPeerError); 44 | } 45 | return () => { 46 | if (peer) { 47 | peer.off("error", onPeerError); 48 | } 49 | }; 50 | // eslint-disable-next-line react-hooks/exhaustive-deps 51 | }, [peer]); 52 | 53 | useEffect(() => { 54 | if (ready) { 55 | setPeerId(peer.id); 56 | logger.log({ 57 | event: "open", 58 | comment: "Remote connected", 59 | payload: { id: peer.id }, 60 | }); 61 | api.on("remote.disconnect", onRemoteDisconnect); 62 | api.on("remote.reconnect", onRemoteReconnect); 63 | api.on("data", onData); 64 | if (name) { 65 | api.send({ type: "REMOTE_SET_NAME", name }); 66 | } 67 | } 68 | return () => { 69 | console.log("Remote.jsx.cleanup"); 70 | if (ready) { 71 | api.off("remote.disconnect", onRemoteDisconnect); 72 | api.off("remote.reconnect", onRemoteReconnect); 73 | api.off("data", onData); 74 | } 75 | }; 76 | }, [ready]); 77 | 78 | function onIncrement() { 79 | if (ready) { 80 | api.send({ type: "COUNTER_INCREMENT" }); 81 | } 82 | } 83 | function onDecrement() { 84 | if (ready) { 85 | api.send({ type: "COUNTER_DECREMENT" }); 86 | } 87 | } 88 | function onChangeName(value) { 89 | setName(value); 90 | } 91 | function onConfirmName() { 92 | if (ready) { 93 | api.send({ type: "REMOTE_SET_NAME", name }); 94 | } 95 | } 96 | return ( 97 | <> 98 | 99 | 104 | 110 |

111 | Check the counter updating in real-time on the original page, thanks to 112 | WebRTC. 113 |

114 | 115 | 116 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /demo/counter-react/js/RemoteCountControl.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function RemoteCountControl({ 5 | onIncrement, 6 | onDecrement, 7 | disabled, 8 | }) { 9 | return ( 10 |
11 | 20 | 29 |
30 | ); 31 | } 32 | 33 | RemoteCountControl.propTypes = { 34 | onIncrement: PropTypes.func, 35 | onDecrement: PropTypes.func, 36 | disabled: PropTypes.bool, 37 | }; 38 | -------------------------------------------------------------------------------- /demo/counter-react/js/RemoteNameControl.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function RemoteNameControl({ 5 | onChangeName, 6 | onConfirmName, 7 | name, 8 | disabled, 9 | }) { 10 | return ( 11 |
{ 15 | e.preventDefault(); 16 | onConfirmName(e.target.name.value); 17 | }} 18 | disabled={disabled} 19 | > 20 | 32 |
33 | ); 34 | } 35 | 36 | RemoteNameControl.propTypes = { 37 | onChangeName: PropTypes.func, 38 | onConfirmName: PropTypes.func, 39 | name: PropTypes.string, 40 | disabled: PropTypes.bool, 41 | }; 42 | -------------------------------------------------------------------------------- /demo/counter-react/js/RemotesList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useCallback } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import "../../shared/js/components/remotes-list"; 5 | 6 | export default function RemotesList({ data, onPing, onPingAll }) { 7 | const ref = useRef(null); 8 | const onPingAllCallback = useCallback(() => { 9 | if (onPingAll) { 10 | onPingAll(); 11 | } 12 | }, [onPingAll]); 13 | const onPingCallback = useCallback( 14 | (e) => { 15 | if (onPing) { 16 | onPing(e.detail.id); 17 | } 18 | }, 19 | [onPing] 20 | ); 21 | useEffect(() => { 22 | // copy the ref to be able to cleanup the right one if it changed 23 | const refCurrent = ref?.current; 24 | if (refCurrent) { 25 | refCurrent.addEventListener("pingAll", onPingAllCallback); 26 | refCurrent.addEventListener("ping", onPingCallback); 27 | } 28 | return () => { 29 | if (ref) { 30 | refCurrent.removeEventListener("pingAll", onPingAllCallback); 31 | refCurrent.removeEventListener("ping", onPingCallback); 32 | } 33 | }; 34 | }, [onPingAllCallback, onPingCallback, ref]); 35 | return ; 36 | } 37 | 38 | RemotesList.propTypes = { 39 | data: PropTypes.arrayOf( 40 | PropTypes.exact({ 41 | counter: PropTypes.number, 42 | peerId: PropTypes.string, 43 | name: PropTypes.string, 44 | }) 45 | ), 46 | onPing: PropTypes.func, 47 | onPingAll: PropTypes.func, 48 | }; 49 | -------------------------------------------------------------------------------- /demo/counter-react/js/demo.react-counter.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import App from "./App"; 3 | import "../../shared/js/animate"; // todo 4 | 5 | const root = createRoot(document.getElementById("content")); 6 | root.render(); 7 | -------------------------------------------------------------------------------- /demo/counter-vanilla/js/__tests__/master.logic.test.js: -------------------------------------------------------------------------------- 1 | import { counterReducer } from "../../../shared/js/counter.master.logic"; 2 | 3 | function makeInitialState() { 4 | return [ 5 | { peerId: "foo", counter: 0 }, 6 | { peerId: "bar", counter: 0 }, 7 | { peerId: "baz", counter: 0 }, 8 | ]; 9 | } 10 | 11 | describe("master.logic", () => { 12 | describe("counterReducer", () => { 13 | it("should return default state if no action passed", () => { 14 | const result = counterReducer(makeInitialState(), {}); 15 | expect(result).toStrictEqual(result); 16 | }); 17 | it("should return new correct state with COUNTER_INCREMENT", () => { 18 | const result = counterReducer(makeInitialState(), { 19 | data: { 20 | type: "COUNTER_INCREMENT", 21 | }, 22 | id: "bar", 23 | }); 24 | expect(result).toStrictEqual([ 25 | { peerId: "foo", counter: 0 }, 26 | { peerId: "bar", counter: 1 }, 27 | { peerId: "baz", counter: 0 }, 28 | ]); 29 | }); 30 | it("should return new correct state with COUNTER_DECREMENT", () => { 31 | const result = counterReducer(makeInitialState(), { 32 | data: { 33 | type: "COUNTER_DECREMENT", 34 | }, 35 | id: "bar", 36 | }); 37 | expect(result).toStrictEqual([ 38 | { peerId: "foo", counter: 0 }, 39 | { peerId: "bar", counter: -1 }, 40 | { peerId: "baz", counter: 0 }, 41 | ]); 42 | }); 43 | it("should return new correct state with REMOTE_SET_NAME", () => { 44 | const result = counterReducer(makeInitialState(), { 45 | data: { 46 | type: "REMOTE_SET_NAME", 47 | name: "tophe", 48 | }, 49 | id: "bar", 50 | }); 51 | expect(result).toStrictEqual([ 52 | { peerId: "foo", counter: 0 }, 53 | { peerId: "bar", counter: 0, name: "tophe" }, 54 | { peerId: "baz", counter: 0 }, 55 | ]); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /demo/counter-vanilla/js/__tests__/master.persistance.test.js: -------------------------------------------------------------------------------- 1 | import { mockSessionStorage } from "../../../test.helpers"; 2 | import { 3 | persistCountersToStorage, 4 | getCountersFromStorage, 5 | } from "../../../shared/js/counter.master.persistance"; 6 | 7 | function makeState() { 8 | return [ 9 | { peerId: "foo", counter: 0 }, 10 | { peerId: "bar", counter: 1 }, 11 | { peerId: "baz", counter: 2 }, 12 | ]; 13 | } 14 | 15 | let sessionStorage = null; 16 | 17 | describe("master.persistance", () => { 18 | beforeAll(() => { 19 | sessionStorage = mockSessionStorage(); 20 | }); 21 | afterEach(() => { 22 | sessionStorage.clear(); 23 | }); 24 | it("should save an object in sessionStorage when an array is passed", () => { 25 | persistCountersToStorage(makeState()); 26 | expect(getCountersFromStorage()).toStrictEqual({ 27 | foo: 0, 28 | bar: 1, 29 | baz: 2, 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /demo/counter-vanilla/js/master.js: -------------------------------------------------------------------------------- 1 | import prepare, { prepareUtils } from "@webrtc-remote-control/core/master"; 2 | 3 | import { getPeerjsConfig } from "../../shared/js/common-peerjs"; 4 | import { makeLogger } from "../../shared/js/common"; 5 | import "../../shared/js/animate"; // todo 6 | import { 7 | persistCountersToStorage, 8 | getCountersFromStorage, 9 | } from "../../shared/js/counter.master.persistance"; 10 | import { 11 | counterReducer, 12 | globalCount, 13 | } from "../../shared/js/counter.master.logic"; 14 | import { render } from "./master.view"; 15 | 16 | async function init() { 17 | const { bindConnection, getPeerId, humanizeError } = prepare( 18 | prepareUtils({ sessionStorageKey: "webrtc-remote-control-peer-id-vanilla" }) 19 | ); 20 | 21 | const { 22 | showLoader, 23 | setPeerId, 24 | setRemotesList, 25 | setGlobalCounter, 26 | setErrors, 27 | setConsoleDisplay, 28 | } = render(); 29 | 30 | let counters = []; 31 | 32 | const logger = makeLogger({ onLog: setConsoleDisplay }); 33 | 34 | // create your own PeerJS connection 35 | const peer = new Peer( 36 | getPeerId(), 37 | // line bellow is optional - you can rely on the signaling server exposed by peerjs 38 | getPeerjsConfig() 39 | ); 40 | peer.on("open", (peerId) => { 41 | setPeerId(peerId); 42 | showLoader(false); 43 | logger.log({ 44 | event: "open", 45 | comment: "Master connected", 46 | payload: { id: peerId }, 47 | }); 48 | }); 49 | peer.on("error", (error) => { 50 | setPeerId(null); 51 | showLoader(false); 52 | logger.error({ event: "error", error }); 53 | setErrors([humanizeError(error)]); 54 | }); 55 | peer.on("disconnected", (id) => { 56 | setPeerId(null); 57 | showLoader(false); 58 | logger.error({ event: "disconnected", id }); 59 | }); 60 | 61 | // bind webrtc-remote-control to `peer` 62 | const wrcMaster = await bindConnection(peer); 63 | wrcMaster.on("remote.connect", ({ id }) => { 64 | const countersFromStorage = getCountersFromStorage(); 65 | logger.log({ event: "remote.connect", payload: { id } }); 66 | counters = [ 67 | ...counters, 68 | { counter: countersFromStorage?.[id] ?? 0, peerId: id }, 69 | ]; 70 | setRemotesList(counters); 71 | }); 72 | wrcMaster.on("remote.disconnect", ({ id }) => { 73 | logger.log({ event: "remote.disconnect", payload: { id } }); 74 | counters = counters.filter(({ peerId }) => peerId !== id); 75 | setRemotesList(counters); 76 | }); 77 | wrcMaster.on("data", ({ id }, data) => { 78 | logger.log({ event: "data", data, id }); 79 | counters = counterReducer(counters, { data, id }); 80 | setRemotesList(counters); 81 | persistCountersToStorage(counters); 82 | setGlobalCounter(globalCount(counters)); 83 | }); 84 | 85 | // bind "ping" buttons 86 | document.querySelector("remotes-list").addEventListener("pingAll", () => { 87 | if (wrcMaster) { 88 | wrcMaster.sendAll({ 89 | type: "PING", 90 | date: new Date(), 91 | }); 92 | } 93 | }); 94 | document.querySelector("remotes-list").addEventListener("ping", (e) => { 95 | if (wrcMaster) { 96 | wrcMaster.sendTo(e.detail.id, { 97 | type: "PING", 98 | date: new Date(), 99 | }); 100 | } 101 | }); 102 | } 103 | init(); 104 | -------------------------------------------------------------------------------- /demo/counter-vanilla/js/master.view.js: -------------------------------------------------------------------------------- 1 | import "../../shared/js/components/console-display"; 2 | // import "../../shared/js/components/counter-display"; 3 | import "../../shared/js/components/errors-display"; 4 | import "../../shared/js/components/footer-display"; 5 | import "../../shared/js/components/qrcode-display"; 6 | import "../../shared/js/components/remotes-list"; 7 | // import "../../shared/js/components/twitter-button"; 8 | 9 | function makeRemotePeerUrl(peerId) { 10 | return `${ 11 | window.location.origin + 12 | window.location.pathname 13 | .replace(/\/$/, "") 14 | .split("/") 15 | .slice(0, -1) 16 | .join("/") 17 | }/remote.html#${peerId}`; 18 | } 19 | 20 | export function render() { 21 | // create view based on