Merge pull request #100 from got-feedBack/fix/loopback-capture-permission
Some checks are pending
Addon CI / addon (arm64, macos-14, mac) (push) Waiting to run
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Waiting to run
Addon CI / addon (x64, windows-latest, win) (push) Waiting to run
Ship CI / CI (push) Waiting to run

fix(main): permission handler was denying whole-app loopback capture
This commit is contained in:
OmikronApex 2026-07-12 01:51:29 +02:00 committed by GitHub
commit ce242a6bcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -509,11 +509,23 @@ function createWindow(port: number): void {
// other applications' audio is NOT captured — a system 'loopback' would
// leak Discord/etc. into the performance mix) and any screen as the
// required-but-unused video track (the feeder stops it immediately).
// [asio-diag] every outcome below is logged: a renderer-side
// NotAllowedError with NO handler line in the main log means the running
// main predates this handler (stale-main/new-bundle mix).
session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
console.log('[asio-diag] display-media request received (loopback capture)');
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
if (!mainWindow || sources.length === 0) { callback({}); return; }
if (!mainWindow || sources.length === 0) {
console.warn(`[asio-diag] display-media DENIED: mainWindow=${!!mainWindow} screenSources=${sources.length}`);
callback({});
return;
}
console.log('[asio-diag] display-media granted: frame audio + screen video');
callback({ video: sources[0], audio: mainWindow.webContents.mainFrame });
}).catch(() => callback({}));
}).catch((e) => {
console.warn(`[asio-diag] display-media DENIED: getSources failed: ${e?.message ?? e}`);
callback({});
});
});
// Local-mute companion for the capture above: when the feeder engages
@ -880,42 +892,65 @@ function installRendererPermissions(rendererPort: number): void {
const isRendererOrigin = makeRendererOriginPredicate(rendererPort);
const def = session.defaultSession;
def.setPermissionRequestHandler((_wc, permission, callback, details) => {
if (DENY_PERMISSIONS.has(permission)) {
// [asio-diag] getDisplayMedia (loopback capture) rides this path
// ('display-capture' and/or 'media' with video) BEFORE the
// display-media handler — log every deny so an upstream permission
// denial is distinguishable from a handler-level one.
const deny = (why: string) => {
console.warn(`[asio-diag] permission-request DENIED: ${permission} (${why}) url=${details.requestingUrl ?? ''}`);
callback(false);
};
if (DENY_PERMISSIONS.has(permission)) {
deny('deny-list');
return;
}
if (!isRendererOrigin(details.requestingUrl || '')) {
callback(false);
deny('non-renderer origin');
return;
}
if (permission === 'media') {
// Electron passes mediaTypes (`'audio'` / `'video'`) for the
// request-handler path. Allow only when the request is
// explicitly audio-only — Slopsmith doesn't use the camera,
// so we want both "video requested" AND "mediaTypes
// missing/empty" to deny (the latter would otherwise let an
// older / synthetic request slip through).
// Electron passes mediaTypes (`'audio'` / `'video'`) for
// getUserMedia requests. Allow explicit audio-only (microphone
// for pitch detection) and deny camera. getDisplayMedia — the
// renderer-bus whole-app loopback capture — ALSO rides this
// permission but with EMPTY mediaTypes, so empty must be
// allowed or the display-media handler below never runs and
// exclusive/ASIO output loses all page audio (song previews,
// fallback element songs). Verified 2026-07-12: the old
// "empty ⇒ deny" rule was exactly the tester's silent-preview
// bug. Camera exposure stays impossible: getDisplayMedia video
// is the app's own frame, and real camera requests always
// carry mediaTypes=['video'].
const mediaTypes = (details as { mediaTypes?: string[] }).mediaTypes;
const types = new Set(mediaTypes ?? []);
const audioOnly = types.has('audio') && !types.has('video');
callback(audioOnly);
if (types.has('video')) { deny(`media types=[${[...types].join(',')}]`); return; }
callback(true);
return;
}
callback(true);
});
def.setPermissionCheckHandler((_wc, permission, requestingOrigin, details) => {
if (DENY_PERMISSIONS.has(permission)) return false;
if (!isRendererOrigin(requestingOrigin || '')) return false;
if (DENY_PERMISSIONS.has(permission)) {
console.warn(`[asio-diag] permission-check DENIED: ${permission} (deny-list) origin=${requestingOrigin ?? ''}`);
return false;
}
if (!isRendererOrigin(requestingOrigin || '')) {
console.warn(`[asio-diag] permission-check DENIED: ${permission} (non-renderer origin) origin=${requestingOrigin ?? ''}`);
return false;
}
if (permission === 'media') {
// Mirror the request-handler's audio-only policy in the
// synchronous check path so navigator.permissions.query()
// reports the same state we'll actually grant. Electron's
// check-handler `details` exposes `mediaType` (singular),
// unlike the request-handler's `mediaTypes` (plural array).
// Treat `'video'` and `'unknown'` as denied, only explicit
// `'audio'` as granted.
// Mirror the request-handler in the synchronous check path so
// navigator.permissions.query() reports the same state we'll
// actually grant: deny camera ('video'), allow audio and the
// type-less display-capture path. Electron's check-handler
// `details` exposes `mediaType` (singular), unlike the
// request-handler's `mediaTypes` (plural array).
const mediaType = (details as { mediaType?: string }).mediaType;
return mediaType === 'audio';
if (mediaType === 'video') {
console.warn(`[asio-diag] permission-check DENIED: media (mediaType=video) origin=${requestingOrigin ?? ''}`);
return false;
}
return true;
}
return true;
});