Files
InstanceBrick--nightcap/tests/window_pointer_capture_owner_test.cc
T
dim-ghub 569df68729 fix(vr): stop trapping the desktop cursor in the window during VR
Roblox's mouse lock and the RMB camera drag put the window into relative
mouse mode with a grab, which locked the cursor inside it while the headset
was in use. In VR the controllers aim and the window only mirrors the left
eye, so the pointer is never captured and the system cursor stays visible.
2026-10-15 19:55:35 -05:00

548 lines
17 KiB
C++

#include "window/window_pointer_capture_owner.h"
#include <gtest/gtest.h>
#include <unistd.h>
#include <condition_variable>
#include <cstddef>
#include <cstdlib>
#include <mutex>
#include <thread>
#include <vector>
namespace mocktail {
namespace window {
class WindowPointerCaptureOwnerTestPeer {
public:
static void WaitForClearingQuery(WindowPointerCaptureOwner* owner) {
std::unique_lock<std::mutex> lock(owner->mutex_);
owner->condition_.wait(lock, [owner] {
return owner->clearing_ && owner->in_flight_ == 1 &&
owner->callback_ == nullptr && owner->context_ == nullptr;
});
}
static bool ClearingQueryIsInFlight(WindowPointerCaptureOwner* owner) {
std::lock_guard<std::mutex> lock(owner->mutex_);
return owner->clearing_ && owner->in_flight_ == 1;
}
static bool HasInFlightQuery(WindowPointerCaptureOwner* owner) {
std::lock_guard<std::mutex> lock(owner->mutex_);
return owner->in_flight_ != 0;
}
};
namespace {
class FakeBackend final : public PointerCaptureBackend {
public:
bool Apply(bool relative_mode, bool cursor_visible) override {
calls.push_back({relative_mode, cursor_visible});
return succeeds;
}
struct Call {
bool relative_mode;
bool cursor_visible;
};
std::vector<Call> calls;
bool succeeds = true;
};
struct QueryState {
bool succeeds = true;
bool locked_center = false;
};
bool Query(void* context, bool* locked_center) {
auto* state = static_cast<QueryState*>(context);
if (state == nullptr || locked_center == nullptr || !state->succeeds) {
return false;
}
*locked_center = state->locked_center;
return true;
}
struct ReentrantQueryState {
WindowPointerCaptureOwner* owner = nullptr;
};
bool ClearQueryReentrantly(void* context, bool* locked_center) {
auto* state = static_cast<ReentrantQueryState*>(context);
if (state == nullptr || state->owner == nullptr || locked_center == nullptr) {
return false;
}
*locked_center = false;
state->owner->ClearQuery();
return true;
}
struct GatedQueryState {
WindowPointerCaptureOwner* owner = nullptr;
std::mutex mutex;
std::condition_variable condition;
bool reentrant = false;
bool entered = false;
bool allow_inner_clear = false;
bool inner_clear_returned = false;
bool allow_return = false;
bool external_clear_returned = false;
bool second_clear_started = false;
bool second_clear_returned = false;
};
bool GatedQuery(void* context, bool* locked_center) {
auto* state = static_cast<GatedQueryState*>(context);
std::unique_lock<std::mutex> lock(state->mutex);
state->entered = true;
state->condition.notify_all();
if (state->reentrant) {
state->condition.wait(lock, [state] { return state->allow_inner_clear; });
lock.unlock();
state->owner->ClearQuery();
lock.lock();
state->inner_clear_returned = true;
state->condition.notify_all();
}
state->condition.wait(lock, [state] { return state->allow_return; });
*locked_center = false;
return true;
}
void RunOverlappingClearScenario(bool reentrant) {
// Every failure exits the child directly. A deadlock cannot strand a join or
// a test destructor in the parent; the alarm bounds all waits in this child.
alarm(8);
FakeBackend backend;
WindowPointerCaptureOwner owner(&backend);
GatedQueryState state;
state.owner = &owner;
state.reentrant = reentrant;
if (!owner.RegisterQuery(GatedQuery, &state)) {
std::_Exit(1);
}
std::thread pump([&] {
if (!owner.Pump(false)) {
std::_Exit(2);
}
});
{
std::unique_lock<std::mutex> lock(state.mutex);
state.condition.wait(lock, [&state] { return state.entered; });
}
std::thread external_clear([&] {
owner.ClearQuery();
if (WindowPointerCaptureOwnerTestPeer::HasInFlightQuery(&owner)) {
std::_Exit(7);
}
std::lock_guard<std::mutex> lock(state.mutex);
state.external_clear_returned = true;
state.condition.notify_all();
});
WindowPointerCaptureOwnerTestPeer::WaitForClearingQuery(&owner);
QueryState replacement;
if (owner.RegisterQuery(Query, &replacement)) {
std::_Exit(3);
}
std::thread second_clear;
if (reentrant) {
std::unique_lock<std::mutex> lock(state.mutex);
state.allow_inner_clear = true;
state.condition.notify_all();
state.condition.wait(lock,
[&state] { return state.inner_clear_returned; });
} else {
second_clear = std::thread([&] {
{
std::lock_guard<std::mutex> lock(state.mutex);
state.second_clear_started = true;
state.condition.notify_all();
}
owner.ClearQuery();
if (WindowPointerCaptureOwnerTestPeer::HasInFlightQuery(&owner)) {
std::_Exit(7);
}
std::lock_guard<std::mutex> lock(state.mutex);
state.second_clear_returned = true;
state.condition.notify_all();
});
std::unique_lock<std::mutex> lock(state.mutex);
state.condition.wait(lock, [&state] { return state.second_clear_started; });
}
if (!WindowPointerCaptureOwnerTestPeer::ClearingQueryIsInFlight(&owner) ||
owner.RegisterQuery(Query, &replacement)) {
std::_Exit(4);
}
{
std::lock_guard<std::mutex> lock(state.mutex);
if (state.external_clear_returned || state.second_clear_returned) {
std::_Exit(5);
}
state.allow_return = true;
state.condition.notify_all();
}
pump.join();
external_clear.join();
if (second_clear.joinable()) {
second_clear.join();
}
if (!state.external_clear_returned ||
(!reentrant && !state.second_clear_returned) ||
!owner.RegisterQuery(Query, &replacement)) {
std::_Exit(6);
}
owner.ClearQuery();
std::_Exit(0);
}
TEST(WindowPointerCaptureOwnerTest, FollowsNativeMouseLockState) {
FakeBackend backend;
QueryState query;
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
query.locked_center = true;
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
query.locked_center = false;
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
}
TEST(WindowPointerCaptureOwnerTest, TextInputPreservesNativeShiftLock) {
FakeBackend backend;
QueryState query{true, true};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
// Opening chat must not turn relative motion into absolute motion while
// Roblox still requests center lock: the pointer would reach the window edge
// and camera rotation would stop during typing.
EXPECT_TRUE(owner.Pump(true));
EXPECT_TRUE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
// Roblox can release its lock to let the user interact with a text field.
query.locked_center = false;
EXPECT_TRUE(owner.Pump(true));
EXPECT_FALSE(owner.captured());
query.locked_center = true;
EXPECT_TRUE(owner.Pump(true));
EXPECT_TRUE(owner.captured());
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.captured());
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_FALSE(owner.captured());
// Regular pumps continue while the window is unfocused. A stale native
// lock must not recapture the pointer before focus actually returns.
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
EXPECT_TRUE(owner.OnFocusGained(true));
EXPECT_TRUE(owner.captured());
}
TEST(WindowPointerCaptureOwnerTest, QueryOrCaptureFailureStaysReleased) {
FakeBackend backend;
backend.succeeds = false;
QueryState query{true, true};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
EXPECT_FALSE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
EXPECT_EQ(backend.calls.size(), 2U);
query.succeeds = false;
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
}
TEST(WindowPointerCaptureOwnerTest, QueryCanClearItselfWithoutDeadlock) {
FakeBackend backend;
WindowPointerCaptureOwner owner(&backend);
ReentrantQueryState query{&owner};
ASSERT_TRUE(owner.RegisterQuery(ClearQueryReentrantly, &query));
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
QueryState replacement;
EXPECT_TRUE(owner.RegisterQuery(Query, &replacement));
}
TEST(WindowPointerCaptureOwnerTest, ClearRemovesQueryAndAllowsReplacement) {
FakeBackend backend;
QueryState query;
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
ASSERT_FALSE(owner.cursor_visible());
owner.ClearQuery();
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.cursor_visible());
EXPECT_TRUE(owner.RegisterQuery(Query, &query));
}
TEST(WindowPointerCaptureOwnerTest,
QueryCanClearItselfWhileExternalClearWaitsForItsReturn) {
ASSERT_EXIT(RunOverlappingClearScenario(true), ::testing::ExitedWithCode(0),
"");
}
TEST(WindowPointerCaptureOwnerTest,
ConcurrentExternalClearsWaitForQueryReturn) {
ASSERT_EXIT(RunOverlappingClearScenario(false), ::testing::ExitedWithCode(0),
"");
}
TEST(WindowPointerCaptureOwnerTest, RightDragDoesNotCaptureOutsideAnExperience) {
// The app surface has no camera. Capturing there would freeze the cursor
// over Roblox's own menu UI.
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.OnRightButton(true, false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.OnRightButton(false, false));
EXPECT_FALSE(owner.captured());
}
TEST(WindowPointerCaptureOwnerTest, RightDragCapturesOnceTheGameSurfaceIsLive) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
owner.SetGameSessionActive(true);
EXPECT_TRUE(owner.OnRightButton(true, false));
EXPECT_TRUE(owner.captured());
// Leaving the experience releases a capture that is still held.
owner.SetGameSessionActive(false);
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
}
TEST(WindowPointerCaptureOwnerTest, VrNeverTrapsThePointer) {
FakeBackend backend;
QueryState query{true, true};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
owner.SetGameSessionActive(true);
ASSERT_TRUE(owner.Pump(false));
ASSERT_TRUE(owner.captured());
// Turning VR on releases a held lock and shows the desktop cursor.
owner.SetVrActive(true);
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
// Neither Roblox's mouse lock nor an RMB drag captures it again.
EXPECT_TRUE(owner.OnRightButton(true, false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.OnShiftKeyPressed(false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.OnFocusGained(false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
ASSERT_FALSE(backend.calls.empty());
EXPECT_FALSE(backend.calls.back().relative_mode);
}
TEST(WindowPointerCaptureOwnerTest, RightDragCapturesUntilButtonRelease) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
owner.SetGameSessionActive(true);
ASSERT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.OnRightButton(true, false));
EXPECT_TRUE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.captured());
EXPECT_TRUE(owner.OnRightButton(false, false));
EXPECT_FALSE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
}
TEST(WindowPointerCaptureOwnerTest,
RightReleaseRestoresUnlockedStateWhenNoNativeLock) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
query.locked_center = true;
ASSERT_TRUE(owner.OnRightButton(true, false));
constexpr int kTenSecondsAt240Hz = 2400;
for (int pump = 0; pump < kTenSecondsAt240Hz; ++pump) {
ASSERT_TRUE(owner.Pump(false));
}
ASSERT_TRUE(owner.captured());
query.locked_center = false;
ASSERT_TRUE(owner.OnRightButton(false, false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.ShouldDispatchMouseMotion());
}
TEST(WindowPointerCaptureOwnerTest,
RightReleasePreservesNativeLockThatPredatedDrag) {
FakeBackend backend;
QueryState query{true, true};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
ASSERT_TRUE(owner.OnRightButton(true, false));
ASSERT_TRUE(owner.OnRightButton(false, false));
EXPECT_TRUE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
EXPECT_TRUE(owner.ShouldDispatchMouseMotion());
}
TEST(WindowPointerCaptureOwnerTest,
ShiftLockActivatedDuringRightDragSurvivesButtonRelease) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
ASSERT_TRUE(owner.OnRightButton(true, false));
query.locked_center = true;
ASSERT_TRUE(owner.OnShiftKeyPressed(false));
ASSERT_TRUE(owner.OnRightButton(false, false));
EXPECT_TRUE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
EXPECT_TRUE(owner.ShouldDispatchMouseMotion());
}
TEST(WindowPointerCaptureOwnerTest,
ShiftDuringRightDragDoesNotInventNativeMouseLock) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
ASSERT_TRUE(owner.OnRightButton(true, false));
ASSERT_TRUE(owner.OnShiftKeyPressed(false));
ASSERT_TRUE(owner.OnRightButton(false, false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.ShouldDispatchMouseMotion());
}
TEST(WindowPointerCaptureOwnerTest, RightDragDoesNotDependOnNativeLockQuery) {
FakeBackend backend;
QueryState query{false, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
owner.SetGameSessionActive(true);
EXPECT_TRUE(owner.OnRightButton(true, false));
EXPECT_TRUE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
EXPECT_TRUE(owner.OnRightButton(false, false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
}
TEST(WindowPointerCaptureOwnerTest, TextAndFocusCancelRightDragCapture) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
owner.SetGameSessionActive(true);
EXPECT_TRUE(owner.OnRightButton(true, true));
EXPECT_FALSE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.captured());
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
EXPECT_TRUE(owner.OnFocusGained(false));
EXPECT_FALSE(owner.captured());
}
// Roblox draws its own cursor while it owns the pointer. Focus loss reveals the
// system cursor; regaining focus has to hide it again in the same call, because
// Pump() runs before SDL_PollEvent delivers the focus event and would otherwise
// leave the desktop arrow on screen for a whole frame.
TEST(WindowPointerCaptureOwnerTest, FocusGainHidesTheSystemCursorImmediately) {
FakeBackend backend;
QueryState query{true, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
ASSERT_FALSE(owner.cursor_visible());
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_TRUE(owner.cursor_visible());
const std::size_t calls_before = backend.calls.size();
EXPECT_TRUE(owner.OnFocusGained(false));
EXPECT_FALSE(owner.cursor_visible());
ASSERT_GT(backend.calls.size(), calls_before);
EXPECT_FALSE(backend.calls.back().cursor_visible);
// Text input releases capture but Roblox still owns the visible cursor.
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_TRUE(owner.cursor_visible());
EXPECT_TRUE(owner.OnFocusGained(true));
EXPECT_FALSE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
}
TEST(WindowPointerCaptureOwnerTest, TextInputWithoutNativeCursorShowsSystemCursor) {
FakeBackend backend;
QueryState query{false, false};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
EXPECT_TRUE(owner.Pump(true));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
EXPECT_TRUE(owner.OnRightButton(true, true));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
query.succeeds = true;
EXPECT_TRUE(owner.Pump(true));
EXPECT_FALSE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
}
} // namespace
} // namespace window
} // namespace mocktail