electron-native-screenshare 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Linux Audio Capture via PipeWire (0.3.26+)
3
+ *
4
+ * Process-level audio capture:
5
+ * - Include mode: Creates a pw_stream connected to the target process's audio node
6
+ * - Exclude mode: Captures from the default sink's monitor source
7
+ * NOTE: Per-process audio exclusion is an OS-level limitation on Linux.
8
+ * A console warning is emitted when exclude mode is used.
9
+ *
10
+ * Audio output: float32, stereo, 48kHz — matching Windows WASAPI and macOS ScreenCaptureKit.
11
+ */
12
+
13
+ #include "pipewire_capture.h"
14
+ #include <pipewire/pipewire.h>
15
+ #include <spa/param/audio/format-utils.h>
16
+ #include <spa/debug/types.h>
17
+ #include <spa/param/audio/type-info.h>
18
+ #include <iostream>
19
+ #include <thread>
20
+ #include <mutex>
21
+ #include <cstring>
22
+
23
+ // --- Pimpl internals ---
24
+
25
+ struct PipewireCapture::Impl {
26
+ struct pw_main_loop* loop = nullptr;
27
+ struct pw_context* context = nullptr;
28
+ struct pw_core* core = nullptr;
29
+ struct pw_stream* stream = nullptr;
30
+ struct pw_registry* registry = nullptr;
31
+ struct spa_hook registryListener = {};
32
+ struct spa_hook streamListener = {};
33
+
34
+ uint32_t targetPid = 0;
35
+ bool includeMode = false;
36
+ uint32_t targetNodeId = PW_ID_ANY;
37
+
38
+ std::thread captureThread;
39
+ std::mutex mutex;
40
+ bool pipewireInitialized = false;
41
+ };
42
+
43
+ // --- PipeWire stream event handlers ---
44
+
45
+ static void onStreamProcess(void* userdata) {
46
+ PipewireCapture* self = static_cast<PipewireCapture*>(userdata);
47
+ // Access through a friend-like pattern via the public Start() that stores the callback
48
+ // The actual buffer reading is done here
49
+ struct pw_buffer* b = pw_stream_dequeue_buffer(self->pImpl->stream);
50
+ if (!b) return;
51
+
52
+ struct spa_buffer* buf = b->buffer;
53
+ if (!buf->datas[0].data) {
54
+ pw_stream_queue_buffer(self->pImpl->stream, b);
55
+ return;
56
+ }
57
+
58
+ uint8_t* data = static_cast<uint8_t*>(buf->datas[0].data);
59
+ uint32_t size = buf->datas[0].chunk->size;
60
+
61
+ if (self->onData && size > 0) {
62
+ PipewireCapture::AudioMetadata meta;
63
+ meta.sampleRate = 48000;
64
+ meta.channels = 2;
65
+ meta.bitsPerSample = 32;
66
+ meta.isFloat = true;
67
+ self->onData(data, size, meta);
68
+ }
69
+
70
+ pw_stream_queue_buffer(self->pImpl->stream, b);
71
+ }
72
+
73
+ static void onStreamStateChanged(void* userdata, enum pw_stream_state old,
74
+ enum pw_stream_state state, const char* error) {
75
+ if (error) {
76
+ std::cerr << "[electron-native-screenshare] PipeWire stream state: "
77
+ << pw_stream_state_as_string(state) << " — " << error << std::endl;
78
+ }
79
+ }
80
+
81
+ static const struct pw_stream_events streamEvents = []() {
82
+ struct pw_stream_events ev = {};
83
+ ev.version = PW_VERSION_STREAM_EVENTS;
84
+ ev.state_changed = onStreamStateChanged;
85
+ ev.process = onStreamProcess;
86
+ return ev;
87
+ }();
88
+
89
+ // --- Registry listener to find target node by PID ---
90
+
91
+ static void onRegistryGlobal(void* userdata, uint32_t id, uint32_t permissions,
92
+ const char* type, uint32_t version,
93
+ const struct spa_dict* props) {
94
+ PipewireCapture::Impl* impl = static_cast<PipewireCapture::Impl*>(userdata);
95
+
96
+ if (!props || strcmp(type, PW_TYPE_INTERFACE_Node) != 0) return;
97
+
98
+ const char* mediaClass = spa_dict_lookup(props, PW_KEY_MEDIA_CLASS);
99
+ if (!mediaClass) return;
100
+
101
+ if (impl->includeMode) {
102
+ // Include mode: find the node belonging to target PID
103
+ if (strcmp(mediaClass, "Stream/Output/Audio") != 0) return;
104
+
105
+ const char* pidStr = spa_dict_lookup(props, PW_KEY_APP_PROCESS_ID);
106
+ if (!pidStr) return;
107
+
108
+ uint32_t nodePid = (uint32_t)atoi(pidStr);
109
+ if (nodePid == impl->targetPid) {
110
+ impl->targetNodeId = id;
111
+ }
112
+ } else {
113
+ // Exclude mode: find the default sink's monitor
114
+ if (strcmp(mediaClass, "Audio/Sink") == 0) {
115
+ // Use the first audio sink as the monitor target
116
+ if (impl->targetNodeId == PW_ID_ANY) {
117
+ impl->targetNodeId = id;
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ static const struct pw_registry_events registryEvents = []() {
124
+ struct pw_registry_events ev = {};
125
+ ev.version = PW_VERSION_REGISTRY_EVENTS;
126
+ ev.global = onRegistryGlobal;
127
+ return ev;
128
+ }();
129
+
130
+ // --- PipewireCapture implementation ---
131
+
132
+ PipewireCapture::PipewireCapture() : pImpl(new Impl()) {}
133
+
134
+ PipewireCapture::~PipewireCapture() {
135
+ Stop();
136
+ if (pImpl) {
137
+ delete pImpl;
138
+ pImpl = nullptr;
139
+ }
140
+ }
141
+
142
+ int PipewireCapture::Initialize(uint32_t processId, bool isIncludeMode, std::string& outError) {
143
+ pImpl->targetPid = processId;
144
+ pImpl->includeMode = isIncludeMode;
145
+
146
+ // Emit warning for exclude mode — Linux limitation
147
+ if (!isIncludeMode) {
148
+ std::cerr << "[electron-native-screenshare] WARNING: On Linux, exclude mode captures all "
149
+ << "system audio from the default output. Per-process audio exclusion is not "
150
+ << "supported natively by PipeWire. The target process (PID: " << processId
151
+ << ") audio will still be present in the capture." << std::endl;
152
+ }
153
+
154
+ // Initialize PipeWire
155
+ pw_init(nullptr, nullptr);
156
+ pImpl->pipewireInitialized = true;
157
+
158
+ pImpl->loop = pw_main_loop_new(nullptr);
159
+ if (!pImpl->loop) {
160
+ outError = "Failed to create PipeWire main loop";
161
+ return -1;
162
+ }
163
+
164
+ pImpl->context = pw_context_new(pw_main_loop_get_loop(pImpl->loop), nullptr, 0);
165
+ if (!pImpl->context) {
166
+ outError = "Failed to create PipeWire context";
167
+ return -2;
168
+ }
169
+
170
+ pImpl->core = pw_context_connect(pImpl->context, nullptr, 0);
171
+ if (!pImpl->core) {
172
+ outError = "Failed to connect to PipeWire daemon. Is PipeWire running?";
173
+ return -3;
174
+ }
175
+
176
+ // Enumerate nodes to find target
177
+ pImpl->registry = pw_core_get_registry(pImpl->core, PW_VERSION_REGISTRY, 0);
178
+ if (!pImpl->registry) {
179
+ outError = "Failed to get PipeWire registry";
180
+ return -4;
181
+ }
182
+
183
+ spa_zero(pImpl->registryListener);
184
+ pw_registry_add_listener(pImpl->registry, &pImpl->registryListener, &registryEvents, pImpl);
185
+
186
+ // Process pending events to discover nodes (timeout: 500ms)
187
+ // We run the loop briefly to let the registry populate
188
+ struct pw_loop* loop = pw_main_loop_get_loop(pImpl->loop);
189
+
190
+ // Flush and process for a short duration
191
+ for (int i = 0; i < 50; i++) {
192
+ int result = pw_loop_iterate(loop, 10); // 10ms per iteration
193
+ if (result < 0) break;
194
+ if (pImpl->targetNodeId != PW_ID_ANY) break;
195
+ }
196
+
197
+ if (pImpl->includeMode && pImpl->targetNodeId == PW_ID_ANY) {
198
+ outError = "Target process audio node not found (PID: " + std::to_string(processId)
199
+ + "). The process may not be producing audio yet.";
200
+ return -5;
201
+ }
202
+
203
+ return 0;
204
+ }
205
+
206
+ void PipewireCapture::Start(DataCallback callback) {
207
+ if (isCapturing.load() || !pImpl->loop) return;
208
+
209
+ onData = callback;
210
+ isCapturing.store(true);
211
+
212
+ pImpl->captureThread = std::thread([this]() {
213
+ // Audio format: float32, stereo, 48kHz
214
+ uint8_t buffer[4096];
215
+ struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
216
+
217
+ struct spa_audio_info_raw rawInfo = {};
218
+ rawInfo.format = SPA_AUDIO_FORMAT_F32;
219
+ rawInfo.rate = 48000;
220
+ rawInfo.channels = 2;
221
+
222
+ const struct spa_pod* params[1];
223
+ params[0] = spa_format_audio_raw_build(&b, SPA_PARAM_EnumFormat, &rawInfo);
224
+
225
+ // Create the capture stream
226
+ struct pw_properties* props = pw_properties_new(
227
+ PW_KEY_MEDIA_TYPE, "Audio",
228
+ PW_KEY_MEDIA_CATEGORY, "Capture",
229
+ PW_KEY_MEDIA_ROLE, "Screen",
230
+ nullptr
231
+ );
232
+
233
+ if (pImpl->includeMode && pImpl->targetNodeId != PW_ID_ANY) {
234
+ // Include mode: target the specific node
235
+ char nodeIdStr[32];
236
+ snprintf(nodeIdStr, sizeof(nodeIdStr), "%u", pImpl->targetNodeId);
237
+ pw_properties_set(props, PW_KEY_TARGET_OBJECT, nodeIdStr);
238
+ } else if (!pImpl->includeMode && pImpl->targetNodeId != PW_ID_ANY) {
239
+ // Exclude mode: connect to default sink monitor
240
+ char nodeIdStr[32];
241
+ snprintf(nodeIdStr, sizeof(nodeIdStr), "%u", pImpl->targetNodeId);
242
+ pw_properties_set(props, PW_KEY_TARGET_OBJECT, nodeIdStr);
243
+ pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true");
244
+ }
245
+
246
+ pImpl->stream = pw_stream_new(pImpl->core, "electron-screenshare-capture", props);
247
+ if (!pImpl->stream) {
248
+ std::cerr << "[electron-native-screenshare] Failed to create PipeWire stream" << std::endl;
249
+ isCapturing.store(false);
250
+ return;
251
+ }
252
+
253
+ spa_zero(pImpl->streamListener);
254
+ pw_stream_add_listener(pImpl->stream, &pImpl->streamListener, &streamEvents, this);
255
+
256
+ pw_stream_connect(
257
+ pImpl->stream,
258
+ PW_DIRECTION_INPUT,
259
+ PW_ID_ANY,
260
+ (enum pw_stream_flags)(PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS),
261
+ params, 1
262
+ );
263
+
264
+ // Run the main loop — blocks until Stop() quits it
265
+ pw_main_loop_run(pImpl->loop);
266
+ });
267
+ }
268
+
269
+ void PipewireCapture::Stop() {
270
+ if (!isCapturing.load()) return;
271
+ isCapturing.store(false);
272
+
273
+ if (pImpl->loop) {
274
+ pw_main_loop_quit(pImpl->loop);
275
+ }
276
+
277
+ if (pImpl->captureThread.joinable()) {
278
+ pImpl->captureThread.join();
279
+ }
280
+
281
+ if (pImpl->stream) {
282
+ pw_stream_destroy(pImpl->stream);
283
+ pImpl->stream = nullptr;
284
+ }
285
+ if (pImpl->registry) {
286
+ pw_proxy_destroy((struct pw_proxy*)pImpl->registry);
287
+ pImpl->registry = nullptr;
288
+ }
289
+ if (pImpl->core) {
290
+ pw_core_disconnect(pImpl->core);
291
+ pImpl->core = nullptr;
292
+ }
293
+ if (pImpl->context) {
294
+ pw_context_destroy(pImpl->context);
295
+ pImpl->context = nullptr;
296
+ }
297
+ if (pImpl->loop) {
298
+ pw_main_loop_destroy(pImpl->loop);
299
+ pImpl->loop = nullptr;
300
+ }
301
+ if (pImpl->pipewireInitialized) {
302
+ pw_deinit();
303
+ pImpl->pipewireInitialized = false;
304
+ }
305
+ }
306
+
307
+ // --- getPidFromWindowId using X11 _NET_WM_PID ---
308
+
309
+ #include <X11/Xlib.h>
310
+ #include <X11/Xatom.h>
311
+
312
+ uint32_t getPidFromWindowId(uint32_t windowId) {
313
+ Display* display = XOpenDisplay(nullptr);
314
+ if (!display) {
315
+ std::cerr << "[electron-native-screenshare] Cannot open X11 display. "
316
+ << "Window-to-PID resolution requires X11 or XWayland." << std::endl;
317
+ return 0;
318
+ }
319
+
320
+ Atom pidAtom = XInternAtom(display, "_NET_WM_PID", True);
321
+ if (pidAtom == None) {
322
+ XCloseDisplay(display);
323
+ return 0;
324
+ }
325
+
326
+ Atom actualType;
327
+ int actualFormat;
328
+ unsigned long nItems, bytesAfter;
329
+ unsigned char* prop = nullptr;
330
+
331
+ int status = XGetWindowProperty(display, (Window)windowId, pidAtom,
332
+ 0, 1, False, XA_CARDINAL,
333
+ &actualType, &actualFormat,
334
+ &nItems, &bytesAfter, &prop);
335
+
336
+ uint32_t pid = 0;
337
+ if (status == Success && prop && nItems > 0) {
338
+ pid = *(uint32_t*)prop;
339
+ XFree(prop);
340
+ }
341
+
342
+ XCloseDisplay(display);
343
+ return pid;
344
+ }
@@ -0,0 +1,64 @@
1
+ #pragma once
2
+
3
+ #include <functional>
4
+ #include <string>
5
+ #include <cstdint>
6
+ #include <atomic>
7
+
8
+ /**
9
+ * Linux audio capture using PipeWire (0.3.26+).
10
+ *
11
+ * Process-level audio isolation:
12
+ * - Include mode: Captures audio from the target process's PipeWire node
13
+ * - Exclude mode: Captures from default sink monitor
14
+ * (full per-process exclusion is not natively supported on Linux;
15
+ * a warning is emitted at runtime)
16
+ *
17
+ * Uses pimpl pattern to encapsulate PipeWire internals.
18
+ */
19
+ class PipewireCapture {
20
+ public:
21
+ struct AudioMetadata {
22
+ uint32_t sampleRate;
23
+ uint16_t channels;
24
+ uint16_t bitsPerSample;
25
+ bool isFloat;
26
+ };
27
+ using DataCallback = std::function<void(const uint8_t* data, size_t length, AudioMetadata metadata)>;
28
+
29
+ PipewireCapture();
30
+ ~PipewireCapture();
31
+
32
+ /**
33
+ * Initialize the capture session.
34
+ * @param processId Target process ID (PID)
35
+ * @param isIncludeMode true = capture only target's audio, false = capture all system audio
36
+ * @param outError Human-readable error message on failure
37
+ * @return 0 on success, non-zero on failure
38
+ */
39
+ int Initialize(uint32_t processId, bool isIncludeMode, std::string& outError);
40
+
41
+ /**
42
+ * Start capturing audio. Calls callback on a background thread.
43
+ */
44
+ void Start(DataCallback callback);
45
+
46
+ /**
47
+ * Stop capturing and release resources.
48
+ */
49
+ void Stop();
50
+
51
+ public:
52
+ struct Impl;
53
+ Impl* pImpl;
54
+ std::atomic<bool> isCapturing{false};
55
+ DataCallback onData;
56
+ };
57
+
58
+ /**
59
+ * Get the owning process ID for a given X11 Window ID.
60
+ * Falls back to 0 on Wayland without XWayland.
61
+ * @param windowId X11 Window ID from Electron's desktopCapturer
62
+ * @return Process ID, or 0 on failure
63
+ */
64
+ uint32_t getPidFromWindowId(uint32_t windowId);
@@ -0,0 +1,107 @@
1
+ /**
2
+ * macOS N-API addon — mirrors the Windows addon.cpp interface exactly.
3
+ *
4
+ * Exports:
5
+ * startCapture(processId, isIncludeMode, callback) → boolean
6
+ * stopCapture() → boolean
7
+ * getPidFromHwnd(windowId) → number
8
+ */
9
+
10
+ #include <napi.h>
11
+ #include "coreaudio_capture.h"
12
+
13
+ static CoreAudioCapture capture;
14
+ static Napi::ThreadSafeFunction tsfn;
15
+
16
+ Napi::Value StartCapture(const Napi::CallbackInfo& info) {
17
+ Napi::Env env = info.Env();
18
+
19
+ uint32_t processId = 0;
20
+ if (info.Length() > 0 && info[0].IsNumber()) {
21
+ processId = info[0].As<Napi::Number>().Uint32Value();
22
+ }
23
+
24
+ bool isIncludeMode = false;
25
+ if (info.Length() > 1 && info[1].IsBoolean()) {
26
+ isIncludeMode = info[1].As<Napi::Boolean>().Value();
27
+ }
28
+
29
+ if (info.Length() < 3 || !info[2].IsFunction()) {
30
+ Napi::TypeError::New(env, "Callback function expected as third argument").ThrowAsJavaScriptException();
31
+ return env.Null();
32
+ }
33
+
34
+ std::string errorMsg;
35
+ int result = capture.Initialize(processId, isIncludeMode, errorMsg);
36
+ if (result != 0 || !errorMsg.empty()) {
37
+ char buf[512];
38
+ snprintf(buf, sizeof(buf), "CoreAudio Init Failed: %s (code: %d)", errorMsg.c_str(), result);
39
+ Napi::TypeError::New(env, buf).ThrowAsJavaScriptException();
40
+ return env.Null();
41
+ }
42
+
43
+ tsfn = Napi::ThreadSafeFunction::New(
44
+ env,
45
+ info[2].As<Napi::Function>(),
46
+ "CoreAudioCaptureCallback",
47
+ 0,
48
+ 1
49
+ );
50
+
51
+ auto callback = [](const uint8_t* data, size_t length, CoreAudioCapture::AudioMetadata metadata) {
52
+ if (!tsfn) return;
53
+
54
+ struct Payload {
55
+ std::vector<uint8_t> buffer;
56
+ CoreAudioCapture::AudioMetadata meta;
57
+ };
58
+ auto* payload = new Payload{ std::vector<uint8_t>(data, data + length), metadata };
59
+
60
+ auto napiCallback = [](Napi::Env env, Napi::Function jsCallback, Payload* p) {
61
+ Napi::Object metaObj = Napi::Object::New(env);
62
+ metaObj.Set("sampleRate", p->meta.sampleRate);
63
+ metaObj.Set("channels", p->meta.channels);
64
+ metaObj.Set("bitsPerSample", p->meta.bitsPerSample);
65
+ metaObj.Set("isFloat", p->meta.isFloat);
66
+
67
+ Napi::Buffer<uint8_t> buffer = Napi::Buffer<uint8_t>::Copy(env, p->buffer.data(), p->buffer.size());
68
+ jsCallback.Call({ buffer, metaObj });
69
+ delete p;
70
+ };
71
+
72
+ tsfn.NonBlockingCall(payload, napiCallback);
73
+ };
74
+
75
+ capture.Start(callback);
76
+ return Napi::Boolean::New(env, true);
77
+ }
78
+
79
+ Napi::Value StopCapture(const Napi::CallbackInfo& info) {
80
+ Napi::Env env = info.Env();
81
+ capture.Stop();
82
+ if (tsfn) {
83
+ tsfn.Release();
84
+ tsfn = nullptr;
85
+ }
86
+ return Napi::Boolean::New(env, true);
87
+ }
88
+
89
+ Napi::Value GetPidFromHwnd(const Napi::CallbackInfo& info) {
90
+ Napi::Env env = info.Env();
91
+ if (info.Length() < 1 || !info[0].IsNumber()) {
92
+ Napi::TypeError::New(env, "Number expected").ThrowAsJavaScriptException();
93
+ return env.Null();
94
+ }
95
+ uint32_t windowId = info[0].As<Napi::Number>().Uint32Value();
96
+ uint32_t pid = getPidFromWindowId(windowId);
97
+ return Napi::Number::New(env, pid);
98
+ }
99
+
100
+ Napi::Object Init(Napi::Env env, Napi::Object exports) {
101
+ exports.Set(Napi::String::New(env, "startCapture"), Napi::Function::New(env, StartCapture));
102
+ exports.Set(Napi::String::New(env, "stopCapture"), Napi::Function::New(env, StopCapture));
103
+ exports.Set(Napi::String::New(env, "getPidFromHwnd"), Napi::Function::New(env, GetPidFromHwnd));
104
+ return exports;
105
+ }
106
+
107
+ NODE_API_MODULE(topluyo_capture, Init)
@@ -0,0 +1,61 @@
1
+ #pragma once
2
+
3
+ #include <functional>
4
+ #include <string>
5
+ #include <cstdint>
6
+ #include <atomic>
7
+
8
+ /**
9
+ * macOS audio capture using ScreenCaptureKit (macOS 13+).
10
+ *
11
+ * Provides process-level audio isolation:
12
+ * - Include mode: Captures only the target process's audio
13
+ * - Exclude mode: Captures all system audio EXCEPT the target process
14
+ *
15
+ * Uses pimpl pattern to encapsulate Objective-C internals.
16
+ */
17
+ class CoreAudioCapture {
18
+ public:
19
+ struct AudioMetadata {
20
+ uint32_t sampleRate;
21
+ uint16_t channels;
22
+ uint16_t bitsPerSample;
23
+ bool isFloat;
24
+ };
25
+ using DataCallback = std::function<void(const uint8_t* data, size_t length, AudioMetadata metadata)>;
26
+
27
+ CoreAudioCapture();
28
+ ~CoreAudioCapture();
29
+
30
+ /**
31
+ * Initialize the capture session.
32
+ * @param processId Target process ID (PID)
33
+ * @param isIncludeMode true = capture only target's audio, false = capture all except target
34
+ * @param outError Human-readable error message on failure
35
+ * @return 0 on success, non-zero on failure
36
+ */
37
+ int Initialize(uint32_t processId, bool isIncludeMode, std::string& outError);
38
+
39
+ /**
40
+ * Start capturing audio. Calls callback on a background thread.
41
+ */
42
+ void Start(DataCallback callback);
43
+
44
+ /**
45
+ * Stop capturing and release resources.
46
+ */
47
+ void Stop();
48
+
49
+ private:
50
+ struct Impl;
51
+ Impl* pImpl;
52
+ std::atomic<bool> isCapturing{false};
53
+ DataCallback onData;
54
+ };
55
+
56
+ /**
57
+ * Get the owning process ID for a given CGWindowID.
58
+ * @param windowId CGWindowID from Electron's desktopCapturer
59
+ * @return Process ID, or 0 on failure
60
+ */
61
+ uint32_t getPidFromWindowId(uint32_t windowId);