feat: Implement an Electron-based broadcasting client with screen/window and audio source selection, including Pipewire integration, and add auto-unmute to the viewer.
This commit is contained in:
176
client/index.html
Normal file
176
client/index.html
Normal file
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Screenshare Broadcaster</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #0f172a;
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent-color: #3b82f6;
|
||||
--glass-bg: rgba(30, 41, 59, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
h1 { margin-top: 0; }
|
||||
.controls {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
input, button, select {
|
||||
font-family: inherit;
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--glass-border);
|
||||
margin-bottom: 1rem;
|
||||
width: 90%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input, select {
|
||||
background: rgba(0,0,0,0.2);
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
button {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
button:hover { background: #2563eb; transform: translateY(-2px); }
|
||||
button:disabled { background: #475569; cursor: not-allowed; transform: none; }
|
||||
video {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
border-radius: 12px;
|
||||
background: #000;
|
||||
display: none;
|
||||
}
|
||||
.status { color: var(--text-secondary); margin-bottom: 1rem; }
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 90%;
|
||||
margin: 0 auto 0.5rem auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Source Grid */
|
||||
.sources-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
width: 90%;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
margin: 0 auto 1.5rem auto;
|
||||
padding: 10px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
.source-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.source-item:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.source-item.selected {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
.source-item img {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
object-fit: cover;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
.source-item span {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Stats Panel */
|
||||
.stats-panel {
|
||||
background: rgba(0,0,0,0.4);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
width: 90%;
|
||||
margin: 1rem auto;
|
||||
text-align: left;
|
||||
display: none;
|
||||
font-family: monospace;
|
||||
font-size: 1.1rem;
|
||||
color: #10b981; /* green text */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Broadcaster Client</h1>
|
||||
|
||||
<div class="controls" id="controlsPanel">
|
||||
<label class="label">Server Connection</label>
|
||||
<input type="text" id="serverUrl" placeholder="Server URL (e.g. http://localhost:3000)" value="http://localhost:3000">
|
||||
<input type="password" id="serverPassword" placeholder="Stream Password">
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; width: 90%; margin: 10px auto;">
|
||||
<h3 style="margin:0;">Media Sources</h3>
|
||||
<button id="getSourcesBtn" style="width:auto; margin:0; padding: 0.4rem 0.8rem;">Refresh Devices</button>
|
||||
</div>
|
||||
|
||||
<label class="label">Visual Source (Screen/Window)</label>
|
||||
<div id="sourcesGrid" class="sources-grid"></div>
|
||||
|
||||
<label class="label">Audio Source (Microphone/Pipewire Virtual Sinks)</label>
|
||||
<select id="audioSelect"></select>
|
||||
|
||||
<button id="startBtn" disabled style="margin-top: 1.5rem;">Start Broadcast</button>
|
||||
<div class="status" id="statusText">Not Broadcasting</div>
|
||||
<button id="stopBtn" style="display:none; background:#ef4444;">Stop Broadcast</button>
|
||||
|
||||
<div class="stats-panel" id="statsPanel">
|
||||
<div><strong>Resolution:</strong> <span id="statsRes">0x0</span></div>
|
||||
<div><strong>FPS:</strong> <span id="statsFps">0</span></div>
|
||||
<div><strong>Upstream:</strong> <span id="statsBitrate">0 kbps</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<video id="localVideo" autoplay playsinline muted></video>
|
||||
|
||||
<!-- Use socket.io client script locally installed via npm -->
|
||||
<script src="./node_modules/socket.io-client/dist/socket.io.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
83
client/main.js
Normal file
83
client/main.js
Normal file
@@ -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');
|
||||
});
|
||||
888
client/package-lock.json
generated
Normal file
888
client/package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
client/package.json
Normal file
17
client/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
148
client/pipewire.js
Normal file
148
client/pipewire.js
Normal file
@@ -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;
|
||||
9
client/preload.js
Normal file
9
client/preload.js
Normal file
@@ -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)
|
||||
});
|
||||
368
client/renderer.js
Normal file
368
client/renderer.js
Normal file
@@ -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 = '<div style="color:var(--text-secondary); width:100%;">Loading sources...</div>';
|
||||
audioSelect.innerHTML = '<option value="">Loading audio devices...</option>';
|
||||
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 = '<option value="none">No Audio (Video Only)</option>';
|
||||
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 = '<div style="color:red; width:100%;">Error loading sources</div>';
|
||||
audioSelect.innerHTML = '<option value="none">Error loading audio</option>';
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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();
|
||||
12
client/test-devices.js
Normal file
12
client/test-devices.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user