Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
Comment on lines +41 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

How to configure macOS entitlements in electron-builder for audio input access?

💡 Result:

To get microphone (audio input) access working on macOS with electron-builder, you need both:

  1. an entitlements plist used at code-sign time, and
  2. the right Info.plist usage-description keys (or macOS won’t show the permission prompt / will deny capture).

1) Create an entitlements file (recommended)

build/entitlements.mac.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <!-- Microphone / audio input -->
    <key>com.apple.security.device.audio-input</key>
    <true/>
  </dict>
</plist>

Apple documents com.apple.security.device.audio-input as the entitlement that allows microphone/audio-input access. [1]


2) Point electron-builder at the entitlements (+ hardened runtime)

In your electron-builder config:

package.json

{
  "build": {
    "mac": {
      "hardenedRuntime": true,
      "entitlements": "build/entitlements.mac.plist",
      "entitlementsInherit": "build/entitlements.mac.plist",
      "extendInfo": {
        "NSMicrophoneUsageDescription": "This app needs microphone access for voice input."
      }
    }
  }
}
  • entitlements signs the main app with your entitlements.
  • entitlementsInherit signs helper apps/frameworks with inherited entitlements (electron-builder explicitly supports this and recommends using it). [2]
  • Notarized macOS builds require hardened runtime + appropriate entitlements. [3]

3) If you capture system/desktop audio, add NSAudioCaptureUsageDescription too (macOS 14.2+)

If you use desktopCapturer to capture audio, Electron documents that on macOS 14.2+ you must add:

  • NSAudioCaptureUsageDescription to Info.plist (via extendInfo) [4]

Example:

"extendInfo": {
  "NSMicrophoneUsageDescription": "Mic is used for voice input.",
  "NSAudioCaptureUsageDescription": "Audio capture is used for screen sharing with audio."
}

4) Trigger the permission prompt at runtime

macOS won’t prompt just because the keys exist; you must actually request/use the microphone, e.g.:

  • navigator.mediaDevices.getUserMedia({ audio: true }) (renderer), or
  • systemPreferences.askForMediaAccess('microphone') (main)

(And you must have NSMicrophoneUsageDescription present, as above.)


Common gotcha

If permissions work in development but not after packaging: the signed .app is a different binary identity than the dev Electron app, so macOS permissions must be granted to (and prompted by) the packaged app.


Sources: Apple entitlement reference [1]; electron-builder Mac configuration (entitlementsInherit) [2]; electron-builder hardened runtime/notarization guidance [3]; Electron desktopCapturer docs for NSAudioCaptureUsageDescription requirement [4].


com.apple.security.device.audio-input needs to be in an entitlements file, not extendInfo

heads up - com.apple.security.device.audio-input is a code signing entitlement, not an Info.plist key. sticking it in extendInfo won't actually do anything. if you're relying on this for sandboxed audio input, it's gonna silently fail.

the NS* keys are legit though - those are meant for extendInfo.

you'll need to set this up properly via electron-builder:

  1. create build/entitlements.mac.plist with the entitlement
  2. configure in electron-builder.json5 (or package.json):
    "mac": {
      "hardenedRuntime": true,
      "entitlements": "build/entitlements.mac.plist",
      "entitlementsInherit": "build/entitlements.mac.plist"
    }

keep the NSMicrophoneUsageDescription and NSAudioCaptureUsageDescription in extendInfo - those are needed for the permission prompts to actually show up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron-builder.json5` around lines 41 - 47, The entitlement key
com.apple.security.device.audio-input is incorrectly placed inside extendInfo in
electron-builder.json5 (Info.plist keys belong there but code-signing
entitlements do not); move that entitlement into a macOS entitlements plist and
reference it from the build config by enabling hardened runtime and setting the
entitlements/entitlementsInherit fields (set mac.hardenedRuntime true and point
entitlements and entitlementsInherit to your entitlements plist), while keeping
NSMicrophoneUsageDescription and NSAudioCaptureUsageDescription in extendInfo so
permission prompts still show.

},
"linux": {
"target": [
Expand All @@ -54,14 +54,22 @@
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico",
"requestedExecutionLevel": "asInvoker",
"artifactName": "${productName}-Windows-${version}-Setup.${ext}"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": "always",
"createStartMenuShortcut": true,
"shortcutName": "Openscreen",
"installerIcon": "icons/icons/win/icon.ico",
"uninstallerIcon": "icons/icons/win/icon.ico",
"uninstallDisplayName": "Openscreen"
}
}
20 changes: 20 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,33 @@ import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Single instance lock
const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
app.quit();
} else {
app.on("second-instance", (_event, _commandLine, _workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
Comment on lines +21 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

race condition after app.quit() - second instance still runs init code

yo, this is lowkey risky. when gotTheLock is false, you call app.quit() but the rest of the file keeps executing since quit is async. the second instance will still hit app.whenReady(), try to create windows, register IPC handlers, etc. before actually dying.

common fix is to wrap all app initialization inside the else block or early-return the module.

🛠️ suggested fix - wrap init code in the else block
 const gotTheLock = app.requestSingleInstanceLock();

 if (!gotTheLock) {
 	app.quit();
-} else {
+	// Note: app.quit() is async, but we need to prevent further initialization
+	// The process will exit soon, but we should not continue setup
+} 
+
+// Only proceed with app initialization if we got the lock
+if (gotTheLock) {
 	app.on("second-instance", (_event, _commandLine, _workingDirectory) => {
 		// Someone tried to run a second instance, we should focus our window.
 		if (mainWindow) {
 			if (mainWindow.isMinimized()) mainWindow.restore();
 			mainWindow.focus();
 		}
 	});
-}
+
+	// ... rest of initialization should be inside this block or guarded

alternatively, you could add a guard before app.whenReady():

if (!gotTheLock) {
	app.quit();
	// Prevent any further initialization
	throw new Error("Another instance is already running");
}

or restructure to check the lock before any heavy setup runs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@electron/main.ts` around lines 21 - 34, The code calls app.quit() when
app.requestSingleInstanceLock() returns false but continues module execution
causing a race where the second instance still runs initialization; modify the
module so no further app setup runs when gotTheLock is false—either move all
initialization (including app.whenReady(), window creation, IPC registration and
any listeners referencing mainWindow) inside the existing else block that
handles the single-instance case, or immediately stop execution after app.quit()
by returning/throwing (e.g., throw new Error("Another instance is already
running")) so functions like app.whenReady(), mainWindow creation, and IPC
handlers never run when gotTheLock is false.


// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
// which doesn't work when running from a terminal/IDE during development, makes my life easier
if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}

if (process.platform === "win32") {
app.setAppUserModelId("com.siddharthvaddem.openscreen");
app.commandLine.appendSwitch("high-dpi-support", "1");
}

export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");

async function ensureRecordingsDir() {
Expand Down
Loading