diff --git a/.ci/create-spec.py b/.ci/create-spec.py new file mode 100755 index 0000000..384e2eb --- /dev/null +++ b/.ci/create-spec.py @@ -0,0 +1,129 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +build.py +""" +from re import findall +from os import listdir +from os.path import join, isfile +from pathlib import Path +from platform import system +import argparse +import PyInstaller.building.makespec + +import sys +sys.path.append(".") +from seedqreader import VERSION + +if __name__ == "__main__": + p = argparse.ArgumentParser() + PyInstaller.building.makespec.__add_options(p) + PyInstaller.log.__add_options(p) + + SYSTEM = system() + + # build executable for following systems + if SYSTEM not in ("Linux", "Windows", "Darwin"): + raise OSError(f"OS '{system()}' not implemented") + + # Get root path to properly setup + DIR = Path(__file__).parents + ROOT_PATH = Path(__file__).parent.parent.absolute() + PYNAME = "seedqreader" + PYFILE = f"{PYNAME}.py" + KFILE = str(ROOT_PATH / PYFILE) + ASSETS = str(ROOT_PATH / "assets") + ICON = join(ASSETS, "icon.png") + # I18NS = str(ROOT_PATH / "src" / "i18n") + + BUILDER_ARGS = [ ] + + # The app name + BUILDER_ARGS.append(f"--name={PYNAME}_{VERSION}") + + # The application has window + BUILDER_ARGS.append("--windowed") + + # Icon + BUILDER_ARGS.append(f"--icon={ICON}") + + # Specifics about operational system + # on how will behave as file or bundled app + if SYSTEM == "Linux": + # Tha application is a GUI + BUILDER_ARGS.append("--onefile") + + elif SYSTEM == "Windows": + # Tha application is a GUI with a hidden console + # to keep `sys` module enabled (necessary for Kboot) + BUILDER_ARGS.append("--onefile") + BUILDER_ARGS.append("--console") + BUILDER_ARGS.append("--hidden-import=win32timezone") + BUILDER_ARGS.append("--hide-console=minimize-early") + BUILDER_ARGS.append("--add-binary=assets/libiconv.dll:.") + BUILDER_ARGS.append("--add-binary=assets/libzbar-64.dll:.") + + elif SYSTEM == "Darwin": + # Tha application is a GUI in a bundled .app + BUILDER_ARGS.append("--onefile") + BUILDER_ARGS.append("--noconsole") + + # For darwin system, will be necessary + # to add a hidden import for ssl + # (necessary for request module) + BUILDER_ARGS.append("--hidden-import=ssl") + BUILDER_ARGS.append("--hidden-import=pillow") + BUILDER_ARGS.append("--optimize=2") + + # Necessary for get version and + # another infos in application + # BUILDER_ARGS.append("--add-data=pyproject.toml:.") + BUILDER_ARGS.append("--add-data=form.ui:.") + + # some assets + for f in listdir(ASSETS): + asset = join(ASSETS, f) + if isfile(asset): + if asset.endswith("png") or asset.endswith("gif") or asset.endswith("ttf"): + BUILDER_ARGS.append(f"--add-data={asset}:assets") + + # Add i18n translations + # for f in listdir(I18NS): + # i18n_abs = join(I18NS, f) + # i18n_rel = join("src", "i18n") + # if isfile(i18n_abs): + # if findall(r"^[a-z]+\_[A-Z]+\.UTF-8\.json$", f): + # BUILDER_ARGS.append(f"--add-data={i18n_abs}:{i18n_rel}") + + + args = p.parse_args(BUILDER_ARGS) + + # Now generate spec + print("============================") + print("create-spec.py") + print("============================") + print() + for k, v in vars(args).items(): + print(f"{k}: {v}") + + print() + PyInstaller.building.makespec.main([PYFILE], **vars(args)) \ No newline at end of file diff --git a/.gitignore b/.gitignore index e87e33c..3cfacc8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ __pycache__/ venv/ config +.seedqrenv/ +.venv/ +.env/ +build/ +*.spec +dist/ \ No newline at end of file diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 04bd362..06f67be --- a/README.md +++ b/README.md @@ -1,34 +1,83 @@ -SeedQReader ---- +# SeedQReader -SeedQReader is a simple tool made for communicate with airgapped Bitcoin Signer. + + +**SeedQReader** is a simple tool made for communicate with airgapped Bitcoin Signer. ![SeedQReader](screenshot.png) It actually can send/receive: - 1 Frame QRCodes -- Multiframes QRCodes using the `Specter` format (_of_) +- Multiframes QRCodes using the `Specter` format (_p M of N_) - Multiframes QRCodes using the `UR` format are partially supported (PSBT and Bytes) +- Multiframes QRCodes using the `BBQR` format are partially supported (PSBT) + +## Download the latest releases +[github releases page](https://github.com/tadeubas/seedQReader/releases) -Dependencies: -opencv might be installed +## Install -Install: -Go into this repo and run: +To install on **Linux**, enter the repo folder and run: ``` +# create environment to install dependencies +python3 -m venv .seedqrenv + +# activate the environment on the current terminal +source .seedqrenv/bin/activate + +# install python dependencies on this environment pip install -r requirements.txt ``` -Run under Linux/MacOS: +If you get this error on **Linux**, please install libxcb-cursor: +``` +# qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is needed to load the Qt xcb platform plugin. +sudo apt install libxcb-cursor0 +``` + +To install on **Windows**: +``` +# create environment to install dependencies +python -m venv .seedqrenv + +# activate the environment on the current cmd +.seedqrenv\Scripts\activate + +# install python dependencies on this environment +pip install -r requirements.txt +``` + +If you get this error on **Windows**, install `vcredist_x64.exe` [Visual C++ Redistributable Packages for Visual Studio 2013](https://www.microsoft.com/en-US/download/details.aspx?id=40784). Then uninstall and install `pyzbar` lib again: +``` +FileNotFoundError: Could not find module 'libiconv.dll' (or one of its dependencies). Try using the full path with constructor syntax. +pyimod03_ctypes.install..PyInstallerImportError: Failed to load dynlib/dll 'libiconv.dll'. Most likely this dynlib/dll was not found when the application was frozen. +[PYI-5780:ERROR] Failed to execute script 'seedqreader' due to unhandled exception! +``` + +## Run + +**Linux/MacOS**: ``` python3 seedqreader.py ``` -Run under Windows: +**Windows**: ``` python seedqreader.py ``` -If you want i build more cool tools you can support me with bitcoin: -`bc1q5pgfrt09f4vuxyryg95erge38nw94usvpe5gg0` +## Build binaries + +``` +pip install PyInstaller +rm seedqreader_*.spec +python3 .ci/create-spec.py +python3 -m PyInstaller seedqreader_*.spec +``` + +## Acknowledgements & Alternatives + +Big thanks to [pythcoiner](https://github.com/pythcoiner) for originally creating SeedQReader! πŸ™Œ + +If SeedQReader isn’t your vibe, check out [bitcoin-qr-tools](https://github.com/andreasgriffin/bitcoin-qr-tools) for another awesome QR code tool for BTC stuff. πŸ˜„ diff --git a/assets/badge_github.png b/assets/badge_github.png new file mode 100755 index 0000000..326d254 Binary files /dev/null and b/assets/badge_github.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..1f6f899 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/libiconv.dll b/assets/libiconv.dll new file mode 100755 index 0000000..a31664d Binary files /dev/null and b/assets/libiconv.dll differ diff --git a/assets/libzbar-64.dll b/assets/libzbar-64.dll new file mode 100755 index 0000000..5353d43 Binary files /dev/null and b/assets/libzbar-64.dll differ diff --git a/bbqr.py b/bbqr.py new file mode 100644 index 0000000..8c07315 --- /dev/null +++ b/bbqr.py @@ -0,0 +1,323 @@ +# The MIT License (MIT) + +# Copyright (c) 2021-2024 Krux contributors + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# This code an adaptation of Coinkite's BBQr python implementation for Krux environment +# https://github.com/coinkite/BBQr + +import gc + +# BBQR +KNOWN_ENCODINGS = {"H", "2", "Z"} + +# File types +# P='PSBT', T='Transaction', J='JSON', C='CBOR' +# U='Unicode Text', X='Executable', B='Binary' +KNOWN_FILETYPES = {"P", "T", "J", "U"} + +BBQR_ALWAYS_COMPRESS_THRESHOLD = 5000 # bytes + +B32CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" +assert len(B32CHARS) == 32 + +BBQR_PREFIX_LENGTH = 8 + +QR_CAPACITY_ALPHANUMERIC = [ + 25, + 47, + 77, + 114, + 154, + 195, + 224, + 279, + 335, + 395, + 468, + 535, + 619, + 667, + 758, + 854, + 938, + 1046, + 1153, + 1249, +] + +class BBQrCode: + """A BBQr code, containing the data, encoding, and file type""" + + + def __init__(self, payload, encoding=None, file_type=None): + """Initializes the BBQr code with the given data, encoding, and file type""" + + if encoding not in KNOWN_ENCODINGS: + raise ValueError("Invalid BBQr encoding") + if file_type not in KNOWN_FILETYPES: + raise ValueError("Invalid BBQr file type") + self.payload = payload + self.encoding = encoding + self.file_type = file_type + + def find_min_num_parts(self, max_width): + qr_capacity = self.max_qr_bytes(max_width) + data_length = len(self.payload) + max_part_size = qr_capacity - BBQR_PREFIX_LENGTH + if data_length < max_part_size: + return 1, data_length + # Round max_part_size to the nearest lower multiple of 8 + max_part_size = (max_part_size // 8) * 8 + # Calculate the number of parts required (rounded up) + num_parts = (data_length + max_part_size - 1) // max_part_size + # Calculate the optimal part size + part_size = data_length // num_parts + # Round to the nearest higher multiple of 8 + part_size = ((part_size + 7) // 8) * 8 + # Check if the part size is within the limits + if part_size > max_part_size: + num_parts += 1 + part_size = data_length // num_parts + # Round to the nearest higher multiple of 8 again + part_size = ((part_size + 7) // 8) * 8 + return num_parts, part_size + + def max_qr_bytes(self, max_width): + """Calculates the maximum length, in bytes, a QR code of a given size can store""" + # Given qr_size = 17 + 4 * version + 2 * frame_size + max_width -= 2 # Subtract frame width + qr_version = (max_width - 17) // 4 + capacity_list = QR_CAPACITY_ALPHANUMERIC + + try: + return capacity_list[qr_version - 1] + except: + # Limited to version 20 + return capacity_list[-1] + + def to_qr_code(self, max_width): + num_parts, part_size = self.find_min_num_parts(max_width) + part_index = 0 + while True: + header = "B$%s%s%s%s" % ( + self.encoding, + self.file_type, + int2base36(num_parts), + int2base36(part_index), + ) + part = None + if part_index == num_parts - 1: + part = header + self.payload[part_index * part_size :] + part_index = 0 + else: + part = ( + header + + self.payload[ + part_index * part_size : (part_index + 1) * part_size + ] + ) + part_index += 1 + yield (part, num_parts) + + def parse(self, data): + part, index, total = parse_bbqr(data) + self.parts[index] = part + self.total = total + return index + + + +def parse_bbqr(data): + """ + Parses the QR as a BBQR part, extracting the part's content, + encoding, file format, index, and total + """ + if len(data) < 8: + raise ValueError("Invalid BBQR format") + + encoding = data[2] + if encoding not in KNOWN_ENCODINGS: + raise ValueError("Invalid encoding") + + file_type = data[3] + if file_type not in KNOWN_FILETYPES: + raise ValueError("Invalid file type") + + try: + part_total = int(data[4:6], 36) + part_index = int(data[6:8], 36) + except ValueError: + raise ValueError("Invalid BBQR format") + + if part_index >= part_total: + raise ValueError("Invalid part index") + + return data[8:], part_index, part_total + + +def deflate_compress(data): + """Compresses the given data using deflate module""" + try: + import deflate + from io import BytesIO + + stream = BytesIO() + with deflate.DeflateIO(stream) as d: + d.write(data) + return stream.getvalue() + except Exception as e: + print(e) + raise ValueError("Error compressing BBQR") + + +def deflate_decompress(data): + """Decompresses the given data using deflate module""" + try: + import deflate + from io import BytesIO + + with deflate.DeflateIO(BytesIO(data)) as d: + return d.read() + except: + raise ValueError("Error decompressing BBQR") + + +def decode_bbqr(parts, encoding, file_type): + """Decodes the given data as BBQR, returning the decoded data""" + + if encoding == "H": + from binascii import unhexlify + + data_bytes = bytearray() + for _, part in sorted(parts.items()): + data_bytes.extend(unhexlify(part)) + return bytes(data_bytes) + + binary_data = b"" + for _, part in sorted(parts.items()): + padding = (8 - (len(part) % 8)) % 8 + padded_part = part + (padding * "=") + binary_data += base32_decode_stream(padded_part) + + if encoding == "Z": + if file_type in "JU": + return deflate_decompress(binary_data).decode("utf-8") + return deflate_decompress(binary_data) + if file_type in "JU": + return binary_data.decode("utf-8") + return binary_data + + +def encode_bbqr(data, encoding="Z", file_type="P"): + """Encodes the given data as BBQR, returning the encoded data and format""" + + if encoding == "H": + from binascii import hexlify + + data = hexlify(data).decode() + return BBQrCode(data.upper(), encoding, file_type) + + if encoding == "Z": + if len(data) > BBQR_ALWAYS_COMPRESS_THRESHOLD: + # RAM won't be enough to have both compressed and not compressed data + # It will always be beneficial to compress large data + data = deflate_compress(data) + else: + # Check if compression is beneficial + cmp = deflate_compress(data) + if len(cmp) >= len(data): + encoding = "2" + else: + encoding = "Z" + data = cmp + + data = data.encode("utf-8") if isinstance(data, str) else data + gc.collect() + return BBQrCode("".join(base32_encode_stream(data)), encoding, file_type) + + +# Base 32 encoding/decoding, used in BBQR only + + +def base32_decode_stream(encoded_str): + """Decodes a Base32 string""" + base32_index = {ch: index for index, ch in enumerate(B32CHARS)} + + # Strip padding + encoded_str = encoded_str.rstrip("=") + + buffer = 0 + bits_left = 0 + decoded_bytes = bytearray() + + for char in encoded_str: + if char not in base32_index: + raise ValueError("Invalid Base32 character: %s" % char) + index = base32_index[char] + buffer = (buffer << 5) | index + bits_left += 5 + + while bits_left >= 8: + bits_left -= 8 + decoded_bytes.append((buffer >> bits_left) & 0xFF) + buffer &= (1 << bits_left) - 1 # Keep only the remaining bits + + return bytes(decoded_bytes) + + +def base32_encode_stream(data, add_padding=False): + """A streaming base32 encoder""" + buffer = 0 + bits_left = 0 + + for byte in data: + buffer = (buffer << 8) | byte + bits_left += 8 + + while bits_left >= 5: + bits_left -= 5 + yield B32CHARS[(buffer >> bits_left) & 0x1F] + buffer &= (1 << bits_left) - 1 # Keep only the remaining bits + + if bits_left > 0: + buffer <<= 5 - bits_left + yield B32CHARS[buffer & 0x1F] + + # Padding + if add_padding: + encoded_length = (len(data) * 8 + 4) // 5 + padding_length = (8 - (encoded_length % 8)) % 8 + for _ in range(padding_length): + yield "=" + + +def int2base36(n): + """Convert integer n to a base36 string.""" + if not 0 <= n <= 1295: # ensure the number is within the valid range + raise ValueError("Number out of range") + + def tostr(x): + """Convert integer x to a base36 character.""" + return chr(48 + x) if x < 10 else chr(65 + x - 10) + + quotient, remainder = divmod(n, 36) + return tostr(quotient) + tostr(remainder) + diff --git a/deflate.py b/deflate.py new file mode 100644 index 0000000..4015314 --- /dev/null +++ b/deflate.py @@ -0,0 +1,35 @@ +import sys +import zlib + + +class DeflateIO: + + def __init__(self, stream) -> None: + self.stream = stream + self.data = stream.read() + + def read(self): + return zlib.decompress(self.data, wbits=-10) + + def write(self, input_data): + compressor = zlib.compressobj(wbits=-10) + compressed_data = compressor.compress(input_data) + compressed_data += compressor.flush() + self.stream.seek(0) # Ensure we overwrite the stream from the beginning + self.stream.write(compressed_data) + self.stream.truncate() # Remove any remaining part of the old data + + def __enter__(self): + # Return the instance itself when entering the context + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # Handle cleanup here if necessary + pass + + +class Deflate: + DeflateIO = DeflateIO + +if "deflate" not in sys.modules: + sys.modules["deflate"] = Deflate diff --git a/form.ui b/form.ui index cad1542..0bba286 100644 --- a/form.ui +++ b/form.ui @@ -1,448 +1,699 @@ - - - - - 0 - 0 - 811 - 650 - - - + centralwidget + + + + + 0 + 0 + 812 + 660 + + + + Read + + + 0 + + + Read - - - 0 - - - - Read - - - - - 190 - 10 - 400 - 300 - - - - - - - - - - 10 - 350 - 771 - 201 - - - - QPlainTextEdit::WidgetWidth - - - true - - - - - - 190 - 320 - 401 - 20 - - - - 0 - - - - - - - - - 10 - 320 - 161 - 27 - - - - Start Read - - - - - - 10 - 30 - 111 - 27 - - - - - - - 20 - 10 - 91 - 17 - - - - Camera: - - - - - - 90 - 3 - 31 - 27 - - - - - - + + + + + 190 + 10 + 400 + 300 + + + + + - - - Send - - - - - 10 - 10 - 781 - 81 - - - - - - - 250 - 140 - 450 - 450 - - - - - - - - - - 10 - 100 - 97 - 27 - - - - Generate - - - - - - 380 - 100 - 291 - 28 - - - - 10 - - - 500 - - - 10 - - - 100 - - - Qt::Horizontal - - - 15 - - - - - - 690 - 100 - 96 - 22 - - - - No split - - - - - - 120 - 100 - 71 - 27 - - - - Clear - - - - - - 20 - 400 - 116 - 22 - - - - Key 1 - - - - - - 20 - 420 - 116 - 22 - - - - Key 2 - - - - - - 20 - 440 - 116 - 22 - - - - Key 3 - - - - - - 20 - 460 - 116 - 22 - - - - Key 4 - - - - - - 20 - 480 - 116 - 22 - - - - Key 5 - - - - - - 200 - 100 - 71 - 27 - - - - Save - - - - - - 20 - 220 - 116 - 22 - - - - Descriptor 1 - - - true - - - - - - 20 - 260 - 116 - 22 - - - - Descriptor 3 - - - - - - 20 - 290 - 116 - 22 - - - - PSBT 1 - - - - - - 20 - 240 - 116 - 22 - - - - Descriptor 2 - - - - - - 20 - 310 - 116 - 22 - - - - PSBT 2 - - - - - - 20 - 330 - 116 - 22 - - - - PSBT 3 - - - - - - 20 - 350 - 116 - 22 - - - - PSBT 4 - - - - - - 20 - 370 - 116 - 22 - - - - PSBT 5 - - - - - - 10 - 180 - 111 - 27 - - - - - - - 10 - 140 - 111 - 27 - - - - + + + + 10 + 350 + 771 + 201 + + + + QPlainTextEdit::WidgetWidth + + + true + + + + + + 190 + 320 + 401 + 20 + + + + 0 + + + + + + + + + 10 + 20 + 154 + 94 + + + + + + + + 11 + true + + + + Camera: + + + Qt::AlignCenter + + + + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + πŸ” + + + + + + + + + πŸ“· Camera Scan + + + + + + + + + 10 + 150 + 154 + 94 + + + + + + + + 11 + true + + + + Monitor: + + + Qt::AlignCenter + + + + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + πŸ” + + + + + + + + + πŸ–₯️ Screen Scan + + + + + + + + + Send + + + + + 250 + 140 + 450 + 450 + + + + + + + + + + 10 + 180 + 111 + 27 + + + + + + + 10 + 140 + 111 + 27 + + + + + + + 440 + 590 + 67 + 17 + + + + + + + + + + 10 + 220 + 121 + 400 + + + + + + + + 12 + true + + + + Selected slot: + + + Qt::AlignCenter + + + + + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + Qt::LeftToRight + + + Descriptor 1 + + + true + + + + + + + Descriptor 2 + + + + + + + Descriptor 3 + + + + + + + Qt::Horizontal + + + + + + + PSBT 1 + + + + + + + PSBT 2 + + + + + + + PSBT 3 + + + + + + + PSBT 4 + + + + + + + PSBT 5 + + + + + + + Qt::Horizontal + + + + + + + Key 1 + + + + + + + Key 2 + + + + + + + Key 3 + + + + + + + Key 4 + + + + + + + Key 5 + + + + + + + + + 10 + 10 + 781 + 88 + + + + + + + + + + Type the QR code content here, then click Generate QR + + + + + + + + + Generate QR + + + + + + + Cleart text + + + + + + + Save on selected slot + + + + + + + + + + + 280 + 100 + 511 + 31 + + + + + 0 + 0 + + + + + 80 + 0 + + + + true + + + + + - 440 - 590 - 67 - 17 + 0 + 0 + 511 + 32 - - - + + + 6 + + + + + 10 + + + 500 + + + 10 + + + 10 + + + 100 + + + Qt::Horizontal + + + 15 + + + + + + + Qt::Vertical + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + QR split size: + + + + + + + Qt::Vertical + + + + + + + 0 + + + + + + 0 + 0 + + + + + 10 + 0 + + + + :disabled { background-color: darkgray; } + + + + + + + + + 0 + 0 + + + + + 50 + 0 + + + + No split + + + 6 + + + + + + - + + + + + 10 + 100 + 261 + 31 + + + + true + + + + + - 284 - 104 - 91 - 17 + 0 + 0 + 261 + 31 - - Split size: - + + + + + 100 + + + 4000 + + + 100 + + + 100 + + + 400 + + + Qt::Horizontal + + + + + + + Qt::Vertical + + + + + + + + 81 + 0 + + + + QR delay: + + + + - + diff --git a/foundation/fountain_decoder.py b/foundation/fountain_decoder.py index c0b3f87..d9d5460 100644 --- a/foundation/fountain_decoder.py +++ b/foundation/fountain_decoder.py @@ -76,8 +76,8 @@ def estimated_percent_complete(self): return 1 if self.expected_part_indexes == None: return 0 - estimated_input_parts = self.expected_part_count() * 1.75 - return min(0.99, self.processed_parts_count / estimated_input_parts) + estimated_input_parts = self.expected_part_count() * .7 + return min(0.99, len(self.received_part_indexes) / estimated_input_parts) def receive_part(self, encoder_part): # Don't process the part if we're already done diff --git a/qr_type.py b/qr_type.py index 6519691..e928d85 100644 --- a/qr_type.py +++ b/qr_type.py @@ -1,4 +1,4 @@ SPECTER = "specter" UR = "ur" - +BBQR = "bbqr" diff --git a/requirements.txt b/requirements.txt index 7314bb3..123ec31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,10 @@ -embit==0.7.0 -numpy==1.26.4 -opencv-python==4.7.0.72 -Pillow==9.5.0 -pypng==0.20220715.0 -PyYAML==6.0.1 +PyYAML==6.0.2 +PySide6==6.9.0 +Pillow==11.2.1 pyzbar==0.1.9 -qrcode==7.4.2 -typing_extensions==4.11.0 -PySide6==6.6.3.1 -shiboken6==6.6.3.1 -urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a +qrcode==8.1 +opencv-python==4.11.0.86 +urtypes==1.0.0 +embit==0.8.0 +mss==10.0.0 +numpy==2.2.6 diff --git a/screenshot.png b/screenshot.png index d155d60..8e755bd 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/seedqreader.py b/seedqreader.py index ac77883..13404a2 100644 --- a/seedqreader.py +++ b/seedqreader.py @@ -10,7 +10,7 @@ from yaml.loader import SafeLoader as Loader from PySide6.QtWidgets import QApplication, QMainWindow -from PySide6.QtGui import QImage, QPixmap, QPalette, QColor +from PySide6.QtGui import QImage, QPixmap, QPalette, QColor, QColorConstants, QIcon from PySide6.QtCore import Qt, QFile, QThread, Signal from PySide6.QtUiTools import QUiLoader from PySide6.QtGui import QTextOption @@ -35,10 +35,29 @@ from embit.psbt import PSBT +from mss import mss +import numpy as np + +VERSION="1.1.0" + MAX_LEN = 100 -QR_DELAY = 400 FILL_COLOR = "#434343" +STOP_QR_TXT = 'Remove QR' +STOP_READ_TXT = ' Stop' +START_READ_TXT = ' Scan' +GENERATE_TXT = 'Generate QR' + +ANIMATED_QR_FIRST_FRAME_DELAY = 900 #ms + +FORMAT_UR = 'UR' +FORMAT_SPECTER = 'Specter' +FORMAT_BBQR = 'BBQR' + +# helper obj to handle bbqr encoding and file_type +bbqr_obj = None + + def to_str(bin_): return bin_.decode('utf-8') @@ -74,7 +93,7 @@ class MultiQRCode(QRCode): encoder = None def step(self): - if self.qr_type == qr_type.SPECTER: + if self.qr_type in (qr_type.SPECTER, qr_type.BBQR): self.total_sequences = len(self.data_stack) return f"{self.current + 1}/{self.total_sequences}" @@ -89,6 +108,42 @@ def append(self, data: tuple): elif self.qr_type == qr_type.UR: self.append_ur(data) + elif self.qr_type == qr_type.BBQR: + self.append_bbqr(data) + + def append_bbqr(self, data: tuple): + data, sequence, total_sequences = data + + if not self.is_init: + self.data_init(total_sequences) + self.is_init = True + + if not self.data_stack[sequence]: + self.data_stack[sequence] = data + else: + if data != self.data_stack[sequence]: + raise ValueError('Same sequences have different data!') + self.check_complete_bbrq() + + def check_complete_bbrq(self): + global bbqr_obj + + fill_sequences = 0 + for i in self.data_stack: + if i: + fill_sequences += 1 + + self.sequences_count = fill_sequences + + if fill_sequences == self.total_sequences: + from bbqr import decode_bbqr + my_dict = {} + for i, val in enumerate(self.data_stack): + my_dict[i] = val + self.data = decode_bbqr(my_dict, bbqr_obj.encoding, bbqr_obj.file_type) + self.is_completed = True + + def append_specter(self, data: tuple): # print(f'MultiQRCode.append({data})') sequence = data[0] @@ -169,20 +224,22 @@ def check_complete_ur(self): print(self.decoder.result_error()) @staticmethod - def from_string(data, max=MAX_LEN, type=None, format=None): - - if (max and len(data) > max) or format == 'UR': + def from_string(data, _max=MAX_LEN, type=None, format=None): + if (_max and len(data) > _max) or format == FORMAT_UR: out = MultiQRCode() out.data = data - if format == 'UR': + + if format == FORMAT_UR: out.qr_type = qr_type.UR - elif format == 'Specter': + elif format == FORMAT_SPECTER: out.qr_type = qr_type.SPECTER + elif format == FORMAT_BBQR: + out.qr_type = qr_type.BBQR - if format == 'Specter': - while len(data) > max: - sequence = data[:max] - data = data[max:] + if format == FORMAT_SPECTER: + while len(data) > _max: + sequence = data[:_max] + data = data[_max:] out.data_stack.append(sequence) if len(data): out.data_stack.append(data) @@ -191,7 +248,34 @@ def from_string(data, max=MAX_LEN, type=None, format=None): out.sequences_count = out.total_sequences out.is_completed = True - elif format == 'UR': + elif format == FORMAT_BBQR: + from bbqr import encode_bbqr + + data_bytes = bytes(data, "utf-8") + + bb = encode_bbqr(data_bytes) + + # adjust BBQR size from 10-500 to 23-200 + old_min, old_max = 10, 500 + new_min, new_max = 23, 100 + + scaled_value = new_min + ((_max - old_min) * (new_max - new_min)) / (old_max - old_min) + _max = int(round(scaled_value)) + + count = 1 + for sequence, total in bb.to_qr_code(_max): + out.data_stack.append(sequence) + count += 1 + if count > total: + break + out.total_sequences = total + out.sequences_count = out.total_sequences + out.is_completed = True + + if total == 1: + out.data = sequence + + elif format == FORMAT_UR: _UR = None if type == 'PSBT': out.data_type = 'crypto-psbt' @@ -201,7 +285,6 @@ def from_string(data, max=MAX_LEN, type=None, format=None): out.data_type = 'bytes' _UR = Bytes elif type == 'Key': - print("key") out.data_type = 'bytes' _UR = Bytes elif type == 'Bytes': @@ -209,12 +292,11 @@ def from_string(data, max=MAX_LEN, type=None, format=None): _UR = Bytes else: return - if not max: - max = 100000 + if not _max: + _max = 100000 ur = UR(out.data_type, _UR(data).to_cbor()) - out.encoder = UREncoder(ur, max) + out.encoder = UREncoder(ur, _max) out.total_sequences = out.encoder.fountain_encoder.seq_len() - else: out = QRCode() out.data = data @@ -223,30 +305,31 @@ def from_string(data, max=MAX_LEN, type=None, format=None): return out def next(self) -> str: + data = None if self.qr_type == qr_type.SPECTER: - self.current += 1 - if self.current >= self.total_sequences: - self.current = 0 - data = self.data_stack[self.current] digit_a = self.current + 1 digit_b = self.total_sequences data = f"p{digit_a}of{digit_b} {data}" - print(data) - - return data + self.current += 1 + if self.current >= self.total_sequences: + self.current = 0 elif self.qr_type == qr_type.UR: self.current = self.encoder.fountain_encoder.seq_num data = self.encoder.next_part().upper() - print(data) - return data + elif self.qr_type == qr_type.BBQR: + data = self.data_stack[self.current] + self.current += 1 + if self.current >= self.total_sequences: + self.current = 0 + + return data class ReadQR(QThread): - data = Signal(object) video_stream = Signal(object) @@ -257,29 +340,83 @@ def __init__(self, parent): self.qr_data: QRCode | MultiQRCode = None self.capture = None self.end = False + self.viaCamera = True def run(self): + from PIL import Image self.qr_data: QRCode | MultiQRCode = None - # Initialize the camera - camera_id = self.parent.get_camera_id() - if camera_id is None: - return - self.capture = cv2.VideoCapture(camera_id) + if self.viaCamera: + # Initialize the camera + camera_id = self.parent.get_camera_id() + + if camera_id is None: + return + + self.capture = cv2.VideoCapture(camera_id) + + self.parent.ui.btn_start_read.setText(' '.join(self.parent.ui.btn_start_read.text().split(' ')[:-1]) + STOP_READ_TXT) + self.parent.ui.monitor_group.setDisabled(True) + else: + # Initialize the monitor + monitor_id = self.parent.get_monitor_id() + + if monitor_id is None: + return + else: + monitor_id += 1 + + self.parent.ui.btn_start_read_monitor.setText(' '.join(self.parent.ui.btn_start_read_monitor.text().split(' ')[:-1]) + STOP_READ_TXT) + self.parent.ui.camera_group.setDisabled(True) + - self.parent.ui.btn_start_read.setText('Stop') while not self.end: self.msleep(30) - ret, frame = self.capture.read() + if self.viaCamera: + ret, frame = self.capture.read() + else: + ret = True + with mss() as sct: + # Get a screenshot of the monitor + monitor = sct.monitors[monitor_id] + width = monitor['width'] + height = monitor['height'] + screenshot = sct.grab(sct.monitors[monitor_id]) if ret: - # Convert the frame to RGB format - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + if self.viaCamera: + # Convert the frame to RGB format + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - # Create a QImage from the frame data - height, width, channel = frame.shape - image = QImage(frame.data, width, height, QImage.Format_RGB888) + # Create a QImage from the frame data + height, width, _ = frame.shape + image = QImage(frame.data, width, height, QImage.Format_RGB888) + else: + # Convert to numpy array (BGRA format) + img_data = np.frombuffer(screenshot.rgb, dtype=np.uint8) + frame = img_data.reshape((screenshot.height, screenshot.width, 3)) + + # Add an alpha channel to convert RGB to RGBA + alpha_channel = np.full((height, width, 1), 255, dtype=np.uint8) # Fully opaque + img_data = np.concatenate([frame, alpha_channel], axis=2) # Append alpha + + # Convert RGB to RGBA (ensure correct channel order for QImage) + img_data = img_data[:, :, [0, 1, 2, 3]] # Already in correct order, but explicit for clarity + + img_data = np.ascontiguousarray(img_data) + + # Create QImage from the data + image = QImage( + img_data.data, + screenshot.width, + screenshot.height, + screenshot.width * 4, # Bytes per line + QImage.Format_RGBA8888 + ) + + # Ensure the data is not garbage-collected + image.ndarray = img_data # Create a QPixmap from the QImage pixmap = QPixmap.fromImage(image) @@ -309,8 +446,7 @@ def run(self): return def decode(self, data): - - # Multipart QR Code case + '''Multipart QR Code case''' # specter format if re.match(r'^p\d+of\d+\s', data, re.IGNORECASE): @@ -331,7 +467,8 @@ def decode(self, data): self.parent.ui.read_progress.setValue(progress) self.parent.ui.read_progress.setFormat(f"{self.qr_data.sequences_count}/{self.qr_data.total_sequences}") self.parent.ui.read_progress.setVisible(True) - + + # UR format elif re.match(r'^UR:', data, re.IGNORECASE): if not self.qr_data: @@ -341,87 +478,116 @@ def decode(self, data): self.qr_data.append(data) progress = self.qr_data.decoder.estimated_percent_complete() * 100 - self.qr_data.total_sequences = self.qr_data.decoder.expected_part_count() - self.qr_data.sequences_count = self.qr_data.decoder.processed_parts_count() + try: + self.qr_data.total_sequences = self.qr_data.decoder.expected_part_count() + except: + self.qr_data.total_sequences = 0 + self.qr_data.sequences_count = len(self.qr_data.decoder.received_part_indexes()) self.parent.ui.read_progress.setValue(progress) self.parent.ui.read_progress.setFormat(f"{self.qr_data.sequences_count}/{self.qr_data.total_sequences}") self.parent.ui.read_progress.setVisible(True) + elif data.startswith("B$"): + global bbqr_obj + if bbqr_obj is None: + from bbqr import BBQrCode, KNOWN_ENCODINGS, KNOWN_FILETYPES + + if data[3] in KNOWN_FILETYPES: + bbqr_file_type = data[3] + if data[2] in KNOWN_ENCODINGS: + bbqr_encoding = data[2] + bbqr_obj = BBQrCode(None, bbqr_encoding, bbqr_file_type) + + from bbqr import parse_bbqr + parsed_data = parse_bbqr(data) + + if not self.qr_data: + self.qr_data = MultiQRCode() + self.qr_data.qr_type = qr_type.BBQR + + self.qr_data.append(parsed_data) + + progress = round(self.qr_data.sequences_count / self.qr_data.total_sequences * 100) + self.parent.ui.read_progress.setValue(progress) + self.parent.ui.read_progress.setFormat(f"{self.qr_data.sequences_count}/{self.qr_data.total_sequences}") + self.parent.ui.read_progress.setVisible(True) + + # Other format else: self.qr_data = QRCode() self.qr_data.append(data) - - def on_finnish(self): if self.capture: self.capture.release() self.parent.ui.read_progress.setValue(0) self.parent.ui.read_progress.setVisible(False) self.parent.ui.read_progress.setFormat('') - self.parent.ui.btn_start_read.setText('Start read') + self.parent.ui.btn_start_read.setText(' '.join(self.parent.ui.btn_start_read.text().split(' ')[:-1]) + START_READ_TXT) + self.parent.ui.btn_start_read_monitor.setText(' '.join(self.parent.ui.btn_start_read_monitor.text().split(' ')[:-1]) + START_READ_TXT) + self.parent.ui.monitor_group.setDisabled(False) + self.parent.ui.camera_group.setDisabled(False) class DisplayQR(QThread): - video_stream = Signal(object) - def __init__(self, parent): + def __init__(self, parent, delay): QThread.__init__(self) self.parent = parent + self.set_delay(delay) self.qr_data: QRCode | MultiQRCode = None - self.stop = False + self.stop = True + + def set_delay(self, delay): + self.delay = delay def run(self): self.stop = False if self.qr_data.total_sequences > 1 or self.qr_data.qr_type == qr_type.UR: + remove_qr = True + firstFrame = True while not self.stop: + self.parent.ui.steps.setText(self.qr_data.step()) data = self.qr_data.next() - + if self.qr_data.qr_type == qr_type.UR: + self.parent.ui.steps.setText(self.qr_data.step()) self.display_qr(data) - self.parent.ui.steps.setText(self.qr_data.step()) + self.msleep(self.delay) if self.qr_data.total_sequences == 1: + remove_qr = False break - if not self.stop: - self.msleep(QR_DELAY) - - if self.qr_data.total_sequences == 1: - while not self.stop: - self.msleep(QR_DELAY) - - self.parent.ui.steps.setText('') - + if firstFrame: + firstFrame = False + self.msleep(ANIMATED_QR_FIRST_FRAME_DELAY) + if remove_qr: + self.video_stream.emit(None) elif self.qr_data.total_sequences == 1: data = self.qr_data.data self.display_qr(data) - while not self.stop: - self.msleep(QR_DELAY) + self.parent.ui.steps.setText('') def display_qr(self, data): + try: + qr = qrcode.QRCode() + qr.add_data(data) + qr.make(fit=False) + img = qr.make_image() + pil_image = img.convert("RGB") + qimage = ImageQt.ImageQt(pil_image) + qimage = qimage.convertToFormat(QImage.Format_RGB888) - qr = qrcode.QRCode() - qr.add_data(data) - qr.make(fit=False) - img = qr.make_image() - pil_image = img.convert("RGB") - qimage = ImageQt.ImageQt(pil_image) - qimage = qimage.convertToFormat(QImage.Format_RGB888) - - # Create a QPixmap from the QImage - pixmap = QPixmap.fromImage(qimage) - - scaled_pixmap = pixmap.scaled(self.parent.ui.video_out.size(), Qt.KeepAspectRatio) - self.video_stream.emit(scaled_pixmap) + # Create a QPixmap from the QImage + pixmap = QPixmap.fromImage(qimage) - def on_stop(self): - self.video_stream.emit(None) - self.stop = True + scaled_pixmap = pixmap.scaled(self.parent.ui.video_out.size(), Qt.KeepAspectRatio) + self.video_stream.emit(scaled_pixmap) + except Exception as e: + print("error making QR", e) class MainWindow(QMainWindow): - stop_display = Signal() - def __init__(self, loader): super().__init__() @@ -431,17 +597,21 @@ def __init__(self, loader): ui_file.open(QFile.ReadOnly) self.ui = loader.load(ui_file, self) ui_file.close() - self.setWindowTitle("SeedQReader") - self.setFixedSize(812,670) + self.setWindowTitle("SeedQReader " + VERSION) + self.setWindowIcon(QIcon('assets/icon.png')) + self.setFixedSize(self.ui.tabWidget.width(),self.ui.tabWidget.height()) self.setCentralWidget(self.ui) self.load_config() - self.ui.btn_start_read.clicked.connect(self.on_qr_read) + self.ui.btn_start_read.clicked.connect(self.on_qr_read_camera) + self.ui.btn_start_read_monitor.clicked.connect(self.on_qr_read_monitor) self.ui.btn_generate.clicked.connect(self.on_btn_generate) self.ui.btn_clear.clicked.connect(self.on_btn_clear) self.ui.send_slider.valueChanged.connect(self.on_slider_move) + self.ui.delay_slider.valueChanged.connect(self.on_delay_slider_move) + self.ui.no_split.stateChanged.connect(self.on_no_split_change) self.ui.data_out.setWordWrapMode(QTextOption.WrapAnywhere) @@ -469,7 +639,7 @@ def __init__(self, loader): self.ui.btn_save.clicked.connect(self.on_btn_save) - self.ui.combo_format.addItems(['Specter', 'UR']) + self.ui.combo_format.addItems([FORMAT_SPECTER, FORMAT_UR, FORMAT_BBQR]) self.format = self.ui.combo_format.currentText() self.ui.combo_format.currentIndexChanged.connect(self.on_format_change) self.ui.combo_type.currentIndexChanged.connect(self.on_data_type_change) @@ -479,24 +649,24 @@ def __init__(self, loader): self.data_type = None self.ui.btn_camera_update.clicked.connect(self.on_camera_update) + self.ui.btn_monitor_update.clicked.connect(self.on_monitor_update) self.on_slider_move() + self.on_delay_slider_move() self.on_camera_update() + self.on_monitor_update() self.init_qr() def init_qr(self): - self.read_qr = ReadQR(self) self.read_qr.video_stream.connect(self.upd_camera_stream) self.read_qr.data.connect(self.on_qr_data_read) - self.display_qr = DisplayQR(self) + self.display_qr = DisplayQR(self, self.ui.delay_slider.value()) self.display_qr.video_stream.connect(self.on_qr_display) - self.stop_display.connect(self.display_qr.on_stop) def load_config(self): - if not os.path.exists('config'): f = open('config', 'w') f.close() @@ -533,6 +703,27 @@ def list_available_cameras(): continue return available_cameras + + @staticmethod + def list_available_monitors(): + with mss() as sct: + return [str(i) for i in list(range(len(sct.monitors)-1))] + + def on_monitor_update(self): + last = self.get_monitor_id() + + monitors = self.list_available_monitors() + self.ui.combo_monitor.clear() + self.ui.combo_monitor.addItems(monitors) + if last and str(last) in monitors: + self.ui.combo_type.setCurrentText(str(last)) + + def get_monitor_id(self) -> int | None: + try: + id = self.ui.combo_monitor.currentText() + return int(id) + except : + return None def get_camera_id(self) -> int | None: try: @@ -553,16 +744,16 @@ def on_camera_update(self): def on_format_change(self): self.format = self.ui.combo_format.currentText() - if self.format != 'Specter': + if self.format == FORMAT_UR: self.ui.combo_type.show() self.on_data_type_change() - else: + elif self.format in (FORMAT_SPECTER, FORMAT_BBQR): self.ui.combo_type.hide() self.data_type = None def on_data_type_change(self): - if self.format == 'UR': + if self.format == FORMAT_UR: self.data_type = self.ui.combo_type.currentText() def on_qr_display(self, frame): @@ -572,6 +763,14 @@ def on_qr_display(self, frame): self.ui.video_out.setPixmap(frame) + def on_qr_read_camera(self): + self.read_qr.viaCamera = True + self.on_qr_read() + + def on_qr_read_monitor(self): + self.read_qr.viaCamera = False + self.on_qr_read() + def on_qr_read(self): if not self.read_qr.isRunning(): self.read_qr.end = False @@ -582,6 +781,16 @@ def on_qr_read(self): def on_qr_data_read(self, data): self.ui.data_in.setWordWrapMode(QTextOption.WrapAnywhere) + if isinstance(data, bytes): + try: + data = data.decode("utf-8") + except: + try: + import base64 + data = base64.b64encode(data).decode("utf-8") + except Exception as e: + print("Could not identify data", e) + self.ui.data_in.setPlainText(data) def upd_camera_stream(self, frame): @@ -592,35 +801,66 @@ def upd_camera_stream(self, frame): self.ui.video_in.setPixmap(frame) def on_slider_move(self): - self.ui.split_size.setText(f"Split size: {self.ui.send_slider.value()}") + self.set_split_slider(self.ui.send_slider.value()) + + def on_no_split_change(self): + self.ui.send_slider.setDisabled(self.ui.no_split.isChecked()) + self.ui.split_size.setDisabled(self.ui.no_split.isChecked()) + self.disableQRCombo(self.ui.no_split.isChecked()) + + if self.ui.no_split.isChecked(): + self.set_split_slider('-') + else: + self.set_split_slider(self.ui.send_slider.value()) + + def set_split_slider(self, val): + self.ui.split_size.setText(f"QR split size: {val}") + + def on_delay_slider_move(self): + self.ui.delay_size.setText(f"QR delay: {self.ui.delay_slider.value()}") + try: + self.display_qr.set_delay(self.ui.delay_slider.value()) + except: + pass def on_btn_generate(self): data: str = self.ui.data_out.toPlainText() data.replace(' ', '').replace('\n', '') - if not self.display_qr.isRunning() and data != '': - if self.ui.no_split.isChecked(): - _max = None - else: - _max = self.ui.send_slider.value() + if not self.display_qr.isRunning() and self.display_qr.stop and data != '': + _max = None if self.ui.no_split.isChecked() else self.ui.send_slider.value() - # print(f"max={_max}") - qr = MultiQRCode.from_string(data, max=_max, type=self.data_type, format=self.format) + try: + qr = MultiQRCode.from_string(data, _max=_max, type=self.data_type, format=self.format) + except Exception as e: + print("error creating MultiQRCode", self.format, e) + return + if not qr: print("error creating MultiQRCode") return + + self.ui.split_group.setDisabled(True) self.display_qr.qr_data = qr self.display_qr.start() - self.ui.btn_generate.setText('Stop') - + self.ui.btn_generate.setText(STOP_QR_TXT) + self.disableQRCombo(True) else: - self.stop_display.emit() - self.ui.btn_generate.setText('Generate') + self.display_qr.stop = True + self.display_qr.video_stream.emit(None) + + self.ui.split_group.setDisabled(False) + self.ui.btn_generate.setText(GENERATE_TXT) + self.disableQRCombo(False) def on_btn_clear(self): self.ui.data_out.setPlainText('') + def disableQRCombo(self, val): + self.ui.combo_type.setDisabled(val) + self.ui.combo_format.setDisabled(val) + def select_data_type(self, data_type): self.data_type = data_type self.ui.combo_type.setCurrentText(data_type) @@ -682,7 +922,6 @@ def radio_select(self): return def on_radio_toggled(self): - self.radio_select() self.load_config() @@ -692,7 +931,6 @@ def on_radio_toggled(self): self.ui.data_out.setPlainText('') def on_btn_save(self): - self.load_config() self.config[self.radio_selected] = self.ui.data_out.toPlainText() self.dump_config() @@ -714,12 +952,17 @@ def on_btn_save(self): palette.setColor(QPalette.ToolTipBase, Qt.black) palette.setColor(QPalette.ToolTipText, Qt.white) palette.setColor(QPalette.Text, Qt.white) + palette.setColor(QPalette.PlaceholderText, Qt.gray) palette.setColor(QPalette.Button, QColor(53, 53, 53)) palette.setColor(QPalette.ButtonText, Qt.white) palette.setColor(QPalette.BrightText, Qt.red) palette.setColor(QPalette.Link, QColor(42, 130, 218)) palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) palette.setColor(QPalette.HighlightedText, Qt.black) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.Button, QColorConstants.DarkGray) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ButtonText, QColorConstants.Black) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.WindowText, QColorConstants.DarkGray) + app.setPalette(palette) main_win = MainWindow(loader)