Refactor QR code generation and presentation

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-22 21:28:19 +08:00
parent 98e2600248
commit 1b3692ec46
5 changed files with 558 additions and 41 deletions
+200 -31
View File
@@ -15,79 +15,248 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Provide the application window for QR code window."""
"""Present exported server configurations as centered QR codes."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Repository import *
from Furious.Frozenlib import APPLICATION_NAME
from Furious.Plugins import exportConfiguration
from Furious.Qt import *
from Furious.Repository import Storage
from Furious.Qt import AppQMainWindow, AppQTabWidget
from Furious.Qt import gettext as _
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
import io
import pyqrcode
import segno
import logging
__all__ = ['QRCodeWindow']
_QR_ERROR_CORRECTION = 'H'
_QR_PAGE_MARGIN = 28
logger = logging.getLogger(__name__)
def createQRCodeImage(data: str) -> QImage:
"""Render opaque text as one grayscale pixel per QR module."""
if not isinstance(data, str) or not data:
raise ValueError('QR code data must be a non-empty string')
qrcode = segno.make_qr(data, error=_QR_ERROR_CORRECTION)
border = qrcode.default_border_size
width, height = qrcode.symbol_size(scale=1, border=border)
if width != height:
raise ValueError('QR code matrix must be square')
image = QImage(width, height, QImage.Format.Format_Grayscale8)
image.fill(255)
for y, row in enumerate(qrcode.matrix_iter(scale=1, border=border)):
image.scanLine(y)[:width] = bytes(0 if module else 255 for module in row)
return image
class _QRCodePage(QWidget):
"""Own and responsively present one logical QR image inside a tab page."""
def __init__(self, image: QImage, parent=None):
"""Create a centered, margin-managed QR presentation page."""
super().__init__(parent)
if image.isNull() or image.width() != image.height():
raise ValueError('QR code image must be a non-null square')
self.setObjectName('QRCodePage')
self._sourceImage = QImage(image)
self._displayScale = 0
self.qrLabel = QLabel(parent=self)
self.qrLabel.setObjectName('QRCodeImage')
self.qrLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.qrLabel.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding,
)
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(
_QR_PAGE_MARGIN,
_QR_PAGE_MARGIN,
_QR_PAGE_MARGIN,
_QR_PAGE_MARGIN,
)
self._layout.setSpacing(0)
self._layout.addWidget(
self.qrLabel,
1,
QtCore.Qt.AlignmentFlag.AlignCenter,
)
self._refreshPixmap()
def sourceImage(self) -> QImage:
"""Return an implicitly shared copy of the logical QR image."""
return QImage(self._sourceImage)
def moduleSpan(self) -> int:
"""Return the QR width expressed in modules including its quiet zone."""
return self._sourceImage.width()
def displayScale(self) -> int:
"""Return the integer scale currently used by the presentation pixmap."""
return self._displayScale
def _targetScale(self) -> int:
"""Return the largest integer scale that fits this page."""
available = self._layout.contentsRect().size()
availableSide = min(available.width(), available.height())
if availableSide <= 0:
return 1
return max(1, availableSide // self.moduleSpan())
def _refreshPixmap(self):
"""Scale the cached image without regenerating or smoothing QR modules."""
displayScale = self._targetScale()
if displayScale == self._displayScale:
return
self._displayScale = displayScale
targetSide = self.moduleSpan() * displayScale
self.qrLabel.setPixmap(
QPixmap.fromImage(
self._sourceImage.scaled(
targetSide,
targetSide,
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
QtCore.Qt.TransformationMode.FastTransformation,
)
)
)
def resizeEvent(self, event):
"""Refresh only the displayed raster when the available page size changes."""
super().resizeEvent(event)
self._refreshPixmap()
def showEvent(self, event):
"""Fit the QR pixmap after the tab layout receives its final geometry."""
super().showEvent(event)
# A newly selected tab has its final page geometry before Qt lays out its
# hidden children. Activate this page synchronously so its first rendered
# pixmap uses the available tab area instead of the construction-time size.
self._layout.activate()
self._refreshPixmap()
class QRCodeWindow(AppQMainWindow):
"""Present the QR code window."""
"""Present exported configurations in independent QR-code tabs."""
DEFAULT_WINDOW_SIZE = QtCore.QSize(720, 680)
MINIMUM_WINDOW_SIZE = QtCore.QSize(520, 520)
def __init__(self, *args, **kwargs):
"""Initialize the QRCodeWindow."""
"""Initialize the transient, resizable QR-code window."""
super().__init__(*args, **kwargs)
self.setWindowTitle(_(APPLICATION_NAME))
self.setFixedSize(640, 640)
self.setMinimumSize(self.MINIMUM_WINDOW_SIZE)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.tabWidget = QTabWidget(self)
self.tabWidget = AppQTabWidget(parent=self, translatable=False)
self.tabWidget.setTabsClosable(True)
self.tabWidget.setElideMode(QtCore.Qt.TextElideMode.ElideRight)
self.tabWidget.tabCloseRequested.connect(self.handleTabCloseRequested)
self.setCentralWidget(self.tabWidget)
def setWidthAndHeight(self):
"""Apply the practical default size while preserving user resizing."""
self.resize(self.DEFAULT_WINDOW_SIZE)
def tabCount(self) -> int:
"""Return the tab count value used by the QR code window."""
"""Return the number of successfully generated QR-code tabs."""
return self.tabWidget.count()
def _clearTabs(self):
"""Remove and destroy every page currently owned by the tab widget."""
while self.tabWidget.count():
page = self.tabWidget.widget(0)
self.tabWidget.removeTab(0)
if page is not None:
page.deleteLater()
def initTabByIndex(self, indexes: list[int]):
"""Handle init tab by index for the QR code window."""
self.tabWidget.clear()
"""Replace the tabs with QR pages for the selected profile indexes."""
self._clearTabs()
profiles = Storage.UserServers()
for index in indexes:
qrdata = io.BytesIO()
config = Storage.UserServers()[index]
config = profiles[index]
try:
uri = exportConfiguration(config)
except Exception:
except Exception as ex:
# Any non-exit exceptions
logger.warning(
'unable to export profile %d for QR presentation (%s)',
index + 1,
type(ex).__name__,
)
continue
if not uri:
continue
try:
image = createQRCodeImage(uri)
except (segno.DataOverflowError, ValueError) as ex:
logger.warning(
'unable to create QR code for profile %d (%s)',
index + 1,
type(ex).__name__,
)
continue
except Exception as ex:
# Any non-exit exceptions
uri = ''
continue
if uri:
qrcode = pyqrcode.create(uri)
qrcode.png(qrdata, scale=5)
page = _QRCodePage(image, parent=self.tabWidget)
title = f'{index + 1} - {config.itemRemark}'
pixmap = QPixmap()
pixmap.loadFromData(qrdata.getvalue(), 'PNG')
tabIndex = self.tabWidget.addTab(page, title)
widget = QLabel(parent=self.tabWidget)
widget.setPixmap(pixmap)
self.tabWidget.addTab(widget, f'{index + 1} - ' + config.itemRemark)
self.tabWidget.setTabToolTip(tabIndex, title)
@QtCore.Slot(int)
def handleTabCloseRequested(self, index):
"""Handle tab close requested."""
def handleTabCloseRequested(self, index: int):
"""Destroy one requested page and close after the final tab is removed."""
page = self.tabWidget.widget(index)
if page is None:
return
self.tabWidget.removeTab(index)
page.deleteLater()
if self.tabWidget.count() == 0:
self.close()
+1 -2
View File
@@ -17,8 +17,7 @@ dependencies = [
"PySide6-Addons",
"ujson",
"pybase64",
"pyqrcode",
"pypng",
"segno",
"icmplib",
"mss",
"zxing-cpp<3",
+1 -2
View File
@@ -2,8 +2,7 @@ PySide6-Essentials
PySide6-Addons
ujson
pybase64
pyqrcode
pypng
segno
icmplib
mss
zxing-cpp<3
+16 -6
View File
@@ -44,11 +44,12 @@ from Furious.Qt import (
AppQTransientDialog,
)
from Furious.Qt.QtWidgets import _AppMessageBoxMask
from Furious.Window.QRCodeWindow import QRCodeWindow
from Furious.Window.QRCodeWindow import QRCodeWindow, _QRCodePage
from Furious.Window.SubscriptionPage import _SubscriptionEditorDialog
from Furious.Window.TextEditorWindow import TextEditorWindow
from PySide6 import QtCore
from PySide6.QtGui import QImage
from PySide6.QtWidgets import QWidget
from shiboken6 import isValid
@@ -346,24 +347,33 @@ class QtLifetimeTest(unittest.TestCase):
)
def testQRCodeTopLevelWindowIsDeletedOnClose(self):
"""Exercise the dedicated transient AppQMainWindow policy 100 times."""
"""Destroy each transient QR window together with its page and label."""
iterations = 100
references, destroyed = [], []
image = QImage(64, 64, QImage.Format.Format_Grayscale8)
image.fill(QtCore.Qt.GlobalColor.white)
for _index in range(iterations):
window = QRCodeWindow()
window.destroyed.connect(lambda *_args: destroyed.append(True))
page = _QRCodePage(image, parent=window.tabWidget)
window.tabWidget.addTab(page, 'Lifetime fixture')
references.append(weakref.ref(window))
for object_ in (window, page, page.qrLabel):
object_.destroyed.connect(lambda *_args: destroyed.append(True))
references.append(weakref.ref(object_))
window.show()
window.close()
del window
del object_, page, window
collectAtBoundary()
self.assertAllDestroyed(references, destroyed, iterations)
self.assertAllDestroyed(
references,
destroyed,
iterations * 3,
)
def testTextEditorWindowIsIntentionallyReusableThenExplicitlyDestroyed(self):
"""Reuse one persistent editor without duplicating menus or actions."""
+340
View File
@@ -83,6 +83,11 @@ from Furious.Service import (
LogManager,
)
from Furious.Window.LogPage import LogPage
from Furious.Window.QRCodeWindow import (
QRCodeWindow,
_QRCodePage,
createQRCodeImage,
)
from Furious.Window.SettingsPage import (
_ApplicationThemeSettingsCard,
_SystemProxySettingsCard,
@@ -92,12 +97,14 @@ from Furious.Widget.ConnectionButton import ConnectionButton
from Furious.Widget.RoutingSelector import RoutingSelector
from PySide6 import QtCore
from PySide6.QtGui import QImage
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QStyle,
QStyleOptionComboBox,
QVBoxLayout,
QWidget,
)
@@ -116,6 +123,8 @@ import unittest
import weakref
from unittest import mock
import segno
import zxingcpp
class RoutingControllerFixture(QtCore.QObject):
@@ -315,6 +324,337 @@ class ComboTranslationLayoutTest(unittest.TestCase):
parent.deleteLater()
def _grayscaleBuffer(image: QImage):
"""Return tightly packed grayscale pixels for zxing-cpp without Pillow."""
grayscale = image.convertToFormat(QImage.Format.Format_Grayscale8)
width = grayscale.width()
height = grayscale.height()
stride = grayscale.bytesPerLine()
source = grayscale.constBits()
pixels = bytearray(width * height)
for y in range(height):
sourceStart = y * stride
targetStart = y * width
pixels[targetStart : targetStart + width] = source[
sourceStart : sourceStart + width
]
return memoryview(pixels).cast('B', (height, width))
def _decodeQRCodeImage(image: QImage):
"""Decode one generated image through the application's QR decoder."""
return zxingcpp.read_barcode(
_grayscaleBuffer(image),
formats=zxingcpp.BarcodeFormat.QRCode,
is_pure=True,
)
class QRCodeImageGenerationTest(unittest.TestCase):
"""Verify the pure Segno matrix-to-QImage contract."""
def testLogicalImageMatchesSegnoMatrixAndQuietZone(self):
"""Map matrix modules and the standard quiet zone to exact pixels."""
payload = 'socks://user:password@example.com:1080#Matrix'
qrCode = segno.make_qr(payload, error='H')
border = qrCode.default_border_size
matrix = tuple(tuple(row) for row in qrCode.matrix)
image = createQRCodeImage(payload)
self.assertFalse(qrCode.is_micro)
self.assertEqual(border, 4)
self.assertEqual(
image.size(),
QtCore.QSize(*qrCode.symbol_size(scale=1, border=border)),
)
self.assertEqual(image.format(), QImage.Format.Format_Grayscale8)
self.assertEqual(image.width(), len(matrix) + (2 * border))
self.assertTrue(
all(image.pixelColor(x, 0).red() == 255 for x in range(image.width()))
)
darkY, darkX = next(
(y, x)
for y, row in enumerate(matrix)
for x, module in enumerate(row)
if module
)
lightY, lightX = next(
(y, x)
for y, row in enumerate(matrix)
for x, module in enumerate(row)
if not module
)
self.assertEqual(image.pixelColor(border + darkX, border + darkY).red(), 0)
self.assertEqual(image.pixelColor(border + lightX, border + lightY).red(), 255)
def testRepresentativePayloadsRoundTripWithoutEncodedIntermediate(self):
"""Decode short, medium, long, special-character, and Unicode payloads."""
payloads = (
'socks://example.com:1080',
(
'vless://synthetic@example.com:443?'
+ 'transport=xhttp&security=reality&' * 8
+ '#Medium%20Profile'
),
'trojan://p%40ss%3Aword@example.com:443?allowInsecure=0#东京-Привет',
'vmess://' + ('synthetic-payload-' * 45),
)
widths = []
for payload in payloads:
image = createQRCodeImage(payload)
scale = 4
scaled = image.scaled(
image.width() * scale,
image.height() * scale,
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
QtCore.Qt.TransformationMode.FastTransformation,
)
decoded = _decodeQRCodeImage(scaled)
self.assertIsNotNone(decoded)
self.assertEqual(decoded.text, payload)
widths.append(image.width())
self.assertGreater(len(set(widths)), 1)
def testIntegerFastScalingKeepsPixelsBinary(self):
"""Keep modules and quiet zone black or white at integer display scales."""
image = createQRCodeImage('socks://scale.example:1080#Scale')
for scale in (2, 5, 8):
scaled = image.scaled(
image.width() * scale,
image.height() * scale,
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
QtCore.Qt.TransformationMode.FastTransformation,
)
pixels = _grayscaleBuffer(scaled)
self.assertEqual(scaled.width(), image.width() * scale)
self.assertEqual(set(pixels.tobytes()), {0, 255})
self.assertTrue(
all(
pixels[y, x] == 255
for y in range(4 * scale)
for x in range(scaled.width())
)
)
def testEmptyPayloadIsRejected(self):
"""Reject an empty payload before asking Segno to encode it."""
with self.assertRaises(ValueError):
createQRCodeImage('')
class QRCodeWindowBehaviorTest(unittest.TestCase):
"""Verify responsive QR presentation without changing export semantics."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def tearDown(self):
"""Finish deferred page and window deletion between tests."""
collectAtBoundary()
@staticmethod
def buildWindow(*uris):
"""Create one isolated QR window from deterministic export results."""
profiles = [
mock.Mock(itemRemark=f'Profile {index + 1}') for index in range(len(uris))
]
with mock.patch(
'Furious.Window.QRCodeWindow.Storage.UserServers',
return_value=profiles,
), mock.patch(
'Furious.Window.QRCodeWindow.exportConfiguration',
side_effect=uris,
):
window = QRCodeWindow()
window.initTabByIndex(list(range(len(profiles))))
return window
def testAllExportFailuresLeaveNoBrokenTabs(self):
"""Leave an unshown empty window when every canonical export fails."""
window = self.buildWindow('', '')
self.assertEqual(window.tabCount(), 0)
self.assertFalse(window.isVisible())
window.close()
def testOversizedPayloadDoesNotPreventOtherTabs(self):
"""Skip one Segno overflow while preserving later valid profiles."""
with self.assertLogs('Furious.Window.QRCodeWindow', level='WARNING'):
window = self.buildWindow(
'x' * 5000,
'socks://valid.example:1080#Valid',
)
self.assertEqual(window.tabCount(), 1)
self.assertEqual(window.tabWidget.tabText(0), '2 - Profile 2')
window.close()
def testPageCentersAndDecodesResponsiveSquarePixmap(self):
"""Center one crisp, square, decodable QR at representative sizes."""
uri = (
'socks://user:password@example.com:1080'
'#A%20representative%20Furious%20profile'
)
window = self.buildWindow(uri)
window.show()
processQtEvents()
page = window.tabWidget.widget(0)
self.assertIsInstance(page, _QRCodePage)
self.assertIsInstance(page.layout(), QVBoxLayout)
self.assertTrue(
page.layout().itemAt(0).alignment() & QtCore.Qt.AlignmentFlag.AlignHCenter
)
self.assertTrue(
page.layout().itemAt(0).alignment() & QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.assertTrue(page.qrLabel.alignment() & QtCore.Qt.AlignmentFlag.AlignCenter)
sourceKey = page.sourceImage().cacheKey()
displayedSides = []
for size in (
QRCodeWindow.MINIMUM_WINDOW_SIZE,
QRCodeWindow.DEFAULT_WINDOW_SIZE,
QtCore.QSize(920, 780),
):
window.resize(size)
processQtEvents()
displayed = page.qrLabel.pixmap()
available = page.layout().contentsRect().size()
self.assertFalse(displayed.isNull())
self.assertEqual(displayed.width(), displayed.height())
self.assertEqual(displayed.width() % page.moduleSpan(), 0)
self.assertLessEqual(displayed.width(), available.width())
self.assertLessEqual(displayed.height(), available.height())
self.assertEqual(displayed.width(), page.moduleSpan() * page.displayScale())
self.assertEqual(page.sourceImage().cacheKey(), sourceKey)
displayedSides.append(displayed.width())
self.assertEqual(displayedSides, sorted(displayedSides))
decoded = _decodeQRCodeImage(page.qrLabel.pixmap().toImage())
self.assertIsNotNone(decoded)
self.assertEqual(decoded.text, uri)
window.close()
def testEveryTabUsesItsSettledLayoutScaleOnFirstVisit(self):
"""Size hidden QR pages correctly the first time each tab becomes visible."""
uris = tuple(
f'socks://profile-{index}.example:1080#Profile%20{index}'
for index in range(10)
)
window = self.buildWindow(*uris)
window.show()
processQtEvents()
for index in range(window.tabCount()):
window.tabWidget.setCurrentIndex(index)
processQtEvents()
page = window.tabWidget.widget(index)
displayed = page.qrLabel.pixmap()
available = page.layout().contentsRect().size()
expectedScale = max(
1,
min(available.width(), available.height()) // page.moduleSpan(),
)
self.assertFalse(displayed.isNull())
self.assertGreater(expectedScale, 1)
self.assertEqual(page.displayScale(), expectedScale)
self.assertEqual(displayed.width(), page.moduleSpan() * expectedScale)
window.close()
def testMultipleTabsSkipFailuresAndDestroyClosedPages(self):
"""Keep valid order while failed exports and closed pages leave no debris."""
window = self.buildWindow(
'socks://one.example:1080#One',
'',
'socks://three.example:1080#Three',
)
window.show()
processQtEvents()
self.assertEqual(window.tabCount(), 2)
self.assertEqual(window.tabWidget.tabText(0), '1 - Profile 1')
self.assertEqual(window.tabWidget.tabText(1), '3 - Profile 3')
self.assertEqual(window.tabWidget.tabToolTip(0), '1 - Profile 1')
self.assertEqual(window.tabWidget.tabToolTip(1), '3 - Profile 3')
firstPage = window.tabWidget.widget(0)
firstPageReference = weakref.ref(firstPage)
firstPageDestroyed = []
firstPage.destroyed.connect(lambda *_args: firstPageDestroyed.append(True))
window.handleTabCloseRequested(0)
del firstPage
collectAtBoundary()
self.assertEqual(window.tabCount(), 1)
self.assertEqual(window.tabWidget.tabText(0), '3 - Profile 3')
self.assertTrue(waitFor(lambda: firstPageReference() is None))
self.assertEqual(firstPageDestroyed, [True])
windowReference = weakref.ref(window)
windowDestroyed = []
window.destroyed.connect(lambda *_args: windowDestroyed.append(True))
window.handleTabCloseRequested(0)
del window
collectAtBoundary()
self.assertTrue(waitFor(lambda: windowReference() is None))
self.assertEqual(windowDestroyed, [True])
def testThemeChangesLeaveTheHighContrastQRSourceUntouched(self):
"""Restyle the surrounding window without regenerating its QR content."""
app = application()
originalStyleSheet = app.styleSheet()
window = self.buildWindow('socks://theme.example:1080#Theme')
window.show()
processQtEvents()
page = window.tabWidget.widget(0)
sourceKey = page.sourceImage().cacheKey()
try:
for theme in (AppStyleSheet.Light, AppStyleSheet.Dark):
app.setStyleSheet(AppStyleSheet.forTheme(theme))
processQtEvents()
image = page.sourceImage()
quietZone = image.pixelColor(0, 0)
self.assertEqual(page.sourceImage().cacheKey(), sourceKey)
self.assertEqual(
(quietZone.red(), quietZone.green(), quietZone.blue()),
(255, 255, 255),
)
finally:
app.setStyleSheet(originalStyleSheet)
window.close()
class EditorMappingTest(unittest.TestCase):
"""Verify editor fields preserve structured configuration semantics."""