From 1b3692ec4631a9480380a4d04f63c7f2ef76dca5 Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sat, 22 Aug 2026 17:35:12 +0800 Subject: [PATCH] Refactor QR code generation and presentation Signed-off-by: Loren Eteval --- Furious/Window/QRCodeWindow.py | 231 +++++++++++++++++++--- pyproject.toml | 3 +- requirements.txt | 3 +- tests/test_qt_lifetime.py | 22 ++- tests/test_ui_behavior.py | 340 +++++++++++++++++++++++++++++++++ 5 files changed, 558 insertions(+), 41 deletions(-) diff --git a/Furious/Window/QRCodeWindow.py b/Furious/Window/QRCodeWindow.py index 15185a8a..3d292258 100644 --- a/Furious/Window/QRCodeWindow.py +++ b/Furious/Window/QRCodeWindow.py @@ -15,79 +15,248 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""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() diff --git a/pyproject.toml b/pyproject.toml index 4c70caac..886396d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,7 @@ dependencies = [ "PySide6-Addons", "ujson", "pybase64", - "pyqrcode", - "pypng", + "segno", "icmplib", "mss", "zxing-cpp<3", diff --git a/requirements.txt b/requirements.txt index b8263b21..25f90b0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,8 +2,7 @@ PySide6-Essentials PySide6-Addons ujson pybase64 -pyqrcode -pypng +segno icmplib mss zxing-cpp<3 diff --git a/tests/test_qt_lifetime.py b/tests/test_qt_lifetime.py index 50e36b64..877c13a9 100644 --- a/tests/test_qt_lifetime.py +++ b/tests/test_qt_lifetime.py @@ -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.""" diff --git a/tests/test_ui_behavior.py b/tests/test_ui_behavior.py index 757ace52..4d49ac38 100644 --- a/tests/test_ui_behavior.py +++ b/tests/test_ui_behavior.py @@ -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."""