Skip to content

fix(win32): 修复前台截图无法激活目标窗口 - #1438

Open
he0119 wants to merge 2 commits into
MaaXYZ:mainfrom
he0119:fix/win32-foreground-screencap
Open

fix(win32): 修复前台截图无法激活目标窗口#1438
he0119 wants to merge 2 commits into
MaaXYZ:mainfrom
he0119:fix/win32-foreground-screencap

Conversation

@he0119

@he0119 he0119 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

close MaaEnd/MaaEnd#5465
close MaaEnd/MaaEnd#4820

问题

Win32 前台截图后端会在截图前尝试激活目标窗口。原实现只依次调用 SetWindowPosSetForegroundWindow,再以固定短延迟等待;当截图进程受 Windows 前台切换限制时,调用可能被拒绝,ScreenDC 或 DXGI_DesktopDup_Window 随后仍会继续截图,导致目标窗口被其他窗口遮挡。

此外,原有 5 秒置前冷却使用函数内静态 DWORD,多个截图线程并发访问时存在数据竞争。

修改内容

  • 将前台恢复实现从头文件移至独立 .cpp,统一维护进程内冷却状态。
  • 在激活前恢复最小化或隐藏的目标窗口,并在有界时间内等待窗口恢复。
  • 先执行普通的 SetWindowPos(HWND_TOP)BringWindowToTopSetForegroundWindow,等待 20 ms 确认结果。
  • 普通激活失败时:
    • 通过 PeekMessageW 确保当前线程拥有消息队列;
    • 使用 RAII 临时关联当前线程与实际前台线程的输入队列;
    • 临时提升目标窗口 Z 序后立即恢复其原有 TOPMOST 状态;
    • 再次请求前台,并最多等待 50 ms。
  • 不关联目标游戏线程,也不跨进程调用 SetFocus / SetActiveWindow;退出作用域时始终解除输入队列关联。
  • 将置前冷却时间戳改为 std::atomic<DWORD>,消除并发数据竞争。
  • 为 ScreenDC 和 DXGI_DesktopDup_Window 的置前失败日志补充目标 HWND、实际前台 HWND、最小化状态和可见状态。

验证

  • git diff --check
  • cmake --build build --config RelWithDebInfo --target MaaWin32ControlUnit --parallel 16
  • Windows 跨线程窗口冒烟测试:后台目标窗口能够成为前台,且测试后未遗留 TOPMOST 状态。
  • 真实游戏窗口测试:从 ChatGPT 前台切换到后台目标窗口成功,succeeded=1foreground_after=targettopmost_after=0
  • 使用 MaaFramework v5.13.0-beta.2 及其原始 MaaUtils/OpenCV 运行完整 ScreenDC 链路:createconnectscreencap 均成功,得到有效的 1920×1080 三通道截图。

已知边界

目标游戏会拒绝外部 SW_MINIMIZE,因此最小化后的真实游戏恢复路径未能完成端到端验证;普通后台遮挡与完整截图链路已验证。

Sourcery 总结

改进 Win32 屏幕截图的前台激活功能,确保目标窗口能够可靠地恢复并置于前台,同时避免并发竞争问题。

Bug 修复:

  • 防止并发的屏幕截图前台激活操作竞争共享的冷却状态。
  • 提高 Win32 屏幕截图的可靠性:在捕获前恢复并激活被遮挡、最小化或隐藏的目标窗口;当常规前台激活受阻时,使用有界重试机制。

增强功能:

  • 集中管理前台激活行为,并为激活失败日志添加诊断上下文,包括目标窗口和实际前台窗口的句柄以及窗口可见性状态。

测试:

  • 验证跨线程的前台激活功能,并在 Windows 上完成 ScreenDC 捕获流程,包括保留目标窗口的 TOPMOST 状态。
Original summary in English

Sourcery 总结

确保 Win32 截图捕获在执行捕获前可靠地激活目标窗口,同时保证并发使用时的安全性。

错误修复:

  • 改进 Win32 前台截图捕获:在捕获前可靠地恢复并激活被遮挡、最小化或隐藏的目标窗口,并在正常激活受阻时提供有界的回退处理。
  • 使前台激活冷却状态具备原子性,以消除并发截图线程之间的数据竞争。

增强功能:

  • 集中管理前台激活行为,并在激活失败日志中添加诊断上下文,包括目标窗口句柄、实际前台窗口句柄以及窗口可见性状态。

测试:

  • 验证跨线程前台激活,以及完整的 Windows ScreenDC 捕获流程,同时保持目标窗口的 TOPMOST 状态。
Original summary in English

Summary by Sourcery

Make Win32 screenshot capture reliably activate the target window before capturing while remaining safe under concurrent use.

Bug Fixes:

  • Improve Win32 foreground screenshot capture by reliably restoring and activating obscured, minimized, or hidden target windows before capture, including bounded fallback handling when normal activation is blocked.
  • Make foreground activation cooldown state atomic to eliminate data races between concurrent screenshot threads.

Enhancements:

  • Centralize foreground activation behavior and add diagnostic context to activation failure logs, including target and actual foreground window handles and window visibility state.

Tests:

  • Validate cross-thread foreground activation and the complete Windows ScreenDC capture flow while preserving the target window's TOPMOST state.

Bug Fixes(缺陷修复):

  • 确保前台激活使用共享的、原子性的冷却时间戳,在并发截图线程之间避免数据竞争。

Enhancements(增强功能):

  • 用专门的前台处理实现替换内联的前台工具逻辑,该实现可恢复最小化或隐藏的窗口,并在正确处理线程输入的前提下进行有界次数的重试。
  • 为截图前台激活失败的日志增加目标窗口句柄、实际前台窗口句柄以及最小化和可见性状态,便于问题诊断。
Original summary in English

恢复最小化或隐藏的目标窗口,并等待前台切换结果。

在普通激活失败时,短暂关联当前前台线程的输入队列并临时调整 Z 序后重试;通过 RAII 保证输入队列解除关联且不遗留永久置顶。

同时将截图置前冷却改为原子状态,并为 ScreenDC 与 DesktopDupWindow 补充失败诊断信息。
@he0119
he0119 marked this pull request as draft August 11, 2026 08:50

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - 我发现了两个问题,并留下了一些总体反馈:

  • ensure_foreground_with_cooldown 中的冷却逻辑现在在仍处于时间间隔内时会提前返回 false,而不是像之前的实现那样再次检查这段时间内窗口是否已经变为前台,这在行为上有细微变化;请考虑这种回退是否可以接受,或者是否需要恢复之前的语义。
  • 围绕 last_foreground_attempt.compare_exchange_weak 的自旋循环使用了 memory_order_relaxed,在高竞争情况下理论上可能无限循环;你可以考虑用 fetch_max/exchange 或单次 CAS 而不手写重试循环来简化逻辑,因为这里对精确的内存序并不敏感。
给 AI Agent 的提示
Please address the comments from this code review:

## Overall Comments
- The cooldown logic in `ensure_foreground_with_cooldown` now returns `false` early when within the interval instead of rechecking whether the window became foreground in the meantime (as the previous implementation did), which subtly changes behavior; consider whether this regression is acceptable or restore the prior semantics.
- The spin loop around `last_foreground_attempt.compare_exchange_weak` uses `memory_order_relaxed` and can theoretically loop indefinitely under high contention; you might simplify this by using `fetch_max`/`exchange` or a single CAS without a manual retry loop since exact ordering is not critical here.

## Individual Comments

### Comment 1
<location path="source/MaaWin32ControlUnit/Base/ForegroundUtils.cpp" line_range="129-138" />
<code_context>
+    const DWORD now = GetTickCount();
+    DWORD previous_attempt = last_foreground_attempt.load(std::memory_order_relaxed);
+    while (true) {
+        if (previous_attempt != 0 && now - previous_attempt < kForegroundRecoveryInterval) {
+            return false;
+        }
+        if (last_foreground_attempt.compare_exchange_weak(previous_attempt, now, std::memory_order_relaxed)) {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Cooldown logic no longer checks if the window became foreground independently, which changes previous behavior.

Previously, within the cooldown interval we returned `hwnd == GetForegroundWindow()`, so the call still succeeded if something else had already brought the window to the foreground. Now we always return `false` in that case. If callers interpret `true` as "already in foreground" (regardless of who did it), this can introduce spurious warnings or retries. To keep the prior behavior, consider checking `GetForegroundWindow()` before returning, e.g. `return hwnd == GetForegroundWindow();`.

```suggestion
    const DWORD now = GetTickCount();
    DWORD previous_attempt = last_foreground_attempt.load(std::memory_order_relaxed);
    while (true) {
        if (previous_attempt != 0 && now - previous_attempt < kForegroundRecoveryInterval) {
            // Within cooldown: preserve previous behavior by still succeeding
            // if the window is already in the foreground (regardless of who did it).
            return hwnd == GetForegroundWindow();
        }
        if (last_foreground_attempt.compare_exchange_weak(previous_attempt, now, std::memory_order_relaxed)) {
            break;
        }
    }
```
</issue_to_address>

### Comment 2
<location path="source/MaaWin32ControlUnit/Base/ForegroundUtils.cpp" line_range="56-59" />
<code_context>
+        return;
+    }
+
+    ShowWindowAsync(hwnd, IsIconic(hwnd) ? SW_RESTORE : SW_SHOW);
+
+    const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
+    while ((IsIconic(hwnd) || !IsWindowVisible(hwnd)) && std::chrono::steady_clock::now() < deadline) {
+        std::this_thread::sleep_for(std::chrono::milliseconds(5));
+    }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Repeated calls to IsIconic/IsWindowVisible without re-checking IsWindow may lead to confusing behavior if the window is destroyed mid-loop.

Inside `restore_window`, consider checking `IsWindow(hwnd)` within the loop and breaking early if it returns false. Otherwise, if the window is destroyed while you’re polling `IsIconic` / `IsWindowVisible`, you may keep looping against a dead handle until the deadline, which can lead to surprising behavior during teardown.

Suggested implementation:

```cpp
void restore_window(HWND hwnd)
{
    if (!IsWindow(hwnd)) {
        return;
    }

    if (!IsIconic(hwnd) && IsWindowVisible(hwnd)) {
        return;
    }

    ShowWindowAsync(hwnd, IsIconic(hwnd) ? SW_RESTORE : SW_SHOW);

    const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
    while (IsWindow(hwnd) &&
           (IsIconic(hwnd) || !IsWindowVisible(hwnd)) &&
           std::chrono::steady_clock::now() < deadline) {
        std::this_thread::sleep_for(std::chrono::milliseconds(5));
    }



        attached_ = AttachThreadInput(source_thread_id_, target_thread_id_, TRUE) != FALSE;

```

1. Ensure `ForegroundUtils.cpp` includes `<chrono>` and `<thread>` at the top of the file:
   - Add `#include <chrono>` and `#include <thread>` if they are not already present.
2. If the project avoids fully-qualified names for `std::chrono` / `std::this_thread`, you may instead add appropriate `using` declarations consistent with the existing style.
</issue_to_address>

Sourcery 对开源项目免费——如果你觉得我们的评审有帮助,请考虑分享 ✨
帮我变得更有用!请在每条评论上点击 👍 或 👎,我会根据你的反馈改进后续评审。
Original comment in English

Hey - I've found 2 issues, and left some high level feedback:

  • The cooldown logic in ensure_foreground_with_cooldown now returns false early when within the interval instead of rechecking whether the window became foreground in the meantime (as the previous implementation did), which subtly changes behavior; consider whether this regression is acceptable or restore the prior semantics.
  • The spin loop around last_foreground_attempt.compare_exchange_weak uses memory_order_relaxed and can theoretically loop indefinitely under high contention; you might simplify this by using fetch_max/exchange or a single CAS without a manual retry loop since exact ordering is not critical here.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The cooldown logic in `ensure_foreground_with_cooldown` now returns `false` early when within the interval instead of rechecking whether the window became foreground in the meantime (as the previous implementation did), which subtly changes behavior; consider whether this regression is acceptable or restore the prior semantics.
- The spin loop around `last_foreground_attempt.compare_exchange_weak` uses `memory_order_relaxed` and can theoretically loop indefinitely under high contention; you might simplify this by using `fetch_max`/`exchange` or a single CAS without a manual retry loop since exact ordering is not critical here.

## Individual Comments

### Comment 1
<location path="source/MaaWin32ControlUnit/Base/ForegroundUtils.cpp" line_range="129-138" />
<code_context>
+    const DWORD now = GetTickCount();
+    DWORD previous_attempt = last_foreground_attempt.load(std::memory_order_relaxed);
+    while (true) {
+        if (previous_attempt != 0 && now - previous_attempt < kForegroundRecoveryInterval) {
+            return false;
+        }
+        if (last_foreground_attempt.compare_exchange_weak(previous_attempt, now, std::memory_order_relaxed)) {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Cooldown logic no longer checks if the window became foreground independently, which changes previous behavior.

Previously, within the cooldown interval we returned `hwnd == GetForegroundWindow()`, so the call still succeeded if something else had already brought the window to the foreground. Now we always return `false` in that case. If callers interpret `true` as "already in foreground" (regardless of who did it), this can introduce spurious warnings or retries. To keep the prior behavior, consider checking `GetForegroundWindow()` before returning, e.g. `return hwnd == GetForegroundWindow();`.

```suggestion
    const DWORD now = GetTickCount();
    DWORD previous_attempt = last_foreground_attempt.load(std::memory_order_relaxed);
    while (true) {
        if (previous_attempt != 0 && now - previous_attempt < kForegroundRecoveryInterval) {
            // Within cooldown: preserve previous behavior by still succeeding
            // if the window is already in the foreground (regardless of who did it).
            return hwnd == GetForegroundWindow();
        }
        if (last_foreground_attempt.compare_exchange_weak(previous_attempt, now, std::memory_order_relaxed)) {
            break;
        }
    }
```
</issue_to_address>

### Comment 2
<location path="source/MaaWin32ControlUnit/Base/ForegroundUtils.cpp" line_range="56-59" />
<code_context>
+        return;
+    }
+
+    ShowWindowAsync(hwnd, IsIconic(hwnd) ? SW_RESTORE : SW_SHOW);
+
+    const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
+    while ((IsIconic(hwnd) || !IsWindowVisible(hwnd)) && std::chrono::steady_clock::now() < deadline) {
+        std::this_thread::sleep_for(std::chrono::milliseconds(5));
+    }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Repeated calls to IsIconic/IsWindowVisible without re-checking IsWindow may lead to confusing behavior if the window is destroyed mid-loop.

Inside `restore_window`, consider checking `IsWindow(hwnd)` within the loop and breaking early if it returns false. Otherwise, if the window is destroyed while you’re polling `IsIconic` / `IsWindowVisible`, you may keep looping against a dead handle until the deadline, which can lead to surprising behavior during teardown.

Suggested implementation:

```cpp
void restore_window(HWND hwnd)
{
    if (!IsWindow(hwnd)) {
        return;
    }

    if (!IsIconic(hwnd) && IsWindowVisible(hwnd)) {
        return;
    }

    ShowWindowAsync(hwnd, IsIconic(hwnd) ? SW_RESTORE : SW_SHOW);

    const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100);
    while (IsWindow(hwnd) &&
           (IsIconic(hwnd) || !IsWindowVisible(hwnd)) &&
           std::chrono::steady_clock::now() < deadline) {
        std::this_thread::sleep_for(std::chrono::milliseconds(5));
    }



        attached_ = AttachThreadInput(source_thread_id_, target_thread_id_, TRUE) != FALSE;

```

1. Ensure `ForegroundUtils.cpp` includes `<chrono>` and `<thread>` at the top of the file:
   - Add `#include <chrono>` and `#include <thread>` if they are not already present.
2. If the project avoids fully-qualified names for `std::chrono` / `std::this_thread`, you may instead add appropriate `using` declarations consistent with the existing style.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread source/MaaWin32ControlUnit/Base/ForegroundUtils.cpp Outdated
Comment thread source/MaaWin32ControlUnit/Base/ForegroundUtils.cpp Outdated
@MistEO

MistEO commented Sep 3, 2026

Copy link
Copy Markdown
Member

这个 PR ready 了吗

@he0119

he0119 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

这个 PR ready 了吗

这是根据我之前遇到的 bug 让 gpt 写的,测试下来是修复了,但是我没有能力去审核。之前 @zmdyy0318 说他要来看看。

@zmdyy0318

Copy link
Copy Markdown
Contributor

@zmdyy0318 push一下

@zmdyy0318
zmdyy0318 marked this pull request as ready for review September 5, 2026 14:52
@zmdyy0318

Copy link
Copy Markdown
Contributor

测试通过 push @MistEO

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

您好——我发现了 1 个问题

面向 AI Agent 的提示
请处理本次代码审查中的评论:

## 单独评论

### 评论 1
<location path="source/MaaWin32ControlUnit/Base/ForegroundUtils.h" line_range="31-34" />
<code_context>
+        // SetForegroundWindow 可能被系统前台限制拒绝,但 TOPMOST 切换不受此限制
         if (hwnd != GetForegroundWindow()) {
-            SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
+            SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
+            SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
             std::this_thread::sleep_for(std::chrono::milliseconds(5));
         }
     }
</code_context>
<issue_to_address>
**问题 (bug_risk):** 当 SetForegroundWindow 仍被阻止时,回退逻辑只执行了一次短暂的 TOPMOST 切换,并且从未验证或确保目标窗口已成为前台窗口;恢复为 NOTOPMOST 后,其他前台窗口或 TOPMOST 窗口仍可能覆盖它,而 ScreenDC 和 DesktopDup 继续进行捕获。

**触发条件:** 当 Windows 拒绝前台窗口请求,且另一个窗口与目标窗口重叠时。

**建议修复:** 仅将此回退逻辑作为重试的一部分,并重新检查 GetForegroundWindow;如果目标窗口仍不是前台窗口,则应让激活/捕获失败,而不是继续执行。
</issue_to_address>

Sourcery 对开源项目免费——如果您喜欢我们的审查结果,请考虑分享它们 ✨
Original comment in English

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="source/MaaWin32ControlUnit/Base/ForegroundUtils.h" line_range="31-34" />
<code_context>
+        // SetForegroundWindow 可能被系统前台限制拒绝,但 TOPMOST 切换不受此限制
         if (hwnd != GetForegroundWindow()) {
-            SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
+            SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
+            SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
             std::this_thread::sleep_for(std::chrono::milliseconds(5));
         }
     }
</code_context>
<issue_to_address>
**issue (bug_risk):** When SetForegroundWindow remains blocked, the fallback only performs a transient TOPMOST toggle and never verifies or establishes that the target became the foreground window; after reverting to NOTOPMOST, another foreground or TOPMOST window can still cover it while ScreenDC and DesktopDup continue capturing.

**Triggers:** When Windows rejects the foreground request and another window overlaps the target.

**Suggested fix:** Use the fallback only as part of a retry that rechecks GetForegroundWindow, and fail the activation/capture rather than proceeding if the target is still not foreground.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread source/MaaWin32ControlUnit/Base/ForegroundUtils.h
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

四种任务均失败 启动 Endfield.exe 任务后随机卡死在启动阶段,窗口未聚焦到游戏进程

3 participants