diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 25be36f7e..2ec8c570c 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -1,26 +1,21 @@ +import time import logging + import traceback -import os -from embit.descriptor import Descriptor -from embit.psbt import PSBT from PIL.Image import Image -from typing import List - -from seedsigner.gui.renderer import Renderer -from seedsigner.hardware.buttons import HardwareButtons -from seedsigner.hardware.microsd import MicroSD -from seedsigner.views.screensaver import ScreensaverScreen -from seedsigner.views.view import Destination, NotYetImplementedView, UnhandledExceptionView -from .models import Seed, SeedStorage, Settings, Singleton, PSBTParser +from seedsigner.models.settings import Settings +from seedsigner.models.singleton import Singleton +from seedsigner.models.threads import BaseThread +from seedsigner.views.view import Destination logger = logging.getLogger(__name__) -class BackStack(List[Destination]): +class BackStack(list[Destination]): def __repr__(self): if len(self) == 0: return "[]" @@ -40,6 +35,7 @@ class StopFlowBasedTest(Exception): pass + class FlowBasedTestException(Exception): """ This is a special exception that is only raised by the test suite. @@ -49,6 +45,36 @@ class FlowBasedTestException(Exception): +class BackgroundImportThread(BaseThread): + def run(self): + from importlib import import_module + + # import seedsigner.hardware.buttons # slowly imports GPIO along the way + + def time_import(module_name): + last = time.time() + import_module(module_name) + # print(time.time() - last, module_name) + + time_import('embit') + time_import('seedsigner.helpers.embit_utils') + + # Do costly initializations + time_import('seedsigner.models.seed_storage') + from seedsigner.models.seed_storage import SeedStorage + Controller.get_instance()._storage = SeedStorage() + + # Get MainMenuView ready to respond quickly + time_import('seedsigner.views.scan_views') + + time_import('seedsigner.views.seed_views') + + time_import('seedsigner.views.tools_views') + + time_import('seedsigner.views.settings_views') + + + class Controller(Singleton): """ The Controller is a globally available singleton that maintains SeedSigner state. @@ -71,22 +97,20 @@ class Controller(Singleton): # Declare class member vars with type hints to enable richer IDE support throughout # the code. - buttons: HardwareButtons = None - storage: SeedStorage = None + _storage: 'SeedStorage' = None # TODO: Rename "storage" to something more indicative of its temp, in-memory state settings: Settings = None - renderer: Renderer = None # TODO: Refactor these flow-related attrs that survive across multiple Screens. # TODO: Should all in-memory flow-related attrs get wiped on MainMenuView? - psbt: PSBT = None - psbt_seed: Seed = None - psbt_parser: PSBTParser = None + psbt: 'embit.psbt.PSBT' = None + psbt_seed: 'Seed' = None + psbt_parser: 'PSBTParser' = None unverified_address = None - multisig_wallet_descriptor: Descriptor = None + multisig_wallet_descriptor: 'embit.descriptor.Descriptor' = None - image_entropy_preview_frames: List[Image] = None + image_entropy_preview_frames: list[Image] = None image_entropy_final_image: Image = None address_explorer_data: dict = None @@ -102,7 +126,7 @@ class Controller(Singleton): resume_main_flow: str = None back_stack: BackStack = None - screensaver: ScreensaverScreen = None + screensaver: 'ScreensaverScreen' = None @classmethod @@ -127,6 +151,9 @@ def configure_instance(cls, disable_hardware=False): each time you try to re-initialize a Controller. """ + from seedsigner.gui.renderer import Renderer + from seedsigner.hardware.microsd import MicroSD + # Must be called before the first get_instance() call if cls._instance: raise Exception("Instance already configured") @@ -135,15 +162,7 @@ def configure_instance(cls, disable_hardware=False): controller = cls.__new__(cls) cls._instance = controller - # Input Buttons - if disable_hardware: - controller.buttons = None - else: - controller.buttons = HardwareButtons.get_instance() - # models - # TODO: Rename "storage" to something more indicative of its temp, in-memory state - controller.storage = SeedStorage() controller.settings = Settings.get_instance() controller.microsd = MicroSD.get_instance() @@ -156,13 +175,14 @@ def configure_instance(cls, disable_hardware=False): # Configure the Renderer Renderer.configure_instance() - controller.screensaver = ScreensaverScreen(controller.buttons) - controller.back_stack = BackStack() # Other behavior constants controller.screensaver_activation_ms = 120 * 1000 + background_import_thread = BackgroundImportThread() + background_import_thread.start() + return cls._instance @@ -170,9 +190,18 @@ def configure_instance(cls, disable_hardware=False): def camera(self): from .hardware.camera import Camera return Camera.get_instance() + + + @property + def storage(self): + while not self._storage: + # Wait for the BackgroundImportThread to finish initializing the storage. + # This is a rare timing issue that likely only occurs in the test suite. + time.sleep(0.001) + return self._storage - def get_seed(self, seed_num: int) -> Seed: + def get_seed(self, seed_num: int) -> 'Seed': if seed_num < len(self.storage.seeds): return self.storage.seeds[seed_num] else: @@ -187,7 +216,6 @@ def discard_seed(self, seed_num: int): def pop_prev_from_back_stack(self): - from .views import Destination if len(self.back_stack) > 0: # Pop the top View (which is the current View_cls) self.back_stack.pop() @@ -212,8 +240,7 @@ def start(self, initial_destination: Destination = None) -> None: from .views import MainMenuView, BackStackView from .views.screensaver import OpeningSplashScreen - opening_splash = OpeningSplashScreen() - opening_splash.start() + OpeningSplashScreen().start() """ Class references can be stored as variables in python! @@ -288,6 +315,7 @@ def run(self): if not next_destination: # Should only happen during dev when you hit an unimplemented option + from seedsigner.views.view import NotYetImplementedView next_destination = Destination(NotYetImplementedView) if next_destination.skip_current_view: @@ -320,7 +348,8 @@ def run(self): print("-" * 30) finally: - if self.screensaver.is_running: + from seedsigner.gui.renderer import Renderer + if self.is_screensaver_running: self.screensaver.stop() # Clear the screen when exiting @@ -328,7 +357,17 @@ def run(self): Renderer.get_instance().display_blank_screen() + @property + def is_screensaver_running(self): + return self.screensaver is not None and self.screensaver.is_running + + def start_screensaver(self): + if not self.screensaver: + # Do a lazy/late import and instantiation to reduce Controller initial startup time + from seedsigner.views.screensaver import ScreensaverScreen + from seedsigner.hardware.buttons import HardwareButtons + self.screensaver = ScreensaverScreen(HardwareButtons.get_instance()) self.screensaver.start() @@ -342,6 +381,7 @@ def handle_exception(self, e) -> Destination: * python file, line num, method name * Exception message """ + from seedsigner.views.view import UnhandledExceptionView logger.exception(e) # The final exception output line is: diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py index 0f9ddda4d..7aafcbcb8 100644 --- a/src/seedsigner/gui/components.py +++ b/src/seedsigner/gui/components.py @@ -7,9 +7,9 @@ from PIL import Image, ImageDraw, ImageFont, ImageFilter from typing import List, Tuple -from seedsigner.models import Singleton from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.models.singleton import Singleton # TODO: Remove all pixel hard coding @@ -625,13 +625,15 @@ def __post_init__(self): def render(self): import time from seedsigner.controller import Controller + from seedsigner.hardware.buttons import HardwareButtons self.controller: Controller = Controller.get_instance() self.current_screen = self.renderer.canvas.copy() + buttons = HardwareButtons.get_instance() # Special case when screensaver is running - if self.controller.screensaver._is_running: - self.controller.buttons.override_ind = True + if self.controller.is_screensaver_running: + buttons.override_ind = True self.image_draw.rounded_rectangle( ( GUIConstants.EDGE_PADDING + 2, self.canvas_height - 60, self.canvas_width - GUIConstants.EDGE_PADDING - 2, self.canvas_width - GUIConstants.EDGE_PADDING - 2), @@ -648,11 +650,13 @@ def render(self): t_end = time.time() + 3 while time.time() < t_end: - if self.controller.buttons.has_any_input(): + if buttons.has_any_input(): break self.renderer.show_image(self.current_screen) + + @dataclass class FormattedAddress(BaseComponent): """ diff --git a/src/seedsigner/gui/renderer.py b/src/seedsigner/gui/renderer.py index cc733bfae..8aaaef4a9 100644 --- a/src/seedsigner/gui/renderer.py +++ b/src/seedsigner/gui/renderer.py @@ -1,9 +1,9 @@ -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw from threading import Lock from seedsigner.gui.components import Fonts, GUIConstants from seedsigner.hardware.ST7789 import ST7789 -from seedsigner.models import ConfigurableSingleton +from seedsigner.models.singleton import ConfigurableSingleton @@ -19,8 +19,6 @@ class Renderer(ConfigurableSingleton): @classmethod def configure_instance(cls): - from seedsigner.models.settings import Settings - # Instantiate the one and only Renderer instance renderer = cls.__new__(cls) cls._instance = renderer diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py index 87062786d..26a53baef 100644 --- a/src/seedsigner/gui/screens/scan_screens.py +++ b/src/seedsigner/gui/screens/scan_screens.py @@ -6,7 +6,7 @@ from seedsigner.gui import renderer from seedsigner.hardware.buttons import HardwareButtonsConstants from seedsigner.hardware.camera import Camera -from seedsigner.models import DecodeQR, DecodeQRStatus +from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus from seedsigner.models.threads import BaseThread from .screen import BaseScreen, ButtonListScreen @@ -42,7 +42,7 @@ class ScanScreen(BaseScreen): this should probably be refactored into the Controller. """ decoder: DecodeQR = None - instructions_text: str = "< back | Scan a QR code" + instructions_text: str = None resolution: tuple[int,int] = (480, 480) framerate: int = 6 # TODO: alternate optimization for Pi Zero 2W? render_rect: tuple[int,int,int,int] = None @@ -53,6 +53,8 @@ def __post_init__(self): # Initialize the base class super().__post_init__() + self.instructions_text = "< back | " + self.instructions_text + self.camera = Camera.get_instance() self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb") diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index 5289c92ad..306ba968e 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -7,7 +7,6 @@ from seedsigner.gui.renderer import Renderer from seedsigner.models.threads import BaseThread, ThreadsafeCounter -from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.settings import SettingsConstants from ..components import (FontAwesomeIconConstants, GUIConstants, BaseComponent, Button, Icon, IconButton, @@ -660,10 +659,10 @@ def swap_selected_button(new_selected_button: int): @dataclass class QRDisplayScreen(BaseScreen): - qr_encoder: EncodeQR = None + qr_encoder: 'EncodeQR' = None class QRDisplayThread(BaseThread): - def __init__(self, qr_encoder: EncodeQR, qr_brightness: ThreadsafeCounter, renderer: Renderer, + def __init__(self, qr_encoder: 'EncodeQR', qr_brightness: ThreadsafeCounter, renderer: Renderer, tips_start_time: ThreadsafeCounter): super().__init__() self.qr_encoder = qr_encoder diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index d1aaf3e3f..ac2230b83 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -2,17 +2,13 @@ import time from dataclasses import dataclass -from typing import List, Tuple +from typing import List from PIL import Image, ImageDraw, ImageFilter from seedsigner.gui.renderer import Renderer from seedsigner.helpers.qr import QR -from seedsigner.models.qr_type import QRType from seedsigner.models.threads import BaseThread, ThreadsafeCounter -from seedsigner.models.seed import Seed -from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition - from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen, WarningEdgesMixin from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, IconButton, IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, diff --git a/src/seedsigner/hardware/buttons.py b/src/seedsigner/hardware/buttons.py index ca2011aa5..8d42105d0 100644 --- a/src/seedsigner/hardware/buttons.py +++ b/src/seedsigner/hardware/buttons.py @@ -72,7 +72,7 @@ def wait_for(self, keys=[], check_release=True, release_keys=[]) -> int: while True: cur_time = int(time.time() * 1000) - if cur_time - self.last_input_time > controller.screensaver_activation_ms and not controller.screensaver.is_running: + if cur_time - self.last_input_time > controller.screensaver_activation_ms and not controller.is_screensaver_running: # Start the screensaver. Will block execution until input detected. controller.start_screensaver() diff --git a/src/seedsigner/hardware/camera.py b/src/seedsigner/hardware/camera.py index b07ed3aae..32dc182d8 100644 --- a/src/seedsigner/hardware/camera.py +++ b/src/seedsigner/hardware/camera.py @@ -2,9 +2,9 @@ from picamera import PiCamera from PIL import Image -from seedsigner.models import Singleton from seedsigner.hardware.pivideostream import PiVideoStream -from seedsigner.models.settings import SettingsConstants +from seedsigner.models.settings import Settings, SettingsConstants +from seedsigner.models.singleton import Singleton @@ -16,7 +16,6 @@ class Camera(Singleton): @classmethod def get_instance(cls): # This is the only way to access the one and only Controller - from seedsigner.models import Settings if cls._instance is None: cls._instance = cls.__new__(cls) cls._instance._camera_rotation = int(Settings.get_instance().get_value(SettingsConstants.SETTING__CAMERA_ROTATION)) diff --git a/src/seedsigner/helpers/mnemonic_generation.py b/src/seedsigner/helpers/mnemonic_generation.py index dab63a480..a021a8346 100644 --- a/src/seedsigner/helpers/mnemonic_generation.py +++ b/src/seedsigner/helpers/mnemonic_generation.py @@ -5,7 +5,6 @@ from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes from typing import List -from seedsigner.models.seed import Seed def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]: @@ -15,6 +14,7 @@ def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str] If 11- or 23-words are provided, append word `0000` to end of list as temp final word. """ + from seedsigner.models.seed import Seed if len(mnemonic) in [11, 23]: mnemonic.append(Seed.get_wordlist(wordlist_language_code)[0]) diff --git a/src/seedsigner/models/__init__.py b/src/seedsigner/models/__init__.py index c21cf9cbf..e69de29bb 100644 --- a/src/seedsigner/models/__init__.py +++ b/src/seedsigner/models/__init__.py @@ -1,10 +0,0 @@ -# Must import crucial base class first! -from .singleton import Singleton, ConfigurableSingleton - -from .seed import * -from .qr_type import * -from .decode_qr import * -from .encode_qr import * -from .psbt_parser import * -from .seed_storage import * -from .settings import * diff --git a/src/seedsigner/models/decode_qr.py b/src/seedsigner/models/decode_qr.py index 9c1623b2d..f5e303020 100644 --- a/src/seedsigner/models/decode_qr.py +++ b/src/seedsigner/models/decode_qr.py @@ -13,7 +13,8 @@ from urtypes.bytes import Bytes from seedsigner.helpers.ur2.ur_decoder import URDecoder -from seedsigner.models import QRType, Seed +from seedsigner.models.qr_type import QRType +from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants diff --git a/src/seedsigner/models/encode_qr.py b/src/seedsigner/models/encode_qr.py index ece3cb42c..0e6493d57 100644 --- a/src/seedsigner/models/encode_qr.py +++ b/src/seedsigner/models/encode_qr.py @@ -11,12 +11,13 @@ from seedsigner.helpers.ur2.ur_encoder import UREncoder from seedsigner.helpers.ur2.ur import UR from seedsigner.helpers.qr import QR -from seedsigner.models import Seed, QRType +from seedsigner.models.qr_type import QRType +from seedsigner.models.seed import Seed +from seedsigner.models.settings import SettingsConstants from urtypes.crypto import PSBT as UR_PSBT from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP -from seedsigner.models.settings import SettingsConstants diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index 3a1374bed..f431a3c6d 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -1,12 +1,12 @@ from binascii import hexlify -from embit import psbt, script, ec, bip32, bip39 +from embit import psbt, script, ec, bip32 from embit.descriptor import Descriptor from embit.networks import NETWORKS from embit.psbt import PSBT from io import BytesIO from typing import List -from seedsigner.models import Seed +from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 7a848ecd3..51eb85553 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -6,7 +6,6 @@ from typing import List from seedsigner.models.settings import SettingsConstants -from seedsigner.helpers import embit_utils class InvalidSeedException(Exception): @@ -108,6 +107,8 @@ def get_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str: def get_xpub(self, wallet_path: str = '/', network: str = SettingsConstants.MAINNET): + # Import here to avoid slow startup times; takes 1.35s to import the first time + from seedsigner.helpers import embit_utils return embit_utils.get_xpub(seed_bytes=self.seed_bytes, derivation_path=wallet_path, embit_network=SettingsConstants.map_network_to_embit(network)) diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py index d2881e32e..dcc30a7a9 100644 --- a/src/seedsigner/models/seed_storage.py +++ b/src/seedsigner/models/seed_storage.py @@ -1,6 +1,5 @@ from typing import List -from seedsigner.models import Seed -from seedsigner.models.seed import InvalidSeedException +from seedsigner.models.seed import Seed, InvalidSeedException from seedsigner.models.settings_definition import SettingsConstants diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py index 6898474b1..eaf9c2470 100644 --- a/src/seedsigner/models/settings.py +++ b/src/seedsigner/models/settings.py @@ -5,8 +5,7 @@ from typing import List from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition -from .singleton import Singleton - +from seedsigner.models.singleton import Singleton class InvalidSettingsQRData(Exception): diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 379e31273..481390651 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -1,6 +1,3 @@ -import logging -from typing import List - from embit.psbt import PSBT from embit import script from embit.networks import NETWORKS @@ -13,10 +10,7 @@ from seedsigner.models.settings import SettingsConstants from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen) - -from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination - -logger = logging.getLogger(__name__) +from seedsigner.views.view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination @@ -24,8 +18,8 @@ class PSBTSelectSeedView(View): SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [] - + + def run(self): # Note: we can't just autoroute to the PSBT Overview because we might have a # multisig where we want to sign with more than one key on this device. @@ -34,6 +28,7 @@ def run(self): raise Exception("No PSBT currently loaded") seeds = self.controller.storage.seeds + button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) @@ -41,11 +36,11 @@ def run(self): # Doesn't look like this seed can sign the current PSBT button_str += " (?)" - self.button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT)) + button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT)) - self.button_data.append(self.SCAN_SEED) - self.button_data.append(self.TYPE_12WORD) - self.button_data.append(self.TYPE_24WORD) + button_data.append(self.SCAN_SEED) + button_data.append(self.TYPE_12WORD) + button_data.append(self.TYPE_24WORD) if self.controller.psbt_seed: if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): @@ -56,7 +51,7 @@ def run(self): ButtonListScreen, title="Select Signer", is_button_text_centered=False, - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: @@ -70,13 +65,13 @@ def run(self): # The remaining flows are a sub-flow; resume PSBT flow once the seed is loaded. self.controller.resume_main_flow = Controller.FLOW__PSBT - if self.button_data[selected_menu_num] == self.SCAN_SEED: - from seedsigner.views.scan_views import ScanView - return Destination(ScanView) + if button_data[selected_menu_num] == self.SCAN_SEED: + from seedsigner.views.scan_views import ScanSeedQRView + return Destination(ScanSeedQRView) - elif self.button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if self.button_data[selected_menu_num] == self.TYPE_12WORD: + if button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) else: self.controller.storage.init_pending_mnemonic(num_words=24) @@ -241,9 +236,6 @@ class PSBTAddressDetailsView(View): """ Shows the recipient's address and amount they will receive """ - NEXT = "Next" - button_data = [] - def __init__(self, address_num): super().__init__() self.address_num = address_num @@ -261,15 +253,14 @@ def run(self): title += f" (#{self.address_num + 1})" if self.address_num < psbt_parser.num_destinations - 1: - self.NEXT = "Next Recipient" + button_data = ["Next Recipient"] else: - self.NEXT = "Next" - self.button_data.append(self.NEXT) + button_data = ["Next"] selected_menu_num = self.run_screen( PSBTAddressDetailsScreen, title=title, - button_data=self.button_data, + button_data=button_data, address=psbt_parser.destination_addresses[self.address_num], amount=psbt_parser.destination_amounts[self.address_num], ) @@ -277,7 +268,7 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if self.button_data[selected_menu_num] == self.NEXT: + else: if self.address_num < len(psbt_parser.destination_addresses) - 1: # Show the next receive addr return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1}) @@ -295,8 +286,8 @@ def run(self): class PSBTChangeDetailsView(View): NEXT = "Next" VERIFY_MULTISIG = "Verify Multisig Change" - button_data = [NEXT] - + + def __init__(self, change_address_num): super().__init__() self.change_address_num = change_address_num @@ -350,11 +341,11 @@ def run(self): # if the known-good multisig descriptor is already onboard: if self.controller.multisig_wallet_descriptor: is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num) - self.button_data = [self.NEXT] + button_data = [self.NEXT] else: # Have the Screen offer to load in the multisig descriptor. - self.button_data = [self.VERIFY_MULTISIG, self.NEXT] + button_data = [self.VERIFY_MULTISIG, self.NEXT] else: # Single sig @@ -397,7 +388,7 @@ def run(self): if change_data["address"] == calc_address: is_change_addr_verified = True - self.button_data = [self.NEXT] + button_data = [self.NEXT] finally: loading_screen.stop() @@ -408,7 +399,7 @@ def run(self): selected_menu_num = self.run_screen( PSBTChangeDetailsScreen, title=title, - button_data=self.button_data, + button_data=button_data, address=change_data.get("address"), amount=change_data.get("amount"), is_multisig=psbt_parser.is_multisig, @@ -422,14 +413,14 @@ def run(self): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == self.NEXT: + elif button_data[selected_menu_num] == self.NEXT: if self.change_address_num < psbt_parser.num_change_outputs - 1: return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1}) else: # There's no more change to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) - elif self.button_data[selected_menu_num] == self.VERIFY_MULTISIG: + elif button_data[selected_menu_num] == self.VERIFY_MULTISIG: from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView self.controller.resume_main_flow = Controller.FLOW__PSBT return Destination(LoadMultisigWalletDescriptorView) @@ -469,7 +460,7 @@ class PSBTFinalizeView(View): """ """ APPROVE_PSBT = "Approve PSBT" - button_data = [APPROVE_PSBT] + def run(self): psbt_parser: PSBTParser = self.controller.psbt_parser @@ -478,13 +469,16 @@ def run(self): if not psbt_parser: # Should not be able to get here return Destination(MainMenuView) - + selected_menu_num = self.run_screen( PSBTFinalizeScreen, - button_data=self.button_data + button_data=[self.APPROVE_PSBT] ) - if self.button_data[selected_menu_num] == self.APPROVE_PSBT: + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) + + else: # Sign PSBT sig_cnt = PSBTParser.sig_count(psbt) psbt.sign_with(psbt_parser.root) @@ -500,9 +494,6 @@ def run(self): self.controller.psbt = trimmed_psbt return Destination(PSBTSignedQRDisplayView) - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - class PSBTSignedQRDisplayView(View): diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 502512674..2774a3de5 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -2,30 +2,66 @@ from embit.descriptor import Descriptor -from seedsigner.gui.screens import scan_screens -from seedsigner.models import DecodeQR, Seed +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.models.decode_qr import DecodeQR +from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants from seedsigner.views.settings_views import SettingsIngestSettingsQRView -from seedsigner.views.view import MainMenuView, NotYetImplementedView, View, Destination +from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, View, Destination class ScanView(View): + """ + The catch-all generic scanning View that will accept any of our supported QR + formats and will route to the most sensible next step. + + Can also be used as a base class for more specific scanning flows with + dedicated errors when an unexpected QR type is scanned (e.g. Scan PSBT was + selected but a SeedQR was scanned). + """ + instructions_text = "Scan a QR code" + invalid_qr_type_message = "QRCode not recognized or not yet supported." + + def __init__(self): super().__init__() - - # Set up the QR decoder here so we can inject data into it in the test suite's - # `before_run`. + # Define the decoder here to make it available to child classes' is_valid_qr_type + # checks and so we can inject data into it in the test suite's `before_run()`. self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) - self.decoder = DecodeQR(wordlist_language_code=self.wordlist_language_code) + self.decoder: DecodeQR = DecodeQR(wordlist_language_code=self.wordlist_language_code) + + + @property + def is_valid_qr_type(self): + return True def run(self): + from seedsigner.gui.screens.scan_screens import ScanScreen + # Start the live preview and background QR reading - self.run_screen(scan_screens.ScanScreen, decoder=self.decoder) + self.run_screen( + ScanScreen, + instructions_text=self.instructions_text, + decoder=self.decoder + ) # Handle the results if self.decoder.is_complete: + if not self.is_valid_qr_type: + # We recognized the QR type but it was not the type expected for the + # current flow. + # Report QR types in more human-readable text (e.g. QRType + # `seed__compactseedqr` as "seed: compactseedqr"). + return Destination(ErrorView, view_args=dict( + title="Error", + status_headline="Wrong QR Type", + text=self.invalid_qr_type_message + f""", received "{self.decoder.qr_type.replace("__", ": ").replace("_", " ")}\" format""", + button_text="Back", + next_destination=Destination(BackStackView, skip_current_view=True), + )) + if self.decoder.is_seed: seed_mnemonic = self.decoder.get_seed_phrase() @@ -103,6 +139,52 @@ def run(self): return Destination(NotYetImplementedView) elif self.decoder.is_invalid: - raise Exception("QRCode not recognized or not yet supported.") + return Destination(ErrorView, view_args=dict( + title="Error", + status_headline="Unknown QR Type", + text="QRCode is invalid or is a data format not yet supported.", + button_text="Back", + next_destination=Destination(BackStackView, skip_current_view=True), + )) return Destination(MainMenuView) + + + +class ScanPSBTView(ScanView): + instructions_text = "Scan PSBT" + invalid_qr_type_message = "Expected a PSBT" + + @property + def is_valid_qr_type(self): + return self.decoder.is_psbt + + + +class ScanSeedQRView(ScanView): + instructions_text = "Scan SeedQR" + invalid_qr_type_message = f"Expected a SeedQR" + + @property + def is_valid_qr_type(self): + return self.decoder.is_seed + + + +class ScanWalletDescriptorView(ScanView): + instructions_text = "Scan descriptor" + invalid_qr_type_message = "Expected a wallet descriptor QR" + + @property + def is_valid_qr_type(self): + return self.decoder.is_wallet_descriptor + + + +class ScanAddressView(ScanView): + instructions_text = "Scan address QR" + invalid_qr_type_message = "Expected an address QR" + + @property + def is_valid_qr_type(self): + return self.decoder.is_address diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index cc11e4a9f..0570b143d 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -14,6 +14,7 @@ from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) from seedsigner.gui.screens.screen import LargeIconStatusScreen, QRDisplayScreen +from seedsigner.helpers import embit_utils from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser @@ -22,10 +23,7 @@ from seedsigner.models.settings import Settings, SettingsConstants from seedsigner.models.settings_definition import SettingsDefinition from seedsigner.models.threads import BaseThread, ThreadsafeCounter -from seedsigner.views.psbt_views import PSBTChangeDetailsView -from seedsigner.views.scan_views import ScanView - -from .view import NotYetImplementedView, View, Destination, BackStackView, MainMenuView +from seedsigner.views.view import NotYetImplementedView, View, Destination, BackStackView, MainMenuView @@ -97,8 +95,8 @@ def run(self): return Destination(BackStackView) if button_data[selected_menu_num] == self.SEED_QR: - from .scan_views import ScanView - return Destination(ScanView) + from .scan_views import ScanSeedQRView + return Destination(ScanSeedQRView) elif button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) @@ -410,9 +408,9 @@ def run(self): return Destination(MainMenuView) if button_data[selected_menu_num] == self.SCAN_PSBT: - from seedsigner.views.scan_views import ScanView + from seedsigner.views.scan_views import ScanPSBTView self.controller.psbt_seed = self.controller.get_seed(self.seed_num) - return Destination(ScanView) + return Destination(ScanPSBTView) elif button_data[selected_menu_num] == self.VERIFY_ADDRESS: return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num)) @@ -1567,8 +1565,8 @@ def run(self): self.controller.resume_main_flow = Controller.FLOW__VERIFY_SINGLESIG_ADDR if button_data[selected_menu_num] == SCAN_SEED: - from seedsigner.views.scan_views import ScanView - return Destination(ScanView) + from seedsigner.views.scan_views import ScanSeedQRView + return Destination(ScanSeedQRView) elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView @@ -1793,7 +1791,8 @@ def run(self): ).display() if button_data[selected_menu_num] == SCAN: - return Destination(ScanView) + from seedsigner.views.scan_views import ScanWalletDescriptorView + return Destination(ScanWalletDescriptorView) elif button_data[selected_menu_num] == CANCEL: if self.controller.resume_main_flow == Controller.FLOW__PSBT: @@ -1841,6 +1840,7 @@ def run(self): elif button_data[selected_menu_num] == RETURN: # Jump straight back to PSBT change verification + from seedsigner.views.psbt_views import PSBTChangeDetailsView self.controller.resume_main_flow = None return Destination(PSBTChangeDetailsView, view_args=dict(change_address_num=0)) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 637c7d70f..8e9b5ef9b 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -27,9 +27,10 @@ class ToolsMenuView(View): DICE = ("New seed", FontAwesomeIconConstants.DICE) KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) EXPLORER = "Address Explorer" + ADDRESS = "Verify address" def run(self): - button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER] + button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER, self.ADDRESS] selected_menu_num = self.run_screen( ButtonListScreen, @@ -53,6 +54,11 @@ def run(self): elif button_data[selected_menu_num] == self.EXPLORER: return Destination(ToolsAddressExplorerSelectSourceView) + elif button_data[selected_menu_num] == self.ADDRESS: + from seedsigner.views.scan_views import ScanAddressView + return Destination(ScanAddressView) + + """**************************************************************************** Image entropy Views @@ -465,9 +471,13 @@ def run(self): ) ) - elif button_data[selected_menu_num] in [self.SCAN_SEED, self.SCAN_DESCRIPTOR]: - from seedsigner.views.scan_views import ScanView - return Destination(ScanView) + elif button_data[selected_menu_num] == self.SCAN_SEED: + from seedsigner.views.scan_views import ScanSeedQRView + return Destination(ScanSeedQRView) + + elif button_data[selected_menu_num] == self.SCAN_DESCRIPTOR: + from seedsigner.views.scan_views import ScanWalletDescriptorView + return Destination(ScanWalletDescriptorView) elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index dc1b95ce7..3bf92beea 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -4,8 +4,8 @@ from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen +from seedsigner.models.settings import Settings from seedsigner.models.threads import BaseThread -from seedsigner.models import Settings class BackStackView: @@ -46,7 +46,6 @@ def __init__(self) -> None: # Import here to avoid circular imports from seedsigner.controller import Controller from seedsigner.gui import Renderer - from seedsigner.models import Settings self.controller: Controller = Controller.get_instance() self.settings = Settings.get_instance() @@ -261,6 +260,29 @@ def run(self): +@dataclass +class ErrorView(View): + """ + """ + title: str = "Error" + status_headline: str = None + text: str = None + button_text: str = None + next_destination: Destination = Destination(MainMenuView, clear_history=True) + + def run(self): + self.run_screen( + WarningScreen, + title=self.title, + status_headline=self.status_headline, + text=self.text, + button_data=[self.button_text], + ) + + return self.next_destination + + + class UnhandledExceptionView(View): def __init__(self, error: list[str]): self.error = error diff --git a/tests/base.py b/tests/base.py index 72bd55f3f..708d64f71 100644 --- a/tests/base.py +++ b/tests/base.py @@ -14,7 +14,7 @@ from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON -from seedsigner.models import Settings +from seedsigner.models.settings import Settings from seedsigner.views.view import Destination, MainMenuView, View diff --git a/tests/test_bip85.py b/tests/test_bip85.py index c9f67a31e..5ade39175 100644 --- a/tests/test_bip85.py +++ b/tests/test_bip85.py @@ -1,6 +1,6 @@ import pytest from mock import MagicMock -from seedsigner.models import Seed +from seedsigner.models.seed import Seed from embit import bip39 from seedsigner.models.settings import SettingsConstants diff --git a/tests/test_decodepsbtqr.py b/tests/test_decodepsbtqr.py index ef9baa4a2..9b7d6981d 100644 --- a/tests/test_decodepsbtqr.py +++ b/tests/test_decodepsbtqr.py @@ -1,7 +1,7 @@ -import pytest -from mock import MagicMock -from seedsigner.models import Seed, DecodeQR, DecodeQRStatus, QRType, PSBTParser -from embit import psbt, bip39 +from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus +from seedsigner.models.psbt_parser import PSBTParser +from seedsigner.models.qr_type import QRType +from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants diff --git a/tests/test_encodepsbtqr.py b/tests/test_encodepsbtqr.py index 0dc5c0f4a..75ed796fb 100644 --- a/tests/test_encodepsbtqr.py +++ b/tests/test_encodepsbtqr.py @@ -1,6 +1,5 @@ -import pytest -from mock import MagicMock -from seedsigner.models import EncodeQR, QRType +from seedsigner.models.encode_qr import EncodeQR +from seedsigner.models.qr_type import QRType from embit import psbt from binascii import a2b_base64 diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index f50491a13..be06b8e1c 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -41,12 +41,12 @@ def load_seed_into_decoder(view: scan_views.ScanView): FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), - FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), + FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), FlowStep(psbt_views.PSBTOverviewView), FlowStep(psbt_views.PSBTMathView), - FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=psbt_views.PSBTAddressDetailsView.NEXT), + FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0), FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 8c6031703..1fb394a25 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -3,9 +3,8 @@ # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.models import SettingsDefinition from seedsigner.models.settings import Settings -from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.models.settings_definition import SettingsDefinition, SettingsConstants from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON from seedsigner.views.view import MainMenuView from seedsigner.views import settings_views diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index ba5abe8c4..ad63bd449 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -4,7 +4,7 @@ from seedsigner.controller import Controller from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition -from seedsigner.views.view import MainMenuView +from seedsigner.views.view import ErrorView, MainMenuView from seedsigner.views import scan_views, seed_views, tools_views @@ -48,7 +48,7 @@ def load_seed_into_decoder(view: scan_views.ScanView): FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER), FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), - FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR + FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), # simulate read SeedQR FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), FlowStep(seed_views.SeedExportXpubScriptTypeView), @@ -70,3 +70,20 @@ def load_seed_into_decoder(view: scan_views.ScanView): FlowStep(seed_views.SeedExportXpubScriptTypeView), ] ) + + + def test_addressexplorer_scan_wrong_qrtype(self): + """ + Scanning the wrong type of QR code when a SeedQR is expected should route to ErrorView + """ + def load_wrong_data_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") + + # Finalize the new seed w/out passphrase + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), + FlowStep(scan_views.ScanSeedQRView, before_run=load_wrong_data_into_decoder), # simulate scanning the wrong QR type + FlowStep(ErrorView), + ]) diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py index e0fb5a843..4de00533f 100644 --- a/tests/test_psbt_parser.py +++ b/tests/test_psbt_parser.py @@ -1,6 +1,5 @@ -import pytest -from mock import MagicMock -from seedsigner.models import PSBTParser, Seed +from seedsigner.models.psbt_parser import PSBTParser +from seedsigner.models.seed import Seed from embit import psbt from binascii import a2b_base64 from seedsigner.models.settings_definition import SettingsConstants diff --git a/tests/test_seed.py b/tests/test_seed.py index cbfa89642..a7e082eee 100644 --- a/tests/test_seed.py +++ b/tests/test_seed.py @@ -1,7 +1,4 @@ -import pytest -from mock import MagicMock -from seedsigner.models import Seed -from embit import bip39 +from seedsigner.models.seed import Seed from seedsigner.models.settings import SettingsConstants