diff options
Diffstat (limited to 'packages/meshbay-client')
| -rw-r--r-- | packages/meshbay-client/src/main.js | 170 |
1 files changed, 169 insertions, 1 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 122a302..755f1f7 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -41,6 +41,19 @@ const { pathToFileURL } = require('node:url'); // runs (`electron .`) consistent with it. app.commandLine.appendSwitch('class', 'MeshBay'); +// Chromium reads `--enable-features` and `--disable-features` as one +// comma-joined list each, and `appendSwitch` **replaces** that list rather +// than adding to it. Two call sites therefore cancel the first silently: no +// error, nothing in a log, just a feature that is quietly not on. The +// hardware-decode section further down is the second caller, so both go +// through this — and so must the third. +function addFeatures(kind, names) { + const current = app.commandLine.getSwitchValue(kind); + const merged = new Set(current ? current.split(',').filter(Boolean) : []); + for (const name of names) merged.add(name); + app.commandLine.appendSwitch(kind, [...merged].join(',')); +} + // Chromium publishes host candidates as random `.local` mDNS names rather // than as the real local IP. Every peer this client talks to is a node running // aiortc/aioice, and aioice has no mDNS resolver on any platform: it logs @@ -56,7 +69,7 @@ app.commandLine.appendSwitch('class', 'MeshBay'); // *client*; the platform that matters is the peer's, not ours, and the peer is // always a node. The trade is that our private address reaches the hub and the // node in the SDP — both the user's own infrastructure, not an arbitrary page. -app.commandLine.appendSwitch('disable-features', 'WebRtcHideLocalIpsWithMdns'); +addFeatures('disable-features', ['WebRtcHideLocalIpsWithMdns']); const UI_DIR = path.join(__dirname, '..', 'ui'); const SCHEME = 'app'; @@ -245,6 +258,159 @@ function writeConfig(next) { let config = readConfig(); +// ── Hardware video decoding (Linux) ───────────────────────────────────────── +// +// Chromium ships VA-API off on Linux; macOS and Windows decode H.264 in +// hardware without being asked, so everything here is Linux-only. +// +// It pays off exactly where it is needed. A 1080p H.264 film is the streaming +// path's normal case — the node hands the bytes over untouched (`-c:v copy`, +// webrtc_server.py) and all the decoding happens here — and every Intel iGPU +// of the last decade decodes that in fixed-function silicon, while an +// Atom/Celeron-class CPU cannot hold 25 fps at 1080p in software. +// +// Three decisions, written down because each replaces something a person +// would otherwise have to do by hand: +// +// - **Detected, not configured.** One package installs on every machine, so +// the switches cannot be chosen at build time and there is no second +// "low-power" package. The probe is two filesystem checks and runs before +// `whenReady`, because Chromium reads its command line exactly once. +// +// - **The feature name is not stable, so it is not trusted.** +// `VaapiVideoDecodeLinuxGL` has been renamed across Chromium releases, and +// `build-client.sh` deliberately rebuilds against the *latest* Electron +// every time — a name pinned here would stop matching one bump later with +// nothing failing to say so. Both known names are passed (Chromium ignores +// an unknown one) and the outcome is measured rather than assumed. +// +// - **The verdict is measured, remembered, and re-opened by a Chromium +// bump.** `measureVideoDecoding` asks the renderer whether the codec the +// node really streams decodes `powerEfficient`ly — Chromium's own answer to +// "is this hardware", which is what reading it out of devtools would have +// told you. A no moves to the next set of switches on the next launch; when +// the list runs out they are dropped altogether, so `--ignore-gpu-blocklist` +// cannot leave a machine with a broken GL stack paying for a feature it +// never got. +// +// `videoAcceleration` in config.json overrides the lot: "off" never applies +// anything, "on" applies it even where the probe finds no driver. + +const VAAPI_FEATURES = ['VaapiVideoDecodeLinuxGL', 'AcceleratedVideoDecodeLinuxGL']; + +// One per launch, in order, until the measurement says hardware. These are not +// a guess about *this* Chromium — they are the ways its GL backend has had to +// be named, and the machine picks which one is true for it. +const VAAPI_ATTEMPTS = [ + [], + [['use-gl', 'egl']], + [['use-gl', 'angle'], ['use-angle', 'gl']], +]; + +function vaapiDriverInstalled() { + const dirs = [process.env.LIBVA_DRIVERS_PATH, '/usr/lib/x86_64-linux-gnu/dri', + '/usr/lib/aarch64-linux-gnu/dri', '/usr/lib64/dri', '/usr/lib/dri']; + for (const dir of dirs) { + if (!dir) continue; + try { + if (fs.readdirSync(dir).some((f) => f.endsWith('_drv_video.so'))) return true; + } catch { /* no such directory on this distribution */ } + } + return false; +} + +// Readable *and* writable: VA-API maps buffers on the render node. On a +// desktop session logind grants that through an ACL rather than through group +// membership, so asking the kernel answers "can this process use it", which is +// the question — rather than "is this user in `render`", which is not. +function renderNode() { + try { + for (const name of fs.readdirSync('/dev/dri')) { + if (!name.startsWith('renderD')) continue; + const dev = `/dev/dri/${name}`; + try { + fs.accessSync(dev, fs.constants.R_OK | fs.constants.W_OK); + return dev; + } catch { /* another GPU may still be usable */ } + } + } catch { /* no /dev/dri at all */ } + return null; +} + +// Which attempt this launch is running, or null when no switches were applied +// — which is also what stops a second window measuring the same launch twice. +let vaapiAttempt = null; + +function configureVideoDecoding() { + if (process.platform !== 'linux') return; + const mode = config.videoAcceleration || 'auto'; + if (mode === 'off') return; + const forced = mode === 'on'; + if (!forced && !(renderNode() && vaapiDriverInstalled())) { + console.log('[video] no usable VA-API driver — decoding in software'); + return; + } + const probe = config.videoDecodeProbe || {}; + const known = probe.chrome === process.versions.chrome; + let attempt = (known && typeof probe.attempt === 'number') ? probe.attempt : 0; + if (attempt >= VAAPI_ATTEMPTS.length) { + if (!forced) { + console.log('[video] VA-API never took on this machine — decoding in software'); + return; + } + // "on" is the person overruling the measurement, so the list running out + // is not an answer here — it keeps the last set rather than climbing an + // index nothing will ever read again. + attempt = VAAPI_ATTEMPTS.length - 1; + } + addFeatures('enable-features', VAAPI_FEATURES); + app.commandLine.appendSwitch('ignore-gpu-blocklist'); + for (const [name, value] of VAAPI_ATTEMPTS[attempt]) + app.commandLine.appendSwitch(name, value); + vaapiAttempt = attempt; +} + +// `avc1.640029` is H.264 High 4.1 — what the node announces for a re-encode +// (webrtc_server.py) and the profile a copied 1080p film carries. Asking about +// the codec that is actually streamed is the point: a machine can decode +// H.264 in hardware and still answer no for HEVC, and the reverse. +const DECODE_PROBE = `navigator.mediaCapabilities.decodingInfo({ + type: 'media-source', + video: { + contentType: 'video/mp4; codecs="avc1.640029"', + width: 1920, height: 1080, bitrate: 5000000, framerate: 25, + }, +}).then((r) => !!r.powerEfficient).catch(() => false)`; + +async function measureVideoDecoding(win) { + const attempt = vaapiAttempt; + vaapiAttempt = null; + if (attempt === null) return; + let hardware; + try { + hardware = await win.webContents.executeJavaScript(DECODE_PROBE); + } catch { + return; // the window went away; the question is asked again next launch + } + const gpu = app.getGPUFeatureStatus().video_decode; + console.log(`[video] hardware decoding: ${hardware} ` + + `(attempt ${attempt}, gpu video_decode: ${gpu})`); + config = { + ...config, + videoDecodeProbe: { + chrome: process.versions.chrome, + attempt: hardware ? attempt : attempt + 1, + hardware, + gpu, + }, + }; + writeConfig(config); + if (!hardware && attempt + 1 < VAAPI_ATTEMPTS.length) + console.log('[video] another GL backend will be tried on the next launch'); +} + +configureVideoDecoding(); + // ── Secrets ───────────────────────────────────────────────────────────────── // // The OS keychain, through safeStorage. What it protects and what it does not @@ -482,6 +648,8 @@ function createWindow() { }); win.once('ready-to-show', () => win.show()); + // Once per launch, and only when the section above applied switches. + win.webContents.once('did-finish-load', () => { measureVideoDecoding(win); }); // Some Wayland compositors (observed under GNOME/Mutter on a VM with a // virtio-gpu device whose command-buffer creation fails) never schedule a // first paint for a surface that isn't mapped yet — but Electron won't map |