983 lines
45 KiB
C++
983 lines
45 KiB
C++
#include <windowsx.h>
|
|
|
|
#include <atomic>
|
|
#include <cstdlib>
|
|
#include <functional>
|
|
#include <future>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#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 RegisterObservedNode(MpvPlayer& player, const std::string& name, int id) {
|
|
player.observed_properties_.Register(name, "node", id);
|
|
}
|
|
|
|
static void HandleEvent(MpvPlayer& player, mpv_event* event) { player.HandleMpvEvent(event); }
|
|
|
|
static void SendPlaybackRestart(MpvPlayer& player, const double* position_seconds) {
|
|
player.SendPlaybackRestartEvent(position_seconds);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
const flutter::EncodableMap& RequireMap(const flutter::EncodableValue& value, const char* message) {
|
|
Check(std::holds_alternative<flutter::EncodableMap>(value), message);
|
|
return std::get<flutter::EncodableMap>(value);
|
|
}
|
|
|
|
const flutter::EncodableValue& RequireMapField(const flutter::EncodableMap& map, const char* key, const char* message) {
|
|
auto value = map.find(flutter::EncodableValue(key));
|
|
Check(value != map.end(), message);
|
|
return value->second;
|
|
}
|
|
|
|
const flutter::EncodableMap& RequireEventData(const flutter::EncodableValue& event, const char* expected_name) {
|
|
const auto& envelope = RequireMap(event, "lifecycle event must be a map");
|
|
const auto& name = RequireMapField(envelope, "name", "lifecycle event name is missing");
|
|
Check(
|
|
std::holds_alternative<std::string>(name) && std::get<std::string>(name) == expected_name,
|
|
"lifecycle event name changed");
|
|
return RequireMap(
|
|
RequireMapField(envelope, "data", "source-qualified lifecycle event data is missing"),
|
|
"lifecycle event data must be a map");
|
|
}
|
|
|
|
int64_t RequireSourceId(const flutter::EncodableMap& data, const char* message) {
|
|
const auto& source_id = RequireMapField(data, "sourceId", message);
|
|
Check(std::holds_alternative<int64_t>(source_id), "source ID must use the signed 64-bit codec type");
|
|
return std::get<int64_t>(source_id);
|
|
}
|
|
|
|
void TestSourceQualifiedEventPayloads() {
|
|
MpvPlayer player;
|
|
MpvPlayerPropertyContractTestPeer::RegisterObservedNode(player, "track-list", 42);
|
|
std::vector<flutter::EncodableValue> events;
|
|
player.SetEventCallback([&events](const flutter::EncodableValue& event) { events.push_back(event); });
|
|
|
|
mpv_event_property property{};
|
|
property.name = "track-list";
|
|
property.format = MPV_FORMAT_NODE;
|
|
mpv_event property_event{};
|
|
property_event.event_id = MPV_EVENT_PROPERTY_CHANGE;
|
|
property_event.data = &property;
|
|
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &property_event);
|
|
|
|
constexpr int64_t kFirstSourceId = -5000000001LL;
|
|
mpv_event_start_file start{};
|
|
start.playlist_entry_id = kFirstSourceId;
|
|
mpv_event start_event{};
|
|
start_event.event_id = MPV_EVENT_START_FILE;
|
|
start_event.data = &start;
|
|
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &start_event);
|
|
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &property_event);
|
|
|
|
mpv_event file_loaded{};
|
|
file_loaded.event_id = MPV_EVENT_FILE_LOADED;
|
|
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &file_loaded);
|
|
|
|
mpv_event playback_restart{};
|
|
playback_restart.event_id = MPV_EVENT_PLAYBACK_RESTART;
|
|
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &playback_restart);
|
|
const double position_seconds = 17.25;
|
|
MpvPlayerPropertyContractTestPeer::SendPlaybackRestart(player, &position_seconds);
|
|
const double invalid_position = std::numeric_limits<double>::infinity();
|
|
MpvPlayerPropertyContractTestPeer::SendPlaybackRestart(player, &invalid_position);
|
|
|
|
constexpr int64_t kEndedSourceId = 6000000002LL;
|
|
mpv_event_end_file end{};
|
|
end.reason = MPV_END_FILE_REASON_ERROR;
|
|
end.error = MPV_ERROR_LOADING_FAILED;
|
|
end.playlist_entry_id = kEndedSourceId;
|
|
mpv_event end_event{};
|
|
end_event.event_id = MPV_EVENT_END_FILE;
|
|
end_event.data = &end;
|
|
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &end_event);
|
|
|
|
constexpr int64_t kNextSourceId = 7000000003LL;
|
|
start.playlist_entry_id = kNextSourceId;
|
|
MpvPlayerPropertyContractTestPeer::HandleEvent(player, &start_event);
|
|
|
|
Check(events.size() == 9, "source-qualified event sequence changed");
|
|
|
|
const auto& property_before_start = std::get<flutter::EncodableList>(events[0]);
|
|
Check(property_before_start.size() == 3, "property event must contain ID, value, and source ID");
|
|
Check(
|
|
std::holds_alternative<int32_t>(property_before_start[0]) && std::get<int32_t>(property_before_start[0]) == 42,
|
|
"property event ID changed");
|
|
Check(std::holds_alternative<std::monostate>(property_before_start[1]), "missing property data must remain null");
|
|
Check(
|
|
std::holds_alternative<std::monostate>(property_before_start[2]),
|
|
"property source must be null before START_FILE");
|
|
|
|
const auto& start_data = RequireEventData(events[1], "start-file");
|
|
Check(
|
|
RequireSourceId(start_data, "start-file source ID is missing") == kFirstSourceId,
|
|
"start-file source ID lost signed 64-bit precision");
|
|
|
|
const auto& source_property = std::get<flutter::EncodableList>(events[2]);
|
|
Check(source_property.size() == 3, "source-qualified property event must remain a triple");
|
|
Check(
|
|
std::holds_alternative<int64_t>(source_property[2]) && std::get<int64_t>(source_property[2]) == kFirstSourceId,
|
|
"property event did not retain the active source ID");
|
|
|
|
const auto& loaded_data = RequireEventData(events[3], "file-loaded");
|
|
Check(
|
|
RequireSourceId(loaded_data, "file-loaded source ID is missing") == kFirstSourceId,
|
|
"file-loaded source ID changed");
|
|
|
|
const auto& restart_without_position = RequireEventData(events[4], "playback-restart");
|
|
Check(
|
|
RequireSourceId(restart_without_position, "playback-restart source ID is missing") == kFirstSourceId,
|
|
"playback-restart source ID changed");
|
|
Check(
|
|
restart_without_position.find(flutter::EncodableValue("positionSeconds")) == restart_without_position.end(),
|
|
"unavailable playback position must not be manufactured");
|
|
|
|
const auto& restart_data = RequireEventData(events[5], "playback-restart");
|
|
Check(
|
|
RequireSourceId(restart_data, "positioned playback-restart source ID is missing") == kFirstSourceId,
|
|
"positioned playback-restart source ID changed");
|
|
const auto& restart_position =
|
|
RequireMapField(restart_data, "positionSeconds", "finite playback position is missing");
|
|
Check(
|
|
std::holds_alternative<double>(restart_position) && std::get<double>(restart_position) == position_seconds,
|
|
"playback-restart position changed");
|
|
|
|
const auto& invalid_restart_data = RequireEventData(events[6], "playback-restart");
|
|
Check(
|
|
invalid_restart_data.find(flutter::EncodableValue("positionSeconds")) == invalid_restart_data.end(),
|
|
"non-finite playback position must not enter the channel payload");
|
|
|
|
const auto& end_data = RequireEventData(events[7], "end-file");
|
|
Check(
|
|
RequireSourceId(end_data, "end-file source ID is missing") == kEndedSourceId,
|
|
"end-file must use its event-specific source ID");
|
|
const auto& end_reason = RequireMapField(end_data, "reason", "end-file reason is missing");
|
|
Check(
|
|
std::holds_alternative<int32_t>(end_reason) && std::get<int32_t>(end_reason) == MPV_END_FILE_REASON_ERROR,
|
|
"end-file reason changed");
|
|
const auto& end_error = RequireMapField(end_data, "error", "end-file error is missing");
|
|
Check(
|
|
std::holds_alternative<int32_t>(end_error) && std::get<int32_t>(end_error) == MPV_ERROR_LOADING_FAILED,
|
|
"end-file error changed");
|
|
Check(
|
|
std::holds_alternative<std::string>(RequireMapField(end_data, "message", "end-file message is missing")),
|
|
"end-file message changed type");
|
|
|
|
const auto& next_start_data = RequireEventData(events[8], "start-file");
|
|
Check(
|
|
RequireSourceId(next_start_data, "replacement source ID is missing") == kNextSourceId,
|
|
"replacement source ID changed");
|
|
Check(
|
|
std::get<int64_t>(source_property[2]) == kFirstSourceId, "later START_FILE relabeled an already-queued property");
|
|
|
|
player.SetEventCallback(nullptr);
|
|
}
|
|
|
|
std::atomic<int> g_forwarded_mouse_messages{0};
|
|
std::atomic<int> g_forwarded_touch_moves{0};
|
|
std::atomic<int> g_forwarded_mouse_down_messages{0};
|
|
std::atomic<int> g_forwarded_mouse_up_messages{0};
|
|
std::atomic<int> g_forwarded_pointer_messages{0};
|
|
std::atomic<LPARAM> g_forwarded_mouse_down_position{0};
|
|
std::atomic<LPARAM> g_forwarded_mouse_up_position{0};
|
|
std::atomic<WPARAM> g_forwarded_mouse_down_flags{0};
|
|
std::atomic<WPARAM> g_forwarded_mouse_up_flags{0};
|
|
constexpr UINT kBlockWindowThreadMessage = WM_APP + 0x0505;
|
|
std::atomic<HANDLE> g_block_entered{nullptr};
|
|
std::atomic<HANDLE> g_block_release{nullptr};
|
|
std::atomic<HANDLE> 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<HWND> g_routing_view{nullptr};
|
|
std::atomic<int> g_routing_view_presses{0};
|
|
std::atomic<int> 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<const OwnerThreadDispatch*>(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<LPARAM>(&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<bool()>& 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<LONG>((static_cast<LONG64>(screen_point.x) * 65535) / (width - 1));
|
|
input.mi.dy = static_cast<LONG>((static_cast<LONG64>(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<TestWindows> 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<WNDPROC>(
|
|
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(CountingWindowProc)));
|
|
const auto inner_original = reinterpret_cast<WNDPROC>(::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<LONG_PTR>(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<WNDPROC>(::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<LPARAM>(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<WNDPROC>(::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<WNDPROC>(::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<WNDPROC>(::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<WNDPROC>(::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<TestWindows> 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<WNDPROC>(
|
|
::SetWindowLongPtrW(target, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(CountingWindowProc)));
|
|
const auto inner_original = reinterpret_cast<WNDPROC>(::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<LONG_PTR>(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<WNDPROC>(::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<WNDPROC>(::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<WNDPROC>(::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<TestWindows> windows_created;
|
|
auto windows_future = windows_created.get_future();
|
|
std::promise<void> 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<WNDPROC>(::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<WNDPROC>(::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<WNDPROC>(::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(RoutingWindowProc)));
|
|
const auto host_original =
|
|
reinterpret_cast<WNDPROC>(::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(RoutingWindowProc)));
|
|
const auto inner_original = reinterpret_cast<WNDPROC>(
|
|
::SetWindowLongPtrW(inner, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(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<LONG_PTR>(inner_original));
|
|
::SetWindowLongPtrW(host, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(host_original));
|
|
::SetWindowLongPtrW(view, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(view_original));
|
|
g_routing_view.store(nullptr, std::memory_order_release);
|
|
::DestroyWindow(inner);
|
|
::DestroyWindow(host);
|
|
::DestroyWindow(view);
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace mpv
|
|
|
|
int main() {
|
|
mpv::TestSourceQualifiedEventPayloads();
|
|
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;
|
|
}
|