├── .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 | [](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 | [](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml)
4 | [](https://www.conventionalcommits.org)
5 | [](http://webrtc-remote-control.vercel.app/)
6 |
7 | Implementations
8 |
9 | | Package | Version |
10 | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
11 | | [@webrtc-remote-control/core](./packages/core#readme) | [](https://www.npmjs.com/package/@webrtc-remote-control/core) |
12 | | [@webrtc-remote-control/react](./packages/react#readme) | [](https://www.npmjs.com/package/@webrtc-remote-control/react) |
13 | | [@webrtc-remote-control/vue](./packages/vue#readme) | [](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 | [](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 |
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 | requestDeviceOrientationAccess()}
131 | >
132 | Click here to start
133 |
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 |
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 |
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 | onIncrement()}
15 | style={{ marginRight: "2px" }}
16 | disabled={disabled}
17 | >
18 | +
19 |
20 | onDecrement()}
24 | style={{ marginLeft: "2px" }}
25 | disabled={disabled}
26 | >
27 | -
28 |
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 |
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 tag content
22 | const templateNode = document.importNode(
23 | document.querySelector("template").content,
24 | true
25 | );
26 | const staticContent = document.querySelector(".static-content");
27 | const { content, ...view } = createView(templateNode, staticContent);
28 | document.querySelector("#content").innerHTML = "";
29 | document.querySelector("#content").appendChild(content);
30 | return view;
31 | }
32 |
33 | function createView(templateNode, staticContent) {
34 | let peerId = null;
35 | const content = document.createElement("div");
36 | content.appendChild(templateNode);
37 | content.querySelector(".static-content-wrapper").appendChild(staticContent);
38 | const loader = content.querySelector(".initial-loading");
39 | const remotesList = content.querySelector("remotes-list");
40 | const errorsDisplay = content.querySelector("errors-display");
41 | const globalCounter = content.querySelector("counter-display.global-counter");
42 | const qrcodeDisplay = content.querySelector("qrcode-display");
43 | const buttonOpenRemote = content.querySelector(".open-remote");
44 | const consoleDisplay = content.querySelector("console-display");
45 | const footerDisplay = content.querySelector("footer-display");
46 | footerDisplay.setAttribute("to", new Date().getFullYear());
47 | return {
48 | content,
49 | showLoader(display) {
50 | if (display) {
51 | loader.classList.remove("hide");
52 | } else {
53 | loader.classList.add("hide");
54 | }
55 | },
56 | setPeerId(id) {
57 | peerId = id;
58 | if (peerId) {
59 | qrcodeDisplay.setAttribute("data", makeRemotePeerUrl(peerId));
60 | buttonOpenRemote.setAttribute("href", makeRemotePeerUrl(peerId));
61 | buttonOpenRemote.removeAttribute("disabled");
62 | } else {
63 | qrcodeDisplay.removeAttribute("data");
64 | buttonOpenRemote.removeAttribute("href");
65 | buttonOpenRemote.setAttribute("disabled", "disabled");
66 | }
67 | },
68 | setRemotesList(data) {
69 | remotesList.data = data;
70 | },
71 | setGlobalCounter(count) {
72 | globalCounter.setAttribute("data", count);
73 | },
74 | setErrors(errors) {
75 | errorsDisplay.data = errors;
76 | },
77 | setConsoleDisplay(logs) {
78 | consoleDisplay.data = [...logs].reverse();
79 | },
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/demo/counter-vanilla/js/remote.js:
--------------------------------------------------------------------------------
1 | import prepare, { prepareUtils } from "@webrtc-remote-control/core/remote";
2 |
3 | import { getPeerjsConfig } from "../../shared/js/common-peerjs";
4 | import { makeLogger } from "../../shared/js/common";
5 | import "../../shared/js/animate"; // todo
6 | import { render } from "./remote.view";
7 |
8 | const REMOTE_NAME_LOCAL_STORAGE_KEY = "remote-name";
9 |
10 | export function getRemoteNameFromSessionStorage() {
11 | return sessionStorage.getItem(REMOTE_NAME_LOCAL_STORAGE_KEY) || "";
12 | }
13 |
14 | export function setRemoteNameToSessionStorage(remoteName) {
15 | sessionStorage.setItem(REMOTE_NAME_LOCAL_STORAGE_KEY, remoteName);
16 | }
17 |
18 | async function init() {
19 | const { bindConnection, getPeerId, humanizeError } = prepare(
20 | prepareUtils({ sessionStorageKey: "webrtc-remote-control-peer-id-vanilla" })
21 | );
22 |
23 | const initialName = getRemoteNameFromSessionStorage();
24 | const { showLoader, setConnected, setEvents, setConsoleDisplay, setErrors } =
25 | render({
26 | initialName,
27 | });
28 |
29 | const logger = makeLogger({ onLog: setConsoleDisplay });
30 |
31 | const masterPeerId = window.location.hash.replace(/^#/, "");
32 |
33 | // create your own PeerJS connection
34 | const peer = new Peer(
35 | getPeerId(),
36 | // line bellow is optional - you can rely on the signaling server exposed by peerjs
37 | getPeerjsConfig()
38 | );
39 | peer.on("open", (peerId) => {
40 | showLoader(false);
41 | setConnected(true);
42 | logger.log({
43 | event: "open",
44 | comment: "Remote connected",
45 | payload: { id: peerId },
46 | });
47 | });
48 | peer.on("error", (error) => {
49 | showLoader(false);
50 | setConnected(false);
51 | setErrors([humanizeError(error)]);
52 | logger.error({ event: "error", error });
53 | });
54 | peer.on("disconnected", (id) => {
55 | showLoader(false);
56 | setConnected(false);
57 | logger.error({ event: "disconnected", id });
58 | });
59 |
60 | // bind webrtc-remote-control to `peer`
61 | const wrcRemote = await bindConnection(peer, masterPeerId);
62 | wrcRemote.on("remote.disconnect", (payload) => {
63 | logger.log({ event: "remote.disconnect", payload });
64 | });
65 | wrcRemote.on("remote.reconnect", (payload) => {
66 | logger.log({ event: "remote.reconnect", payload });
67 | if (initialName) {
68 | wrcRemote.send({ type: "REMOTE_SET_NAME", name: initialName });
69 | }
70 | });
71 | wrcRemote.on("data", (_, data) => {
72 | logger.log({ event: "data", data });
73 | if (data.type === "PING") {
74 | window?.frameworkIconPlay();
75 | }
76 | });
77 | if (initialName) {
78 | wrcRemote.send({ type: "REMOTE_SET_NAME", name: initialName });
79 | }
80 | window.wrcRemote = wrcRemote;
81 | setEvents({
82 | onClickPlus() {
83 | wrcRemote.send({ type: "COUNTER_INCREMENT" });
84 | },
85 | onClickMinus() {
86 | wrcRemote.send({ type: "COUNTER_DECREMENT" });
87 | },
88 | onUpdateName(name) {
89 | wrcRemote.send({ type: "REMOTE_SET_NAME", name });
90 | setRemoteNameToSessionStorage(name);
91 | },
92 | });
93 | }
94 | init();
95 |
--------------------------------------------------------------------------------
/demo/counter-vanilla/js/remote.view.js:
--------------------------------------------------------------------------------
1 | import "../../shared/js/components/errors-display";
2 | import "../../shared/js/components/console-display";
3 | import "../../shared/js/components/footer-display";
4 |
5 | const defaultEvents = {
6 | onClickPlus() {},
7 | onClickMinus() {},
8 | onUpdateName() {},
9 | };
10 |
11 | export function render(initialState) {
12 | // create view based on tag content
13 | const templateNode = document.importNode(
14 | document.querySelector("template").content,
15 | true
16 | );
17 | const staticContent = document.querySelector(".static-content");
18 | const { content, ...view } = createView(
19 | templateNode,
20 | staticContent,
21 | initialState
22 | );
23 | document.querySelector("#content").innerHTML = "";
24 | document.querySelector("#content").appendChild(content);
25 | return view;
26 | }
27 |
28 | function createView(
29 | templateNode,
30 | staticContent,
31 | initialState = {
32 | initialName: "",
33 | }
34 | ) {
35 | let events = { ...defaultEvents };
36 | const content = document.createElement("div");
37 | content.appendChild(templateNode);
38 | content.querySelector(".static-content-wrapper").appendChild(staticContent);
39 | const loader = content.querySelector(".initial-loading");
40 | const errorsDisplay = content.querySelector("errors-display");
41 | const formInput = content.querySelector(".form-set-name input");
42 | const formButton = content.querySelector(".form-set-name button");
43 | const buttons = content.querySelectorAll(".counter-control button");
44 | formInput.value = initialState.initialName || "";
45 | const consoleDisplay = content.querySelector("console-display");
46 | const footerDisplay = content.querySelector("footer-display");
47 | footerDisplay.setAttribute("to", new Date().getFullYear());
48 | // event delegation
49 | content.addEventListener(
50 | "click",
51 | (e) => {
52 | if (e.target.classList.contains("counter-control-add")) {
53 | events.onClickPlus();
54 | return;
55 | }
56 | if (e.target.classList.contains("counter-control-sub")) {
57 | events.onClickMinus();
58 | }
59 | },
60 | false
61 | );
62 | content.addEventListener(
63 | "submit",
64 | (e) => {
65 | if (e.target.classList.contains("form-set-name")) {
66 | e.preventDefault();
67 | events.onUpdateName(e.target.querySelector("input").value);
68 | }
69 | },
70 | false
71 | );
72 | return {
73 | content,
74 | setEvents(passedEvents) {
75 | events = passedEvents;
76 | },
77 | showLoader(display) {
78 | if (display) {
79 | loader.classList.remove("hide");
80 | } else {
81 | loader.classList.add("hide");
82 | }
83 | },
84 | setErrors(errors) {
85 | errorsDisplay.data = errors;
86 | },
87 | setConsoleDisplay(logs) {
88 | consoleDisplay.data = [...logs].reverse();
89 | },
90 | setConnected(connected) {
91 | [...buttons, formInput, formButton].forEach((button) => {
92 | if (!connected) {
93 | button.setAttribute("disabled", "");
94 | } else {
95 | button.removeAttribute("disabled");
96 | }
97 | });
98 | },
99 | };
100 | // store.subscribe((state) => {
101 | // if (state.common.peerId || state.common.signalErrors.length > 0) {
102 | // loader.classList.add("hide");
103 | // }
104 | // [...buttons, formInput, formButton].forEach((button) => {
105 | // if (isDisconnected(state)) {
106 | // button.setAttribute("disabled", "");
107 | // } else {
108 | // button.removeAttribute("disabled");
109 | // }
110 | // });
111 | // errorsDisplay.data = [
112 | // isDisconnected(state) && isLocalIp(location.hostname)
113 | // ? `You're disconnected. You are running the app in development on a local ip (${location.hostname}) (without https ) which leads to disconnections.Use tools such as localtunnel.me or ngrock.io to test with mobile devices . Features such as WebRTC need https in production.`
114 | // : "",
115 | // ...state.common.signalErrors,
116 | // ].filter(Boolean);
117 | // consoleDisplay.data = [...state.logs].reverse();
118 | // });
119 | // return content;
120 | }
121 |
--------------------------------------------------------------------------------
/demo/counter-vanilla/master.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | webrtc-remote-control / demo / vanilla / counter
12 |
13 |
17 |
21 |
25 |
26 |
27 |
31 |
35 |
36 |
40 |
44 |
48 |
52 |
53 |
57 |
61 |
65 |
69 |
70 |
71 |
72 |
99 |
100 |
101 | Loading ...
102 |
103 |
104 |
105 | Direct link to source code
109 |
110 |
111 |
112 |
113 |
114 |
115 | Loading ...
116 |
117 |
118 |
119 |
120 | 👆Snap the the QR code or
121 | click here to open an
122 | other window from where you will control the counters
123 | on this page (like with a remote).
124 |
125 |
126 | Global counter:
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
--------------------------------------------------------------------------------
/demo/counter-vanilla/remote.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | webrtc-remote-control / demo / counter
12 |
13 |
14 |
15 |
42 |
43 |
44 | Loading ...
45 |
46 |
55 |
56 |
57 |
58 | Loading ...
59 |
60 |
61 |
62 | +
63 | -
64 |
65 |
72 |
73 | Check the counter updating in real-time on the original page, thanks to
74 | WebRTC.
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------
/demo/counter-vue/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | webrtc-remote-control / demo / vue / counter
13 |
14 |
18 |
22 |
26 |
27 |
28 |
32 |
36 |
37 |
41 |
45 |
49 |
53 |
54 |
58 |
62 |
66 |
70 |
71 |
72 |
73 |
100 |
101 |
102 | Loading ...
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
49 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/DirectLinkToSource.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Direct link to source code:
4 |
7 | {{ target }}.vue
8 |
9 | /
10 |
13 | App.vue
14 |
15 |
16 |
17 |
18 |
30 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/Master.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 | Global counter:
13 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
188 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/OpenRemote.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | 👆Snap the the QR code or
4 |
11 | click here
13 | to open an
14 | other window from where you will control the counters on
15 | this page (like with a remote).
16 |
17 |
18 |
19 |
26 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/Remote.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
15 |
16 | Check the counter updating in real-time on the original page, thanks to
17 | WebRTC.
18 |
19 |
20 |
21 |
22 |
23 |
24 |
140 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/RemoteCountControl.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 | +
11 |
12 |
19 | -
20 |
21 |
22 |
23 |
24 |
33 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/RemoteNameControl.vue:
--------------------------------------------------------------------------------
1 |
2 |
24 |
25 |
26 |
36 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/common.js:
--------------------------------------------------------------------------------
1 | import { ref, shallowRef } from "vue";
2 |
3 | import { makeLogger } from "../../shared/js/common";
4 |
5 | export function useLogger() {
6 | const loggerRef = shallowRef(makeLogger());
7 | const logs = ref([]);
8 | const logger = Object.fromEntries(
9 | ["log", "info", "warn", "error"].map((level) => [
10 | level,
11 | (msg) => {
12 | const fullLogs = loggerRef.value[level](msg);
13 | logs.value = fullLogs;
14 | },
15 | ])
16 | );
17 | return {
18 | logger,
19 | logs,
20 | };
21 | }
22 |
--------------------------------------------------------------------------------
/demo/counter-vue/js/demo.vue-counter.js:
--------------------------------------------------------------------------------
1 | import { createApp } from "vue";
2 | import App from "./App.vue";
3 | import "../../shared/js/animate"; // todo
4 |
5 | createApp(App).mount("#content");
6 |
--------------------------------------------------------------------------------
/demo/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/demo/index.js:
--------------------------------------------------------------------------------
1 | import "./shared/js/components/footer-display";
2 |
3 | function init() {
4 | document
5 | .querySelector("footer-display")
6 | .setAttribute("to", new Date().getFullYear());
7 | }
8 |
9 | init();
10 |
--------------------------------------------------------------------------------
/demo/jest-puppeteer.config.js:
--------------------------------------------------------------------------------
1 | const SLOW_MO = Number(process.env.SLOW_MO) || 250;
2 |
3 | module.exports = {
4 | launch: {
5 | dumpio: true,
6 | headless: process.env.HEADLESS !== "false",
7 | product: "chrome",
8 | slowMo: process.env.HEADLESS !== "false" ? undefined : SLOW_MO,
9 | },
10 | browserContext: "default",
11 | };
12 |
--------------------------------------------------------------------------------
/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@webrtc-remote-control/demo",
3 | "version": "0.2.2",
4 | "description": "",
5 | "private": true,
6 | "main": "index.js",
7 | "scripts": {
8 | "dev": "vite",
9 | "build": "vite build",
10 | "preview": "vite preview",
11 | "forward": "ssh -R 80:localhost:3000 localhost.run",
12 | "test": "jest",
13 | "test:precommit": "jest --bail --findRelatedTests",
14 | "test:watch": "jest --watch -o",
15 | "test:e2e": "jest --config ./jest.e2e.config.js",
16 | "test:e2e:watch": "jest --config ./jest.e2e.config.js --watch -o",
17 | "test:e2e:headless-false": "HEADLESS=false jest --config ./jest.e2e.config.js",
18 | "test:e2e:start-server-and-test": "PORT=3001 npx start-server-and-test preview :3001 test:e2e"
19 | },
20 | "author": "Christophe Rosset (http://labs.topheman.com/)",
21 | "license": "MIT",
22 | "devDependencies": {
23 | "@babel/preset-env": "^7.16.11",
24 | "@vitejs/plugin-react": "^1.2.0",
25 | "@vitejs/plugin-vue": "^2.2.4",
26 | "jest": "^27.5.1",
27 | "jest-cucumber": "^3.0.1",
28 | "jest-puppeteer": "^6.1.0",
29 | "puppeteer": "^13.4.0",
30 | "start-server-and-test": "^1.14.0",
31 | "vite": "^2.8.3"
32 | },
33 | "dependencies": {
34 | "@react-three/fiber": "^8.0.12",
35 | "@vueuse/core": "^8.0.1",
36 | "@webrtc-remote-control/core": "^0.1.3",
37 | "@webrtc-remote-control/react": "^0.1.3",
38 | "@webrtc-remote-control/vue": "^0.1.3",
39 | "lodash": "^4.17.21",
40 | "prop-types": "^15.8.1",
41 | "react": "^18.0.0",
42 | "react-dom": "^18.0.0",
43 | "three": "^0.139.2",
44 | "vue": "^3.2.31"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/demo/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/webrtc-remote-control/77b34900aaadc6aaa19933385f2baa10a57cdbd5/demo/public/favicon.ico
--------------------------------------------------------------------------------
/demo/public/images/WebRTC-Logo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/webrtc-remote-control/77b34900aaadc6aaa19933385f2baa10a57cdbd5/demo/public/images/WebRTC-Logo.jpeg
--------------------------------------------------------------------------------
/demo/shared/css/assets/github-retina.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/webrtc-remote-control/77b34900aaadc6aaa19933385f2baa10a57cdbd5/demo/shared/css/assets/github-retina.png
--------------------------------------------------------------------------------
/demo/shared/css/assets/javascript-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/demo/shared/css/assets/twitter-retina.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/webrtc-remote-control/77b34900aaadc6aaa19933385f2baa10a57cdbd5/demo/shared/css/assets/twitter-retina.png
--------------------------------------------------------------------------------
/demo/shared/css/assets/vue-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/demo/shared/css/counter-master.css:
--------------------------------------------------------------------------------
1 | .global-counter {
2 | color: #900000;
3 | font-weight: bold;
4 | font-size: 120%;
5 | }
6 | qrcode-display {
7 | display: block;
8 | width: 160px;
9 | margin: 0 auto;
10 | }
11 | .open-remote {
12 | cursor: pointer;
13 | text-decoration: underline;
14 | font-weight: bold;
15 | color: black;
16 | }
17 | .open-remote[disabled="disabled"] {
18 | cursor: not-allowed;
19 | color: rgb(175, 175, 175);
20 | }
21 |
--------------------------------------------------------------------------------
/demo/shared/css/counter-remote.css:
--------------------------------------------------------------------------------
1 | body {
2 | /* prevent zoom when double tap: https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action#Syntax */
3 | touch-action: manipulation;
4 | }
5 | .counter-control {
6 | text-align: center;
7 | }
8 | .counter-control button {
9 | width: 100px;
10 | height: 100px;
11 | font-size: 400%;
12 | padding: 0;
13 | color: #900000;
14 | border: 1px solid #900000;
15 | /* prevent user to trigger a selection (mostly on mobile) */
16 | -webkit-touch-callout: none;
17 | -webkit-user-select: none;
18 | -khtml-user-select: none;
19 | -moz-user-select: none;
20 | -ms-user-select: none;
21 | user-select: none;
22 | }
23 | .counter-control button:disabled {
24 | color: #ffc2c2;
25 | }
26 | console-display {
27 | display: block;
28 | margin-top: 20px;
29 | }
30 | .form-set-name label {
31 | margin-top: 15px;
32 | text-align: center;
33 | display: block;
34 | /* border: 1px solid red; */
35 | }
36 | .form-set-name label input,
37 | .form-set-name label button {
38 | border-radius: 0;
39 | font-size: 150%;
40 | display: inline-block;
41 | padding: 5px;
42 | margin: 0px;
43 | border: 1px solid #900000;
44 | }
45 | .form-set-name label input {
46 | vertical-align: top;
47 | width: 220px;
48 | }
49 | .form-set-name label input:disabled {
50 | color: #ffc2c2;
51 | }
52 | .form-set-name label button {
53 | color: white;
54 | background: #900000;
55 | }
56 | .form-set-name label button:disabled {
57 | color: #ffc2c2;
58 | }
59 |
--------------------------------------------------------------------------------
/demo/shared/css/counter.css:
--------------------------------------------------------------------------------
1 | /** Icon with animation **/
2 |
3 | .framework-icon-wrapper {
4 | position: relative;
5 | display: inline-block;
6 | }
7 |
8 | .framework-icon {
9 | position: absolute;
10 | height: var(--icon-height);
11 | width: var(--icon-width);
12 | top: calc(-1 * var(--icon-height));
13 | transition: all 1000ms;
14 | background-repeat: no-repeat;
15 | background-size: cover;
16 | }
17 |
18 | .framework-icon:hover {
19 | -webkit-transform: rotate(360deg);
20 | -moz-transform: rotate(360deg);
21 | -ms-transform: rotate(360deg);
22 | -o-transform: rotate(360deg);
23 | transform: rotate(360deg);
24 | transition: all 1000ms;
25 | }
26 |
27 | @media (max-width: 768px) {
28 | .framework-icon {
29 | top: calc(-0.6 * var(--icon-height));
30 | }
31 | }
32 |
33 | .framework-icon.animate {
34 | width: calc(5 * var(--icon-width));
35 | height: calc(5 * var(--icon-height));
36 | top: calc(-1 * var(--icon-height));
37 | transition: all 1000ms;
38 | -webkit-transform: rotate(360deg);
39 | -moz-transform: rotate(360deg);
40 | -ms-transform: rotate(360deg);
41 | -o-transform: rotate(360deg);
42 | transform: rotate(360deg);
43 | }
44 |
45 | .framework-icon.react {
46 | --icon-width: 32px;
47 | --icon-height: 28.5px;
48 | background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xMS41IC0xMC4yMzE3NCAyMyAyMC40NjM0OCI+CiAgPHRpdGxlPlJlYWN0IExvZ288L3RpdGxlPgogIDxjaXJjbGUgY3g9IjAiIGN5PSIwIiByPSIyLjA1IiBmaWxsPSIjNjFkYWZiIi8+CiAgPGcgc3Ryb2tlPSIjNjFkYWZiIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiPgogICAgPGVsbGlwc2Ugcng9IjExIiByeT0iNC4yIi8+CiAgICA8ZWxsaXBzZSByeD0iMTEiIHJ5PSI0LjIiIHRyYW5zZm9ybT0icm90YXRlKDYwKSIvPgogICAgPGVsbGlwc2Ugcng9IjExIiByeT0iNC4yIiB0cmFuc2Zvcm09InJvdGF0ZSgxMjApIi8+CiAgPC9nPgo8L3N2Zz4K);
49 | }
50 |
51 | .framework-icon.vue {
52 | --icon-width: 32px;
53 | --icon-height: 27.5px;
54 | background-image: url(assets/vue-logo.svg);
55 | }
56 |
57 | .framework-icon.vanilla {
58 | --icon-width: 32px;
59 | --icon-height: 32px;
60 | background-image: url(assets/javascript-logo.svg);
61 | }
62 |
--------------------------------------------------------------------------------
/demo/shared/css/main.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Arial, Helvetica, sans-serif;
3 | }
4 |
5 | h1,
6 | h2,
7 | h3,
8 | h4,
9 | h5,
10 | h6,
11 | a,
12 | strong {
13 | color: #900000;
14 | }
15 | button {
16 | -webkit-appearance: none;
17 | }
18 |
19 | .hide {
20 | display: none;
21 | }
22 |
23 | h1.title {
24 | max-width: 80%;
25 | }
26 |
27 | h1.title a {
28 | text-decoration: none;
29 | }
30 |
31 | h1.title a:hover {
32 | text-decoration: underline;
33 | }
34 |
35 | @media only screen and (max-width: 410px) {
36 | .title {
37 | font-size: 120%;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/demo/shared/css/network.css:
--------------------------------------------------------------------------------
1 | /** github logo from my other sites**/
2 | /** networks header */
3 | .site-networks {
4 | z-index: 20;
5 | position: absolute;
6 | right: 20px;
7 | top: 10px;
8 | margin: 0;
9 | padding: 0;
10 | }
11 |
12 | ul.site-networks {
13 | list-style: none;
14 | text-align: center;
15 | padding: 0px 0px 10px 0px;
16 | }
17 |
18 | ul.site-networks li {
19 | position: relative;
20 | display: inline-block;
21 | vertical-align: middle;
22 | margin-left: 15px;
23 | }
24 |
25 | ul.site-networks li a {
26 | display: block;
27 | width: 32px;
28 | height: 32px;
29 | text-decoration: none;
30 | padding-top: 0px;
31 | -webkit-transition: all 0.5s;
32 | -moz-transition: all 0.5s;
33 | -ms-transition: all 0.5s;
34 | -o-transition: all 0.5s;
35 | transition: all 0.5s;
36 | }
37 |
38 | ul.site-networks li a span.icon {
39 | position: absolute;
40 | display: block;
41 | width: 32px;
42 | height: 32px;
43 | -webkit-transition: all 0.5s;
44 | -moz-transition: all 0.5s;
45 | -ms-transition: all 0.5s;
46 | -o-transition: all 0.5s;
47 | transition: all 0.5s;
48 | }
49 |
50 | ul.site-networks li a span.desc {
51 | display: none;
52 | }
53 |
54 | ul.site-networks li a:hover span.icon {
55 | left: 0px;
56 | -webkit-transform: rotate(360deg);
57 | -moz-transform: rotate(360deg);
58 | -ms-transform: rotate(360deg);
59 | -o-transform: rotate(360deg);
60 | transform: rotate(360deg);
61 | }
62 |
63 | /** since logos are included with the css in base64, we don't bother about pixel ratio media query (everybody gets the retina version)*/
64 | ul.site-networks li.twitter a span.icon {
65 | background-image: url(./assets/twitter-retina.png);
66 | background-size: 32px 32px;
67 | }
68 |
69 | ul.site-networks li.github a span.icon {
70 | background-image: url(./assets/github-retina.png);
71 | background-size: 32px 32px;
72 | }
73 |
74 | @media only screen and (max-width: 700px) and (min-width: 370px) {
75 | ul.site-networks {
76 | /* right: 70px; */
77 | }
78 | ul.site-networks li {
79 | margin-left: 0px;
80 | }
81 | .site-title {
82 | font-size: 26px;
83 | }
84 | .content {
85 | padding: 0 10px;
86 | }
87 | img {
88 | max-width: 100%;
89 | }
90 | }
91 |
92 | .hide-site-networks .site-networks {
93 | display: none;
94 | }
95 |
--------------------------------------------------------------------------------
/demo/shared/js/animate.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This file has side-effects.
3 | * It exposes `window.frameworkIconPlay`
4 | */
5 |
6 | function sleep(ms = 0) {
7 | return new Promise((res) => {
8 | setTimeout(res, ms);
9 | });
10 | }
11 |
12 | function makeAnimate(elm, duration = 1500) {
13 | let timerId = null;
14 | return async function play() {
15 | function rewind() {
16 | clearTimeout(timerId);
17 | elm.classList.remove("animate");
18 | }
19 | // begin by rewinding the transition (whether it's started or not)
20 | if (elm.classList.contains("animate")) {
21 | rewind();
22 | await sleep(1000);
23 | }
24 | // start the transition
25 | elm.classList.add("animate");
26 | // rewind the transition after `duration` (rewindable meanwhile)
27 | timerId = setTimeout(() => {
28 | rewind();
29 | }, duration);
30 | return rewind;
31 | };
32 | }
33 |
34 | // eslint-disable-next-line no-unused-vars
35 | function init() {
36 | window.frameworkIconPlay = makeAnimate(
37 | document.querySelector(".framework-icon")
38 | );
39 | }
40 |
41 | init();
42 |
--------------------------------------------------------------------------------
/demo/shared/js/common-peerjs.js:
--------------------------------------------------------------------------------
1 | export function getPeerjsConfig() {
2 | // when using the local signaling server
3 | if (import.meta.env.VITE_USE_LOCAL_PEER_SERVER) {
4 | return {
5 | host: "localhost",
6 | port: 9000,
7 | path: "/myapp",
8 | };
9 | }
10 | // default case, we use the alternate server since on some mobile carriers (orange - France)
11 | // the default host 0.peerjs.com hangs on forever - see https://github.com/peers/peerjs/issues/948#issuecomment-1107437915
12 | // todo what if this fix triggers the same kind of problem on other carriers ? implement some kind of balancing ?
13 | return {
14 | host: "0.peerjs.com",
15 | port: 443,
16 | path: "/",
17 | };
18 | }
19 |
--------------------------------------------------------------------------------
/demo/shared/js/common.js:
--------------------------------------------------------------------------------
1 | // todo part of it should be in core (expose humanized error ?)
2 | export function humanizeErrors(errors = []) {
3 | const transform = [
4 | [
5 | /ID ".*" is taken/,
6 | "You may have this main page opened on an other tab, please close it",
7 | ],
8 | ];
9 | return errors.reduce((errorsList, currentError) => {
10 | const humanizedCurrentError = transform.reduce(
11 | (acc, [regExp, replaceError]) => {
12 | // eslint-disable-next-line no-param-reassign
13 | acc = currentError.replace(regExp, replaceError);
14 | return acc;
15 | },
16 | currentError
17 | );
18 | errorsList.push(humanizedCurrentError);
19 | return errorsList;
20 | }, []);
21 | }
22 |
23 | export function makeLogger({ onLog = () => {}, logs = [], size = 30 } = {}) {
24 | function makeLogFunction(type) {
25 | return function log(payload) {
26 | // eslint-disable-next-line no-param-reassign
27 | logs = logs.concat({
28 | payload,
29 | key: (logs.slice(-1)[0] || { key: 0 }).key + 1,
30 | level: type,
31 | });
32 | while (logs.length > size) {
33 | logs.shift();
34 | }
35 | // eslint-disable-next-line no-console
36 | console[type](payload);
37 | onLog(logs);
38 | return logs;
39 | };
40 | }
41 | return Object.fromEntries(
42 | ["log", "info", "warn", "error"].map((level) => [
43 | level,
44 | makeLogFunction(level),
45 | ])
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/demo/shared/js/common.test.js:
--------------------------------------------------------------------------------
1 | import { disableConsole } from "../../test.helpers";
2 |
3 | import { makeLogger } from "./common";
4 |
5 | describe("common", () => {
6 | describe("makeLogger", () => {
7 | it("should return log, info, warn, error methods", () => {
8 | const restoreConsole = disableConsole();
9 | const logger = makeLogger();
10 | expect(Object.keys(logger)).toStrictEqual([
11 | "log",
12 | "info",
13 | "warn",
14 | "error",
15 | ]);
16 | restoreConsole();
17 | });
18 | it("logging function should return an array of logs", () => {
19 | const restoreConsole = disableConsole();
20 | const logger = makeLogger();
21 | const a = logger.log("foo");
22 | const b = logger.log("bar");
23 | const c = logger.log("baz");
24 | expect(a).toHaveLength(1);
25 | expect(b).toHaveLength(2);
26 | expect(c).toHaveLength(3);
27 | restoreConsole();
28 | });
29 | it("logs should have key and level", () => {
30 | const restoreConsole = disableConsole();
31 | const logger = makeLogger();
32 | const a = logger.log("foo");
33 | const b = logger.warn("bar");
34 | expect(a).toHaveLength(1);
35 | expect(b).toHaveLength(2);
36 | expect(a[0]).toStrictEqual({ key: 1, level: "log", payload: "foo" });
37 | expect(b[1]).toStrictEqual({ key: 2, level: "warn", payload: "bar" });
38 | restoreConsole();
39 | });
40 | it("array of logs should be limited to max length and rotate", () => {
41 | const restoreConsole = disableConsole();
42 | const logger = makeLogger({ onLog: () => {}, logs: [], size: 3 });
43 | const a = logger.log("foo");
44 | const b = logger.warn("bar");
45 | const c = logger.log("baz");
46 | expect(a).toHaveLength(1);
47 | expect(b).toHaveLength(2);
48 | expect(c).toHaveLength(3);
49 | expect(c[0].payload).toBe("foo");
50 | expect(c[2].payload).toBe("baz");
51 |
52 | const d = logger.log("qux");
53 | expect(d).toHaveLength(3);
54 | expect(d[0].payload).toBe("bar");
55 | expect(d[2].payload).toBe("qux");
56 |
57 | const e = logger.log("quux");
58 | expect(e).toHaveLength(3);
59 | expect(e[0].payload).toBe("baz");
60 | expect(e[2].payload).toBe("quux");
61 |
62 | restoreConsole();
63 | });
64 | it("onLog callback should be called on log", () => {
65 | const restoreConsole = disableConsole();
66 | const onLog = jest.fn();
67 | const logger = makeLogger({ onLog });
68 | logger.log("foo");
69 | expect(onLog).toHaveBeenNthCalledWith(1, [
70 | { key: 1, level: "log", payload: "foo" },
71 | ]);
72 | restoreConsole();
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/demo/shared/js/components/ConsoleDisplay.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import "./console-display";
5 |
6 | export default function ConsoleDisplay({ data }) {
7 | return ;
8 | }
9 |
10 | ConsoleDisplay.propTypes = {
11 | data: PropTypes.arrayOf(
12 | PropTypes.exact({
13 | key: PropTypes.number,
14 | level: PropTypes.string,
15 | payload: PropTypes.object,
16 | })
17 | ),
18 | };
19 |
--------------------------------------------------------------------------------
/demo/shared/js/components/CounterDisplay.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import "./counter-display";
5 |
6 | export default function CounterDisplay({ count }) {
7 | return (
8 |
12 | );
13 | }
14 |
15 | CounterDisplay.propTypes = {
16 | count: PropTypes.number,
17 | };
18 |
--------------------------------------------------------------------------------
/demo/shared/js/components/ErrorsDisplay.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import "./errors-display";
5 |
6 | export default function ErrorsDisplay({ data }) {
7 | return ;
8 | }
9 |
10 | ErrorsDisplay.propTypes = {
11 | data: PropTypes.arrayOf(PropTypes.string),
12 | };
13 |
--------------------------------------------------------------------------------
/demo/shared/js/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import "./footer-display";
5 |
6 | export default function FooterDisplay({ from, to }) {
7 | return ;
8 | }
9 |
10 | FooterDisplay.propTypes = {
11 | from: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
12 | to: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
13 | };
14 |
--------------------------------------------------------------------------------
/demo/shared/js/components/QrcodeDisplay.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import "./qrcode-display";
5 |
6 | export default function QrcodeDisplay({ data }) {
7 | return (
8 |
13 | );
14 | }
15 |
16 | QrcodeDisplay.propTypes = {
17 | data: PropTypes.string,
18 | };
19 |
--------------------------------------------------------------------------------
/demo/shared/js/components/README.md:
--------------------------------------------------------------------------------
1 | # webrtc-remote-control
2 |
3 | ## Components
4 |
5 | The components in that folder were retrieved from my previous project: [webrtc-experiments](https://github.com/topheman/webrtc-experiments/tree/master/src/js/components).
6 |
--------------------------------------------------------------------------------
/demo/shared/js/components/console-display.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | class ConsoleDisplay extends HTMLElement {
3 | constructor() {
4 | super();
5 | const template = document.createElement("template");
6 | template.innerHTML = `
7 |
74 |
80 | `;
81 | const shadow = this.attachShadow({ mode: "open" });
82 | shadow.appendChild(template.content.cloneNode(true));
83 | shadow.querySelector(".header").addEventListener(
84 | "click",
85 | () => {
86 | const rootDivClassList = this.shadowRoot.querySelector("div").classList;
87 | rootDivClassList.toggle("slidedown");
88 | rootDivClassList.toggle("slideup");
89 | },
90 | false
91 | );
92 | this.render();
93 | }
94 |
95 | static get observedAttributes() {
96 | return ["data"];
97 | }
98 |
99 | /**
100 | * Accept a `data` attribute with a serialized object
101 | * `data` attribute is not kept in sync with `data` property
102 | * for performance reasons (to avoid large object serialization)
103 | */
104 |
105 | attributeChangedCallback(attrName, oldVal, newVal) {
106 | if (oldVal !== newVal) {
107 | if (attrName === "data") {
108 | try {
109 | const data = JSON.parse(newVal);
110 | this._data = data;
111 | } catch (e) {
112 | // eslint-disable-next-line no-console
113 | console.error(
114 | "Failed to parse `data` attribute in `errors-display` element",
115 | e
116 | );
117 | }
118 | }
119 | this.render();
120 | }
121 | }
122 |
123 | get data() {
124 | return this._data;
125 | }
126 |
127 | set data(newVal) {
128 | this._data = newVal;
129 | this.render();
130 | }
131 |
132 | render() {
133 | const ul = this.shadowRoot.querySelector("ul");
134 | const content = (this._data || [])
135 | .map((line) => {
136 | return `${
140 | typeof line.payload === "object"
141 | ? (() => {
142 | try {
143 | return JSON.stringify(line.payload);
144 | } catch (_) {
145 | return "";
146 | }
147 | })()
148 | : line.payload
149 | } `;
150 | })
151 | .join("");
152 | ul.innerHTML = content;
153 | }
154 | }
155 |
156 | customElements.define("console-display", ConsoleDisplay);
157 |
--------------------------------------------------------------------------------
/demo/shared/js/components/counter-display.js:
--------------------------------------------------------------------------------
1 | class CounterDisplay extends HTMLElement {
2 | constructor() {
3 | super();
4 | const shadow = this.attachShadow({ mode: "open" });
5 | const style = document.createElement("style");
6 | const span = document.createElement("span");
7 | style.textContent = `
8 | span {
9 | color: #900000;
10 | animation-name: counter-change;
11 | animation-duration: 0.5s;
12 | }
13 | @keyframes counter-change {
14 | 0% {color: #900000;}
15 | 50% {color: red;}
16 | 100% {color: #900000;}
17 | }
18 | `;
19 | shadow.appendChild(style);
20 | shadow.appendChild(span);
21 | this.render();
22 | }
23 |
24 | static get observedAttributes() {
25 | return ["data"];
26 | }
27 |
28 | attributeChangedCallback(attrName, oldVal, newVal) {
29 | if (oldVal !== newVal) {
30 | this.render();
31 | }
32 | }
33 |
34 | render() {
35 | this.shadowRoot.querySelector("span").innerHTML = this.getAttribute("data");
36 | }
37 | }
38 |
39 | customElements.define("counter-display", CounterDisplay);
40 |
--------------------------------------------------------------------------------
/demo/shared/js/components/errors-display.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import { humanizeErrors } from "../common";
3 |
4 | class ErrorsDisplay extends HTMLElement {
5 | constructor() {
6 | super();
7 | const shadow = this.attachShadow({ mode: "open" });
8 | const style = document.createElement("style");
9 | const ul = document.createElement("ul");
10 | ul.className = "alert alert-danger hide";
11 | style.textContent = `
12 | .hide {display:none;}
13 |
14 | ul {
15 | list-style: none;
16 | }
17 |
18 | .alert {
19 | position: relative;
20 | padding: 0.75rem 1.25rem;
21 | margin-bottom: 1rem;
22 | border: 1px solid transparent;
23 | border-radius: 0.25rem;
24 | }
25 |
26 | .alert-danger {
27 | color: #721c24;
28 | background-color: #f8d7da;
29 | border-color: #f5c6cb;
30 | }
31 | `;
32 | shadow.appendChild(style);
33 | shadow.appendChild(ul);
34 | this.render();
35 | }
36 |
37 | static get observedAttributes() {
38 | return ["data"];
39 | }
40 |
41 | /**
42 | * Accept a `data` attribute with a serialized object
43 | * `data` attribute is not kept in sync with `data` property
44 | * for performance reasons (to avoid large object serialization)
45 | */
46 |
47 | attributeChangedCallback(attrName, oldVal, newVal) {
48 | if (oldVal !== newVal) {
49 | if (attrName === "data") {
50 | try {
51 | const data = JSON.parse(newVal);
52 | this._data = data;
53 | } catch (e) {
54 | // eslint-disable-next-line no-console
55 | console.error(
56 | "Failed to parse `data` attribute in `errors-display` element",
57 | e
58 | );
59 | }
60 | }
61 | this.render();
62 | }
63 | }
64 |
65 | get data() {
66 | return this._data;
67 | }
68 |
69 | set data(newVal) {
70 | this._data = newVal;
71 | this.render();
72 | }
73 |
74 | render() {
75 | const ul = this.shadowRoot.querySelector("ul");
76 | let content;
77 | if (!this._data || this.data.length === 0) {
78 | ul.classList.add("hide");
79 | } else {
80 | const div = document.createElement("div"); // used for error message html sanitizing
81 | content = humanizeErrors(this.data)
82 | .map((message) => {
83 | if (message) {
84 | div.textContent = message;
85 | return div.textContent;
86 | }
87 | return undefined;
88 | })
89 | .filter(Boolean)
90 | .map((message) => {
91 | return `${message} `;
92 | })
93 | .join("");
94 | ul.innerHTML = content;
95 | ul.classList.remove("hide");
96 | }
97 | }
98 | }
99 |
100 | customElements.define("errors-display", ErrorsDisplay);
101 |
--------------------------------------------------------------------------------
/demo/shared/js/components/footer-display.js:
--------------------------------------------------------------------------------
1 | import "./twitter-button";
2 |
3 | class FooterDisplay extends HTMLElement {
4 | constructor() {
5 | super();
6 | const shadow = this.attachShadow({ mode: "open" });
7 | const style = document.createElement("style");
8 | const footer = document.createElement("footer");
9 | style.textContent = `
10 | footer {
11 | text-align: center;
12 | font-size: 85%;
13 | opacity: 0.8;
14 | }
15 | p {
16 | line-height: 1.5rem;
17 | }
18 | a {
19 | color: #900000;
20 | }
21 | `;
22 | shadow.appendChild(style);
23 | shadow.appendChild(footer);
24 | this.render();
25 | }
26 |
27 | static get observedAttributes() {
28 | return ["from", "to"];
29 | }
30 |
31 | attributeChangedCallback(attrName, oldVal, newVal) {
32 | if (oldVal !== newVal) {
33 | this.render();
34 | }
35 | }
36 |
37 | render() {
38 | const from = Number(this.getAttribute("from")) || 2019;
39 | const to = Number(this.getAttribute("to")) || 2019;
40 | const fromTo = from === to ? from : `${from}-${to}`;
41 | this.shadowRoot.querySelector("footer").innerHTML = `
42 |
43 | ©${fromTo} - labs.topheman.com - Christophe Rosset
44 |
45 |
46 |
47 |
48 | `;
49 | }
50 | }
51 |
52 | customElements.define("footer-display", FooterDisplay);
53 |
--------------------------------------------------------------------------------
/demo/shared/js/components/qrcode-display.js:
--------------------------------------------------------------------------------
1 | if (typeof QRCode === "undefined") {
2 | throw new Error(
3 | "Missing `QRCode` function, please include `qrcode.min.js` as script tags before from https://unpkg.com/qrcodejs@1.0.0/qrcode.min.js"
4 | );
5 | }
6 |
7 | class QRCodeDisplay extends HTMLElement {
8 | constructor() {
9 | super();
10 | const shadow = this.attachShadow({ mode: "open" });
11 | const style = document.createElement("style");
12 | const run = document.createElement("div"); // wraps the qrcode that will be shown
13 | run.className = "run";
14 | const build = document.createElement("div"); // wraps the div where the qrcode is built
15 | build.className = "build";
16 | style.textContent = `
17 | .build {
18 | display: none;
19 | }
20 | `;
21 | shadow.appendChild(style);
22 | shadow.appendChild(run);
23 | shadow.appendChild(build);
24 | this.render();
25 | }
26 |
27 | static get observedAttributes() {
28 | return ["data", "width", "height", "wrap-anchor"];
29 | }
30 |
31 | attributeChangedCallback(attrName, oldVal, newVal) {
32 | if (oldVal !== newVal) {
33 | this.render();
34 | }
35 | }
36 |
37 | render() {
38 | const data = this.getAttribute("data");
39 | let wrapAnchor = false;
40 | try {
41 | wrapAnchor = JSON.parse(this.getAttribute("wrap-anchor"));
42 | } catch (e) {
43 | // eslint-disable-next-line no-console
44 | console.warn(
45 | "Wrong `wrap-anchor` attribute passed to `qrcode-display` (only accepts `true` or `false`)"
46 | );
47 | wrapAnchor = false;
48 | }
49 | if (data) {
50 | this.shadowRoot.querySelector(".build").innerHTML = "";
51 | /* eslint-disable */
52 | new QRCode(this.shadowRoot.querySelector(".build"), {
53 | text: this.getAttribute("data"),
54 | width: parseInt(this.getAttribute("width")) || 200,
55 | height: parseInt(this.getAttribute("height")) || 200,
56 | colorDark: "#900000",
57 | });
58 | /* eslint-enable */
59 | const img = this.shadowRoot.querySelector(".build img");
60 | // 😢
61 | setTimeout(() => {
62 | img.style.display = "initial";
63 | }, 0);
64 | img.title = data;
65 | this.shadowRoot.querySelector(".run").innerHTML = "";
66 | if (wrapAnchor) {
67 | const a = document.createElement("a");
68 | a.href = data;
69 | a.title = data;
70 | a.appendChild(img);
71 | this.shadowRoot.querySelector(".run").appendChild(a);
72 | } else {
73 | this.shadowRoot.querySelector(".run").appendChild(img);
74 | }
75 | }
76 | }
77 | }
78 |
79 | customElements.define("qrcode-display", QRCodeDisplay);
80 |
--------------------------------------------------------------------------------
/demo/shared/js/components/remotes-list.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import "./counter-display";
3 |
4 | class RemotesList extends HTMLElement {
5 | constructor() {
6 | super();
7 | const shadow = this.attachShadow({ mode: "open" });
8 | const style = document.createElement("style");
9 | const div = document.createElement("div");
10 | style.textContent = `
11 | ul {
12 | list-style: none;
13 | padding-left: 0;
14 | }
15 | li::before {
16 | padding-left: 5px;
17 | content: "📱";
18 | }
19 | .remote-peerId {
20 | font-size: 80%;
21 | }
22 | .ping-button {
23 | border: 0;
24 | padding: 3px;
25 | vertical-align: bottom;
26 | cursor: pointer;
27 | }
28 | .ping-button::after {
29 | content: " 🔔"
30 | }
31 | counter-display {
32 | font-weight: bold;
33 | font-size: 120%;
34 | }
35 | `;
36 | shadow.appendChild(style);
37 | shadow.appendChild(div);
38 | this.render();
39 | }
40 |
41 | static get observedAttributes() {
42 | return ["data"];
43 | }
44 |
45 | /**
46 | * Accept a `data` attribute with a serialized object
47 | * `data` attribute is not kept in sync with `data` property
48 | * for performance reasons (to avoid large object serialization)
49 | */
50 |
51 | attributeChangedCallback(attrName, oldVal, newVal) {
52 | if (oldVal !== newVal) {
53 | if (attrName === "data") {
54 | try {
55 | const data = JSON.parse(newVal);
56 | this._data = data;
57 | } catch (e) {
58 | // eslint-disable-next-line no-console
59 | console.error(
60 | "Failed to parse `data` attribute in `remotes-list` element",
61 | e
62 | );
63 | }
64 | }
65 | this.render();
66 | }
67 | }
68 |
69 | get data() {
70 | return this._data;
71 | }
72 |
73 | set data(newVal) {
74 | this._data = newVal;
75 | this.render();
76 | }
77 |
78 | render() {
79 | let content;
80 | if (!this._data) {
81 | content = "No connected remotes
";
82 | } else {
83 | const div = document.createElement("div"); // used for remote.name sanitizing
84 | content = `${this._data.length} connected remote${
85 | this._data.length > 1 ? "s" : ""
86 | } PING ALL
${this.data
87 | .slice()
88 | .sort((a, b) => (a.peerId > b.peerId ? 1 : -1))
89 | .map((remote) => {
90 | if (remote.name) {
91 | div.textContent = remote.name;
92 | }
93 | return `${
94 | remote.peerId
95 | } counter: ${
98 | remote.name ? ` ${div.innerHTML}` : ""
99 | } PING `;
102 | })
103 | .join("")} `;
104 | }
105 | this.shadowRoot.querySelector("div").innerHTML = content;
106 | }
107 |
108 | /**
109 | * Triggered when the shadowRoot is append to the document
110 | *
111 | * Exposes two events:
112 | * - `pingAll`: ({detail: {all: true, id: null}})
113 | * - `ping`: ({detail: {all: false, id: "someid"}})
114 | */
115 | connectedCallback() {
116 | // event delegation - the webcomponent way
117 | this.shadowRoot.addEventListener("click", (e) => {
118 | for (const elm of e.composedPath()) {
119 | if (elm.classList?.contains("ping-button")) {
120 | if (elm.classList.contains("ping-all")) {
121 | this.dispatchEvent(
122 | new CustomEvent("pingAll", {
123 | detail: {
124 | all: true,
125 | id: null,
126 | },
127 | bubbles: true,
128 | composed: true,
129 | })
130 | );
131 | break;
132 | }
133 | if (elm.classList.contains("ping-one")) {
134 | this.dispatchEvent(
135 | new CustomEvent("ping", {
136 | detail: {
137 | all: false,
138 | id: elm.dataset.id,
139 | },
140 | bubbles: true,
141 | composed: true,
142 | })
143 | );
144 | break;
145 | }
146 | }
147 | }
148 | });
149 | }
150 | }
151 |
152 | customElements.define("remotes-list", RemotesList);
153 |
--------------------------------------------------------------------------------
/demo/shared/js/components/twitter-button.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Inspired by https://github.com/topheman/npm-registry-browser/blob/master/src/components/TwitterButton.js
3 | */
4 |
5 | const defaultAttributes = {
6 | size: "l",
7 | lang: "en",
8 | dnt: false,
9 | buttonTitle: "Twitter Tweet Button",
10 | text: null,
11 | url: null,
12 | hashtags: null,
13 | via: null,
14 | related: null,
15 | className: null,
16 | style: null,
17 | };
18 |
19 | class TwitterButton extends HTMLElement {
20 | constructor() {
21 | super();
22 | this.initDefaultValues();
23 | const template = document.createElement("template");
24 | template.innerHTML = `
25 |
30 |
31 | `;
32 | const shadow = this.attachShadow({ mode: "open" });
33 | shadow.appendChild(template.content.cloneNode(true));
34 | this.render();
35 | }
36 |
37 | initDefaultValues() {
38 | Object.entries(defaultAttributes).forEach(
39 | ([attributeName, defaultValue]) => {
40 | this[attributeName] = this[attributeName] || defaultValue;
41 | }
42 | );
43 | }
44 |
45 | static get observedAttributes() {
46 | return Object.keys(defaultAttributes);
47 | }
48 |
49 | attributeChangedCallback(attrName, oldVal, newVal) {
50 | if (oldVal !== newVal) {
51 | this.render();
52 | }
53 | }
54 |
55 | render() {
56 | const params = [
57 | `size=${this.size}`,
58 | "count=none",
59 | `dnt=${this.dnt}`,
60 | `lang=${this.lang}`,
61 | this.text != null && `text=${encodeURIComponent(this.text)}`,
62 | this.url != null && `url=${encodeURIComponent(this.url)}`,
63 | this.hashtags != null && `hashtags=${encodeURIComponent(this.hashtags)}`,
64 | this.via != null && `via=${encodeURIComponent(this.via)}`,
65 | this.related != null && `related=${encodeURIComponent(this.related)}`,
66 | ]
67 | .filter(Boolean)
68 | .join("&");
69 | const iframe = this.shadowRoot.querySelector("iframe");
70 | iframe.src = `https://platform.twitter.com/widgets/tweet_button.html?${params}`;
71 | iframe.title = this.buttonTitle;
72 | }
73 | }
74 |
75 | Object.keys(defaultAttributes).forEach((attributeName) => {
76 | Object.defineProperty(TwitterButton.prototype, attributeName, {
77 | get() {
78 | return this.getAttribute(attributeName);
79 | },
80 | set(value) {
81 | if (typeof value === "undefined" || value === null) {
82 | this.removeAttribute(attributeName);
83 | } else {
84 | this.setAttribute(attributeName, value);
85 | }
86 | },
87 | });
88 | });
89 |
90 | customElements.define("twitter-button", TwitterButton);
91 |
--------------------------------------------------------------------------------
/demo/shared/js/counter.master.logic.js:
--------------------------------------------------------------------------------
1 | export function counterReducer(state, { data, id }) {
2 | return state.reduce((acc, cur) => {
3 | if (cur.peerId === id) {
4 | switch (data.type) {
5 | case "COUNTER_INCREMENT":
6 | acc.push({
7 | ...cur,
8 | counter: cur.counter + 1,
9 | });
10 | break;
11 | case "COUNTER_DECREMENT":
12 | acc.push({
13 | ...cur,
14 | counter: cur.counter - 1,
15 | });
16 | break;
17 | case "REMOTE_SET_NAME":
18 | acc.push({
19 | ...cur,
20 | name: data.name,
21 | });
22 | break;
23 | default:
24 | acc.push(cur);
25 | break;
26 | }
27 | } else {
28 | acc.push(cur);
29 | }
30 | return acc;
31 | }, []);
32 | }
33 |
34 | export function globalCount(counters) {
35 | return counters.reduce((acc, { counter }) => counter + acc, 0);
36 | }
37 |
--------------------------------------------------------------------------------
/demo/shared/js/counter.master.persistance.js:
--------------------------------------------------------------------------------
1 | const MASTER_PERSISTANCE_COUNTERS_SESSION_STORAGE_KEY =
2 | "master-persist-counters";
3 |
4 | export function persistCountersToStorage(counters) {
5 | let payload;
6 | try {
7 | payload = JSON.stringify(
8 | counters.reduce((acc, cur) => {
9 | acc[cur.peerId] = cur.counter;
10 | return acc;
11 | }, {})
12 | );
13 | } catch {
14 | payload = JSON.stringify({});
15 | }
16 | sessionStorage.setItem(
17 | MASTER_PERSISTANCE_COUNTERS_SESSION_STORAGE_KEY,
18 | payload
19 | );
20 | }
21 |
22 | export function getCountersFromStorage() {
23 | try {
24 | return JSON.parse(
25 | sessionStorage.getItem(MASTER_PERSISTANCE_COUNTERS_SESSION_STORAGE_KEY)
26 | );
27 | } catch {
28 | return {};
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/demo/shared/js/react-common.js:
--------------------------------------------------------------------------------
1 | import { useState, useRef } from "react";
2 |
3 | import { makeLogger } from "./common";
4 |
5 | export function useLogger() {
6 | const loggerRef = useRef(makeLogger());
7 | const [logs, setLogs] = useState([]);
8 | const logger = Object.fromEntries(
9 | ["log", "info", "warn", "error"].map((level) => [
10 | level,
11 | (msg) => {
12 | const fullLogs = loggerRef.current[level](msg);
13 | setLogs(fullLogs);
14 | },
15 | ])
16 | );
17 | return {
18 | logger,
19 | logs,
20 | };
21 | }
22 |
23 | // inspired by https://usehooks.com/useLocalStorage/
24 | export function useSessionStorage(key, initialValue) {
25 | // State to store our value
26 | // Pass initial state function to useState so logic is only executed once
27 | const [storedValue, setStoredValue] = useState(() => {
28 | if (typeof window === "undefined") {
29 | return initialValue;
30 | }
31 | try {
32 | // Get from local storage by key
33 | const item = window.sessionStorage.getItem(key);
34 | // Parse stored json or if none return initialValue
35 | return item ? JSON.parse(item) : initialValue;
36 | } catch (error) {
37 | // If error also return initialValue
38 | console.log(error);
39 | return initialValue;
40 | }
41 | });
42 | // Return a wrapped version of useState's setter function that ...
43 | // ... persists the new value to sessionStorage.
44 | const setValue = (value) => {
45 | try {
46 | // Allow value to be a function so we have same API as useState
47 | const valueToStore =
48 | value instanceof Function ? value(storedValue) : value;
49 | // Save state
50 | setStoredValue(valueToStore);
51 | // Save to session storage
52 | if (typeof window !== "undefined") {
53 | window.sessionStorage.setItem(key, JSON.stringify(valueToStore));
54 | }
55 | } catch (error) {
56 | // A more advanced implementation would handle the error case
57 | console.log(error);
58 | }
59 | };
60 | return [storedValue, setValue];
61 | }
62 |
--------------------------------------------------------------------------------
/demo/shared/js/react-useDeviceOrientation.js:
--------------------------------------------------------------------------------
1 | // inspired by https://trekhleb.dev/blog/2021/gyro-web/
2 | import { useCallback, useEffect, useState } from "react";
3 | import lodashThrottle from "lodash/throttle";
4 |
5 | export const useDeviceOrientation = ({ precision, throttle = 0 } = {}) => {
6 | const [error, setError] = useState(null);
7 | const [orientation, setOrientation] = useState(null);
8 | const [permissionState, setPermissionState] = useState(null);
9 |
10 | const onDeviceOrientation = lodashThrottle((event) => {
11 | setOrientation({
12 | alpha: precision ? Number(event.alpha.toFixed(precision)) : event.alpha,
13 | beta: precision ? Number(event.beta.toFixed(precision)) : event.beta,
14 | gamma: precision ? Number(event.gamma.toFixed(precision)) : event.gamma,
15 | });
16 | }, throttle);
17 |
18 | const revokeAccessAsync = async () => {
19 | window.removeEventListener("deviceorientation", onDeviceOrientation);
20 | setOrientation(null);
21 | };
22 |
23 | const requestAccessAsync = async () => {
24 | if (typeof DeviceOrientationEvent === "undefined") {
25 | setError(
26 | new Error("Device orientation event is not supported by your browser")
27 | );
28 | return false;
29 | }
30 |
31 | if (
32 | DeviceOrientationEvent.requestPermission &&
33 | typeof DeviceMotionEvent.requestPermission === "function"
34 | ) {
35 | let permission;
36 | try {
37 | permission = await DeviceOrientationEvent.requestPermission();
38 | setPermissionState(permission);
39 | } catch (err) {
40 | setError(err);
41 | return false;
42 | }
43 | if (permission !== "granted") {
44 | setError(
45 | new Error("Request to access the device orientation was rejected")
46 | );
47 | return false;
48 | }
49 | }
50 |
51 | window.addEventListener("deviceorientation", onDeviceOrientation);
52 |
53 | return true;
54 | };
55 |
56 | const requestAccess = useCallback(requestAccessAsync, []);
57 | const revokeAccess = useCallback(revokeAccessAsync, []);
58 |
59 | useEffect(() => {
60 | return () => {
61 | revokeAccess();
62 | };
63 | }, [revokeAccess]);
64 |
65 | return {
66 | orientation,
67 | error,
68 | permissionState, // null/denied/granted
69 | requestAccess,
70 | revokeAccess,
71 | };
72 | };
73 |
--------------------------------------------------------------------------------
/demo/test.helpers.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | export function disableConsole(
3 | mockFunction = () => {},
4 | methodNames = ["error", "warn", "log", "info"]
5 | ) {
6 | const originalConsoleMethods = methodNames.map((methodName) => ({
7 | methodName,
8 | method: console[methodName],
9 | }));
10 | methodNames.forEach((methodName) => {
11 | console[methodName] = mockFunction;
12 | });
13 | return function restoreConsole() {
14 | originalConsoleMethods.forEach(({ methodName, method }) => {
15 | console[methodName] = method;
16 | });
17 | };
18 | }
19 |
20 | export function mockSessionStorage() {
21 | class SessionStorageMock {
22 | constructor() {
23 | this.store = {};
24 | }
25 |
26 | clear() {
27 | this.store = {};
28 | }
29 |
30 | getItem(key) {
31 | return this.store[key] || null;
32 | }
33 |
34 | setItem(key, value) {
35 | this.store[key] = String(value);
36 | }
37 |
38 | removeItem(key) {
39 | delete this.store[key];
40 | }
41 | }
42 |
43 | global.sessionStorage = new SessionStorageMock();
44 | return global.sessionStorage;
45 | }
46 |
47 | export function getE2eTestServerAddress() {
48 | return `http://localhost:${process.env.PORT || 3000}`;
49 | }
50 |
51 | export function sleep(ms = 0) {
52 | return new Promise((res) => {
53 | setTimeout(res, ms);
54 | });
55 | }
56 |
--------------------------------------------------------------------------------
/demo/vite.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | const { resolve } = require("path");
3 | const { defineConfig } = require("vite");
4 | const react = require("@vitejs/plugin-react");
5 | const vue = require("@vitejs/plugin-vue");
6 |
7 | module.exports = defineConfig({
8 | // necessary for production build with monorepo - vue will use the wrong instance
9 | // in the node_modules of @webrtc-remote-control/vue and throw errors about:
10 | // `provide() can only be used inside setup()` / `inject() can only be used inside setup()`
11 | // https://github.com/vitejs/vite/issues/7454
12 | resolve: {
13 | dedupe: ["vue"],
14 | },
15 | build: {
16 | rollupOptions: {
17 | // https://vitejs.dev/guide/build.html#multi-page-app
18 | input: {
19 | main: resolve(__dirname, "index.html"),
20 | counterVanillaMaster: resolve(__dirname, "counter-vanilla/master.html"),
21 | counterVanillaRemote: resolve(__dirname, "counter-vanilla/remote.html"),
22 | counterReact: resolve(__dirname, "counter-react/index.html"),
23 | counterVue: resolve(__dirname, "counter-vue/index.html"),
24 | "accelerometer-3d": resolve(__dirname, "accelerometer-3d/index.html"),
25 | },
26 | },
27 | },
28 | server: {
29 | host: "0.0.0.0",
30 | port: process.env.PORT || 3000,
31 | },
32 | preview: {
33 | port: process.env.PORT || 3000,
34 | },
35 | plugins: [
36 | react(),
37 | // https://vuejs.org/guide/extras/web-components.html#using-custom-elements-in-vue
38 | vue({
39 | template: {
40 | compilerOptions: {
41 | // treat all tags with a dash as custom elements
42 | isCustomElement: (tag) => tag.includes("-"),
43 | },
44 | },
45 | }),
46 | ],
47 | });
48 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "packages": ["packages/*", "demo"],
3 | "version": "independent"
4 | }
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "webrtc-remote-control",
3 | "version": "0.0.1",
4 | "description": "",
5 | "private": true,
6 | "main": "src/index.js",
7 | "scripts": {
8 | "bootstrap": "lerna bootstrap",
9 | "build": "npm run build:core && npm run build:react && npm run build:vue && npm run build:demo",
10 | "build:core": "lerna run --stream --scope @webrtc-remote-control/core build",
11 | "build:react": "lerna run --stream --scope @webrtc-remote-control/react build",
12 | "build:vue": "lerna run --stream --scope @webrtc-remote-control/vue build",
13 | "build:demo": "lerna run --stream --scope @webrtc-remote-control/demo build",
14 | "build:peer-server": "VITE_USE_LOCAL_PEER_SERVER=true npm run build",
15 | "dev": "lerna run --parallel --scope @webrtc-remote-control/core --scope @webrtc-remote-control/react --scope @webrtc-remote-control/vue --scope @webrtc-remote-control/demo dev",
16 | "dev:peer-server": "VITE_USE_LOCAL_PEER_SERVER=true npm-run-all --parallel dev peer-server",
17 | "dev:forward": "npm-run-all --parallel dev demo:forward",
18 | "dev:core": "lerna run --stream --scope @webrtc-remote-control/core dev",
19 | "dev:react": "lerna run --stream --scope @webrtc-remote-control/react dev",
20 | "dev:vue": "lerna run --stream --scope @webrtc-remote-control/vue dev",
21 | "dev:demo": "lerna run --stream --scope @webrtc-remote-control/demo dev",
22 | "demo:forward": "npm run --prefix demo forward",
23 | "diff": "lerna diff",
24 | "lint": "eslint --ext .js,.jsx .",
25 | "peer-server": "npx peerjs --port 9000 --key peerjs --path /myapp",
26 | "preview": "lerna run --stream --scope @webrtc-remote-control/demo preview",
27 | "preview:peer-server": "npm-run-all --parallel preview peer-server",
28 | "preview:forward": "npm-run-all --parallel preview demo:forward",
29 | "test": "npm run test:core && npm run test:demo",
30 | "test:core": "npm run --prefix packages/core test",
31 | "test:core:precommit": "npm run --prefix packages/core test:precommit",
32 | "test:core:watch": "npm run --prefix packages/core test:watch",
33 | "test:demo": "npm run --prefix demo test",
34 | "test:demo:precommit": "npm run --prefix demo test:precommit",
35 | "test:demo:watch": "npm run --prefix demo test:watch",
36 | "test:e2e": "npm run --prefix demo test:e2e",
37 | "test:e2e:watch": "npm run --prefix demo test:e2e:watch",
38 | "test:e2e:start-server-and-test": "npm run --prefix demo test:e2e:start-server-and-test",
39 | "test:e2e:start-server-and-test:peer-server": "npm-run-all --parallel --race peer-server test:e2e:start-server-and-test",
40 | "prepare": "husky install; npm run bootstrap",
41 | "check:dotenv": "dotenv -c -- node -e 'console.log(process.env.FOO_BAR)'",
42 | "lerna:diff": "lerna diff",
43 | "lerna:version": "HUSKY=0 lerna version --conventional-commits",
44 | "lerna:version:yes": "HUSKY=0 lerna version --conventional-commits --yes",
45 | "lerna:changed": "lerna changed",
46 | "lerna:publish": "HUSKY=0 lerna publish --conventional-commits",
47 | "lerna:publish:yes": "HUSKY=0 lerna publish --conventional-commits --yes",
48 | "clean:install": "find . -name \"node_modules\" -exec rm -rf '{}' +"
49 | },
50 | "keywords": [
51 | "webrtc",
52 | "remote"
53 | ],
54 | "author": "Christophe Rosset (http://labs.topheman.com/)",
55 | "license": "MIT",
56 | "devDependencies": {
57 | "@commitlint/cli": "^16.2.3",
58 | "@commitlint/config-conventional": "^16.2.1",
59 | "@types/jest": "^27.4.0",
60 | "eslint": "^8.6.0",
61 | "eslint-config-airbnb-base": "^15.0.0",
62 | "eslint-config-prettier": "^8.3.0",
63 | "eslint-plugin-import": "^2.25.4",
64 | "eslint-plugin-jsx-a11y": "^6.5.1",
65 | "eslint-plugin-prettier": "^4.0.0",
66 | "eslint-plugin-react": "^7.28.0",
67 | "eslint-plugin-react-hooks": "^4.3.0",
68 | "husky": "^7.0.4",
69 | "lerna": "^4.0.0",
70 | "lint-staged": "^12.1.7",
71 | "npm-run-all": "^4.1.5",
72 | "peer": "^0.6.1",
73 | "prettier": "2.5.1"
74 | },
75 | "lint-staged": {
76 | "*.{js,jsx}": [
77 | "eslint --cache --fix"
78 | ],
79 | "packages/core/**/*.js": [
80 | "npm run test:core:precommit"
81 | ],
82 | "demo/**/*.{js,jsx}": [
83 | "npm run test:demo:precommit"
84 | ],
85 | "*.{css,html,ts,tsx}": [
86 | "prettier --write"
87 | ]
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/packages/core/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | ## [0.1.3](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/core@0.1.2...@webrtc-remote-control/core@0.1.3) (2025-05-27)
7 |
8 | **Note:** Version bump only for package @webrtc-remote-control/core
9 |
10 |
11 |
12 |
13 |
14 | ## [0.1.2](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/core@0.1.1...@webrtc-remote-control/core@0.1.2) (2025-05-21)
15 |
16 |
17 | ### Bug Fixes
18 |
19 | * correct types for esm ([3e65e30](https://github.com/topheman/webrtc-remote-control/commit/3e65e3093988d1b8bb6523995d0ee96f9f705323))
20 | * missing shared folder in packages.json#files of core - failing types ([1276866](https://github.com/topheman/webrtc-remote-control/commit/12768665885a0897e5a3e2d0a83d42514280e673))
21 |
22 |
23 | ### Features
24 |
25 | * **peerjs:** upgrade peerjs on demos from 1.3.2 to 1.4.6 ([4b89d7a](https://github.com/topheman/webrtc-remote-control/commit/4b89d7ad7993a6b3bf7f31e034ed9b4ac19f3b74))
26 |
27 |
28 |
29 |
30 |
31 | ## [0.1.1](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/core@0.1.0...@webrtc-remote-control/core@0.1.1) (2022-06-13)
32 |
33 |
34 | ### Bug Fixes
35 |
36 | * **core:** missing types for core/shared ([8a91135](https://github.com/topheman/webrtc-remote-control/commit/8a91135ef6965dcf754851fd8ddd08f6095b6397))
37 |
38 |
39 |
40 |
41 |
42 | # [0.1.0](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/core@0.0.1...@webrtc-remote-control/core@0.1.0) (2022-04-16)
43 |
44 |
45 | ### Features
46 |
47 | * update homepage in package.json ([4038fd5](https://github.com/topheman/webrtc-remote-control/commit/4038fd51ac19f7285808de4ac8ad21eb7a461ab7))
48 |
--------------------------------------------------------------------------------
/packages/core/README.md:
--------------------------------------------------------------------------------
1 | # @webrtc-remote-control/core
2 |
3 | [](https://www.npmjs.com/package/@webrtc-remote-control/core)
4 | [](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml)
5 | [](http://webrtc-remote-control.vercel.app/)
6 |
7 | Imagine you could simply control a web page opened in a browser (master) from an other page in an other browser (remote), just like you would with a TV and a remote.
8 |
9 | webrtc-remote-control lets you do that (based on [PeerJS](https://peerjs.com)) and handles the disconnections / reconnections, providing a simple API.
10 |
11 | ## Installation
12 |
13 | ```sh
14 | npm install peerjs @webrtc-remote-control/core
15 | ```
16 |
17 | This package is the core one. Implementations for popular frameworks such as react or vue are available [here](https://github.com/topheman/webrtc-remote-control/tree/master/packages).
18 |
19 | ## Usage
20 |
21 | Add the peerjs library as a script tag in your html page. You'll have access to `Peer` constructor.
22 |
23 | ```html
24 |
25 | ```
26 |
27 | Direct link to the [demo](https://webrtc-remote-control.vercel.app/counter-vanilla/master.html) source code: [master.js](https://github.com/topheman/webrtc-remote-control/blob/master/demo/counter-vanilla/js/master.js) / [remote.js](https://github.com/topheman/webrtc-remote-control/blob/master/demo/counter-vanilla/js/remote.js)
28 |
29 | ### master
30 |
31 | ```js
32 | import prepare, { prepareUtils } from "@webrtc-remote-control/core/master";
33 |
34 | async function init() {
35 | const { bindConnection, getPeerId, humanizeError } = prepare(prepareUtils());
36 | const peer = new Peer(getPeerId());
37 | peer.on("open", (peerId) => {
38 | // do something with this master peerId - create some url to open the browser based on it
39 | });
40 |
41 | const api = await bindConnection(peer);
42 | api.on("remote.connect", ({ id }) => {
43 | console.log(`Yay, remote ${id} just connected to master!`);
44 | });
45 | api.on("remote.disconnect", ({ id }) => {
46 | console.log(`Boo, remote ${id} just disconnected from master!`);
47 | });
48 | api.on("data", ({ id }, data) => {
49 | console.log(`Remote ${id} just sent the message`, data);
50 | });
51 |
52 | // send some data to the remotes
53 | api.sendAll({ msg: "Hello world to all remotes" });
54 | // api.sendTo(remoteId, { msg: "Hello world to a specific remote" });
55 | }
56 | ```
57 |
58 | ### remote
59 |
60 | ```js
61 | import prepare, { prepareUtils } from "@webrtc-remote-control/core/remote";
62 |
63 | async function init() {
64 | const { bindConnection, getPeerId, humanizeError } = prepare(prepareUtils());
65 | const peer = new Peer(getPeerId());
66 |
67 | // connect to master with `masterPeerId` (passed via QRCode, url, email ...)
68 | const api = await bindConnection(peer, masterPeerId);
69 | api.on("remote.disconnect", ({ id }) => {
70 | console.log(`Boo, remote ${id} just disconnected from master!`);
71 | });
72 | api.on("remote.reconnect", ({ id }) => {
73 | console.log(`Yay, remote ${id} just reconnected to master!`);
74 | });
75 | api.on("data", (_, data) => {
76 | console.log("Master just sent this message", data);
77 | });
78 |
79 | // send some data
80 | api.send({ msg: "Hello master page" });
81 | }
82 | ```
83 |
84 | ## TypeScript
85 |
86 | TypeScript types are shipped with the package.
87 |
88 | ## UMD build
89 |
90 | Don't want to use a bundler ? You can simply use the UMD (Universal Module Definition) build and drop it with a script tag from your favorite js cdn, you'll have access to a `webrtcRemoteControl` object on the `window`.
91 |
92 | - Development build: [https://unpkg.com/@webrtc-remote-control/core/dist/webrtc-remote-control.umd.dev.js](https://unpkg.com/@webrtc-remote-control/core/dist/webrtc-remote-control.umd.dev.js)
93 | - Production build: [https://unpkg.com/@webrtc-remote-control/core/dist/webrtc-remote-control.umd.prod.js](https://unpkg.com/@webrtc-remote-control/core/dist/webrtc-remote-control.umd.prod.js)
94 |
--------------------------------------------------------------------------------
/packages/core/babel.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [["@babel/preset-env", { targets: { node: "current" } }]],
3 | };
4 |
--------------------------------------------------------------------------------
/packages/core/master/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "core-master",
3 | "amdName": "webrtcRemoteControlMaster",
4 | "version": "0.0.0",
5 | "private": true,
6 | "description": "",
7 | "main": "./dist/master.js",
8 | "module": "./dist/master.module.js",
9 | "umd:main": "./dist/master.umd.js",
10 | "exports": "./dist/master.modern.js",
11 | "source": "src/core.master.js",
12 | "author": "Christophe Rosset (http://labs.topheman.com/)",
13 | "types": "src/core.master.d.ts",
14 | "license": "MIT"
15 | }
16 |
--------------------------------------------------------------------------------
/packages/core/master/src/core.master.d.ts:
--------------------------------------------------------------------------------
1 | import EventEmitter from "eventemitter3";
2 |
3 | import {
4 | HumanizeErrorType,
5 | IsConnectionFromRemoteType,
6 | GetPeerIdType,
7 | SetPeerIdToSessionStorageType,
8 | } from "../../shared/common";
9 |
10 | export { prepareUtils } from "../../shared/common";
11 |
12 | export default function ({
13 | humanizeError,
14 | isConnectionFromRemote,
15 | getPeerId,
16 | setPeerIdToSessionStorage,
17 | }: {
18 | humanizeError: HumanizeErrorType;
19 | isConnectionFromRemote: IsConnectionFromRemoteType;
20 | getPeerId: GetPeerIdType;
21 | setPeerIdToSessionStorage: SetPeerIdToSessionStorageType;
22 | }): {
23 | humanizeError: HumanizeErrorType;
24 | getPeerId: GetPeerIdType;
25 | bindConnection(peer: any): Promise<{
26 | sendTo(id: string, payload: any): any;
27 | sendAll(payload: any): any;
28 | on: InstanceType["on"];
29 | off: InstanceType["off"];
30 | }>;
31 | };
32 |
--------------------------------------------------------------------------------
/packages/core/master/src/core.master.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-relative-packages,import/no-extraneous-dependencies */
2 | import EventEmitter from "eventemitter3";
3 |
4 | export { prepareUtils } from "../../shared/common";
5 |
6 | export default function prepare({
7 | humanizeError,
8 | isConnectionFromRemote,
9 | getPeerId,
10 | setPeerIdToSessionStorage,
11 | }) {
12 | return {
13 | humanizeError,
14 | isConnectionFromRemote,
15 | getPeerId,
16 | bindConnection(peer) {
17 | return new Promise((res) => {
18 | const ee = new EventEmitter();
19 | const connections = new Map();
20 | const wrcMaster = {
21 | sendTo(id, payload) {
22 | const conn = connections.get(id);
23 | if (conn) {
24 | return conn.send(payload);
25 | }
26 | return null;
27 | },
28 | sendAll(payload) {
29 | [...connections.values()].forEach((conn) => {
30 | conn.send(payload);
31 | });
32 | },
33 | on: ee.on.bind(ee),
34 | off: ee.off.bind(ee),
35 | };
36 | peer.on("open", (peerId) => {
37 | setPeerIdToSessionStorage(peerId);
38 | res(wrcMaster);
39 | });
40 | peer.on("connection", (conn) => {
41 | // we don't track the connections made by the user directly using `peer.connect`
42 | if (!isConnectionFromRemote(conn)) {
43 | return;
44 | }
45 | // if this is a reconnect from the same peer, replace connection with the latest one
46 | connections.set(conn.peer, conn);
47 | conn.on("open", () => {
48 | ee.emit("remote.connect", { id: conn.peer });
49 | console.log("connections", connections);
50 | });
51 | conn.on("data", (data) => {
52 | ee.emit("data", { id: conn.peer, from: "remote" }, data);
53 | });
54 | conn.on("close", () => {
55 | connections.delete(conn.peer);
56 | ee.emit("remote.disconnect", { id: conn.peer });
57 | console.log("connections", connections);
58 | });
59 | });
60 | });
61 | },
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@webrtc-remote-control/core",
3 | "amdName": "webrtcRemoteControl",
4 | "version": "0.1.3",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/topheman/webrtc-remote-control.git"
8 | },
9 | "homepage": "http://webrtc-remote-control.vercel.app/",
10 | "bugs": "https://github.com/topheman/webrtc-remote-control/issues",
11 | "description": "Thin abstraction layer above peerjs that will let you be more productive at making WebRTC data channels based apps.",
12 | "keywords": [
13 | "WebRTC",
14 | "peerjs",
15 | "RTCDataChannel"
16 | ],
17 | "type": "module",
18 | "main": "./dist/index.cjs",
19 | "module": "./dist/index.module.js",
20 | "unpkg": "./dist/index.umd.js",
21 | "source": "./src/core.index.js",
22 | "exports": {
23 | ".": {
24 | "import": "./dist/index.modern.js",
25 | "types": "./src/core.index.d.ts"
26 | },
27 | "./master": {
28 | "import": "./master/dist/master.modern.js",
29 | "types": "./master/src/core.master.d.ts"
30 | },
31 | "./remote": {
32 | "import": "./remote/dist/remote.modern.js",
33 | "types": "./remote/src/core.remote.d.ts"
34 | }
35 | },
36 | "scripts": {
37 | "build": "npm-run-all --parallel build:*",
38 | "build:modules": "microbundle build --generateTypes false --raw",
39 | "build:umd-dev": "microbundle build --generateTypes false --raw --external none --define process.env.NODE_ENV='development' --no-pkg-main -o dist/webrtc-remote-control.umd.dev.js -f umd --no-compress",
40 | "build:umd-prod": "microbundle build --generateTypes false --raw --external none --define process.env.NODE_ENV='production' --no-pkg-main -o dist/webrtc-remote-control.umd.prod.js -f umd",
41 | "build:master": "microbundle build --generateTypes false --raw --cwd master",
42 | "build:remote": "microbundle build --generateTypes false --raw --cwd remote",
43 | "dev": "npm-run-all --parallel dev:*",
44 | "dev:core": "microbundle watch --generateTypes false --raw --no-compress",
45 | "dev:master": "microbundle watch --generateTypes false --raw --no-compress --cwd master",
46 | "dev:remote": "microbundle watch --generateTypes false --raw --no-compress --cwd remote",
47 | "test": "jest",
48 | "test:precommit": "jest --bail --findRelatedTests",
49 | "test:watch": "jest --watch -o"
50 | },
51 | "author": "Christophe Rosset (http://labs.topheman.com/)",
52 | "types": "src/core.index.d.ts",
53 | "license": "MIT",
54 | "files": [
55 | "src",
56 | "dist",
57 | "master",
58 | "master/dist",
59 | "remote",
60 | "remote/dist",
61 | "shared"
62 | ],
63 | "devDependencies": {
64 | "jest": "^27.5.1",
65 | "microbundle": "^0.14.2",
66 | "npm-run-all": "^4.1.5"
67 | },
68 | "peerDependencies": {
69 | "peerjs": "^1.3.2"
70 | },
71 | "dependencies": {
72 | "eventemitter3": "^4.0.7"
73 | },
74 | "publishConfig": {
75 | "access": "public"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/packages/core/remote/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "core-remote",
3 | "amdName": "webrtcRemoteControlRemote",
4 | "version": "0.0.0",
5 | "private": true,
6 | "description": "",
7 | "main": "./dist/remote.js",
8 | "module": "./dist/remote.module.js",
9 | "umd:main": "./dist/remote.umd.js",
10 | "exports": "./dist/remote.modern.js",
11 | "source": "src/core.remote.js",
12 | "author": "Christophe Rosset (http://labs.topheman.com/)",
13 | "types": "src/core.remote.d.ts",
14 | "license": "MIT"
15 | }
16 |
--------------------------------------------------------------------------------
/packages/core/remote/src/core.remote.d.ts:
--------------------------------------------------------------------------------
1 | import EventEmitter from "eventemitter3";
2 |
3 | import {
4 | HumanizeErrorType,
5 | GetPeerIdType,
6 | SetPeerIdToSessionStorageType,
7 | } from "../../shared/common";
8 |
9 | export { prepareUtils } from "../../shared/common";
10 |
11 | export default function ({
12 | humanizeError,
13 | getPeerId,
14 | setPeerIdToSessionStorage,
15 | }: {
16 | humanizeError: HumanizeErrorType;
17 | getPeerId: GetPeerIdType;
18 | setPeerIdToSessionStorage: SetPeerIdToSessionStorageType;
19 | }): {
20 | humanizeError: HumanizeErrorType;
21 | getPeerId: GetPeerIdType;
22 | bindConnection(peer: any): Promise<{
23 | send(payload: any): any;
24 | on: InstanceType["on"];
25 | off: InstanceType["off"];
26 | }>;
27 | };
28 |
--------------------------------------------------------------------------------
/packages/core/remote/src/core.remote.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-relative-packages,import/no-extraneous-dependencies */
2 | import EventEmitter from "eventemitter3";
3 |
4 | import { makeConnectionFilterUtilities } from "../../shared/common";
5 |
6 | export { prepareUtils } from "../../shared/common";
7 |
8 | function makePeerConnection(peer, masterPeerId, ee, onConnectionOpened) {
9 | const { connMetadata } = makeConnectionFilterUtilities();
10 | // to ensure connections with iOs, must use json serialization
11 | const conn = peer.connect(masterPeerId, {
12 | serialization: "json",
13 | metadata: connMetadata, // will let us identify which connections are managed by the package / by the user
14 | });
15 | conn.on("open", () => {
16 | if (typeof onConnectionOpened === "function") {
17 | onConnectionOpened();
18 | }
19 | });
20 | conn.on("data", (data) => {
21 | ee.emit("data", { from: "master" }, data);
22 | });
23 | return conn;
24 | }
25 |
26 | export default function prepare({
27 | humanizeError,
28 | getPeerId,
29 | setPeerIdToSessionStorage,
30 | }) {
31 | return {
32 | humanizeError,
33 | getPeerId,
34 | bindConnection(peer, masterPeerId) {
35 | return new Promise((res) => {
36 | let conn = null;
37 | const ee = new EventEmitter();
38 | const wrcRemote = {
39 | send(payload) {
40 | if (conn) {
41 | conn.send(payload);
42 | } else {
43 | // eslint-disable-next-line no-console
44 | console.warning("You called `send` with no connection");
45 | }
46 | },
47 | on: ee.on.bind(ee),
48 | off: ee.off.bind(ee),
49 | };
50 | const createPeerConnectionWithReconnectOnClose = (
51 | onConnectionOpened
52 | ) => {
53 | conn = null;
54 | conn = makePeerConnection(peer, masterPeerId, ee, onConnectionOpened);
55 | conn.on("close", () => {
56 | ee.emit("remote.disconnect", { id: peer.id });
57 | createPeerConnectionWithReconnectOnClose(() => {
58 | ee.emit("remote.reconnect", { id: peer.id });
59 | });
60 | });
61 | };
62 | peer.on("open", (peerId) => {
63 | setPeerIdToSessionStorage(peerId);
64 | createPeerConnectionWithReconnectOnClose(() => res(wrcRemote));
65 | conn.on("error", (e) => {
66 | // todo emit some error ? same on master ?
67 | console.log("conn.error", e);
68 | });
69 | // ensure to disconnect remote when the page is closed
70 | const onBeforeUnloadPeerDisconnect = () => {
71 | if (conn && conn.disconnect) {
72 | conn.disconnect();
73 | }
74 | };
75 | window.addEventListener("beforeunload", onBeforeUnloadPeerDisconnect);
76 | });
77 | });
78 | },
79 | };
80 | }
81 |
--------------------------------------------------------------------------------
/packages/core/shared/common.d.ts:
--------------------------------------------------------------------------------
1 | export function makeStoreAccessor(sessionStorageKey?: string): {
2 | getPeerId(): string;
3 | setPeerIdToSessionStorage(peerId: string): void;
4 | };
5 |
6 | export function makeConnectionFilterUtilities(): {
7 | isConnectionFromRemote(conn): boolean;
8 | connMetadata: string;
9 | };
10 |
11 | type HumanErrorsMapping = Record & {
12 | default: (error: { type: string }) => string;
13 | };
14 |
15 | export function makeHumanizeError(options?: {
16 | mapping?: HumanErrorsMapping;
17 | withTechicalErrorMessage?: boolean;
18 | }): (error: { type: string }) => string;
19 |
20 | export function prepareUtils({
21 | sessionStorageKey,
22 | humanErrors,
23 | }?: {
24 | sessionStorageKey: string;
25 | humanErrors: HumanErrorsMapping;
26 | }): {
27 | humanizeError: ReturnType;
28 | isConnectionFromRemote: ReturnType<
29 | typeof makeConnectionFilterUtilities
30 | >["isConnectionFromRemote"];
31 | getPeerId: ReturnType["getPeerId"];
32 | setPeerIdToSessionStorage: ReturnType<
33 | typeof makeStoreAccessor
34 | >["setPeerIdToSessionStorage"];
35 | };
36 |
37 | export type HumanizeErrorType = ReturnType<
38 | typeof prepareUtils
39 | >["humanizeError"];
40 | export type IsConnectionFromRemoteType = ReturnType<
41 | typeof prepareUtils
42 | >["isConnectionFromRemote"];
43 | export type GetPeerIdType = ReturnType["getPeerId"];
44 | export type SetPeerIdToSessionStorageType = ReturnType<
45 | typeof prepareUtils
46 | >["setPeerIdToSessionStorage"];
47 |
--------------------------------------------------------------------------------
/packages/core/shared/common.js:
--------------------------------------------------------------------------------
1 | export function makeStoreAccessor(
2 | sessionStorageKey = "webrtc-remote-control-peer-id"
3 | ) {
4 | return {
5 | getPeerId() {
6 | return sessionStorage.getItem(sessionStorageKey);
7 | },
8 | setPeerIdToSessionStorage(peerId) {
9 | sessionStorage.setItem(sessionStorageKey, peerId);
10 | },
11 | };
12 | }
13 |
14 | export function makeConnectionFilterUtilities() {
15 | const connMetadata = "from-webrtc-remote-control";
16 | return {
17 | isConnectionFromRemote(conn) {
18 | return conn.metadata === connMetadata;
19 | },
20 | connMetadata,
21 | };
22 | }
23 |
24 | /**
25 | * Pass mapping of error.type / message
26 | * You can pass `default` key a function that accepts an `error` and returns a string
27 | */
28 | export function makeHumanizeError(
29 | { mapping: overrideMapping, withTechicalErrorMessage } = {
30 | mapping: {},
31 | withTechicalErrorMessage: true,
32 | }
33 | ) {
34 | const mapping = {
35 | "browser-incompatible":
36 | "Your browser doesn't support WebRTC features, please try with a recent browser.",
37 | disconnected:
38 | "You are disconnected and can't make any more peer connection, please reload.",
39 | network: "It seems you're experimenting some network problems.",
40 | "peer-unavailable":
41 | "The peer you were connected to seems to have lost connection, try to reload it.",
42 | "server-error":
43 | "An error occured on the signaling server. Sorry, try to come back later.",
44 | default: (error) =>
45 | `An error occured${error.type ? ` - type: ${error.type}` : ""}`,
46 | ...overrideMapping,
47 | };
48 | return function humanizeError(error) {
49 | const humanError =
50 | mapping[error.type] ||
51 | (typeof mapping.default === "function"
52 | ? mapping.default(error)
53 | : mapping.default);
54 | return humanError && error.message && withTechicalErrorMessage
55 | ? `${humanError} (${error.message})`
56 | : humanError;
57 | };
58 | }
59 |
60 | export function prepareUtils({ sessionStorageKey, humanErrors } = {}) {
61 | const humanizeError = makeHumanizeError(humanErrors);
62 | const { isConnectionFromRemote } = makeConnectionFilterUtilities();
63 | const { getPeerId, setPeerIdToSessionStorage } =
64 | makeStoreAccessor(sessionStorageKey);
65 | return {
66 | humanizeError,
67 | isConnectionFromRemote,
68 | getPeerId,
69 | setPeerIdToSessionStorage,
70 | };
71 | }
72 |
--------------------------------------------------------------------------------
/packages/core/shared/common.test.js:
--------------------------------------------------------------------------------
1 | import { mockSessionStorage } from "../test.helpers";
2 | import {
3 | makeStoreAccessor,
4 | makeConnectionFilterUtilities,
5 | makeHumanizeError,
6 | } from "./common";
7 |
8 | let sessionStorage = null;
9 |
10 | describe("shared/common", () => {
11 | beforeAll(() => {
12 | sessionStorage = mockSessionStorage();
13 | });
14 | describe("makeStoreAccessor", () => {
15 | afterEach(() => {
16 | sessionStorage.clear();
17 | });
18 | it("should persist state with default key", () => {
19 | const { getPeerId, setPeerIdToSessionStorage } = makeStoreAccessor();
20 | expect(getPeerId()).toBeFalsy();
21 |
22 | setPeerIdToSessionStorage("foo");
23 |
24 | expect(getPeerId()).toBe("foo");
25 | expect(sessionStorage.getItem("webrtc-remote-control-peer-id")).toBe(
26 | "foo"
27 | );
28 | });
29 | it("should persist state with default key", () => {
30 | const { getPeerId, setPeerIdToSessionStorage } =
31 | makeStoreAccessor("some-other-key");
32 | expect(getPeerId()).toBeFalsy();
33 |
34 | setPeerIdToSessionStorage("bar");
35 |
36 | expect(getPeerId()).toBe("bar");
37 | expect(sessionStorage.getItem("some-other-key")).toBe("bar");
38 | });
39 | });
40 | describe("makeConnectionFilterUtilities", () => {
41 | it("isConnectionFromRemote should return true if conn was issued by remote", () => {
42 | const { isConnectionFromRemote } = makeConnectionFilterUtilities();
43 | expect(
44 | isConnectionFromRemote({ metadata: "from-webrtc-remote-control" })
45 | ).toBe(true);
46 | });
47 | });
48 | describe("makeHumanizeError", () => {
49 | it("should be a factory that returns a translating function", () => {
50 | const humanizeError = makeHumanizeError();
51 | expect(humanizeError({ type: "browser-incompatible" })).toBe(
52 | "Your browser doesn't support WebRTC features, please try with a recent browser."
53 | );
54 | });
55 | it("non translated errors should show default message", () => {
56 | const humanizeError = makeHumanizeError();
57 | expect(humanizeError({ type: "some-unsupported-error" })).toBe(
58 | "An error occured - type: some-unsupported-error"
59 | );
60 | });
61 | it("you should be able to pass a mapping", () => {
62 | const humanizeError = makeHumanizeError({
63 | mapping: {
64 | network: "My custom message",
65 | },
66 | });
67 | expect(humanizeError({ type: "network" })).toBe("My custom message");
68 | });
69 | it("should have error.message by default if present", () => {
70 | const humanizeError = makeHumanizeError();
71 | expect(
72 | humanizeError({
73 | type: "network",
74 | message: "Lost connection to server.",
75 | })
76 | ).toBe(
77 | "It seems you're experimenting some network problems. (Lost connection to server.)"
78 | );
79 | });
80 | it("should NOT have error.message of withTechicalErrorMessage = false", () => {
81 | const humanizeError = makeHumanizeError({
82 | withTechicalErrorMessage: false,
83 | });
84 | expect(
85 | humanizeError({
86 | type: "network",
87 | message: "Lost connection to server.",
88 | })
89 | ).toBe("It seems you're experimenting some network problems.");
90 | });
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/packages/core/src/core.index.d.ts:
--------------------------------------------------------------------------------
1 | export * as master from "../master/src/core.master";
2 | export * as remote from "../remote/src/core.remote";
3 | export * from "../shared/common";
4 |
5 | import { default as MasterDefault } from "../master/src/core.master";
6 | import { default as RemoteDefault } from "../remote/src/core.remote";
7 |
8 | export type MasterBindConnectionApiResolved = Awaited<
9 | ReturnType["bindConnection"]>
10 | >;
11 | export type RemoteBindConnectionApiResolved = Awaited<
12 | ReturnType["bindConnection"]>
13 | >;
14 |
--------------------------------------------------------------------------------
/packages/core/src/core.index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-relative-packages */
2 | export * as master from "../master/src/core.master";
3 | export * as remote from "../remote/src/core.remote";
4 | export { prepareUtils } from "../shared/common";
5 |
--------------------------------------------------------------------------------
/packages/core/test.helpers.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | export function disableConsole(
3 | mockFunction = () => {},
4 | methodNames = ["error", "warn", "log", "info"]
5 | ) {
6 | const originalConsoleMethods = methodNames.map((methodName) => ({
7 | methodName,
8 | method: console[methodName],
9 | }));
10 | methodNames.forEach((methodName) => {
11 | console[methodName] = mockFunction;
12 | });
13 | return function restoreConsole() {
14 | originalConsoleMethods.forEach(({ methodName, method }) => {
15 | console[methodName] = method;
16 | });
17 | };
18 | }
19 |
20 | export function mockSessionStorage() {
21 | class SessionStorageMock {
22 | constructor() {
23 | this.store = {};
24 | }
25 |
26 | clear() {
27 | this.store = {};
28 | }
29 |
30 | getItem(key) {
31 | return this.store[key] || null;
32 | }
33 |
34 | setItem(key, value) {
35 | this.store[key] = String(value);
36 | }
37 |
38 | removeItem(key) {
39 | delete this.store[key];
40 | }
41 | }
42 |
43 | global.sessionStorage = new SessionStorageMock();
44 | return global.sessionStorage;
45 | }
46 |
--------------------------------------------------------------------------------
/packages/react/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | ## [0.1.3](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/react@0.1.2...@webrtc-remote-control/react@0.1.3) (2025-05-27)
7 |
8 |
9 | ### Bug Fixes
10 |
11 | * correct nested type declaration ([af5194e](https://github.com/topheman/webrtc-remote-control/commit/af5194e696693440c27cd002cc104681722b3b29))
12 |
13 |
14 |
15 |
16 |
17 | ## [0.1.2](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/react@0.1.1...@webrtc-remote-control/react@0.1.2) (2025-05-21)
18 |
19 |
20 | ### Features
21 |
22 | * **peerjs:** upgrade peerjs on demos from 1.3.2 to 1.4.6 ([4b89d7a](https://github.com/topheman/webrtc-remote-control/commit/4b89d7ad7993a6b3bf7f31e034ed9b4ac19f3b74))
23 |
24 |
25 |
26 |
27 |
28 | ## [0.1.1](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/react@0.1.0...@webrtc-remote-control/react@0.1.1) (2022-06-13)
29 |
30 | **Note:** Version bump only for package @webrtc-remote-control/react
31 |
32 |
33 |
34 |
35 |
36 | # [0.1.0](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/react@0.0.1...@webrtc-remote-control/react@0.1.0) (2022-04-16)
37 |
38 |
39 | ### Features
40 |
41 | * update homepage in package.json ([4038fd5](https://github.com/topheman/webrtc-remote-control/commit/4038fd51ac19f7285808de4ac8ad21eb7a461ab7))
42 |
--------------------------------------------------------------------------------
/packages/react/README.md:
--------------------------------------------------------------------------------
1 | # @webrtc-remote-control/react
2 |
3 | [](https://www.npmjs.com/package/@webrtc-remote-control/react)
4 | [](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml)
5 | [](http://webrtc-remote-control.vercel.app/)
6 |
7 | Imagine you could simply control a web page opened in a browser (master) from an other page in an other browser (remote), just like you would with a TV and a remote.
8 |
9 | webrtc-remote-control lets you do that (based on [PeerJS](https://peerjs.com)) and handles the disconnections / reconnections, providing a simple API.
10 |
11 | ## Installation
12 |
13 | ```sh
14 | npm install peerjs @webrtc-remote-control/react
15 | ```
16 |
17 | This package relies on [@webrtc-remote-control/core](https://github.com/topheman/webrtc-remote-control/tree/master/packages/core#readme) (the implementation in vanillaJS). Other implementations for popular frameworks are available [here](https://github.com/topheman/webrtc-remote-control/tree/master/packages).
18 |
19 | ## Usage
20 |
21 | Add the peerjs library as a script tag in your html page. You'll have access to `Peer` constructor.
22 |
23 | ```html
24 |
25 | ```
26 |
27 | Direct link to the [demo](https://webrtc-remote-control.vercel.app/counter-react/index.html) source code: [App.jsx](https://github.com/topheman/webrtc-remote-control/blob/master/demo/counter-react/js/App.jsx) / [Master.jsx](https://github.com/topheman/webrtc-remote-control/blob/master/demo/counter-react/js/Master.jsx) / [Remote.jsx](https://github.com/topheman/webrtc-remote-control/blob/master/demo/counter-react/js/Remote.jsx)
28 |
29 | ## TypeScript
30 |
31 | TypeScript types are shipped with the package.
32 |
33 | ## UMD build
34 |
35 | Don't want to use a bundler ? You can simply use the UMD (Universal Module Definition) build and drop it with a script tag, you'll have access to a `webrtcRemoteControlReact` object on the `window`.
36 |
37 | - Development build: [https://unpkg.com/@webrtc-remote-control/react/dist/webrtc-remote-control-react.umd.dev.js](https://unpkg.com/@webrtc-remote-control/react/dist/webrtc-remote-control-react.umd.dev.js)
38 | - Production build: [https://unpkg.com/@webrtc-remote-control/react/dist/webrtc-remote-control-react.umd.prod.js](https://unpkg.com/@webrtc-remote-control/react/dist/webrtc-remote-control-react.umd.prod.js)
39 |
--------------------------------------------------------------------------------
/packages/react/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@webrtc-remote-control/react",
3 | "amdName": "webrtcRemoteControlReact",
4 | "version": "0.1.3",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/topheman/webrtc-remote-control.git"
8 | },
9 | "homepage": "http://webrtc-remote-control.vercel.app/",
10 | "bugs": "https://github.com/topheman/webrtc-remote-control/issues",
11 | "description": "Thin abstraction layer above peerjs that will let you be more productive at making WebRTC data channels based apps.",
12 | "keywords": [
13 | "WebRTC",
14 | "peerjs",
15 | "RTCDataChannel",
16 | "react"
17 | ],
18 | "type": "module",
19 | "main": "./dist/react.cjs",
20 | "module": "./dist/react.module.js",
21 | "unpkg": "./dist/react.umd.js",
22 | "source": "./src/react.jsx",
23 | "exports": {
24 | ".": {
25 | "types": "./src/react.d.ts",
26 | "import": "./dist/react.modern.js",
27 | "require": "./dist/react.cjs"
28 | }
29 | },
30 | "scripts": {
31 | "build": "npm-run-all --parallel build:*",
32 | "build:modules": "microbundle --generateTypes false --no-compress --jsx React.createElement",
33 | "build:umd-dev": "microbundle build --generateTypes false --globals react=React --jsx React.createElement --raw --external react --define process.env.NODE_ENV='development' --no-pkg-main -o dist/webrtc-remote-control-react.umd.dev.js -f umd --no-compress",
34 | "build:umd-prod": "microbundle build --generateTypes false --globals react=React --jsx React.createElement --raw --external react --define process.env.NODE_ENV='production' --no-pkg-main -o dist/webrtc-remote-control-react.umd.prod.js -f umd",
35 | "dev": "microbundle watch --generateTypes false --no-compress --jsx React.createElement"
36 | },
37 | "author": "Christophe Rosset (http://labs.topheman.com/)",
38 | "types": "src/react.d.ts",
39 | "license": "MIT",
40 | "files": [
41 | "src",
42 | "dist"
43 | ],
44 | "devDependencies": {
45 | "microbundle": "^0.14.2",
46 | "npm-run-all": "^4.1.5"
47 | },
48 | "peerDependencies": {
49 | "react": ">=16.8.0"
50 | },
51 | "dependencies": {
52 | "@webrtc-remote-control/core": "^0.1.3",
53 | "prop-types": "^15.8.1"
54 | },
55 | "publishConfig": {
56 | "access": "public"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/packages/react/src/Provider.d.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import {
4 | HumanErrorsMapping,
5 | HumanizeErrorType,
6 | GetPeerIdType,
7 | IsConnectionFromRemoteType,
8 | } from "@webrtc-remote-control/core";
9 |
10 | export function Provider({
11 | children,
12 | sessionStorageKey,
13 | humanErrors,
14 | mode,
15 | masterPeerId,
16 | init,
17 | }: {
18 | children: React.ReactNode;
19 | sessionStorageKey?: string;
20 | humanErrors?: Partial;
21 | mode: "remote" | "master";
22 | masterPeerId?: string;
23 | init: ({
24 | humanizeError,
25 | getPeerId,
26 | isConnectionFromRemote,
27 | }: {
28 | humanizeError: HumanizeErrorType;
29 | getPeerId: GetPeerIdType;
30 | isConnectionFromRemote?: IsConnectionFromRemoteType;
31 | }) => any;
32 | }): React.ReactElement | null;
33 |
--------------------------------------------------------------------------------
/packages/react/src/Provider.jsx:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import React, { createContext, useEffect, useRef } from "react";
3 | import PropTypes from "prop-types";
4 | import { master, remote, prepareUtils } from "@webrtc-remote-control/core";
5 |
6 | export const MyContext = createContext();
7 |
8 | export function Provider({
9 | children,
10 | sessionStorageKey,
11 | humanErrors,
12 | mode,
13 | masterPeerId,
14 | init,
15 | }) {
16 | const allowedMode = ["master", "remote"];
17 | if (!allowedMode.includes(mode)) {
18 | throw new Error(
19 | `Unsupported "${mode}" mode. Only ${allowedMode
20 | .map((a) => `"${a}"`)
21 | .join(", ")} accepted.`
22 | );
23 | }
24 | if (mode === "master" && masterPeerId) {
25 | console.log(typeof masterPeerId);
26 | throw new Error(
27 | `\`masterPeerId\` prop not allowed in "master" mode - "${masterPeerId}" was passed.`
28 | );
29 | }
30 | if (mode === "remote" && !masterPeerId) {
31 | throw new Error(`\`masterPeerId\` prop required in "remote" mode.`);
32 | }
33 | const utils = prepareUtils({
34 | sessionStorageKey,
35 | humanErrors,
36 | });
37 | const providerValue = useRef({
38 | peer: null,
39 | promise: null,
40 | mode,
41 | masterPeerId,
42 | });
43 | useEffect(() => {
44 | // expose the following on the ref forwarded to the provider
45 | providerValue.current.mode = mode;
46 | providerValue.current.humanizeError = utils.humanizeError;
47 | if (mode === "master") {
48 | providerValue.current.isConnectionFromRemote =
49 | utils.isConnectionFromRemote;
50 | }
51 |
52 | // init callback that should return a peer instance like:
53 | // `({ getPeerId }) => new Peer(getPeerId())`
54 | providerValue.current.peer = init({
55 | humanizeError: utils.humanizeError,
56 | getPeerId: utils.getPeerId,
57 | isConnectionFromRemote:
58 | mode === "master" ? utils.isConnectionFromRemote : undefined,
59 | });
60 |
61 | providerValue.current.promise = (mode === "master" ? master : remote)
62 | .default(utils)
63 | .bindConnection(
64 | providerValue.current.peer,
65 | remote ? masterPeerId : undefined
66 | );
67 | // start resolving the promise as soon as possible (it will be used in `usePeer`)
68 | providerValue.current.promise.then(() => {});
69 | return () => {
70 | if (providerValue.current) {
71 | // eslint-disable-next-line react-hooks/exhaustive-deps
72 | providerValue.current.peer.disconnect();
73 | }
74 | };
75 | }, [mode, masterPeerId, init, utils]);
76 | return (
77 |
78 | {children}
79 |
80 | );
81 | }
82 |
83 | Provider.propTypes = {
84 | children: PropTypes.any,
85 | sessionStorageKey: PropTypes.string,
86 | humanErrors: PropTypes.object,
87 | mode: PropTypes.oneOf(["master", "remote"]),
88 | masterPeerId: PropTypes.string,
89 | init: PropTypes.func,
90 | };
91 |
--------------------------------------------------------------------------------
/packages/react/src/hooks.d.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MasterBindConnectionApiResolved,
3 | RemoteBindConnectionApiResolved,
4 | HumanizeErrorType,
5 | IsConnectionFromRemoteType,
6 | } from "@webrtc-remote-control/core";
7 |
8 | export function usePeer(): {
9 | ready: boolean;
10 | api?: M extends "remote"
11 | ? RemoteBindConnectionApiResolved
12 | : MasterBindConnectionApiResolved;
13 | peer: any;
14 | mode: "remote" | "master";
15 | humanizeError: HumanizeErrorType;
16 | isConnectionFromRemote: M extends "master"
17 | ? IsConnectionFromRemoteType
18 | : undefined;
19 | };
20 |
--------------------------------------------------------------------------------
/packages/react/src/hooks.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { useEffect, useContext, useRef, useState } from "react";
3 |
4 | import { MyContext } from "./Provider";
5 |
6 | export function usePeer() {
7 | // track if the hook is fully ready
8 | const [ready, setReady] = useState(false);
9 | // track if the peer object is ready (to allow consumer to subscribe to error event)
10 | const [, setPeerReady] = useState(false);
11 | const context = useContext(MyContext);
12 | const resolvedWrcApi = useRef(null);
13 | useEffect(() => {
14 | // run on next tick (ensure the `then` of the Provider has executed + retrieve the api from the resolve promise)
15 | Promise.resolve().then(() => {
16 | setPeerReady(true); // peer object is not null anymore
17 | context?.promise?.then((wrcApi) => {
18 | resolvedWrcApi.current = wrcApi;
19 | setReady(true);
20 | });
21 | });
22 | // eslint-disable-next-line react-hooks/exhaustive-deps
23 | }, []);
24 | return {
25 | ready,
26 | api: resolvedWrcApi.current,
27 | ...context,
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/packages/react/src/react.d.ts:
--------------------------------------------------------------------------------
1 | export { Provider as WebRTCRemoteControlProvider } from "./Provider";
2 | export { usePeer } from "./hooks";
3 |
--------------------------------------------------------------------------------
/packages/react/src/react.jsx:
--------------------------------------------------------------------------------
1 | export { Provider as WebRTCRemoteControlProvider } from "./Provider";
2 | export { usePeer } from "./hooks";
3 |
--------------------------------------------------------------------------------
/packages/vue/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5 |
6 | ## [0.1.3](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/vue@0.1.2...@webrtc-remote-control/vue@0.1.3) (2025-05-27)
7 |
8 |
9 | ### Bug Fixes
10 |
11 | * correct nested type declaration ([af5194e](https://github.com/topheman/webrtc-remote-control/commit/af5194e696693440c27cd002cc104681722b3b29))
12 |
13 |
14 |
15 |
16 |
17 | ## [0.1.2](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/vue@0.1.1...@webrtc-remote-control/vue@0.1.2) (2025-05-21)
18 |
19 |
20 | ### Features
21 |
22 | * **peerjs:** upgrade peerjs on demos from 1.3.2 to 1.4.6 ([4b89d7a](https://github.com/topheman/webrtc-remote-control/commit/4b89d7ad7993a6b3bf7f31e034ed9b4ac19f3b74))
23 |
24 |
25 |
26 |
27 |
28 | ## [0.1.1](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/vue@0.1.0...@webrtc-remote-control/vue@0.1.1) (2022-06-13)
29 |
30 | **Note:** Version bump only for package @webrtc-remote-control/vue
31 |
32 |
33 |
34 |
35 |
36 | # [0.1.0](https://github.com/topheman/webrtc-remote-control/compare/@webrtc-remote-control/vue@0.0.1...@webrtc-remote-control/vue@0.1.0) (2022-04-16)
37 |
38 |
39 | ### Features
40 |
41 | * update homepage in package.json ([4038fd5](https://github.com/topheman/webrtc-remote-control/commit/4038fd51ac19f7285808de4ac8ad21eb7a461ab7))
42 |
--------------------------------------------------------------------------------
/packages/vue/README.md:
--------------------------------------------------------------------------------
1 | # @webrtc-remote-control/vue
2 |
3 | [](https://www.npmjs.com/package/@webrtc-remote-control/vue)
4 | [](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml)
5 | [](http://webrtc-remote-control.vercel.app/)
6 |
7 | Imagine you could simply control a web page opened in a browser (master) from an other page in an other browser (remote), just like you would with a TV and a remote.
8 |
9 | webrtc-remote-control lets you do that (based on [PeerJS](https://peerjs.com)) and handles the disconnections / reconnections, providing a simple API.
10 |
11 | ## Installation
12 |
13 | ```sh
14 | npm install peerjs @webrtc-remote-control/vue
15 | ```
16 |
17 | This package relies on [@webrtc-remote-control/core](https://github.com/topheman/webrtc-remote-control/tree/master/packages/core#readme) (the implementation in vanillaJS). Other implementations for popular frameworks are available [here](https://github.com/topheman/webrtc-remote-control/tree/master/packages).
18 |
19 | ## Usage
20 |
21 | Add the peerjs library as a script tag in your html page. You'll have access to `Peer` constructor.
22 |
23 | ```html
24 |
25 | ```
26 |
27 | Direct link to the [demo](https://webrtc-remote-control.vercel.app/counter-vue/index.html) source code: [App.vue](https://github.com/topheman/webrtc-remote-control/blob/master/demo/counter-vue/js/App.vue) / [Master.vue](https://github.com/topheman/webrtc-remote-control/blob/master/demo/counter-vue/js/Master.vue) / [Remote.vue](https://github.com/topheman/webrtc-remote-control/blob/master/demo/counter-vue/js/Remote.vue)
28 |
29 | ## TypeScript
30 |
31 | TypeScript types are shipped with the package.
32 |
33 | ## UMD build
34 |
35 | Don't want to use a bundler ? You can simply use the UMD (Universal Module Definition) build and drop it with a script tag, you'll have access to a `webrtcRemoteControlVue` object on the `window`.
36 |
37 | - Development build: [https://unpkg.com/@webrtc-remote-control/vue/dist/webrtc-remote-control-vue.umd.dev.js](https://unpkg.com/@webrtc-remote-control/vue/dist/webrtc-remote-control-vue.umd.dev.js)
38 | - Production build: [https://unpkg.com/@webrtc-remote-control/vue/dist/webrtc-remote-control-vue.umd.prod.js](https://unpkg.com/@webrtc-remote-control/vue/dist/webrtc-remote-control-vue.umd.prod.js)
39 |
--------------------------------------------------------------------------------
/packages/vue/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@webrtc-remote-control/vue",
3 | "amdName": "webrtcRemoteControlVue",
4 | "version": "0.1.3",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/topheman/webrtc-remote-control.git"
8 | },
9 | "homepage": "http://webrtc-remote-control.vercel.app/",
10 | "bugs": "https://github.com/topheman/webrtc-remote-control/issues",
11 | "description": "Thin abstraction layer above peerjs that will let you be more productive at making WebRTC data channels based apps.",
12 | "keywords": [
13 | "WebRTC",
14 | "peerjs",
15 | "RTCDataChannel",
16 | "vue"
17 | ],
18 | "type": "module",
19 | "main": "./dist/vue.cjs",
20 | "module": "./dist/vue.module.js",
21 | "unpkg": "./dist/vue.umd.js",
22 | "source": "./src/vue.js",
23 | "exports": {
24 | ".": {
25 | "types": "./src/vue.d.ts",
26 | "import": "./dist/vue.modern.js",
27 | "require": "./dist/vue.cjs"
28 | }
29 | },
30 | "scripts": {
31 | "build": "npm-run-all --parallel build:*",
32 | "build:modules": "microbundle --generateTypes false --no-compress",
33 | "build:umd-dev": "microbundle build --generateTypes false --globals vue=Vue --raw --external vue --define process.env.NODE_ENV='development' --no-pkg-main -o dist/webrtc-remote-control-vue.umd.dev.js -f umd --no-compress",
34 | "build:umd-prod": "microbundle build --generateTypes false --globals vue=Vue --raw --external vue --define process.env.NODE_ENV='production' --no-pkg-main -o dist/webrtc-remote-control-vue.umd.prod.js -f umd",
35 | "dev": "microbundle watch --generateTypes false --no-compress"
36 | },
37 | "author": "Christophe Rosset (http://labs.topheman.com/)",
38 | "types": "src/vue.d.ts",
39 | "license": "MIT",
40 | "files": [
41 | "src",
42 | "dist"
43 | ],
44 | "devDependencies": {
45 | "microbundle": "^0.14.2",
46 | "npm-run-all": "^4.1.5"
47 | },
48 | "peerDependencies": {
49 | "vue": ">=3.0.0"
50 | },
51 | "dependencies": {
52 | "@webrtc-remote-control/core": "^0.1.3"
53 | },
54 | "publishConfig": {
55 | "access": "public"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/packages/vue/src/Provider.d.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HumanErrorsMapping,
3 | HumanizeErrorType,
4 | GetPeerIdType,
5 | IsConnectionFromRemoteType,
6 | } from "@webrtc-remote-control/core";
7 |
8 | export function provideWebTCRemoteControl(
9 | init: ({
10 | humanizeError,
11 | getPeerId,
12 | isConnectionFromRemote,
13 | }: {
14 | humanizeError: HumanizeErrorType;
15 | getPeerId: GetPeerIdType;
16 | isConnectionFromRemote?: IsConnectionFromRemoteType;
17 | }) => any,
18 | mode: "remote" | "master",
19 | options?: {
20 | masterPeerId?: string;
21 | sessionStorageKey?: string;
22 | humanErrors?: Partial;
23 | }
24 | ): void;
25 |
--------------------------------------------------------------------------------
/packages/vue/src/Provider.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import { shallowRef, watchEffect, provide } from "vue";
3 | import { master, remote, prepareUtils } from "@webrtc-remote-control/core";
4 |
5 | // use Symbol to avoid collision in provide/inject
6 | export const MyContext = Symbol("context-webrtc-remote-control");
7 |
8 | export function provideWebTCRemoteControl(
9 | init,
10 | mode,
11 | { masterPeerId, sessionStorageKey, humanErrors } = {}
12 | ) {
13 | const allowedMode = ["master", "remote"];
14 | if (!allowedMode.includes(mode)) {
15 | throw new Error(
16 | `Unsupported "${mode}" mode. Only ${allowedMode
17 | .map((a) => `"${a}"`)
18 | .join(", ")} accepted.`
19 | );
20 | }
21 | if (mode === "master" && masterPeerId) {
22 | console.log(typeof masterPeerId);
23 | throw new Error(
24 | `\`masterPeerId\` prop not allowed in "master" mode - "${masterPeerId}" was passed.`
25 | );
26 | }
27 | if (mode === "remote" && !masterPeerId) {
28 | throw new Error(`\`masterPeerId\` prop required in "remote" mode.`);
29 | }
30 | const utils = prepareUtils({
31 | sessionStorageKey,
32 | humanErrors,
33 | });
34 | const providerValue = shallowRef({
35 | peer: null,
36 | promise: null,
37 | mode,
38 | masterPeerId,
39 | });
40 | // expose providerValue so that it can be injected inside the hook
41 | provide(MyContext, providerValue);
42 |
43 | watchEffect((onCleanup) => {
44 | console.log("Provider.watch");
45 | providerValue.value.mode = mode;
46 | providerValue.value.humanizeError = utils.humanizeError;
47 | if (mode === "master") {
48 | providerValue.value.isConnectionFromRemote = utils.isConnectionFromRemote;
49 | }
50 |
51 | // init callback that should return a peer instance like:
52 | // `({ getPeerId }) => new Peer(getPeerId())`
53 | providerValue.value.peer = init({
54 | humanizeError: utils.humanizeError,
55 | getPeerId: utils.getPeerId,
56 | isConnectionFromRemote:
57 | mode === "master" ? utils.isConnectionFromRemote : undefined,
58 | });
59 |
60 | providerValue.value.promise = (mode === "master" ? master : remote)
61 | .default(utils)
62 | .bindConnection(
63 | providerValue.value.peer,
64 | remote ? masterPeerId : undefined
65 | );
66 | // start resolving the promise as soon as possible (it will be used in `usePeer`)
67 | providerValue.value.promise.then((wrcApi) => {
68 | console.log("Provider.then", wrcApi);
69 | });
70 | // register cleanup
71 | onCleanup(() => {
72 | console.log("Provider.onInvalidate", providerValue.value);
73 | if (providerValue.value) {
74 | providerValue.value.peer.disconnect();
75 | }
76 | });
77 | });
78 | }
79 |
--------------------------------------------------------------------------------
/packages/vue/src/hooks.d.ts:
--------------------------------------------------------------------------------
1 | import { ToRefs, UnwrapNestedRefs } from "vue";
2 |
3 | import {
4 | MasterBindConnectionApiResolved,
5 | RemoteBindConnectionApiResolved,
6 | HumanizeErrorType,
7 | IsConnectionFromRemoteType,
8 | } from "@webrtc-remote-control/core";
9 |
10 | export function usePeer(): ToRefs<
11 | UnwrapNestedRefs<{
12 | peerReady: boolean;
13 | ready: boolean;
14 | api?: M extends "remote"
15 | ? RemoteBindConnectionApiResolved
16 | : MasterBindConnectionApiResolved;
17 | peer: any;
18 | mode: "remote" | "master";
19 | humanizeError: HumanizeErrorType;
20 | isConnectionFromRemote: M extends "master"
21 | ? IsConnectionFromRemoteType
22 | : undefined;
23 | }>
24 | >;
25 |
--------------------------------------------------------------------------------
/packages/vue/src/hooks.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import { inject, watchEffect, toRefs, unref, reactive } from "vue";
3 |
4 | import { MyContext } from "./Provider";
5 |
6 | export function usePeer() {
7 | console.log("usePeer");
8 | // const ready = ref(false);
9 | const context = inject(MyContext);
10 | // const resolvedWrcApi = shallowRef(null);
11 | console.log("context", context);
12 | const result = reactive({
13 | ...unref(context),
14 | peerReady: false,
15 | ready: false,
16 | api: null,
17 | });
18 | watchEffect(() => {
19 | // run on next tick (ensure the `then` of the Provider has executed + retrieve the api from the resolve promise)
20 | Promise.resolve().then(() => {
21 | console.log("hooks.Promise.resolve", context);
22 | result.peerReady = true;
23 | context.value?.promise?.then((wrcApi) => {
24 | console.log("hooks.Promise.resolve - context.promise.then", wrcApi);
25 | // resolvedWrcApi.value = wrcApi;
26 | // ready.value = true;
27 | result.ready = true;
28 | result.api = wrcApi;
29 | });
30 | });
31 | });
32 | // use toRefs ? https://vuejs.org/api/reactivity-utilities.html#torefs
33 | return toRefs(result); // todo - is spread necessary ?
34 | }
35 |
--------------------------------------------------------------------------------
/packages/vue/src/vue.d.ts:
--------------------------------------------------------------------------------
1 | export { provideWebTCRemoteControl } from "./Provider";
2 | export { usePeer } from "./hooks";
3 |
--------------------------------------------------------------------------------
/packages/vue/src/vue.js:
--------------------------------------------------------------------------------
1 | export { provideWebTCRemoteControl } from "./Provider";
2 | export { usePeer } from "./hooks";
3 |
--------------------------------------------------------------------------------
/public/star-network-topology.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/webrtc-remote-control/77b34900aaadc6aaa19933385f2baa10a57cdbd5/public/star-network-topology.png
--------------------------------------------------------------------------------