#include #include #include #include #include #include #include #include #include "mpv_player.h" namespace mpv { class MpvPlayerPropertyContractTestPeer { public: static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) { player.pending_requests_.RegisterStatus(std::move(callback)); } static void RegisterPendingPropertyRead(MpvPlayer& player, MpvPlayer::GetPropertyCallback callback) { player.pending_requests_.RegisterProperty(std::move(callback)); } static void ConfigureInnerSubclass(MpvPlayer& player, HWND host, HWND target) { player.hwnd_ = host; player.forward_target_view_ = target; } static void EnsureInnerSubclass(MpvPlayer& player) { player.EnsureMpvInnerSubclassed(); } static void DetachInnerSubclass(MpvPlayer& player) { player.DetachMpvInnerSubclass(); } static const void* InnerSubclassIdentity(const MpvPlayer& player) { return player.inner_subclass_.get(); } static void ReleaseTestWindows(MpvPlayer& player) { player.DetachMpvInnerSubclass(); player.hwnd_ = nullptr; player.forward_target_view_ = nullptr; } }; namespace { void Check(bool condition, const char* message) { if (!condition) { std::cerr << "mpv_player_property_contract_test: " << message << '\n'; std::exit(1); } } std::atomic g_forwarded_mouse_messages{0}; std::atomic g_forwarded_touch_moves{0}; std::atomic g_forwarded_mouse_down_messages{0}; std::atomic g_forwarded_mouse_up_messages{0}; std::atomic g_forwarded_pointer_messages{0}; std::atomic g_forwarded_mouse_down_position{0}; std::atomic g_forwarded_mouse_up_position{0}; std::atomic g_forwarded_mouse_down_flags{0}; std::atomic g_forwarded_mouse_up_flags{0}; constexpr UINT kBlockWindowThreadMessage = WM_APP + 0x0505; std::atomic g_block_entered{nullptr}; std::atomic g_block_release{nullptr}; std::atomic g_block_exited{nullptr}; // A WM_POINTER message cannot be handed to a window owned by another thread: // the system drops a cross-thread send and refuses the post outright with // ERROR_MESSAGE_SYNC_ONLY, because pointer data belongs to the queue of the // thread that retrieved it. Driving mpv's window from the test thread therefore // delivers nothing at all. This relays the send through the window's own thread, // which is also where the real messages arrive in production. constexpr UINT kDispatchOnOwnerThreadMessage = WM_APP + 0x0506; struct OwnerThreadDispatch { HWND target; UINT message; WPARAM wparam; LPARAM lparam; }; std::atomic g_routing_view{nullptr}; std::atomic g_routing_view_presses{0}; std::atomic g_routing_child_presses{0}; LRESULT CALLBACK CountingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { if (message == kBlockWindowThreadMessage) { const HANDLE entered = g_block_entered.load(std::memory_order_acquire); const HANDLE release = g_block_release.load(std::memory_order_acquire); const HANDLE exited = g_block_exited.load(std::memory_order_acquire); if (entered && release && exited) { ::SetEvent(entered); ::WaitForSingleObject(release, INFINITE); ::SetEvent(exited); } return 0; } if (message == kDispatchOnOwnerThreadMessage) { const auto* dispatch = reinterpret_cast(lparam); return ::SendMessageW(dispatch->target, dispatch->message, dispatch->wparam, dispatch->lparam); } if (message == WM_MOUSEMOVE) { if ((wparam & MK_LBUTTON) != 0) { g_forwarded_touch_moves.fetch_add(1, std::memory_order_relaxed); } else { g_forwarded_mouse_messages.fetch_add(1, std::memory_order_relaxed); } return 0; } if (message == WM_LBUTTONDOWN) { g_forwarded_mouse_down_position.store(lparam, std::memory_order_relaxed); g_forwarded_mouse_down_flags.store(wparam, std::memory_order_relaxed); g_forwarded_mouse_down_messages.fetch_add(1, std::memory_order_relaxed); return 0; } if (message == WM_LBUTTONUP) { g_forwarded_mouse_up_position.store(lparam, std::memory_order_relaxed); g_forwarded_mouse_up_flags.store(wparam, std::memory_order_relaxed); g_forwarded_mouse_up_messages.fetch_add(1, std::memory_order_relaxed); return 0; } if (message >= WM_POINTERUPDATE && message <= WM_POINTERCAPTURECHANGED) { g_forwarded_pointer_messages.fetch_add(1, std::memory_order_relaxed); return 0; } return ::DefWindowProcW(hwnd, message, wparam, lparam); } // Sends |message| to |target| from the thread that owns |owner_window|, so a // WM_POINTER message reaches the window procedure instead of being discarded as // a cross-thread send. |owner_window| must be handled by CountingWindowProc and // live on the same thread as |target|. LRESULT SendFromOwnerThread(HWND owner_window, HWND target, UINT message, WPARAM wparam, LPARAM lparam) { OwnerThreadDispatch dispatch{target, message, wparam, lparam}; return ::SendMessageW(owner_window, kDispatchOnOwnerThreadMessage, 0, reinterpret_cast(&dispatch)); } // Attributes a real button press to the window that received it. Installed on // the stand-in view and on every window of the video subtree below it. The // STATIC class answers WM_NCHITTEST with HTTRANSPARENT, which would take these // windows out of the hit test for a reason other than the one under test, so // every one of them claims its client area here. LRESULT CALLBACK RoutingWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { if (message == WM_NCHITTEST) { return HTCLIENT; } if (message == WM_LBUTTONDOWN || message == WM_LBUTTONDBLCLK) { if (hwnd == g_routing_view.load(std::memory_order_acquire)) { g_routing_view_presses.fetch_add(1, std::memory_order_relaxed); } else { g_routing_child_presses.fetch_add(1, std::memory_order_relaxed); } return 0; } return ::DefWindowProcW(hwnd, message, wparam, lparam); } // Pumps this thread's queue until |predicate| holds or the budget runs out. bool PumpUntil(const std::function& predicate, DWORD timeout_ms) { const ULONGLONG deadline = ::GetTickCount64() + timeout_ms; for (;;) { MSG message; while (::PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { ::TranslateMessage(&message); ::DispatchMessageW(&message); } if (predicate()) return true; if (::GetTickCount64() >= deadline) return false; ::Sleep(5); } } bool SendAbsoluteMouse(POINT screen_point, DWORD flags) { const LONG width = ::GetSystemMetrics(SM_CXSCREEN); const LONG height = ::GetSystemMetrics(SM_CYSCREEN); if (width < 2 || height < 2) return false; INPUT input = {}; input.type = INPUT_MOUSE; input.mi.dx = static_cast((static_cast(screen_point.x) * 65535) / (width - 1)); input.mi.dy = static_cast((static_cast(screen_point.y) * 65535) / (height - 1)); input.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | flags; return ::SendInput(1, &input, sizeof(input)) == 1; } bool InjectClick(POINT screen_point) { return SendAbsoluteMouse(screen_point, MOUSEEVENTF_MOVE) && SendAbsoluteMouse(screen_point, MOUSEEVENTF_LEFTDOWN) && SendAbsoluteMouse(screen_point, MOUSEEVENTF_LEFTUP); } // A real touch contact at |screen_point|, for the touch half of the routing // test. The contact is held across a few frames rather than sent as a bare // down/up pair, because a contact that lifts in the same frame it landed is // short enough for a consumer to discard as noise. bool InjectTap(POINT screen_point) { POINTER_TOUCH_INFO contact = {}; contact.pointerInfo.pointerType = PT_TOUCH; contact.pointerInfo.pointerId = 0; contact.pointerInfo.ptPixelLocation = screen_point; contact.touchFlags = TOUCH_FLAG_NONE; contact.touchMask = TOUCH_MASK_CONTACTAREA | TOUCH_MASK_ORIENTATION | TOUCH_MASK_PRESSURE; contact.rcContact.left = screen_point.x - 2; contact.rcContact.top = screen_point.y - 2; contact.rcContact.right = screen_point.x + 2; contact.rcContact.bottom = screen_point.y + 2; contact.orientation = 90; contact.pressure = 32000; contact.pointerInfo.pointerFlags = POINTER_FLAG_DOWN | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT; if (!::InjectTouchInput(1, &contact)) return false; contact.pointerInfo.pointerFlags = POINTER_FLAG_UPDATE | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT; for (int frame = 0; frame < 5; ++frame) { ::Sleep(20); if (!::InjectTouchInput(1, &contact)) return false; } contact.pointerInfo.pointerFlags = POINTER_FLAG_UP; return ::InjectTouchInput(1, &contact) != FALSE; } void TestUnavailablePropertyWriteFails() { MpvPlayer player; int callback_count = 0; int status = MPV_ERROR_SUCCESS; player.SetPropertyAsync("pause", "yes", [&](int error) { ++callback_count; status = error; }); Check(callback_count == 1, "a property write without an mpv handle must complete exactly once"); Check(status == MPV_ERROR_UNINITIALIZED, "a property write without an mpv handle must fail as uninitialized"); } void TestPendingPropertyWriteFailsOnDispose() { MpvPlayer player; int callback_count = 0; int status = MPV_ERROR_SUCCESS; MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyWrite(player, [&](int error) { ++callback_count; status = error; }); player.Dispose(); Check(callback_count == 1, "dispose must complete a pending property write exactly once"); Check(status == MPV_ERROR_UNINITIALIZED, "dispose must cancel a pending property write as uninitialized"); player.Dispose(); Check(callback_count == 1, "repeated dispose must not complete a property write twice"); } void TestPendingRequestTypesRemainDistinctOnDispose() { MpvPlayer player; int write_count = 0; int read_count = 0; std::string read_value = "unexpected"; MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyWrite(player, [&](int error) { Check(error < 0, "cancelled property write must receive an error"); ++write_count; }); MpvPlayerPropertyContractTestPeer::RegisterPendingPropertyRead(player, [&](int error, const std::string& value) { Check(error < 0, "cancelled property read must receive an error"); ++read_count; read_value = value; }); player.Dispose(); Check(write_count == 1, "dispose must complete the typed write request exactly once"); Check(read_count == 1, "dispose must complete the typed read request exactly once"); Check(read_value.empty(), "cancelled property reads must not manufacture a value"); } void TestInnerSubclassOwnershipIsSerializedAndDetached() { struct TestWindows { HWND target; HWND host; HWND inner; WNDPROC inner_original; DWORD owner_thread; }; std::promise windows_created; auto windows_future = windows_created.get_future(); std::thread window_owner([&]() { HWND target = ::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr); HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr); HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr); const auto target_original = reinterpret_cast( ::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast(CountingWindowProc))); const auto inner_original = reinterpret_cast(::GetWindowLongPtrW(inner, GWLP_WNDPROC)); windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()}); MSG message; while (::GetMessageW(&message, nullptr, 0, 0) > 0) { ::TranslateMessage(&message); ::DispatchMessageW(&message); } ::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast(target_original)); ::DestroyWindow(inner); ::DestroyWindow(host); ::DestroyWindow(target); }); const TestWindows windows = windows_future.get(); Check(windows.target && windows.host && windows.inner, "test windows must be created"); MpvPlayer player; MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target); std::thread first([&]() { MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); }); std::thread second([&]() { MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); }); first.join(); second.join(); const auto installed = reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)); Check(installed && installed != windows.inner_original, "exactly one subclass procedure must be installed"); ::SendMessageW(windows.inner, WM_NULL, 0, 0); ::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(4, 7)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 1; ++attempt) { ::Sleep(10); } Check(g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1, "active generation must forward mouse input"); constexpr UINT32 kPrimaryPointerId = 7; constexpr UINT32 kSecondaryPointerId = 8; constexpr UINT32 kCancelledPointerId = 9; constexpr UINT32 kDetachedPointerId = 10; constexpr UINT32 kDestroyedPointerId = 11; constexpr UINT32 kPointerDownFlags = POINTER_MESSAGE_FLAG_NEW | POINTER_MESSAGE_FLAG_INRANGE | POINTER_MESSAGE_FLAG_INCONTACT | POINTER_MESSAGE_FLAG_FIRSTBUTTON | POINTER_MESSAGE_FLAG_PRIMARY; constexpr UINT32 kPointerMoveFlags = POINTER_MESSAGE_FLAG_INRANGE | POINTER_MESSAGE_FLAG_INCONTACT | POINTER_MESSAGE_FLAG_FIRSTBUTTON | POINTER_MESSAGE_FLAG_PRIMARY; constexpr UINT32 kPointerUpFlags = POINTER_MESSAGE_FLAG_INRANGE | POINTER_MESSAGE_FLAG_PRIMARY; POINT down_position = {14, 18}; ::ClientToScreen(windows.inner, &down_position); POINT expected_down_position = down_position; ::ScreenToClient(windows.target, &expected_down_position); POINT up_position = {30, 36}; ::ClientToScreen(windows.inner, &up_position); POINT expected_up_position = up_position; ::ScreenToClient(windows.target, &expected_up_position); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kPrimaryPointerId, kPointerDownFlags), MAKELPARAM(down_position.x, down_position.y)); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERUPDATE, MAKEWPARAM(kPrimaryPointerId, kPointerMoveFlags), MAKELPARAM(up_position.x, up_position.y)); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERUP, MAKEWPARAM(kPrimaryPointerId, kPointerUpFlags), MAKELPARAM(up_position.x, up_position.y)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 1; ++attempt) { ::Sleep(10); } Check(g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 1, "primary touch must press once"); Check(g_forwarded_touch_moves.load(std::memory_order_relaxed) == 1, "primary touch movement must drag once"); Check(g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 1, "primary touch must release once"); Check( g_forwarded_pointer_messages.load(std::memory_order_relaxed) == 0, "raw pointer messages must not reach Flutter"); Check( g_forwarded_mouse_down_flags.load(std::memory_order_relaxed) == MK_LBUTTON, "touch down must hold the mouse button"); Check(g_forwarded_mouse_up_flags.load(std::memory_order_relaxed) == 0, "touch up must release the mouse button"); const LPARAM forwarded_down_position = g_forwarded_mouse_down_position.load(std::memory_order_relaxed); Check( GET_X_LPARAM(forwarded_down_position) == expected_down_position.x && GET_Y_LPARAM(forwarded_down_position) == expected_down_position.y, "touch down must use Flutter-view client coordinates"); const LPARAM forwarded_up_position = g_forwarded_mouse_up_position.load(std::memory_order_relaxed); Check( GET_X_LPARAM(forwarded_up_position) == expected_up_position.x && GET_Y_LPARAM(forwarded_up_position) == expected_up_position.y, "touch up must use Flutter-view client coordinates"); const WPARAM secondary_down = MAKEWPARAM(kSecondaryPointerId, kPointerDownFlags & ~POINTER_MESSAGE_FLAG_PRIMARY); const WPARAM secondary_up = MAKEWPARAM(kSecondaryPointerId, kPointerUpFlags & ~POINTER_MESSAGE_FLAG_PRIMARY); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERDOWN, secondary_down, MAKELPARAM(down_position.x, down_position.y)); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERUP, secondary_up, MAKELPARAM(up_position.x, up_position.y)); ::Sleep(30); Check( g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 1 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 1, "secondary touch must not synthesize another click"); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kCancelledPointerId, kPointerDownFlags), MAKELPARAM(down_position.x, down_position.y)); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERCAPTURECHANGED, MAKEWPARAM(kCancelledPointerId, kPointerUpFlags), reinterpret_cast(windows.host)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 2; ++attempt) { ::Sleep(10); } Check( g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 2 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 2, "capture loss must release an active synthetic mouse press"); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kDetachedPointerId, kPointerDownFlags), MAKELPARAM(down_position.x, down_position.y)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) < 3; ++attempt) { ::Sleep(10); } MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(player); Check( reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original, "detach must restore the original procedure before window destruction"); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 3; ++attempt) { ::Sleep(10); } Check( g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 3 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 3, "detach must release an active synthetic mouse press"); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERUP, MAKEWPARAM(kDetachedPointerId, kPointerUpFlags), MAKELPARAM(up_position.x, up_position.y)); ::Sleep(30); Check( g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 3, "a late pointer up after detach must not release twice"); ::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(8, 9)); ::Sleep(30); Check( g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1, "a callback after detaching the old generation must be ignored"); MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); const auto replacement = reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)); Check(replacement && replacement != windows.inner_original, "a replacement generation must install cleanly"); ::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(10, 11)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 2; ++attempt) { ::Sleep(10); } Check( g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 2, "replacement generation must own forwarding after installation"); MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player); Check( reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original, "replacement detach must restore the original procedure"); MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target); MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); const auto destroy_generation = reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)); Check( destroy_generation && destroy_generation != windows.inner_original, "destroy test must install a fresh subclass generation"); SendFromOwnerThread( windows.target, windows.inner, WM_POINTERDOWN, MAKEWPARAM(kDestroyedPointerId, kPointerDownFlags), MAKELPARAM(down_position.x, down_position.y)); ::SendMessageW(windows.inner, WM_CLOSE, 0, 0); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) < 4; ++attempt) { ::Sleep(10); } Check(!::IsWindow(windows.inner), "destroy test must close the inner window"); Check( g_forwarded_mouse_down_messages.load(std::memory_order_relaxed) == 4 && g_forwarded_mouse_up_messages.load(std::memory_order_relaxed) == 4, "window destruction must release an active synthetic mouse press"); MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player); ::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0); window_owner.join(); g_forwarded_mouse_messages.store(0, std::memory_order_relaxed); g_forwarded_touch_moves.store(0, std::memory_order_relaxed); g_forwarded_mouse_down_messages.store(0, std::memory_order_relaxed); g_forwarded_mouse_up_messages.store(0, std::memory_order_relaxed); g_forwarded_pointer_messages.store(0, std::memory_order_relaxed); } void TestTimedOutSubclassDetachCanBeAdopted() { struct TestWindows { HWND target; HWND host; HWND inner; WNDPROC inner_original; DWORD owner_thread; }; std::promise windows_created; auto windows_future = windows_created.get_future(); std::thread window_owner([&]() { HWND target = ::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr); HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr); HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr); const auto target_original = reinterpret_cast( ::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast(CountingWindowProc))); const auto inner_original = reinterpret_cast(::GetWindowLongPtrW(inner, GWLP_WNDPROC)); windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()}); MSG message; while (::GetMessageW(&message, nullptr, 0, 0) > 0) { ::TranslateMessage(&message); ::DispatchMessageW(&message); } ::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast(target_original)); ::DestroyWindow(inner); ::DestroyWindow(host); ::DestroyWindow(target); }); const TestWindows windows = windows_future.get(); Check(windows.target && windows.host && windows.inner, "detach-timeout test windows must be created"); const HANDLE block_entered = ::CreateEventW(nullptr, TRUE, FALSE, nullptr); const HANDLE block_release = ::CreateEventW(nullptr, TRUE, FALSE, nullptr); const HANDLE block_exited = ::CreateEventW(nullptr, TRUE, FALSE, nullptr); Check(block_entered && block_release && block_exited, "detach-timeout synchronization events must be created"); g_block_entered.store(block_entered, std::memory_order_release); g_block_release.store(block_release, std::memory_order_release); g_block_exited.store(block_exited, std::memory_order_release); g_forwarded_mouse_messages.store(0, std::memory_order_relaxed); { MpvPlayer original; MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(original, windows.host, windows.target); MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(original); Check( reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) != windows.inner_original, "the initial generation must be installed before forcing detach timeout"); const void* retained_generation = MpvPlayerPropertyContractTestPeer::InnerSubclassIdentity(original); Check(retained_generation != nullptr, "the initial generation must have live state"); Check( ::PostMessageW(windows.target, kBlockWindowThreadMessage, 0, 0) != FALSE, "the owner-thread blocking message must be posted"); Check( ::WaitForSingleObject(block_entered, 1000) == WAIT_OBJECT_0, "the owner thread must enter the deterministic blocking message"); MpvPlayerPropertyContractTestPeer::DetachInnerSubclass(original); MpvPlayer replacement; MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(replacement, windows.host, windows.target); MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(replacement); Check( MpvPlayerPropertyContractTestPeer::InnerSubclassIdentity(replacement) == retained_generation, "replacement must atomically adopt the retained generation"); Check( reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) != windows.inner_original, "replacement must adopt the retained installed generation without duplicate subclassing"); ::SetEvent(block_release); Check( ::WaitForSingleObject(block_exited, 1000) == WAIT_OBJECT_0, "the owner thread must leave the deterministic blocking message"); ::SendMessageW(windows.inner, WM_NULL, 0, 0); ::SendMessageW(windows.inner, WM_MOUSEMOVE, 0, MAKELPARAM(12, 13)); for (int attempt = 0; attempt < 100 && g_forwarded_mouse_messages.load(std::memory_order_relaxed) < 1; ++attempt) { ::Sleep(10); } Check( g_forwarded_mouse_messages.load(std::memory_order_relaxed) == 1, "the adopted generation must resume input forwarding"); MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(replacement); Check( reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original, "adopted generation cleanup must eventually restore the original procedure"); } g_block_entered.store(nullptr, std::memory_order_release); g_block_release.store(nullptr, std::memory_order_release); g_block_exited.store(nullptr, std::memory_order_release); ::CloseHandle(block_entered); ::CloseHandle(block_release); ::CloseHandle(block_exited); ::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0); window_owner.join(); g_forwarded_mouse_messages.store(0, std::memory_order_relaxed); } void TestTimedOutSubclassInstallCannotOutliveItsState() { struct TestWindows { HWND target; HWND host; HWND inner; WNDPROC inner_original; DWORD owner_thread; }; std::promise windows_created; auto windows_future = windows_created.get_future(); std::promise begin_dispatch; auto begin_dispatch_future = begin_dispatch.get_future(); std::thread window_owner([&]() { HWND target = ::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr); HWND host = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, target, nullptr, nullptr, nullptr); HWND inner = ::CreateWindowExW(0, L"STATIC", L"", WS_CHILD, 0, 0, 100, 100, host, nullptr, nullptr, nullptr); const auto inner_original = reinterpret_cast(::GetWindowLongPtrW(inner, GWLP_WNDPROC)); windows_created.set_value(TestWindows{target, host, inner, inner_original, ::GetCurrentThreadId()}); // Keep the owning thread alive but unavailable long enough for // SendMessageTimeoutW to cancel the cross-thread ownership action. begin_dispatch_future.wait(); MSG message; while (::GetMessageW(&message, nullptr, 0, 0) > 0) { ::TranslateMessage(&message); ::DispatchMessageW(&message); } ::DestroyWindow(inner); ::DestroyWindow(host); ::DestroyWindow(target); }); const TestWindows windows = windows_future.get(); Check(windows.target && windows.host && windows.inner, "timeout test windows must be created"); { MpvPlayer player; MpvPlayerPropertyContractTestPeer::ConfigureInnerSubclass(player, windows.host, windows.target); MpvPlayerPropertyContractTestPeer::EnsureInnerSubclass(player); MpvPlayerPropertyContractTestPeer::ReleaseTestWindows(player); } // The action and its subclass reference data have now left caller scope. // Dispatching the timed-out message must neither install late nor touch the // destroyed caller state. begin_dispatch.set_value(); ::SendMessageW(windows.inner, WM_NULL, 0, 0); Check( reinterpret_cast(::GetWindowLongPtrW(windows.inner, GWLP_WNDPROC)) == windows.inner_original, "a timed-out action must remain cancelled after the window thread resumes"); ::PostThreadMessageW(windows.owner_thread, WM_QUIT, 0, 0); window_owner.join(); } // The video child must never own a contact: mpv's window lives on mpv's own // thread, where the cross-thread hit-test opt-outs (WS_EX_TRANSPARENT, an // HTTRANSPARENT WM_NCHITTEST reply) do not apply. Disabling the host is what // keeps mouse, touch, and pen on the Flutter view. The window mpv creates // inside the host afterwards needs no state of its own — targeting skips the // host without descending into it — so only the host's own state is asserted // here (a disabled parent never clears its children's WS_DISABLED bit). void TestDisabledVideoHostKeepsInputOnTheFlutterView() { HWND view = ::CreateWindowExW(0, L"STATIC", L"", WS_OVERLAPPED, 0, 0, 400, 400, nullptr, nullptr, nullptr, nullptr); Check(view != nullptr, "video host test needs a stand-in Flutter view window"); RECT client = {}; Check(::GetClientRect(view, &client) != FALSE, "the stand-in view must report a client area"); Check(client.right > 2 && client.bottom > 2, "the stand-in view must have a usable client area"); HWND host = ::CreateWindowExW( WS_EX_NOPARENTNOTIFY, L"STATIC", L"", kVideoHostWindowStyle, 0, 0, client.right, client.bottom, view, nullptr, nullptr, nullptr); Check(host != nullptr, "the video host window must be created"); Check(!::IsWindowEnabled(host), "the video host must be created disabled"); const POINT contact = {client.right / 2, client.bottom / 2}; Check( ::ChildWindowFromPointEx(view, contact, CWP_SKIPDISABLED) == view, "input over the disabled video host must target the Flutter view"); ::EnableWindow(host, TRUE); Check( ::ChildWindowFromPointEx(view, contact, CWP_SKIPDISABLED) == host, "the same contact must reach an enabled host, so the skip is the disabled state and not the geometry"); ::DestroyWindow(host); ::DestroyWindow(view); } // The assertions above only prove that USER32's own child lookup honors // CWP_SKIPDISABLED. What the video child actually depends on is the input hit // test: a press over the disabled host has to reach the parent view, and must // not be swallowed — swallowing is what the previous implementation reported // when it tried disabling this subtree, and no relay can recover from it // because mpv's window never sees the message either. // // A control press with the video subtree hidden runs first. If that one does // not arrive, injection is unavailable here and the test skips. Once it has // landed, silence from the press over the disabled host is a failure, not an // environment problem. void TestDisabledVideoHostRoutesRealPressesToParentView() { HWND view = ::CreateWindowExW( 0, L"STATIC", L"", WS_OVERLAPPEDWINDOW | WS_VISIBLE, 100, 100, 400, 400, nullptr, nullptr, nullptr, nullptr); Check(view != nullptr, "input routing test needs a stand-in Flutter view window"); RECT client = {}; Check(::GetClientRect(view, &client) != FALSE, "the stand-in view must report a client area"); Check(client.right > 2 && client.bottom > 2, "the stand-in view must have a usable client area"); HWND host = ::CreateWindowExW( WS_EX_NOPARENTNOTIFY, L"STATIC", L"", kVideoHostWindowStyle | WS_VISIBLE, 0, 0, client.right, client.bottom, view, nullptr, nullptr, nullptr); Check(host != nullptr, "the video host window must be created"); HWND inner = ::CreateWindowExW( 0, L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, client.right, client.bottom, host, nullptr, nullptr, nullptr); Check(inner != nullptr, "the mpv inner window stand-in must be created"); g_routing_view.store(view, std::memory_order_release); g_routing_view_presses.store(0, std::memory_order_relaxed); g_routing_child_presses.store(0, std::memory_order_relaxed); const auto view_original = reinterpret_cast(::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast(RoutingWindowProc))); const auto host_original = reinterpret_cast(::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast(RoutingWindowProc))); const auto inner_original = reinterpret_cast( ::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast(RoutingWindowProc))); ::SetForegroundWindow(view); PumpUntil([]() { return false; }, 100); POINT contact = {client.right / 2, client.bottom / 2}; ::ClientToScreen(view, &contact); POINT restore_cursor = {}; const bool cursor_known = ::GetCursorPos(&restore_cursor) != FALSE; // Control: with the video subtree hidden, this press can only land on the // view. It establishes that injection reaches this process at all. ::ShowWindow(host, SW_HIDE); PumpUntil([]() { return false; }, 50); bool control_landed = false; if (::WindowFromPoint(contact) == view && InjectClick(contact)) { control_landed = PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000); } if (!control_landed) { std::cout << "mpv_player_property_contract_test: skipped injected input routing (no usable desktop)\n"; } else { g_routing_view_presses.store(0, std::memory_order_relaxed); g_routing_child_presses.store(0, std::memory_order_relaxed); ::ShowWindow(host, SW_SHOWNA); PumpUntil([]() { return false; }, 50); Check( ::WindowFromPoint(contact) == view, "the input hit test must skip the disabled video subtree and resolve to the Flutter view"); Check(InjectClick(contact), "the control press injected, so the press over the video host must inject too"); PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000); Check( g_routing_child_presses.load(std::memory_order_relaxed) == 0, "a disabled video subtree must not receive the press itself"); Check( g_routing_view_presses.load(std::memory_order_relaxed) == 1, "a press over the disabled video host must reach the parent Flutter view instead of being swallowed"); // Touch is the contact kind issue #1556 was reported for, and it takes a // different path into the system than the mouse press above: the injection // below produces a real touch contact, so the system's own hit test picks // the target and promotes it to the legacy mouse stream the counters watch. // A machine without a digitizer can still inject, so this is not gated on // SM_DIGITIZER - only on the injection API being usable at all. if (!::InitializeTouchInjection(1, TOUCH_FEEDBACK_NONE)) { std::cout << "mpv_player_property_contract_test: skipped injected touch routing (injection unavailable)\n"; } else { g_routing_view_presses.store(0, std::memory_order_relaxed); g_routing_child_presses.store(0, std::memory_order_relaxed); ::ShowWindow(host, SW_HIDE); PumpUntil([]() { return false; }, 50); // Same control as the mouse pass: with the video subtree hidden the tap // can only land on the view, which proves touch injection works here // before silence over the disabled host is treated as a failure. bool touch_control_landed = false; if (InjectTap(contact)) { touch_control_landed = PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000); } if (!touch_control_landed) { std::cout << "mpv_player_property_contract_test: skipped injected touch routing (no usable desktop)\n"; } else { g_routing_view_presses.store(0, std::memory_order_relaxed); g_routing_child_presses.store(0, std::memory_order_relaxed); ::ShowWindow(host, SW_SHOWNA); PumpUntil([]() { return false; }, 50); Check(InjectTap(contact), "the control tap injected, so the tap over the video host must inject too"); PumpUntil([]() { return g_routing_view_presses.load(std::memory_order_relaxed) > 0; }, 2000); Check( g_routing_child_presses.load(std::memory_order_relaxed) == 0, "a disabled video subtree must not receive the touch itself"); Check( g_routing_view_presses.load(std::memory_order_relaxed) == 1, "a touch over the disabled video host must reach the parent Flutter view instead of being swallowed"); } } } if (cursor_known) ::SetCursorPos(restore_cursor.x, restore_cursor.y); ::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast(inner_original)); ::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast(host_original)); ::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast(view_original)); g_routing_view.store(nullptr, std::memory_order_release); ::DestroyWindow(inner); ::DestroyWindow(host); ::DestroyWindow(view); } } // namespace } // namespace mpv int main() { mpv::TestUnavailablePropertyWriteFails(); mpv::TestPendingPropertyWriteFailsOnDispose(); mpv::TestPendingRequestTypesRemainDistinctOnDispose(); mpv::TestInnerSubclassOwnershipIsSerializedAndDetached(); mpv::TestTimedOutSubclassDetachCanBeAdopted(); mpv::TestTimedOutSubclassInstallCannotOutliveItsState(); mpv::TestDisabledVideoHostKeepsInputOnTheFlutterView(); mpv::TestDisabledVideoHostRoutesRealPressesToParentView(); std::cout << "mpv_player_property_contract_test: PASS\n"; return 0; }