diff --git a/.gitignore b/.gitignore
index c2658d7..65c258f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
node_modules/
+client/config.json
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..cf84308
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,176 @@
+
+
+
+
+
+ Screenshare Broadcaster
+
+
+
+
+ Broadcaster Client
+
+
+
Server Connection
+
+
+
+
+
Media Sources
+ Refresh Devices
+
+
+
Visual Source (Screen/Window)
+
+
+
Audio Source (Microphone/Pipewire Virtual Sinks)
+
+
+
Start Broadcast
+
Not Broadcasting
+
Stop Broadcast
+
+
+
Resolution: 0x0
+
FPS: 0
+
Upstream: 0 kbps
+
+
+
+
+
+
+
+
+
+
diff --git a/client/main.js b/client/main.js
new file mode 100644
index 0000000..00aff1f
--- /dev/null
+++ b/client/main.js
@@ -0,0 +1,83 @@
+const { app, BrowserWindow, desktopCapturer, ipcMain } = require('electron');
+const path = require('path');
+const PipewireHelper = require('./pipewire');
+
+// Ensure Chromium tries to use PipeWire for screen sharing on Linux
+app.commandLine.appendSwitch('enable-features', 'WebRTCPipeWireCapturer');
+
+function createWindow() {
+ const win = new BrowserWindow({
+ width: 1000,
+ height: 800,
+ webPreferences: {
+ preload: path.join(__dirname, 'preload.js'),
+ nodeIntegration: false,
+ contextIsolation: true,
+ }
+ });
+
+ win.loadFile('index.html');
+}
+
+app.whenReady().then(async () => {
+ // Setup the virtual microphone as soon as the app starts
+ await PipewireHelper.createVirtualMic();
+
+ createWindow();
+
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) {
+ createWindow();
+ }
+ });
+});
+
+app.on('will-quit', async () => {
+ await PipewireHelper.destroyVirtualMic();
+});
+
+app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') {
+ app.quit();
+ }
+});
+
+// Handle IPC request from renderer to get screen/audio sources
+ipcMain.handle('get-sources', async () => {
+ const inputSources = await desktopCapturer.getSources({
+ types: ['window', 'screen'],
+ fetchWindowIcons: true
+ });
+
+ return inputSources.map(source => ({
+ id: source.id,
+ name: source.name,
+ thumbnail: source.thumbnail.toDataURL()
+ }));
+});
+
+// Handle Pipewire specific audio isolation requests
+ipcMain.handle('get-audio-apps', async () => {
+ return await PipewireHelper.getAudioApplications();
+});
+
+ipcMain.handle('link-app-audio', async (event, appName) => {
+ return await PipewireHelper.linkApplicationToMic(appName);
+});
+
+// Handle saving and loading the config.json profile
+const fs = require('fs');
+const configPath = path.join(__dirname, 'config.json');
+
+ipcMain.handle('get-config', () => {
+ try {
+ if (fs.existsSync(configPath)) {
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ }
+ } catch (e) { console.error("Could not read config", e); }
+ return { serverUrl: 'http://localhost:3000', serverPassword: '' };
+});
+
+ipcMain.handle('save-config', (event, newConfig) => {
+ fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf8');
+});
diff --git a/client/package-lock.json b/client/package-lock.json
new file mode 100644
index 0000000..342d653
--- /dev/null
+++ b/client/package-lock.json
@@ -0,0 +1,888 @@
+{
+ "name": "client",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "client",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "electron": "^40.6.0",
+ "socket.io-client": "^4.8.3"
+ }
+ },
+ "node_modules/@electron/get": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
+ "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "env-paths": "^2.2.0",
+ "fs-extra": "^8.1.0",
+ "got": "^11.8.5",
+ "progress": "^2.0.3",
+ "semver": "^6.2.0",
+ "sumchecker": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "global-agent": "^3.0.0"
+ }
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
+ "node_modules/@szmarczak/http-timer": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
+ "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+ "license": "MIT",
+ "dependencies": {
+ "defer-to-connect": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@types/cacheable-request": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
+ "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-cache-semantics": "*",
+ "@types/keyv": "^3.1.4",
+ "@types/node": "*",
+ "@types/responselike": "^1.0.0"
+ }
+ },
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/keyv": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
+ "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.10.13",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
+ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/responselike": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
+ "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/boolean": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
+ "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
+ "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.6.0"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
+ "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
+ "license": "MIT",
+ "dependencies": {
+ "clone-response": "^1.0.2",
+ "get-stream": "^5.1.0",
+ "http-cache-semantics": "^4.0.0",
+ "keyv": "^4.0.0",
+ "lowercase-keys": "^2.0.0",
+ "normalize-url": "^6.0.1",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/clone-response": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
+ "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defer-to-connect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/detect-node": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/electron": {
+ "version": "40.6.0",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-40.6.0.tgz",
+ "integrity": "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@electron/get": "^2.0.0",
+ "@types/node": "^24.9.0",
+ "extract-zip": "^2.0.1"
+ },
+ "bin": {
+ "electron": "cli.js"
+ },
+ "engines": {
+ "node": ">= 12.20.55"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/engine.io-client": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
+ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.18.3",
+ "xmlhttprequest-ssl": "~2.1.1"
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-error": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+ "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/global-agent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
+ "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "es6-error": "^4.1.1",
+ "matcher": "^3.0.0",
+ "roarr": "^2.15.3",
+ "semver": "^7.3.2",
+ "serialize-error": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
+ "node_modules/global-agent/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/got": {
+ "version": "11.8.6",
+ "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
+ "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^4.0.0",
+ "@szmarczak/http-timer": "^4.0.5",
+ "@types/cacheable-request": "^6.0.1",
+ "@types/responselike": "^1.0.0",
+ "cacheable-lookup": "^5.0.3",
+ "cacheable-request": "^7.0.2",
+ "decompress-response": "^6.0.0",
+ "http2-wrapper": "^1.0.0-beta.5.2",
+ "lowercase-keys": "^2.0.0",
+ "p-cancelable": "^2.0.0",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http2-wrapper": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
+ "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+ "license": "MIT",
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "license": "MIT"
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/jsonfile": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/lowercase-keys": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+ "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/matcher": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
+ "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
+ "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/normalize-url": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
+ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "license": "MIT"
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "license": "MIT"
+ },
+ "node_modules/responselike": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
+ "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
+ "license": "MIT",
+ "dependencies": {
+ "lowercase-keys": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/roarr": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
+ "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "dependencies": {
+ "boolean": "^3.0.1",
+ "detect-node": "^2.0.4",
+ "globalthis": "^1.0.1",
+ "json-stringify-safe": "^5.0.1",
+ "semver-compare": "^1.0.0",
+ "sprintf-js": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/semver-compare": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
+ "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/serialize-error": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
+ "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "type-fest": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/socket.io-client": {
+ "version": "4.8.3",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
+ "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1",
+ "engine.io-client": "~6.6.1",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
+ "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "license": "BSD-3-Clause",
+ "optional": true
+ },
+ "node_modules/sumchecker": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
+ "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
+ "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
+ "license": "(MIT OR CC0-1.0)",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
+ "node_modules/universalify": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xmlhttprequest-ssl": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+ "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ }
+ }
+}
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..1948b74
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "client",
+ "version": "1.0.0",
+ "description": "",
+ "main": "main.js",
+ "scripts": {
+ "start": "electron ."
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs",
+ "dependencies": {
+ "electron": "^40.6.0",
+ "socket.io-client": "^4.8.3"
+ }
+}
diff --git a/client/pipewire.js b/client/pipewire.js
new file mode 100644
index 0000000..0fa8913
--- /dev/null
+++ b/client/pipewire.js
@@ -0,0 +1,148 @@
+const { exec } = require('child_process');
+const util = require('util');
+const execAsync = util.promisify(exec);
+
+const VIRT_MIC_NAME = 'simplescreenshare-audio';
+
+// Pipewire helper class to isolate application audio
+class PipewireHelper {
+
+ // Create a virtual microphone (a null-audio-sink) that Electron can listen to
+ static async createVirtualMic() {
+ try {
+ await execAsync(`pw-cli destroy ${VIRT_MIC_NAME}`).catch(() => {}); // Cleanup old
+
+ const cmd = `pw-cli create-node adapter '{ factory.name=support.null-audio-sink node.name=${VIRT_MIC_NAME} node.description="SimpleScreenshare Audio" media.class=Audio/Source/Virtual object.linger=1 audio.position=[FL,FR] }'`;
+ const { stdout } = await execAsync(cmd);
+ console.log("Created virtual mic:", stdout.trim());
+
+ // Wait a moment for Pipewire to register it
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return true;
+ } catch (error) {
+ console.error("Failed to create virtual mic:", error);
+ return false;
+ }
+ }
+
+ // Destroy the virtual microphone
+ static async destroyVirtualMic() {
+ try {
+ // Find node ID
+ const { stdout } = await execAsync(`pw-cli dump short Node`);
+ const lines = stdout.split('\\n');
+ for (const line of lines) {
+ if (line.includes(VIRT_MIC_NAME)) {
+ const id = line.split(',')[0].trim();
+ await execAsync(`pw-cli destroy ${id}`);
+ console.log(`Destroyed virtual mic (ID: ${id})`);
+ }
+ }
+ } catch (error) {
+ console.error("Failed to destroy virtual mic:", error);
+ }
+ }
+
+ // Get all running applications currently playing audio
+ static async getAudioApplications() {
+ try {
+ // pw-dump outputs the entire graph as JSON, increase buffer!
+ const { stdout } = await execAsync('pw-dump', { maxBuffer: 1024 * 1024 * 50 });
+ const dump = JSON.parse(stdout);
+
+ // Filter out audio output streams (applications playing sound)
+ const apps = dump.filter(node =>
+ node.info &&
+ node.info.props &&
+ node.info.props['media.class'] === 'Stream/Output/Audio' &&
+ node.info.props['application.name']
+ ).map(node => ({
+ id: node.id,
+ name: node.info.props['application.name'],
+ mediaName: node.info.props['media.name'] || 'Unknown Stream'
+ }));
+
+ // Deduplicate by application name and return
+ const uniqueApps = [];
+ const seen = new Set();
+ for (const app of apps) {
+ if (!seen.has(app.name)) {
+ seen.add(app.name);
+ uniqueApps.push(app);
+ }
+ }
+ return uniqueApps;
+
+ } catch (error) {
+ console.error("Failed to get audio applications:", error);
+ return [];
+ }
+ }
+
+ // Link a target application's output to our Virtual Microphone
+ static async linkApplicationToMic(targetAppName) {
+ try {
+ // 1. Get the dump with massive buffer
+ const { stdout: dumpOut } = await execAsync('pw-dump', { maxBuffer: 1024 * 1024 * 50 });
+ const dump = JSON.parse(dumpOut);
+
+ // 2. Find the target application node
+ const targetNode = dump.find(node =>
+ node.info &&
+ node.info.props &&
+ node.info.props['media.class'] === 'Stream/Output/Audio' &&
+ node.info.props['application.name'] === targetAppName
+ );
+
+ // 3. Find our virtual mic node
+ const micNode = dump.find(node =>
+ node.info &&
+ node.info.props &&
+ node.info.props['node.name'] === VIRT_MIC_NAME
+ );
+
+ if (!targetNode || !micNode) {
+ console.error("Could not find target node or virtual mic node");
+ return false;
+ }
+
+ console.log(`Linking ${targetAppName} (ID: ${targetNode.id}) to ${VIRT_MIC_NAME} (ID: ${micNode.id})`);
+
+ // 4. Find the Ports for both nodes
+ const ports = dump.filter(n => n.type === 'PipeWire:Interface:Port');
+
+ // Target App Output Ports
+ const targetPorts = ports.filter(p => p.info.props['node.id'] === targetNode.id && p.info.direction === 'output');
+ const targetFL = targetPorts.find(p => p.info.props['audio.channel'] === 'FL');
+ const targetFR = targetPorts.find(p => p.info.props['audio.channel'] === 'FR');
+
+ // Virtual Mic Input Ports
+ const micPorts = ports.filter(p => p.info.props['node.id'] === micNode.id && p.info.direction === 'input');
+ const micFL = micPorts.find(p => p.info.props['audio.channel'] === 'FL');
+ const micFR = micPorts.find(p => p.info.props['audio.channel'] === 'FR');
+
+ if (!targetFL || !targetFR || !micFL || !micFR) {
+ console.error("Could not find stereo ports for linking");
+ return false;
+ }
+
+ // 5. Link them using port aliases!
+ const targetFlAlias = targetFL.info.props['port.alias'] || targetFL.info.props['object.path'] || targetFL.id;
+ const targetFrAlias = targetFR.info.props['port.alias'] || targetFR.info.props['object.path'] || targetFR.id;
+ const micFlAlias = micFL.info.props['port.alias'] || micFL.info.props['object.path'] || micFL.id;
+ const micFrAlias = micFR.info.props['port.alias'] || micFR.info.props['object.path'] || micFR.id;
+
+ await execAsync(`pw-link "${targetFlAlias}" "${micFlAlias}"`).catch(e => console.log("pw-link output:", e.message));
+ await execAsync(`pw-link "${targetFrAlias}" "${micFrAlias}"`).catch(e => console.log("pw-link output:", e.message));
+
+ console.log("Successfully linked audio.");
+ return true;
+
+ } catch (error) {
+ console.error("Failed to link application:", error);
+ return false;
+ }
+ }
+}
+
+module.exports = PipewireHelper;
diff --git a/client/preload.js b/client/preload.js
new file mode 100644
index 0000000..9123d74
--- /dev/null
+++ b/client/preload.js
@@ -0,0 +1,9 @@
+const { contextBridge, ipcRenderer } = require('electron');
+
+contextBridge.exposeInMainWorld('electronAPI', {
+ getSources: () => ipcRenderer.invoke('get-sources'),
+ getAudioApps: () => ipcRenderer.invoke('get-audio-apps'),
+ linkAppAudio: (appName) => ipcRenderer.invoke('link-app-audio', appName),
+ getConfig: () => ipcRenderer.invoke('get-config'),
+ saveConfig: (config) => ipcRenderer.invoke('save-config', config)
+});
diff --git a/client/renderer.js b/client/renderer.js
new file mode 100644
index 0000000..236c228
--- /dev/null
+++ b/client/renderer.js
@@ -0,0 +1,368 @@
+const serverUrlInput = document.getElementById('serverUrl');
+const serverPasswordInput = document.getElementById('serverPassword');
+const sourcesGrid = document.getElementById('sourcesGrid');
+const audioSelect = document.getElementById('audioSelect');
+const getSourcesBtn = document.getElementById('getSourcesBtn');
+const startBtn = document.getElementById('startBtn');
+const stopBtn = document.getElementById('stopBtn');
+const localVideo = document.getElementById('localVideo');
+const statusText = document.getElementById('statusText');
+const statsPanel = document.getElementById('statsPanel');
+
+let socket;
+let peerConnections = {};
+let activeStream;
+let selectedVideoSourceId = null;
+
+const config = {
+ iceServers: [
+ { urls: "stun:localhost:3478" },
+ { urls: "turn:localhost:3478", username: "myuser", credential: "mypassword" }
+ ]
+};
+
+// 1. Get Desktop Sources from Main Process and populate raw select tags
+// Also enumerate native audio devices from navigator!
+getSourcesBtn.addEventListener('click', async () => {
+ sourcesGrid.innerHTML = 'Loading sources...
';
+ audioSelect.innerHTML = 'Loading audio devices... ';
+ startBtn.disabled = true;
+ selectedVideoSourceId = null;
+
+ try {
+ // --- Fetch Virtual Video Sources ---
+ const sources = await window.electronAPI.getSources();
+ sourcesGrid.innerHTML = '';
+ sources.forEach(source => {
+ const item = document.createElement('div');
+ item.className = 'source-item';
+
+ const img = document.createElement('img');
+ img.src = source.thumbnail;
+
+ const label = document.createElement('span');
+ label.innerText = source.name;
+ label.title = source.name;
+
+ item.appendChild(img);
+ item.appendChild(label);
+
+ item.addEventListener('click', () => {
+ document.querySelectorAll('.source-item').forEach(i => i.classList.remove('selected'));
+ item.classList.add('selected');
+ selectedVideoSourceId = source.id;
+ startPreview(source.id);
+ startBtn.disabled = false;
+ });
+
+ sourcesGrid.appendChild(item);
+ });
+
+ // --- Fetch Application Audio Sources via built Pipewire Helper ---
+ const audioApps = await window.electronAPI.getAudioApps();
+ audioSelect.innerHTML = 'No Audio (Video Only) ';
+ audioApps.forEach(app => {
+ const option = document.createElement('option');
+ // We pass the actual application name into the value so the main process can find it via pw-dump
+ option.value = app.name;
+ option.text = `${app.name} (${app.mediaName})`;
+ audioSelect.appendChild(option);
+ });
+
+ // If we don't disable start button here, it would be enabled before user clicked a grid item
+ startBtn.disabled = true;
+ } catch (e) {
+ console.error(e);
+ sourcesGrid.innerHTML = 'Error loading sources
';
+ audioSelect.innerHTML = 'Error loading audio ';
+ }
+});
+
+// --- Preview Stream Logic ---
+let previewStream = null;
+
+async function startPreview(videoSourceId) {
+ // Cleanup previous preview
+ if (previewStream) {
+ previewStream.getTracks().forEach(t => t.stop());
+ previewStream = null;
+ }
+
+ if (!videoSourceId) {
+ localVideo.style.display = 'none';
+ return;
+ }
+
+ try {
+ previewStream = await navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ chromeMediaSourceId: videoSourceId,
+ }
+ }
+ });
+
+ // Removed 1080p ideal limit to prevent Chromium from green-padding non-16:9 window captures!
+ const videoTrack = previewStream.getVideoTracks()[0];
+ await videoTrack.applyConstraints({ frameRate: { ideal: 60 } });
+
+ localVideo.srcObject = previewStream;
+ localVideo.style.display = 'block';
+ } catch (e) {
+ console.error("Failed to start preview stream:", e);
+ }
+}
+
+// 2. Start Broadcast
+startBtn.addEventListener('click', async () => {
+ const url = serverUrlInput.value;
+ const password = serverPasswordInput.value;
+ const videoSourceId = selectedVideoSourceId;
+ const targetAppName = audioSelect.value;
+
+ if (!videoSourceId || !url || !password) {
+ alert("Please fill out URL, Password, and select a visual source.");
+ return;
+ }
+
+ // Save credentials for next time
+ window.electronAPI.saveConfig({ serverUrl: url, serverPassword: password });
+
+ try {
+ // Stop the preview grab so we can grab the real stream cleanly
+ if (previewStream) {
+ previewStream.getTracks().forEach(t => t.stop());
+ previewStream = null;
+ }
+
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: {
+ mandatory: {
+ chromeMediaSource: 'desktop',
+ chromeMediaSourceId: videoSourceId,
+ }
+ }
+ });
+
+ const videoTrack = stream.getVideoTracks()[0];
+ await videoTrack.applyConstraints({ frameRate: { ideal: 60 } });
+
+ // If user selected an application, grab the Virtual Mic input and link the app to it!
+ if (targetAppName && targetAppName !== 'none') {
+ const linked = await window.electronAPI.linkAppAudio(targetAppName);
+ if (linked) {
+ // Now that the pipewire graph is linked, we just need to read from our Virtual Mic sink!
+ // Chromium registers this as a standard Input device
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const virtMic = devices.find(d => d.kind === 'audioinput' && d.label.toLowerCase().includes('simplescreenshare'));
+
+ if (virtMic) {
+ const audioStream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ deviceId: { exact: virtMic.deviceId },
+ echoCancellation: false,
+ autoGainControl: false,
+ noiseSuppression: false,
+ googAutoGainControl: false,
+ googEchoCancellation: false,
+ googNoiseSuppression: false,
+ googHighpassFilter: false,
+ channelCount: 2,
+ sampleRate: 48000
+ },
+ video: false
+ });
+ stream.addTrack(audioStream.getAudioTracks()[0]);
+ } else {
+ console.warn("Virtual mic device not found in navigator enumeration");
+ }
+ } else {
+ alert("Failed to link application audio. Broadcasting video only.");
+ }
+ }
+
+ activeStream = stream;
+ localVideo.srcObject = stream;
+ localVideo.style.display = 'block';
+
+ connectAndBroadcast(url, password);
+
+ startBtn.style.display = 'none';
+ stopBtn.style.display = 'inline-block';
+ statsPanel.style.display = 'block';
+ statusText.innerText = `Broadcasting to ${url}`;
+
+ // Auto stop if user closes the requested window
+ stream.getVideoTracks()[0].onended = stopSharing;
+
+ } catch (e) {
+ console.error("Stream capture error:", e);
+ alert("Failed to capture screen. See console for details.");
+ }
+});
+
+function connectAndBroadcast(url, password) {
+ socket = io(url);
+
+ socket.on('connect', () => {
+ socket.emit('broadcaster', password);
+ });
+
+ socket.on('authError', (msg) => {
+ alert(msg);
+ stopSharing();
+ });
+
+ socket.on('viewer', id => {
+ if (!activeStream) return;
+
+ const peerConnection = new RTCPeerConnection(config);
+ peerConnections[id] = peerConnection;
+
+ activeStream.getTracks().forEach(track => {
+ const sender = peerConnection.addTrack(track, activeStream);
+ if (track.kind === 'video') {
+ const params = sender.getParameters();
+ if (!params.encodings) params.encodings = [{}];
+ params.encodings[0].maxBitrate = 10000000;
+ sender.setParameters(params).catch(e => console.error(e));
+ } else if (track.kind === 'audio') {
+ const params = sender.getParameters();
+ if (!params.encodings) params.encodings = [{}];
+ params.encodings[0].maxBitrate = 510000; // max Opus bitrate
+ sender.setParameters(params).catch(e => console.error(e));
+ }
+ });
+
+ peerConnection.onicecandidate = event => {
+ if (event.candidate) {
+ socket.emit('candidate', id, event.candidate);
+ }
+ };
+
+ peerConnection.createOffer().then(sdp => {
+ if (window.RTCRtpSender && window.RTCRtpSender.getCapabilities) {
+ const caps = window.RTCRtpSender.getCapabilities('video');
+ if (caps && caps.codecs) {
+ const h264 = caps.codecs.filter(c => c.mimeType.toLowerCase() === 'video/h264' || c.mimeType.toLowerCase() === 'video/vp8');
+ const transceivers = peerConnection.getTransceivers();
+ transceivers.forEach(t => {
+ if (t.receiver.track.kind === 'video') t.setCodecPreferences(h264);
+ });
+ }
+ }
+
+ // WebRTC defaults to voice-optimized ~32kbps mono. Let's force high-fidelity stereo!
+ let sdpLines = sdp.sdp.split('\r\n');
+ let opusPayloadType = null;
+ for (let i = 0; i < sdpLines.length; i++) {
+ if (sdpLines[i].includes('a=rtpmap:') && sdpLines[i].includes('opus/48000/2')) {
+ const match = sdpLines[i].match(/a=rtpmap:(\d+) /);
+ if (match) opusPayloadType = match[1];
+ }
+ }
+ if (opusPayloadType) {
+ let fmtpFound = false;
+ for (let i = 0; i < sdpLines.length; i++) {
+ if (sdpLines[i].startsWith(`a=fmtp:${opusPayloadType}`)) {
+ // Completely overwrite the opus config for pristine stereo
+ sdpLines[i] = `a=fmtp:${opusPayloadType} minptime=10;useinbandfec=1;maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=510000;cbr=1`;
+ fmtpFound = true;
+ }
+ }
+ if (!fmtpFound) {
+ sdpLines.push(`a=fmtp:${opusPayloadType} minptime=10;useinbandfec=1;maxplaybackrate=48000;stereo=1;sprop-stereo=1;maxaveragebitrate=510000;cbr=1`);
+ }
+ }
+ sdp.sdp = sdpLines.join('\r\n');
+
+ return peerConnection.setLocalDescription(sdp);
+ }).then(() => {
+ socket.emit('offer', id, peerConnection.localDescription);
+ });
+ });
+
+ socket.on('answer', (id, description) => {
+ if (peerConnections[id]) peerConnections[id].setRemoteDescription(description);
+ });
+
+ socket.on('candidate', (id, candidate) => {
+ if (peerConnections[id]) peerConnections[id].addIceCandidate(new RTCIceCandidate(candidate));
+ });
+
+ socket.on('disconnectPeer', id => {
+ if (peerConnections[id]) {
+ peerConnections[id].close();
+ delete peerConnections[id];
+ }
+ });
+}
+
+function stopSharing() {
+ if (activeStream) {
+ activeStream.getTracks().forEach(t => t.stop());
+ activeStream = null;
+ }
+ if (socket) {
+ socket.disconnect();
+ socket = null;
+ }
+ Object.values(peerConnections).forEach(pc => pc.close());
+ peerConnections = {};
+
+ localVideo.style.display = 'none';
+ statsPanel.style.display = 'none';
+ startBtn.style.display = 'inline-block';
+ stopBtn.style.display = 'none';
+ statusText.innerText = 'Not Broadcasting';
+}
+
+stopBtn.addEventListener('click', stopSharing);
+
+// --- Stats Monitoring Loop ---
+let lastBytesSent = 0;
+let lastTimestamp = 0;
+
+setInterval(async () => {
+ if (!activeStream || Object.keys(peerConnections).length === 0) return;
+
+ // Get stats from the first active peer connection
+ const pc = Object.values(peerConnections)[0];
+ if (!pc) return;
+
+ try {
+ const stats = await pc.getStats();
+ stats.forEach(report => {
+ if (report.type === 'outbound-rtp' && report.kind === 'video') {
+ const fps = report.framesPerSecond || 0;
+ const bytesSent = report.bytesSent || 0;
+ const timestamp = report.timestamp;
+ const res = `${report.frameWidth || 0}x${report.frameHeight || 0}`;
+
+ let bitrate = 0;
+ if (lastTimestamp && lastBytesSent) {
+ const timeDiff = timestamp - lastTimestamp; // ms
+ const bytesDiff = bytesSent - lastBytesSent;
+ // convert bytes/ms to kbps: (bytes * 8 / 1000) / (timeDiff / 1000) => (bytes * 8) / timeDiff
+ bitrate = Math.round((bytesDiff * 8) / timeDiff);
+ }
+ lastBytesSent = bytesSent;
+ lastTimestamp = timestamp;
+
+ document.getElementById('statsFps').innerText = fps;
+ document.getElementById('statsRes').innerText = res;
+ document.getElementById('statsBitrate').innerText = bitrate + ' kbps';
+ }
+ });
+ } catch (e) { console.error("Stats error", e); }
+}, 1000);
+
+// Initial load of sources & config
+window.electronAPI.getConfig().then(cfg => {
+ if (cfg.serverUrl) serverUrlInput.value = cfg.serverUrl;
+ if (cfg.serverPassword) serverPasswordInput.value = cfg.serverPassword;
+});
+getSourcesBtn.click();
diff --git a/client/test-devices.js b/client/test-devices.js
new file mode 100644
index 0000000..b25e942
--- /dev/null
+++ b/client/test-devices.js
@@ -0,0 +1,12 @@
+const { app, BrowserWindow } = require('electron');
+app.whenReady().then(() => {
+ const win = new BrowserWindow({ show: false });
+ win.webContents.executeJavaScript(`
+ navigator.mediaDevices.getUserMedia({audio:true}).then(() =>
+ navigator.mediaDevices.enumerateDevices()
+ ).then(devices => devices.map(d => d.label))
+ `).then(labels => {
+ console.log("LABELS:", labels.filter(l => l.toLowerCase().includes('screenshare')));
+ app.quit();
+ });
+});
diff --git a/public/viewer.html b/public/viewer.html
index 341e335..decec73 100644
--- a/public/viewer.html
+++ b/public/viewer.html
@@ -15,6 +15,7 @@
Connecting to Broadcaster
+ Click anywhere to enable audio once connected
diff --git a/public/viewer.js b/public/viewer.js
index 79dd59f..5eb7564 100644
--- a/public/viewer.js
+++ b/public/viewer.js
@@ -23,6 +23,13 @@ socket.on('offer', (id, description) => {
overlay.classList.add('hidden');
};
+ // Auto-unmute when the user interacts with the document to bypass browser// Auto-unmute when the user interacts with the document to bypass browser
+document.addEventListener('click', () => {
+ remoteVideo.muted = false;
+ // Set sink to max possible volume explicitly avoiding browser gain staging
+ remoteVideo.volume = 1.0;
+}, {once: true});
+
peerConnection.onicecandidate = event => {
if (event.candidate) {
socket.emit('candidate', id, event.candidate);