Skip to content

Commit b1f2671

Browse files
committed
feat: stream a whole monitor and leave out the caller's own windows
1 parent 1b3ee8d commit b1f2671

7 files changed

Lines changed: 178 additions & 60 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ robot-cpp is built around the distinctions those libraries collapse:
1717

1818
## What it does
1919

20-
- Move the cursor (absolute or smoothly interpolated), click, double-click, drag, and scroll, with buttons through X1/X2 and line- or pixel-unit scrolling.
20+
- Move the cursor (absolute, smoothly interpolated, or by a raw relative delta for software that captures it), click, double-click, drag, and scroll, with buttons through X1/X2 and line- or pixel-unit scrolling.
2121
- Press and release physical keys by position, build modifier chords, and type arbitrary Unicode text independent of the active keyboard layout.
2222
- Enumerate every display with its own scale factor and both logical and physical bounds, capture any region or a whole monitor at native pixel resolution, sample individual pixels, and encode captures as PNG.
2323
- Enumerate on-screen application windows, query their current bounds and z-order, activate their owning application, and stream a single window as zero-copy video frames carrying the density and content-rect metadata needed to map frame pixels back to desktop coordinates (macOS ScreenCaptureKit; reported as Unsupported elsewhere until those backends exist).
@@ -159,6 +159,7 @@ Mouse control operates in global logical coordinates (the virtual desktop shared
159159
```cpp
160160
mouse.move({800.0, 450.0}); // absolute warp in logical coordinates
161161
mouse.moveSmooth({100.0, 100.0}); // interpolated over a duration
162+
mouse.moveBy(12.0, -7.0); // raw delta, for game cameras and mouse steering
162163
163164
mouse.click(); // left click at the current position
164165
mouse.click(robot::MouseButton::Right);
@@ -238,6 +239,8 @@ if (auto point = robot::toWindowPoint(frame.info(), pixelX, pixelY)) {
238239
}
239240
```
240241

242+
`windows.streamMonitor(monitor.id, options, onFrame, onError)` streams a whole monitor under the same rules (macOS ScreenCaptureKit; Unsupported elsewhere until those backends exist). By default it leaves the calling process's own windows out of the frames, so an overlay never captures itself; set `excludesOwnWindows` to false to include them.
243+
241244
`topmostWindowId()` reports the topmost normal-layer window in z-order (an ordering query - the honest primitive for "is my target frontmost"), and `activateApplicationFor(windowId)` brings a window's owning application forward; which of its windows ends up on top is the OS's decision.
242245

243246
## Recording and replay

‎include/robot/WindowStream.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ class IWindowStreamBackend;
1414
struct WindowStreamOptions {
1515
double maxFps = 60.0; // must be finite and in [1, 1000]
1616
bool showsCursor = true;
17+
// Monitor streams only: leave this process's own windows out of the frames,
18+
// so an overlay never sees itself. A window stream only ever shows its window.
19+
bool excludesOwnWindows = true;
1720
};
1821

1922
// Invoked on the stream's internal capture thread for every delivered frame,

‎include/robot/Windows.h‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,18 @@ class Windows {
6060
VideoFrameCallback onFrame, StreamErrorCallback onError = nullptr
6161
);
6262

63+
// Starts streaming a whole monitor, by Monitor::id. For an overlay, or for
64+
// following anything on the desktop rather than one window.
65+
[[nodiscard]] std::expected<WindowStream, Error> streamMonitor(
66+
std::uint32_t monitorId, const WindowStreamOptions& options,
67+
VideoFrameCallback onFrame, StreamErrorCallback onError = nullptr
68+
);
69+
6370
private:
71+
[[nodiscard]] std::expected<void, Error> validateStream(
72+
const WindowStreamOptions& options, const VideoFrameCallback& onFrame
73+
) const;
74+
6475
backend::IWindowBackend* backend_;
6576
};
6677

‎include/robot/backend/IWindowBackend.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ class IWindowBackend {
6868
std::uint32_t windowId, const WindowStreamOptions& options,
6969
VideoFrameCallback onFrame, StreamErrorCallback onError
7070
) = 0;
71+
72+
// Starts streaming a whole monitor, under the same delivery rules. Frames
73+
// report windowId 0.
74+
[[nodiscard]] virtual std::expected<
75+
std::unique_ptr<IWindowStreamBackend>, Error>
76+
startMonitorStream(
77+
std::uint32_t monitorId, const WindowStreamOptions& options,
78+
VideoFrameCallback onFrame, StreamErrorCallback onError
79+
) = 0;
7180
};
7281

7382
} // namespace robot::backend

‎src/common/Windows.cpp‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,13 @@ std::expected<void, Error> Windows::activateApplicationFor(
6161
return backend_->activateApplicationForWindow(windowId);
6262
}
6363

64-
std::expected<WindowStream, Error> Windows::stream(
65-
const std::uint32_t windowId, const WindowStreamOptions& options,
66-
VideoFrameCallback onFrame, StreamErrorCallback onError
67-
) {
64+
std::expected<void, Error> Windows::validateStream(
65+
const WindowStreamOptions& options, const VideoFrameCallback& onFrame
66+
) const {
6867
if (backend_ == nullptr) return std::unexpected(Error::unsupported(kNoBackend));
6968
if (onFrame == nullptr) {
7069
return std::unexpected(
71-
Error::invalidArgument("stream() requires a frame callback")
70+
Error::invalidArgument("streaming requires a frame callback")
7271
);
7372
}
7473
if (!std::isfinite(options.maxFps) || options.maxFps < 1.0 ||
@@ -78,11 +77,37 @@ std::expected<WindowStream, Error> Windows::stream(
7877
)));
7978
}
8079

80+
return {};
81+
}
82+
83+
std::expected<WindowStream, Error> Windows::stream(
84+
const std::uint32_t windowId, const WindowStreamOptions& options,
85+
VideoFrameCallback onFrame, StreamErrorCallback onError
86+
) {
87+
if (auto valid = validateStream(options, onFrame); !valid) {
88+
return std::unexpected(valid.error());
89+
}
90+
8191
auto stream = backend_->startStream(
8292
windowId, options, std::move(onFrame), std::move(onError)
8393
);
8494
if (!stream) return std::unexpected(stream.error());
8595
return WindowStream(std::move(*stream));
8696
}
8797

98+
std::expected<WindowStream, Error> Windows::streamMonitor(
99+
const std::uint32_t monitorId, const WindowStreamOptions& options,
100+
VideoFrameCallback onFrame, StreamErrorCallback onError
101+
) {
102+
if (auto valid = validateStream(options, onFrame); !valid) {
103+
return std::unexpected(valid.error());
104+
}
105+
106+
auto stream = backend_->startMonitorStream(
107+
monitorId, options, std::move(onFrame), std::move(onError)
108+
);
109+
if (!stream) return std::unexpected(stream.error());
110+
return WindowStream(std::move(*stream));
111+
}
112+
88113
} // namespace robot

‎src/platform/macos/MacWindowBackend.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ class MacWindowBackend final : public backend::IWindowBackend {
3232
std::uint32_t windowId, const WindowStreamOptions& options,
3333
VideoFrameCallback onFrame, StreamErrorCallback onError
3434
) override;
35+
36+
[[nodiscard]] std::expected<std::unique_ptr<backend::IWindowStreamBackend>, Error>
37+
startMonitorStream(
38+
std::uint32_t monitorId, const WindowStreamOptions& options,
39+
VideoFrameCallback onFrame, StreamErrorCallback onError
40+
) override;
3541
};
3642

3743
} // namespace robot::mac

‎src/platform/macos/MacWindowBackend.mm‎

Lines changed: 115 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
#import <ScreenCaptureKit/ScreenCaptureKit.h>
77

88
#include <mach/mach_time.h>
9+
#include <unistd.h>
910

1011
#include <atomic>
1112
#include <chrono>
13+
#include <format>
1214
#include <memory>
1315
#include <optional>
1416

@@ -295,6 +297,79 @@ void stop() override {
295297
std::shared_ptr<StreamChannel> channel_;
296298
};
297299

300+
// Starts capture of whatever the filter selects, at its native pixel size.
301+
std::expected<std::unique_ptr<backend::IWindowStreamBackend>, Error>
302+
startFilteredStream(
303+
SCContentFilter* filter, const std::uint32_t windowId,
304+
const WindowStreamOptions& options, VideoFrameCallback onFrame,
305+
StreamErrorCallback onError
306+
) {
307+
SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init];
308+
const double scale = static_cast<double>(filter.pointPixelScale);
309+
config.width = static_cast<size_t>(filter.contentRect.size.width * scale);
310+
config.height = static_cast<size_t>(filter.contentRect.size.height * scale);
311+
config.pixelFormat = kCVPixelFormatType_32BGRA;
312+
config.minimumFrameInterval =
313+
CMTimeMake(1, static_cast<std::int32_t>(options.maxFps));
314+
config.queueDepth = 3;
315+
config.showsCursor = options.showsCursor ? YES : NO;
316+
317+
dispatch_queue_t queue =
318+
dispatch_queue_create("robot.window-stream", DISPATCH_QUEUE_SERIAL);
319+
auto channel = std::make_shared<StreamChannel>();
320+
channel->onFrame = std::move(onFrame);
321+
channel->onError = std::move(onError);
322+
channel->queue = queue;
323+
channel->windowId = windowId;
324+
channel->active.store(true);
325+
326+
RobotWindowStreamOutput* output = [[RobotWindowStreamOutput alloc] init];
327+
output->channel = channel;
328+
SCStream* stream = [[SCStream alloc] initWithFilter:filter
329+
configuration:config
330+
delegate:output];
331+
332+
NSError* addError = nil;
333+
[stream addStreamOutput:output
334+
type:SCStreamOutputTypeScreen
335+
sampleHandlerQueue:queue
336+
error:&addError];
337+
if (addError != nil) {
338+
return std::unexpected(
339+
Error::captureFailed(addError.localizedDescription.UTF8String)
340+
);
341+
}
342+
343+
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
344+
__block NSError* startError = nil;
345+
[stream startCaptureWithCompletionHandler:^(NSError* err) {
346+
startError = err;
347+
dispatch_semaphore_signal(semaphore);
348+
}];
349+
const auto deadline =
350+
dispatch_time(DISPATCH_TIME_NOW, 5 * static_cast<int64_t>(NSEC_PER_SEC));
351+
if (dispatch_semaphore_wait(semaphore, deadline) != 0) {
352+
// Startup may still complete later; deactivate the channel and issue a
353+
// fire-and-forget stop so a late success never delivers frames.
354+
// startError is deliberately not read here - the completion handler may
355+
// still be about to write it.
356+
channel->active.store(false);
357+
[stream stopCaptureWithCompletionHandler:^(NSError*) {
358+
}];
359+
return std::unexpected(Error::captureFailed("timed out starting capture"));
360+
}
361+
if (startError != nil) {
362+
channel->active.store(false);
363+
return std::unexpected(
364+
Error::captureFailed(startError.localizedDescription.UTF8String)
365+
);
366+
}
367+
368+
return std::make_unique<MacWindowStream>(
369+
stream, output, queue, std::move(channel)
370+
);
371+
}
372+
298373
} // namespace
299374

300375
std::expected<std::vector<Window>, Error> MacWindowBackend::enumerateWindows() {
@@ -444,69 +519,55 @@ void stop() override {
444519

445520
SCContentFilter* filter =
446521
[[SCContentFilter alloc] initWithDesktopIndependentWindow:scWindow];
447-
SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init];
448-
const double scale = static_cast<double>(filter.pointPixelScale);
449-
config.width = static_cast<size_t>(filter.contentRect.size.width * scale);
450-
config.height = static_cast<size_t>(filter.contentRect.size.height * scale);
451-
config.pixelFormat = kCVPixelFormatType_32BGRA;
452-
config.minimumFrameInterval =
453-
CMTimeMake(1, static_cast<std::int32_t>(options.maxFps));
454-
config.queueDepth = 3;
455-
config.showsCursor = options.showsCursor ? YES : NO;
456-
457-
dispatch_queue_t queue =
458-
dispatch_queue_create("robot.window-stream", DISPATCH_QUEUE_SERIAL);
459-
auto channel = std::make_shared<StreamChannel>();
460-
channel->onFrame = std::move(onFrame);
461-
channel->onError = std::move(onError);
462-
channel->queue = queue;
463-
channel->windowId = windowId;
464-
channel->active.store(true);
465522

466-
RobotWindowStreamOutput* output = [[RobotWindowStreamOutput alloc] init];
467-
output->channel = channel;
468-
SCStream* stream = [[SCStream alloc] initWithFilter:filter
469-
configuration:config
470-
delegate:output];
523+
return startFilteredStream(
524+
filter, windowId, options, std::move(onFrame), std::move(onError)
525+
);
526+
}
471527

472-
NSError* addError = nil;
473-
[stream addStreamOutput:output
474-
type:SCStreamOutputTypeScreen
475-
sampleHandlerQueue:queue
476-
error:&addError];
477-
if (addError != nil) {
528+
std::expected<std::unique_ptr<backend::IWindowStreamBackend>, Error>
529+
MacWindowBackend::startMonitorStream(
530+
const std::uint32_t monitorId, const WindowStreamOptions& options,
531+
VideoFrameCallback onFrame, StreamErrorCallback onError
532+
) {
533+
if (@available(macOS 14.0, *)) {
534+
// Streaming is available; continue below.
535+
} else {
478536
return std::unexpected(
479-
Error::captureFailed(addError.localizedDescription.UTF8String)
537+
Error::unsupported("monitor streaming requires macOS 14 or newer")
480538
);
481539
}
482540

483-
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
484-
__block NSError* startError = nil;
485-
[stream startCaptureWithCompletionHandler:^(NSError* err) {
486-
startError = err;
487-
dispatch_semaphore_signal(semaphore);
488-
}];
489-
const auto deadline =
490-
dispatch_time(DISPATCH_TIME_NOW, 5 * static_cast<int64_t>(NSEC_PER_SEC));
491-
if (dispatch_semaphore_wait(semaphore, deadline) != 0) {
492-
// Startup may still complete later; deactivate the channel and issue a
493-
// fire-and-forget stop so a late success never delivers frames.
494-
// startError is deliberately not read here - the completion handler may
495-
// still be about to write it.
496-
channel->active.store(false);
497-
[stream stopCaptureWithCompletionHandler:^(NSError*) {
498-
}];
499-
return std::unexpected(Error::captureFailed("timed out starting capture"));
541+
auto contentResult = fetchShareableContent();
542+
if (!contentResult) return std::unexpected(contentResult.error());
543+
544+
SCDisplay* scDisplay = nil;
545+
for (SCDisplay* display in (*contentResult).displays) {
546+
if (display.displayID == monitorId) {
547+
scDisplay = display;
548+
break;
549+
}
500550
}
501-
if (startError != nil) {
502-
channel->active.store(false);
503-
return std::unexpected(
504-
Error::captureFailed(startError.localizedDescription.UTF8String)
505-
);
551+
if (scDisplay == nil) {
552+
return std::unexpected(Error::invalidArgument(
553+
std::format("no monitor with id {}", monitorId)
554+
));
506555
}
507556

508-
return std::make_unique<MacWindowStream>(
509-
stream, output, queue, std::move(channel)
557+
NSMutableArray<SCRunningApplication*>* excluded = [NSMutableArray array];
558+
if (options.excludesOwnWindows) {
559+
for (SCRunningApplication* app in (*contentResult).applications) {
560+
if (app.processID == getpid()) [excluded addObject:app];
561+
}
562+
}
563+
564+
SCContentFilter* filter =
565+
[[SCContentFilter alloc] initWithDisplay:scDisplay
566+
excludingApplications:excluded
567+
exceptingWindows:@[]];
568+
569+
return startFilteredStream(
570+
filter, 0, options, std::move(onFrame), std::move(onError)
510571
);
511572
}
512573

0 commit comments

Comments
 (0)