mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-05-08 14:33:23 +00:00
fixed QR scaner
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user