@midscene/computer 1.12.4 → 1.12.5-beta-20260907080441.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.
Binary file
Binary file
package/dist/es/cli.mjs CHANGED
@@ -523,8 +523,10 @@ async function captureScreenshot(options) {
523
523
  }
524
524
  const SMOOTH_MOVE_STEPS_TAP = 8;
525
525
  const SMOOTH_MOVE_STEPS_MOUSE_MOVE = 10;
526
+ const SMOOTH_MOVE_STEPS_DRAG = 20;
526
527
  const SMOOTH_MOVE_DELAY_TAP = 8;
527
528
  const SMOOTH_MOVE_DELAY_MOUSE_MOVE = 10;
529
+ const SMOOTH_MOVE_DELAY_DRAG = 10;
528
530
  const MOUSE_MOVE_EFFECT_WAIT = 300;
529
531
  const CLICK_SETTLE_DELAY = 50;
530
532
  const CLICK_HOLD_DURATION = 100;
@@ -1151,7 +1153,7 @@ Available Displays: ${displays.length > 0 ? displays.map((d)=>d.name).join(', ')
1151
1153
  }
1152
1154
  async healthCheck(displays) {
1153
1155
  console.log('[HealthCheck] Starting health check...');
1154
- console.log("[HealthCheck] @midscene/computer v1.12.4");
1156
+ console.log("[HealthCheck] @midscene/computer v1.12.5-beta-20260907080441.0");
1155
1157
  console.log('[HealthCheck] Taking screenshot...');
1156
1158
  const screenshotTimeout = 15000;
1157
1159
  let timeoutId;
@@ -1628,7 +1630,10 @@ $g.Dispose(); $bmp.Dispose(); $ms.Dispose()
1628
1630
  await this.moveDisplayPointer(from, 'Mouse did not reach the drag start target');
1629
1631
  await this.inputDriver.withMouseButton('left', async ()=>{
1630
1632
  await this.inputDriver.delay(100);
1631
- await this.moveDisplayPointer(to, 'Mouse did not reach the drag end target');
1633
+ await this.moveDisplayPointer(to, 'Mouse did not reach the drag end target', {
1634
+ smoothSteps: SMOOTH_MOVE_STEPS_DRAG,
1635
+ smoothDelay: SMOOTH_MOVE_DELAY_DRAG
1636
+ });
1632
1637
  await this.inputDriver.delay(100);
1633
1638
  });
1634
1639
  }
@@ -2067,10 +2072,10 @@ function rdp_device_define_property(obj, key, value) {
2067
2072
  const device_debug = getDebug('rdp:device');
2068
2073
  const device_SMOOTH_MOVE_STEPS_TAP = 8;
2069
2074
  const device_SMOOTH_MOVE_STEPS_MOUSE_MOVE = 10;
2070
- const SMOOTH_MOVE_STEPS_DRAG = 12;
2075
+ const device_SMOOTH_MOVE_STEPS_DRAG = 12;
2071
2076
  const device_SMOOTH_MOVE_DELAY_TAP = 8;
2072
2077
  const device_SMOOTH_MOVE_DELAY_MOUSE_MOVE = 10;
2073
- const SMOOTH_MOVE_DELAY_DRAG = 10;
2078
+ const device_SMOOTH_MOVE_DELAY_DRAG = 10;
2074
2079
  const device_MOUSE_MOVE_EFFECT_WAIT = 300;
2075
2080
  const device_CLICK_HOLD_DURATION = 50;
2076
2081
  const DRAG_HOLD_DURATION = 100;
@@ -2273,8 +2278,8 @@ class RDPDevice {
2273
2278
  await this.backend.mouseButton('left', 'down');
2274
2279
  await sleep(DRAG_HOLD_DURATION);
2275
2280
  await this.movePointer(Math.round(to.x), Math.round(to.y), {
2276
- steps: SMOOTH_MOVE_STEPS_DRAG,
2277
- stepDelayMs: SMOOTH_MOVE_DELAY_DRAG
2281
+ steps: device_SMOOTH_MOVE_STEPS_DRAG,
2282
+ stepDelayMs: device_SMOOTH_MOVE_DELAY_DRAG
2278
2283
  });
2279
2284
  await sleep(DRAG_HOLD_DURATION);
2280
2285
  await this.backend.mouseButton('left', 'up');
@@ -2601,7 +2606,7 @@ const tools = new ComputerMidsceneTools({
2601
2606
  });
2602
2607
  runToolsCLI(tools, 'midscene-computer', {
2603
2608
  stripPrefix: 'computer_',
2604
- version: "1.12.4",
2609
+ version: "1.12.5-beta-20260907080441.0",
2605
2610
  extraCommands: createReportCliCommands()
2606
2611
  }).catch((e)=>{
2607
2612
  process.exit(reportCLIError(e));
package/dist/es/index.mjs CHANGED
@@ -523,8 +523,10 @@ async function captureScreenshot(options) {
523
523
  }
524
524
  const SMOOTH_MOVE_STEPS_TAP = 8;
525
525
  const SMOOTH_MOVE_STEPS_MOUSE_MOVE = 10;
526
+ const SMOOTH_MOVE_STEPS_DRAG = 20;
526
527
  const SMOOTH_MOVE_DELAY_TAP = 8;
527
528
  const SMOOTH_MOVE_DELAY_MOUSE_MOVE = 10;
529
+ const SMOOTH_MOVE_DELAY_DRAG = 10;
528
530
  const MOUSE_MOVE_EFFECT_WAIT = 300;
529
531
  const CLICK_SETTLE_DELAY = 50;
530
532
  const CLICK_HOLD_DURATION = 100;
@@ -1151,7 +1153,7 @@ Available Displays: ${displays.length > 0 ? displays.map((d)=>d.name).join(', ')
1151
1153
  }
1152
1154
  async healthCheck(displays) {
1153
1155
  console.log('[HealthCheck] Starting health check...');
1154
- console.log("[HealthCheck] @midscene/computer v1.12.4");
1156
+ console.log("[HealthCheck] @midscene/computer v1.12.5-beta-20260907080441.0");
1155
1157
  console.log('[HealthCheck] Taking screenshot...');
1156
1158
  const screenshotTimeout = 15000;
1157
1159
  let timeoutId;
@@ -1628,7 +1630,10 @@ $g.Dispose(); $bmp.Dispose(); $ms.Dispose()
1628
1630
  await this.moveDisplayPointer(from, 'Mouse did not reach the drag start target');
1629
1631
  await this.inputDriver.withMouseButton('left', async ()=>{
1630
1632
  await this.inputDriver.delay(100);
1631
- await this.moveDisplayPointer(to, 'Mouse did not reach the drag end target');
1633
+ await this.moveDisplayPointer(to, 'Mouse did not reach the drag end target', {
1634
+ smoothSteps: SMOOTH_MOVE_STEPS_DRAG,
1635
+ smoothDelay: SMOOTH_MOVE_DELAY_DRAG
1636
+ });
1632
1637
  await this.inputDriver.delay(100);
1633
1638
  });
1634
1639
  }
@@ -2110,10 +2115,10 @@ function rdp_device_define_property(obj, key, value) {
2110
2115
  const device_debug = getDebug('rdp:device');
2111
2116
  const device_SMOOTH_MOVE_STEPS_TAP = 8;
2112
2117
  const device_SMOOTH_MOVE_STEPS_MOUSE_MOVE = 10;
2113
- const SMOOTH_MOVE_STEPS_DRAG = 12;
2118
+ const device_SMOOTH_MOVE_STEPS_DRAG = 12;
2114
2119
  const device_SMOOTH_MOVE_DELAY_TAP = 8;
2115
2120
  const device_SMOOTH_MOVE_DELAY_MOUSE_MOVE = 10;
2116
- const SMOOTH_MOVE_DELAY_DRAG = 10;
2121
+ const device_SMOOTH_MOVE_DELAY_DRAG = 10;
2117
2122
  const device_MOUSE_MOVE_EFFECT_WAIT = 300;
2118
2123
  const device_CLICK_HOLD_DURATION = 50;
2119
2124
  const DRAG_HOLD_DURATION = 100;
@@ -2316,8 +2321,8 @@ class RDPDevice {
2316
2321
  await this.backend.mouseButton('left', 'down');
2317
2322
  await sleep(DRAG_HOLD_DURATION);
2318
2323
  await this.movePointer(Math.round(to.x), Math.round(to.y), {
2319
- steps: SMOOTH_MOVE_STEPS_DRAG,
2320
- stepDelayMs: SMOOTH_MOVE_DELAY_DRAG
2324
+ steps: device_SMOOTH_MOVE_STEPS_DRAG,
2325
+ stepDelayMs: device_SMOOTH_MOVE_DELAY_DRAG
2321
2326
  });
2322
2327
  await sleep(DRAG_HOLD_DURATION);
2323
2328
  await this.backend.mouseButton('left', 'up');
@@ -2640,7 +2645,7 @@ class ComputerMidsceneTools extends BaseMidsceneTools {
2640
2645
  }
2641
2646
  }
2642
2647
  function version() {
2643
- const currentVersion = "1.12.4";
2648
+ const currentVersion = "1.12.5-beta-20260907080441.0";
2644
2649
  console.log(`@midscene/computer v${currentVersion}`);
2645
2650
  return currentVersion;
2646
2651
  }
package/dist/lib/cli.js CHANGED
@@ -549,8 +549,10 @@ async function captureScreenshot(options) {
549
549
  }
550
550
  const SMOOTH_MOVE_STEPS_TAP = 8;
551
551
  const SMOOTH_MOVE_STEPS_MOUSE_MOVE = 10;
552
+ const SMOOTH_MOVE_STEPS_DRAG = 20;
552
553
  const SMOOTH_MOVE_DELAY_TAP = 8;
553
554
  const SMOOTH_MOVE_DELAY_MOUSE_MOVE = 10;
555
+ const SMOOTH_MOVE_DELAY_DRAG = 10;
554
556
  const MOUSE_MOVE_EFFECT_WAIT = 300;
555
557
  const CLICK_SETTLE_DELAY = 50;
556
558
  const CLICK_HOLD_DURATION = 100;
@@ -1177,7 +1179,7 @@ Available Displays: ${displays.length > 0 ? displays.map((d)=>d.name).join(', ')
1177
1179
  }
1178
1180
  async healthCheck(displays) {
1179
1181
  console.log('[HealthCheck] Starting health check...');
1180
- console.log("[HealthCheck] @midscene/computer v1.12.4");
1182
+ console.log("[HealthCheck] @midscene/computer v1.12.5-beta-20260907080441.0");
1181
1183
  console.log('[HealthCheck] Taking screenshot...');
1182
1184
  const screenshotTimeout = 15000;
1183
1185
  let timeoutId;
@@ -1654,7 +1656,10 @@ $g.Dispose(); $bmp.Dispose(); $ms.Dispose()
1654
1656
  await this.moveDisplayPointer(from, 'Mouse did not reach the drag start target');
1655
1657
  await this.inputDriver.withMouseButton('left', async ()=>{
1656
1658
  await this.inputDriver.delay(100);
1657
- await this.moveDisplayPointer(to, 'Mouse did not reach the drag end target');
1659
+ await this.moveDisplayPointer(to, 'Mouse did not reach the drag end target', {
1660
+ smoothSteps: SMOOTH_MOVE_STEPS_DRAG,
1661
+ smoothDelay: SMOOTH_MOVE_DELAY_DRAG
1662
+ });
1658
1663
  await this.inputDriver.delay(100);
1659
1664
  });
1660
1665
  }
@@ -2094,10 +2099,10 @@ function rdp_device_define_property(obj, key, value) {
2094
2099
  const device_debug = (0, logger_namespaceObject.getDebug)('rdp:device');
2095
2100
  const device_SMOOTH_MOVE_STEPS_TAP = 8;
2096
2101
  const device_SMOOTH_MOVE_STEPS_MOUSE_MOVE = 10;
2097
- const SMOOTH_MOVE_STEPS_DRAG = 12;
2102
+ const device_SMOOTH_MOVE_STEPS_DRAG = 12;
2098
2103
  const device_SMOOTH_MOVE_DELAY_TAP = 8;
2099
2104
  const device_SMOOTH_MOVE_DELAY_MOUSE_MOVE = 10;
2100
- const SMOOTH_MOVE_DELAY_DRAG = 10;
2105
+ const device_SMOOTH_MOVE_DELAY_DRAG = 10;
2101
2106
  const device_MOUSE_MOVE_EFFECT_WAIT = 300;
2102
2107
  const device_CLICK_HOLD_DURATION = 50;
2103
2108
  const DRAG_HOLD_DURATION = 100;
@@ -2300,8 +2305,8 @@ class RDPDevice {
2300
2305
  await this.backend.mouseButton('left', 'down');
2301
2306
  await (0, utils_namespaceObject.sleep)(DRAG_HOLD_DURATION);
2302
2307
  await this.movePointer(Math.round(to.x), Math.round(to.y), {
2303
- steps: SMOOTH_MOVE_STEPS_DRAG,
2304
- stepDelayMs: SMOOTH_MOVE_DELAY_DRAG
2308
+ steps: device_SMOOTH_MOVE_STEPS_DRAG,
2309
+ stepDelayMs: device_SMOOTH_MOVE_DELAY_DRAG
2305
2310
  });
2306
2311
  await (0, utils_namespaceObject.sleep)(DRAG_HOLD_DURATION);
2307
2312
  await this.backend.mouseButton('left', 'up');
@@ -2628,7 +2633,7 @@ const tools = new ComputerMidsceneTools({
2628
2633
  });
2629
2634
  (0, cli_namespaceObject.runToolsCLI)(tools, 'midscene-computer', {
2630
2635
  stripPrefix: 'computer_',
2631
- version: "1.12.4",
2636
+ version: "1.12.5-beta-20260907080441.0",
2632
2637
  extraCommands: (0, core_namespaceObject.createReportCliCommands)()
2633
2638
  }).catch((e)=>{
2634
2639
  process.exit((0, cli_namespaceObject.reportCLIError)(e));
package/dist/lib/index.js CHANGED
@@ -575,8 +575,10 @@ async function captureScreenshot(options) {
575
575
  }
576
576
  const SMOOTH_MOVE_STEPS_TAP = 8;
577
577
  const SMOOTH_MOVE_STEPS_MOUSE_MOVE = 10;
578
+ const SMOOTH_MOVE_STEPS_DRAG = 20;
578
579
  const SMOOTH_MOVE_DELAY_TAP = 8;
579
580
  const SMOOTH_MOVE_DELAY_MOUSE_MOVE = 10;
581
+ const SMOOTH_MOVE_DELAY_DRAG = 10;
580
582
  const MOUSE_MOVE_EFFECT_WAIT = 300;
581
583
  const CLICK_SETTLE_DELAY = 50;
582
584
  const CLICK_HOLD_DURATION = 100;
@@ -1203,7 +1205,7 @@ Available Displays: ${displays.length > 0 ? displays.map((d)=>d.name).join(', ')
1203
1205
  }
1204
1206
  async healthCheck(displays) {
1205
1207
  console.log('[HealthCheck] Starting health check...');
1206
- console.log("[HealthCheck] @midscene/computer v1.12.4");
1208
+ console.log("[HealthCheck] @midscene/computer v1.12.5-beta-20260907080441.0");
1207
1209
  console.log('[HealthCheck] Taking screenshot...');
1208
1210
  const screenshotTimeout = 15000;
1209
1211
  let timeoutId;
@@ -1680,7 +1682,10 @@ $g.Dispose(); $bmp.Dispose(); $ms.Dispose()
1680
1682
  await this.moveDisplayPointer(from, 'Mouse did not reach the drag start target');
1681
1683
  await this.inputDriver.withMouseButton('left', async ()=>{
1682
1684
  await this.inputDriver.delay(100);
1683
- await this.moveDisplayPointer(to, 'Mouse did not reach the drag end target');
1685
+ await this.moveDisplayPointer(to, 'Mouse did not reach the drag end target', {
1686
+ smoothSteps: SMOOTH_MOVE_STEPS_DRAG,
1687
+ smoothDelay: SMOOTH_MOVE_DELAY_DRAG
1688
+ });
1684
1689
  await this.inputDriver.delay(100);
1685
1690
  });
1686
1691
  }
@@ -2164,10 +2169,10 @@ function rdp_device_define_property(obj, key, value) {
2164
2169
  const device_debug = (0, logger_namespaceObject.getDebug)('rdp:device');
2165
2170
  const device_SMOOTH_MOVE_STEPS_TAP = 8;
2166
2171
  const device_SMOOTH_MOVE_STEPS_MOUSE_MOVE = 10;
2167
- const SMOOTH_MOVE_STEPS_DRAG = 12;
2172
+ const device_SMOOTH_MOVE_STEPS_DRAG = 12;
2168
2173
  const device_SMOOTH_MOVE_DELAY_TAP = 8;
2169
2174
  const device_SMOOTH_MOVE_DELAY_MOUSE_MOVE = 10;
2170
- const SMOOTH_MOVE_DELAY_DRAG = 10;
2175
+ const device_SMOOTH_MOVE_DELAY_DRAG = 10;
2171
2176
  const device_MOUSE_MOVE_EFFECT_WAIT = 300;
2172
2177
  const device_CLICK_HOLD_DURATION = 50;
2173
2178
  const DRAG_HOLD_DURATION = 100;
@@ -2370,8 +2375,8 @@ class RDPDevice {
2370
2375
  await this.backend.mouseButton('left', 'down');
2371
2376
  await (0, utils_namespaceObject.sleep)(DRAG_HOLD_DURATION);
2372
2377
  await this.movePointer(Math.round(to.x), Math.round(to.y), {
2373
- steps: SMOOTH_MOVE_STEPS_DRAG,
2374
- stepDelayMs: SMOOTH_MOVE_DELAY_DRAG
2378
+ steps: device_SMOOTH_MOVE_STEPS_DRAG,
2379
+ stepDelayMs: device_SMOOTH_MOVE_DELAY_DRAG
2375
2380
  });
2376
2381
  await (0, utils_namespaceObject.sleep)(DRAG_HOLD_DURATION);
2377
2382
  await this.backend.mouseButton('left', 'up');
@@ -2698,7 +2703,7 @@ class ComputerMidsceneTools extends base_tools_namespaceObject.BaseMidsceneTools
2698
2703
  }
2699
2704
  const env_namespaceObject = require("@midscene/shared/env");
2700
2705
  function version() {
2701
- const currentVersion = "1.12.4";
2706
+ const currentVersion = "1.12.5-beta-20260907080441.0";
2702
2707
  console.log(`@midscene/computer v${currentVersion}`);
2703
2708
  return currentVersion;
2704
2709
  }
@@ -92,6 +92,27 @@ if(BUILD_TESTING)
92
92
  NAME rdp-session-policy-test
93
93
  COMMAND rdp-session-policy-test
94
94
  )
95
+
96
+ add_executable(
97
+ rdp-screenshot-test
98
+ tests/screenshot_test.cpp
99
+ src/session.cpp
100
+ )
101
+ target_include_directories(
102
+ rdp-screenshot-test PRIVATE
103
+ ${CMAKE_CURRENT_SOURCE_DIR}/include
104
+ ${FREERDP_INCLUDE_DIRS}
105
+ )
106
+ target_link_libraries(
107
+ rdp-screenshot-test PRIVATE
108
+ ${FREERDP_LIBRARIES}
109
+ Threads::Threads
110
+ )
111
+ if(APPLE)
112
+ target_compile_definitions(rdp-screenshot-test PRIVATE _DARWIN_C_SOURCE)
113
+ endif()
114
+ add_test(NAME rdp-screenshot-test COMMAND rdp-screenshot-test)
115
+ set_tests_properties(rdp-screenshot-test PROPERTIES TIMEOUT 30)
95
116
  endif()
96
117
 
97
118
  if(APPLE)
@@ -1,6 +1,7 @@
1
1
  #pragma once
2
2
 
3
3
  #include <atomic>
4
+ #include <chrono>
4
5
  #include <condition_variable>
5
6
  #include <cstdint>
6
7
  #include <mutex>
@@ -76,7 +77,7 @@ class FreeRdpSessionTransport final : public SessionTransport {
76
77
  void HookEndPaint(rdpUpdate* update);
77
78
  // Records that at least one paint has reached the local framebuffer and
78
79
  // wakes Connect().
79
- void MarkFramePainted(std::optional<RawFrame> first_frame = std::nullopt);
80
+ void MarkFramePainted();
80
81
  // Records a framebuffer update even when it is too blank or uniform to count
81
82
  // as the first informative frame.
82
83
  void MarkFramebufferUpdated();
@@ -90,6 +91,7 @@ class FreeRdpSessionTransport final : public SessionTransport {
90
91
 
91
92
  private:
92
93
  friend struct MidsceneRdpContext;
94
+ friend struct RdpScreenshotTestPeer;
93
95
 
94
96
  freerdp* instance_ = nullptr;
95
97
  std::thread event_thread_;
@@ -110,8 +112,8 @@ class FreeRdpSessionTransport final : public SessionTransport {
110
112
  std::atomic<bool> session_active_{false};
111
113
  std::condition_variable frame_cv_;
112
114
  std::mutex frame_mutex_;
113
- std::optional<RawFrame> first_frame_;
114
- bool first_frame_consumed_ = false;
115
+ // Protected by frame_mutex_; use a monotonic clock for screenshot settling.
116
+ std::chrono::steady_clock::time_point last_frame_update_{};
115
117
  pEndPaint original_end_paint_ = nullptr;
116
118
  uint16_t mouse_x_ = 0;
117
119
  uint16_t mouse_y_ = 0;
@@ -68,6 +68,8 @@ using FdSetSocket = int;
68
68
  // never paints within this window is treated as blank/locked and fails fast
69
69
  // instead of feeding an all-black screenshot to the caller.
70
70
  constexpr int kFirstFrameTimeoutMs = 20'000;
71
+ constexpr auto kScreenshotQuietPeriod = std::chrono::milliseconds(300);
72
+ constexpr auto kScreenshotTimeout = std::chrono::seconds(3);
71
73
  // The first frame should prove that real desktop pixels reached the primary
72
74
  // buffer without requiring a complex wallpaper or fully loaded app content.
73
75
  constexpr size_t kMinInformativeColorCount = 128;
@@ -436,11 +438,11 @@ bool HasPendingFramebufferInvalidation(rdpContext* context) {
436
438
  return hwnd->ninvalid > 0 && hwnd->invalid && !hwnd->invalid->null;
437
439
  }
438
440
 
439
- std::optional<RawFrame> CaptureInformativeFramebuffer(rdpContext* context) {
441
+ bool HasInformativeFramebuffer(rdpContext* context) {
440
442
  if (!context || !context->gdi || !context->gdi->primary_buffer ||
441
443
  context->gdi->width <= 0 || context->gdi->height <= 0 ||
442
444
  context->gdi->stride == 0) {
443
- return std::nullopt;
445
+ return false;
444
446
  }
445
447
 
446
448
  rdpGdi* gdi = context->gdi;
@@ -448,7 +450,7 @@ std::optional<RawFrame> CaptureInformativeFramebuffer(rdpContext* context) {
448
450
  const auto height = static_cast<size_t>(gdi->height);
449
451
  const auto stride = static_cast<size_t>(gdi->stride);
450
452
  if (stride < width * 4) {
451
- return std::nullopt;
453
+ return false;
452
454
  }
453
455
 
454
456
  const BYTE* buffer = gdi->primary_buffer;
@@ -489,16 +491,37 @@ std::optional<RawFrame> CaptureInformativeFramebuffer(rdpContext* context) {
489
491
 
490
492
  if (colors.size() < kMinInformativeColorCount ||
491
493
  non_black_pixels < min_non_black_pixels) {
492
- return std::nullopt;
494
+ return false;
493
495
  }
496
+ return true;
497
+ }
494
498
 
495
- RawFrame frame;
496
- frame.size.width = gdi->width;
497
- frame.size.height = gdi->height;
498
- frame.stride = stride;
499
- const size_t buffer_size = stride * height;
500
- frame.bgra.assign(buffer, buffer + buffer_size);
501
- return frame;
499
+ // Called under the transport mutex so paints and resizes cannot race the copy.
500
+ // A black image is a retryable transition, not a successful screenshot.
501
+ std::optional<RawFrame> CaptureNonBlackFramebuffer(const rdpGdi& gdi) {
502
+ if (!gdi.primary_buffer || gdi.width <= 0 || gdi.height <= 0 ||
503
+ gdi.stride < static_cast<size_t>(gdi.width) * 4) {
504
+ throw std::runtime_error("Remote framebuffer is empty or invalid");
505
+ }
506
+
507
+ // Ignore alpha and row padding. Uniform non-black screens are valid; the
508
+ // first-paint color-diversity heuristic does not apply to later screenshots.
509
+ for (int y = 0; y < gdi.height; ++y) {
510
+ const BYTE* row = gdi.primary_buffer + static_cast<size_t>(y) * gdi.stride;
511
+ for (int x = 0; x < gdi.width; ++x) {
512
+ const BYTE* pixel = row + static_cast<size_t>(x) * 4;
513
+ if (pixel[0] || pixel[1] || pixel[2]) {
514
+ RawFrame frame;
515
+ frame.size.width = gdi.width;
516
+ frame.size.height = gdi.height;
517
+ frame.stride = static_cast<size_t>(gdi.stride);
518
+ const size_t buffer_size = frame.stride * static_cast<size_t>(gdi.height);
519
+ frame.bgra.assign(gdi.primary_buffer, gdi.primary_buffer + buffer_size);
520
+ return frame;
521
+ }
522
+ }
523
+ }
524
+ return std::nullopt;
502
525
  }
503
526
 
504
527
  // EndPaint hook chained onto FreeRDP's update pipeline. FreeRDP invokes this
@@ -516,9 +539,8 @@ BOOL MidsceneEndPaint(rdpContext* context) {
516
539
  typed_context->owner->MarkFramebufferUpdated();
517
540
  if (already_painted) {
518
541
  typed_context->owner->MarkFramePainted();
519
- } else if (auto first_frame = CaptureInformativeFramebuffer(context);
520
- first_frame.has_value()) {
521
- typed_context->owner->MarkFramePainted(std::move(first_frame));
542
+ } else if (HasInformativeFramebuffer(context)) {
543
+ typed_context->owner->MarkFramePainted();
522
544
  }
523
545
  }
524
546
  }
@@ -983,8 +1005,7 @@ ConnectionInfo FreeRdpSessionTransport::Connect(const ConnectionConfig& config)
983
1005
  framebuffer_updates_.store(0, std::memory_order_relaxed);
984
1006
  {
985
1007
  std::lock_guard<std::mutex> frame_lock(frame_mutex_);
986
- first_frame_.reset();
987
- first_frame_consumed_ = false;
1008
+ last_frame_update_ = {};
988
1009
  }
989
1010
  original_end_paint_ = nullptr;
990
1011
  ClearSessionErrorLocked();
@@ -1083,36 +1104,46 @@ void FreeRdpSessionTransport::Disconnect() {
1083
1104
  }
1084
1105
 
1085
1106
  RawFrame FreeRdpSessionTransport::CaptureFrame() {
1086
- std::lock_guard<std::mutex> lock(mutex_);
1087
- if (!connected_ || !instance_ || !instance_->context || !instance_->context->gdi) {
1088
- throw std::runtime_error("No remote framebuffer is available");
1089
- }
1107
+ const auto started = std::chrono::steady_clock::now();
1108
+ const auto deadline = started + kScreenshotTimeout;
1109
+ for (;;) {
1110
+ std::unique_lock<std::mutex> lock(mutex_);
1111
+ if (!connected_ || !instance_ || !instance_->context ||
1112
+ !instance_->context->gdi ||
1113
+ !session_active_.load(std::memory_order_relaxed)) {
1114
+ throw std::runtime_error(
1115
+ last_error_ ? "RDP screenshot failed: " + last_error_->message
1116
+ : "No remote framebuffer is available");
1117
+ }
1090
1118
 
1091
- if (frames_painted_.load(std::memory_order_relaxed) == 0) {
1092
- throw std::runtime_error(
1093
- "Remote framebuffer has not received its first paint yet");
1094
- }
1119
+ if (frames_painted_.load(std::memory_order_relaxed) == 0) {
1120
+ throw std::runtime_error(
1121
+ "Remote framebuffer has not received its first paint yet");
1122
+ }
1095
1123
 
1096
- {
1097
- std::lock_guard<std::mutex> frame_lock(frame_mutex_);
1098
- if (!first_frame_consumed_ && first_frame_.has_value()) {
1099
- first_frame_consumed_ = true;
1100
- return *first_frame_;
1124
+ std::unique_lock<std::mutex> frame_lock(frame_mutex_);
1125
+ const auto now = std::chrono::steady_clock::now();
1126
+ const auto quiet_until =
1127
+ std::max(started, last_frame_update_) + kScreenshotQuietPeriod;
1128
+ auto wake_at = std::min(quiet_until, deadline);
1129
+ if (now >= wake_at) {
1130
+ // Copy the live buffer even on the first screenshot; a cached first paint
1131
+ // can be partial or stale by the time the caller requests a screenshot.
1132
+ if (auto frame = CaptureNonBlackFramebuffer(*instance_->context->gdi)) {
1133
+ return std::move(*frame);
1134
+ }
1135
+ if (now >= deadline) {
1136
+ throw std::runtime_error(
1137
+ "RDP screenshot was still black after waiting for framebuffer updates");
1138
+ }
1139
+ wake_at = deadline;
1101
1140
  }
1102
- }
1103
1141
 
1104
- rdpGdi* gdi = instance_->context->gdi;
1105
- if (!gdi->primary_buffer || gdi->width <= 0 || gdi->height <= 0 || gdi->stride == 0) {
1106
- throw std::runtime_error("Remote framebuffer is empty");
1142
+ // Let the event loop process paints while waiting. Every screenshot gets a
1143
+ // short settling window, but animations cannot extend the total deadline.
1144
+ lock.unlock();
1145
+ frame_cv_.wait_until(frame_lock, wake_at);
1107
1146
  }
1108
-
1109
- RawFrame frame;
1110
- frame.size.width = gdi->width;
1111
- frame.size.height = gdi->height;
1112
- frame.stride = static_cast<size_t>(gdi->stride);
1113
- const size_t buffer_size = frame.stride * static_cast<size_t>(gdi->height);
1114
- frame.bgra.assign(gdi->primary_buffer, gdi->primary_buffer + buffer_size);
1115
- return frame;
1116
1147
  }
1117
1148
 
1118
1149
  Size FreeRdpSessionTransport::GetSize() {
@@ -1360,8 +1391,7 @@ void FreeRdpSessionTransport::ResetStateLocked() {
1360
1391
  framebuffer_updates_.store(0, std::memory_order_relaxed);
1361
1392
  {
1362
1393
  std::lock_guard<std::mutex> frame_lock(frame_mutex_);
1363
- first_frame_.reset();
1364
- first_frame_consumed_ = false;
1394
+ last_frame_update_ = {};
1365
1395
  }
1366
1396
  original_end_paint_ = nullptr;
1367
1397
  mouse_x_ = 0;
@@ -1388,23 +1418,21 @@ BOOL FreeRdpSessionTransport::CallOriginalEndPaint(rdpContext* context) {
1388
1418
  return TRUE;
1389
1419
  }
1390
1420
 
1391
- void FreeRdpSessionTransport::MarkFramePainted(
1392
- std::optional<RawFrame> first_frame) {
1421
+ void FreeRdpSessionTransport::MarkFramePainted() {
1393
1422
  {
1394
1423
  std::lock_guard<std::mutex> lock(frame_mutex_);
1395
- if (first_frame.has_value() &&
1396
- frames_painted_.load(std::memory_order_relaxed) == 0 &&
1397
- !first_frame_.has_value()) {
1398
- first_frame_ = std::move(*first_frame);
1399
- first_frame_consumed_ = false;
1400
- }
1401
1424
  frames_painted_.fetch_add(1, std::memory_order_relaxed);
1402
1425
  }
1403
1426
  frame_cv_.notify_all();
1404
1427
  }
1405
1428
 
1406
1429
  void FreeRdpSessionTransport::MarkFramebufferUpdated() {
1407
- framebuffer_updates_.fetch_add(1, std::memory_order_relaxed);
1430
+ {
1431
+ std::lock_guard<std::mutex> lock(frame_mutex_);
1432
+ framebuffer_updates_.fetch_add(1, std::memory_order_relaxed);
1433
+ last_frame_update_ = std::chrono::steady_clock::now();
1434
+ }
1435
+ frame_cv_.notify_all();
1408
1436
  }
1409
1437
 
1410
1438
  bool FreeRdpSessionTransport::HasFramePainted() const {
@@ -0,0 +1,225 @@
1
+ #include <algorithm>
2
+ #include <chrono>
3
+ #include <future>
4
+ #include <iostream>
5
+ #include <stdexcept>
6
+ #include <string>
7
+ #include <thread>
8
+
9
+ #include <freerdp/gdi/gdi.h>
10
+
11
+ #include "rdp_helper_session.hpp"
12
+
13
+ namespace midscene::rdp {
14
+
15
+ // Supply a framebuffer without a Windows server, but exercise the real
16
+ // CaptureFrame(), paint notifications, locks, and disconnect wakeups.
17
+ struct RdpScreenshotTestPeer {
18
+ FreeRdpSessionTransport transport;
19
+ freerdp instance{};
20
+ rdpContext context{};
21
+ rdpGdi gdi{};
22
+ std::vector<uint8_t> pixels;
23
+
24
+ RdpScreenshotTestPeer() : pixels(36 * 8, 255) {
25
+ gdi.width = 8;
26
+ gdi.height = 8;
27
+ gdi.stride = 36; // Non-black padding must not affect black detection.
28
+ gdi.primary_buffer = pixels.data();
29
+ context.gdi = &gdi;
30
+ instance.context = &context;
31
+ transport.instance_ = &instance;
32
+ transport.connected_ = true;
33
+ transport.running_ = true;
34
+ transport.session_active_.store(true);
35
+ }
36
+
37
+ ~RdpScreenshotTestPeer() {
38
+ // These FreeRDP structs belong to the fixture, not freerdp_new().
39
+ transport.instance_ = nullptr;
40
+ }
41
+
42
+ void Paint(uint8_t color, int rows = 8) {
43
+ std::lock_guard<std::mutex> lock(transport.mutex_);
44
+ for (int y = 0; y < 8; ++y) {
45
+ for (int x = 0; x < 8; ++x) {
46
+ const size_t offset = y * gdi.stride + x * 4;
47
+ std::fill_n(pixels.data() + offset, 3, y < rows ? color : 0);
48
+ }
49
+ }
50
+ transport.MarkFramebufferUpdated();
51
+ transport.MarkFramePainted();
52
+ }
53
+
54
+ void LoseSession() {
55
+ {
56
+ std::lock_guard<std::mutex> lock(transport.mutex_);
57
+ transport.connected_ = false;
58
+ transport.last_error_ = ErrorPayload{"session_lost", "test disconnect"};
59
+ }
60
+ transport.SignalSessionInactive();
61
+ }
62
+ };
63
+
64
+ } // namespace midscene::rdp
65
+
66
+ namespace {
67
+
68
+ using namespace std::chrono_literals;
69
+ using midscene::rdp::RawFrame;
70
+ using midscene::rdp::RdpScreenshotTestPeer;
71
+
72
+ void Expect(bool condition, const char* message) {
73
+ if (!condition) {
74
+ throw std::runtime_error(message);
75
+ }
76
+ }
77
+
78
+ bool HasColor(const RawFrame& frame, uint8_t color) {
79
+ for (int y = 0; y < frame.size.height; ++y) {
80
+ for (int x = 0; x < frame.size.width; ++x) {
81
+ const size_t offset = y * frame.stride + x * 4;
82
+ for (size_t channel = 0; channel < 3; ++channel) {
83
+ if (frame.bgra[offset + channel] != color) {
84
+ return false;
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return true;
90
+ }
91
+
92
+ void TestPartialFirstPaint() {
93
+ RdpScreenshotTestPeer peer;
94
+ peer.Paint(64, 2);
95
+ auto painting = std::async(std::launch::async, [&] {
96
+ // Keep painting beyond the initial quiet window: each update must extend
97
+ // settling, while still letting the event-loop mutex make progress.
98
+ for (int rows = 3; rows <= 6; ++rows) {
99
+ std::this_thread::sleep_for(100ms);
100
+ peer.Paint(128, rows);
101
+ }
102
+ std::this_thread::sleep_for(100ms);
103
+ peer.Paint(192);
104
+ });
105
+ const auto frame = peer.transport.CaptureFrame();
106
+ painting.get();
107
+ Expect(HasColor(frame, 192), "first screenshot returned a partial old paint");
108
+ }
109
+
110
+ void TestLaterScreenshotAndBlackRecovery() {
111
+ RdpScreenshotTestPeer peer;
112
+ peer.Paint(64);
113
+ Expect(HasColor(peer.transport.CaptureFrame(), 64),
114
+ "uniform non-black screen should be accepted");
115
+ peer.Paint(0);
116
+ auto painting = std::async(std::launch::async, [&] {
117
+ // Stay black beyond the quiet period; the screenshot must await recovery.
118
+ std::this_thread::sleep_for(500ms);
119
+ peer.Paint(128);
120
+ });
121
+ const auto frame = peer.transport.CaptureFrame();
122
+ painting.get();
123
+ Expect(HasColor(frame, 128), "later screenshot returned a black or cached frame");
124
+ }
125
+
126
+ void TestContinuousUpdatesHaveDeadline() {
127
+ RdpScreenshotTestPeer peer;
128
+ peer.Paint(64);
129
+ std::atomic<bool> stop{false};
130
+ auto painting = std::async(std::launch::async, [&] {
131
+ while (!stop.load()) {
132
+ peer.Paint(128);
133
+ std::this_thread::sleep_for(30ms);
134
+ }
135
+ });
136
+ auto capture = std::async(std::launch::async,
137
+ [&] { return peer.transport.CaptureFrame(); });
138
+ const auto status = capture.wait_for(5s);
139
+ stop.store(true);
140
+ painting.get();
141
+ Expect(status == std::future_status::ready,
142
+ "continuous updates extended the screenshot deadline");
143
+ Expect(HasColor(capture.get(), 128), "deadline did not return the live frame");
144
+ }
145
+
146
+ void TestBlackTimeout() {
147
+ RdpScreenshotTestPeer peer;
148
+ peer.Paint(0);
149
+ auto capture = std::async(std::launch::async, [&] {
150
+ try {
151
+ peer.transport.CaptureFrame();
152
+ return std::string();
153
+ } catch (const std::exception& error) {
154
+ return std::string(error.what());
155
+ }
156
+ });
157
+ Expect(capture.wait_for(500ms) == std::future_status::timeout,
158
+ "black framebuffer was not given time to recover");
159
+ const auto status = capture.wait_for(5s);
160
+ if (status != std::future_status::ready) {
161
+ peer.LoseSession();
162
+ }
163
+ Expect(status == std::future_status::ready, "black screenshot never timed out");
164
+ Expect(capture.get().find("still black") != std::string::npos,
165
+ "black framebuffer (with opaque alpha and padding) was not rejected");
166
+ }
167
+
168
+ void TestDisconnectWakesScreenshot(uint8_t color) {
169
+ RdpScreenshotTestPeer peer;
170
+ peer.Paint(color);
171
+ auto capture = std::async(std::launch::async, [&] {
172
+ try {
173
+ peer.transport.CaptureFrame();
174
+ return std::string();
175
+ } catch (const std::exception& error) {
176
+ return std::string(error.what());
177
+ }
178
+ });
179
+ Expect(capture.wait_for(50ms) == std::future_status::timeout,
180
+ "screenshot did not wait for framebuffer updates");
181
+ peer.LoseSession();
182
+ Expect(capture.wait_for(1s) == std::future_status::ready,
183
+ "session loss did not wake the screenshot waiter");
184
+ Expect(capture.get().find("test disconnect") != std::string::npos,
185
+ "screenshot lost the session failure reason");
186
+ }
187
+
188
+ void TestInvalidFramebuffer() {
189
+ for (int invalid_case = 0; invalid_case < 4; ++invalid_case) {
190
+ RdpScreenshotTestPeer peer;
191
+ peer.Paint(64);
192
+ switch (invalid_case) {
193
+ case 0: peer.gdi.primary_buffer = nullptr; break;
194
+ case 1: peer.gdi.width = 0; break;
195
+ case 2: peer.gdi.height = 0; break;
196
+ case 3: peer.gdi.stride = 4; break;
197
+ }
198
+ std::string error_message;
199
+ try {
200
+ peer.transport.CaptureFrame();
201
+ } catch (const std::exception& error) {
202
+ error_message = error.what();
203
+ }
204
+ Expect(error_message == "Remote framebuffer is empty or invalid",
205
+ "invalid framebuffer layout was not rejected");
206
+ }
207
+ }
208
+
209
+ } // namespace
210
+
211
+ int main() {
212
+ try {
213
+ TestPartialFirstPaint();
214
+ TestLaterScreenshotAndBlackRecovery();
215
+ TestContinuousUpdatesHaveDeadline();
216
+ TestBlackTimeout();
217
+ TestDisconnectWakesScreenshot(0);
218
+ TestDisconnectWakesScreenshot(64);
219
+ TestInvalidFramebuffer();
220
+ return 0;
221
+ } catch (const std::exception& error) {
222
+ std::cerr << error.what() << '\n';
223
+ return 1;
224
+ }
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midscene/computer",
3
- "version": "1.12.4",
3
+ "version": "1.12.5-beta-20260907080441.0",
4
4
  "description": "Midscene.js Computer Desktop Automation",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,8 +33,8 @@
33
33
  "@computer-use/libnut": "^4.2.0",
34
34
  "clipboardy": "^4.0.0",
35
35
  "screenshot-desktop": "^1.15.3",
36
- "@midscene/core": "1.12.4",
37
- "@midscene/shared": "1.12.4"
36
+ "@midscene/core": "1.12.5-beta-20260907080441.0",
37
+ "@midscene/shared": "1.12.5-beta-20260907080441.0"
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "node-mac-permissions": "2.5.0"