From 56e59a5e2b5d997d4e98c00a63083fd1770d64f9 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 23 Feb 2026 04:06:02 +0100 Subject: [PATCH] feat: Implement an Electron-based broadcasting client with screen/window and audio source selection, including Pipewire integration, and add auto-unmute to the viewer. --- .gitignore | 1 + client/index.html | 176 ++++++++ client/main.js | 83 ++++ client/package-lock.json | 888 +++++++++++++++++++++++++++++++++++++++ client/package.json | 17 + client/pipewire.js | 148 +++++++ client/preload.js | 9 + client/renderer.js | 368 ++++++++++++++++ client/test-devices.js | 12 + public/viewer.html | 1 + public/viewer.js | 7 + 11 files changed, 1710 insertions(+) create mode 100644 client/index.html create mode 100644 client/main.js create mode 100644 client/package-lock.json create mode 100644 client/package.json create mode 100644 client/pipewire.js create mode 100644 client/preload.js create mode 100644 client/renderer.js create mode 100644 client/test-devices.js 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

+ +
+ + + + +
+

Media Sources

+ +
+ + +
+ + + + + +
Not Broadcasting
+ + +
+
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 = ''; + 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 = ''; + 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 = ''; + } +}); + +// --- 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);