├── .nvmrc ├── .prettierrc ├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── .eslintignore ├── demo ├── .gitignore ├── public │ ├── favicon.ico │ └── images │ │ └── WebRTC-Logo.jpeg ├── babel.config.js ├── shared │ ├── css │ │ ├── assets │ │ │ ├── github-retina.png │ │ │ ├── twitter-retina.png │ │ │ ├── vue-logo.svg │ │ │ └── javascript-logo.svg │ │ ├── counter-master.css │ │ ├── main.css │ │ ├── counter-remote.css │ │ ├── counter.css │ │ └── network.css │ └── js │ │ ├── components │ │ ├── README.md │ │ ├── ErrorsDisplay.jsx │ │ ├── CounterDisplay.jsx │ │ ├── QrcodeDisplay.jsx │ │ ├── Footer.jsx │ │ ├── ConsoleDisplay.jsx │ │ ├── counter-display.js │ │ ├── footer-display.js │ │ ├── qrcode-display.js │ │ ├── twitter-button.js │ │ ├── errors-display.js │ │ ├── console-display.js │ │ └── remotes-list.js │ │ ├── common-peerjs.js │ │ ├── counter.master.persistance.js │ │ ├── counter.master.logic.js │ │ ├── animate.js │ │ ├── common.js │ │ ├── react-common.js │ │ ├── react-useDeviceOrientation.js │ │ └── common.test.js ├── __integration__ │ ├── .eslintrc.js │ ├── helpers.js │ └── counter │ │ ├── features │ │ └── connection.feature │ │ └── step-definitions │ │ └── connection.steps.js ├── counter-vue │ ├── js │ │ ├── demo.vue-counter.js │ │ ├── common.js │ │ ├── OpenRemote.vue │ │ ├── DirectLinkToSource.vue │ │ ├── RemoteCountControl.vue │ │ ├── RemoteNameControl.vue │ │ ├── App.vue │ │ ├── Remote.vue │ │ └── Master.vue │ └── index.html ├── index.js ├── README.md ├── counter-react │ ├── js │ │ ├── demo.react-counter.jsx │ │ ├── DirectLinkToSource.jsx │ │ ├── OpenRemote.jsx │ │ ├── RemoteCountControl.jsx │ │ ├── RemoteNameControl.jsx │ │ ├── App.jsx │ │ ├── RemotesList.jsx │ │ ├── Remote.jsx │ │ └── Master.jsx │ └── index.html ├── accelerometer-3d │ ├── js │ │ ├── demo.accelerometer-3d.jsx │ │ ├── accelerometer.helpers.js │ │ ├── OpenRemote.jsx │ │ ├── DirectLinkToSource.jsx │ │ ├── color.js │ │ ├── master.logic.js │ │ ├── App.jsx │ │ ├── RemotesList.jsx │ │ ├── Phone3D.jsx │ │ ├── Master.jsx │ │ └── Remote.jsx │ ├── accelerometer-3d-remote.css │ └── index.html ├── jest-puppeteer.config.js ├── counter-vanilla │ ├── js │ │ ├── __tests__ │ │ │ ├── master.persistance.test.js │ │ │ └── master.logic.test.js │ │ ├── master.view.js │ │ ├── remote.js │ │ ├── master.js │ │ └── remote.view.js │ ├── remote.html │ └── master.html ├── test.helpers.js ├── favicon.svg ├── package.json └── vite.config.js ├── commitlint.config.js ├── lerna.json ├── packages ├── vue │ ├── src │ │ ├── vue.js │ │ ├── vue.d.ts │ │ ├── Provider.d.ts │ │ ├── hooks.d.ts │ │ ├── hooks.js │ │ └── Provider.js │ ├── CHANGELOG.md │ ├── package.json │ └── README.md ├── core │ ├── babel.config.cjs │ ├── src │ │ ├── core.index.js │ │ └── core.index.d.ts │ ├── master │ │ ├── package.json │ │ └── src │ │ │ ├── core.master.d.ts │ │ │ └── core.master.js │ ├── remote │ │ ├── package.json │ │ └── src │ │ │ ├── core.remote.d.ts │ │ │ └── core.remote.js │ ├── test.helpers.js │ ├── shared │ │ ├── common.d.ts │ │ ├── common.js │ │ └── common.test.js │ ├── CHANGELOG.md │ ├── package.json │ └── README.md └── react │ ├── src │ ├── react.d.ts │ ├── react.jsx │ ├── hooks.d.ts │ ├── Provider.d.ts │ ├── hooks.js │ └── Provider.jsx │ ├── CHANGELOG.md │ ├── package.json │ └── README.md ├── public └── star-network-topology.png ├── .vscode └── settings.json ├── .prettierignore ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── .eslintrc.js ├── README.md ├── package.json └── CONTRIBUTING.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*", "demo"], 3 | "version": "independent" 4 | } 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webrtc-remote-control/HEAD/demo/public/favicon.ico -------------------------------------------------------------------------------- /packages/vue/src/vue.js: -------------------------------------------------------------------------------- 1 | export { provideWebTCRemoteControl } from "./Provider"; 2 | export { usePeer } from "./hooks"; 3 | -------------------------------------------------------------------------------- /demo/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [["@babel/preset-env", { targets: { node: "current" } }]], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/vue/src/vue.d.ts: -------------------------------------------------------------------------------- 1 | export { provideWebTCRemoteControl } from "./Provider"; 2 | export { usePeer } from "./hooks"; 3 | -------------------------------------------------------------------------------- /public/star-network-topology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webrtc-remote-control/HEAD/public/star-network-topology.png -------------------------------------------------------------------------------- /demo/public/images/WebRTC-Logo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webrtc-remote-control/HEAD/demo/public/images/WebRTC-Logo.jpeg -------------------------------------------------------------------------------- /packages/core/babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [["@babel/preset-env", { targets: { node: "current" } }]], 3 | }; 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/shared/css/assets/github-retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webrtc-remote-control/HEAD/demo/shared/css/assets/github-retina.png -------------------------------------------------------------------------------- /demo/shared/css/assets/twitter-retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/webrtc-remote-control/HEAD/demo/shared/css/assets/twitter-retina.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # @webrtc-remote-control/demo 2 | 3 | [![Demo](https://img.shields.io/badge/demo-online-blue.svg)](http://webrtc-remote-control.vercel.app/) 4 | 5 | Check the npm scripts to launch the app in [CONTRIBUTING.md](../CONTRIBUTING.md). 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/shared/css/assets/vue-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/counter-vue/js/OpenRemote.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/counter-vue/js/DirectLinkToSource.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /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/counter-vue/js/RemoteCountControl.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/counter-vue/js/RemoteNameControl.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/shared/css/assets/javascript-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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-react/js/RemoteCountControl.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function RemoteCountControl({ 5 | onIncrement, 6 | onDecrement, 7 | disabled, 8 | }) { 9 | return ( 10 |
11 | 20 | 29 |
30 | ); 31 | } 32 | 33 | RemoteCountControl.propTypes = { 34 | onIncrement: PropTypes.func, 35 | onDecrement: PropTypes.func, 36 | disabled: PropTypes.bool, 37 | }; 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /demo/counter-react/js/RemoteNameControl.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | export default function RemoteNameControl({ 5 | onChangeName, 6 | onConfirmName, 7 | name, 8 | disabled, 9 | }) { 10 | return ( 11 |
{ 15 | e.preventDefault(); 16 | onConfirmName(e.target.name.value); 17 | }} 18 | disabled={disabled} 19 | > 20 | 32 |
33 | ); 34 | } 35 | 36 | RemoteNameControl.propTypes = { 37 | onChangeName: PropTypes.func, 38 | onConfirmName: PropTypes.func, 39 | name: PropTypes.string, 40 | disabled: PropTypes.bool, 41 | }; 42 | -------------------------------------------------------------------------------- /demo/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/counter-vue/js/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 49 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/accelerometer-3d/js/RemotesList.jsx: -------------------------------------------------------------------------------- 1 | import React, { lazy, Suspense } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { orientationToRotation } from "./accelerometer.helpers"; 5 | 6 | const Phone3D = lazy(() => import("./Phone3D")); 7 | 8 | export default function RemotesList({ list }) { 9 | if (list && list.length) { 10 | return ( 11 |
    12 | {list.map(({ peerId, alpha, beta, gamma, scale, color }) => ( 13 |
  • 14 | {peerId} 15 |
    16 | Loading 3D model ...
    }> 17 | 25 | 26 |
      27 |
    • alpha: {alpha}
    • 28 |
    • beta: {beta}
    • 29 |
    • gamma: {gamma}
    • 30 |
    31 | 32 |
  • 33 | ))} 34 |
35 | ); 36 | } 37 | return null; 38 | } 39 | 40 | RemotesList.propTypes = { 41 | list: PropTypes.arrayOf( 42 | PropTypes.exact({ 43 | peerId: PropTypes.string, 44 | alpha: PropTypes.number, 45 | beta: PropTypes.number, 46 | gamma: PropTypes.number, 47 | }) 48 | ), 49 | }; 50 | -------------------------------------------------------------------------------- /demo/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /demo/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/vue/README.md: -------------------------------------------------------------------------------- 1 | # @webrtc-remote-control/vue 2 | 3 | [![npm](https://img.shields.io/npm/v/@webrtc-remote-control/vue?color=blue)](https://www.npmjs.com/package/@webrtc-remote-control/vue) 4 | [![ci](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml/badge.svg)](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml) 5 | [![Demo](https://img.shields.io/badge/demo-online-blue.svg)](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/react/README.md: -------------------------------------------------------------------------------- 1 | # @webrtc-remote-control/react 2 | 3 | [![npm](https://img.shields.io/npm/v/@webrtc-remote-control/react?color=blue)](https://www.npmjs.com/package/@webrtc-remote-control/react) 4 | [![ci](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml/badge.svg)](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml) 5 | [![Demo](https://img.shields.io/badge/demo-online-blue.svg)](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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webrtc-remote-control 2 | 3 | [![ci](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml/badge.svg)](https://github.com/topheman/webrtc-remote-control/actions/workflows/ci.yml) 4 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://www.conventionalcommits.org) 5 | [![Demo](https://img.shields.io/badge/demo-online-blue.svg)](http://webrtc-remote-control.vercel.app/) 6 | 7 | Implementations 8 | 9 | | Package | Version | 10 | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | 11 | | [@webrtc-remote-control/core](./packages/core#readme) | [![npm](https://img.shields.io/npm/v/@webrtc-remote-control/core?color=blue)](https://www.npmjs.com/package/@webrtc-remote-control/core) | 12 | | [@webrtc-remote-control/react](./packages/react#readme) | [![npm](https://img.shields.io/npm/v/@webrtc-remote-control/react?color=blue)](https://www.npmjs.com/package/@webrtc-remote-control/react) | 13 | | [@webrtc-remote-control/vue](./packages/vue#readme) | [![npm](https://img.shields.io/npm/v/@webrtc-remote-control/vue?color=blue)](https://www.npmjs.com/package/@webrtc-remote-control/vue) | 14 | 15 | - [Demo](./demo#readme) 16 | - [CONTRIBUTING](CONTRIBUTING.md) 17 | 18 | ## The problem 19 | 20 | [PeerJS](https://peerjs.com) is a great layer of abstraction above WebRTC with a simple API, though, you still need to: 21 | 22 | - track your connections 23 | - handle reconnects of peers when your page reloads 24 | 25 | You don't want to think about this kind of networking problems, you want to focus on your app logic. 26 | 27 | **webrtc-remote-control** handles all of that. 28 | 29 | ## The use case 30 | 31 | **webrtc-remote-control** was made to handle star topology network: 32 | 33 |

    34 | 35 | You have: 36 | 37 | - One "master" page connected to 38 | - Multiple "remote" pages 39 | 40 | What you can do (through data-channel): 41 | 42 | - From "master" page, you can send data to any or all "remote" pages 43 | - From one "remote" page, you can send data to the master page 44 | 45 | When "master" page drops connection (the page closes or reloads), the "remote" pages are notified (and remote automatically reconnect when master retrieves connection). 46 | 47 | When a "remote" page drops connection (the page closes or reloads), the "master" page gets notified (and the remote reconnects to master as soon as it reloads). 48 | 49 | ## Genesis 50 | 51 | A few years ago I made [topheman/webrtc-experiments](https://github.com/topheman/webrtc-experiments), as a proof of concept for WebRTC data-channels relying on PeerJS. 52 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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