fixed QR scaner

This commit is contained in:
dranik
2026-05-07 23:37:48 +03:00
parent 6fc65dba8a
commit 5a192cec15
11 changed files with 132 additions and 53 deletions

View File

@@ -214,14 +214,6 @@ if(AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY)
target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY)
endif()
option(AMNEZIA_LAN_PLAINTEXT_GATEWAY "Dev: plaintext JSON to private LAN gateway hosts (requires AMNEZIA_QR_PAIRING_ALLOW)" OFF)
if(AMNEZIA_LAN_PLAINTEXT_GATEWAY)
if(NOT AMNEZIA_QR_PAIRING_ALLOW)
message(FATAL_ERROR "AMNEZIA_LAN_PLAINTEXT_GATEWAY=ON requires AMNEZIA_QR_PAIRING_ALLOW=ON")
endif()
target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_LAN_PLAINTEXT_GATEWAY)
endif()
target_sources(${PROJECT} PRIVATE ${SOURCES} ${HEADERS} ${RESOURCES} ${QRC} ${I18NQRC})
# Finalize the executable so Qt can gather/deploy QML modules and plugins correctly (Android needs this).

View File

@@ -30,7 +30,7 @@ bool isLocalGatewayHost(const QString &gatewayUrl)
|| gatewayUrl.contains(QStringLiteral("::1"), Qt::CaseInsensitive)) {
return true;
}
#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY
#ifdef AMNEZIA_QR_PAIRING_ALLOW
const QUrl u(gatewayUrl);
return NetworkUtilities::hostIsPrivateLanAddress(u.host());
#else

View File

@@ -1,6 +1,5 @@
#include "subscriptionController.h"
#include <QCoreApplication>
#include <QDebug>
#include <QDateTime>
#include <QEventLoop>
@@ -339,9 +338,7 @@ ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QStrin
if (duplicateServerIndex) {
*duplicateServerIndex = i;
}
#ifndef AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY
return ErrorCode::ApiConfigAlreadyAdded;
#endif
}
}
@@ -387,24 +384,6 @@ ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QStrin
apiV2->apiConfig.vpnKey = fullKey;
}
#ifdef AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY
static int existingSameKeyCount = 0;
if (apiV2) {
++existingSameKeyCount;
const QString suffix = QCoreApplication::translate("SubscriptionController", " (paired copy %1)")
.arg(existingSameKeyCount);
if (!apiV2->name.isEmpty()) {
apiV2->name += suffix;
} else if (!apiV2->description.isEmpty()) {
apiV2->description += suffix;
} else {
apiV2->name = QCoreApplication::translate("SubscriptionController", "Paired subscription %1")
.arg(existingSameKeyCount);
}
}
#endif
m_serversRepository->addServer(serverConfigModel);
serverConfig = serverConfigModel;
return ErrorCode::NoError;

View File

@@ -58,12 +58,25 @@ namespace
constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?");
constexpr int proxyStorageRequestTimeoutMsecs = 3000;
/** Pairing/subscription paths use "%1api/v1/..." / "%1v1/..." — %1 must end with '/' or the host and path merge (404). */
QString normalizedGatewayBase(const QString &endpoint)
{
QString e = endpoint.trimmed();
if (e.isEmpty()) {
return e;
}
if (!e.endsWith(QLatin1Char('/'))) {
e.append(QLatin1Char('/'));
}
return e;
}
} // namespace
GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
const bool isStrictKillSwitchEnabled, QObject *parent)
: QObject(parent),
m_gatewayEndpoint(gatewayEndpoint),
m_gatewayEndpoint(normalizedGatewayBase(gatewayEndpoint)),
m_isDevEnvironment(isDevEnvironment),
m_requestTimeoutMsecs(requestTimeoutMsecs),
m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled)
@@ -104,12 +117,10 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const
{
const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl);
const QString host = gatewayUrl.host().toLower();
bool usePlaintext = (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1"));
#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY
if (!usePlaintext) {
usePlaintext = NetworkUtilities::hostIsPrivateLanAddress(host);
}
#endif
const bool loopback =
(host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1"));
// tools/local_gateway on a LAN IP (e.g. phone → Mac -auto-public): mock expects plaintext JSON.
const bool usePlaintext = loopback || NetworkUtilities::hostIsPrivateLanAddress(host);
if (usePlaintext) {
encRequestData.isPlaintextLocalGateway = true;
encRequestData.requestBody = QJsonDocument(apiPayload).toJson();

View File

@@ -261,7 +261,7 @@ QString SecureAppSettingsRepository::getGatewayEndpoint(bool isTestPurchase) con
|| base.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)) {
return m_gatewayEndpoint;
}
#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY
#ifdef AMNEZIA_QR_PAIRING_ALLOW
{
const QUrl gatewayUrl(base);
if (NetworkUtilities::hostIsPrivateLanAddress(gatewayUrl.host())) {

View File

@@ -221,6 +221,10 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &ssl
return amnezia::ErrorCode::ApiConfigDownloadError;
}
if (httpStatusCode == httpStatusCodeNotFound) {
return amnezia::ErrorCode::ApiNotFoundError;
}
qDebug() << "something went wrong";
return amnezia::ErrorCode::ApiConfigDownloadError;
}

View File

@@ -2,6 +2,15 @@
#include <QIODevice>
#include <QList>
#include <QString>
QList<QString> qrCodeUtils::generateQrCodeImageSeriesPlainText(const QByteArray &utf8Text)
{
const QString text = QString::fromUtf8(utf8Text);
qrcodegen::QrCode qr = qrcodegen::QrCode::encodeText(text.toUtf8().constData(), qrcodegen::QrCode::Ecc::LOW);
const QString svg = QString::fromStdString(toSvgString(qr, 1));
return { svgToBase64(svg) };
}
QList<QString> qrCodeUtils::generateQrCodeImageSeries(const QByteArray &data)
{

View File

@@ -10,6 +10,8 @@ namespace qrCodeUtils
constexpr const qint16 qrMagicCode = 1984;
QList<QString> generateQrCodeImageSeries(const QByteArray &data);
/** QR payload is raw UTF-8 text (e.g. hyphenated session UUID) so phone cameras return a parsable string. */
QList<QString> generateQrCodeImageSeriesPlainText(const QByteArray &utf8Text);
qrcodegen::QrCode generateQrCode(const QByteArray &data);
QString svgToBase64(const QString &image);
};

View File

@@ -1,6 +1,8 @@
#include "pairingUiController.h"
#include <QDataStream>
#include <QDebug>
#include <QIODevice>
#include <QRegularExpression>
#include <QTimer>
#include <QUuid>
@@ -40,6 +42,50 @@ int pairingRetryDelayMs(int zeroBasedAttempt)
constexpr int baseMs = 500;
return baseMs * (1 << zeroBasedAttempt);
}
/** Legacy TV QR: generateQrCodeImageSeries base64url-wrapped QDataStream chunk (see qrCodeUtils.cpp). */
bool tryDecodeLegacyChunkedPairingQrPayload(const QString &t, QString *outUuid)
{
static const QRegularExpression binUrlSafe(QStringLiteral("^[A-Za-z0-9_-]+$"));
if (!binUrlSafe.match(t).hasMatch() || t.size() < 16) {
return false;
}
QByteArray padded = t.toUtf8();
switch (padded.size() % 4) {
case 2:
padded += "==";
break;
case 3:
padded += "=";
break;
default:
break;
}
const QByteArray raw = QByteArray::fromBase64(padded, QByteArray::Base64UrlEncoding);
if (raw.isEmpty()) {
return false;
}
QDataStream ds(raw);
ds.setByteOrder(QDataStream::BigEndian);
qint16 magic = 0;
quint8 nChunks = 0;
quint8 chunkIndex = 0;
QByteArray pl;
ds >> magic >> nChunks >> chunkIndex >> pl;
if (ds.status() != QDataStream::Ok) {
return false;
}
if (magic != qrCodeUtils::qrMagicCode || nChunks < 1 || nChunks > 200 || chunkIndex >= nChunks) {
return false;
}
const QString candidate = QString::fromUtf8(pl).trimmed();
const QUuid u = QUuid::fromString(candidate);
if (u.isNull()) {
return false;
}
*outUuid = u.toString(QUuid::WithoutBraces);
return true;
}
} // namespace
#if defined(Q_OS_ANDROID)
@@ -96,18 +142,31 @@ bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw)
qInfo() << "[PairingUi] scan rejected: looks like vpn:// bundle, not session UUID";
return false;
}
static const QRegularExpression re(QStringLiteral(
static const QRegularExpression reV4(QStringLiteral(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"));
const QRegularExpressionMatch m = re.match(t);
if (!m.hasMatch()) {
qInfo() << "[PairingUi] scan rejected: no UUID v4 pattern in payload";
return false;
}
const QRegularExpressionMatch m = reV4.match(t);
if (m.hasMatch()) {
const QString uuid = m.captured(0);
qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "...";
emit pairingUuidFromScan(uuid);
return true;
}
QString fromLegacy;
if (tryDecodeLegacyChunkedPairingQrPayload(t, &fromLegacy)) {
qInfo() << "[PairingUi] scan accepted legacy chunked QR uuid=" << fromLegacy.left(13) << "...";
emit pairingUuidFromScan(fromLegacy);
return true;
}
const QUuid parsed = QUuid::fromString(t);
if (!parsed.isNull()) {
const QString canon = parsed.toString(QUuid::WithoutBraces);
qInfo() << "[PairingUi] scan accepted QUuid::fromString uuid=" << canon.left(13) << "...";
emit pairingUuidFromScan(canon);
return true;
}
qInfo() << "[PairingUi] scan rejected: no session UUID recognized in payload";
return false;
}
#if defined(Q_OS_ANDROID)
bool PairingUiController::tryConsumeAndroidQrScan(const QString &code)
@@ -201,6 +260,8 @@ QString PairingUiController::tvFailureMessage(ErrorCode code) const
return tr("QR session expired. Tap Start to show a new QR code.");
case ErrorCode::ApiConfigAlreadyAdded:
return tr("This configuration is already on the device.");
case ErrorCode::ApiNotFoundError:
return tr("This gateway does not expose QR pairing (HTTP 404). Check the gateway URL or use the local mock (tools/local_gateway).");
default:
return tr("Pairing failed");
}
@@ -226,7 +287,7 @@ void PairingUiController::startTvQrSession()
m_tvSessionUuid = QUuid::createUuid().toString(QUuid::WithoutBraces);
const QByteArray qrPayload = m_tvSessionUuid.toUtf8();
m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeries(qrPayload);
m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeriesPlainText(qrPayload);
emit tvQrCodesChanged();
emit tvSessionUuidChanged();

View File

@@ -15,6 +15,17 @@ PageType {
property int qrImageIndex: 0
property bool pairingCameraOpen: false
property int lastPairingScanToastClockMs: 0
function notifyPairingScanSuccess() {
const now = new Date().getTime()
if (now - root.lastPairingScanToastClockMs < 1600) {
return
}
root.lastPairingScanToastClockMs = now
PageController.showNotificationMessage(
qsTr("QR session ID captured. Tap Send from current subscription to complete pairing."))
}
Timer {
id: pairingCameraKickTimer
@@ -238,13 +249,12 @@ PageType {
QRCodeReader {
id: pairingQrReader
anchors.fill: parent
onCodeReaded: function(code) {
if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
pairingQrReader.stopReading()
root.pairingCameraOpen = false
PageController.showNotificationMessage(qsTr("Session ID filled from QR"))
root.notifyPairingScanSuccess()
}
}
}

View File

@@ -14,6 +14,18 @@ PageType {
id: root
property bool pairingCameraOpen: false
/** iOS AVFoundation can fire the same QR repeatedly; avoid stacking identical toasts. */
property int lastPairingScanToastClockMs: 0
function notifyPairingScanSuccess() {
const now = new Date().getTime()
if (now - root.lastPairingScanToastClockMs < 1600) {
return
}
root.lastPairingScanToastClockMs = now
PageController.showNotificationMessage(
qsTr("QR session ID captured. Tap Send from current subscription to complete pairing."))
}
Timer {
id: pairingCameraKickTimer
@@ -160,13 +172,12 @@ PageType {
QRCodeReader {
id: pairingQrReader
anchors.fill: parent
onCodeReaded: function(code) {
if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
pairingQrReader.stopReading()
root.pairingCameraOpen = false
PageController.showNotificationMessage(qsTr("Session ID filled from QR"))
root.notifyPairingScanSuccess()
}
}
}