diff --git a/README.md b/README.md index 831ef839..b3592239 100644 --- a/README.md +++ b/README.md @@ -176,15 +176,19 @@ You can use SeedSigner to export your seed to a hand-transcribed SeedQR format t Standard SeedQR templates: -* [12-word SeedQR template (25x25)](docs/seed_qr/printable_templates/12words_seedqr_template.pdf) -* [24-word SeedQR template (29x29)](docs/seed_qr/printable_templates/24words_seedqr_template.pdf) -* [Baseball card template: 24-word SeedQR (29x29)](docs/seed_qr/printable_templates/Seed_QR_Card.pdf) +* [12-word SeedQR template dots (25x25)](docs/seed_qr/printable_templates/dots_25x25.pdf) +* [24-word SeedQR template dots (29x29)](docs/seed_qr/printable_templates/dots_29x29.pdf) +* [12-word SeedQR template grid (25x25)](docs/seed_qr/printable_templates/grid_25x25.pdf) +* [24-word SeedQR template grid (29x29)](docs/seed_qr/printable_templates/grid_29x29.pdf) +* [Baseball card template: 24-word SeedQR (29x29)](docs/seed_qr/printable_templates/trading_card_29x29_w24words.pdf) CompactSeedQR templates: -* [12-word CompactSeedQR template (21x21)](docs/seed_qr/printable_templates/compact_seedqr/12words_compactseedqr_template.pdf) -* [24-word CompactSeedQR template (25x25)](docs/seed_qr/printable_templates/compact_seedqr/24words_compactseedqr_template.pdf) - -_note: CompactSeedQR is an advanced feature that can be enabled in Settings_ +* [12-word CompactSeedQR template dots (21x21)](docs/seed_qr/printable_templates/dots_21x21.pdf) +* [24-word CompactSeedQR template dots (25x25)](docs/seed_qr/printable_templates/dots_25x25.pdf) +* [12-word CompactSeedQR template grid (21x21)](docs/seed_qr/printable_templates/grid_21x21.pdf) +* [24-word CompactSeedQR template grid (25x25)](docs/seed_qr/printable_templates/grid_25x25.pdf) +* [Baseball card template: 12-word Compact SeedQR (21x21)](docs/seed_qr/printable_templates/trading_card_21x21_w12words.pdf) +* [Baseball card template: 24-word Compact SeedQR (25x25)](docs/seed_qr/printable_templates/trading_card_25x25_w24words.pdf) --------------- diff --git a/docs/recovery.md b/docs/recovery.md index 5725dc75..7e8569ff 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -23,7 +23,7 @@ Derivation paths for standard script types for mainnet: - Script Type: P2WSH in P2SH - Public Key Encoding: 0x0295b43f - Ypub -Custom derivation paths are also optional when generating an xpub from SeedSigner. The Public Key Encodings is detected based on the derivation path configured. Embit bitcoin library does the detection and is documented [here](https://github.com/diybitcoinhardware/embit/blob/master/docs/api/bip32.md#detect_version). For a video explination of these standards see a presentation by Stepan of Embit/Specter on this topic: https://youtube.com/watch?v=JCaC5DG2HTM +Custom derivation paths are also optional when generating an xpub from SeedSigner. The Public Key Encodings are detected based on the derivation path configured. The `embit` bitcoin library does this detection and is documented [here](https://github.com/diybitcoinhardware/embit/blob/master/docs/api/bip32.md#detect_version). For a video explanation of these standards see a presentation by Stepan of `embit` on this topic: https://youtube.com/watch?v=JCaC5DG2HTM Changing the network settings from main to test in SeedSigner will change the public key encoding and derivation path following [slip-0132](https://github.com/satoshilabs/slips/blob/master/slip-0132.md) standards. @@ -32,4 +32,4 @@ Related Standards: - [bip-0044](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) - [bip-0048](https://github.com/bitcoin/bips/blob/master/bip-0048.mediawiki) - [bip-0049](https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki) -- [bip-0084](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) \ No newline at end of file +- [bip-0084](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) diff --git a/requirements.txt b/requirements.txt index 42e2527f..bff5bc24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Babel==2.10.1 -embit==0.6.1 +embit==0.7.0 numpy==1.21.1 picamera==1.13 Pillow==9.1.0 diff --git a/setup.py b/setup.py index cc8d8fb3..82dcfb5d 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh: setuptools.setup( name="seedsigner", - version="0.5.0", + version="0.6.0", author="SeedSigner", author_email="author@example.com", description="Build an offline, airgapped Bitcoin signing device for less than $50!", @@ -23,4 +23,4 @@ setuptools.setup( package_dir={"": "src"}, packages=setuptools.find_packages(where="src"), python_requires=">=3.6", -) \ No newline at end of file +) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index da0b04b2..1b11d419 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -51,7 +51,7 @@ class Controller(Singleton): rather than at the top in order avoid circular imports. """ - VERSION = "0.5.1" + VERSION = "0.6.0" # Declare class member vars with type hints to enable richer IDE support throughout # the code. @@ -233,12 +233,15 @@ class Controller(Singleton): # Home always wipes the back_stack self.clear_back_stack() - # Clear other temp vars + # Home always wipes the back_stack/state of temp vars self.resume_main_flow = None self.multisig_wallet_descriptor = None self.unverified_address = None self.address_explorer_data = None - + self.psbt = None + self.psbt_parser = None + self.psbt_seed = None + print(f"back_stack: {self.back_stack}") try: diff --git a/src/seedsigner/gui/screens/main_menu_screens.py b/src/seedsigner/gui/screens/main_menu_screens.py index 8ae9aa23..85cdb71e 100644 --- a/src/seedsigner/gui/screens/main_menu_screens.py +++ b/src/seedsigner/gui/screens/main_menu_screens.py @@ -15,33 +15,3 @@ class MainMenuScreen(LargeButtonScreen): self.show_power_button=True super().__post_init__() - - - -@dataclass -class ResetScreen(BaseTopNavScreen): - def __post_init__(self): - self.title = _("Restarting") - self.show_back_button = False - super().__post_init__() - - self.components.append(TextArea( - text=_("SeedSigner is restarting.\n\nAll in-memory data will be wiped."), - screen_y=self.top_nav.height, - height=self.canvas_height - self.top_nav.height, - )) - - - -@dataclass -class PowerOffScreen(BaseTopNavScreen): - def __post_init__(self): - self.title = _("Powering Off") - self.show_back_button = False - super().__post_init__() - - self.components.append(TextArea( - text=_("Please wait about 30 seconds before disconnecting power."), - screen_y=self.top_nav.height, - height=self.canvas_height - self.top_nav.height, - )) diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py index 6456cd50..dcb57f9a 100644 --- a/src/seedsigner/gui/screens/screen.py +++ b/src/seedsigner/gui/screens/screen.py @@ -272,6 +272,9 @@ class ButtonListScreen(BaseTopNavScreen): Button_cls = Button checked_buttons: List[int] = None + # Enables returning w/buttons rendered at the same place + scroll_y_initial_offset: int = None + def __post_init__(self): if not self.button_font_name: @@ -327,6 +330,7 @@ class ButtonListScreen(BaseTopNavScreen): right_icon_name=right_icon_name, screen_x=GUIConstants.EDGE_PADDING, screen_y=button_list_y + i * (button_height + GUIConstants.LIST_ITEM_PADDING), + scroll_y=self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0, width=self.canvas_width - (2 * GUIConstants.EDGE_PADDING), height=button_height, is_text_centered=self.is_button_text_centered, @@ -342,7 +346,7 @@ class ButtonListScreen(BaseTopNavScreen): if self.has_scroll_arrows: self.arrow_half_width = 10 - self.cur_scroll_y = 0 + self.cur_scroll_y = self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0 self.up_arrow_img = Image.new("RGBA", size=(2 * self.arrow_half_width, 8), color="black") self.up_arrow_img_y = self.top_nav.height - 12 arrow_draw = ImageDraw.Draw(self.up_arrow_img) @@ -358,10 +362,6 @@ class ButtonListScreen(BaseTopNavScreen): cur_selected_button = self.buttons[self.selected_button] cur_selected_button.is_selected = True - if self.has_scroll_arrows: - frame_scroll = self.buttons[0].screen_y - cur_selected_button.screen_y - for button in self.buttons: - button.scroll_y -= frame_scroll def _render(self): @@ -902,6 +902,51 @@ class DireWarningScreen(WarningScreen): +@dataclass +class ResetScreen(BaseTopNavScreen): + def __post_init__(self): + self.title = _("Restarting") + self.show_back_button = False + super().__post_init__() + + self.components.append(TextArea( + text=_("SeedSigner is restarting.\n\nAll in-memory data will be wiped."), + screen_y=self.top_nav.height, + height=self.canvas_height - self.top_nav.height, + )) + + + +@dataclass +class PowerOffScreen(BaseTopNavScreen): + def __post_init__(self): + self.title = _("Powering Off") + self.show_back_button = False + super().__post_init__() + + self.components.append(TextArea( + text=_("Please wait about 30 seconds before disconnecting power."), + screen_y=self.top_nav.height, + height=self.canvas_height - self.top_nav.height, + )) + + + +@dataclass +class PowerOffNotRequiredScreen(BaseTopNavScreen): + def __post_init__(self): + self.title = "Just Unplug It" + self.show_back_button = True + super().__post_init__() + + self.components.append(TextArea( + text="It is safe to disconnect power at any time.", + screen_y=self.top_nav.height, + height=self.canvas_height - self.top_nav.height, + )) + + + @dataclass class KeyboardScreen(BaseTopNavScreen): """ diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index 15c425c6..f673f2e3 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -642,7 +642,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): def __post_init__(self): - self.title = _("Add Passphrase") + self.title = _("BIP-39 Passphrase") super().__post_init__() keys_lower = "abcdefghijklmnopqrstuvwxyz" @@ -852,9 +852,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen): self.hw_button3.is_selected = True self.hw_button3.render() self.renderer.show_image() - - if len(self.passphrase) > 0: - return self.passphrase.strip() + return self.passphrase elif input == HardwareButtonsConstants.KEY_PRESS and self.top_nav.is_selected: # Back button clicked @@ -1062,9 +1060,11 @@ class SeedReviewPassphraseScreen(ButtonListScreen): screen_y=screen_y, )) - available_height = fingerprint_change_label.screen_y - self.top_nav.height - max_font_size = 28 - min_font_size = 16 + if self.passphrase != self.passphrase.strip() or " " in self.passphrase: + self.passphrase = self.passphrase.replace(" ", "\u2589") + available_height = self.components[-1].screen_y - self.top_nav.height + GUIConstants.COMPONENT_PADDING + max_font_size = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8 + min_font_size = GUIConstants.TOP_NAV_TITLE_FONT_SIZE - 4 font_size = max_font_size max_lines = 3 passphrase = [self.passphrase] diff --git a/src/seedsigner/models/seed.py b/src/seedsigner/models/seed.py index 5740c686..7f3e031a 100644 --- a/src/seedsigner/models/seed.py +++ b/src/seedsigner/models/seed.py @@ -1,16 +1,13 @@ import unicodedata from binascii import hexlify -from embit import bip39, bip32 +from embit import bip39, bip32, bip85 from embit.networks import NETWORKS from typing import List from seedsigner.models.settings import SettingsConstants from seedsigner.helpers import embit_utils -import hashlib -import hmac - class InvalidSeedException(Exception): pass @@ -37,7 +34,7 @@ class Seed: @staticmethod def get_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]: - # TODO: Support other bip-39 wordlist languages! + # TODO: Support other BIP-39 wordlist languages! if wordlist_language_code == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH: return bip39.WORDLIST else: @@ -106,7 +103,7 @@ class Seed: def set_wordlist_language_code(self, language_code: str): - # TODO: Support other bip-39 wordlist languages! + # TODO: Support other BIP-39 wordlist languages! raise Exception("Not yet implemented!") @@ -119,32 +116,12 @@ class Seed: return embit_utils.get_xpub(seed_bytes=self.seed_bytes, derivation_path=wallet_path, embit_network=SettingsConstants.map_network_to_embit(network)) - # Derives a BIP85 mnemonic (seed word) from the master seed words using embit functions def get_bip85_child_mnemonic(self, bip85_index: int, bip85_num_words: int, network: str = SettingsConstants.MAINNET): - - # Calculate the master bip32 root key from the parents bip39 seed_bytes (the mnemonic entropy) + """Derives the seed's nth BIP-85 child mnemonic""" root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"]) - # TODO: Support other bip-39 wordlist languages! - # As per the BIP85 spec, 39 is the application number used for bip39 mnemonic, - # we use the selected word count and the index to form the full path. - path = "m/83696968'/39'/0'/{bip85_num_words}'/{bip85_index}'".format( - bip85_num_words=bip85_num_words, - bip85_index=bip85_index) - - # Derive the child xprv (HDKey) using the path format defined above - xprv = root.derive(path) - - # The xprv.secret plus the BIP85 key is hashed together using hmac sha512. - entropy = hmac.new(key=b'bip-entropy-from-k', msg=xprv.secret, digestmod=hashlib.sha512).digest() - - # Calculate number of bytes to retain for the entropy - # 24 words the width is 32bytes (256bits) - # 12 words the width is 16bytes (128bits) - width = round(bip85_num_words / 12 * 16) - - # Return the derived BIP85 child mnemonic using the truncated derived entropy - return bip39.mnemonic_from_bytes(entropy[:width]) + # TODO: Support other BIP-39 wordlist languages! + return bip85.derive_mnemonic(root, bip85_num_words, bip85_index) ### override operators diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index ddeeda8d..646b7aa2 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -75,10 +75,10 @@ class SettingsConstants: BTC_DENOMINATION__THRESHOLD = "thr" BTC_DENOMINATION__BTCSATSHYBRID = "hyb" ALL_BTC_DENOMINATIONS = [ - (BTC_DENOMINATION__BTC, _("Btc-only")), - (BTC_DENOMINATION__SATS, _("Sats-only")), - (BTC_DENOMINATION__THRESHOLD, _("Threshold at 0.01")), - (BTC_DENOMINATION__BTCSATSHYBRID, _("Btc | Sats hybrid")), + (BTC_DENOMINATION__BTC, "BTC"), + (BTC_DENOMINATION__SATS, "sats"), + (BTC_DENOMINATION__THRESHOLD, "Threshold at 0.01"), + (BTC_DENOMINATION__BTCSATSHYBRID, "BTC | sats hybrid"), ] CAMERA_ROTATION__0 = 0 @@ -459,7 +459,7 @@ class SettingsDefinition: attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, display_name=_("Compact SeedQR"), visibility=SettingsConstants.VISIBILITY__ADVANCED, - default_value=SettingsConstants.OPTION__DISABLED), + default_value=SettingsConstants.OPTION__ENABLED), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__BIP85_CHILD_SEEDS, @@ -510,10 +510,10 @@ class SettingsDefinition: @classmethod - def get_settings_entries(cls, visibiilty: str = SettingsConstants.VISIBILITY__GENERAL) -> List[SettingsEntry]: + def get_settings_entries(cls, visibility: str = SettingsConstants.VISIBILITY__GENERAL) -> List[SettingsEntry]: entries = [] for entry in cls.settings_entries: - if entry.visibility == visibiilty: + if entry.visibility == visibility: entries.append(entry) return entries diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index b4bc2012..5a431d36 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -51,6 +51,11 @@ class PSBTSelectSeedView(View): button_data.append(TYPE_12WORD) button_data.append(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)): + # skip the seed prompt if a seed was previous selected and has matching input fingerprint + return Destination(PSBTOverviewView) + selected_menu_num = ButtonListScreen( title=_("Select Signer"), is_button_text_centered=False, @@ -145,6 +150,7 @@ class PSBTOverviewView(View): selected_menu_num = screen.display() if selected_menu_num == RET_CODE__BACK_BUTTON: + self.controller.psbt_seed = None return Destination(BackStackView) # expecting p2sh (legacy multisig) and p2pkh to have no policy set @@ -454,11 +460,8 @@ class PSBTAddressVerificationFailedView(View): show_back_button=False, ).display() - # Clear out the bad PSBT - self.controller.psbt = None - self.controller.psbt_parser = None - self.controller.psbt_seed = None - + # We're done with this PSBT. Route back to MainMenuView which always + # clears all ephemeral data (except in-memory seeds). return Destination(MainMenuView, clear_history=True) @@ -509,11 +512,8 @@ class PSBTSignedQRDisplayView(View): ) QRDisplayScreen(qr_encoder=qr_encoder).display() - # We're done with this PSBT. Remove all related data - self.controller.psbt = None - self.controller.psbt_parser = None - self.controller.psbt_seed = None - + # We're done with this PSBT. Route back to MainMenuView which always + # clears all ephemeral data (except in-memory seeds). return Destination(MainMenuView, clear_history=True) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 60c7926c..6d000b37 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -227,7 +227,7 @@ class SeedFinalizeView(View): def run(self): FINALIZE = _("Done") - PASSPHRASE = (_("Add Passphrase"), FontAwesomeIconConstants.LOCK) + PASSPHRASE = (_("BIP-39 Passphrase"), FontAwesomeIconConstants.LOCK) button_data = [] button_data.append(FINALIZE) @@ -281,9 +281,12 @@ class SeedAddPassphraseView(View): if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - # The new passphrase will be the return value + # The new passphrase will be the return value; it might be empty. self.seed.set_passphrase(ret) - return Destination(SeedReviewPassphraseView) + if len(self.seed.passphrase) > 0: + return Destination(SeedReviewPassphraseView) + else: + return Destination(SeedFinalizeView) @@ -382,7 +385,6 @@ class SeedOptionsView(View): from seedsigner.views.psbt_views import PSBTOverviewView SCAN_PSBT = (_("Scan PSBT"), FontAwesomeIconConstants.QRCODE) - REVIEW_PSBT = _("Review PSBT") VERIFY_ADDRESS = _("Verify Addr") EXPORT_XPUB = _("Export Xpub") EXPLORER = _("Address Explorer") @@ -408,20 +410,7 @@ class SeedOptionsView(View): VERIFY_ADDRESS += f" {addr}" button_data.append(VERIFY_ADDRESS) - if self.controller.psbt: - if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): - if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: - # Re-route us directly back to the start of the PSBT flow - self.controller.resume_main_flow = None - self.controller.psbt_seed = self.seed - return Destination(PSBTOverviewView, skip_current_view=True) - else: - # This seed does not seem to be a signer for this PSBT - # TODO: How sure are we? Should disable this entirely if we're 100% sure? - REVIEW_PSBT += _(" (?)") - button_data.append(REVIEW_PSBT) - else: - button_data.append(SCAN_PSBT) + button_data.append(SCAN_PSBT) if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: button_data.append(EXPORT_XPUB) @@ -444,12 +433,9 @@ class SeedOptionsView(View): # Force BACK to always return to the Main Menu return Destination(MainMenuView) - if button_data[selected_menu_num] == REVIEW_PSBT: - self.controller.psbt_seed = self.controller.get_seed(self.seed_num) - return Destination(PSBTOverviewView) - if button_data[selected_menu_num] == SCAN_PSBT: from seedsigner.views.scan_views import ScanView + self.controller.psbt_seed = self.controller.get_seed(self.seed_num) return Destination(ScanView) elif button_data[selected_menu_num] == VERIFY_ADDRESS: diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 6849a1fd..439194ae 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -10,18 +10,21 @@ from .view import View, Destination class SettingsMenuView(View): - def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None): + def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0): super().__init__() self.visibility = visibility self.selected_attr = selected_attr + # Used to preserve the rendering position in the list + self.initial_scroll = initial_scroll + def run(self): IO_TEST = _("I/O test") DONATE = _("Donate") settings_entries = SettingsDefinition.get_settings_entries( - visibiilty=self.visibility + visibility=self.visibility ) button_data=[_(e.display_name) for e in settings_entries] @@ -54,12 +57,17 @@ class SettingsMenuView(View): title = _("Dev Options") next = None - selected_menu_num = ButtonListScreen( + screen = ButtonListScreen( title=title, is_button_text_centered=False, button_data=button_data, selected_button=selected_button, - ).display() + scroll_y_initial_offset=self.initial_scroll, + ) + selected_menu_num = screen.display() + + # Preserve our scroll position in this Screen so we can return + initial_scroll = screen.buttons[0].scroll_y if selected_menu_num == RET_CODE__BACK_BUTTON: if self.visibility == SettingsConstants.VISIBILITY__GENERAL: @@ -79,8 +87,7 @@ class SettingsMenuView(View): return Destination(DonateView) else: - # TODO: Free-entry types (are there any?) will need their own SettingsEntryUpdateFreeEntryView(?). - return Destination(SettingsEntryUpdateSelectionView, view_args={"attr_name": settings_entries[selected_menu_num].attr_name}) + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) @@ -89,10 +96,11 @@ class SettingsEntryUpdateSelectionView(View): Handles changes to all selection-type settings (Multiselect, SELECT_1, Enabled/Disabled, etc). """ - def __init__(self, attr_name: str): + def __init__(self, attr_name: str, parent_initial_scroll: int = 0): super().__init__() self.settings_entry = SettingsDefinition.get_settings_entry(attr_name) self.selected_button = None + self.parent_initial_scroll = parent_initial_scroll def run(self): @@ -130,7 +138,8 @@ class SettingsEntryUpdateSelectionView(View): SettingsMenuView, view_args={ "visibility": self.settings_entry.visibility, - "selected_attr": self.settings_entry.attr_name + "selected_attr": self.settings_entry.attr_name, + "initial_scroll": self.parent_initial_scroll, } ) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 7c02a5cc..f6645e12 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -568,11 +568,12 @@ class ToolsAddressExplorerAddressTypeView(View): class ToolsAddressExplorerAddressListView(View): - def __init__(self, is_change: bool = False, start_index: int = 0, selected_button_index: int = 0): + def __init__(self, is_change: bool = False, start_index: int = 0, selected_button_index: int = 0, initial_scroll: int = 0): super().__init__() self.is_change = is_change self.start_index = start_index self.selected_button_index = selected_button_index + self.initial_scroll = initial_scroll def run(self): @@ -642,6 +643,7 @@ class ToolsAddressExplorerAddressListView(View): is_button_text_centered=False, is_bottom_list=True, selected_button=self.selected_button_index, + scroll_y_initial_offset=self.initial_scroll, ) finally: # Everything is set. Stop the loading screen @@ -657,20 +659,23 @@ class ToolsAddressExplorerAddressListView(View): # User clicked NEXT return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index + addrs_per_screen)) + # Preserve the list's current scroll so we can return to the same spot + initial_scroll = screen.buttons[0].scroll_y + index = selected_menu_num + self.start_index - return Destination(ToolsAddressExplorerAddressView, view_args=dict(index=index, address=addresses[selected_menu_num], is_change=self.is_change, start_index=self.start_index), skip_current_view=True) + return Destination(ToolsAddressExplorerAddressView, view_args=dict(index=index, address=addresses[selected_menu_num], is_change=self.is_change, start_index=self.start_index, parent_initial_scroll=initial_scroll), skip_current_view=True) class ToolsAddressExplorerAddressView(View): - def __init__(self, index: int, address: str, is_change: bool, start_index: int): + def __init__(self, index: int, address: str, is_change: bool, start_index: int, parent_initial_scroll: int = 0): super().__init__() self.index = index self.address = address self.is_change = is_change self.start_index = start_index + self.parent_initial_scroll = parent_initial_scroll - data = self.controller.address_explorer_data def run(self): qr_encoder = EncodeQR(qr_type=QRType.BITCOIN_ADDRESS, bitcoin_address=self.address) @@ -679,4 +684,4 @@ class ToolsAddressExplorerAddressView(View): ).display() # Exiting/Cancelling the QR display screen always returns to the list - return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index, selected_button_index=self.index - self.start_index), skip_current_view=True) + return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index, selected_button_index=self.index - self.start_index, initial_scroll=self.parent_initial_scroll), skip_current_view=True) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index e31714ab..aba568e1 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -4,7 +4,7 @@ from typing import List from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, WarningScreen +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen from seedsigner.models.threads import BaseThread from seedsigner.models import Settings @@ -200,9 +200,13 @@ class RestartView(View): class PowerOffView(View): def run(self): - thread = PowerOffView.PowerOffThread() - thread.start() - PowerOffScreen().display() + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + PowerOffNotRequiredScreen().display() + return Destination(BackStackView) + else: + thread = PowerOffView.PowerOffThread() + thread.start() + PowerOffScreen().display() class PowerOffThread(BaseThread): @@ -211,13 +215,7 @@ class PowerOffView(View): from subprocess import call while self.keep_running: time.sleep(5) - if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - # disable microsd detection before shutdown to prevent display of toast notification during shutdown - from seedsigner.controller import Controller - Controller.get_instance().microsd.stop() - call("poweroff", shell=True) - else: - call("sudo shutdown --poweroff now", shell=True) + call("sudo shutdown --poweroff now", shell=True) class NotYetImplementedView(View): diff --git a/tests/test_controller.py b/tests/test_controller.py index b6887369..99c6f7b3 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,29 +1,37 @@ import configparser import pytest from mock import MagicMock +from seedsigner.hardware.microsd import MicroSD from seedsigner.controller import Controller -from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants -def test_singleton_init_fails(): +@pytest.fixture() +def reset_controller(): + """fixture to setup, then yield to run test, then tear down""" + + # setup + Controller.configure_instance(disable_hardware=True) + + # yield to run a single test + yield + + # tear down + MicroSD._instance = None + Controller._instance = None + + +def test_singleton_init_fails(reset_controller): """ The Controller should not allow any code to instantiate it via Controller() """ with pytest.raises(Exception): c = Controller() -def test_singleton_get_instance_preserves_state(): +def test_singleton_get_instance_preserves_state(reset_controller): """ Changes to the Controller singleton should be preserved across calls to get_instance() """ - # Must reset Singleton instances; pytest cannot properly isolate Singletons for us - # automatically. - # TODO: Cleaner solution here would be nice. - Settings._instance = None - Controller._instance = None - # Initialize the instance and verify that it read the config settings - Controller.configure_instance(disable_hardware=True) controller = Controller.get_instance() assert controller.unverified_address is None @@ -35,19 +43,33 @@ def test_singleton_get_instance_preserves_state(): assert controller.unverified_address == "123abc" -def test_missing_settings_get_defaults(): - """ Should gracefully handle any missing fields from `settings.ini` """ - # TODO: This is not complete; currently only handles missing compact_seedqr_enabled. +def test_missing_settings_get_defaults(reset_controller): + """ Should gracefully handle all missing fields from `settings.json` """ - # Must reset Singleton instances; pytest cannot properly isolate Singletons for us - # automatically. - # TODO: Cleaner solution here would be nice. - Settings._instance = None - Controller._instance = None - - # Controller should parse the settings fine, even though a field is missing - Controller.configure_instance(disable_hardware=True) - - # Controller should still have a default value controller = Controller.get_instance() - assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__DISABLED + + # Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS] + assert controller.settings.get_value(SettingsConstants.SETTING__BTC_DENOMINATION) == SettingsConstants.BTC_DENOMINATION__THRESHOLD + + # Advanced Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__NETWORK) == SettingsConstants.MAINNET + assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM + assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES] + assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT] + assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__0 + assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED + + # Hidden Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 189 +