diff --git a/.gitignore b/.gitignore
index f2651158a..6ae5b3796 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
__pycache__/
src/seedsigner.egg-info/
.nova
+.vscode
+src/seedsigner/models/settings_definition.json
diff --git a/docs/code_structure.md b/docs/code_structure.md
new file mode 100644
index 000000000..50b7d3153
--- /dev/null
+++ b/docs/code_structure.md
@@ -0,0 +1,21 @@
+# Code Structure
+
+SeedSigner roughly follows a Model-View-Controller approach. Like in a typical web app (e.g. Flask) the `View`s can be called as needed like individual web urls. After completing display and interaction with the user, the `View` then decides where to route the user next, analogous to a web app returning a `response.redirect(url)`.
+
+The `Controller` then ends up being quite stripped down. For example, there's no need for a web app's `urls.py` since there are no mappings from url to `View` to maintain since we're not actually using a url/http routing approach.
+
+`View`s have to handle user interaction so there are `while True` loops that cycle between waiting for user input, gathering data, and then updating the UI components accordingly. You wouldn't find this kind of cycle in a web app because this sort of interactive user input is handled in the browser at the html/css/js level.
+
+
+
+* `Model`s: Store the persistent settings, the in-memory seeds, current wallet information, etc.
+* `Controller`: Manages the state of the world and controls access to global resources.
+* `View`s: Implementation of each screen. Prepares relevant data for display. Must also instantiate the display objects that will actually render the UI.
+* `gui.screens`: Re-usable formatted UI renderers.
+* `gui.components`: Basic individual UI elements that are used by the `templates` such as the top nav, buttons, button lists, text displays.
+
+In an typical webserver context the `View` would send data to an html template (e.g. Jinja) which would then dynamically populate the page with html elements like ` `, ``, ` `, etc. This is analgous to our `gui.screens` constructing a UI renderer by piecing together various `gui.components` as needed.
+
+
+
+`Controller` is a global singleton that any `View` can access and update as needed.
diff --git a/docs/feature_roadmap.md b/docs/feature_roadmap.md
new file mode 100644
index 000000000..29174e09f
--- /dev/null
+++ b/docs/feature_roadmap.md
@@ -0,0 +1,67 @@
+# Feature Roadmap
+
+Current focus: v0.5.0 preview releases.
+
+*Note: It may or may not make sense to do minor bugfix preview releases along the way (e.g. 1.0 -> 1.1).*
+
+
+## v0.5.0 Pre-Release 1.x
+* Scan SeedQR / CompactSeedQR
+* Add/Edit passphrase
+* View seed words w/configurable warnings
+* Export xpub w/configurable warnings and flow determined by Settings
+* Scan PSBT
+* Full PSBT review screens
+* "Full Spend" (no change) warning
+* Fully verify PSBT change addrs
+* Send signed PSBT via QR
+* QR display dimming/brightness UP/DOWN
+* Subset of configurable Settings; persistent Settings storage
+* SettingsQR integration proof-of-concept
+
+Screens will be functional but not necessarily in their final presentation state (icons, text, positioning, etc).
+
+
+## v0.5.0 Pre-Release 2.x
+* Existing screen refinement (visual presentation, text, etc)
+* Create new seed via image entropy
+* Manual mnemonic seed word entry
+* 12th/24th word calc
+* SeedQR/CompactSeedQR manual transcription UI w/configurable UI style (dots vs grid)
+* Single sig address scan and verification
+* SettingsQR standalone UI refinement
+* Fix broken tests
+* All GUI Components support scrollable Screens
+
+
+## v0.5.0 Pre-Release 3.x
+* Further existing screen refinement
+* "Final" bugfixes
+* Create new seed via dice rolls
+* Custom derivation paths in xpub export flow
+* QR display dimming/brightness, framerate, density(?) controls in transparent overlay
+* HRF partner logo on startup
+* Improve test suite coverage
+
+
+## Initial v0.5.0 Release
+All of the above!
+
+
+## Beyond v0.5.0
+These features will not be included in the initial v0.5.0 release and will have varying degrees of priority for subsequent releases (or possibly not at all).
+
+* Multisig wallet descriptor QR scan(?) and addr verification(?)
+* Multi-language support (Transifex free for open source projects)
+* Multisig: sign PSBT with multiple keys at once.
+* Custom OS, possibly with swappable SD card PSBT and multisig wallet descriptor storage
+* Decoy game mode at launch (Snake, Tetris, Sudoku...?)
+* BIP-39 wordlists in additional languages
+* Address message signing
+* UI color scheme customization
+
+
+# v0.6 and Beyond...?
+* Alternate hardware profile / touchscreen
+* PGP signer
+* Liquid?
diff --git a/setup.py b/setup.py
index bfee1ee7d..cc8d8fb39 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@
setuptools.setup(
name="seedsigner",
- version="0.4.4",
+ version="0.5.0",
author="SeedSigner",
author_email="author@example.com",
description="Build an offline, airgapped Bitcoin signing device for less than $50!",
diff --git a/src/default_settings.ini b/src/default_settings.ini
deleted file mode 100644
index 6d22247e2..000000000
--- a/src/default_settings.ini
+++ /dev/null
@@ -1,16 +0,0 @@
-[system]
-debug = False
-default_language = en
-persistent_settings = False
-
-[display]
-text_color = ORANGE
-qr_background_color = 555555
-camera_rotation = 0
-
-[wallet]
-network = main
-software = Prompt
-qr_density = 2
-custom_derivation = m/0/0
-compact_seedqr_enabled = False
\ No newline at end of file
diff --git a/src/main.py b/src/main.py
index deb8df176..740faf30a 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1,16 +1,4 @@
-import configparser
-import sys
-import time
-
from seedsigner.controller import Controller
-
-config = configparser.ConfigParser()
-config.read("settings.ini")
-
-# One-time setup to intialize the one and only Controller
-Controller.configure_instance(config)
-
# Get the one and only Controller instance and start our main loop
-controller = Controller.get_instance()
-controller.start()
+Controller.get_instance().start()
diff --git a/src/seedsigner/__init__.py b/src/seedsigner/__init__.py
new file mode 100644
index 000000000..d1ee4f421
--- /dev/null
+++ b/src/seedsigner/__init__.py
@@ -0,0 +1 @@
+from .controller import Controller
\ No newline at end of file
diff --git a/src/seedsigner/camera.py b/src/seedsigner/camera.py
index edac1702a..10112bba2 100644
--- a/src/seedsigner/camera.py
+++ b/src/seedsigner/camera.py
@@ -3,7 +3,9 @@
from picamera import PiCamera
from PIL import Image
-from seedsigner.helpers import PiVideoStream, Singleton
+from seedsigner.models import Singleton
+from seedsigner.helpers import PiVideoStream
+from seedsigner.models.settings import SettingsConstants
@@ -18,7 +20,7 @@ def get_instance(cls):
from seedsigner.models import Settings
if cls._instance is None:
cls._instance = cls.__new__(cls)
- cls._instance._camera_rotation = Settings.get_instance().camera_rotation
+ cls._instance._camera_rotation = int(Settings.get_instance().get_value(SettingsConstants.SETTING__CAMERA_ROTATION))
return cls._instance
diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py
index ab238d2b2..90d7b3d3e 100644
--- a/src/seedsigner/controller.py
+++ b/src/seedsigner/controller.py
@@ -1,20 +1,13 @@
-# External Dependencies
-import time
-import re
-from multiprocessing import Process, Queue
-from subprocess import call
-import os, sys
-from embit import bip32, script, ec
-from embit.networks import NETWORKS
-from embit.descriptor import Descriptor
-from binascii import hexlify
-from threading import Thread
-
-# Internal file class dependencies
-from .views import (View, MenuView, SeedToolsView,SigningToolsView,
- SettingsToolsView, IOTestView, OpeningSplashView, ScreensaverView)
-from .helpers import Buttons, B, Path, Singleton
-from .models import (EncodeQRDensity, QRType, Seed, SeedStorage, Settings, DecodeQR, DecodeQRStatus, EncodeQR, PSBTParser)
+from embit.psbt import PSBT
+from seedsigner.gui.renderer import Renderer
+from seedsigner.gui.screens.screen import WarningScreen
+from seedsigner.helpers.buttons import Buttons
+from seedsigner.views.screensaver import ScreensaverView
+from seedsigner.views.view import NotYetImplementedView
+
+from .models import Seed, SeedStorage, Settings, Singleton, PSBTParser
+
+
class Controller(Singleton):
"""
@@ -33,20 +26,36 @@ class Controller(Singleton):
Note: In many/most cases you'll need to do the Controller import within a method
rather than at the top in order avoid circular imports.
"""
- VERSION = "0.4.6"
+ from .helpers import Buttons
+
+ VERSION = "0.5.0 Pre-Release 1"
+
+ # Declare class member vars with type hints to enable richer IDE support throughout
+ # the code.
+ buttons: Buttons = None
+ storage: SeedStorage = None
+ settings: Settings = None
+ psbt: PSBT = None
+ psbt_seed: Seed = None
+ psbt_parser: PSBTParser = None
+ renderer: Renderer = None
@classmethod
def get_instance(cls):
- # This is the only way to access the one and only Controller
+ from .gui import Renderer
+ from .helpers import Buttons
+ from .views import ScreensaverView
+ # This is the only way to access the one and only instance
if cls._instance:
return cls._instance
else:
- raise Exception("Must call Controller.configure_instance(config) first")
+ # Instantiate the one and only Controller instance
+ return cls.configure_instance()
@classmethod
- def configure_instance(cls, config=None, disable_hardware=False):
+ def configure_instance(cls, disable_hardware=False):
"""
- `disable_hardware` is only meant to be used by the test suite so that it
can keep re-initializing a Controller in however many tests it needs to. But
@@ -69,27 +78,28 @@ def configure_instance(cls, config=None, disable_hardware=False):
if disable_hardware:
controller.buttons = None
else:
- controller.buttons = Buttons()
+ controller.buttons = Buttons.get_instance()
# models
+ # TODO: Rename "storage" to something more indicative of its temp, in-memory state
controller.storage = SeedStorage()
- Settings.configure_instance(config)
controller.settings = Settings.get_instance()
- # settings
- controller.DEBUG = controller.settings.debug
- controller.color = controller.settings.text_color
- controller.current_bg_qr_color = controller.settings.qr_background_color
-
- # Views
- controller.menu_view = MenuView()
- controller.seed_tools_view = SeedToolsView()
- controller.io_test_view = IOTestView()
- controller.signing_tools_view = SigningToolsView(controller.storage)
- controller.settings_tools_view = SettingsToolsView()
+ # Store one working psbt in memory
+ controller.psbt = None
+ controller.psbt_parser = None
+
+ # Configure the Renderer
+ Renderer.configure_instance()
+
controller.screensaver = ScreensaverView(controller.buttons)
+ controller.back_stack = []
+
+ # Other behavior constants
controller.screensaver_activation_ms = 120 * 1000
+
+ return cls._instance
@property
@@ -98,41 +108,117 @@ def camera(self):
return Camera.get_instance()
+ def get_seed(self, seed_num: int) -> Seed:
+ if seed_num < len(self.storage.seeds):
+ return self.storage.seeds[seed_num]
+ else:
+ raise Exception(f"There is no seed_num {seed_num}; only {len(self.storage.seeds)} in memory.")
+
+
+ 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()
+
+ if len(self.back_stack) > 0:
+ # One more pop back gives us the actual "back" View_cls
+ return self.back_stack.pop()
+ return Destination(None)
+
+
+ def clear_back_stack(self):
+ self.back_stack = []
+
+
def start(self) -> None:
+ from .views import View, Destination, OpeningSplashView, MainMenuView, BackStackView
+
opening_splash = OpeningSplashView()
opening_splash.start()
- if self.DEBUG:
- # Let Exceptions halt execution
- try:
- self.show_main_menu()
- finally:
- # Clear the screen when exiting
- self.menu_view.display_blank_screen()
+ # TODO: Remove for v0.5.0 production release
+ WarningScreen(
+ title="Warning",
+ warning_headline="Pre-Release Code",
+ warning_text="Do not use this with real funds or to create new secure keys!",
+ show_top_nav_left_button=False,
+ ).display()
- else:
- # Handle Unexpected crashes by restarting up to 3 times
- crash_cnt = 0
+
+ """ Class references can be stored as variables in python!
+
+ This loop receives a View class to execute and stores it in the `View_cls`
+ var along with any input arguments in the `init_args` dict.
+
+ The `View_cls` is instantiated with `init_args` passed in and then run(). It
+ returns either a new View class to execute next or None.
+
+ Example:
+ class MyView(View)
+ def run(self, some_arg, other_arg):
+ print(other_arg)
+
+ class OtherView():
+ def run(self):
+ return (MyView, {"some_arg": 1, "other_arg": "hello"})
+
+ When `OtherView` is instantiated and run, we capture its return values:
+
+ (View_cls, init_args) = OtherView().run()
+
+ And then we can instantiate and run that View class:
+
+ View_cls(**init_args).run()
+ """
+ try:
+ next_destination = Destination(MainMenuView)
while True:
- try:
- self.show_main_menu()
- except Exception as error:
- if crash_cnt >= 3:
- break
- else:
- print('Caught this error: ' + repr(error)) # debug
- self.menu_view.draw_modal(["Crashed ..."], "", "restarting")
- time.sleep(5)
+ # Destination(None) is a special case; render the Home screen
+ if next_destination.View_cls is None:
+ next_destination = Destination(MainMenuView)
+
+ if next_destination.View_cls == MainMenuView:
+ # Home always wipes the back_stack
+ self.clear_back_stack()
+
+ print(f"Executing {next_destination}")
+ next_destination = next_destination.run()
+
+ if not next_destination:
+ # Should only happen during dev when you hit an unimplemented option
+ next_destination = Destination(NotYetImplementedView)
+
+ if next_destination.skip_current_view:
+ # Remove the current View from history; it's forwarding us straight
+ # to the next View so it should be as if this View never happened.
+ self.back_stack.pop()
+
+ # Hang on to this reference...
+ clear_history = next_destination.clear_history
+
+ if next_destination.View_cls == BackStackView:
+ # "Back" arrow was clicked; load the previous view
+ next_destination = self.pop_prev_from_back_stack()
- crash_cnt += 1
+ # ...now apply it, if needed
+ if clear_history:
+ self.clear_back_stack()
- self.menu_view.draw_modal(["Crashed ..."], "", "requires hard restart")
+ # The next_destination up always goes on the back_stack, even if it's the
+ # one we just popped.
+ self.back_stack.append(next_destination)
+
+ finally:
+ # Clear the screen when exiting
+ Renderer.get_instance().display_blank_screen()
def start_screensaver(self):
self.screensaver.start()
+"""
### Menu
### Menu View handles navigation within the menu
### Sub Menu's like Seed Tools, Signing Tools, Settings are all in the Menu View
@@ -154,8 +240,6 @@ def show_main_menu(self, sub_menu = 0):
ret_val = self.show_store_a_seed_tool()
elif ret_val == Path.PASSPHRASE_SEED:
ret_val = self.show_add_remove_passphrase_tool()
- elif ret_val == Path.GEN_XPUB:
- ret_val = self.show_generate_xpub()
elif ret_val == Path.SIGN_TRANSACTION:
ret_val = self.show_sign_transaction()
elif ret_val == Path.IO_TEST_TOOL:
@@ -178,22 +262,10 @@ def show_main_menu(self, sub_menu = 0):
ret_val = self.show_donate_tool()
elif ret_val == Path.RESET:
ret_val = self.show_reset_tool()
- elif ret_val == Path.POWER_OFF:
- ret_val = self.show_power_off()
raise Exception("Unhandled case")
- ### Power Off
-
- def show_power_off(self):
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Power Off?")
- if r == 1: #Yes
- self.menu_view.display_power_off_screen()
- call("sudo shutdown --poweroff now", shell=True)
- time.sleep(10)
- else: # No
- return Path.MAIN_MENU
###
### Seed Tools Controller Naviation/Launcher
@@ -230,12 +302,12 @@ def show_generate_last_word_tool(self) -> int:
# Ask to save seed
if self.storage.slot_avaliable():
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
+ r = self.renderer.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
if r == 1: #Yes
slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None)
if slot_num in (1,2,3):
self.storage.add_seed(seed, slot_num)
- self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu")
+ self.renderer.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.MAIN_MENU
@@ -264,12 +336,12 @@ def show_create_seed_with_dice_tool(self) -> int:
# Ask to save seed
if self.storage.slot_avaliable():
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
+ r = self.renderer.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
if r == 1: #Yes
slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None)
if slot_num in (1,2,3):
self.storage.add_seed(seed, slot_num)
- self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu")
+ self.renderer.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.MAIN_MENU
@@ -302,12 +374,12 @@ def show_create_seed_with_image_tool(self) -> int:
# Ask to save seed
if self.storage.slot_avaliable():
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
+ r = self.renderer.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
if r == 1: #Yes
slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None)
if slot_num in (1,2,3):
self.storage.add_seed(seed, slot_num)
- self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu")
+ self.renderer.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Main Menu")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.MAIN_MENU
@@ -359,11 +431,11 @@ def show_store_a_seed_tool(self):
if not seed:
# Seed is not valid, Exit if not valid with message
- self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again", ""], "", "Right to Continue")
+ self.renderer.draw_modal(["Seed Invalid", "check seed phrase", "and try again", ""], "", "Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.SEED_TOOLS_SUB_MENU
else:
- self.menu_view.draw_modal(["Valid Seed!"], "", "Right to Continue")
+ self.renderer.draw_modal(["Valid Seed!"], "", "Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT])
while display_saved_seed == False:
@@ -376,7 +448,7 @@ def show_store_a_seed_tool(self):
if seed:
self.storage.add_seed(seed, slot_num)
- self.menu_view.draw_modal(["", "Saved to Slot #" + str(slot_num)], "", "Right to Exit")
+ self.renderer.draw_modal(["", "Saved to Slot #" + str(slot_num)], "", "Right to Exit")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.SEED_TOOLS_SUB_MENU
@@ -388,7 +460,7 @@ def show_add_remove_passphrase_tool(self):
r = 0
if self.storage.num_of_saved_seeds() == 0:
- self.menu_view.draw_modal(["Store a seed phrase", "prior to adding", "a passphrase"], "Error", "Right to Continue")
+ self.renderer.draw_modal(["Store a seed phrase", "prior to adding", "a passphrase"], "Error", "Right to Continue")
self.buttons.wait_for([B.KEY_RIGHT])
return Path.SEED_TOOLS_SUB_MENU
@@ -405,11 +477,11 @@ def show_add_remove_passphrase_tool(self):
if self.storage.check_slot_passphrase(slot_num) == True:
# only display menu to remove/update if there is a passphrase to remove
- r = self.menu_view.display_generic_selection_menu(["... [ Return to Seed Tools ]", "Change Passphrase", "Remove Passphrase"], "Passphrase Action")
+ r = self.renderer.display_generic_selection_menu(["... [ Return to Seed Tools ]", "Change Passphrase", "Remove Passphrase"], "Passphrase Action")
if r == 3:
# Remove Passphrase Workflow
self.storage.delete_passphrase(slot_num)
- self.menu_view.draw_modal(["Passphrase Deleted", "from Slot #" + str(slot_num)], "", "Right to Continue")
+ self.renderer.draw_modal(["Passphrase Deleted", "from Slot #" + str(slot_num)], "", "Right to Continue")
self.buttons.wait_for([B.KEY_RIGHT])
return Path.SEED_TOOLS_SUB_MENU
@@ -435,145 +507,6 @@ def show_add_remove_passphrase_tool(self):
### Signing Tools Navigation/Launcher
###
- ### Generate XPUB
-
- def show_generate_xpub(self):
- seed = Seed(wordlist=self.settings.wordlist)
-
- # If there is a saved seed, ask to use saved seed
- if self.storage.num_of_saved_seeds() > 0:
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Use Saved Seed?")
- if r == 1: #Yes
- slot_num = self.menu_view.display_saved_seed_menu(self.storage,3,None)
- if slot_num not in (1,2,3):
- return Path.SEED_TOOLS_SUB_MENU
- seed = self.storage.get_seed(slot_num)
-
- if not seed:
- # no valid seed, gather seed phrase
- # display menu to select 12 or 24 word seed for last word
- ret_val = self.menu_view.display_qr_12_24_word_menu("... [ Return to Sign Tools ]")
- if ret_val == Path.SEED_WORD_12:
- seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(12)
- elif ret_val == Path.SEED_WORD_24:
- seed.mnemonic = self.seed_tools_view.display_manual_seed_entry(24)
- elif ret_val == Path.SEED_WORD_QR:
- seed.mnemonic = self.seed_tools_view.read_seed_phrase_qr()
- else:
- return Path.SEED_TOOLS_SUB_MENU
-
- if not seed:
- return Path.SEED_TOOLS_SUB_MENU
-
- # check if seed phrase is valid
- if not seed:
- self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again"], "", "Right to Continue")
- input = self.buttons.wait_for([B.KEY_RIGHT])
- return Path.MAIN_MENU
-
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?")
- if r == 1:
- # display a tool to pick letters/numbers to make a passphrase
- seed.passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry()
- if len(seed.passphrase) == 0:
- self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue")
- input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT])
- if input == B.KEY_LEFT:
- return Path.MAIN_MENU
- else:
- self.menu_view.draw_modal(["Optional passphrase", "added to seed words", seed.passphrase], "", "Right to Continue")
- self.buttons.wait_for([B.KEY_RIGHT])
-
- # display seed phrase
- while True:
- r = self.seed_tools_view.display_seed_phrase(seed.mnemonic_list, seed.passphrase, "Right to Continue")
- if r == True:
- break
- else:
- # Cancel
- return Path.SEED_TOOLS_SUB_MENU
-
- # choose single sig or multisig wallet type
- wallet_type = "multisig"
- script_type = "native segwit"
- derivation = self.settings.custom_derivation
- r = self.menu_view.display_generic_selection_menu(["Single Sig", "Multisig"], "Wallet Type?")
- if r == 1:
- wallet_type = "single sig"
- elif r == 2:
- wallet_type = "multisig"
-
- # choose derivation standard
- r = self.menu_view.display_generic_selection_menu(["Native Segwit", "Nested Segwit", "Custom"], "Derivation Path?")
- if r == 1:
- script_type = "native segwit"
- elif r == 2:
- script_type = "nested segwit"
- elif r == 3:
- script_type = "custom"
-
- # calculated derivation or get custom from keyboard entry
- if script_type == "custom":
- derivation = self.settings_tools_view.draw_derivation_keyboard_entry(existing_derivation=self.settings.custom_derivation)
- self.settings.custom_derivation = derivation # save for next time
- else:
- derivation = Settings.calc_derivation(self.settings.network, wallet_type, script_type)
-
- if derivation == "" or derivation == None:
- self.menu_view.draw_modal(["Invalid Derivation", "try again"], "", "Right to Continue")
- return Path.SEED_TOOLS_SUB_MENU
-
- if self.settings.software == "Prompt":
- lines = ["Specter Desktop", "Blue Wallet", "Sparrow"]
- r = self.menu_view.display_generic_selection_menu(lines, "Which Wallet?")
- qr_xpub_type = Settings.getXPubType(lines[r-1])
- else:
- qr_xpub_type = self.settings.qr_xpub_type
-
- self.signing_tools_view.draw_modal(["Loading xPub Info ..."])
-
- version = bip32.detect_version(derivation, default="xpub", network=NETWORKS[self.settings.network])
- root = bip32.HDKey.from_seed(seed.seed, version=NETWORKS[self.settings.network]["xprv"])
- fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8')
- xprv = root.derive(derivation)
- xpub = xprv.to_public()
- xpub_base58 = xpub.to_string(version=version)
-
- self.signing_tools_view.display_xpub_info(fingerprint, derivation, xpub_base58)
- self.buttons.wait_for([B.KEY_RIGHT])
-
- self.signing_tools_view.draw_modal(["Generating xPub QR ..."])
- e = EncodeQR(seed_phrase=seed.mnemonic_list, passphrase=seed.passphrase, derivation=derivation, network=self.settings.network, qr_type=qr_xpub_type, qr_density=self.settings.qr_density, wordlist=self.settings.wordlist)
-
- while e.totalParts() > 1:
-
- cur_time = int(time.time() * 1000)
- if cur_time - self.buttons.last_input_time > self.screensaver_activation_ms and not self.screensaver.is_running:
- self.start_screensaver()
- self.buttons.update_last_input_time()
- else:
- image = e.nextPartImage(240,240,2,background=self.current_bg_qr_color)
- View.DispShowImage(image)
- time.sleep(0.1)
- if self.buttons.check_for_low(B.KEY_RIGHT):
- break
- elif self.buttons.check_for_low(B.KEY_UP):
- self.prev_qr_background_color()
- elif self.buttons.check_for_low(B.KEY_DOWN):
- self.next_qr_background_color()
-
- while e.totalParts() == 1:
- image = e.nextPartImage(240,240,1,background=self.current_bg_qr_color)
- View.DispShowImage(image)
- input = self.buttons.wait_for([B.KEY_RIGHT,B.KEY_UP,B.KEY_DOWN])
- if input == B.KEY_RIGHT:
- break
- elif input == B.KEY_UP:
- self.prev_qr_background_color()
- elif input == B.KEY_DOWN:
- self.next_qr_background_color()
-
- return Path.MAIN_MENU
### Sign Transactions
@@ -584,7 +517,7 @@ def show_sign_transaction(self):
# reusable qr scan function
def scan_qr(scan_text="Scan QR"):
# Scan QR using Camera
- self.menu_view.draw_modal(["Initializing Camera"])
+ self.renderer.draw_modal(["Initializing Camera"])
self.camera.start_video_stream_mode(resolution=(480, 480), framerate=12, format="rgb")
decoder = DecodeQR(wordlist=self.settings.wordlist)
@@ -594,7 +527,7 @@ def live_preview(camera, decoder, scan_text):
if frame is not None:
if decoder.getPercentComplete() > 0 and decoder.isPSBT():
scan_text = str(decoder.getPercentComplete()) + "% Complete"
- View.DispShowImageWithText(frame.resize((240,240)), scan_text, font=View.ASSISTANT22, text_color=View.color, text_background=(0,0,0,225))
+ self.renderer.show_image_with_text(frame.resize((240,240)), scan_text, font=Fonts.get_font("Assistant-Medium", 22), text_color=View.color, text_background=(0,0,0,225))
time.sleep(0.1) # turn this up or down to tune performance while decoding psbt
if camera._video_stream is None:
break
@@ -625,46 +558,46 @@ def live_preview(camera, decoder, scan_text):
if decoder.isComplete() and decoder.isPSBT():
# first QR is PSBT
- self.menu_view.draw_modal(["Validating PSBT"])
+ self.renderer.draw_modal(["Validating PSBT"])
psbt = decoder.getPSBT()
- self.menu_view.draw_modal(["PSBT Valid!", "Enter", "seed phrase", "to sign this tx"], "", "Right to Continue")
+ self.renderer.draw_modal(["PSBT Valid!", "Enter", "seed phrase", "to sign this tx"], "", "Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT])
elif decoder.isComplete() and decoder.isSeed():
# first QR is Seed
- self.menu_view.draw_modal(["Validating Seed"])
+ self.renderer.draw_modal(["Validating Seed"])
seed.mnemonic = decoder.getSeedPhrase()
if not seed:
# seed is not valid, Exit if not valid with message
- self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again", ""], "", "Right to Continue")
+ self.renderer.draw_modal(["Seed Invalid", "check seed phrase", "and try again", ""], "", "Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.MAIN_MENU
else:
- self.menu_view.draw_modal(["Valid Seed!"], "", "Right to Continue")
+ self.renderer.draw_modal(["Valid Seed!"], "", "Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT])
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?")
+ r = self.renderer.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?")
if r == 1:
# display a tool to pick letters/numbers to make a passphrase
seed.passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry()
if len(seed.passphrase) == 0:
- self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue")
+ self.renderer.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT])
if input == B.KEY_LEFT:
return Path.MAIN_MENU
else:
- self.menu_view.draw_modal(["Optional passphrase", "added to seed words"], "", "Right to Continue")
+ self.renderer.draw_modal(["Optional passphrase", "added to seed words"], "", "Right to Continue")
self.buttons.wait_for([B.KEY_RIGHT])
# Ask to save seed
if self.storage.slot_avaliable():
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
+ r = self.renderer.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
if r == 1: #Yes
slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None)
if slot_num in (1,2,3):
self.storage.add_seed(seed, slot_num)
- self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Continue")
+ self.renderer.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT])
# display seed phrase
@@ -681,10 +614,10 @@ def live_preview(camera, decoder, scan_text):
if decoder.isComplete() and decoder.isPSBT():
# second QR must be a PSBT
- self.menu_view.draw_modal(["Validating PSBT"])
+ self.renderer.draw_modal(["Validating PSBT"])
psbt = decoder.getPSBT()
elif ( decoder.isComplete() and not decoder.isPSBT() ) or decoder.isInvalid():
- self.menu_view.draw_modal(["Not a valid PSBT QR"], "", "Right to Exit")
+ self.renderer.draw_modal(["Not a valid PSBT QR"], "", "Right to Exit")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.MAIN_MENU
else:
@@ -893,7 +826,7 @@ def live_preview(camera, decoder, scan_text):
return Path.MAIN_MENU
elif ( decoder.isComplete() and not decoder.isPSBT() ) or decoder.isInvalid():
- self.menu_view.draw_modal(["Not a valid PSBT QR"], "", "Right to Exit")
+ self.renderer.draw_modal(["Not a valid PSBT QR"], "", "Right to Exit")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.MAIN_MENU
else:
@@ -927,24 +860,24 @@ def live_preview(camera, decoder, scan_text):
return Path.MAIN_MENU
# check if seed phrase is valid
- self.menu_view.draw_modal(["Validating Seed ..."])
+ self.renderer.draw_modal(["Validating Seed ..."])
if not seed:
- self.menu_view.draw_modal(["Seed Invalid", "check seed phrase", "and try again"], "", "Right to Continue")
+ self.renderer.draw_modal(["Seed Invalid", "check seed phrase", "and try again"], "", "Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT])
return Path.MAIN_MENU
if len(seed.passphrase) == 0:
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?")
+ r = self.renderer.display_generic_selection_menu(["Yes", "No"], "Add Seed Passphrase?")
if r == 1:
# display a tool to pick letters/numbers to make a passphrase
seed.passphrase = self.seed_tools_view.draw_passphrase_keyboard_entry()
if len(seed.passphrase) == 0:
- self.menu_view.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue")
+ self.renderer.draw_modal(["No passphrase added", "to seed words"], "", "Left to Exit, Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT])
if input == B.KEY_LEFT:
return Path.MAIN_MENU
else:
- self.menu_view.draw_modal(["Optional passphrase", "added to seed words", seed.passphrase], "", "Right to Continue")
+ self.renderer.draw_modal(["Optional passphrase", "added to seed words", seed.passphrase], "", "Right to Continue")
self.buttons.wait_for([B.KEY_RIGHT])
# display seed phrase
@@ -958,16 +891,16 @@ def live_preview(camera, decoder, scan_text):
# Ask to save seed
if self.storage.slot_avaliable() and used_saved_seed == False:
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
+ r = self.renderer.display_generic_selection_menu(["Yes", "No"], "Save Seed?")
if r == 1: #Yes
slot_num = self.menu_view.display_saved_seed_menu(self.storage,2,None)
if slot_num in (1,2,3):
self.storage.add_seed(seed, slot_num)
- self.menu_view.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Continue")
+ self.renderer.draw_modal(["Seed Valid", "Saved to Slot #" + str(slot_num)], "", "Right to Continue")
input = self.buttons.wait_for([B.KEY_RIGHT])
# show transaction information before sign
- self.menu_view.draw_modal(["Parsing PSBT"])
+ self.renderer.draw_modal(["Parsing PSBT"])
p = PSBTParser(psbt,seed,self.settings.network)
self.signing_tools_view.display_transaction_information(p)
input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT], False)
@@ -975,19 +908,19 @@ def live_preview(camera, decoder, scan_text):
return Path.MAIN_MENU
# Sign PSBT
- self.menu_view.draw_modal(["PSBT Signing ..."])
+ self.renderer.draw_modal(["PSBT Signing ..."])
sig_cnt = PSBTParser.sigCount(psbt)
psbt.sign_with(p.root)
trimmed_psbt = PSBTParser.trim(psbt)
if sig_cnt == PSBTParser.sigCount(trimmed_psbt):
- self.menu_view.draw_modal(["Signing failed", "left to exit", "or right to continue", "to display PSBT QR"], "", "")
+ self.renderer.draw_modal(["Signing failed", "left to exit", "or right to continue", "to display PSBT QR"], "", "")
input = self.buttons.wait_for([B.KEY_RIGHT, B.KEY_LEFT], False)
if input == B.KEY_LEFT:
return Path.MAIN_MENU
# Display Animated QR Code
- self.menu_view.draw_modal(["Generating PSBT QR ..."])
+ self.renderer.draw_modal(["Generating PSBT QR ..."])
e = EncodeQR(psbt=trimmed_psbt, qr_type=self.settings.qr_psbt_type, qr_density=self.settings.qr_density, wordlist=self.settings.wordlist)
while True:
cur_time = int(time.time() * 1000)
@@ -1036,7 +969,7 @@ def show_current_network_tool(self):
def show_wallet_tool(self):
r = self.settings_tools_view.display_wallet_selection()
if r is not None:
- self.settings.software = r
+ self.settings.coordinators = r
return Path.SETTINGS_SUB_MENU
@@ -1044,7 +977,7 @@ def show_wallet_tool(self):
def show_qr_density_tool(self):
r = self.settings_tools_view.display_qr_density_selection()
- if r in (EncodeQRDensity.LOW, EncodeQRDensity.MEDIUM, EncodeQRDensity.HIGH):
+ if r in (SettingsConstants.DENSITY__LOW, SettingsConstants.DENSITY__MEDIUM, SettingsConstants.DENSITY__HIGH):
self.settings.qr_density = r
return Path.SETTINGS_SUB_MENU
@@ -1065,12 +998,12 @@ def show_persistent_settings_tool(self):
r = self.settings_tools_view.display_persistent_settings()
if r is not None:
if r == True:
- self.menu_view.draw_modal(["Persistent settings", "keeps settings saved", "across reboot.", "Seeds are never saved"], "Warning", "Right to Continue")
+ self.renderer.draw_modal(["Persistent settings", "keeps settings saved", "across reboot.", "Seeds are never saved"], "Warning", "Right to Continue")
input = self.buttons.wait_for([B.KEY_LEFT, B.KEY_RIGHT])
if input == B.KEY_RIGHT:
self.settings.persistent = r
else:
- self.menu_view.draw_modal(["This will restore", "the default", "settings.", ""], "Warning", "Right to Continue")
+ self.renderer.draw_modal(["This will restore", "the default", "settings.", ""], "Warning", "Right to Continue")
input = self.buttons.wait_for([B.KEY_LEFT, B.KEY_RIGHT])
if input == B.KEY_RIGHT:
self.settings.persistent = r
@@ -1147,12 +1080,12 @@ def show_donate_tool(self):
return Path.MAIN_MENU
def show_reset_tool(self):
- self.menu_view.draw_modal(["This will restore", "default settings and", "restart the app", ""], "Warning", "Right to Continue")
+ self.renderer.draw_modal(["This will restore", "default settings and", "restart the app", ""], "Warning", "Right to Continue")
input = self.buttons.wait_for([B.KEY_LEFT, B.KEY_RIGHT])
if input == B.KEY_RIGHT:
- r = self.menu_view.display_generic_selection_menu(["Yes", "No"], "Reset SeedSigner?")
+ r = self.renderer.display_generic_selection_menu(["Yes", "No"], "Reset SeedSigner?")
if r == 1: #Yes
- self.menu_view.display_blank_screen()
+ self.renderer.display_blank_screen()
self.settings.restoreDefault()
time.sleep(0.1) # give time to write to disk
@@ -1171,3 +1104,4 @@ def show_reset_tool(self):
return Path.MAIN_MENU
+"""
\ No newline at end of file
diff --git a/src/seedsigner/gui/__init__.py b/src/seedsigner/gui/__init__.py
new file mode 100644
index 000000000..05667487c
--- /dev/null
+++ b/src/seedsigner/gui/__init__.py
@@ -0,0 +1 @@
+from .renderer import Renderer
\ No newline at end of file
diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py
new file mode 100644
index 000000000..1ab526a9f
--- /dev/null
+++ b/src/seedsigner/gui/components.py
@@ -0,0 +1,971 @@
+import math
+import os
+import pathlib
+
+from dataclasses import dataclass
+from PIL import Image, ImageDraw, ImageFont, ImageFilter
+from typing import List, Tuple
+
+from seedsigner.models import Singleton
+
+
+# TODO: Remove all pixel hard coding
+class GUIConstants:
+ EDGE_PADDING = 8
+ COMPONENT_PADDING = 8
+ LIST_ITEM_PADDING = 4
+
+ BACKGROUND_COLOR = "black"
+ WARNING_COLOR = "#FFD60A"
+ DIRE_WARNING_COLOR = "red"
+ BITCOIN_ORANGE = "#ff9416"
+ ACCENT_COLOR = "orange"
+
+ ICON_FONT_NAME__FONT_AWESOME = "Font_Awesome_6_Free-Solid-900"
+ ICON_FONT_NAME__SEEDSIGNER = "seedsigner-glyphs"
+ ICON_FONT_SIZE = 22
+ ICON_INLINE_FONT_SIZE = 24
+ ICON_LARGE_BUTTON_SIZE = 36
+ ICON_PRIMARY_SCREEN_SIZE = 44
+
+ TOP_NAV_TITLE_FONT_NAME = "OpenSans-SemiBold"
+ TOP_NAV_TITLE_FONT_SIZE = 20
+ TOP_NAV_HEIGHT = 48
+ TOP_NAV_BUTTON_SIZE = 32
+
+ BODY_FONT_NAME = "OpenSans-Regular"
+ BODY_FONT_SIZE = 17
+ BODY_FONT_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE
+ BODY_FONT_MIN_SIZE = 15
+ BODY_FONT_COLOR = "#fcfcfc"
+ BODY_LINE_SPACING = 0.25
+
+ FIXED_WIDTH_FONT_NAME = "Inconsolata-Regular"
+ FIXED_WIDTH_EMPHASIS_FONT_NAME = "Inconsolata-SemiBold"
+
+ LABEL_FONT_SIZE = BODY_FONT_MIN_SIZE
+ LABEL_FONT_COLOR = "#777"
+
+ BUTTON_FONT_NAME = "OpenSans-SemiBold"
+ BUTTON_FONT_SIZE = 18
+ BUTTON_FONT_COLOR = "#e8e8e8"
+ BUTTON_HEIGHT = 32
+
+
+
+class FontAwesomeIconConstants:
+ CAMERA = "\uf030"
+ SOLID_CIRCLE_CHECK = "\uf058"
+ CIRCLE = "\uf111"
+ CIRCLE_CHEVRON_RIGHT = "\uf138"
+ DICE = "\uf522"
+ GEAR = "\uf013"
+ KEY = "\uf084"
+ KEYBOARD = "\uf11c"
+ LOCK = "\uf023"
+ MAP = "\uf279"
+ PAPER_PLANE = "\uf1d8"
+ PLUS = "+"
+ POWER_OFF = "\uf011"
+ ROTATE_RIGHT = "\uf2f9"
+ SCREWDRIVER_WRENCH = "\uf7d9"
+ SQUARE = "\uf0c8"
+ SQUARE_CHECK = "\uf14a"
+ TRIANGLE_EXCLAMATION = "\uf071"
+ UNLOCK = "\uf09c"
+ QRCODE = "\uf029"
+ X = "\u0058"
+
+
+
+class SeedSignerCustomIconConstants:
+ LARGE_CHEVRON_LEFT = "\ue900"
+ SMALL_CHEVRON_RIGHT = "\ue901"
+ PAGE_DOWN = "\ue902"
+ PAGE_UP = "\ue903"
+ CIRCLE_X = "\ue904"
+ CIRCLE_EXCLAMATION = "\ue905"
+ CIRCLE_CHECK = "\ue906"
+ FINGERPRINT = "\ue907"
+ PATH = "\ue908"
+ BITCOIN_LOGO = "\ue909"
+ BITCOIN_LOGO_2 = "\ue90a"
+
+ MIN_VALUE = LARGE_CHEVRON_LEFT
+ MAX_VALUE = BITCOIN_LOGO_2
+
+
+
+def calc_text_centering(font: ImageFont,
+ text: str,
+ is_text_centered: bool,
+ total_width: int,
+ total_height: int,
+ start_x: int = 0,
+ start_y: int = 0) -> Tuple[int, int]:
+ # see: https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html#text-anchors
+
+ # Gap between the starting coordinate and the first marking.
+ offset_x, offset_y = font.getoffset(text)
+
+ # Bounding box of the actual pixels rendered.
+ (box_left, box_top, box_right, box_bottom) = font.getbbox(text, anchor='lt')
+
+ # Ascender/descender are oversized ranges baked into the font.
+ ascent, descent = font.getmetrics()
+
+ # print(f"""----- "{text}" / {font.getname()} -----""")
+ # print(f"offset_x: {offset_x} | offset_y: {offset_y})")
+ # print(f"box_left: {box_left} | box_top: {box_top} | box_right: {box_right} | box_bottom: {box_bottom}")
+ # print(f"ascent: {ascent} | descent: {descent})")
+
+ if is_text_centered:
+ text_x = int((total_width - (box_right - offset_x)) / 2) - offset_x
+ else:
+ text_x = GUIConstants.COMPONENT_PADDING
+
+ text_y = int((total_height - (ascent - offset_y)) / 2) - offset_y
+
+ return (start_x + text_x, start_y + text_y)
+
+
+
+def load_icon(icon_name: str, load_selected_variant: bool = False):
+ icon_url = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "icons", icon_name)
+ icon = Image.open(icon_url + ".png").convert("RGB")
+ if not load_selected_variant:
+ return icon
+ else:
+ icon_selected = Image.open(icon_url + "_selected.png").convert("RGB")
+ return (icon, icon_selected)
+
+
+def load_image(image_name: str):
+ image_url = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "img", image_name)
+ image = Image.open(image_url).convert("RGB")
+ return image
+
+
+
+class Fonts(Singleton):
+ font_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "fonts")
+ fonts = {}
+
+ @classmethod
+ def get_font(cls, font_name, size, file_extension: str = "ttf") -> ImageFont.FreeTypeFont:
+ # Cache already-loaded fonts
+ if font_name not in cls.fonts:
+ cls.fonts[font_name] = {}
+
+ if size not in cls.fonts[font_name]:
+ try:
+ cls.fonts[font_name][size] = ImageFont.truetype(os.path.join(cls.font_path, f"{font_name}.{file_extension}"), size)
+ except OSError as e:
+ if "cannot open resource" in str(e):
+ raise Exception(f"Font {font_name}.ttf not found: {repr(e)}")
+ else:
+ raise e
+
+ return cls.fonts[font_name][size]
+
+
+
+class TextDoesNotFitException(Exception):
+ pass
+
+
+
+@dataclass
+class BaseComponent:
+ image_draw: ImageDraw = None
+ canvas: Image = None
+
+ def __post_init__(self):
+ from seedsigner.gui import Renderer
+ self.renderer: Renderer = Renderer.get_instance()
+ self.canvas_width = self.renderer.canvas_width
+ self.canvas_height = self.renderer.canvas_height
+
+ if not self.image_draw:
+ self.set_image_draw(self.renderer.draw)
+
+ if not self.canvas:
+ self.set_canvas(self.renderer.canvas)
+
+
+ def set_image_draw(self, image_draw: ImageDraw):
+ self.image_draw = image_draw
+
+ def set_canvas(self, canvas: Image):
+ self.canvas = canvas
+
+
+ def render(self):
+ raise Exception("render() not implemented in the child class!")
+
+
+
+@dataclass
+class TextArea(BaseComponent):
+ """
+ Not to be confused with an html