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

IJHack / QtPass / 27715113697

17 Jun 2026 07:43PM UTC coverage: 57.077%. Remained the same
27715113697

push

github

web-flow
refactor(#1511): snapshot AppSettings in QtPass destructor and init() (#1561)

Two multi-field read sites missed in PR G:
- ~QtPass() (Windows): isUseWebDav() + getPassStore() → AppSettings snapshot
- QtPass::init() fresh-install block: getAutoclearSeconds(),
  getAutoclearPanelSeconds(), getPwgenExecutable() → single snapshot;
  collapses the usePwgen if/else into one call

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

0 of 6 new or added lines in 1 file covered. (0.0%)

2 existing lines in 1 file now uncovered.

3964 of 6945 relevant lines covered (57.08%)

23.69 hits per line

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

36.47
/src/qtpass.cpp
1
// SPDX-FileCopyrightText: 2018 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "qtpass.h"
4
#include "mainwindow.h"
5
#include "qtpasssettings.h"
6
#include "util.h"
7
#include <QApplication>
8
#include <QClipboard>
9
#include <QDialog>
10
#include <QLabel>
11
#include <QPixmap>
12
#include <QVBoxLayout>
13

14
#ifndef Q_OS_WIN
15
#include <QInputDialog>
16
#include <QLineEdit>
17
#include <QMimeData>
18
#include <utility>
19
#else
20
#define WIN32_LEAN_AND_MEAN /*_KILLING_MACHINE*/
21
#define WIN32_EXTRA_LEAN
22
#include <windows.h>
23
#include <winnetwk.h>
24
#undef DELETE
25
#include <QMimeData>
26
#endif
27

28
#ifdef QT_DEBUG
29
#include "debughelper.h"
30
#endif
31

32
/**
33
 * @brief Constructs a QtPass instance.
34
 * @param mainWindow The main window reference
35
 */
36
QtPass::QtPass(MainWindow *mainWindow) : m_mainWindow(mainWindow) {
12✔
37
  setClipboardTimer();
12✔
38
  clearClipboardTimer.setSingleShot(true);
12✔
39
  connect(&clearClipboardTimer, &QTimer::timeout, this,
12✔
40
          &QtPass::clearClipboard);
12✔
41

42
  QObject::connect(QApplication::instance(), &QApplication::aboutToQuit, this,
12✔
43
                   &QtPass::clearClipboard);
12✔
44

45
  setMainWindow();
12✔
46
}
12✔
47

48
/**
49
 * @brief QtPass::~QtPass destroy!
50
 */
51
QtPass::~QtPass() {
24✔
52
#ifdef Q_OS_WIN
53
  const AppSettings s = QtPassSettings::load();
54
  if (s.useWebDav)
55
    WNetCancelConnection2A(s.passStore.toUtf8().constData(), 0, 1);
56
#else
57
  if (fusedav.state() == QProcess::Running) {
12✔
58
    fusedav.terminate();
×
59
    fusedav.waitForFinished(2000);
×
60
  }
61
#endif
62
}
24✔
63

64
/**
65
 * @brief QtPass::init make sure we are ready to go as soon as
66
 * possible
67
 */
68
auto QtPass::init() -> bool {
12✔
69
  QString passStore = QtPassSettings::getPassStore(Util::findPasswordStore());
12✔
70
  QtPassSettings::setPassStore(passStore);
12✔
71

72
  QtPassSettings::initExecutables();
12✔
73

74
  QString version = QtPassSettings::getVersion();
24✔
75

76
  // Config updates
77
  if (version.isEmpty()) {
12✔
78
#ifdef QT_DEBUG
79
    dbg() << "assuming fresh install";
80
#endif
NEW
81
    const AppSettings s = QtPassSettings::load();
×
NEW
82
    if (s.autoclearSeconds < 5) {
×
UNCOV
83
      QtPassSettings::setAutoclearSeconds(10);
×
84
    }
NEW
85
    if (s.autoclearPanelSeconds < 5) {
×
86
      QtPassSettings::setAutoclearPanelSeconds(10);
×
87
    }
NEW
88
    QtPassSettings::setUsePwgen(!s.pwgenExecutable.isEmpty());
×
NEW
89
    QtPassSettings::setPassTemplate(QStringLiteral("login\nurl"));
×
UNCOV
90
  } else {
×
91
    if (QtPassSettings::getPassTemplate().isEmpty()) {
24✔
NEW
92
      QtPassSettings::setPassTemplate(QStringLiteral("login\nurl"));
×
93
    }
94
  }
95

96
  QtPassSettings::setVersion(VERSION);
12✔
97

98
  if (!Util::configIsValid(QtPassSettings::load())) {
12✔
99
    m_mainWindow->config();
×
100
    if (freshStart && !Util::configIsValid(QtPassSettings::load())) {
×
101
      return false;
102
    }
103
  }
104

105
  // Note: WebDAV mount needs to happen before accessing the store,
106
  // but ideally should be done after Window is shown to avoid long delay.
107
  if (QtPassSettings::isUseWebDav()) {
12✔
108
    mountWebDav();
×
109
  }
110

111
  freshStart = false;
12✔
112
  return true;
12✔
113
}
114

115
/**
116
 * @brief Sets up the main window and connects signal handlers.
117
 */
118
void QtPass::setMainWindow() {
12✔
119
  m_mainWindow->restoreWindow();
12✔
120

121
  fusedav.setParent(m_mainWindow);
12✔
122

123
  // Signal handlers are connected for both pass implementations
124
  // Note: When pass binary changes, QtPass restart is required to reconnect
125
  // This is acceptable as pass binary change is infrequent
126
  connectPassSignalHandlers(QtPassSettings::getRealPass());
12✔
127
  connectPassSignalHandlers(QtPassSettings::getImitatePass());
12✔
128

129
  connect(m_mainWindow, &MainWindow::passShowHandlerFinished, this,
12✔
130
          &QtPass::passShowHandlerFinished);
12✔
131

132
  // only for ipass
133
  connect(QtPassSettings::getImitatePass(), &ImitatePass::startReencryptPath,
12✔
134
          m_mainWindow, &MainWindow::startReencryptPath);
12✔
135
  connect(QtPassSettings::getImitatePass(), &ImitatePass::endReencryptPath,
12✔
136
          m_mainWindow, &MainWindow::endReencryptPath);
12✔
137

138
  connect(m_mainWindow, &MainWindow::passGitInitNeeded, []() {
12✔
139
#ifdef QT_DEBUG
140
    dbg() << "Pass git init called";
141
#endif
142
    QtPassSettings::getPass()->GitInit();
×
143
  });
×
144

145
  connect(m_mainWindow, &MainWindow::generateGPGKeyPair, m_mainWindow,
12✔
146
          [this](const QString &batch) {
12✔
147
            QtPassSettings::getPass()->GenerateGPGKeys(batch);
×
148
            m_mainWindow->showStatusMessage(tr("Generating GPG key pair"),
×
149
                                            60000);
150
          });
×
151
}
12✔
152

153
/**
154
 * @brief Connects pass signal handlers to QtPass slots.
155
 * @param pass The pass instance to connect
156
 */
157
void QtPass::connectPassSignalHandlers(Pass *pass) {
24✔
158
  connect(pass, &Pass::error, this, &QtPass::processError);
24✔
159
  connect(pass, &Pass::processErrorExit, this, &QtPass::processErrorExit);
24✔
160
  connect(pass, &Pass::critical, m_mainWindow, &MainWindow::critical);
24✔
161
  connect(pass, &Pass::startingExecuteWrapper, m_mainWindow,
24✔
162
          &MainWindow::executeWrapperStarted);
24✔
163
  connect(pass, &Pass::statusMsg, m_mainWindow, &MainWindow::showStatusMessage);
24✔
164
  connect(pass, &Pass::finishedShow, m_mainWindow,
24✔
165
          &MainWindow::passShowHandler);
24✔
166
  connect(pass, &Pass::finishedOtpGenerate, m_mainWindow,
24✔
167
          &MainWindow::passOtpHandler);
24✔
168

169
  connect(pass, &Pass::finishedGitInit, this, &QtPass::passStoreChanged);
24✔
170
  connect(pass, &Pass::finishedGitPull, this, &QtPass::processFinished);
24✔
171
  connect(pass, &Pass::finishedGitPush, this, &QtPass::processFinished);
24✔
172
  connect(pass, &Pass::finishedInsert, this, &QtPass::finishedInsert);
24✔
173
  connect(pass, &Pass::finishedRemove, this, &QtPass::passStoreChanged);
24✔
174
  connect(pass, &Pass::finishedInit, this, &QtPass::passStoreChanged);
24✔
175
  connect(pass, &Pass::finishedMove, this, &QtPass::passStoreChanged);
24✔
176
  connect(pass, &Pass::finishedCopy, this, &QtPass::passStoreChanged);
24✔
177
  connect(pass, &Pass::finishedGenerateGPGKeys, this,
24✔
178
          &QtPass::onKeyGenerationComplete);
24✔
179
  connect(pass, &Pass::finishedGrep, m_mainWindow, &MainWindow::onGrepFinished);
24✔
180
}
24✔
181

182
/**
183
 * @brief QtPass::mountWebDav is some scary voodoo magic
184
 */
185
void QtPass::mountWebDav() {
×
186
  const AppSettings s = QtPassSettings::load();
×
187
#ifdef Q_OS_WIN
188
  char dst[20] = {0};
189
  NETRESOURCEA netres;
190
  memset(&netres, 0, sizeof(netres));
191
  netres.dwType = RESOURCETYPE_DISK;
192
  netres.lpLocalName = nullptr;
193
  // Store QByteArray in variables to ensure lifetime during WNetUseConnectionA
194
  // call
195
  QByteArray webDavUrlUtf8 = s.webDavUrl.toUtf8();
196
  QByteArray webDavPasswordUtf8 = s.webDavPassword.toUtf8();
197
  QByteArray webDavUserUtf8 = s.webDavUser.toUtf8();
198
  netres.lpRemoteName = const_cast<char *>(webDavUrlUtf8.constData());
199
  DWORD size = sizeof(dst);
200
  DWORD r = WNetUseConnectionA(
201
      reinterpret_cast<HWND>(m_mainWindow->effectiveWinId()), &netres,
202
      const_cast<char *>(webDavPasswordUtf8.constData()),
203
      const_cast<char *>(webDavUserUtf8.constData()),
204
      CONNECT_TEMPORARY | CONNECT_INTERACTIVE | CONNECT_REDIRECT, dst, &size,
205
      0);
206
  if (r == NO_ERROR) {
207
    QtPassSettings::setPassStore(dst);
208
  } else {
209
    char message[256] = {0};
210
    FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM, 0, r, 0, message,
211
                   sizeof(message), 0);
212
    m_mainWindow->flashText(tr("Failed to connect WebDAV:\n") + message +
213
                                " (0x" + QString::number(r, 16) + ")",
214
                            true);
215
  }
216
#else
217
  fusedav.start("fusedav", QStringList()
×
218
                               << "-o"
×
219
                               << "nonempty"
×
220
                               << "-u"
×
221
                               << "\"" + s.webDavUser + "\"" << s.webDavUrl
×
222
                               << "\"" + s.passStore + "\"");
×
223
  fusedav.waitForStarted();
×
224
  if (fusedav.state() == QProcess::Running) {
×
225
    QString pwd = s.webDavPassword;
226
    bool ok = true;
×
227
    if (pwd.isEmpty()) {
×
228
      pwd = QInputDialog::getText(m_mainWindow, tr("QtPass WebDAV password"),
×
229
                                  tr("Enter password to connect to WebDAV:"),
×
230
                                  QLineEdit::Password, "", &ok);
231
    }
232
    if (ok && !pwd.isEmpty()) {
×
233
      fusedav.write(pwd.toUtf8() + '\n');
×
234
      fusedav.closeWriteChannel();
×
235
      fusedav.waitForFinished(2000);
×
236
    } else {
237
      fusedav.terminate();
×
238
    }
239
  }
240
  QString error = fusedav.readAllStandardError();
×
241
  qsizetype prompt = error.indexOf("Password:");
×
242
  if (prompt >= 0) {
×
243
    error.remove(0, prompt + 10);
×
244
  }
245
  if (fusedav.state() != QProcess::Running) {
×
246
    error = tr("fusedav exited unexpectedly\n") + error;
×
247
  }
248
  if (error.size() > 0) {
×
249
    m_mainWindow->flashText(
×
250
        tr("Failed to start fusedav to connect WebDAV:\n") + error, true);
×
251
  }
252
#endif
253
}
×
254

255
/**
256
 * @brief QtPass::processError something went wrong
257
 * @param error
258
 */
259
void QtPass::processError(QProcess::ProcessError error) {
×
260
  QString errorString;
×
261
  switch (error) {
×
262
  case QProcess::FailedToStart:
×
263
    errorString = tr("QProcess::FailedToStart");
×
264
    break;
×
265
  case QProcess::Crashed:
×
266
    errorString = tr("QProcess::Crashed");
×
267
    break;
×
268
  case QProcess::Timedout:
×
269
    errorString = tr("QProcess::Timedout");
×
270
    break;
×
271
  case QProcess::ReadError:
×
272
    errorString = tr("QProcess::ReadError");
×
273
    break;
×
274
  case QProcess::WriteError:
×
275
    errorString = tr("QProcess::WriteError");
×
276
    break;
×
277
  case QProcess::UnknownError:
×
278
    errorString = tr("QProcess::UnknownError");
×
279
    break;
×
280
  }
281
  m_mainWindow->flashText(errorString, true);
×
282
  m_mainWindow->setUiElementsEnabled(true);
×
283
}
×
284

285
/**
286
 * @brief Handles process error exit.
287
 * @param exitCode The exit code
288
 * @param p_error The error message
289
 */
290
void QtPass::processErrorExit(int exitCode, const QString &p_error) {
×
291
  if (nullptr != m_mainWindow->getKeyGenDialog()) {
×
292
    m_mainWindow->cleanKeygenDialog();
×
293
    if (exitCode != 0) {
×
294
      m_mainWindow->showStatusMessage(tr("GPG key pair generation failed"),
×
295
                                      10000);
296
    }
297
  }
298

299
  if (!p_error.isEmpty()) {
×
300
    QString output;
×
301
    QString error = p_error.toHtmlEscaped();
×
302
    if (exitCode == 0) {
×
303
      //  https://github.com/IJHack/qtpass/issues/111
304
      output = "<span style=\"color: darkgray;\">" + error + "</span><br />";
×
305
    } else {
306
      output = "<span style=\"color: red;\">" + error + "</span><br />";
×
307
    }
308

309
    output.replace(Util::protocolRegex(), R"(<a href="\1">\1</a>)");
×
310
    output.replace(QStringLiteral("\n"), "<br />");
×
311

312
    m_mainWindow->flashText(output, false, true);
×
313
  }
314

315
  m_mainWindow->setUiElementsEnabled(true);
×
316
}
×
317

318
/**
319
 * @brief QtPass::processFinished background process has finished
320
 * @param exitCode
321
 * @param exitStatus
322
 * @param output    stdout from a process
323
 * @param errout    stderr from a process
324
 */
325
void QtPass::processFinished(const QString &p_output, const QString &p_errout) {
×
326
  showInTextBrowser(p_output);
×
327
  //    Sometimes there is error output even with 0 exit code, which is
328
  //    assumed in this function
329
  processErrorExit(0, p_errout);
×
330

331
  m_mainWindow->setUiElementsEnabled(true);
×
332
}
×
333

334
/**
335
 * @brief Called when pass store has changed.
336
 * @param p_out Output from the process
337
 * @param p_err Error output
338
 */
339
void QtPass::passStoreChanged(const QString &p_out, const QString &p_err) {
×
340
  processFinished(p_out, p_err);
×
341
  doGitPush();
×
342
}
×
343

344
/**
345
 * @brief Called when an insert operation has finished.
346
 * @param p_output Output from the process
347
 * @param p_errout Error output
348
 */
349
void QtPass::finishedInsert(const QString &p_output, const QString &p_errout) {
×
350
  processFinished(p_output, p_errout);
×
351
  doGitPush();
×
352
  m_mainWindow->on_treeView_clicked(m_mainWindow->getCurrentTreeViewIndex());
×
353
}
×
354

355
/**
356
 * @brief Called when GPG key generation is complete.
357
 * @param p_output Standard output from the key generation process
358
 * @param p_errout Standard error output from the key generation process
359
 */
360
void QtPass::onKeyGenerationComplete(const QString &p_output,
×
361
                                     const QString &p_errout) {
362
  if (nullptr != m_mainWindow->getKeyGenDialog()) {
×
363
#ifdef QT_DEBUG
364
    qDebug() << "Keygen Done";
365
#endif
366

367
    m_mainWindow->cleanKeygenDialog();
×
368
    m_mainWindow->showStatusMessage(tr("GPG key pair generated successfully"),
×
369
                                    10000);
370
  }
371

372
  processFinished(p_output, p_errout);
×
373
}
×
374

375
/**
376
 * @brief Called when the password show handler has finished.
377
 * @param output The password content to display
378
 */
379
void QtPass::passShowHandlerFinished(QString output) {
×
380
  showInTextBrowser(std::move(output));
×
381
}
×
382

383
/**
384
 * @brief Displays output text in the main window's text browser.
385
 * @param output The text to display
386
 * @param prefix Optional prefix to prepend to the output
387
 * @param postfix Optional postfix to append to the output
388
 */
389
void QtPass::showInTextBrowser(QString output, const QString &prefix,
×
390
                               const QString &postfix) {
391
  output = output.toHtmlEscaped();
×
392

393
  output.replace(Util::protocolRegex(), R"(<a href="\1">\1</a>)");
×
394
  output.replace(QStringLiteral("\n"), "<br />");
×
395
  output = prefix + output + postfix;
×
396

397
  m_mainWindow->flashText(output, false, true);
×
398
}
×
399

400
/**
401
 * @brief Performs automatic git push if enabled in settings.
402
 */
403
void QtPass::doGitPush() {
×
404
  if (QtPassSettings::isAutoPush()) {
×
405
    m_mainWindow->onPush();
×
406
  }
407
}
×
408

409
/**
410
 * @brief Sets the text to be stored in clipboard and handles clipboard
411
 * operations.
412
 * @param password The password or text to store
413
 * @param p_output Additional output text
414
 */
415
void QtPass::setClippedText(const QString &password, const QString &p_output) {
×
416
  const AppSettings s = QtPassSettings::load();
×
417
  if (s.clipBoardType != Enums::CLIPBOARD_NEVER && !p_output.isEmpty()) {
×
418
    clippedText = password;
×
419
    if (s.clipBoardType == Enums::CLIPBOARD_ALWAYS) {
×
420
      copyTextToClipboard(password);
×
421
    }
422
  }
423
}
×
424
/**
425
 * @brief Clears the stored clipped text.
426
 */
427
void QtPass::clearClippedText() { clippedText = ""; }
×
428

429
/**
430
 * @brief Sets the clipboard clear timer based on autoclear settings.
431
 */
432
void QtPass::setClipboardTimer() {
12✔
433
  clearClipboardTimer.setInterval(MS_PER_SECOND *
24✔
434
                                  QtPassSettings::getAutoclearSeconds());
24✔
435
}
12✔
436

437
/**
438
 * @brief MainWindow::clearClipboard remove clipboard contents.
439
 */
440
void QtPass::clearClipboard() {
1✔
441
  QClipboard *clipboard = QApplication::clipboard();
1✔
442
  bool cleared = false;
443
  if (this->clippedText == clipboard->text(QClipboard::Selection)) {
1✔
444
    clipboard->clear(QClipboard::Selection);
1✔
445
    clipboard->setText(QString(""), QClipboard::Selection);
2✔
446
    cleared = true;
447
  }
448
  if (this->clippedText == clipboard->text(QClipboard::Clipboard)) {
2✔
449
    clipboard->clear(QClipboard::Clipboard);
1✔
450
    cleared = true;
451
  }
452
  if (cleared) {
×
453
    m_mainWindow->showStatusMessage(tr("Clipboard cleared"));
2✔
454
  } else {
455
    m_mainWindow->showStatusMessage(tr("Clipboard not cleared"));
×
456
  }
457

458
  clippedText.clear();
1✔
459
}
1✔
460

461
/**
462
 * @brief Build clipboard MIME data with platform-specific security hints.
463
 * @param text - Plain text to copy
464
 * @return QMimeData with text and security hints
465
 */
466
auto buildClipboardMimeData(const QString &text) -> QMimeData * {
1✔
467
  auto *mimeData = new QMimeData();
1✔
468
  mimeData->setText(text);
1✔
469
#ifdef Q_OS_LINUX
470
  mimeData->setData("x-kde-passwordManagerHint", QByteArray("secret"));
2✔
471
#endif
472
#ifdef Q_OS_MAC
473
  mimeData->setData("application/x-nspasteboard-concealed-type", QByteArray());
474
#endif
475
#ifdef Q_OS_WIN
476
  mimeData->setData("ExcludeClipboardContentFromMonitorProcessing",
477
                    dwordBytes(1));
478
  mimeData->setData("CanIncludeInClipboardHistory", dwordBytes(0));
479
  mimeData->setData("CanUploadToCloudClipboard", dwordBytes(0));
480
#endif
481
  return mimeData;
1✔
482
}
483

484
/**
485
 * @brief MainWindow::copyTextToClipboard copies text to your clipboard
486
 * @param text
487
 */
488
void QtPass::copyTextToClipboard(const QString &text) {
×
489
  const AppSettings s = QtPassSettings::load();
×
490
  QClipboard *clip = QApplication::clipboard();
×
491

492
  QClipboard::Mode mode = QClipboard::Clipboard;
493
  if (s.useSelection && clip->supportsSelection()) {
×
494
    mode = QClipboard::Selection;
495
  }
496

497
  auto *mimeData = buildClipboardMimeData(text);
×
498
  clip->setMimeData(mimeData, mode);
×
499

500
  clippedText = text;
×
501
  m_mainWindow->showStatusMessage(tr("Copied to clipboard"));
×
502
  if (s.useAutoclear) {
×
503
    clearClipboardTimer.start();
×
504
  }
505
}
×
506

507
/**
508
 * @brief displays the text as qrcode
509
 * @param text
510
 */
511
void QtPass::showTextAsQRCode(const QString &text) {
×
512
  const AppSettings s = QtPassSettings::load();
×
513
  const QString qrExe = s.qrencodeExecutable.isEmpty()
514
                            ? QStringLiteral("/usr/bin/qrencode")
×
515
                            : s.qrencodeExecutable;
516
  QProcess qrencode;
×
517
  qrencode.start(qrExe, QStringList() << "-o-"
×
518
                                      << "-tPNG");
×
519
  qrencode.write(text.toUtf8());
×
520
  qrencode.closeWriteChannel();
×
521
  qrencode.waitForFinished();
×
522
  QByteArray output(qrencode.readAllStandardOutput());
×
523

524
  if (qrencode.exitStatus() || qrencode.exitCode()) {
×
525
    QString error(qrencode.readAllStandardError());
×
526
    m_mainWindow->showStatusMessage(error);
×
527
  } else {
528
    QPixmap image;
×
529
    image.loadFromData(output, "PNG");
×
530
    QDialog *popup = createQRCodePopup(image);
×
531
    popup->exec();
×
532
  }
×
533
}
×
534

535
/**
536
 * @brief QtPass::createQRCodePopup creates a popup dialog with the given QR
537
 * code image. This is extracted for testability. The caller is responsible
538
 * for showing and managing the popup lifecycle.
539
 * @param image The QR code pixmap to display
540
 * @return The created popup dialog
541
 */
542
QDialog *QtPass::createQRCodePopup(const QPixmap &image) {
1✔
543
  auto *popup = new QDialog(nullptr, Qt::Popup | Qt::FramelessWindowHint);
1✔
544
  popup->setAttribute(Qt::WA_DeleteOnClose);
1✔
545
  auto *layout = new QVBoxLayout;
1✔
546
  auto *popupLabel = new QLabel();
1✔
547
  layout->addWidget(popupLabel);
1✔
548
  popupLabel->setPixmap(image);
1✔
549
  popupLabel->setScaledContents(true);
1✔
550
  popupLabel->show();
1✔
551
  popup->setLayout(layout);
1✔
552
  popup->move(QCursor::pos());
1✔
553
  return popup;
1✔
554
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc