Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/arstack_studio/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ qt_add_executable(arstack_studio
src/StudioClipboard.hpp
src/SvCaptureController.cpp
src/SvCaptureController.hpp
src/CaptureAdapterCatalog.cpp
src/CaptureAdapterCatalog.hpp
${ARSTACK_STUDIO_CORE_SOURCES}
)

Expand Down Expand Up @@ -77,11 +79,17 @@ qt_add_executable(arstack_capture_harness
src/SvCaptureHarness.cpp
src/SvCaptureController.cpp
src/SvCaptureController.hpp
src/CaptureAdapterCatalog.cpp
src/CaptureAdapterCatalog.hpp
)
target_link_libraries(arstack_capture_harness PRIVATE
Qt6::Core Qt6::Gui Qt6::Concurrent
)
target_compile_features(arstack_capture_harness PRIVATE cxx_std_20)
if(WIN32)
target_link_libraries(arstack_studio PRIVATE iphlpapi)
target_link_libraries(arstack_capture_harness PRIVATE iphlpapi)
endif()

# RC2 is a fail-closed evidence evaluator, not a hardware simulator. CI self-tests
# its acceptance policy; a physical run feeds the same executable a machine-readable
Expand Down
10 changes: 10 additions & 0 deletions apps/arstack_studio/qml/SvCapturePanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ Rectangle {
onClicked: capture.openCaptureSupportHelp()
}
}
Label {
Layout.fillWidth: true
text: adapter.currentIndex >= 0 && adapter.currentIndex < capture.interfaceDetails.length
? capture.interfaceDetails[adapter.currentIndex]
: "Select the physical Ethernet port receiving SV/PTP packets."
wrapMode: Text.WordWrap
color: panel.theme.textSoft
font.family: panel.uiFont
font.pixelSize: 9
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Expand Down
140 changes: 140 additions & 0 deletions apps/arstack_studio/src/CaptureAdapterCatalog.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "CaptureAdapterCatalog.hpp"
#include <QHash>
#include <QRegularExpression>
#include <algorithm>
#include <vector>
#ifdef Q_OS_WIN
#include <winsock2.h>
#include <iphlpapi.h>
#include <ipifcons.h>
#endif

namespace {
QString nicKey(const QString& text) {
static const QRegularExpression guid(
QStringLiteral(R"(([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))"));
const auto found = guid.match(text);
return found.hasMatch() ? found.captured(1).toUpper() : text.trimmed().toCaseFolded();
}
bool isVirtual(const QString& text) {
const QString lower = text.toLower();
for (const auto* token : {"virtual", "hyper-v", "vmware", "virtualbox",
"wintun", "loopback", "bluetooth", "tap-windows", "miniport", "vpn"}) {
if (lower.contains(QString::fromLatin1(token))) return true;
}
return false;
}
struct Candidate {
QString id, label, detail;
int priority{3};
bool physicalUp{false};
};
} // namespace

QVector<CaptureNicMetadata> discoverSystemCaptureNics() {
QVector<CaptureNicMetadata> result;
#ifdef Q_OS_WIN
ULONG size = 16384;
std::vector<unsigned char> bytes(size);
auto* list = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(bytes.data());
ULONG status = GetAdaptersAddresses(
AF_UNSPEC, GAA_FLAG_INCLUDE_ALL_INTERFACES, nullptr, list, &size);
if (status == ERROR_BUFFER_OVERFLOW && size <= 2 * 1024 * 1024) {
bytes.resize(size);
list = reinterpret_cast<IP_ADAPTER_ADDRESSES*>(bytes.data());
status = GetAdaptersAddresses(
AF_UNSPEC, GAA_FLAG_INCLUDE_ALL_INTERFACES, nullptr, list, &size);
}
if (status != NO_ERROR) return result;
for (const auto* item = list; item; item = item->Next) {
if (!item->AdapterName) continue;
CaptureNicMetadata nic;
nic.interfaceKey = QString::fromLocal8Bit(item->AdapterName);
if (item->FriendlyName) nic.alias = QString::fromWCharArray(item->FriendlyName).trimmed();
if (item->Description) nic.hardware = QString::fromWCharArray(item->Description).trimmed();
nic.kind = item->IfType == IF_TYPE_ETHERNET_CSMACD
? CaptureNicKind::Ethernet
: item->IfType == IF_TYPE_IEEE80211
? CaptureNicKind::Wireless : CaptureNicKind::Other;
nic.linkUp = item->OperStatus == IfOperStatusUp;
nic.virtualAdapter = isVirtual(nic.alias + QLatin1Char(' ') + nic.hardware);
result.append(nic);
}
#endif
return result;
}

CaptureAdapterCatalog buildCaptureAdapterCatalog(
const QString& dumpcapList, const QVector<CaptureNicMetadata>& nics,
const QString& preferredId) {
CaptureAdapterCatalog result;
QHash<QString, CaptureNicMetadata> byId;
for (const auto& nic : nics) byId.insert(nicKey(nic.interfaceKey), nic);
static const QRegularExpression linePattern(
QStringLiteral(R"(^\s*\d+\.\s+(\S+)(?:\s+\((.*)\))?)"));
QVector<Candidate> rows;
for (const auto& line : dumpcapList.split(QLatin1Char('\n'))) {
const auto match = linePattern.match(line);
if (!match.hasMatch()) continue;
const QString id = match.captured(1);
if (id.isEmpty()) continue;
const auto it = byId.constFind(nicKey(id));
const bool osKnown = it != byId.constEnd();
const CaptureNicMetadata nic = osKnown ? it.value() : CaptureNicMetadata{};
const QString dumpcapLabel = match.captured(2).trimmed();
const QString hardware = nic.hardware.isEmpty() ? dumpcapLabel : nic.hardware;
const QString name = !nic.alias.isEmpty() && !hardware.isEmpty() &&
nic.alias.compare(hardware, Qt::CaseInsensitive) != 0
? nic.alias + QStringLiteral(" — ") + hardware
: !hardware.isEmpty() ? hardware : !nic.alias.isEmpty() ? nic.alias
: QStringLiteral("Unidentified capture interface");
const bool virtualNic = nic.virtualAdapter || (!osKnown && isVirtual(dumpcapLabel));
const bool ethernet = osKnown && nic.kind == CaptureNicKind::Ethernet && !virtualNic;
const bool wireless = osKnown && nic.kind == CaptureNicKind::Wireless;
const bool physicalUp = ethernet && nic.linkUp;
const QString type = ethernet ? QStringLiteral("Ethernet")
: wireless ? QStringLiteral("Wi-Fi")
: virtualNic ? QStringLiteral("Virtual") : QStringLiteral("Other");
Candidate row;
row.id = id;
row.label = type + QStringLiteral(" · ") + name;
row.detail = osKnown
? QStringLiteral("%1 · %2 · Npcap capture available")
.arg(nic.linkUp ? QStringLiteral("Connected") : QStringLiteral("Disconnected"),
ethernet ? QStringLiteral("Wired network adapter")
: wireless ? QStringLiteral("Wireless network adapter")
: virtualNic ? QStringLiteral("Virtual network adapter")
: QStringLiteral("Other network adapter"))
: QStringLiteral("Windows NIC identity unavailable; verify the capture interface.");
row.priority = physicalUp ? 0 : ethernet ? 1 : wireless ? 2 : 3;
row.physicalUp = physicalUp;
rows.append(row);
}
std::stable_sort(rows.begin(), rows.end(),
[](const Candidate& a, const Candidate& b) { return a.priority < b.priority; });
for (const auto& row : rows) {
result.ids.append(row.id);
result.labels.append(row.label);
result.details.append(row.detail);
if (row.physicalUp) ++result.physicalUpCount;
}
const auto preferred = nicKey(preferredId);
if (!preferredId.isEmpty()) {
for (int i = 0; i < rows.size(); ++i) {
if (nicKey(rows[i].id) == preferred) {
result.suggested = i;
return result;
}
}
}
if (result.physicalUpCount == 1) {
for (int i = 0; i < rows.size(); ++i) {
if (rows[i].physicalUp) {
result.suggested = i;
break;
}
}
}
return result;
}
25 changes: 25 additions & 0 deletions apps/arstack_studio/src/CaptureAdapterCatalog.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
#include <QStringList>
#include <QVector>

// Npcap ID remains the capture identity. The OS metadata is presentation only.
enum class CaptureNicKind { Ethernet, Wireless, Other };
struct CaptureNicMetadata {
QString interfaceKey;
QString alias;
QString hardware;
CaptureNicKind kind{CaptureNicKind::Other};
bool linkUp{false};
bool virtualAdapter{false};
};
struct CaptureAdapterCatalog {
QStringList ids, labels, details;
int suggested{-1};
int physicalUpCount{0};
};
QVector<CaptureNicMetadata> discoverSystemCaptureNics();
CaptureAdapterCatalog buildCaptureAdapterCatalog(
const QString& dumpcapList, const QVector<CaptureNicMetadata>& nics,
const QString& preferredId = {});
49 changes: 20 additions & 29 deletions apps/arstack_studio/src/SvCaptureController.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SvCaptureController.hpp"
#include "CaptureAdapterCatalog.hpp"

#include <QtConcurrentRun>
#include <QDateTime>
Expand All @@ -14,6 +15,7 @@
#include <QStandardPaths>
#include <QStorageInfo>
#include <QSaveFile>
#include <QSettings>
#include <QJsonArray>
#include <QTextStream>
#include <QUrl>
Expand Down Expand Up @@ -187,38 +189,24 @@ SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) {
setStatus(QStringLiteral("Adapter discovery failed. Install Wireshark/Npcap and check capture permissions."));
return;
}
interfaceIds_.clear();
interfaceLabels_.clear();
suggestedInterface_ = -1;
const QRegularExpression lineRe(QStringLiteral(R"(^\s*\d+\.\s+(\S+)(?:\s+\((.*)\))?)"));
const QString output = QString::fromUtf8(discovery_.readAllStandardOutput());
for (const auto& line : output.split(QLatin1Char('\n'))) {
const auto match = lineRe.match(line);
if (!match.hasMatch()) continue;
const QString id = match.captured(1);
const QString label = match.captured(2).trimmed();
interfaceIds_.append(id);
interfaceLabels_.append(label.isEmpty() ? id : label + QStringLiteral(" · ") + id);
}
int eligible = 0;
for (int i = 0; i < interfaceLabels_.size(); ++i) {
const QString label = interfaceLabels_.at(i).toLower();
if (label.contains(QStringLiteral("loopback")) ||
label.contains(QStringLiteral("bluetooth")) ||
label.contains(QStringLiteral("wan miniport")) ||
label.contains(QStringLiteral("wireless")) ||
label.contains(QStringLiteral("wi-fi")) ||
label.contains(QStringLiteral("wifi"))) continue;
++eligible;
suggestedInterface_ = i;
}
if (eligible != 1) suggestedInterface_ = -1;
// Display name comes from Windows, capture ID comes from dumpcap.
// Persist only the stable NPF interface ID, never the -D list index.
QSettings prefs(QSettings::IniFormat, QSettings::UserScope,
QStringLiteral("ARStack"), QStringLiteral("Studio"));
const auto catalog = buildCaptureAdapterCatalog(
QString::fromUtf8(discovery_.readAllStandardOutput()),
discoverSystemCaptureNics(),
prefs.value(QStringLiteral("capture/lastInterface")).toString());
interfaceIds_ = catalog.ids;
interfaceLabels_ = catalog.labels;
interfaceDetails_ = catalog.details;
suggestedInterface_ = catalog.suggested;
emit interfacesChanged();
setStatus(interfaceIds_.isEmpty()
? QStringLiteral("No Npcap capture interfaces found.")
: eligible == 1
? QStringLiteral("Ethernet adapter identified. One-click capture is ready.")
: QStringLiteral("Select the mirrored/TAP Ethernet interface once; automatic selection is ambiguous."));
: suggestedInterface_ >= 0
? QStringLiteral("Adapter identified. Verify actual SV/PTP traffic on this port before acceptance.")
: QStringLiteral("Choose the correct Ethernet adapter by its Windows hardware name."));
});
connect(&discovery_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) {
setStatus(QStringLiteral("Unable to enumerate network capture adapters. Install Wireshark with Npcap."));
Expand Down Expand Up @@ -333,6 +321,9 @@ bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap
activeEvents_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_events.csv"));
activeJson_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_report.json"));
activeAdapter_ = interfaceIds_.at(index);
QSettings prefs(QSettings::IniFormat, QSettings::UserScope,
QStringLiteral("ARStack"), QStringLiteral("Studio"));
prefs.setValue(QStringLiteral("capture/lastInterface"), activeAdapter_);
activeStudioTelemetry_ = studioTelemetry;
activeStudioTelemetrySeries_.clear();
activeStudioTelemetrySeries_.append(studioTelemetry);
Expand Down
3 changes: 3 additions & 0 deletions apps/arstack_studio/src/SvCaptureController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
class SvCaptureController : public QObject {
Q_OBJECT
Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY interfacesChanged)
Q_PROPERTY(QStringList interfaceDetails READ interfaceDetails NOTIFY interfacesChanged)
Q_PROPERTY(int suggestedInterface READ suggestedInterface NOTIFY interfacesChanged)
Q_PROPERTY(QString status READ status NOTIFY statusChanged)
Q_PROPERTY(bool capturing READ capturing NOTIFY stateChanged)
Expand All @@ -24,6 +25,7 @@ class SvCaptureController : public QObject {
~SvCaptureController() override;

[[nodiscard]] QStringList interfaces() const { return interfaceLabels_; }
[[nodiscard]] QStringList interfaceDetails() const { return interfaceDetails_; }
[[nodiscard]] int suggestedInterface() const { return suggestedInterface_; }
[[nodiscard]] QString status() const { return status_; }
[[nodiscard]] bool capturing() const { return capture_.state() != QProcess::NotRunning; }
Expand Down Expand Up @@ -62,6 +64,7 @@ class SvCaptureController : public QObject {
QProcess capture_;
QFutureWatcher<QVariantMap> decoder_;
QStringList interfaceLabels_;
QStringList interfaceDetails_;
QStringList interfaceIds_;
int suggestedInterface_{-1};
QString status_{QStringLiteral("Discover an Ethernet capture adapter to begin.")};
Expand Down
36 changes: 36 additions & 0 deletions apps/arstack_studio/src/SvCaptureHarness.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Deterministic fixture uses the exact production capture decoder; no Npcap needed.
#include "SvCaptureController.hpp"
#include "CaptureAdapterCatalog.hpp"

#include <QCoreApplication>
#include <QFile>
Expand Down Expand Up @@ -79,6 +80,41 @@ class SvCaptureHarness {
static bool run() {
QTemporaryDir dir;
if (!dir.isValid()) return false;
// Realtek must be shown instead of Local Area Connection* / GUID.
// Reordering dumpcap -D must never change the chosen NPF identity.
const QString realId = QStringLiteral(R"(\Device\NPF_{992B837A-63FA-4D5C-9EF1-92B687EA1053})");
const QString virtualId = QStringLiteral(R"(\Device\NPF_{00000000-0000-0000-0000-000000000001})");
const QVector<CaptureNicMetadata> known{
{QStringLiteral("{992B837A-63FA-4D5C-9EF1-92B687EA1053}"),
QStringLiteral("Ethernet"), QStringLiteral("Realtek PCIe GbE Family Controller"),
CaptureNicKind::Ethernet, true, false},
{QStringLiteral("{00000000-0000-0000-0000-000000000001}"),
QStringLiteral("vEthernet"), QStringLiteral("Hyper-V Virtual Ethernet"),
CaptureNicKind::Ethernet, true, true}
};
const auto catalog = buildCaptureAdapterCatalog(
QStringLiteral("1. ") + virtualId + QStringLiteral(" (Local Area Connection* 10)\n") +
QStringLiteral("2. ") + realId + QStringLiteral(" (Local Area Connection* 9)\n"), known);
const auto reordered = buildCaptureAdapterCatalog(
QStringLiteral("1. ") + realId + QStringLiteral(" (Local Area Connection* 9)\n") +
QStringLiteral("2. ") + virtualId + QStringLiteral(" (Local Area Connection* 10)\n"),
known, realId);
const auto ambiguous = buildCaptureAdapterCatalog(
QStringLiteral("1. ") + realId + QStringLiteral(" (LAN)\n") +
QStringLiteral("2. ") + virtualId + QStringLiteral(" (LAN)\n"),
{{known[0].interfaceKey, known[0].alias, known[0].hardware,
CaptureNicKind::Ethernet, true, false},
{known[1].interfaceKey, QStringLiteral("Ethernet 2"), QStringLiteral("Intel I225-V"),
CaptureNicKind::Ethernet, true, false}});
if (catalog.suggested != 0 || catalog.ids[0] != realId ||
!catalog.labels[0].contains(QStringLiteral("Realtek PCIe GbE")) ||
catalog.labels[0].contains(QStringLiteral("NPF_")) ||
reordered.suggested != 0 || reordered.ids[0] != realId ||
ambiguous.suggested != -1) {
qCritical() << "NIC discovery failed: identity, display or safe selection regression";
return false;
}

// Regression: actual 2026-10-22 24-byte PCAP despite old cached
// 3999 FPS. Capture must block while publisher STOPPED, not silently
// make a successful-looking 30s measurement.
Expand Down
Loading