• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ossia / score / 30237868584

27 Jul 2026 04:43AM UTC coverage: 15.388% (+0.09%) from 15.298%
30237868584

Pull #2146

github

web-flow
Merge 648972a7d into d36abedc4
Pull Request #2146: Per-platform relative mouse motion (pointer lock) instead of cursor warping

120 of 181 new or added lines in 7 files covered. (66.3%)

422 existing lines in 6 files now uncovered.

30591 of 198799 relevant lines covered (15.39%)

1072.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

28.57
/src/lib/score/tools/PointerLock.cpp
1
#include <score/tools/PointerLock.hpp>
2

3
#if !defined(__APPLE__)
4

5
#include <QWindow>
6

7
#if defined(__EMSCRIPTEN__)
8
#include <emscripten/emscripten.h>
9
#include <emscripten/html5.h>
10

11
// Qt puts its windows in a shadow tree and matches events on composedPath()[0];
12
// anything else we lock makes QWasmWindow::processPointer drop every event for
13
// the duration of the lock, release included.
14
// clang-format off
15
EM_JS(void, score_pointerlock_install, (), {
16
  if (Module.scorePointerLock)
17
    return;
18
  const st = { target: null, x: 0, y: 0, owned: false, scale: 1,
19
               lastX: null, lastY: 0, css: 0, mov: 0 };
20
  Module.scorePointerLock = st;
21
  // Only the press decides what gets locked: a move landing on another element
22
  // would otherwise retarget the lock and make Qt drop the release.
23
  document.addEventListener('pointerdown', (e) => {
24
    if (e.button !== 0)
25
      return;
26
    st.target = e.composedPath()[0] || e.target;
27
    st.x = e.clientX;
28
    st.y = e.clientY;
29
  }, true);
30
  // Only our own lock: a canvas rendering a 3D scene may hold one of its own.
31
  document.addEventListener('pointerup', (e) => {
32
    if (e.button === 0 && st.owned && document.pointerLockElement)
33
      document.exitPointerLock();
34
  }, true);
35
  document.addEventListener('pointerlockchange', () => {
36
    if (!document.pointerLockElement)
37
      st.owned = false;
38
  }, true);
39
  // movementX/Y are not in CSS pixels in every browser: measure them against
40
  // clientX/Y, which are, while the pointer is free.
41
  document.addEventListener('mousemove', (e) => {
42
    if (document.pointerLockElement) {
43
      st.lastX = null;
44
      return;
45
    }
46
    if (st.lastX !== null) {
47
      const css = Math.abs(e.clientX - st.lastX) + Math.abs(e.clientY - st.lastY);
48
      const mov = Math.abs(e.movementX) + Math.abs(e.movementY);
49
      if (css > 0 && mov > 0) {
50
        st.css += css;
51
        st.mov += mov;
52
      }
53
      if (st.css > 400) {
54
        const s = st.css / st.mov;
55
        if (s > 0.05 && s < 20)
56
          st.scale = s;
57
        st.css = 0;
58
        st.mov = 0;
59
      }
60
    }
61
    st.lastX = e.clientX;
62
    st.lastY = e.clientY;
63
  }, true);
64
});
65

66
EM_JS(double, score_pointerlock_scale, (), {
67
  const st = Module.scorePointerLock;
68
  return st && st.scale > 0 ? st.scale : 1;
69
});
70

71
EM_JS(void, score_pointerlock_disown, (), {
72
  const st = Module.scorePointerLock;
73
  if (st)
74
    st.owned = false;
75
});
76

77
EM_JS(int, score_pointerlock_request, (), {
78
  const st = Module.scorePointerLock;
79
  let el = st ? st.target : null;
80
  if (!el || !el.isConnected) {
81
    el = document.elementFromPoint(st ? st.x : 0, st ? st.y : 0);
82
    while (el && el.shadowRoot) {
83
      const inner = el.shadowRoot.elementFromPoint(st.x, st.y);
84
      if (!inner || inner === el)
85
        break;
86
      el = inner;
87
    }
88
  }
89
  if (!el || !el.requestPointerLock)
90
    return 0;
91
  try {
92
    const p = el.requestPointerLock();
93
    // Refusal (no user activation, element gone) also raises pointerlockerror,
94
    // which is what actually drives the state; this only silences the rejection.
95
    if (p && p.catch)
96
      p.catch(() => {});
97
  } catch (e) {
98
    return 0;
99
  }
100
  if (st)
101
    st.owned = true;
102
  return 1;
103
});
104
// clang-format on
105

106
namespace
107
{
108
struct PointerLockListeners
109
{
110
  PointerLockListeners() { score_pointerlock_install(); }
111
};
112

113
const PointerLockListeners g_listeners;
114
}
115

116
namespace score
117
{
118
namespace
119
{
120
enum class LockState
121
{
122
  Idle,
123
  Requested,
124
  Locked,
125
  Lost
126
};
127

128
LockState g_state{LockState::Idle};
129
PointerLock::MotionCallback g_callback{};
130
PointerLock::ReleaseCallback g_release{};
131
double g_dx{};
132
double g_dy{};
133
double g_scale{1.};
134

135
bool on_mousemove(int, const EmscriptenMouseEvent* e, void*)
136
{
137
  if(g_state != LockState::Locked)
138
    return false;
139

140
  const QPointF delta{e->movementX * g_scale, e->movementY * g_scale};
141
  g_dx += delta.x();
142
  g_dy += delta.y();
143
  if(g_callback)
144
    g_callback(delta);
145
  return false;
146
}
147

148
bool on_mouseup(int, const EmscriptenMouseEvent* e, void*)
149
{
150
  if(e->button == 0 && g_state != LockState::Idle && g_release)
151
    g_release();
152
  return false;
153
}
154

155
bool on_lockchange(int, const EmscriptenPointerlockChangeEvent* e, void*)
156
{
157
  if(e->isActive)
158
    g_state = LockState::Locked;
159
  else if(g_state != LockState::Idle)
160
    g_state = LockState::Lost;
161
  return false;
162
}
163

164
bool on_lockerror(int, const void*, void*)
165
{
166
  if(g_state == LockState::Requested)
167
    g_state = LockState::Lost;
168
  return false;
169
}
170
}
171

172
bool PointerLock::beginRelative(
173
    QWindow*, MotionCallback onMotion, ReleaseCallback onRelease) noexcept
174
{
175
  if(g_state == LockState::Requested || g_state == LockState::Locked)
176
    return true;
177

178
  static const bool listeners = [] {
179
    emscripten_set_mousemove_callback(
180
        EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, true, &on_mousemove);
181
    emscripten_set_mouseup_callback(
182
        EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, true, &on_mouseup);
183
    emscripten_set_pointerlockchange_callback(
184
        EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, true, &on_lockchange);
185
    emscripten_set_pointerlockerror_callback(
186
        EMSCRIPTEN_EVENT_TARGET_DOCUMENT, nullptr, true, &on_lockerror);
187
    return true;
188
  }();
189
  (void)listeners;
190

191
  if(!score_pointerlock_request())
192
    return false;
193

194
  g_scale = score_pointerlock_scale();
195
  g_callback = onMotion;
196
  g_release = onRelease;
197
  g_dx = 0.;
198
  g_dy = 0.;
199
  g_state = LockState::Requested;
200
  return true;
201
}
202

203
bool PointerLock::active() noexcept
204
{
205
  // Only once the browser has actually granted the lock: the request is
206
  // asynchronous and may be refused, and no motion is delivered until then.
207
  return g_state == LockState::Locked;
208
}
209

210
QPointF PointerLock::takeDelta() noexcept
211
{
212
  const QPointF d{g_dx, g_dy};
213
  g_dx = 0.;
214
  g_dy = 0.;
215
  return d;
216
}
217

218
void PointerLock::endRelative() noexcept
219
{
220
  if(g_state == LockState::Idle)
221
    return;
222

223
  g_state = LockState::Idle;
224
  g_callback = nullptr;
225
  g_release = nullptr;
226
  g_dx = 0.;
227
  g_dy = 0.;
228
  score_pointerlock_disown();
229
  emscripten_exit_pointerlock();
230
}
231
}
232

233
#elif defined(_WIN32)
234
#include <QAbstractNativeEventFilter>
235
#include <QByteArray>
236
#include <QGuiApplication>
237

238
#include <windows.h>
239

240
#include <algorithm>
241
#include <vector>
242

243
namespace score
244
{
245
namespace
246
{
247
double g_dx{};
248
double g_dy{};
249
PointerLock::MotionCallback g_callback{};
250
PointerLock::ReleaseCallback g_release{};
251
HWND g_window{};
252

253
// Raw counts are what the mouse hardware reports, before the system applies
254
// its pointer speed to them; the scroller works in the logical pixels the
255
// other backends deliver.
256
double g_countScale{1.};
257
double g_pixelScale{1.};
258
bool g_haveAbsolute{};
259
QPointF g_lastAbsolute{};
260

261
double pointerSpeedFactor() noexcept
262
{
263
  // The multipliers the pointer-speed slider selects, per "Pointer Ballistics
264
  // for Windows XP"; index 10 (1.0) is the default.
265
  static constexpr double factors[]
266
      = {1. / 32., 1. / 16., 1. / 8., 2. / 8., 3. / 8., 4. / 8., 5. / 8.,
267
         6. / 8.,  7. / 8.,  1.,      1.25,    1.5,     1.75,    2.,
268
         2.25,     2.5,      2.75,    3.,      3.25,    3.5};
269

270
  int speed = 10;
271
  if(!SystemParametersInfoW(SPI_GETMOUSESPEED, 0, &speed, 0))
272
    speed = 10;
273
  return factors[std::clamp(speed, 1, 20) - 1];
274
}
275

276
QPointF absolutePosition(const RAWMOUSE& mouse) noexcept
277
{
278
  const bool virtualDesktop = mouse.usFlags & MOUSE_VIRTUAL_DESKTOP;
279
  const double left = virtualDesktop ? GetSystemMetrics(SM_XVIRTUALSCREEN) : 0;
280
  const double top = virtualDesktop ? GetSystemMetrics(SM_YVIRTUALSCREEN) : 0;
281
  const double width
282
      = GetSystemMetrics(virtualDesktop ? SM_CXVIRTUALSCREEN : SM_CXSCREEN);
283
  const double height
284
      = GetSystemMetrics(virtualDesktop ? SM_CYVIRTUALSCREEN : SM_CYSCREEN);
285

286
  return {
287
      left + (mouse.lLastX / 65535.) * width, top + (mouse.lLastY / 65535.) * height};
288
}
289

290
struct RawMouseFilter final : public QAbstractNativeEventFilter
291
{
292
  bool nativeEventFilter(const QByteArray&, void* message, qintptr*) override
293
  {
294
    auto* msg = static_cast<MSG*>(message);
295
    if(msg->message != WM_INPUT || !g_window)
296
      return false;
297

298
    UINT size = 0;
299
    if(GetRawInputData(
300
           (HRAWINPUT)msg->lParam, RID_INPUT, nullptr, &size, sizeof(RAWINPUTHEADER))
301
       != 0)
302
      return false;
303

304
    m_buffer.resize(size);
305
    if(GetRawInputData(
306
           (HRAWINPUT)msg->lParam, RID_INPUT, m_buffer.data(), &size,
307
           sizeof(RAWINPUTHEADER))
308
       != size)
309
      return false;
310

311
    const auto& raw = *reinterpret_cast<const RAWINPUT*>(m_buffer.data());
312
    if(raw.header.dwType != RIM_TYPEMOUSE)
313
      return false;
314

315
    const auto& mouse = raw.data.mouse;
316
    QPointF delta{};
317
    bool moved = false;
318

319
    // Absolute reporting is what RDP negotiates down to and what tablets and
320
    // virtual-machine pointing devices use: difference the samples instead of
321
    // dropping them, or the control is dead for the whole drag.
322
    if(mouse.usFlags & MOUSE_MOVE_ABSOLUTE)
323
    {
324
      const QPointF pos = absolutePosition(mouse);
325
      if(g_haveAbsolute && pos != g_lastAbsolute)
326
      {
327
        delta = (pos - g_lastAbsolute) * g_pixelScale;
328
        moved = true;
329
      }
330
      g_lastAbsolute = pos;
331
      g_haveAbsolute = true;
332
    }
333
    else if(mouse.lLastX != 0 || mouse.lLastY != 0)
334
    {
335
      delta = QPointF(mouse.lLastX, mouse.lLastY) * g_countScale;
336
      moved = true;
337
    }
338

339
    if(moved)
340
    {
341
      g_dx += delta.x();
342
      g_dy += delta.y();
343
      if(g_callback)
344
        g_callback(delta);
345
    }
346

347
    if((mouse.usButtonFlags & RI_MOUSE_LEFT_BUTTON_UP) && g_release)
348
      g_release();
349
    return false;
350
  }
351

352
private:
353
  std::vector<char> m_buffer;
354
};
355

356
RawMouseFilter g_filter;
357
}
358

359
bool PointerLock::beginRelative(
360
    QWindow* window, MotionCallback onMotion, ReleaseCallback onRelease) noexcept
361
{
362
  if(g_window)
363
    return true;
364
  if(!window)
365
    return false;
366

367
  auto hwnd = (HWND)window->winId();
368
  if(!hwnd)
369
    return false;
370

371
  RAWINPUTDEVICE rid{};
372
  rid.usUsagePage = 0x01;
373
  rid.usUsage = 0x02;
374
  rid.dwFlags = RIDEV_INPUTSINK;
375
  rid.hwndTarget = hwnd;
376
  if(!RegisterRawInputDevices(&rid, 1, sizeof(rid)))
377
    return false;
378

379
  static const bool quitHandler = [] {
380
    QObject::connect(
381
        qGuiApp, &QCoreApplication::aboutToQuit, qGuiApp, [] { endRelative(); });
382
    return true;
383
  }();
384
  Q_UNUSED(quitHandler);
385

386
  const double dpr = window->devicePixelRatio();
387
  g_pixelScale = dpr > 0. ? 1. / dpr : 1.;
388
  g_countScale = pointerSpeedFactor() * g_pixelScale;
389

390
  g_haveAbsolute = false;
391
  g_lastAbsolute = {};
392
  g_dx = 0.;
393
  g_dy = 0.;
394
  g_callback = onMotion;
395
  g_release = onRelease;
396
  g_window = hwnd;
397
  qGuiApp->installNativeEventFilter(&g_filter);
398

399
  POINT p{};
400
  if(GetCursorPos(&p))
401
  {
402
    const RECT r{p.x, p.y, p.x + 1, p.y + 1};
403
    ClipCursor(&r);
404
  }
405
  return true;
406
}
407

408
bool PointerLock::active() noexcept
409
{
410
  return g_window != nullptr;
411
}
412

413
QPointF PointerLock::takeDelta() noexcept
414
{
415
  const QPointF d{g_dx, g_dy};
416
  g_dx = 0.;
417
  g_dy = 0.;
418
  return d;
419
}
420

421
void PointerLock::endRelative() noexcept
422
{
423
  if(!g_window)
424
    return;
425

426
  ClipCursor(nullptr);
427
  qGuiApp->removeNativeEventFilter(&g_filter);
428

429
  RAWINPUTDEVICE rid{};
430
  rid.usUsagePage = 0x01;
431
  rid.usUsage = 0x02;
432
  rid.dwFlags = RIDEV_REMOVE;
433
  rid.hwndTarget = nullptr;
434
  RegisterRawInputDevices(&rid, 1, sizeof(rid));
435

436
  g_window = nullptr;
437
  g_callback = nullptr;
438
  g_release = nullptr;
439
  g_haveAbsolute = false;
440
  g_dx = 0.;
441
  g_dy = 0.;
442
}
443
}
444

445
#elif defined(SCORE_HAS_WAYLAND_POINTER_LOCK)
446
#include <QGuiApplication>
447
#include <qpa/qplatformnativeinterface.h>
448

449
#include <cstring>
450
#include <vector>
451

452
#include <pointer-constraints-unstable-v1-client-protocol.h>
453
#include <relative-pointer-unstable-v1-client-protocol.h>
454
#include <wayland-client.h>
455

456
namespace score
457
{
458
namespace
459
{
460
struct WaylandGlobals
461
{
462
  wl_display* display{};
463
  wl_registry* registry{};
464
  zwp_relative_pointer_manager_v1* relative_manager{};
465
  uint32_t relative_manager_name{};
466
  zwp_pointer_constraints_v1* constraints{};
467
  uint32_t constraints_name{};
468
  std::vector<uint32_t> seats;
469
  bool tried{};
470
};
471

472
WaylandGlobals g_wl;
473
zwp_relative_pointer_v1* g_relative{};
474
zwp_locked_pointer_v1* g_locked{};
475
bool g_lockActive{};
476
PointerLock::MotionCallback g_callback{};
477
PointerLock::ReleaseCallback g_release{};
478
double g_dx{};
479
double g_dy{};
480

481
void registry_global(
482
    void* data, wl_registry* registry, uint32_t name, const char* interface, uint32_t)
483
{
484
  auto& g = *static_cast<WaylandGlobals*>(data);
485
  if(std::strcmp(interface, zwp_relative_pointer_manager_v1_interface.name) == 0)
486
  {
487
    g.relative_manager = static_cast<zwp_relative_pointer_manager_v1*>(
488
        wl_registry_bind(registry, name, &zwp_relative_pointer_manager_v1_interface, 1));
489
    g.relative_manager_name = name;
490
  }
491
  else if(std::strcmp(interface, zwp_pointer_constraints_v1_interface.name) == 0)
492
  {
493
    g.constraints = static_cast<zwp_pointer_constraints_v1*>(
494
        wl_registry_bind(registry, name, &zwp_pointer_constraints_v1_interface, 1));
495
    g.constraints_name = name;
496
  }
497
  else if(std::strcmp(interface, wl_seat_interface.name) == 0)
498
  {
499
    g.seats.push_back(name);
500
  }
501
}
502

503
void registry_global_remove(void* data, wl_registry*, uint32_t name)
504
{
505
  auto& g = *static_cast<WaylandGlobals*>(data);
506
  std::erase(g.seats, name);
507

508
  if(g.relative_manager && name == g.relative_manager_name)
509
  {
510
    PointerLock::endRelative();
511
    zwp_relative_pointer_manager_v1_destroy(g.relative_manager);
512
    g.relative_manager = nullptr;
513
  }
514
  if(g.constraints && name == g.constraints_name)
515
  {
516
    PointerLock::endRelative();
517
    zwp_pointer_constraints_v1_destroy(g.constraints);
518
    g.constraints = nullptr;
519
  }
520
}
521

522
const wl_registry_listener registry_listener{registry_global, registry_global_remove};
523

524
void relative_motion(
525
    void*, zwp_relative_pointer_v1*, uint32_t, uint32_t, wl_fixed_t dx, wl_fixed_t dy,
526
    wl_fixed_t, wl_fixed_t)
527
{
528
  const QPointF delta{wl_fixed_to_double(dx), wl_fixed_to_double(dy)};
529
  g_dx += delta.x();
530
  g_dy += delta.y();
531
  if(g_callback)
532
    g_callback(delta);
533
}
534

535
const zwp_relative_pointer_v1_listener relative_listener{relative_motion};
536

537
void pointer_locked(void*, zwp_locked_pointer_v1*)
538
{
539
  g_lockActive = true;
540
  g_dx = 0.;
541
  g_dy = 0.;
542
}
543

544
void pointer_unlocked(void*, zwp_locked_pointer_v1*)
545
{
546
  g_lockActive = false;
547
}
548

549
const zwp_locked_pointer_v1_listener locked_listener{pointer_locked, pointer_unlocked};
550

551
WaylandGlobals* globals()
552
{
553
  if(!g_wl.tried)
554
  {
555
    g_wl.tried = true;
556

557
    if(!qGuiApp->platformName().startsWith(QStringLiteral("wayland")))
558
      return nullptr;
559

560
    auto* ni = qGuiApp->platformNativeInterface();
561
    if(!ni)
562
      return nullptr;
563

564
    g_wl.display
565
        = static_cast<wl_display*>(ni->nativeResourceForIntegration("wl_display"));
566
    if(!g_wl.display)
567
      return nullptr;
568

569
    auto* queue = wl_display_create_queue(g_wl.display);
570
    g_wl.registry = wl_display_get_registry(g_wl.display);
571
    wl_proxy_set_queue((wl_proxy*)g_wl.registry, queue);
572
    wl_registry_add_listener(g_wl.registry, &registry_listener, &g_wl);
573
    wl_display_roundtrip_queue(g_wl.display, queue);
574

575
    wl_proxy_set_queue((wl_proxy*)g_wl.registry, nullptr);
576
    if(g_wl.relative_manager)
577
      wl_proxy_set_queue((wl_proxy*)g_wl.relative_manager, nullptr);
578
    if(g_wl.constraints)
579
      wl_proxy_set_queue((wl_proxy*)g_wl.constraints, nullptr);
580
    wl_event_queue_destroy(queue);
581
  }
582

583
  return g_wl.constraints && g_wl.relative_manager ? &g_wl : nullptr;
584
}
585

586
wl_pointer* currentPointer(const WaylandGlobals& g) noexcept
587
{
588
  if(g.seats.empty())
589
    return nullptr;
590

591
  return static_cast<wl_pointer*>(
592
      qGuiApp->platformNativeInterface()->nativeResourceForIntegration("wl_pointer"));
593
}
594
}
595

596
bool PointerLock::beginRelative(
597
    QWindow* window, MotionCallback onMotion, ReleaseCallback onRelease) noexcept
598
{
599
  if(g_relative)
600
    return true;
601
  if(!window)
602
    return false;
603

604
  auto* g = globals();
605
  if(!g)
606
    return false;
607

608
  auto* pointer = currentPointer(*g);
609
  if(!pointer)
610
    return false;
611

612
  auto* surface = static_cast<wl_surface*>(
613
      qGuiApp->platformNativeInterface()->nativeResourceForWindow("surface", window));
614
  if(!surface)
615
    return false;
616

617
  static const bool quitHandler = [] {
618
    QObject::connect(
619
        qGuiApp, &QCoreApplication::aboutToQuit, qGuiApp, [] { endRelative(); });
620
    return true;
621
  }();
622
  Q_UNUSED(quitHandler);
623

624
  g_relative = zwp_relative_pointer_manager_v1_get_relative_pointer(
625
      g->relative_manager, pointer);
626
  if(!g_relative)
627
    return false;
628

629
  zwp_relative_pointer_v1_add_listener(g_relative, &relative_listener, nullptr);
630

631
  g_lockActive = false;
632
  g_locked = zwp_pointer_constraints_v1_lock_pointer(
633
      g->constraints, surface, pointer, nullptr,
634
      ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_PERSISTENT);
635
  if(g_locked)
636
    zwp_locked_pointer_v1_add_listener(g_locked, &locked_listener, nullptr);
637

638
  g_callback = onMotion;
639
  g_release = onRelease;
640
  g_dx = 0.;
641
  g_dy = 0.;
642
  wl_display_flush(g->display);
643
  return true;
644
}
645

646
bool PointerLock::active() noexcept
647
{
648
  return g_relative && g_locked && g_lockActive;
649
}
650

651
QPointF PointerLock::takeDelta() noexcept
652
{
653
  const QPointF d{g_dx, g_dy};
654
  g_dx = 0.;
655
  g_dy = 0.;
656
  return d;
657
}
658

659
void PointerLock::endRelative() noexcept
660
{
661
  if(g_locked)
662
  {
663
    zwp_locked_pointer_v1_destroy(g_locked);
664
    g_locked = nullptr;
665
  }
666
  if(g_relative)
667
  {
668
    zwp_relative_pointer_v1_destroy(g_relative);
669
    g_relative = nullptr;
670
  }
671
  if(g_wl.display)
672
    wl_display_flush(g_wl.display);
673

674
  g_lockActive = false;
675
  g_callback = nullptr;
676
  g_release = nullptr;
677
  g_dx = 0.;
678
  g_dy = 0.;
679
}
680
}
681

682
#else
683

684
namespace score
685
{
686
bool PointerLock::beginRelative(QWindow*, MotionCallback, ReleaseCallback) noexcept
3✔
687
{
688
  return false;
3✔
689
}
690

NEW
691
bool PointerLock::active() noexcept
×
692
{
NEW
693
  return false;
×
694
}
695

NEW
696
QPointF PointerLock::takeDelta() noexcept
×
697
{
NEW
698
  return {};
×
699
}
700

NEW
701
void PointerLock::endRelative() noexcept { }
×
702
}
703

704
#endif
705
#endif
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc