add qml QR Code

This commit is contained in:
dranik
2026-05-07 21:51:39 +03:00
parent 5beae954c7
commit 2cb12c596c
14 changed files with 525 additions and 17 deletions

View File

@@ -34,7 +34,9 @@ add_definitions(-DDEV_S3_ENDPOINT="$ENV{DEV_S3_ENDPOINT}")
add_definitions(-DFREE_V2_ENDPOINT="$ENV{FREE_V2_ENDPOINT}")
add_definitions(-DPREM_V1_ENDPOINT="$ENV{PREM_V1_ENDPOINT}")
include(../.cache/agw_rsa_public_keys.cmake)
if(AMNEZIA_QR_PAIRING_ALLOW)
include(../.cache/agw_rsa_public_keys.cmake)
endif()
if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID))
set(PACKAGES ${PACKAGES} Widgets)
@@ -204,8 +206,8 @@ list(APPEND SOURCES ${CMAKE_CURRENT_LIST_DIR}/main.cpp)
target_link_libraries(${PROJECT} PRIVATE ${LIBS})
target_compile_definitions(${PROJECT} PRIVATE "MZ_$<UPPER_CASE:${MZ_PLATFORM_NAME}>")
if(AMNEZIA_LOCAL_GATEWAY)
target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_LOCAL_GATEWAY)
if(AMNEZIA_QR_PAIRING_ALLOW)
target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_QR_PAIRING_ALLOW)
endif()
if(AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY)

View File

@@ -251,6 +251,10 @@ bool AmneziaApplication::parseCommands()
#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) && !defined(MACOS_NE)
void AmneziaApplication::startLocalServer() {
#ifdef AMNEZIA_QR_PAIRING_ALLOW
return;
#endif
const QString serverName("AmneziaVPNInstance");
QLocalServer::removeServer(serverName);

View File

@@ -86,7 +86,7 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const
}
#endif
#ifdef AMNEZIA_LOCAL_GATEWAY
#ifdef AMNEZIA_QR_PAIRING_ALLOW
{
const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl);
const QString host = gatewayUrl.host().toLower();

View File

@@ -16,7 +16,7 @@
using namespace amnezia;
namespace {
#ifdef AMNEZIA_LOCAL_GATEWAY
#ifdef AMNEZIA_QR_PAIRING_ALLOW
// Prefer 127.0.0.1 with local mock (tools/local_gateway listens on 0.0.0.0:8080); avoids LAN/IPv6 ambiguity in dev.
constexpr char gatewayEndpoint[] = "http://127.0.0.1:8080/";
#else

View File

@@ -143,6 +143,15 @@ QString PairingUiController::tvStatusMessage() const
return m_tvStatusMessage;
}
int PairingUiController::tvPairingWaitWindowSeconds() const
{
if (!m_pairingController) {
return 30;
}
const int msec = m_pairingController->pairingLongPollTimeoutMsecs();
return qMax(1, (msec + 999) / 1000);
}
bool PairingUiController::phonePairingBusy() const
{
return m_phonePairingBusy;

View File

@@ -24,6 +24,8 @@ class PairingUiController : public QObject
Q_PROPERTY(QString tvSessionUuid READ tvSessionUuid NOTIFY tvSessionUuidChanged)
Q_PROPERTY(bool tvPairingBusy READ tvPairingBusy NOTIFY tvPairingBusyChanged)
Q_PROPERTY(QString tvStatusMessage READ tvStatusMessage NOTIFY tvStatusMessageChanged)
/** Long-poll window for generate_qr (seconds), for receive UI countdown. */
Q_PROPERTY(int tvPairingWaitWindowSeconds READ tvPairingWaitWindowSeconds NOTIFY tvQrCodesChanged)
Q_PROPERTY(bool phonePairingBusy READ phonePairingBusy NOTIFY phonePairingBusyChanged)
Q_PROPERTY(QString phoneStatusMessage READ phoneStatusMessage NOTIFY phoneStatusMessageChanged)
@@ -41,6 +43,7 @@ public:
QString tvSessionUuid() const;
bool tvPairingBusy() const;
QString tvStatusMessage() const;
int tvPairingWaitWindowSeconds() const;
bool phonePairingBusy() const;
QString phoneStatusMessage() const;

View File

@@ -80,7 +80,9 @@ namespace PageLoader
PageSetupWizardApiPremiumInfo,
PageSetupWizardApiTrialEmail,
PageSettingsApiQrPairing,
PageSettingsApiQrPairingDev,
PageSettingsApiQrPairingSend,
PageSetupWizardApiQrPairingReceive,
PageDevMenu
};

View File

@@ -90,12 +90,27 @@ PageType {
footer: ColumnLayout {
width: listView.width
SwitcherType {
LabelWithButtonType {
Layout.fillWidth: true
Layout.topMargin: 24
Layout.rightMargin: 16
Layout.leftMargin: 16
text: qsTr("QR pairing (full dev UI)")
descriptionText: qsTr("Receive + send on one device for local gateway / QA")
rightImageSource: "qrc:/images/controls/chevron-right.svg"
clickedFunction: function() {
PageController.goToPage(PageEnum.PageSettingsApiQrPairingDev)
}
}
SwitcherType {
Layout.fillWidth: true
Layout.topMargin: 16
Layout.rightMargin: 16
Layout.leftMargin: 16
text: qsTr("Dev gateway environment")
checked: SettingsController.isDevGatewayEnv
onToggled: function() {

View File

@@ -3,7 +3,6 @@ import QtQuick.Controls
import QtQuick.Layouts
import QRCodeReader 1.0
import PageEnum 1.0
import Style 1.0
import "../Controls2"
@@ -46,13 +45,22 @@ PageType {
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
text: qsTr("QR pairing")
text: qsTr("QR pairing (dev — single device)")
font.pixelSize: 28
font.bold: true
color: AmneziaStyle.color.paleGray
wrapMode: Text.Wrap
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
color: AmneziaStyle.color.goldenApricot
text: qsTr("Developer / QA: receive and send on one device (e.g. with local gateway). Not shown in production menus unless opened from Dev menu.")
wrapMode: Text.Wrap
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
@@ -88,8 +96,6 @@ PageType {
Layout.leftMargin: 16
Layout.rightMargin: 16
text: qsTr("Cancel receive")
// Do not use defaultColor: transparent here: when enabled, BasicButtonType paints that
// as the idle background, so midnightBlack label sits on the page invisible until hover.
enabled: PairingUiController.tvPairingBusy
clickedFunc: function() {
PairingUiController.cancelTvQrSession()
@@ -105,7 +111,6 @@ PageType {
wrapMode: Text.Wrap
}
// SVG QR from qrCodeUtils has a tiny viewBox (~45px); without a sized container + sourceSize it stays small.
Item {
id: qrBox
Layout.fillWidth: true
@@ -182,7 +187,6 @@ PageType {
visible: Layout.preferredHeight > 0
clip: true
// QRCodeReader is a QObject (not Item): no anchors; preview rect via setCameraSize like PageSetupWizardQrReader.
QRCodeReader {
id: pairingQrReader
@@ -246,11 +250,22 @@ PageType {
}
function onTvPairingConfigReceived() {
root.pairingCameraOpen = false
pairingQrReader.stopReading()
qrImage.source = ""
PageController.showNotificationMessage(qsTr("Configuration received from gateway"))
Qt.callLater(function() {
PageController.closePage()
})
}
function onPhonePairingSucceeded() {
root.pairingCameraOpen = false
pairingQrReader.stopReading()
PageController.showNotificationMessage(qsTr("Configuration sent"))
Qt.callLater(function() {
PageController.closePage()
})
}
function onPairingUuidFromScan(uuid) {

View File

@@ -0,0 +1,178 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QRCodeReader 1.0
import Style 1.0
import "../Controls2"
import "../Controls2/TextTypes"
import "../Config"
import "../Components"
PageType {
id: root
property bool pairingCameraOpen: false
Connections {
target: root
function onVisibleChanged() {
if (!root.visible) {
pairingQrReader.stopReading()
root.pairingCameraOpen = false
PairingUiController.cancelAllPairingActivity()
}
}
}
FlickableType {
anchors.fill: parent
contentHeight: layout.implicitHeight
ColumnLayout {
id: layout
width: root.width
spacing: 8
BackButtonType {
Layout.topMargin: 20 + PageController.safeAreaTopMargin
}
Label {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
text: qsTr("Transfer subscription (QR)")
font.pixelSize: 28
font.bold: true
color: AmneziaStyle.color.paleGray
wrapMode: Text.Wrap
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
text: qsTr("Scan the session QR shown on the receiving device, then send this servers Amnezia Premium configuration through the gateway.")
wrapMode: Text.Wrap
}
Label {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 16
text: qsTr("Send from this subscription")
font.pixelSize: 18
font.bold: true
color: AmneziaStyle.color.mutedGray
}
TextFieldWithHeaderType {
id: uuidField
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
headerText: qsTr("QR session UUID")
textField.placeholderText: qsTr("Paste UUID from the other devices QR")
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: Qt.platform.os === "android" || Qt.platform.os === "ios"
text: {
if (Qt.platform.os === "ios" && root.pairingCameraOpen) {
return qsTr("Hide camera")
}
return qsTr("Scan QR code")
}
enabled: !PairingUiController.phonePairingBusy
clickedFunc: function() {
if (Qt.platform.os === "android") {
PairingUiController.openPairingQrScanner()
} else {
root.pairingCameraOpen = !root.pairingCameraOpen
}
}
}
Item {
id: cameraSlot
Layout.fillWidth: true
Layout.preferredHeight: (root.pairingCameraOpen && Qt.platform.os === "ios") ? 220 : 0
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: Layout.preferredHeight > 0
clip: true
QRCodeReader {
id: pairingQrReader
onCodeReaded: function(code) {
if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
pairingQrReader.stopReading()
root.pairingCameraOpen = false
PageController.showNotificationMessage(qsTr("Session ID filled from QR"))
}
}
}
onVisibleChanged: {
if (!visible) {
pairingQrReader.stopReading()
return
}
if (Qt.platform.os === "ios") {
Qt.callLater(function() {
var p = cameraSlot.mapToItem(root, 0, 0)
pairingQrReader.setCameraSize(Qt.rect(p.x, p.y, cameraSlot.width, cameraSlot.height))
pairingQrReader.startReading()
})
}
}
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
text: PairingUiController.phonePairingBusy ? qsTr("Sending…") : qsTr("Send from current subscription")
enabled: !PairingUiController.phonePairingBusy
clickedFunc: function() {
PairingUiController.submitPhonePairing(uuidField.textField.text, ServersUiController.getProcessedServerIndex())
}
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.bottomMargin: 24 + PageController.safeAreaBottomMargin
visible: PairingUiController.phoneStatusMessage.length > 0
text: PairingUiController.phoneStatusMessage
wrapMode: Text.Wrap
}
}
}
Connections {
target: PairingUiController
function onPhonePairingSucceeded() {
root.pairingCameraOpen = false
pairingQrReader.stopReading()
PageController.showNotificationMessage(qsTr("Configuration sent"))
Qt.callLater(function() {
PageController.closePage()
})
}
function onPairingUuidFromScan(uuid) {
uuidField.textField.text = uuid
}
}
}

View File

@@ -378,12 +378,12 @@ PageType {
LabelWithButtonType {
Layout.fillWidth: true
text: qsTr("QR pairing (beta)")
descriptionText: qsTr("Transfer config via gateway using a QR code")
text: qsTr("Transfer by QR (send)")
descriptionText: qsTr("Scan the session QR from the receiving device and send this subscription via the gateway")
rightImageSource: "qrc:/images/controls/chevron-right.svg"
clickedFunction: function() {
PageController.goToPage(PageEnum.PageSettingsApiQrPairing)
PageController.goToPage(PageEnum.PageSettingsApiQrPairingSend)
}
}

View File

@@ -0,0 +1,264 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Style 1.0
import "../Controls2"
import "../Controls2/TextTypes"
import "../Config"
import "../Components"
PageType {
id: root
property int qrImageIndex: 0
property int pairingSecondsLeft: 0
function formatMmSs(totalSec) {
if (totalSec <= 0) {
return "0:00"
}
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
return m + (s < 10 ? ":0" : ":") + s
}
function scrollPairingToBottom() {
receiveScroll.contentY = Math.max(0, receiveScroll.contentHeight - receiveScroll.height)
}
Timer {
id: scrollToBottomRetryTimer
interval: 48
repeat: true
property int retries: 0
onTriggered: {
root.scrollPairingToBottom()
retries++
if (retries >= 12) {
stop()
}
}
onRunningChanged: {
if (!running) {
retries = 0
}
}
}
Timer {
id: pairingCountdownTimer
interval: 1000
repeat: true
running: PairingUiController.tvPairingBusy && PairingUiController.tvQrCodesCount > 0 && root.pairingSecondsLeft > 0
onTriggered: {
if (root.pairingSecondsLeft > 0) {
root.pairingSecondsLeft--
}
}
}
Connections {
target: root
function onVisibleChanged() {
if (!root.visible) {
PairingUiController.cancelAllPairingActivity()
scrollToBottomRetryTimer.stop()
pairingCountdownTimer.stop()
root.pairingSecondsLeft = 0
}
}
}
FlickableType {
id: receiveScroll
anchors.fill: parent
contentHeight: layout.implicitHeight
Behavior on contentY {
NumberAnimation {
duration: 320
easing.type: Easing.OutCubic
}
}
onContentHeightChanged: {
if (PairingUiController.tvQrCodesCount > 0) {
Qt.callLater(root.scrollPairingToBottom)
}
}
ColumnLayout {
id: layout
width: root.width
spacing: 8
BackButtonType {
Layout.topMargin: 20 + PageController.safeAreaTopMargin
}
Label {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
text: qsTr("Get Premium server from mobile")
font.pixelSize: 28
font.bold: true
color: AmneziaStyle.color.paleGray
wrapMode: Text.Wrap
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
color: AmneziaStyle.color.goldenApricot
text: qsTr("Amnezia Premium only. Someone who already has this subscription in Amnezia on a phone or tablet must send it to you; otherwise the session expires.")
wrapMode: Text.Wrap
}
Label {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 12
text: qsTr("How to get the server from a mobile device")
font.pixelSize: 18
font.bold: true
color: AmneziaStyle.color.mutedGray
wrapMode: Text.Wrap
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
text: qsTr("On this device (TV, tablet, or second phone):\n1) In the “Start receiving” section, tap “Start and show QR” and leave this screen open until the transfer finishes or times out.\n\nOn the mobile device that already has Amnezia Premium:\n2) Open Amnezia VPN → Settings (gear).\n3) Select your Amnezia Premium API server in the list, then open its details screen.\n4) Choose “Transfer by QR (send)”.\n5) Scan the QR code shown on this device, or paste the session ID if you copy it from this screen.\n6) Tap “Send from current subscription” and wait. When the gateway completes pairing, this device receives the configuration and adds the server.")
wrapMode: Text.Wrap
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
visible: PairingUiController.tvStatusMessage.length > 0
text: PairingUiController.tvStatusMessage
wrapMode: Text.Wrap
}
Item {
id: qrBox
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
Layout.preferredHeight: PairingUiController.tvQrCodesCount > 0 ? width : 0
visible: PairingUiController.tvQrCodesCount > 0
Image {
id: qrImage
anchors.fill: parent
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(2048, 2048)
source: PairingUiController.tvQrCodesCount > 0 ? PairingUiController.tvQrCodes[root.qrImageIndex] : ""
MouseArea {
anchors.fill: parent
enabled: PairingUiController.tvQrCodesCount > 1
onClicked: {
root.qrImageIndex = (root.qrImageIndex + 1) % PairingUiController.tvQrCodesCount
}
}
}
}
ParagraphTextType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 8
visible: PairingUiController.tvPairingBusy && PairingUiController.tvQrCodesCount > 0
color: AmneziaStyle.color.mutedGray
text: qsTr("This QR code will refresh in %1. If the session expires, tap Start again for a new code.")
.arg(root.formatMmSs(root.pairingSecondsLeft))
wrapMode: Text.Wrap
}
Label {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
Layout.topMargin: 16
text: qsTr("Start receiving")
font.pixelSize: 18
font.bold: true
color: AmneziaStyle.color.mutedGray
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
text: PairingUiController.tvPairingBusy ? qsTr("Waiting…") : qsTr("Start and show QR")
enabled: !PairingUiController.tvPairingBusy && !PairingUiController.phonePairingBusy
clickedFunc: function() {
PairingUiController.startTvQrSession()
}
}
BasicButtonType {
Layout.fillWidth: true
Layout.leftMargin: 16
Layout.rightMargin: 16
text: qsTr("Cancel receive")
enabled: PairingUiController.tvPairingBusy
clickedFunc: function() {
PairingUiController.cancelTvQrSession()
}
}
Item {
Layout.fillWidth: true
Layout.preferredHeight: 24 + PageController.safeAreaBottomMargin
}
}
}
Connections {
target: PairingUiController
function onTvQrCodesChanged() {
root.qrImageIndex = 0
if (PairingUiController.tvQrCodesCount > 0) {
root.pairingSecondsLeft = PairingUiController.tvPairingWaitWindowSeconds
scrollToBottomRetryTimer.retries = 0
scrollToBottomRetryTimer.start()
Qt.callLater(function() {
root.scrollPairingToBottom()
})
Qt.callLater(function() {
Qt.callLater(function() {
root.scrollPairingToBottom()
})
})
}
}
function onTvSessionUuidChanged() {
root.qrImageIndex = 0
}
function onTvPairingConfigReceived() {
scrollToBottomRetryTimer.stop()
root.pairingSecondsLeft = 0
qrImage.source = ""
PageController.showNotificationMessage(qsTr("Configuration received from gateway"))
Qt.callLater(function() {
PageController.closePage()
})
}
}
}

View File

@@ -269,6 +269,7 @@ PageType {
selfHostVpn,
backupRestore,
fileOpen,
gatewayQrPairingAddServer,
qrScan,
restorePurchases,
siteLink
@@ -343,6 +344,19 @@ PageType {
}
}
QtObject {
id: gatewayQrPairingAddServer
property bool featuredAmneziaConnection: false
property string title: qsTr("Get Premium server from mobile")
property string description: qsTr("Premium · QR transfer — steps inside")
property string imageSource: "qrc:/images/controls/qr-code.svg"
property bool isVisible: true
property var handler: function() {
PageController.goToPage(PageEnum.PageSetupWizardApiQrPairingReceive)
}
}
QtObject {
id: qrScan

View File

@@ -85,7 +85,9 @@
<file>Pages2/PageSettingsAbout.qml</file>
<file>Pages2/PageSettingsApiAvailableCountries.qml</file>
<file>Pages2/PageSettingsApiServerInfo.qml</file>
<file>Pages2/PageSettingsApiQrPairing.qml</file>
<file>Pages2/PageSettingsApiQrPairingDev.qml</file>
<file>Pages2/PageSettingsApiQrPairingSend.qml</file>
<file>Pages2/PageSetupWizardApiQrPairingReceive.qml</file>
<file>Pages2/PageSettingsApplication.qml</file>
<file>Pages2/PageSettingsAppSplitTunneling.qml</file>
<file>Pages2/PageSettingsBackup.qml</file>