Merge pull request #154 from kdmukai/ui_overhaul

v0.5.0 Pre-Release 1
This commit is contained in:
SeedSigner
2022-03-02 09:52:16 -06:00
committed by GitHub
82 changed files with 7926 additions and 3555 deletions
+2
View File
@@ -2,3 +2,5 @@
__pycache__/
src/seedsigner.egg-info/
.nova
.vscode
src/seedsigner/models/settings_definition.json
+21
View File
@@ -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 `<input>`, `<button>`, `<img>`, 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.
+67
View File
@@ -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?
+1 -1
View File
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
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!",
-16
View File
@@ -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
+1 -13
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
from .controller import Controller
+4 -2
View File
@@ -3,7 +3,9 @@ import numpy
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 @@ class Camera(Singleton):
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
+185 -251
View File
@@ -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
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
# 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)
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 @@ class Controller(Singleton):
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
# Store one working psbt in memory
controller.psbt = None
controller.psbt_parser = None
# Configure the Renderer
Renderer.configure_instance()
# 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()
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 @@ class Controller(Singleton):
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)
crash_cnt += 1
if next_destination.View_cls == MainMenuView:
# Home always wipes the back_stack
self.clear_back_stack()
self.menu_view.draw_modal(["Crashed ..."], "", "requires hard restart")
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()
# ...now apply it, if needed
if clear_history:
self.clear_back_stack()
# 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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
# 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 @@ class Controller(Singleton):
# 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 @@ class Controller(Singleton):
# 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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
### 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 @@ class Controller(Singleton):
# 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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
# 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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
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 @@ class Controller(Singleton):
return Path.MAIN_MENU
"""
+1
View File
@@ -0,0 +1 @@
from .renderer import Renderer
+971
View File
@@ -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 <textarea>! This is a rect-delimited text
display box that could be the main body content of a screen or a sub-zone
of text within a more complicated page.
Auto-calcs line breaks based on input text and font (somewhat naive; only
breaks on spaces. Future enhancement could break on hyphens, too).
Raises an Exception if the text won't fit in the given rect.
Attrs with defaults must be listed last.
"""
text: str = "My text content"
width: int = None # TODO: Implement autosize width?
height: int = None # None = special case: autosize to min height
screen_x: int = 0
screen_y: int = 0
background_color: str = "black"
font_name: str = GUIConstants.BODY_FONT_NAME
font_size: int = GUIConstants.BODY_FONT_SIZE
font_color: str = GUIConstants.BODY_FONT_COLOR
edge_padding: int = GUIConstants.EDGE_PADDING
is_text_centered: bool = True
supersampling_factor: int = 1
auto_line_break: bool = True
def __post_init__(self):
super().__post_init__()
if not self.width:
self.width = self.canvas_width
if self.font_size < 18 and (not self.supersampling_factor or self.supersampling_factor == 1):
self.supersampling_factor = 2
self.font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size))
self.supersampled_width = self.supersampling_factor * self.width
if self.height is None:
self.supersampled_height = None
else:
self.supersampled_height = self.supersampling_factor * self.height
self.line_spacing = int(GUIConstants.BODY_LINE_SPACING * self.font_size)
# We have to figure out if and where to make line breaks in the text so that it
# fits in its bounding rect (plus accounting for edge padding) using its given
# font.
full_text_width, self.text_height = self.font.getsize(self.text)
# Stores each line of text and its rendering starting x-coord
self.text_lines = []
self.text_width = 0
def _add_text_line(text, width):
if self.is_text_centered:
text_x = int((self.supersampled_width - width) / 2)
else:
text_x = self.supersampling_factor * self.edge_padding
self.text_lines.append({"text": text, "text_x": text_x})
if width > self.text_width:
self.text_width = width
if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor):
# The whole text fits on one line
_add_text_line(self.text, full_text_width)
if self.height is None:
self.text_y = 0
self.supersampled_height = self.text_height
else:
# Vertical starting point calc is easy in this case
self.text_y = int(((self.supersampling_factor * self.supersampled_height) - self.text_height) / 2)
self.text_width = full_text_width
else:
# Have to calc how to break text into multiple lines
def _binary_len_search(min_index, max_index):
# Try the middle of the range
index = math.ceil((max_index + min_index) / 2)
if index == 0:
# Handle edge case where there's only one word in the last line
index = 1
tw, th = self.font.getsize(" ".join(words[0:index]))
if tw > self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor):
# Candidate line is still too long. Restrict search range down.
if min_index + 1 == index:
# There's no room left to search
index -= 1
return _binary_len_search(min_index, index)
elif index == max_index:
# We have converged
return (index, tw)
else:
# Candidate line is possibly shorter than necessary.
return _binary_len_search(index, max_index)
if len(self.text.split()) == 1:
# No whitespace chars to split on!
raise TextDoesNotFitException("Text cannot fit in target rect with this font/size")
words = self.text.split()
while words:
(index, tw) = _binary_len_search(0, len(words))
_add_text_line(" ".join(words[0:index]), tw)
words = words[index:]
total_text_height = self.text_height * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1)
if self.height is not None and total_text_height > self.supersampled_height + 2 * GUIConstants.COMPONENT_PADDING * self.supersampling_factor:
raise TextDoesNotFitException("Text cannot fit in target rect with this font/size")
else:
self.supersampled_height = total_text_height
# Vertically center the text's starting point
self.text_y = int((self.supersampled_height - total_text_height) / 2)
# Make sure the width/height that get referenced outside this obj are
# specified and restored to their normal scaling factor.
self.height = int(self.supersampled_height / self.supersampling_factor)
self.width = int(self.text_width / self.supersampling_factor)
def render(self):
# Render to a temp img scaled up by self.supersampling_factor, then resize down
# with bicubic resampling.
# TODO: Store resulting super-sampled image as a member var in __post_init__ and
# just re-paste it here.
img = Image.new("RGB", (self.supersampled_width, self.supersampled_height), self.background_color)
draw = ImageDraw.Draw(img)
cur_y = self.text_y
for line in self.text_lines:
draw.text((line["text_x"], cur_y), line["text"], fill=self.font_color, font=self.font)
cur_y += self.text_height + self.line_spacing
resized = img.resize((int(self.supersampled_width / self.supersampling_factor), self.height), Image.LANCZOS)
resized = resized.filter(ImageFilter.SHARPEN)
self.canvas.paste(resized, (self.screen_x, self.screen_y))
@dataclass
class Icon(BaseComponent):
screen_x: int = 0
screen_y: int = 0
icon_name: str = SeedSignerCustomIconConstants.BITCOIN_LOGO
icon_size: int = GUIConstants.ICON_FONT_SIZE
icon_color: str = GUIConstants.BODY_FONT_COLOR
def __post_init__(self):
super().__post_init__()
if SeedSignerCustomIconConstants.MIN_VALUE <= self.icon_name and self.icon_name <= SeedSignerCustomIconConstants.MAX_VALUE:
self.icon_font = Fonts.get_font(GUIConstants.ICON_FONT_NAME__SEEDSIGNER, self.icon_size, file_extension="otf")
else:
self.icon_font = Fonts.get_font(GUIConstants.ICON_FONT_NAME__FONT_AWESOME, self.icon_size, file_extension="otf")
# Set width/height based on exact pixels that are rendered
(left, top, self.width, self.height) = self.icon_font.getbbox(self.icon_name, anchor="lt")
def render(self):
self.image_draw.text(
(self.screen_x, self.screen_y),
text=self.icon_name,
font=self.icon_font,
fill=self.icon_color,
anchor="lt", # left, top anchor to avoid "ascender" gap space
)
@dataclass
class IconTextLine(BaseComponent):
"""
Renders an icon next to a label/value pairing (or just value)
"""
icon_name: str = SeedSignerCustomIconConstants.CIRCLE_CHECK
icon_size: int = GUIConstants.ICON_FONT_SIZE
icon_color: str = GUIConstants.BODY_FONT_COLOR
label_text: str = None
value_text: str = "73c5da0a"
font_size: int = GUIConstants.BODY_FONT_SIZE
is_text_centered: bool = False
screen_x: int = 0
screen_y: int = 0
def __post_init__(self):
super().__post_init__()
self.icon = Icon(
image_draw=self.image_draw,
canvas=self.canvas,
screen_x=self.screen_x,
screen_y=0, # We'll update this later below
icon_name=self.icon_name,
icon_size=self.icon_size,
icon_color=self.icon_color
)
self.icon_horizontal_spacer = int(GUIConstants.COMPONENT_PADDING/2)
text_screen_x = self.screen_x + self.icon.width + self.icon_horizontal_spacer
if self.label_text:
self.label_textarea = TextArea(
image_draw=self.image_draw,
canvas=self.canvas,
text=self.label_text,
font_size=GUIConstants.BODY_FONT_SIZE - 2,
font_color="#666",
edge_padding=0,
is_text_centered=False,
auto_line_break=False,
screen_x=text_screen_x,
screen_y=self.screen_y,
)
else:
self.label_textarea = None
value_textarea_screen_y = self.screen_y
if self.label_text:
value_textarea_screen_y += self.label_textarea.height
self.value_textarea = TextArea(
image_draw=self.image_draw,
canvas=self.canvas,
text=self.value_text,
font_size=self.font_size,
edge_padding=0,
is_text_centered=False,
auto_line_break=False,
screen_x=text_screen_x,
screen_y=value_textarea_screen_y,
)
if self.label_text:
self.height = self.label_textarea.height + self.value_textarea.height
icon_y = self.screen_y + int((self.height - self.icon.height) / 2)
max_textarea_width = max(self.label_textarea.width, self.value_textarea.width)
else:
self.height = self.value_textarea.height
icon_y = self.screen_y
max_textarea_width = self.value_textarea.width
# Now we can update the icon's y position
self.icon.screen_y = icon_y
if self.is_text_centered:
total_width = max_textarea_width + self.icon.width + self.icon_horizontal_spacer
self.icon.screen_x = self.screen_x + int((self.canvas_width - self.screen_x - total_width) / 2)
if self.label_text:
self.label_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer
self.value_textarea.screen_x = self.icon.screen_x + self.icon.width + self.icon_horizontal_spacer
self.height = self.value_textarea.screen_y + self.value_textarea.height - self.screen_y
def render(self):
if self.label_textarea:
self.label_textarea.render()
self.value_textarea.render()
self.icon.render()
@dataclass
class FormattedAddress(BaseComponent):
"""
Display a Bitcoin address in a "{first 7} {middle} {last 7}" formatted view with
a possible/likely line break in the middle and using a fixed-width font:
bc1q567 abcdefg1234567abcdefg
1234567abcdefg1234567 1234567
single sig taproot: 62 chars
multisig native segwit: 62 chars
multisig nested segwit: 34 chars
single sig native segwit: 42 chars
* max_lines: forces truncation on long addresses to fit
"""
width: int = 0
screen_x: int = 0
screen_y: int = 0
address: str = None
max_lines: int = None
font_name: str = GUIConstants.FIXED_WIDTH_FONT_NAME
font_size: int = 24
font_accent_color: str = GUIConstants.ACCENT_COLOR
font_base_color: str = GUIConstants.LABEL_FONT_COLOR
def __post_init__(self):
super().__post_init__()
if self.width == 0:
self.width = self.renderer.canvas_width
self.font = Fonts.get_font(self.font_name, self.font_size)
self.accent_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, self.font_size)
# Fixed width font means we only have to measure one max-height character
char_width, char_height = self.font.getsize("Q")
n = 7
display_str = f"{self.address[:n]} {self.address[n:-1*n]} {self.address[-1*n:]}"
self.text_params = []
cur_y = 0
if self.max_lines == 1:
addr_lines_x = int((self.width - char_width*(2*n + 3))/2)
# Can only show first/last n truncated
self.text_params.append((
(addr_lines_x, cur_y),
display_str.split()[0],
self.font_accent_color,
self.accent_font
))
self.text_params.append((
(
addr_lines_x + char_width*n,
cur_y
),
"...",
self.font_base_color,
self.font
))
self.text_params.append((
(
addr_lines_x + char_width*(n + 3),
cur_y
),
display_str.split()[2],
self.font_accent_color,
self.accent_font
))
cur_y += char_height
else:
max_chars_per_line = math.floor(self.width / char_width)
num_lines = math.ceil(len(display_str)/max_chars_per_line)
# Recalc chars per line to even out all x lines to the same width
max_chars_per_line = math.ceil(len(display_str) / num_lines)
remaining_display_str = display_str
addr_lines_x = self.screen_x + int((self.width - char_width*max_chars_per_line) / 2)
for i in range(0, num_lines):
cur_str = remaining_display_str[:max_chars_per_line]
if i == 0:
# Split cur_str into two sections to highlight first_n
self.text_params.append((
(addr_lines_x, cur_y),
cur_str.split()[0],
self.font_accent_color,
self.accent_font
))
self.text_params.append((
(
addr_lines_x + char_width*(n+1),
cur_y
),
cur_str.split()[1],
self.font_base_color,
self.font
))
elif i == num_lines - 1:
# Split cur_str into two sections to highlight last_n
self.text_params.append((
(
addr_lines_x,
cur_y
),
cur_str.split()[0],
self.font_base_color,
self.font
))
self.text_params.append((
(
addr_lines_x + char_width*(len(cur_str) - (n)),
cur_y
),
cur_str.split()[1],
self.font_accent_color,
self.accent_font
))
elif self.max_lines and i == self.max_lines - 1:
# We can't fit the whole address. Have to truncate here and highlight the
# last_n.
self.text_params.append((
(
addr_lines_x,
cur_y
),
cur_str[:-1*n - 3] + "...",
self.font_base_color,
self.font
))
self.text_params.append((
(
addr_lines_x + char_width*(len(cur_str) - (n)),
cur_y
),
self.address[-1*n:],
self.font_accent_color,
self.accent_font
))
cur_y += char_height
break
else:
# This is a middle line with no highlighted section
self.text_params.append((
(
addr_lines_x,
cur_y
),
cur_str,
self.font_base_color,
self.font
))
remaining_display_str = remaining_display_str[max_chars_per_line:]
cur_y += char_height
self.height = cur_y
def render(self):
for p in self.text_params:
self.image_draw.text((p[0][0], p[0][1] + self.screen_y), text=p[1], fill=p[2], font=p[3])
@dataclass
class Button(BaseComponent):
# TODO: Rename the seedsigner.helpers.Buttons class (to Inputs?) to reduce confusion
# with this GUI component.
"""
Attrs with defaults must be listed last.
"""
text: str = "Button Label"
screen_x: int = 0
screen_y: int = 0
scroll_y: int = 0
width: int = None
height: int = None
icon_name: str = None # Optional icon to accompany the text
icon_size: int = GUIConstants.ICON_INLINE_FONT_SIZE
icon_color: str = GUIConstants.BUTTON_FONT_COLOR
selected_icon_color: str = "black"
icon_y_offset: int = 0
is_icon_inline: bool = True # True = render next to text; False = render centered above text
text_y_offset: int = 0
background_color: str = "#2c2c2c"
selected_color: str = GUIConstants.ACCENT_COLOR
font_name: str = GUIConstants.BUTTON_FONT_NAME
font_size: int = GUIConstants.BUTTON_FONT_SIZE
# font_color: str = "#fcfcfc"
font_color: str = GUIConstants.BUTTON_FONT_COLOR
selected_font_color: str = "black"
is_text_centered: bool = True
is_selected: bool = False
def __post_init__(self):
super().__post_init__()
if not self.width:
self.width = self.canvas_width
if not self.height:
self.height = GUIConstants.BUTTON_HEIGHT
if not self.icon_color:
self.icon_color = GUIConstants.BUTTON_FONT_COLOR
self.font = Fonts.get_font(self.font_name, self.font_size)
if self.text:
if self.is_text_centered:
self.text_x = int(self.width/2)
self.text_anchor = "ms" # centered horizontally, baseline
else:
self.text_x = GUIConstants.COMPONENT_PADDING
self.text_anchor = "ls" # left, baseline
# Calc true pixel height (any anchor from "baseline" will work)
(left, top, self.text_width, bottom) = self.font.getbbox(self.text, anchor="ls")
# print(f"left: {left} | top: {top} | right: {self.text_width} | bottom: {bottom}")
# Note: "top" is negative when measured from a "baseline" anchor
self.text_height = -1 * top
# TODO: Only apply screen_y at render
if self.text_y_offset:
self.text_y = self.text_y_offset + self.text_height
else:
self.text_y = self.height - int((self.height - self.text_height)/2)
# Preload the icon and its "_selected" variant
if self.icon_name:
icon_padding = GUIConstants.COMPONENT_PADDING
self.icon = Icon(icon_name=self.icon_name, icon_size=self.icon_size, icon_color=self.icon_color)
self.icon_selected = Icon(icon_name=self.icon_name, icon_size=self.icon_size, icon_color=self.selected_icon_color)
if self.is_icon_inline:
# TODO: Only apply screen_* at render
if self.is_text_centered:
# Shift the text's centering
if self.text:
self.text_x += int((self.icon.width + icon_padding) / 2)
self.icon_x = self.text_x - int(self.text_width/2) - (self.icon.width + icon_padding)
else:
self.icon_x = math.ceil((self.width - self.icon.width)/2)
else:
if self.text:
self.text_x += self.icon.width + icon_padding
self.icon_x = GUIConstants.COMPONENT_PADDING
else:
self.icon_x = int((self.width - self.icon.width) / 2)
if self.icon_y_offset:
self.icon_y = self.icon_y_offset
else:
# print(f"self.icon_name: {self.icon_name} | self.height: {self.height} | self.icon.height: {self.icon.height}")
# print(f"\t\tself.width: {self.width} | self.icon.width: {self.icon.width}")
self.icon_y = math.ceil((self.height - self.icon.height)/2)
self.icon.screen_x = self.icon_x
self.icon_selected.screen_x = self.icon_x
def render(self):
if self.is_selected:
background_color = self.selected_color
font_color = self.selected_font_color
else:
background_color = self.background_color
font_color = self.font_color
self.image_draw.rounded_rectangle((self.screen_x, self.screen_y - self.scroll_y, self.screen_x + self.width, self.screen_y + self.height - self.scroll_y), fill=background_color, radius=8)
if self.text:
self.image_draw.text(
(self.screen_x + self.text_x, self.screen_y + self.text_y - self.scroll_y),
self.text,
fill=font_color,
font=self.font,
anchor=self.text_anchor
)
if self.icon_name:
icon = self.icon
if self.is_selected:
icon = self.icon_selected
icon.screen_y = self.screen_y + self.icon_y - self.scroll_y
icon.screen_x = self.screen_x + self.icon_x
icon.render()
@dataclass
class CheckedSelectionButton(Button):
is_checked: bool = False
def __post_init__(self):
self.is_text_centered = False
self.icon_name = FontAwesomeIconConstants.SOLID_CIRCLE_CHECK
self.icon_color = "#00dd00"
super().__post_init__()
if not self.is_checked:
# Remove the checkmark icon but leave the text_x spacing as-is
self.icon_name = None
self.icon = None
self.icon_selected = None
@dataclass
class CheckboxButton(Button):
is_checked: bool = False
def __post_init__(self):
self.is_text_centered = False
if self.is_checked:
self.icon_name = FontAwesomeIconConstants.SQUARE_CHECK
self.icon_color = "#00dd00"
else:
self.icon_name = FontAwesomeIconConstants.SQUARE
self.icon_color = GUIConstants.BODY_FONT_COLOR
super().__post_init__()
@dataclass
class IconButton(Button):
"""
A button that is just an icon (e.g. the BACK arrow)
"""
icon_size: int = GUIConstants.ICON_INLINE_FONT_SIZE
text: str = None
is_icon_inline: bool = False
@dataclass
class LargeIconButton(IconButton):
"""
A button that is primarily a big icon (e.g. the Home screen buttons) w/text below
the icon.
"""
icon_size: int = GUIConstants.ICON_LARGE_BUTTON_SIZE
icon_y_offset: int = GUIConstants.COMPONENT_PADDING
@dataclass
class TopNav(BaseComponent):
text: str = "Screen Title"
width: int = None
height: int = GUIConstants.TOP_NAV_HEIGHT
background_color: str = GUIConstants.BACKGROUND_COLOR
font_name: str = GUIConstants.TOP_NAV_TITLE_FONT_NAME
font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE
font_color: str = "#fcfcfc"
show_left_button: bool = True
left_button_icon_name: str = SeedSignerCustomIconConstants.LARGE_CHEVRON_LEFT
left_button_icon_color: str = GUIConstants.BUTTON_FONT_COLOR
show_right_button: bool = False
right_button_icon_name: str = FontAwesomeIconConstants.POWER_OFF
right_button_icon_color: str = GUIConstants.BUTTON_FONT_COLOR
is_selected: bool = False
def __post_init__(self):
super().__post_init__()
if not self.width:
self.width = self.canvas_width
self.font = Fonts.get_font(self.font_name, self.font_size)
if self.show_left_button:
self.back_button = IconButton(
icon_name=self.left_button_icon_name,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
icon_color=self.left_button_icon_color,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=GUIConstants.EDGE_PADDING,
width=GUIConstants.TOP_NAV_BUTTON_SIZE,
height=GUIConstants.TOP_NAV_BUTTON_SIZE,
)
if self.show_right_button:
self.power_button = IconButton(
icon_name=self.right_button_icon_name,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
icon_color=self.right_button_icon_color,
screen_x=self.width - GUIConstants.TOP_NAV_BUTTON_SIZE - GUIConstants.EDGE_PADDING,
screen_y=GUIConstants.EDGE_PADDING,
width=GUIConstants.TOP_NAV_BUTTON_SIZE,
height=GUIConstants.TOP_NAV_BUTTON_SIZE,
)
# TODO: Complete this code if we want title font size to dynamically size itself
# if not self.font:
# # Pre-calc how much room the title bar text will take up. Use the biggest font
# # that will fit.
# max_font_width = self.width - self.back_button.width - GUIConstants.COMPONENT_PADDING - 2*GUIConstants.EDGE_PADDING
# for font_size in range(GUIConstants.):
# self.text_width, self.text_height = font.getsize(self.text)
# if self.text_width < max_font_width:
# self.font = font
# self.text_x = int((self.width - self.text_width) / 2)
# self.text_y = int((self.height - self.text_height) / 2)
# break
(self.text_x, self.text_y) = calc_text_centering(
font=self.font,
text=self.text,
is_text_centered=True,
total_width=self.width,
total_height=self.height,
start_x=0,
start_y=0
)
if self.show_left_button:
# Don't let the title intrude on the BACK button
min_x = self.back_button.screen_x + self.back_button.width + GUIConstants.COMPONENT_PADDING
if self.text_x < min_x:
self.text_x = min_x
@property
def selected_button(self):
from .screens import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON
if not self.is_selected:
return None
if self.show_left_button:
return RET_CODE__BACK_BUTTON
if self.show_right_button:
return RET_CODE__POWER_BUTTON
def render(self):
if self.show_left_button:
self.back_button.is_selected = self.is_selected
self.back_button.render()
if self.show_right_button:
self.power_button.is_selected = self.is_selected
self.power_button.render()
self.image_draw.text(
(self.text_x, self.text_y),
self.text,
font=self.font,
fill=self.font_color,
stroke_width=1,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
)
def linear_interp(a, b, t):
return (
int((1.0 - t)*a[0] + t*b[0]),
int((1.0 - t)*a[1] + t*b[1])
)
def calc_bezier_curve(p1: Tuple[int,int], p2: Tuple[int,int], p3: Tuple[int,int], segments: int) -> List[Tuple[Tuple[int,int], Tuple[int,int]]]:
"""
Calculates the points of a bezier curve between points p1 and p3 with p2 as a
control point influencing the amount of curve deflection.
Bezier curve calcs start with two trivial linear interpolations of each line
segment:
L1 = p1 to p2 = (1 - t)*p1 + t*p2
L2 = p2 to p3 = (1 - t)*p2 + t*p3
And then interpolate over the two line segments
Q1 = (1 - t)*L1(t) + t*L2(t)
"""
t_step = 1.0 / segments
points = [p1]
for i in range(1, segments + 1):
t = t_step * i
if i == segments:
points.append(p3)
break
l1_t = linear_interp(p1, p2, t)
l2_t = linear_interp(p2, p3, t)
q1 = linear_interp(l1_t, l2_t, t)
points.append(q1)
return points
@@ -1,7 +1,9 @@
from dataclasses import dataclass
from PIL import Image, ImageDraw
from PIL import Image, ImageDraw, ImageFont
from typing import Tuple
from . import B
from seedsigner.gui.components import Fonts, GUIConstants
from seedsigner.helpers.buttons import B
@@ -110,31 +112,31 @@ class Keyboard:
if Keyboard.ADDITIONAL_KEYS[self.code]["font"] == Keyboard.COMPACT_KEY_FONT:
font = self.keyboard.additonal_key_compact_font
outline_color = "#333"
if not self.is_active:
rect_color = self.keyboard.background_color
font_color = "#666" # Show the letter but render as gray
font_color = "#e8e8e8" # Show the letter but render as gray
if self.is_selected:
# Inactive, selected just gets highlighted outline
outline_color = self.keyboard.highlight_color
elif self.is_selected:
rect_color = self.keyboard.highlight_color # Render solid background with the UI's hero color
font_color = self.keyboard.background_color
font_color = "black"
else:
if self.is_additional_key:
rect_color = "#111"
# rect_color = "#111"
rect_color = self.keyboard.background_color
else:
rect_color = self.keyboard.background_color
font_color = self.keyboard.highlight_color
font_color = "#e8e8e8"
self.keyboard.draw.rounded_rectangle((self.screen_x, self.screen_y, self.screen_x + self.keyboard.x_width * self.size - 1, self.screen_y + self.keyboard.y_height), outline=outline_color, fill=rect_color, radius=4)
self.keyboard.draw.rounded_rectangle((self.screen_x, self.screen_y, self.screen_x + self.keyboard.x_width * self.size - 1, self.screen_y + self.keyboard.y_height), fill=rect_color, radius=4)
tw, th = self.keyboard.draw.textsize(self.letter, font=font)
self.keyboard.draw.text((self.screen_x + int((self.keyboard.x_width * self.size - tw) / 2), self.screen_y + int((self.keyboard.y_height - th)/2)), self.letter, fill=font_color, font=font)
self.keyboard.draw.text((self.screen_x + int((self.keyboard.x_width * self.size - tw) / 2), self.screen_y + int((self.keyboard.y_height - th)/2) - 1), self.letter, fill=font_color, font=font)
def __init__(self,
draw,
def __init__(self,
draw: ImageDraw,
charset="1234567890abcdefghijklmnopqrstuvwxyz",
selected_char="a",
rows=4,
@@ -143,15 +145,12 @@ class Keyboard:
font=None,
additional_keys=[KEY_BACKSPACE],
auto_wrap=[WRAP_TOP, WRAP_BOTTOM, WRAP_LEFT, WRAP_RIGHT],
render_now=True):
render_now=True,
highlight_color: str = GUIConstants.ACCENT_COLOR):
"""
`auto_wrap` specifies which edges the keyboard is allowed to loop back when
navigating past the end.
"""
# Import here to avoid circular import problems
from seedsigner.views import View
self.draw = draw
self.charset = charset
self.rows = rows
@@ -160,10 +159,10 @@ class Keyboard:
if font:
self.font = font
else:
self.font = View.ROBOTOCONDENSED_REGULAR_24
self.font = Fonts.get_font("RobotoCondensed-Regular", 24)
self.auto_wrap = auto_wrap
self.background_color = "black"
self.highlight_color = View.color
self.background_color = "#2c2c2c"
self.highlight_color = highlight_color
# Does the specified layout work?
additional_key_spaces = 0
@@ -177,7 +176,7 @@ class Keyboard:
# Set up the rendering and state params
self.active_keys = list(self.charset)
self.additonal_key_compact_font = View.ROBOTOCONDENSED_BOLD_18
self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold", 18)
self.x_start = rect[0]
self.y_start = rect[1]
self.x_gap = 1
@@ -257,7 +256,7 @@ class Keyboard:
that section, as in when changing `active_keys` or swapping to alternate
charsets (e.g. alpha to special symbols).
Does NOT call View.DispShowImage to avoid multiple calls on the same screen.
Does NOT call self.renderer.show_image to avoid multiple calls on the same screen.
"""
# Start with a clear screen
self.draw.rectangle(self.rect, outline=0, fill=0)
@@ -352,7 +351,7 @@ class Keyboard:
Returns the character currently highlighted or one of the EXIT_* codes if the
user has navigated off the keyboard past an edge that is not in `auto_wrap`.
Does NOT call View.DispShowImage to avoid multiple calls on the same screen.
Does NOT call self.renderer.show_image to avoid multiple calls on the same screen.
"""
key = self.get_key_at(self.selected_key["x"], self.selected_key["y"])
@@ -477,10 +476,10 @@ class TextEntryDisplayConstants:
@dataclass
class TextEntryDisplay(TextEntryDisplayConstants):
draw: any
rect: (int,int,int,int)
font: any
font_color: any
canvas: Image
rect: Tuple[int,int,int,int]
font: ImageFont
font_color: str
cursor_mode: str = TextEntryDisplayConstants.CURSOR_MODE__BLOCK
is_centered: bool = True
has_outline: bool = False
@@ -497,9 +496,6 @@ class TextEntryDisplay(TextEntryDisplayConstants):
def render(self, cur_text=None, cursor_position=None):
# Import here to avoid circular import problems
from seedsigner.views import View
""" Render the live text entry display """
if cur_text is not None:
self.cur_text = cur_text
@@ -580,5 +576,5 @@ class TextEntryDisplay(TextEntryDisplayConstants):
draw.line((cursor_bar_x - cursor_bar_serif_half_width, self.height - 3, cursor_bar_x + cursor_bar_serif_half_width, self.height - 3), fill=cursor_bar_color)
# Paste the display onto the main canvas
View.canvas.paste(image, (self.rect[0], self.rect[1]))
self.canvas.paste(image, (self.rect[0], self.rect[1]))
+208
View File
@@ -0,0 +1,208 @@
from PIL import Image, ImageDraw, ImageFont
from threading import Lock
from seedsigner.gui.components import Fonts
from seedsigner.helpers.ST7789 import ST7789
from seedsigner.models import ConfigurableSingleton
class Renderer(ConfigurableSingleton):
buttons = None
canvas_width = 0
canvas_height = 0
canvas: Image = None
draw: ImageDraw = None
disp = None
lock = Lock()
@classmethod
def configure_instance(cls, config={}):
from seedsigner.models.settings import Settings
super().configure_instance(config)
# Instantiate the one and only Renderer instance
renderer = cls.__new__(cls)
cls._instance = renderer
# TODO: Use Settings values to wire up diff hardware params
settings = Settings.get_instance()
# Eventually we'll be able to plug in other display controllers
renderer.disp = ST7789()
renderer.canvas_width = renderer.disp.width
renderer.canvas_height = renderer.disp.height
renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height))
renderer.draw = ImageDraw.Draw(renderer.canvas)
def show_image(self, image=None, alpha_overlay=None):
if image == None:
image = self.canvas
if alpha_overlay:
image = Image.alpha_composite(image, alpha_overlay)
# Always write to the current canvas, rather than trying to replace it
self.canvas.paste(image)
self.disp.ShowImage(image, 0, 0)
def show_image_pan(self, image, start_x, start_y, end_x, end_y, rate, alpha_overlay=None):
cur_x = start_x
cur_y = start_y
rate_x = rate
rate_y = rate
if end_x - start_x < 0:
rate_x = rate_x * -1
if end_y - start_y < 0:
rate_y = rate_y * -1
while (cur_x != end_x or cur_y != end_y) and (rate_x != 0 or rate_y != 0):
cur_x += rate_x
if (rate_x > 0 and cur_x > end_x) or (rate_x < 0 and cur_x < end_x):
# We've moved too far; back up and undo that last move.
cur_x -= rate_x
rate_x = 0
cur_y += rate_y
if (rate_y > 0 and cur_y > end_y) or (rate_y < 0 and cur_y < end_y):
# We've moved too far; back up and undo that last move.
cur_y -= rate_y
rate_y = 0
crop = image.crop((cur_x, cur_y, cur_x + self.canvas_width, cur_y + self.canvas_height))
if alpha_overlay:
crop = Image.alpha_composite(crop, alpha_overlay)
# Always keep a copy of the current display in the canvas
self.canvas.paste(crop)
self.disp.ShowImage(crop, 0, 0)
def show_image_with_text(self, image, text, font=None, text_color="GREY", text_background=None):
image_copy = image.copy().convert("RGBA")
draw = ImageDraw.Draw(image_copy)
text_overlay = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
text_overlay_draw = ImageDraw.Draw(text_overlay)
if not font:
font = Fonts.get_font("Assistant-Medium", 18)
tw, th = text_overlay_draw.textsize(text, font=font)
if text_background:
text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background)
text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font)
self.show_image(image_copy, alpha_overlay=text_overlay)
# TODO: Should probably move this to screens.py
def draw_modal(self, lines = [], title = "", bottom = "") -> None:
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
if len(title) > 0:
tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
if len(bottom) > 0:
tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
if len(lines) == 1:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
elif len(lines) == 2:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
elif len(lines) == 3:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
elif len(lines) == 4:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
self.show_image()
return
# TODO: Should probably move this to templates.py
def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None:
self.draw_prompt_custom("", "Yes ", "No ", lines, title, bottom)
return
# TODO: Should probably move this to templates.py
def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None:
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
if len(title) > 0:
tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
if len(bottom) > 0:
tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
if len(lines) == 1:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
elif len(lines) == 2:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
elif len(lines) == 3:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
elif len(lines) == 4:
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
a_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(a_txt)[0]
self.draw.text((a_x_offset, 39 + 0), a_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
b_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(b_txt)[0]
self.draw.text((b_x_offset , 39 + 60), b_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
c_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(c_txt)[0]
self.draw.text((c_x_offset , 39 + 120), c_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
self.show_image()
return
def display_blank_screen(self):
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
self.show_image()
+1
View File
@@ -0,0 +1 @@
from .screen import *
+749
View File
@@ -0,0 +1,749 @@
from dataclasses import dataclass
from lzma import is_check_supported
from PIL import Image, ImageDraw, ImageFilter
from typing import List
from seedsigner.gui.renderer import Renderer
from seedsigner.helpers.threads import BaseThread
from .screen import ButtonListScreen, WarningScreen
from ..components import (Button, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerCustomIconConstants, TextArea,
calc_bezier_curve, linear_interp)
@dataclass
class PSBTOverviewScreen(ButtonListScreen):
spend_amount: int = 0
change_amount: int = 0
fee_amount: int = 0
num_inputs: int = 0
num_self_transfer_outputs: int = 0
num_change_outputs: int = 0
destination_addresses: List[str] = None
def __post_init__(self):
# Customize defaults
self.title = "Review PSBT"
self.is_bottom_list = True
self.button_data = ["Review Details"]
# This screen can take a while to load while parsing the PSBT
self.show_loading_screen = True
super().__post_init__()
# Prep the headline amount being spent in large callout
# icon_text_lines_y = self.components[-1].screen_y + self.components[-1].height
icon_text_lines_y = self.top_nav.height
if not self.destination_addresses:
# This is a self-transfer
spend_amount = self.change_amount
else:
spend_amount = self.spend_amount
if spend_amount <= 1e6:
amount_display = f"{spend_amount:,} sats"
else:
amount_display = f"{spend_amount/1e8:,} btc"
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.BITCOIN_LOGO,
icon_color=GUIConstants.ACCENT_COLOR,
icon_size=34,
is_text_centered=True,
value_text=f"{amount_display}",
font_size=24,
screen_y=icon_text_lines_y,
))
# Prep the transaction flow chart
self.chart_x = 0
self.chart_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
chart_height = self.buttons[0].screen_y - self.chart_y - GUIConstants.COMPONENT_PADDING
# We need to supersample the whole panel so that small/thin elements render
# clearly.
ssf = 4 # super-sampling factor
# Set up our temp supersampled rendering surface
image = Image.new(
"RGB",
(self.canvas_width * ssf, chart_height * ssf),
GUIConstants.BACKGROUND_COLOR
)
draw = ImageDraw.Draw(image)
font_size = GUIConstants.BODY_FONT_MIN_SIZE * ssf
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, font_size)
tw, chart_text_height = font.getsize("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890") # All possible chars for max range
vertical_center = int(image.height/2)
# Supersampling renders thin elements poorly if they land on an even line before scaling down
if vertical_center % 2 == 1:
vertical_center += 1
association_line_color = "#666"
association_line_width = 3*ssf
curve_steps = 4
chart_font_color = "#ddd"
# First calculate how wide the inputs col will be
inputs_column = []
if self.num_inputs == 1:
inputs_column.append("1 input")
elif self.num_inputs > 5:
inputs_column.append("input 1")
inputs_column.append("input 2")
inputs_column.append("[ ... ]")
inputs_column.append(f"input {self.num_inputs-1}")
inputs_column.append(f"input {self.num_inputs}")
else:
for i in range(0, self.num_inputs):
inputs_column.append(f"input {i+1}")
max_inputs_text_width = 0
for input in inputs_column:
tw, th = font.getsize(input)
max_inputs_text_width = max(tw, max_inputs_text_width)
# Given how wide we want our curves on each side to be...
curve_width = 4*GUIConstants.COMPONENT_PADDING*ssf
# ...and the minimum center divider width...
center_bar_width = 2*GUIConstants.COMPONENT_PADDING*ssf
# We can calculate how wide the destination col can be
max_destination_col_width = image.width - (GUIConstants.EDGE_PADDING*ssf + max_inputs_text_width + \
int(GUIConstants.COMPONENT_PADDING*ssf/4) + curve_width + \
center_bar_width + \
curve_width + int(GUIConstants.COMPONENT_PADDING*ssf/4) + \
GUIConstants.EDGE_PADDING*ssf)
# if self.num_inputs == 1:
# # Use up more of the space on the input side
# max_destination_col_width += curve_width
# Now let's maximize the actual destination col by adjusting our addr truncation
def calculate_destination_col_width(truncate_at: int):
def truncate_destination_addr(addr):
return f"{addr[:truncate_at]}..."
destination_column = []
if len(self.destination_addresses) <= 3:
for addr in self.destination_addresses:
destination_column.append(truncate_destination_addr(addr))
else:
# destination_column.append(f"{len(self.destination_addresses)} recipients")
destination_column.append(f"recipient 1")
destination_column.append(f"[ ... ]")
destination_column.append(f"recipient {len(self.destination_addresses)}")
if self.num_self_transfer_outputs > 0:
for i in range(0, self.num_self_transfer_outputs):
destination_column.append(f"self-transfer")
destination_column.append(f"fee")
if self.num_change_outputs > 0:
for i in range(0, self.num_change_outputs):
destination_column.append("change")
max_destination_text_width = 0
for destination in destination_column:
tw, th = font.getsize(destination)
max_destination_text_width = max(tw, max_destination_text_width)
return (max_destination_text_width, destination_column)
if len(self.destination_addresses) > 3:
# We're not going to display any destination addrs so truncation doesn't matter
(destination_text_width, destination_column) = calculate_destination_col_width(truncate_at=0)
else:
# Steadliy widen out the destination column until we run out of space
for i in range(6, 13):
(new_width, new_col_text) = calculate_destination_col_width(truncate_at=i)
if new_width > max_destination_col_width:
break
destination_text_width = new_width
destination_column = new_col_text
destination_col_x = image.width - (destination_text_width + GUIConstants.EDGE_PADDING*ssf)
# Now we can finalize our center bar values
center_bar_x = GUIConstants.EDGE_PADDING*ssf + max_inputs_text_width + int(GUIConstants.COMPONENT_PADDING*ssf/4) + curve_width
# Center bar stretches to fill any excess width
center_bar_width = destination_col_x - int(GUIConstants.COMPONENT_PADDING*ssf/4) - curve_width - center_bar_x
# Position each input row
num_rendered_inputs = len(inputs_column)
if self.num_inputs == 1:
inputs_y = vertical_center - int(chart_text_height/2)
inputs_y_spacing = 0 # Not used
else:
inputs_y = int((image.height - num_rendered_inputs*chart_text_height) / (num_rendered_inputs + 1))
inputs_y_spacing = inputs_y + chart_text_height
# Don't render lines from an odd number
if inputs_y % 2 == 1:
inputs_y += 1
if inputs_y_spacing % 2 == 1:
inputs_y_spacing += 1
inputs_conjunction_x = center_bar_x
inputs_x = GUIConstants.EDGE_PADDING*ssf
input_curves = []
for input in inputs_column:
# Calculate right-justified input display
tw, th = font.getsize(input)
cur_x = inputs_x + max_inputs_text_width - tw
draw.text(
(cur_x, inputs_y),
text=input,
font=font,
fill=chart_font_color,
)
# Render the association line to the conjunction point
# First calculate a bezier curve to an inflection point
start_pt = (
inputs_x + max_inputs_text_width + int(GUIConstants.COMPONENT_PADDING*ssf/4),
inputs_y + int(chart_text_height/2)
)
conjunction_pt = (inputs_conjunction_x, vertical_center)
mid_pt = (
int(start_pt[0]*0.5 + conjunction_pt[0]*0.5),
int(start_pt[1]*0.5 + conjunction_pt[1]*0.5)
)
if len(inputs_column) == 1:
# Use fewer segments for single input straight line
bezier_points = [
start_pt,
linear_interp(start_pt, conjunction_pt, 0.33),
linear_interp(start_pt, conjunction_pt, 0.66),
conjunction_pt
]
else:
bezier_points = calc_bezier_curve(
start_pt,
(mid_pt[0], start_pt[1]),
mid_pt,
curve_steps
)
# We don't need the "final" point as it's repeated below
bezier_points.pop()
# Now render the second half after the inflection point
bezier_points += calc_bezier_curve(
mid_pt,
(mid_pt[0], conjunction_pt[1]),
conjunction_pt,
curve_steps
)
input_curves.append(bezier_points)
prev_pt = bezier_points[0]
for pt in bezier_points[1:]:
draw.line(
(prev_pt[0], prev_pt[1], pt[0], pt[1]),
fill=association_line_color,
width=association_line_width + 1,
joint="curve",
)
prev_pt = pt
inputs_y += inputs_y_spacing
# Render center bar
draw.line(
(
center_bar_x,
vertical_center,
center_bar_x + center_bar_width,
vertical_center
),
fill=association_line_color,
width=association_line_width
)
# Position each destination
num_rendered_destinations = len(destination_column)
if num_rendered_destinations == 1:
destination_y = vertical_center - int(chart_text_height/2)
else:
destination_y = int((image.height - num_rendered_destinations*chart_text_height) / (num_rendered_destinations + 1))
destination_y_spacing = destination_y + chart_text_height
# Don't render lines from an odd number
if destination_y % 2 == 1:
destination_y += 1
if destination_y_spacing % 2 == 1:
destination_y_spacing += 1
destination_conjunction_x = center_bar_x + center_bar_width
recipients_text_x = destination_col_x
output_curves = []
for destination in destination_column:
draw.text(
(recipients_text_x, destination_y),
text=destination,
font=font,
fill=chart_font_color,
)
# Render the association line from the conjunction point
# First calculate a bezier curve to an inflection point
conjunction_pt = (destination_conjunction_x, vertical_center)
end_pt = (
conjunction_pt[0] + curve_width,
destination_y + int(chart_text_height/2)
)
mid_pt = (
int(conjunction_pt[0]*0.5 + end_pt[0]*0.5),
int(conjunction_pt[1]*0.5 + end_pt[1]*0.5)
)
bezier_points = calc_bezier_curve(
conjunction_pt,
(mid_pt[0], conjunction_pt[1]),
mid_pt,
curve_steps
)
# We don't need the "final" point as it's repeated below
bezier_points.pop()
# Now render the second half after the inflection point
curve_bias = 1.0
bezier_points += calc_bezier_curve(
mid_pt,
(int(mid_pt[0]*curve_bias + end_pt[0]*(1.0-curve_bias)), end_pt[1]),
end_pt,
curve_steps
)
output_curves.append(bezier_points)
prev_pt = bezier_points[0]
for pt in bezier_points[1:]:
draw.line(
(prev_pt[0], prev_pt[1], pt[0], pt[1]),
fill=association_line_color,
width=association_line_width + 1,
joint="curve",
)
prev_pt = pt
destination_y += destination_y_spacing
# Resize to target and sharpen final image
image = image.resize((self.canvas_width, chart_height), Image.LANCZOS)
self.paste_images.append((image.filter(ImageFilter.SHARPEN), (self.chart_x, self.chart_y)))
# Pass input and output curves to the animation thread
self.threads.append(
PSBTOverviewScreen.TxExplorerAnimationThread(
inputs=input_curves,
outputs=output_curves,
supersampling_factor=ssf,
offset_y=self.chart_y,
renderer=self.renderer
)
)
class TxExplorerAnimationThread(BaseThread):
def __init__(self, inputs, outputs, supersampling_factor, offset_y, renderer: Renderer):
super().__init__()
# Translate the point coords into renderer space
ssf = supersampling_factor
self.inputs = [[(int(i[0]/ssf), int(i[1]/ssf + offset_y)) for i in curve] for curve in inputs]
self.outputs = [[(int(i[0]/ssf), int(i[1]/ssf + offset_y)) for i in curve] for curve in outputs]
self.renderer = renderer
def run(self):
pulse_color = GUIConstants.ACCENT_COLOR
reset_color = "#666"
line_width = 3
pulses = []
# The center bar needs to be segmented to support animation across it
start_pt = self.inputs[0][-1]
end_pt = self.outputs[0][0]
if start_pt == end_pt:
# In single input the center bar width can be zeroed out.
# Ugly hack: Insert this line segment that will be skipped otherwise.
center_bar_pts = [end_pt, self.outputs[0][1]]
else:
center_bar_pts = [
start_pt,
linear_interp(start_pt, end_pt, 0.25),
linear_interp(start_pt, end_pt, 0.50),
linear_interp(start_pt, end_pt, 0.75),
end_pt,
]
def draw_line_segment(curves, i, j, color):
# print(f"draw: {curves[0][i]} to {curves[0][j]}")
for points in curves:
pt1 = points[i]
pt2 = points[j]
self.renderer.draw.line(
(pt1[0], pt1[1], pt2[0], pt2[1]),
fill=color,
width=line_width
)
prev_color = reset_color
while self.keep_running:
with self.renderer.lock:
# Only generate one new pulse at a time; trailing "reset_color" pulse
# erases the most recent pulse.
if not pulses or (
prev_color == pulse_color and pulses[-1][0] == 10):
# Create a new pulse
if prev_color == pulse_color:
pulses.append([0, reset_color])
else:
pulses.append([0, pulse_color])
prev_color = pulses[-1][1]
for pulse_num, pulse in enumerate(pulses):
i = pulse[0]
color = pulse[1]
if i < len(self.inputs[0]) - 1:
# We're in the input curves
draw_line_segment(self.inputs, i, i+1, color)
elif i < len(self.inputs[0]) + len(center_bar_pts) - 2:
# We're in the center bar
index = i - len(self.inputs[0]) + 1
draw_line_segment([center_bar_pts], index, index+1, color)
elif i < len(self.inputs[0]) + len(center_bar_pts) - 2 + len(self.outputs[0]) - 1:
index = i - (len(self.inputs[0]) + len(center_bar_pts) - 2)
draw_line_segment(self.outputs, index, index+1, color)
else:
# This pulse is done
del pulses[pulse_num]
continue
pulse[0] += 1
self.renderer.show_image()
# No need to CPU limit when running in its own thread?
# time.sleep(0.02)
@dataclass
class PSBTNoChangeWarningScreen(WarningScreen):
fingerprint: str = None
def __post_init__(self):
# Customize defaults
self.title: str = "Caution"
self.button_label: str = "Continue"
self.is_bottom_list: bool = True
self.warning_headline: str = "Full Spend!" # The colored text under the alert icon
self.warning_text: str = "This PSBT spends its entire input value. No change is coming back to your wallet." # The body text of the warning
super().__post_init__()
@dataclass
class PSBTMathScreen(ButtonListScreen):
input_amount: int = 0
num_inputs: int = 0
spend_amount: int = 0
num_recipients: int = 0
fee_amount: int = 0
change_amount: int = 0
def __post_init__(self):
# Customize defaults
self.title = "PSBT Math"
self.button_data = ["Review Recipients"]
self.is_bottom_list = True
super().__post_init__()
if self.input_amount > 1e6:
denomination = "btc"
self.input_amount /= 1e8
self.spend_amount /= 1e8
self.change_amount /= 1e8
self.input_amount = f"{self.input_amount:,.8f}"
self.spend_amount = f"{self.spend_amount:,.8f}"
self.change_amount = f"{self.change_amount:,.8f}"
# Note: We keep the fee denominated in sats; just left pad it so it still
# lines up properly.
self.fee_amount = f"{self.fee_amount:10}"
else:
denomination = "sats"
self.input_amount = f"{self.input_amount:,}"
self.spend_amount = f"{self.spend_amount:,}"
self.fee_amount = f"{self.fee_amount:,}"
self.change_amount = f"{self.change_amount:,}"
longest_amount = max(len(self.input_amount), len(self.spend_amount), len(self.fee_amount), len(self.change_amount))
if len(self.input_amount) < longest_amount:
self.input_amount = " " * (longest_amount - len(self.input_amount)) + self.input_amount
if len(self.spend_amount) < longest_amount:
self.spend_amount = " " * (longest_amount - len(self.spend_amount)) + self.spend_amount
if len(self.fee_amount) < longest_amount:
self.fee_amount = " " * (longest_amount - len(self.fee_amount)) + self.fee_amount
if len(self.change_amount) < longest_amount:
self.change_amount = " " * (longest_amount - len(self.change_amount)) + self.change_amount
# Render the info to temp Image
# TODO: Test rendering the numeric amounts without the supersampling
body_width = self.canvas_width - 2*GUIConstants.EDGE_PADDING
body_height = self.buttons[0].screen_y - self.top_nav.height - 2*GUIConstants.COMPONENT_PADDING
ssf = 2 # Super-sampling factor
image = Image.new("RGB", (body_width*ssf, body_height*ssf))
draw = ImageDraw.Draw(image)
body_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, (GUIConstants.BODY_FONT_SIZE)*ssf)
fixed_width_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, (GUIConstants.BODY_FONT_SIZE + 6)*ssf)
digits_width, digits_height = fixed_width_font.getsize(self.input_amount + "+")
# Draw each line of the equation
cur_y = 0
def render_amount(cur_y, amount_str, info_text, info_text_color=GUIConstants.BODY_FONT_COLOR):
secondary_digit_color = "#888"
tertiary_digit_color = "#666"
digit_group_spacing = 2 * ssf
# secondary_digit_color = GUIConstants.BODY_FONT_COLOR
# tertiary_digit_color = GUIConstants.BODY_FONT_COLOR
# digit_group_spacing = 0
if denomination == 'btc':
display_str = amount_str
main_zone = display_str[:-6]
mid_zone = display_str[-6:-3]
end_zone = display_str[-3:]
main_zone_width, th = fixed_width_font.getsize(main_zone)
mid_zone_width, th = fixed_width_font.getsize(end_zone)
draw.text((0, cur_y), text=main_zone, font=fixed_width_font, fill=GUIConstants.BODY_FONT_COLOR)
draw.text((main_zone_width + digit_group_spacing, cur_y), text=mid_zone, font=fixed_width_font, fill=secondary_digit_color)
draw.text((main_zone_width + digit_group_spacing + mid_zone_width + digit_group_spacing, cur_y), text=end_zone, font=fixed_width_font, fill=tertiary_digit_color)
else:
draw.text((0, cur_y), text=amount_str, font=fixed_width_font, fill=GUIConstants.BODY_FONT_COLOR)
draw.text((digits_width + 2*digit_group_spacing, cur_y), text=info_text, font=body_font, fill=info_text_color)
render_amount(
cur_y,
f" {self.input_amount}",
# info_text=f""" {self.num_inputs} input{"s" if self.num_inputs > 1 else ""}""",
info_text=f""" input{"s" if self.num_inputs > 1 else ""}""",
)
# spend_amount will be zero on self-transfers; only display when there's an
# external recipient.
if self.num_recipients > 0:
cur_y += int(digits_height * 1.2)
render_amount(
cur_y,
f"-{self.spend_amount}",
# info_text=f""" {self.num_recipients} recipient{"s" if self.num_recipients > 1 else ""}""",
info_text=f""" recipient{"s" if self.num_recipients > 1 else ""}""",
)
cur_y += int(digits_height * 1.2)
render_amount(
cur_y,
f"-{self.fee_amount}",
info_text=f""" fee""",
)
cur_y += int(digits_height * 1.2) + 4 * ssf
draw.line((0, cur_y, image.width, cur_y), fill=GUIConstants.BODY_FONT_COLOR, width=1)
cur_y += 8 * ssf
render_amount(
cur_y,
f" {self.change_amount}",
info_text=f" {denomination} change",
info_text_color="darkorange" # super-sampling alters the perceived color
)
# Resize to target and sharpen final image
image = image.resize((body_width, body_height), Image.LANCZOS)
self.paste_images.append((image.filter(ImageFilter.SHARPEN), (GUIConstants.EDGE_PADDING, self.top_nav.height + GUIConstants.COMPONENT_PADDING)))
@dataclass
class PSBTAddressDetailsScreen(ButtonListScreen):
address: str = None
amount: int = 0
def __post_init__(self):
# Customize defaults
self.is_bottom_list = True
super().__post_init__()
center_img_height = self.buttons[0].screen_y - self.top_nav.height
# Figuring out how to vertically center the sats and the address is
# difficult so we just render to a temp image and paste it in place.
center_img = Image.new("RGB", (self.canvas_width, center_img_height), GUIConstants.BACKGROUND_COLOR)
draw = ImageDraw.Draw(center_img)
if self.amount <= 1e7:
amount_display = f"{self.amount:,} sats"
else:
amount_display = f"{self.amount/1e8:,} btc"
icon_text_line = IconTextLine(
image_draw=draw,
canvas=center_img,
icon_name=SeedSignerCustomIconConstants.BITCOIN_LOGO,
icon_color=GUIConstants.ACCENT_COLOR,
icon_size=28,
is_text_centered=True,
value_text=f"{amount_display}",
font_size=22,
screen_y=0,
)
formatted_address = FormattedAddress(
image_draw=draw,
canvas=center_img,
width=self.canvas_width - 2*GUIConstants.EDGE_PADDING,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=icon_text_line.height + GUIConstants.COMPONENT_PADDING,
font_size=24,
address=self.address,
)
# Render each to the temp img we passed in
icon_text_line.render()
formatted_address.render()
self.body_img = center_img.crop((
0,
0,
self.canvas_width,
formatted_address.screen_y + formatted_address.height
))
body_img_y = self.top_nav.height + int((center_img_height - self.body_img.height - GUIConstants.COMPONENT_PADDING)/2)
self.paste_images.append((self.body_img, (0, body_img_y)))
@dataclass
class PSBTChangeDetailsScreen(ButtonListScreen):
title: str = "Your Change"
amount: int = 0
address: str = None
is_multisig: bool = False
fingerprint: str = None
derivation_path: str = None
is_change_derivation_path: bool = True
derivation_path_addr_index: int = 0
is_change_addr_verified: bool = False
def __post_init__(self):
# Customize defaults
self.is_bottom_list = True
super().__post_init__()
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.BITCOIN_LOGO,
icon_color=GUIConstants.ACCENT_COLOR,
icon_size=28,
value_text=f"{self.amount} sats" if self.amount < 1e6 else f"{self.amount/1e8:0.8f} btc",
font_size=22,
is_text_centered=True,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
))
self.components.append(FormattedAddress(
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
address=self.address,
max_lines=1,
))
screen_y = self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING
if self.is_multisig and not self.is_change_addr_verified:
# Adjust the vertical spacing
screen_y -= GUIConstants.COMPONENT_PADDING
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
value_text=f"""{"Multisig" if self.is_multisig else self.fingerprint}: {"Change" if self.is_change_derivation_path else "Addr"} #{self.derivation_path_addr_index}""",
is_text_centered=False,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=screen_y,
))
if self.is_change_addr_verified:
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.CIRCLE_CHECK,
icon_color="#00dd00",
value_text="Address verified!",
is_text_centered=False,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
))
else:
pass
@dataclass
class PSBTFinalizeScreen(ButtonListScreen):
def __post_init__(self):
# Customize defaults
self.title = "Sign PSBT"
self.is_bottom_list = True
super().__post_init__()
icon = Icon(
icon_name=FontAwesomeIconConstants.PAPER_PLANE,
icon_color="#00dd00",
icon_size=GUIConstants.ICON_LARGE_BUTTON_SIZE,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
)
icon.screen_x = int((self.canvas_width - icon.width)/2)
self.components.append(icon)
self.components.append(TextArea(
text="Click to authorize this transaction",
screen_y=icon.screen_y + icon.height + GUIConstants.COMPONENT_PADDING
))
@dataclass
class PSBTSelectCoordinatorScreen(ButtonListScreen):
def __post_init__(self):
# Customize defaults
self.title = "Signed PSBT"
self.is_bottom_list = True
super().__post_init__()
self.components.append(TextArea(
text="Export as a QR code for:",
is_text_centered=True,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
))
+127
View File
@@ -0,0 +1,127 @@
import time
from dataclasses import dataclass
from PIL import Image, ImageDraw, ImageFont
from threading import Thread
from .screen import BaseTopNavScreen, ButtonListScreen
from ..components import GUIConstants, Fonts, TextArea, calc_text_centering
from seedsigner.helpers import B
from seedsigner.models import DecodeQR, DecodeQRStatus
@dataclass
class ScanScreen(BaseTopNavScreen):
decoder: DecodeQR = None
def __post_init__(self):
from seedsigner.camera import Camera
# Customize defaults
title = "Scan"
# Initialize the base class
super().__post_init__()
self.camera = Camera.get_instance()
self.camera.start_video_stream_mode(resolution=(480, 480), framerate=12, format="rgb")
# Prep the bottom semi-transparent instruction bar
self.instructions_background = Image.new("RGBA", (self.canvas_width, 40), color="black")
self.instructions_background_y = self.canvas_height - self.instructions_background.height
# Pre-calc where the instruction text goes
self.instructions_text = "Scan a QR code"
self.instructions_font = Fonts.get_font(GUIConstants.BUTTON_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
# TODO: Add the QR code icon and adjust start_x
(self.instructions_text_x, self.instructions_text_y) = calc_text_centering(
font=self.instructions_font,
text=self.instructions_text,
is_text_centered=True,
total_width=self.canvas_width,
total_height=self.instructions_background.height,
start_x=0,
start_y=0
)
def _run(self):
"""
_render() is mostly meant to be a one-time initial drawing call to set up the
Screen. Once interaction starts, the display updates have to be managed in
_run(). The live preview is an extra-complex case.
"""
def live_preview():
while True:
frame = self.camera.read_video_stream(as_image=True)
if frame is not None:
scan_text = self.instructions_text
if self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt:
scan_text = str(self.decoder.get_percent_complete()) + "% Complete"
# TODO: Render TopNav & instructions_background w/transparency
# img = Image.new(mode='RGBA', size=(self.canvas_width, self.canvas_height))
# img.paste(frame.resize((self.canvas_width, self.canvas_height)))
self.renderer.show_image_with_text(frame.resize((self.canvas_width, self.canvas_height), resample=Image.NEAREST), scan_text, font=self.instructions_font, text_color="white", text_background=(0,0,0,225))
# self.top_nav.render()
# self.renderer.show_image()
time.sleep(0.1) # turn this up or down to tune performance while decoding psbt
if self.camera._video_stream is None:
break
# putting live preview in its own thread to improve psbt decoding performance
t = Thread(target=live_preview)
t.start()
while True:
frame = self.camera.read_video_stream()
if frame is not None:
status = self.decoder.add_image(frame)
if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID):
self.camera.stop_video_stream_mode()
break
# TODO: KEY_UP gives control to NavBar; use its back arrow to cancel
if self.hw_inputs.check_for_low(B.KEY_RIGHT) or self.hw_inputs.check_for_low(B.KEY_LEFT):
self.camera.stop_video_stream_mode()
break
time.sleep(0.2) # time to let live preview thread complete to avoid race condition on display
@dataclass
class SettingsUpdatedScreen(ButtonListScreen):
config_name: str = None
title: str = "Settings QR"
is_bottom_list: bool = True
def __post_init__(self):
# Customize defaults
self.button_data = ["Home"]
super().__post_init__()
start_y = self.top_nav.height + 20
if self.config_name:
self.config_name_textarea = TextArea(
text=f'"{self.config_name}"',
is_text_centered=True,
auto_line_break=True,
screen_y=start_y
)
self.components.append(self.config_name_textarea)
start_y = self.config_name_textarea.screen_y + 50
self.components.append(TextArea(
text="Settings imported successfully!",
is_text_centered=True,
auto_line_break=True,
screen_y=start_y
))
+778
View File
@@ -0,0 +1,778 @@
import time
from dataclasses import dataclass
from PIL import Image, ImageDraw, ImageColor
from typing import Any, List, Tuple
from seedsigner.gui.renderer import Renderer
from seedsigner.helpers.threads import BaseThread
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.settings import SettingsConstants
from ..components import (CheckedSelectionButton, FontAwesomeIconConstants, GUIConstants, BaseComponent, Button, Icon, IconButton, LargeIconButton, SeedSignerCustomIconConstants, TopNav,
TextArea, load_icon, load_image)
from seedsigner.helpers import B, Buttons
# Must be huge numbers to avoid conflicting with the selected_button returned by the
# screens with buttons.
RET_CODE__BACK_BUTTON = 1000
RET_CODE__POWER_BUTTON = 1001
@dataclass
class BaseScreen(BaseComponent):
def __post_init__(self):
super().__post_init__()
self.hw_inputs = Buttons.get_instance()
# Implementation classes can add their own BaseThread to run in parallel with the
# main execution thread.
self.threads: List[BaseThread] = []
# Implementation classes can add additional BaseComponent-derived objects to the
# list. They'll be called to `render()` themselves in BaseScreen._render().
self.components: List[BaseComponent] = []
# Implementation classes can add PIL.Image objs here. Format is a tuple of the
# Image and its (x,y) paste coords.
self.paste_images: List[Tuple] = []
# Tracks position on scrollable pages, determines which elements are visible.
self.scroll_y = 0
def display(self) -> Any:
try:
with self.renderer.lock:
self._render()
self.renderer.show_image()
for t in self.threads:
t.start()
return self._run()
except Exception as e:
print(e)
print("------")
repr(e)
raise e
finally:
for t in self.threads:
t.stop()
def clear_screen(self):
# Clear the whole canvas
self.image_draw.rectangle(
(0, 0, self.canvas_width, self.canvas_height),
fill=0,
)
def _render(self):
self.clear_screen()
# TODO: Check self.scroll_y and only render visible elements
for component in self.components:
component.render()
for img, coords in self.paste_images:
self.canvas.paste(img, coords)
def _run(self):
"""
Screen can run on its own until it returns a final exit input from the user.
For example: A basic menu screen where the user can key up and down. The
Screen can handle the UI updates to light up the currently selected menu item
on its own. Only when the user clicks to make a selection would run() exit
and returns the selected option.
But an alternate use case returns immediately after each user input so the
View can update its controlling logic accordingly (e.g. as the user joysticks
over different letters in the keyboard UI, we need to make matching changes
to the list of mnemonic seed words that match the new letter).
In this case, it would be called repeatedly in a loop:
* run() and wait for it to handle user input
* run() exits and returns the user input (e.g. KEY_UP)
* View updates its state of the world accordingly
* loop and call run() again
"""
raise Exception("Must implement in a child class")
class LoadingScreenThread(BaseThread):
def __init__(self, text: str = None):
super().__init__()
self.text =text
def run(self):
renderer: Renderer = Renderer.get_instance()
center_image = load_image("btc_logo_60x60.png")
orbit_gap = 2*GUIConstants.COMPONENT_PADDING
bounding_box = (
int((renderer.canvas_width - center_image.width)/2 - orbit_gap),
int((renderer.canvas_height - center_image.height)/2 - orbit_gap),
int((renderer.canvas_width + center_image.width)/2 + orbit_gap),
int((renderer.canvas_height + center_image.height)/2 + orbit_gap),
)
position = 0
arc_sweep = 45
arc_color = "#ff9416"
arc_trailing_color = "#80490b"
# Need to flush the screen
with renderer.lock:
renderer.draw.rectangle((0, 0, renderer.canvas_width, renderer.canvas_height), fill=GUIConstants.BACKGROUND_COLOR)
renderer.canvas.paste(center_image, (bounding_box[0] + orbit_gap, bounding_box[1] + orbit_gap))
if self.text:
TextArea(
text=self.text,
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
screen_y=int((renderer.canvas_height - bounding_box[3])/2),
).render()
while self.keep_running:
with renderer.lock:
# Render leading arc
renderer.draw.arc(
bounding_box,
start=position,
end=position + arc_sweep,
fill=arc_color,
width=GUIConstants.COMPONENT_PADDING
)
# Render trailing arc
renderer.draw.arc(
bounding_box,
start=position - arc_sweep,
end=position,
fill=arc_trailing_color,
width=GUIConstants.COMPONENT_PADDING
)
# Erase previous trailing arc leading arc
renderer.draw.arc(
bounding_box,
start=position - 2*arc_sweep,
end=position - arc_sweep,
fill=GUIConstants.BACKGROUND_COLOR,
width=GUIConstants.COMPONENT_PADDING
)
renderer.show_image()
position += arc_sweep
@dataclass
class BaseTopNavScreen(BaseScreen):
title: str = "Screen Title"
title_font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE
show_top_nav_left_button: bool = True
top_nav_left_button_icon_name: str = SeedSignerCustomIconConstants.LARGE_CHEVRON_LEFT
show_top_nav_right_button: bool = False
top_nav_right_button_icon_name: str = FontAwesomeIconConstants.POWER_OFF
def __post_init__(self):
super().__post_init__()
self.top_nav = TopNav(
text=self.title,
font_size=self.title_font_size,
width=self.canvas_width,
height=GUIConstants.TOP_NAV_HEIGHT,
show_left_button=self.show_top_nav_left_button,
left_button_icon_name=self.top_nav_left_button_icon_name,
show_right_button=self.show_top_nav_right_button,
right_button_icon_name=self.top_nav_right_button_icon_name,
)
self.is_input_in_top_nav = False
self.components.append(self.top_nav)
def _run(self):
raise Exception("Must implement in a child class")
@dataclass
class TextTopNavScreen(BaseTopNavScreen):
text: str = "Body text"
is_text_centered: bool = True
text_font_name: str = GUIConstants.BODY_FONT_NAME
text_font_size: int = GUIConstants.BODY_FONT_SIZE
def __post_init__(self):
super().__post_init__()
self.text_area = TextArea(
text=self.text,
screen_x=0,
screen_y=self.top_nav.height,
width=self.canvas_width,
height=self.canvas_height - self.top_nav.height,
font_name=self.text_font_name,
font_size=self.text_font_size,
is_text_centered=self.is_text_centered
)
self.components.append(self.text_area)
def _run(self):
while True:
user_input = self.hw_inputs.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
with self.renderer.lock:
if user_input == B.KEY_UP:
if not self.top_nav.is_selected:
# Only move navigation up there if there's something to select
if self.top_nav.show_left_button or self.top_nav.show_right_button:
self.top_nav.is_selected = True
self.top_nav.render()
elif user_input == B.KEY_DOWN:
if self.top_nav.is_selected:
self.top_nav.is_selected = False
self.top_nav.render()
elif user_input == B.KEY_PRESS:
if self.top_nav.is_selected:
return self.top_nav.selected_button
# Write the screen updates
self.renderer.show_image()
@dataclass
class ButtonListScreen(BaseTopNavScreen):
button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str)
selected_button: int = 0
is_button_text_centered: bool = True
is_bottom_list: bool = False
button_font_name: str = GUIConstants.BUTTON_FONT_NAME
button_font_size: int = GUIConstants.BUTTON_FONT_SIZE
button_selected_color: str = GUIConstants.ACCENT_COLOR
# Params for version of list used for Settings
Button_cls = Button
checked_buttons: List[int] = None
def __post_init__(self):
super().__post_init__()
button_height = GUIConstants.BUTTON_HEIGHT
if len(self.button_data) == 1:
button_list_height = button_height
else:
button_list_height = (len(self.button_data) * button_height) + (GUIConstants.COMPONENT_PADDING * (len(self.button_data) - 1))
if self.is_bottom_list:
button_list_y = self.canvas_height - (button_list_height + GUIConstants.EDGE_PADDING)
else:
button_list_y = self.top_nav.height + int((self.canvas_height - self.top_nav.height - button_list_height) / 2)
self.has_scroll_arrows = False
if button_list_y < self.top_nav.height:
# The button list is too long; force it to run off the bottom of the screen.
button_list_y = self.top_nav.height
self.has_scroll_arrows = True
self.buttons: List[Button] = []
for i, button_label in enumerate(self.button_data):
if type(button_label) == tuple:
if len(button_label) == 2:
(button_label, icon_name) = button_label
icon_color = GUIConstants.BUTTON_FONT_COLOR
elif len(button_label) == 3:
(button_label, icon_name, icon_color) = button_label
else:
icon_name = None
icon_color = None
button_kwargs = dict(
text=button_label,
icon_name=icon_name,
icon_color=icon_color,
is_icon_inline=True,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=button_list_y + i * (button_height + GUIConstants.LIST_ITEM_PADDING),
width=self.canvas_width - (2 * GUIConstants.EDGE_PADDING),
height=button_height,
is_text_centered=self.is_button_text_centered,
font_name=self.button_font_name,
font_size=self.button_font_size,
selected_color=self.button_selected_color
)
if self.checked_buttons and i in self.checked_buttons:
button_kwargs["is_checked"] = True
button = self.Button_cls(**button_kwargs)
self.buttons.append(button)
if self.has_scroll_arrows:
self.arrow_half_width = 10
self.cur_scroll_y = 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)
arrow_draw.line((self.arrow_half_width, 1, 0, 7), fill=GUIConstants.BUTTON_FONT_COLOR)
arrow_draw.line((self.arrow_half_width, 1, 2 * self.arrow_half_width, 7), fill=GUIConstants.BUTTON_FONT_COLOR)
self.down_arrow_img = Image.new("RGBA", size=(2 * self.arrow_half_width, 8), color="black")
self.down_arrow_img_y = self.canvas_height - 16 + 2
arrow_draw = ImageDraw.Draw(self.down_arrow_img)
center_x = int(self.canvas_width / 2)
arrow_draw.line((self.arrow_half_width, 7, 0, 1), fill=GUIConstants.BUTTON_FONT_COLOR)
arrow_draw.line((self.arrow_half_width, 7, 2 * self.arrow_half_width, 1), fill=GUIConstants.BUTTON_FONT_COLOR)
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):
super()._render()
self._render_visible_buttons()
def _render_visible_buttons(self):
if self.has_scroll_arrows:
self._render_up_arrow()
self._render_down_arrow()
for i, button in enumerate(self.buttons):
if not self.has_scroll_arrows:
button.render()
continue
button_position_y = button.screen_y - button.scroll_y
if button_position_y >= self.top_nav.height and button_position_y < self.down_arrow_img_y:
if i == 0:
# We rendered the top button; no more to scroll up for.
self._hide_up_arrow()
if i == len(self.buttons) - 1:
# We just pulled up the last button; no more to scroll down for.
self._hide_down_arrow()
# Render the button after the arrows to cover up overlap
button.render()
def _render_up_arrow(self):
self.canvas.paste(self.up_arrow_img, (int(self.canvas_width / 2) - self.arrow_half_width, self.up_arrow_img_y))
def _render_down_arrow(self):
self.canvas.paste(self.down_arrow_img, (int(self.canvas_width / 2) - self.arrow_half_width, self.down_arrow_img_y))
def _hide_up_arrow(self):
self.image_draw.rectangle(
(
int(self.canvas_width / 2) - self.arrow_half_width, self.up_arrow_img_y,
int(self.canvas_width / 2) + self.arrow_half_width, self.up_arrow_img_y + self.up_arrow_img.height
),
fill="black"
)
def _hide_down_arrow(self):
self.image_draw.rectangle(
(
int(self.canvas_width / 2) - self.arrow_half_width, self.down_arrow_img_y,
int(self.canvas_width / 2) + self.arrow_half_width, self.down_arrow_img_y + self.down_arrow_img.height
),
fill="black"
)
def _run(self):
while True:
user_input = self.hw_inputs.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_LEFT, B.KEY_RIGHT, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
with self.renderer.lock:
if not self.top_nav.is_selected and (user_input == B.KEY_LEFT or (user_input == B.KEY_UP and self.selected_button == 0)):
# SHORTCUT to escape long menu screens!
# OR keyed UP from the top of the list.
# Move selection up to top_nav
# Only move navigation up there if there's something to select
if self.top_nav.show_left_button or self.top_nav.show_right_button:
self.buttons[self.selected_button].is_selected = False
self.buttons[self.selected_button].render()
self.top_nav.is_selected = True
self.top_nav.render()
elif user_input == B.KEY_UP:
if self.top_nav.is_selected:
# Can't go up any further
pass
else:
cur_selected_button: Button = self.buttons[self.selected_button]
self.selected_button -= 1
next_selected_button: Button = self.buttons[self.selected_button]
cur_selected_button.is_selected = False
next_selected_button.is_selected = True
if self.has_scroll_arrows and next_selected_button.screen_y - next_selected_button.scroll_y + next_selected_button.height < self.top_nav.height:
# Selected a Button that's off the top of the screen
frame_scroll = cur_selected_button.screen_y - next_selected_button.screen_y
for button in self.buttons:
button.scroll_y -= frame_scroll
self._render_visible_buttons()
else:
cur_selected_button.render()
next_selected_button.render()
elif user_input == B.KEY_DOWN or (self.top_nav.is_selected and user_input == B.KEY_RIGHT):
if self.selected_button == len(self.buttons) - 1:
# Already at the bottom of the list. Nowhere to go. But may need
# to re-render if we're returning from top_nav; otherwise skip
# this update loop.
if not self.top_nav.is_selected:
continue
if self.top_nav.is_selected:
self.top_nav.is_selected = False
self.top_nav.render()
cur_selected_button = None
next_selected_button = self.buttons[self.selected_button]
next_selected_button.is_selected = True
else:
cur_selected_button: Button = self.buttons[self.selected_button]
self.selected_button += 1
next_selected_button: Button = self.buttons[self.selected_button]
cur_selected_button.is_selected = False
next_selected_button.is_selected = True
if self.has_scroll_arrows and next_selected_button.screen_y - next_selected_button.scroll_y + next_selected_button.height > self.down_arrow_img_y:
# Selected a Button that's off the bottom of the screen
frame_scroll = next_selected_button.screen_y - cur_selected_button.screen_y
for button in self.buttons:
button.scroll_y += frame_scroll
self._render_visible_buttons()
else:
if cur_selected_button:
cur_selected_button.render()
next_selected_button.render()
elif user_input == B.KEY_PRESS:
if self.top_nav.is_selected:
return self.top_nav.selected_button
return self.selected_button
# Write the screen updates
self.renderer.show_image()
@dataclass
class LargeButtonScreen(BaseTopNavScreen):
button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str)
button_font_name: str = GUIConstants.BUTTON_FONT_NAME
button_font_size: int = 20
button_selected_color: str = GUIConstants.ACCENT_COLOR
def __post_init__(self):
super().__post_init__()
if len(self.button_data) not in [2, 4]:
raise Exception("LargeButtonScreen only supports 2 or 4 buttons")
# Maximize 2-across width; calc height with a 4:3 aspect ratio
button_width = int((self.canvas_width - (2 * GUIConstants.EDGE_PADDING) - GUIConstants.COMPONENT_PADDING) / 2)
button_height = int(button_width * (3.0 / 4.0))
# Vertically center the buttons
if len(self.button_data) == 2:
button_start_y = self.top_nav.height + int((self.canvas_height - (self.top_nav.height + GUIConstants.COMPONENT_PADDING) - button_height) / 2)
else:
button_start_y = self.top_nav.height + int((self.canvas_height - (self.top_nav.height + GUIConstants.COMPONENT_PADDING) - (2 * button_height) - GUIConstants.COMPONENT_PADDING) / 2)
self.buttons = []
for i, button_label in enumerate(self.button_data):
if type(button_label) == tuple:
(button_label, icon_name) = button_label
else:
icon_name = None
if i % 2 == 0:
button_start_x = GUIConstants.EDGE_PADDING
else:
button_start_x = GUIConstants.EDGE_PADDING + button_width + GUIConstants.COMPONENT_PADDING
button_args = {
"text": button_label,
"screen_x": button_start_x,
"screen_y": button_start_y,
"width": button_width,
"height": button_height,
"is_text_centered": True,
"font_name": self.button_font_name,
"font_size": self.button_font_size,
"selected_color": self.button_selected_color,
}
if icon_name:
button_args["icon_name"] = icon_name
button_args["text_y_offset"] = int(48 / 240 * self.renderer.canvas_height) + GUIConstants.COMPONENT_PADDING
button = LargeIconButton(**button_args)
else:
button = Button(**button_args)
self.buttons.append(button)
self.components.append(button)
if i == 1:
button_start_y += button_height + GUIConstants.COMPONENT_PADDING
self.buttons[0].is_selected = True
self.selected_button = 0
def _run(self):
def swap_selected_button(new_selected_button: int):
self.buttons[self.selected_button].is_selected = False
self.buttons[self.selected_button].render()
self.selected_button = new_selected_button
self.buttons[self.selected_button].is_selected = True
self.buttons[self.selected_button].render()
while True:
user_input = self.hw_inputs.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_LEFT, B.KEY_RIGHT, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
with self.renderer.lock:
if user_input == B.KEY_UP:
if self.selected_button in [0, 1]:
# Move selection up to top_nav
self.top_nav.is_selected = True
self.top_nav.render()
self.buttons[self.selected_button].is_selected = False
self.buttons[self.selected_button].render()
elif len(self.buttons) == 4:
swap_selected_button(self.selected_button - 2)
elif user_input == B.KEY_DOWN:
if self.top_nav.is_selected:
self.top_nav.is_selected = False
self.top_nav.render()
self.buttons[self.selected_button].is_selected = True
self.buttons[self.selected_button].render()
elif self.selected_button in [2, 3]:
pass
elif len(self.buttons) == 4:
swap_selected_button(self.selected_button + 2)
elif user_input == B.KEY_RIGHT and not self.top_nav.is_selected:
if self.selected_button in [0, 2]:
swap_selected_button(self.selected_button + 1)
elif user_input == B.KEY_RIGHT and self.top_nav.is_selected and not self.top_nav.show_right_button:
self.top_nav.is_selected = False
self.top_nav.render()
self.buttons[self.selected_button].is_selected = True
self.buttons[self.selected_button].render()
elif user_input == B.KEY_LEFT and not self.top_nav.is_selected:
if self.selected_button in [1, 3]:
swap_selected_button(self.selected_button - 1)
else:
# Left from the far edge takes us up to the BACK arrow
if self.top_nav.show_left_button:
self.top_nav.is_selected = True
self.top_nav.render()
self.buttons[self.selected_button].is_selected = False
self.buttons[self.selected_button].render()
elif user_input == B.KEY_PRESS:
if self.top_nav.is_selected:
return self.top_nav.selected_button
return self.selected_button
# Write the screen updates
self.renderer.show_image()
@dataclass
class QRDisplayScreen(BaseScreen):
qr_encoder: EncodeQR = None
def _run(self):
from seedsigner.models.settings import Settings
settings = Settings.get_instance()
cur_brightness = settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS)
if self.qr_encoder.total_parts() == 1:
image = self.qr_encoder.next_part_image(240,240,1)
self.renderer.show_image(image)
self.hw_inputs.wait_for([B.KEY_RIGHT])
else:
while True:
# convert the cur_brightness integer (31-255) into hex triplets
hex_color = (hex(cur_brightness).split('x')[1]) * 3
image = self.qr_encoder.next_part_image(240,240, border=2, background_color=hex_color)
self.renderer.show_image(image)
# Target n held frames per second before rendering next QR image
time.sleep(5/30.0)
if self.hw_inputs.check_for_low(B.KEY_DOWN):
# Reduce QR code background brightness
cur_brightness = max(31, cur_brightness - 31)
elif self.hw_inputs.check_for_low(B.KEY_UP):
# Incrase QR code background brightness
cur_brightness = min(cur_brightness + 31, 255)
elif self.hw_inputs.check_for_low(B.KEY_RIGHT):
break
settings.set_value(SettingsConstants.SETTING__QR_BRIGHTNESS, cur_brightness)
# TODO: handle left as BACK
class WarningEdgesThread(BaseThread):
def __init__(self, args):
super().__init__()
self.args = args
def run(self):
screen = self.args[0]
inhale_step = 1
inhale_max = 10
inhale_hold = 8
cur_inhale_hold = 0
inhale_factor = 0
rgb = ImageColor.getrgb(screen.warning_color)
def render_border(color, width):
screen.image_draw.rounded_rectangle(
(0, 0, screen.canvas_width, screen.canvas_height),
fill=None,
outline=color,
width=width,
radius=5
)
while self.keep_running:
with screen.renderer.lock:
# Ramp the edges from a darker version out to full color
inhale_scalar = inhale_factor * int(255/inhale_max)
for index, n in enumerate(range(4, -1, -1)):
# Reverse range steadily increases rgb in brightness until reaching full.
# 34 == 0x22; just eyeballed a good step size
r = max(0, rgb[0] - 34*n - inhale_scalar)
g = max(0, rgb[1] - 34*n - inhale_scalar)
b = max(0, rgb[2] - 34*n - inhale_scalar)
# `index` shrinks the border at each step
render_border((r, g, b), GUIConstants.EDGE_PADDING - 2 - index)
# Write the screen updates
screen.renderer.show_image()
if inhale_factor == inhale_max:
inhale_step = -1
elif inhale_factor == 0 and inhale_step == -1:
cur_inhale_hold += 1
if cur_inhale_hold > inhale_hold:
inhale_step = 1
cur_inhale_hold = 0
else:
# It's about to be decremented below zero
inhale_factor = 1
inhale_factor += inhale_step
# Target ~10fps
time.sleep(0.05)
@dataclass
class WarningScreenMixin:
warning_color: str = GUIConstants.WARNING_COLOR
def __post_init__(self):
super().__post_init__()
self.threads.append(WarningEdgesThread(args=(self,)))
@dataclass
class WarningScreen(WarningScreenMixin, ButtonListScreen):
title: str = "Caution"
button_label: str = "I Understand"
is_bottom_list: bool = True
warning_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
warning_icon_size=GUIConstants.ICON_PRIMARY_SCREEN_SIZE
warning_headline: str = "Privacy Leak!" # The colored text under the alert icon
warning_text: str = "" # The body text of the warning
def __post_init__(self):
# Populate the required button_data for the ButtonListScreen
self.button_data = [self.button_label]
super().__post_init__()
self.warning_icon = Icon(
icon_name=self.warning_icon_name,
icon_size=self.warning_icon_size,
icon_color=self.warning_color,
)
self.warning_icon.screen_y = self.top_nav.height - int(GUIConstants.COMPONENT_PADDING/2)
self.warning_icon.screen_x = int((self.canvas_width - self.warning_icon.width) / 2)
self.components.append(self.warning_icon)
next_y = self.warning_icon.screen_y + self.warning_icon.height + 4
if self.warning_headline:
self.warning_headline_textarea = TextArea(
text=self.warning_headline,
width=self.canvas_width,
screen_y=next_y,
font_color=self.warning_color,
)
self.components.append(self.warning_headline_textarea)
next_y = next_y + self.warning_headline_textarea.height + 8
self.warning_text_textarea = TextArea(
text=self.warning_text,
width=self.canvas_width,
screen_y=next_y,
)
self.components.append(self.warning_text_textarea)
@dataclass
class DireWarningScreen(WarningScreen):
title: str = "Caution"
warning_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
warning_headline: str = "Classified Info!" # The colored text under the alert icon
warning_color: str = GUIConstants.DIRE_WARNING_COLOR
+779
View File
@@ -0,0 +1,779 @@
import math
import time
from dataclasses import dataclass
from PIL import Image, ImageDraw, ImageFilter
from seedsigner.gui.renderer import Renderer
from seedsigner.helpers.threads import BaseThread, ThreadsafeCounter
from seedsigner.models.seed import Seed
from .screen import BaseTopNavScreen, ButtonListScreen, WarningScreenMixin
from ..components import FontAwesomeIconConstants, Fonts, FormattedAddress, IconTextLine, SeedSignerCustomIconConstants, TextArea, GUIConstants, TextDoesNotFitException, calc_text_centering
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
from seedsigner.helpers import B
@dataclass
class SeedValidScreen(ButtonListScreen):
fingerprint: str = None
title: str = "Seed Valid"
is_bottom_list: bool = True
button_data: list = None
def __post_init__(self):
# TODO: Replace this with explicit "Continue"/"Discard" options
self.show_top_nav_left_button = True
self.top_nav_left_button_icon_name = FontAwesomeIconConstants.X
super().__post_init__()
self.fingerprint_icontl = IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
icon_size=GUIConstants.ICON_FONT_SIZE + 12,
label_text="fingerprint",
value_text=self.fingerprint,
font_size=GUIConstants.BODY_FONT_SIZE + 2,
is_text_centered=True,
screen_y=self.top_nav.height + int((self.buttons[0].screen_y - self.top_nav.height) / 2) - 30
)
self.components.append(self.fingerprint_icontl)
@dataclass
class SeedOptionsScreen(ButtonListScreen):
# Customize defaults
title: str = "Seed Options"
is_bottom_list: bool = True
fingerprint: str = None
has_passphrase: bool = False
def __post_init__(self):
super().__post_init__()
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
value_text=self.fingerprint,
is_text_centered=True,
screen_y=self.top_nav.height
))
@dataclass
class SeedWordsScreen(WarningScreenMixin, ButtonListScreen):
title: str = "Seed Words"
seed: Seed = None
is_first_page: bool = True
is_bottom_list: bool = True
warning_color: str = GUIConstants.DIRE_WARNING_COLOR
def __post_init__(self):
super().__post_init__()
# Can only render 12 words per screen
mnemonic = self.seed.mnemonic_display_list
if len(mnemonic) == 12 or self.is_first_page:
self.mnemonic = mnemonic[:12]
else:
self.mnemonic = mnemonic[12:]
self.body_x = 0
self.body_y = self.top_nav.height - int(GUIConstants.COMPONENT_PADDING / 2)
self.body_height = self.buttons[0].screen_y - self.body_y
# Have to supersample the whole body since it's all at the small font size
supersampling_factor = 2
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, 16 * supersampling_factor)
# Calc vertical placement for the numbers
(number_x, number_y) = calc_text_centering(
font=font,
text="1234567890",
is_text_centered=True,
total_width=20 * supersampling_factor,
total_height=20 * supersampling_factor
)
number_box_x = GUIConstants.EDGE_PADDING * supersampling_factor
number_box_y = GUIConstants.COMPONENT_PADDING * supersampling_factor
number_box_width = 20 * supersampling_factor
number_box_height = 20 * supersampling_factor
# Set up our temp supersampled rendering surface
self.body_img = Image.new(
"RGB",
(self.canvas_width * supersampling_factor, self.body_height * supersampling_factor),
GUIConstants.BACKGROUND_COLOR
)
draw = ImageDraw.Draw(self.body_img)
for index, word in enumerate(self.mnemonic):
if index == 6:
# Start of the second column of words
number_box_x = (int(self.canvas_width / 2) + 4) * supersampling_factor
number_box_y = GUIConstants.COMPONENT_PADDING * supersampling_factor
draw.rounded_rectangle(
(number_box_x, number_box_y, number_box_x + number_box_width, number_box_y + number_box_height),
fill="#202020",
radius=5 * supersampling_factor
)
if self.is_first_page:
number_str = str(index + 1)
else:
number_str = str(index + 1 + 12)
tw, th = font.getsize(number_str)
draw.text(
(number_box_x + int((number_box_width - tw) / 2), number_box_y + number_y),
font=font,
text=number_str,
fill="#0084ff"
)
# Now draw the word
draw.text(
(number_box_x + number_box_width + (4 * supersampling_factor), number_box_y + number_y),
font=font,
text=word,
fill=GUIConstants.BODY_FONT_COLOR
)
number_box_y += number_box_height + (4 * supersampling_factor)
# Resize to target and sharpen final image
self.body_img = self.body_img.resize((self.canvas_width, self.body_height), Image.LANCZOS)
self.body_img = self.body_img.filter(ImageFilter.SHARPEN)
self.paste_images.append((self.body_img, (self.body_x, self.body_y)))
@dataclass
class SeedExportXpubCustomDerivationScreen(BaseTopNavScreen):
title: str = "Derivation Path"
derivation_path: str = "m/"
def __post_init__(self):
super().__post_init__()
# Set up the keyboard params
right_panel_buttons_width = 60
# Set up the live text entry display
font = Fonts.get_font("RobotoCondensed-Regular", 28)
tw, th = font.getsize("m/1234567890") # All possible chars for max range
text_entry_side_padding = 0
text_entry_top_padding = 1
text_entry_bottom_padding = 10
text_entry_top_y = self.top_nav.height + text_entry_top_padding
text_entry_bottom_y = text_entry_top_y + 3 + th + 3
self.text_entry_display = TextEntryDisplay(
canvas=self.renderer.canvas,
rect=(text_entry_side_padding,text_entry_top_y, self.renderer.canvas_width - right_panel_buttons_width - GUIConstants.COMPONENT_PADDING, text_entry_bottom_y),
font=font,
font_color=GUIConstants.ACCENT_COLOR,
cursor_mode=TextEntryDisplay.CURSOR_MODE__BAR,
is_centered=False,
has_outline=True,
cur_text=''.join(self.derivation_path)
)
keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding
self.keyboard_digits = Keyboard(
draw=self.renderer.draw,
charset="/'0123456789",
rows=3,
cols=6,
rect=(0, keyboard_start_y, self.renderer.canvas_width - right_panel_buttons_width - GUIConstants.COMPONENT_PADDING, self.renderer.canvas_height),
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
self.keyboard_digits.set_selected_key(selected_letter="/")
def _render(self):
super()._render()
self.keyboard_digits.render_keys()
# Render the right button panel (only has a Key3 "Save" button)
row_height = 28
right_button_left_margin = 10
right_button_width = 60
font_padding_right = 2
font_padding_top = 1
key_x = self.renderer.canvas_width - right_button_width
key_y = int(self.renderer.canvas_height - row_height) / 2 - 1 - 60
font = Fonts.get_font("RobotoCondensed-Regular", 24)
background_color = "#111"
font_color = GUIConstants.ACCENT_COLOR
button3_text = "Save"
tw, th = font.getsize(button3_text)
key_y = int(self.renderer.canvas_height - row_height) / 2 - 1 + 60
self.renderer.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=GUIConstants.ACCENT_COLOR, fill=background_color, radius=5, width=1)
self.renderer.draw.text((self.renderer.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color)
self.text_entry_display.render(self.derivation_path)
self.renderer.show_image()
def _run(self):
cursor_position = len(self.derivation_path)
# Start the interactive update loop
while True:
input = self.hw_inputs.wait_for(
[B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY3],
check_release=True,
release_keys=[B.KEY_PRESS, B.KEY3]
)
# Check our two possible exit conditions
if input == B.KEY3:
# Save!
if len(self.derivation_path) > 0:
return self.derivation_path.strip()
elif self.top_nav.is_selected and input == B.KEY_PRESS:
# Prev button clicked; return empty string to signal cancel.
return self.top_nav.selected_button
# Process normal input
if input in [B.KEY_UP, B.KEY_DOWN] and self.top_nav.is_selected:
# We're navigating off the previous button
self.top_nav.is_selected = False
self.top_nav.render()
# Override the actual input w/an ENTER signal for the Keyboard
if input == B.KEY_DOWN:
input = Keyboard.ENTER_TOP
else:
input = Keyboard.ENTER_BOTTOM
elif input in [B.KEY_LEFT, B.KEY_RIGHT] and self.top_nav.is_selected:
# ignore
continue
ret_val = self.keyboard_digits.update_from_input(input)
# Now process the result from the keyboard
if ret_val in Keyboard.EXIT_DIRECTIONS:
self.top_nav.is_selected = True
self.top_nav.render()
elif ret_val in Keyboard.ADDITIONAL_KEYS and input == B.KEY_PRESS:
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
if len(self.derivation_path) <= 2:
pass
elif cursor_position == len(self.derivation_path):
self.derivation_path = self.derivation_path[:-1]
cursor_position -= 1
else:
self.derivation_path = self.derivation_path[:cursor_position - 1] + self.derivation_path[cursor_position:]
cursor_position -= 1
elif input == B.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
# User has locked in the current letter
if cursor_position == len(self.derivation_path):
self.derivation_path += ret_val
else:
self.derivation_path = self.derivation_path[:cursor_position] + ret_val + self.derivation_path[cursor_position:]
cursor_position += 1
elif input in [B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN]:
# Live joystick movement; haven't locked this new letter in yet.
# Leave current spot blank for now. Only update the active keyboard keys
# when a selection has been locked in (KEY_PRESS) or removed ("del").
pass
# Render the text entry display and cursor block
self.text_entry_display.render(self.derivation_path)
self.renderer.show_image()
@dataclass
class SeedExportXpubDetailsScreen(WarningScreenMixin, ButtonListScreen):
# Customize defaults
title: str = "Xpub Details"
is_bottom_list: bool = True
fingerprint: str = None
has_passphrase: bool = False
derivation_path: str = "m/84'/0'/0'"
xpub: str = "zpub6r..."
button_data=["Export Xpub"]
def __post_init__(self):
# Programmatically set up other args
self.button_data = ["Export Xpub"]
# Initialize the base class
super().__post_init__()
# Set up the fingerprint and passphrase displays
self.fingerprint_line = IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
label_text="Fingerprint",
value_text=self.fingerprint,
screen_x=GUIConstants.COMPONENT_PADDING,
screen_y=self.top_nav.height,
)
self.components.append(self.fingerprint_line)
self.derivation_line = IconTextLine(
icon_name=SeedSignerCustomIconConstants.PATH,
label_text="Derivation",
value_text=self.derivation_path,
screen_x=GUIConstants.COMPONENT_PADDING,
screen_y=self.fingerprint_line.screen_y + self.fingerprint_line.height + GUIConstants.COMPONENT_PADDING,
)
self.components.append(self.derivation_line)
self.xpub_line = IconTextLine(
icon_name=FontAwesomeIconConstants.X,
label_text="Xpub",
value_text=self.xpub,
screen_x=GUIConstants.COMPONENT_PADDING,
screen_y=self.derivation_line.screen_y + self.derivation_line.height + GUIConstants.COMPONENT_PADDING,
)
self.components.append(self.xpub_line)
@dataclass
class SeedAddPassphraseScreen(BaseTopNavScreen):
title: str = "Add Passphrase"
passphrase: str = ""
def __post_init__(self):
super().__post_init__()
keys_lower = "abcdefghijklmnopqrstuvwxyz"
keys_upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
keys_number = "0123456789"
keys_symbol = "!\"#$%&'()*+,=./;:<>?@[]|-_`~"
# Set up the keyboard params
self.right_panel_buttons_width = 60
font = Fonts.get_font("RobotoCondensed-Regular", 28)
tw, th = font.getsize(keys_lower + keys_upper + keys_number + keys_symbol) # All possible chars for max size measurements
text_entry_side_padding = 0
text_entry_top_padding = 1
text_entry_bottom_padding = 10
text_entry_top_y = self.top_nav.height + text_entry_top_padding
text_entry_bottom_y = text_entry_top_y + 3 + th + 3
self.text_entry_display = TextEntryDisplay(
canvas=self.renderer.canvas,
rect=(text_entry_side_padding,text_entry_top_y, self.canvas_width - self.right_panel_buttons_width - 1, text_entry_bottom_y),
font=font,
font_color=GUIConstants.ACCENT_COLOR,
cursor_mode=TextEntryDisplay.CURSOR_MODE__BAR,
is_centered=False,
has_outline=True,
cur_text=''.join(self.passphrase)
)
keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding
self.keyboard_abc = Keyboard(
draw=self.renderer.draw,
charset=keys_lower,
rows=4,
cols=9,
rect=(0, keyboard_start_y, self.canvas_width - self.right_panel_buttons_width, self.canvas_height),
additional_keys=[Keyboard.KEY_SPACE_5, Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT]
)
self.keyboard_ABC = Keyboard(
draw=self.renderer.draw,
charset=keys_upper,
rows=4,
cols=9,
rect=(0, keyboard_start_y, self.canvas_width - self.right_panel_buttons_width, self.canvas_height),
additional_keys=[Keyboard.KEY_SPACE_5, Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
self.keyboard_digits = Keyboard(
draw=self.renderer.draw,
charset=keys_number,
rows=3,
cols=5,
rect=(0, keyboard_start_y, self.canvas_width - self.right_panel_buttons_width, self.canvas_height),
additional_keys=[Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
self.keyboard_symbols = Keyboard(
draw=self.renderer.draw,
charset=keys_symbol,
rows=4,
cols=10,
rect=(0, keyboard_start_y, self.canvas_width - self.right_panel_buttons_width, self.canvas_height),
additional_keys=[Keyboard.KEY_SPACE_4, Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
self.button1_is_active = False
self.button2_is_active = False
self.button3_is_active = False
def render_right_panel(self, button1_text="ABC", button2_text="123"):
# Render the up/down arrow buttons for KEY1 and KEY3
row_height = 28
right_button_left_margin = 10
right_button_width = self.right_panel_buttons_width - right_button_left_margin
font_padding_right = 2
font_padding_top = 1
key_x = self.canvas_width - right_button_width
key_y = int(self.canvas_height - row_height) / 2 - 1 - 60
background_color = "#111"
font_color = GUIConstants.ACCENT_COLOR
font = Fonts.get_font("RobotoCondensed-Regular", 24)
tw, th = font.getsize(button1_text)
if self.button1_is_active:
background_color = GUIConstants.ACCENT_COLOR
font_color = "#111"
self.renderer.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=GUIConstants.ACCENT_COLOR, fill=background_color, radius=5, width=1)
self.renderer.draw.text((self.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button1_text, fill=font_color)
background_color = "#111"
font_color = GUIConstants.ACCENT_COLOR
tw, th = font.getsize(button2_text)
if self.button2_is_active:
background_color = GUIConstants.ACCENT_COLOR
font_color = "#111"
key_y = int(self.canvas_height - row_height) / 2 - 1
self.renderer.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=GUIConstants.ACCENT_COLOR, fill=background_color, radius=5, width=1)
self.renderer.draw.text((self.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button2_text, fill=font_color)
background_color = "#111"
font_color = GUIConstants.ACCENT_COLOR
button3_text = "Save"
tw, th = font.getsize(button3_text)
if self.button3_is_active:
background_color = GUIConstants.ACCENT_COLOR
font_color = "#111"
key_y = int(self.canvas_height - row_height) / 2 - 1 + 60
self.renderer.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=GUIConstants.ACCENT_COLOR, fill=background_color, radius=5, width=1)
self.renderer.draw.text((self.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color)
def _render(self):
super()._render()
self.text_entry_display.render()
self.render_right_panel()
self.keyboard_abc.render_keys()
self.renderer.show_image()
def _run(self):
cursor_position = len(self.passphrase)
KEYBOARD__LOWERCASE_BUTTON_TEXT = "abc"
KEYBOARD__UPPERCASE_BUTTON_TEXT = "ABC"
KEYBOARD__DIGITS_BUTTON_TEXT = "123"
KEYBOARD__SYMBOLS_BUTTON_TEXT = "!@#"
cur_keyboard = self.keyboard_abc
cur_button1_text = KEYBOARD__UPPERCASE_BUTTON_TEXT
cur_button2_text = KEYBOARD__DIGITS_BUTTON_TEXT
# Start the interactive update loop
while True:
input = self.hw_inputs.wait_for(
[B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY1, B.KEY2, B.KEY3],
check_release=True,
release_keys=[B.KEY_PRESS, B.KEY1, B.KEY2, B.KEY3]
)
keyboard_swap = False
# Check our two possible exit conditions
if input == B.KEY3:
# Save!
if len(self.passphrase) > 0:
return self.passphrase.strip()
elif input == B.KEY_PRESS and self.top_nav.is_selected:
# Back button clicked
return self.top_nav.selected_button
# Check for keyboard swaps
if input == B.KEY1:
# Return to the same button2 keyboard, if applicable
if cur_keyboard == self.keyboard_digits:
cur_button2_text = KEYBOARD__DIGITS_BUTTON_TEXT
elif cur_keyboard == self.keyboard_symbols:
cur_button2_text = KEYBOARD__SYMBOLS_BUTTON_TEXT
if cur_button1_text == KEYBOARD__LOWERCASE_BUTTON_TEXT:
self.keyboard_abc.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"])
cur_keyboard = self.keyboard_abc
cur_button1_text = KEYBOARD__UPPERCASE_BUTTON_TEXT
self.render_right_panel(button1_text=cur_button1_text, button2_text=cur_button2_text)
else:
self.keyboard_ABC.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"])
cur_keyboard = self.keyboard_ABC
cur_button1_text = KEYBOARD__LOWERCASE_BUTTON_TEXT
self.render_right_panel(button1_text=cur_button1_text, button2_text=cur_button2_text)
cur_keyboard.render_keys()
keyboard_swap = True
ret_val = None
elif input == B.KEY2:
# Return to the same button1 keyboard, if applicable
if cur_keyboard == self.keyboard_abc:
cur_button1_text = KEYBOARD__LOWERCASE_BUTTON_TEXT
elif cur_keyboard == self.keyboard_ABC:
cur_button1_text = KEYBOARD__UPPERCASE_BUTTON_TEXT
if cur_button2_text == KEYBOARD__DIGITS_BUTTON_TEXT:
self.keyboard_digits.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"])
cur_keyboard = self.keyboard_digits
cur_keyboard.render_keys()
cur_button2_text = KEYBOARD__SYMBOLS_BUTTON_TEXT
self.render_right_panel(button1_text=cur_button1_text, button2_text=cur_button2_text)
else:
self.keyboard_symbols.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"])
cur_keyboard = self.keyboard_symbols
cur_keyboard.render_keys()
cur_button2_text = KEYBOARD__DIGITS_BUTTON_TEXT
self.render_right_panel(button1_text=cur_button1_text, button2_text=cur_button2_text)
cur_keyboard.render_keys()
keyboard_swap = True
ret_val = None
else:
# Process normal input
if input in [B.KEY_UP, B.KEY_DOWN] and self.top_nav.is_selected:
# We're navigating off the previous button
self.top_nav.is_selected = False
self.top_nav.render()
# Override the actual input w/an ENTER signal for the Keyboard
if input == B.KEY_DOWN:
input = Keyboard.ENTER_TOP
else:
input = Keyboard.ENTER_BOTTOM
elif input in [B.KEY_LEFT, B.KEY_RIGHT] and self.top_nav.is_selected:
# ignore
continue
ret_val = cur_keyboard.update_from_input(input)
# Now process the result from the keyboard
if ret_val in Keyboard.EXIT_DIRECTIONS:
self.top_nav.is_selected = True
self.top_nav.render()
elif ret_val in Keyboard.ADDITIONAL_KEYS and input == B.KEY_PRESS:
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
if cursor_position == 0:
pass
elif cursor_position == len(self.passphrase):
self.passphrase = self.passphrase[:-1]
else:
self.passphrase = self.passphrase[:cursor_position - 1] + self.passphrase[cursor_position:]
cursor_position -= 1
elif ret_val == Keyboard.KEY_CURSOR_LEFT["code"]:
cursor_position -= 1
if cursor_position < 0:
cursor_position = 0
elif ret_val == Keyboard.KEY_CURSOR_RIGHT["code"]:
cursor_position += 1
if cursor_position > len(self.passphrase):
cursor_position = len(self.passphrase)
elif ret_val == Keyboard.KEY_SPACE["code"]:
if cursor_position == len(self.passphrase):
self.passphrase += " "
else:
self.passphrase = self.passphrase[:cursor_position] + " " + self.passphrase[cursor_position:]
cursor_position += 1
# Update the text entry display and cursor
self.text_entry_display.render(self.passphrase, cursor_position)
elif input == B.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
# User has locked in the current letter
if cursor_position == len(self.passphrase):
self.passphrase += ret_val
else:
self.passphrase = self.passphrase[:cursor_position] + ret_val + self.passphrase[cursor_position:]
cursor_position += 1
# Update the text entry display and cursor
self.text_entry_display.render(self.passphrase, cursor_position)
elif input in [B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN] or keyboard_swap:
# Live joystick movement; haven't locked this new letter in yet.
# Leave current spot blank for now. Only update the active keyboard keys
# when a selection has been locked in (KEY_PRESS) or removed ("del").
pass
self.renderer.show_image()
@dataclass
class SeedReviewPassphraseScreen(ButtonListScreen):
fingerprint_without: str = None
fingerprint_with: str = None
passphrase: str = None
def __post_init__(self):
# Customize defaults
self.title = "Verify Passphrase"
self.is_bottom_list = True
super().__post_init__()
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
label_text="changes fingerprint",
value_text=f"{self.fingerprint_without} >> {self.fingerprint_with}",
is_text_centered=True,
screen_y = self.buttons[0].screen_y - GUIConstants.COMPONENT_PADDING - int(GUIConstants.BODY_FONT_SIZE*2.5)
))
available_height = self.components[-1].screen_y - self.top_nav.height + GUIConstants.COMPONENT_PADDING
print(f"available_height: {available_height}")
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]
found_solution = False
for font_size in range(max_font_size, min_font_size, -2):
if found_solution:
break
font = Fonts.get_font(font_name=GUIConstants.FIXED_WIDTH_FONT_NAME, size=font_size)
char_width, char_height = font.getsize("X")
for num_lines in range(1, max_lines+1):
print(f"font_size: {font_size} | num_lines: {num_lines}")
# Break the passphrase into n lines
chars_per_line = math.ceil(len(self.passphrase) / num_lines)
passphrase = []
for i in range(0, len(self.passphrase), chars_per_line):
passphrase.append(self.passphrase[i:i+chars_per_line])
print(passphrase)
# See if it fits in this configuration
print(f"width: {char_width * len(passphrase[0])} | height: {num_lines * char_height}")
if char_width * len(passphrase[0]) <= self.canvas_width - 2*GUIConstants.EDGE_PADDING:
# Width is good...
if num_lines * char_height <= available_height:
# And the height is good!
found_solution = True
break
# Set up each line of text
screen_y = self.top_nav.height + int((available_height - char_height*num_lines)/2) - GUIConstants.COMPONENT_PADDING
for line in passphrase:
self.components.append(TextArea(
text=line,
font_name=GUIConstants.FIXED_WIDTH_FONT_NAME,
font_size=font_size,
is_text_centered=True,
screen_y=screen_y,
))
screen_y += char_height + 2
@dataclass
class SingleSigAddressVerificationScreen(ButtonListScreen):
"""
TODO: Reserved for Nick
Not yet exposed in the UI. This was moved from the PSBT Verification flow since
we don't need to brute force iterate the change addrs there. But this can still
be useful for a generalized address verification process. Probably makes sense to
have a screen before this that prompts for the index num but also give the user
the choice to just start the brute force search.
"Skip 10" feature not yet implemented. To do this you would simply increment the
`ThreadsafeCounter` via its `increment(step=10)` method. Because it is
threadsafe, the next brute force round by the
`SingleSigAddressVerificationThread` can just check its value and resume its work
from the updated index.
"""
address: str = None
threadsafe_counter: ThreadsafeCounter = None
def __post_init__(self):
# Customize defaults
self.title = "Verify Change"
self.is_bottom_list = True
self.button_data = ["Skip 10", "Cancel"]
super().__post_init__()
label = TextArea(
text="Address",
font_size=GUIConstants.LABEL_FONT_SIZE,
font_color=GUIConstants.LABEL_FONT_COLOR,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
)
self.components.append(label)
address_display = FormattedAddress(
address=self.address,
max_lines=1,
screen_y=label.screen_y + label.height
)
self.components.append(address_display)
self.threads.append(SingleSigAddressVerificationScreen.ProgressThread(
renderer=self.renderer,
screen_y=address_display.screen_y + address_display.height + GUIConstants.COMPONENT_PADDING,
threadsafe_counter=self.threadsafe_counter,
))
class ProgressThread(BaseThread):
def __init__(self, renderer: Renderer, screen_y: int, threadsafe_counter: ThreadsafeCounter):
self.renderer = renderer
self.screen_y = screen_y
self.threadsafe_counter = threadsafe_counter
super().__init__()
def run(self):
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE)
tw, th = font.getsize("Checking address 001")
while self.keep_running:
with self.renderer.lock:
# Need to clear the pixels
# self.renderer.draw.rectangle((0, self.screen_y, self.renderer.canvas_width, self.screen_y + th), fill=GUIConstants.BACKGROUND_COLOR)
textarea = TextArea(
text=f"Checking address {self.threadsafe_counter.cur_count}",
font_name=GUIConstants.BODY_FONT_NAME,
font_size=GUIConstants.BODY_FONT_SIZE,
screen_y=self.screen_y
)
textarea.render()
# self.renderer.draw.text((int((self.renderer.canvas_width - tw)/2), self.screen_y), text=f"Checking address {self.threadsafe_counter.cur_count}", font=font, fill=GUIConstants.BODY_FONT_COLOR)
self.renderer.show_image()
time.sleep(0.1)
@@ -0,0 +1,43 @@
from dataclasses import dataclass
from typing import List
from seedsigner.gui.components import CheckboxButton, CheckedSelectionButton, GUIConstants, TextArea
from seedsigner.gui.screens.screen import ButtonListScreen
from seedsigner.models.settings import SettingsConstants
@dataclass
class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
display_name: str = None
help_text: str = None
checked_buttons: List[int] = None
settings_entry_type: str = SettingsConstants.TYPE__ENABLED_DISABLED
selected_button: int = 0
def __post_init__(self):
self.title = "Settings"
self.is_bottom_list = True
self.use_checked_selection_buttons = True
if self.settings_entry_type == SettingsConstants.TYPE__MULTISELECT:
self.Button_cls = CheckboxButton
else:
self.Button_cls = CheckedSelectionButton
super().__post_init__()
self.title_textarea = TextArea(
text=self.display_name,
font_size=GUIConstants.BODY_FONT_MAX_SIZE,
is_text_centered=True,
auto_line_break=False,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
)
self.components.append(self.title_textarea)
if self.help_text:
self.title_textarea = TextArea(
text=self.help_text,
font_color=GUIConstants.LABEL_FONT_COLOR,
is_text_centered=True,
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
)
self.components.append(self.title_textarea)
+14 -6
View File
@@ -3,26 +3,34 @@ import RPi.GPIO as GPIO
import time
import numpy as np
class ST7789(object):
"""class for ST7789 240*240 1.3inch OLED displays."""
def __init__(self,spi,rst = 27,dc = 25,bl = 24):
def __init__(self):
self.width = 240
self.height = 240
#Initialize DC RST pin
self._dc = dc
self._rst = rst
self._bl = bl
self._dc = 25
self._rst = 27
self._bl = 24
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(self._dc,GPIO.OUT)
GPIO.setup(self._rst,GPIO.OUT)
GPIO.setup(self._bl,GPIO.OUT)
GPIO.output(self._bl, GPIO.HIGH)
#Initialize SPI
self._spi = spi
self._spi = spidev.SpiDev(0, 0)
self._spi.max_speed_hz = 40000000
self.init()
""" Write register address and data """
def command(self, cmd):
GPIO.output(self._dc, GPIO.LOW)
@@ -32,7 +40,7 @@ class ST7789(object):
GPIO.output(self._dc, GPIO.HIGH)
self._spi.writebytes([val])
def Init(self):
def init(self):
"""Initialize dispaly"""
self.reset()
+1 -3
View File
@@ -1,7 +1,5 @@
from .buttons import *
from .keyboard import Keyboard, TextEntryDisplay
from .path import *
from .pivideostream import PiVideoStream
from .qr import *
from .singleton import Singleton
from .ST7789 import *
from .ST7789 import ST7789
+30 -21
View File
@@ -1,9 +1,10 @@
import RPi.GPIO as GPIO
import time
from seedsigner.models import Singleton
class Buttons:
class Buttons(Singleton):
KEY_UP_PIN = 6
KEY_DOWN_PIN = 19
KEY_LEFT_PIN = 5
@@ -14,29 +15,37 @@ class Buttons:
KEY2_PIN = 20
KEY3_PIN = 16
def __init__(self) -> None:
#init GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setup(Buttons.KEY_UP_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY_DOWN_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY_LEFT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY_RIGHT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY_PRESS_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY1_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY2_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY3_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
self.GPIO = GPIO
self.override_ind = False
@classmethod
def get_instance(cls):
# This is the only way to access the one and only instance
if cls._instance is None:
cls._instance = cls.__new__(cls)
self.add_events([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS, B.KEY_LEFT, B.KEY_RIGHT, B.KEY1, B.KEY2, B.KEY3])
#init GPIO
GPIO.setmode(GPIO.BCM)
GPIO.setup(Buttons.KEY_UP_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY_DOWN_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY_LEFT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY_RIGHT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY_PRESS_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY1_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY2_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(Buttons.KEY3_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
# Track state over time so we can apply input delays/ignores as needed
self.cur_input = None # Track which direction or button was last pressed
self.cur_input_started = None # Track when that input began
self.last_input_time = int(time.time() * 1000) # How long has it been since the last input?
self.first_repeat_threshold = 175 # Long-press time required before returning continuous input
self.next_repeat_threshold = 250 # Amount of time where we no longer consider input a continuous hold
cls._instance.GPIO = GPIO
cls._instance.override_ind = False
cls._instance.add_events([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS, B.KEY_LEFT, B.KEY_RIGHT, B.KEY1, B.KEY2, B.KEY3])
# Track state over time so we can apply input delays/ignores as needed
cls._instance.cur_input = None # Track which direction or button was last pressed
cls._instance.cur_input_started = None # Track when that input began
cls._instance.last_input_time = int(time.time() * 1000) # How long has it been since the last input?
cls._instance.first_repeat_threshold = 175 # Long-press time required before returning continuous input
cls._instance.next_repeat_threshold = 250 # Amount of time where we no longer consider input a continuous hold
return cls._instance
def wait_for(self, keys=[], check_release=True, release_keys=[]) -> int:
+22 -10
View File
@@ -1,33 +1,44 @@
import hashlib
import unicodedata
from embit import bip39
from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes
import unicodedata
import hashlib
from typing import List
def calculate_checksum(partial_mnemonic: list, wordlist):
# Provide 11- or 23-word mnemonic, returns complete mnemonic w/checksum
def calculate_checksum(partial_mnemonic: list, wordlist) -> List[str]:
""" Provide 11- or 23-word mnemonic, returns complete mnemonic w/checksum as a list """
if len(partial_mnemonic) not in [11, 23]:
raise Exception("Pass in a 11- or 23-word mnemonic")
# Work on a copy of the input list
mnemonic_copy = partial_mnemonic.copy()
# 12-word seeds contribute 7 bits of entropy to the final word; 24-word seeds
# contribute 3 bits. But we don't have any partial entropy bits to use to help us
# create the final word. So just default to filling those missing values with zeroes
# ("abandon" is word 0000, so effectively inserts zeroes).
mnemonic_copy.append("abandon")
# Ignores the final checksum word and recalcs
# Convert the resulting mnemonic to bytes, but we `ignore_checksum` validation
# because we have to assume it's incorrect since we just hard-coded it above; we'll
# fix that next.
mnemonic_bytes = bip39.mnemonic_to_bytes(unicodedata.normalize("NFKD", " ".join(mnemonic_copy)), ignore_checksum=True, wordlist=wordlist)
# Return as a list
# This function will convert the bytes back into a mnemonic, but it will also
# calculate the proper checksum bits while doing so. For a 12-word seed it will just
# overwrite the last 4 bits from the above result with the checksum; for a 24-word
# seed it'll overwrite the last 8 bits.
return bip39.mnemonic_from_bytes(mnemonic_bytes).split()
def generate_mnemonic_from_bytes(entropy_bytes):
# Return as a list
def generate_mnemonic_from_bytes(entropy_bytes) -> List[str]:
return bip39.mnemonic_from_bytes(entropy_bytes).split()
def generate_mnemonic_from_dice(roll_data: str):
def generate_mnemonic_from_dice(roll_data: str) -> List[str]:
entropy_bytes = hashlib.sha256(roll_data.encode()).digest()
# Return as a list
@@ -37,7 +48,8 @@ def generate_mnemonic_from_dice(roll_data: str):
# Note: This currently isn't being used since we're now chaining hashed bytes for the
# image-based entropy and aren't just ingesting a single image.
def generate_mnemonic_from_image(image):
def generate_mnemonic_from_image(image) -> List[str]:
import hashlib
hash = hashlib.sha256(image.tobytes())
# Return as a list
+6 -6
View File
@@ -12,17 +12,17 @@ class QR:
def __init__(self) -> None:
return
def qrimage(self, data, width=240, height=240, border=3, style=None):
def qrimage(self, data, width=240, height=240, border=3, style=None, background_color="#444"):
qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=5, border=border )
qr.add_data(data)
qr.make(fit=True)
if not style or style == QR.STYLE__DEFAULT:
return qr.make_image(fill_color="black", back_color="white").resize((width,height)).convert('RGBA')
return qr.make_image(fill_color="black", back_color=background_color).resize((width,height)).convert('RGBA')
else:
if style == QR.STYLE__ROUNDED:
return qr.make_image(
fill_color="black",
back_color="white",
back_color=background_color,
image_factory=StyledPilImage,
module_drawer=CircleModuleDrawer()
).resize((width,height)).convert('RGBA')
@@ -30,19 +30,19 @@ class QR:
elif style == QR.STYLE__GRID:
return qr.make_image(
fill_color="black",
back_color="white",
back_color=background_color,
image_factory=StyledPilImage,
module_drawer=GappedSquareModuleDrawer()
).resize((width,height)).convert('RGBA')
def qrimage_io(self, data, width=240, height=240, border=3, background="FFFFFF"):
def qrimage_io(self, data, width=240, height=240, border=3, background_color="808080"):
if 1 <= border <= 10:
border_str = str(border)
else:
border_str = "3"
cmd = 'qrencode -m '+border_str+' -s 3 -l L --foreground=000000 --background='+background+' -t PNG -o "/dev/shm/qrcode.png" "' + str(data) + '"'
cmd = f"""qrencode -m {border_str} -s 3 -l L --foreground=000000 --background={background_color} -t PNG -o "/dev/shm/qrcode.png" "{str(data)}" """
rv = subprocess.call(cmd, shell=True)
# if qrencode fails, fall back to only encoder
-13
View File
@@ -1,13 +0,0 @@
class Singleton:
_instance = None
def __init__(self):
# Singleton pattern must prevent normal instantiation
raise Exception("Cannot directly instantiate a Singleton. Access via get_instance()")
@classmethod
def get_instance(cls):
# This is the only way to access the one and only Controller
if cls._instance is None:
cls._instance = cls.__new__(cls)
return cls._instance
+42
View File
@@ -0,0 +1,42 @@
import logging
from threading import Thread, Lock
logger = logging.getLogger(__name__)
class BaseThread(Thread):
def __init__(self):
super().__init__(daemon=True)
def start(self):
logger.debug(f"{self.__class__.__name__} STARTING")
self.keep_running = True
super().start()
def stop(self):
logger.debug(f"{self.__class__.__name__} EXITING")
self.keep_running = False
def run(self):
while self.keep_running:
# Do something
raise Exception(f"Must implement run() in {self.__class__.__name__}")
class ThreadsafeCounter:
def __init__(self, initial_value: int = 0):
self.count = initial_value
self._lock = Lock()
@property
def cur_count(self):
# Reads don't require the lock
return self.count
def increment(self, step: int = 1):
# Updates must be locked
with self._lock:
self.count += step
+4 -2
View File
@@ -1,8 +1,10 @@
# Must import crucial base class first!
from .singleton import Singleton, ConfigurableSingleton
from .seed import *
from .qr_type import *
from .encode_qr_density import *
from .decode_qr import *
from .encode_qr import *
from .psbt_parser import *
from .seed_storage import *
from .settings import *
from .settings import *
File diff suppressed because it is too large Load Diff
+229 -163
View File
@@ -1,155 +1,211 @@
import math
from enum import IntEnum
from embit import psbt, bip32
from embit import bip32
from embit.networks import NETWORKS
from binascii import b2a_base64, hexlify
from dataclasses import dataclass
from typing import List
from embit import bip32
from embit.networks import NETWORKS
from embit.psbt import PSBT
from seedsigner.helpers.ur2.ur_encoder import UREncoder
from seedsigner.helpers.ur2.cbor_lite import CBOREncoder
from seedsigner.helpers.ur2.ur import UR
from seedsigner.helpers.bcur import (bc32encode, cbor_encode, bcur_encode)
from seedsigner.helpers.qr import QR
from seedsigner.models import Seed, QRType, EncodeQRDensity
from seedsigner.models import Seed, QRType
from urtypes.crypto import PSBT as UR_PSBT
from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP
###
### EncodeQR Class
### Purpose: used to encode psbt for displaying as qr image
###
from seedsigner.models.settings import SettingsConstants
@dataclass
class EncodeQR:
"""
Encode psbt for displaying as qr image
"""
# TODO: Refactor so that this is a base class with implementation classes for each
# QR type. No reason exterior code can't directly instantiate the encoder it needs.
WORDLIST = None
# Dataclass input vars on __init__()
psbt: PSBT = None
seed_phrase: List[str] = None
passphrase: str = None
derivation: str = None
network: str = SettingsConstants.MAINNET
qr_type: str = None
qr_density: str = SettingsConstants.DENSITY__MEDIUM
wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH
def __init__(self, **kwargs):
self.psbt = None
self.seed_phrase = None
self.passphrase = None
self.derivation = None
self.network = None
self.qr_type = None
self.qr_density = None
def __post_init__(self):
self.qr = QR()
self.wordlist = None
for key, value in kwargs.items():
if key == "psbt":
self.psbt = value
elif key == "seed_phrase":
self.seed_phrase = value
elif key == "passphrase":
self.passphrase = value
elif key == "derivation":
self.derivation = value
elif key == "network":
self.network = value
elif key == "qr_type":
self.qr_type = value
elif key == "qr_density":
self.qr_density = value
elif key == "wordlist":
self.wordlist = value
if self.wordlist == None:
raise Exception('Wordlist Required')
if self.qr_type == None:
raise Exception('Encoder Type Required')
if not self.qr_type:
raise Exception('qr_type is required')
if self.qr_density == None:
self.qr_density = EncodeQRDensity.MEDIUM
self.qr_density = SettingsConstants.DENSITY__MEDIUM
self.encoder: BaseQrEncoder = None
# PSBT formats
if self.qr_type == QRType.PSBT__SPECTER:
self.encoder = SpecterPsbtQrEncoder(psbt=self.psbt, qr_density=self.qr_density)
elif self.qr_type == QRType.PSBT__UR2:
self.encoder = UrPsbtQrEncoder(psbt=self.psbt, qr_density=self.qr_density)
# XPUB formats
elif self.qr_type == QRType.XPUB:
self.encoder = XpubQrEncoder(
seed_phrase=self.seed_phrase,
passphrase=self.passphrase,
derivation=self.derivation,
network=self.network,
wordlist_language_code=self.wordlist_language_code
)
elif self.qr_type == QRType.XPUB__UR:
self.encoder = UrXpubQrEncoder(
qr_density=self.qr_density,
seed_phrase=self.seed_phrase,
passphrase=self.passphrase,
derivation=self.derivation,
network=self.network,
wordlist_language_code=self.wordlist_language_code
)
elif self.qr_type == QRType.XPUB__SPECTER:
self.encoder = SpecterXPubQrEncoder(
qr_density=self.qr_density,
seed_phrase=self.seed_phrase,
passphrase=self.passphrase,
derivation=self.derivation,
network=self.network,
wordlist_language_code=self.wordlist_language_code
)
# SeedQR formats
elif self.qr_type == QRType.SEED__SEEDQR:
self.encoder = SeedQrEncoder(seed_phrase=self.seed_phrase,
wordlist_language_code=self.wordlist_language_code)
elif self.qr_type == QRType.SEED__COMPACTSEEDQR:
self.encoder = CompactSeedQrEncoder(seed_phrase=self.seed_phrase,
wordlist_language_code=self.wordlist_language_code)
self.encoder = None
if self.qr_type == QRType.PSBTSPECTER:
self.encoder = SpecterEncodePSBTQR(self.psbt, self.qr_density)
elif self.qr_type == QRType.PSBTUR2:
self.encoder = UREncodePSBTQR(self.psbt, self.qr_density)
elif self.qr_type == QRType.SEEDQR:
self.encoder = SeedQR(self.seed_phrase, self.wordlist)
elif self.qr_type == QRType.COMPACTSEEDQR:
self.encoder = CompactSeedQR(self.seed_phrase, self.wordlist)
elif self.qr_type == QRType.URXPUBQR:
self.encoder = URXPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.qr_density, self.wordlist)
elif self.qr_type == QRType.XPUBQR:
self.encoder = XPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.wordlist)
elif self.qr_type == QRType.SPECTERXPUBQR:
self.encoder = SpecterXPubQR(self.seed_phrase, self.passphrase, self.derivation, self.network, self.qr_density, self.wordlist)
else:
raise Exception('Encoder Type not Supported')
raise Exception('QR Type not supported')
def totalParts(self):
return self.encoder.seqLen()
def nextPart(self):
return self.encoder.nextPart()
def total_parts(self):
return self.encoder.seq_len()
def part2Image(self, part, width=240, height=240, border=3):
def next_part(self):
return self.encoder.next_part()
def part_to_image(self, part, width=240, height=240, border=3):
return self.qr.qrimage_io(part, width, height, border)
def nextPartImage(self, width=240, height=240, border=3, background="FFFFFF"):
part = self.nextPart()
if self.qr_type == QRType.SEEDQR:
def next_part_image(self, width=240, height=240, border=3, background_color="bdbdbd"):
part = self.next_part()
if self.qr_type == QRType.SEED__SEEDQR:
return self.qr.qrimage(part, width, height, border)
else:
return self.qr.qrimage_io(part, width, height, border, background=background)
return self.qr.qrimage_io(part, width, height, border, background_color=background_color)
def isComplete(self):
return self.encoder.isComplete()
def getQRDensity(self):
# TODO: Make these properties?
def is_complete(self):
return self.encoder.is_complete
def get_qr_density(self):
return self.qr_density
def getQRType(self):
def get_qr_type(self):
return self.qr_type
class UREncodePSBTQR:
def __init__(self, p, qr_density):
self.psbt = p
self.qr_max_fragement_size = 20
class BaseQrEncoder:
def seq_len(self):
raise Exception("Not implemented in child class")
def next_part(self) -> str:
raise Exception("Not implemented in child class")
@property
def is_complete(self):
raise Exception("Not implemented in child class")
def _create_parts(self):
raise Exception("Not implemented in child class")
class BasePsbtQrEncoder(BaseQrEncoder):
def __init__(self, psbt: PSBT):
self.psbt = psbt
class UrPsbtQrEncoder(BasePsbtQrEncoder):
def __init__(self, psbt, qr_density):
super().__init__(psbt)
self.qr_max_fragment_size = 20
qr_ur_bytes = UR("crypto-psbt", UR_PSBT(self.psbt.serialize()).to_cbor())
if qr_density == EncodeQRDensity.LOW:
self.qr_max_fragement_size = 10
elif qr_density == EncodeQRDensity.MEDIUM:
self.qr_max_fragement_size = 30
elif qr_density == EncodeQRDensity.HIGH:
self.qr_max_fragement_size = 120
if qr_density == SettingsConstants.DENSITY__LOW:
self.qr_max_fragment_size = 10
elif qr_density == SettingsConstants.DENSITY__MEDIUM:
self.qr_max_fragment_size = 30
elif qr_density == SettingsConstants.DENSITY__HIGH:
self.qr_max_fragment_size = 120
self.ur2_encode = UREncoder(qr_ur_bytes,self.qr_max_fragement_size,0)
self.ur2_encode = UREncoder(ur=qr_ur_bytes, max_fragment_len=self.qr_max_fragment_size)
def seqLen(self):
def seq_len(self):
return self.ur2_encode.fountain_encoder.seq_len()
def nextPart(self) -> str:
def next_part(self) -> str:
return self.ur2_encode.next_part().upper()
def isComplete(self):
@property
def is_complete(self):
return self.ur2_encode.is_complete()
class SpecterEncodePSBTQR:
def __init__(self, p, qr_density):
self.psbt = p
class SpecterPsbtQrEncoder(BasePsbtQrEncoder):
def __init__(self, psbt, qr_density):
super().__init__(psbt)
self.qr_max_fragement_size = 65
self.parts = []
self.part_num_sent = 0
self.sent_complete = False
if qr_density == EncodeQRDensity.LOW:
if qr_density == SettingsConstants.DENSITY__LOW:
self.qr_max_fragement_size = 40
elif qr_density == EncodeQRDensity.MEDIUM:
elif qr_density == SettingsConstants.DENSITY__MEDIUM:
self.qr_max_fragement_size = 65
elif qr_density == EncodeQRDensity.HIGH:
elif qr_density == SettingsConstants.DENSITY__HIGH:
self.qr_max_fragement_size = 90
self.__createParts()
self._create_parts()
def __createParts(self):
def _create_parts(self):
base64_psbt = b2a_base64(self.psbt.serialize())
if base64_psbt[-1:] == b"\n":
@@ -175,10 +231,12 @@ class SpecterEncodePSBTQR:
stop = len(base64_psbt)
cnt += 1
def seqLen(self):
def seq_len(self):
return len(self.parts)
def nextPart(self) -> str:
def next_part(self) -> str:
# if part num sent is gt number of parts, start at 0
if self.part_num_sent > (len(self.parts) - 1):
self.part_num_sent = 0
@@ -194,41 +252,44 @@ class SpecterEncodePSBTQR:
return part
def isComplete(self):
@property
def is_complete(self):
return self.sent_complete
class SeedQR:
def __init__(self, seed_phrase, wordlist):
class SeedQrEncoder(BaseQrEncoder):
def __init__(self, seed_phrase: List[str], wordlist_language_code: str):
super().__init__()
self.seed_phrase = seed_phrase
self.wordlist = wordlist
self.wordlist = Seed.get_wordlist(wordlist_language_code)
if self.wordlist == None:
raise Exception('Wordlist Required')
def seqLen(self):
def seq_len(self):
return 1
def nextPart(self):
def next_part(self):
data = ""
# Output as Numeric data format
for word in self.seed_phrase:
index = self.wordlist.index(word)
data += str("%04d" % index)
return data
def isComplete(self):
@property
def is_complete(self):
return True
class CompactSeedQR(SeedQR):
def nextPart(self):
class CompactSeedQrEncoder(SeedQrEncoder):
def next_part(self):
# Output as binary data format
binary_str = ""
for word in self.seed_phrase:
@@ -257,12 +318,13 @@ class CompactSeedQR(SeedQR):
class XPubQR:
def __init__(self, seed_phrase, passphrase, derivation, network, wordlist):
class XpubQrEncoder(BaseQrEncoder):
def __init__(self, seed_phrase, passphrase, derivation, network, wordlist_language_code):
self.seed_phrase = seed_phrase
self.passphrase = passphrase
self.derivation = derivation
self.wordlist = wordlist
self.network = network
self.wordlist = Seed.get_wordlist(wordlist_language_code)
self.parts = []
self.part_num_sent = 0
self.sent_complete = False
@@ -270,56 +332,66 @@ class XPubQR:
if self.wordlist == None:
raise Exception('Wordlist Required')
version = bip32.detect_version(self.derivation, default="xpub", network=NETWORKS[network])
self.seed = Seed(mnemonic=self.seed_phrase, passphrase=self.passphrase, wordlist=self.wordlist)
self.root = bip32.HDKey.from_seed(self.seed.seed, version=NETWORKS[network]["xprv"])
version = bip32.detect_version(self.derivation, default="xpub", network=NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
self.seed = Seed(mnemonic=self.seed_phrase,
passphrase=self.passphrase,
wordlist_language_code=wordlist_language_code)
self.root = bip32.HDKey.from_seed(self.seed.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]["xprv"])
self.fingerprint = self.root.child(0).fingerprint
self.xprv = self.root.derive(self.derivation)
self.xpub = self.xprv.to_public()
self.xpub_base58 = self.xpub.to_string(version=version)
self.network = network
self.xpubstring = "[%s%s]%s" % (hexlify(self.fingerprint).decode('utf-8'),self.derivation[1:],self.xpub_base58)
self.xpubstring = "[{}{}]{}".format(
hexlify(self.fingerprint).decode('utf-8'),
self.derivation[1:],
self.xpub_base58
)
if isinstance(self, XPubQR):
self.__createParts()
self._create_parts()
def __createParts(self):
def _create_parts(self):
self.parts = []
self.parts.append(self.xpubstring)
def nextPart(self) -> str:
def next_part(self) -> str:
if len(self.parts) > 0:
self.sent_complete = True
return self.parts[0]
def seqLen(self):
def seq_len(self):
return len(self.parts)
def isComplete(self):
@property
def is_complete(self):
return self.sent_complete
class SpecterXPubQR(XPubQR):
def __init__(self, seed_phrase, passphrase, derivation, network, qr_density, wordlist):
self.qr_max_fragement_size = 65
if qr_density == EncodeQRDensity.LOW:
self.qr_max_fragement_size = 40
elif qr_density == EncodeQRDensity.MEDIUM:
self.qr_max_fragement_size = 65
elif qr_density == EncodeQRDensity.HIGH:
self.qr_max_fragement_size = 90
XPubQR.__init__(self, seed_phrase, passphrase, derivation, network, wordlist)
self.__createParts()
class SpecterXPubQrEncoder(XpubQrEncoder):
def __init__(self, qr_density, **kwargs):
# Must set up qr_max_fragment_size before calling super().__init__()
self.qr_max_fragment_size = 65
if qr_density == SettingsConstants.DENSITY__LOW:
self.qr_max_fragment_size = 40
elif qr_density == SettingsConstants.DENSITY__MEDIUM:
self.qr_max_fragment_size = 65
elif qr_density == SettingsConstants.DENSITY__HIGH:
self.qr_max_fragment_size = 90
def __createParts(self):
super().__init__(**kwargs)
def _create_parts(self):
self.parts = []
start = 0
stop = self.qr_max_fragement_size
qr_cnt = ((len(self.xpubstring)-1) // self.qr_max_fragement_size) + 1
stop = self.qr_max_fragment_size
qr_cnt = ((len(self.xpubstring)-1) // self.qr_max_fragment_size) + 1
if qr_cnt == 1:
self.parts.append(self.xpubstring[start:stop])
@@ -329,13 +401,14 @@ class SpecterXPubQR(XPubQR):
part = "p" + str(cnt+1) + "of" + str(qr_cnt) + " " + self.xpubstring[start:stop]
self.parts.append(part)
start = start + self.qr_max_fragement_size
stop = stop + self.qr_max_fragement_size
start = start + self.qr_max_fragment_size
stop = stop + self.qr_max_fragment_size
if stop > len(self.xpubstring):
stop = len(self.xpubstring)
cnt += 1
def nextPart(self) -> str:
def next_part(self) -> str:
# if part num sent is gt number of parts, start at 0
if self.part_num_sent > (len(self.parts) - 1):
self.part_num_sent = 0
@@ -351,24 +424,18 @@ class SpecterXPubQR(XPubQR):
return part
def seqLen(self):
return len(self.parts)
def isComplete(self):
return self.sent_complete
class URXPubQR(XPubQR):
def __init__(self, seed_phrase, passphrase, derivation, network, qr_density, wordlist):
class UrXpubQrEncoder(XpubQrEncoder):
def __init__(self, qr_density, **kwargs):
super().__init__(**kwargs)
XPubQR.__init__(self, seed_phrase, passphrase, derivation, network, wordlist)
if qr_density == EncodeQRDensity.LOW:
self.qr_max_fragement_size = 10
elif qr_density == EncodeQRDensity.MEDIUM:
self.qr_max_fragement_size = 30
elif qr_density == EncodeQRDensity.HIGH:
self.qr_max_fragement_size = 120
if qr_density == SettingsConstants.DENSITY__LOW:
self.qr_max_fragment_size = 10
elif qr_density == SettingsConstants.DENSITY__MEDIUM:
self.qr_max_fragment_size = 30
elif qr_density == SettingsConstants.DENSITY__HIGH:
self.qr_max_fragment_size = 120
def derivation_to_keypath(path: str) -> list:
arr = path.split("/")
@@ -388,7 +455,7 @@ class URXPubQR(XPubQR):
return Keypath(arr, self.root.my_fingerprint, len(arr))
origin = derivation_to_keypath(derivation)
origin = derivation_to_keypath(self.derivation)
self.ur_hdkey = HDKey({ 'key': self.xpub.key.serialize(),
'chain_code': self.xpub.chain_code,
@@ -421,13 +488,12 @@ class URXPubQR(XPubQR):
qr_ur_bytes = UR("crypto-account", ur_account.to_cbor())
self.ur2_encode = UREncoder(qr_ur_bytes, self.qr_max_fragement_size, 0)
def seqLen(self):
return self.ur2_encode.fountain_encoder.seq_len()
self.ur2_encode = UREncoder(ur=qr_ur_bytes, max_fragment_len=self.qr_max_fragment_size)
def nextPart(self) -> str:
def seq_len(self):
return self.ur2_encode.fountain_encoder.seq_len()
def next_part(self) -> str:
return self.ur2_encode.next_part().upper()
def isComplete(self):
return self.ur2_encode.is_complete()
@@ -1,11 +0,0 @@
from enum import IntEnum
###
### QRType Class IntEum
### Purpose: used with DecodeQR and EncodeQR to communicate qr encoding type
###
class EncodeQRDensity(IntEnum):
LOW = 1
MEDIUM = 2
HIGH = 3
+153 -35
View File
@@ -1,77 +1,110 @@
from binascii import hexlify
from embit import psbt, script, ec, bip32, bip39
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.settings import SettingsConstants
class PSBTParser():
def __init__(self, p, seed: Seed, network="main"):
def __init__(self, p: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET):
self.psbt: PSBT = p
self.seed = seed
self.network = network
self.psbt = p
self.policy = None
self.spend_amount = 0
self.change_amount = 0
self.change_data = []
self.fee_amount = 0
self.input_amount = 0
self.num_inputs = 0
self.destination_addresses = []
self.self_addresses = []
self.destination_amounts = []
self.root = None
if self.seed:
self.parse(self.psbt,self.seed,self.network)
if self.seed is not None:
self.parse()
def __setRoot(self, seed: Seed, network):
self.root = bip32.HDKey.from_seed(seed.seed, version=NETWORKS[network]["xprv"])
def parse(self, p, seed: Seed, network="main"):
is_psbt_empty = False
try:
if p == None:
is_psbt_empty = True
except:
pass
def get_change_data(self, change_num: int) -> dict:
if change_num < len(self.change_data):
return self.change_data[change_num]
if is_psbt_empty:
@property
def num_change_outputs(self):
return len(self.change_data)
@property
def is_multisig(self):
"""
Multisig psbts will have "m" and "n" defined in policy
"""
return "m" in self.policy
@property
def num_destinations(self):
return len(self.destination_addresses)
def _set_root(self):
self.root = bip32.HDKey.from_seed(self.seed.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]["xprv"])
print(f"root: {self.root}")
def parse(self):
if self.psbt is None:
print(f"self.psbt is None!!")
return False
if not seed:
if not self.seed:
print("self.seed is None!")
return False
self.__setRoot(seed, network)
self._set_root()
rt = self.__parseInputs()
rt = self._parse_inputs()
if rt == False:
return False
rt = self.__parseOutputs()
rt = self._parse_outputs()
if rt == False:
return False
return True
def __parseInputs(self):
def _parse_inputs(self):
self.input_amount = 0
print(f"psbt.inputs: {self.psbt.inputs}")
self.num_inputs = len(self.psbt.inputs)
for inp in self.psbt.inputs:
if inp.witness_utxo:
self.input_amount += inp.witness_utxo.value
inp_policy = PSBTParser.__get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs)
inp_policy = PSBTParser._get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs)
if self.policy == None:
self.policy = inp_policy
else:
if self.policy != inp_policy:
raise RuntimeError("Mixed inputs in the transaction")
def __parseOutputs(self):
def _parse_outputs(self):
self.spend_amount = 0
self.change_amount = 0
self.change_data = []
self.fee_amount = 0
self.destination_addresses = []
self.self_addresses = []
self.destination_amounts = []
for i, out in enumerate(self.psbt.outputs):
out_policy = PSBTParser.__get_policy(out, self.psbt.tx.vout[i].script_pubkey, self.psbt.xpubs)
out_policy = PSBTParser._get_policy(out, self.psbt.tx.vout[i].script_pubkey, self.psbt.xpubs)
is_change = False
# if policy is the same - probably change
@@ -109,15 +142,32 @@ class PSBTParser():
if sc.data == self.psbt.tx.vout[i].script_pubkey.data:
is_change = True
if is_change:
addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
fingerprints = None
derivation_paths = None
if len(self.psbt.outputs[i].bip32_derivations) > 0:
fingerprints = []
derivation_paths = []
for d, derivation_path in self.psbt.outputs[i].bip32_derivations.items():
fingerprints.append(hexlify(derivation_path.fingerprint).decode())
derivation_paths.append(bip32.path_to_str(derivation_path.derivation))
self.change_data.append({
"address": addr,
"amount": self.psbt.tx.vout[i].value,
"fingerprint": fingerprints,
"derivation_path": derivation_paths,
})
self.change_amount += self.psbt.tx.vout[i].value
self.self_addresses.append(self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[self.network]))
else:
addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
self.destination_addresses.append(addr)
self.destination_amounts.append(self.psbt.tx.vout[i].value)
self.spend_amount += self.psbt.tx.vout[i].value
self.destination_addresses.append(self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[self.network]))
self.fee_amount = self.psbt.fee()
return True
@staticmethod
def trim(tx):
trimmed_psbt = psbt.PSBT(tx.tx)
@@ -128,17 +178,54 @@ class PSBTParser():
return trimmed_psbt
@staticmethod
def sigCount(tx):
def sig_count(tx):
cnt = 0
for i, inp in enumerate(tx.inputs):
cnt += len(list(inp.partial_sigs.keys()))
return cnt
# checks that all inputs are from the same wallet
@staticmethod
def __get_policy(scope, scriptpubkey, xpubs):
def calc_derivation(network, wallet_type, script_type):
# TODO: Move this to Seed?
if network == SettingsConstants.MAINNET:
network_path = "0'"
elif network == SettingsConstants.TESTNET:
network_path = "1'"
elif network == SettingsConstants.REGTEST:
# TODO: Is this right?
network_path = "1'"
else:
raise Exception("Unexpected network")
if wallet_type == SettingsConstants.SINGLE_SIG:
if script_type == SettingsConstants.NATIVE_SEGWIT:
return f"m/84'/{network_path}/0'"
elif script_type == SettingsConstants.NESTED_SEGWIT:
return f"m/49'/{network_path}/0'"
elif script_type == SettingsConstants.TAPROOT:
return f"m/86'/{network_path}/0'"
else:
raise Exception("Unexpected script type")
elif wallet_type == SettingsConstants.MULTISIG:
if script_type == SettingsConstants.NATIVE_SEGWIT:
return f"m/48'/{network_path}/0'/2'"
elif script_type == SettingsConstants.NESTED_SEGWIT:
return f"m/48'/{network_path}/0'/1'"
elif script_type == SettingsConstants.TAPROOT:
raise Exception("Taproot multisig/musig not yet supported")
else:
raise Exception("Unexpected script type")
else:
raise Exception("Unexpected wallet type") # checks that all inputs are from the same wallet
@staticmethod
def _get_policy(scope, scriptpubkey, xpubs):
"""Parse scope and get policy"""
# we don't know the policy yet, let's parse it
script_type = scriptpubkey.script_type()
@@ -155,18 +242,18 @@ class PSBTParser():
policy = {"type": script_type}
# expected multisig
if "p2wsh" in script_type and scope.witness_script is not None:
m, n, pubkeys = PSBTParser.__parse_multisig(scope.witness_script)
m, n, pubkeys = PSBTParser._parse_multisig(scope.witness_script)
# check pubkeys are derived from cosigners
try:
cosigners = PSBTParser.__get_cosigners(pubkeys, scope.bip32_derivations, xpubs)
cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs)
policy.update({"m": m, "n": n, "cosigners": cosigners})
except:
policy.update({"m": m, "n": n})
return policy
# returns m, n, and pubkeys from multisig script
@staticmethod
def __parse_multisig(sc):
def _parse_multisig(sc):
"""Takes a script and extracts m,n and pubkeys from it"""
# OP_m <len:pubkey> ... <len:pubkey> OP_n OP_CHECKMULTISIG
# check min size
@@ -193,8 +280,9 @@ class PSBTParser():
raise ValueError("Invalid multisig script")
return m, n, pubkeys
@staticmethod
def __get_cosigners(pubkeys, derivations, xpubs):
def _get_cosigners(pubkeys, derivations, xpubs):
"""Returns xpubs used to derive pubkeys using global xpub field from psbt"""
cosigners = []
for i, pubkey in enumerate(pubkeys):
@@ -215,3 +303,33 @@ class PSBTParser():
if len(cosigners) != len(pubkeys):
raise RuntimeError("Can't get all cosigners")
return sorted(cosigners)
@staticmethod
def get_input_fingerprints(psbt: PSBT) -> List[str]:
"""
Exctracts the fingerprint from each input's derivation path.
TODO: It's unclear if these derivations/fingerprints would ever be missing.
Research on PSBT standard and known wallet coordinator implementations
needed.
"""
fingerprints = set()
for input in psbt.inputs:
for pub, derivation_path in input.bip32_derivations.items():
fingerprints.add(hexlify(derivation_path.fingerprint).decode())
return list(fingerprints)
@staticmethod
def has_matching_input_fingerprint(psbt: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET):
"""
Extracts the fingerprint from each psbt input utxo. Returns True if any match
the current seed.
"""
seed_fingerprint = seed.get_fingerprint(network)
for input in psbt.inputs:
for pub, derivation_path in input.bip32_derivations.items():
if seed_fingerprint == hexlify(derivation_path.fingerprint).decode():
return True
return False
+27 -24
View File
@@ -1,26 +1,29 @@
from enum import IntEnum
class QRType:
"""
Used with DecodeQR and EncodeQR to communicate qr encoding type
"""
PSBT__BASE64 = "psbt__base64"
PSBT__SPECTER = "psbt__specter"
PSBT__LEGACY_UR = "psbt__legacy_ur"
PSBT__BASE43 = "psbt__base43"
PSBT__UR2 = "psbt__ur2"
###
### QRType Class IntEum
### Purpose: used with DecodeQR and EncodeQR to communicate qr encoding type
###
SEED__SEEDQR = "seed__seedqr"
SEED__COMPACTSEEDQR = "seed__compactseedqr"
SEED__UR2 = "seed__ur2"
SEED__MNEMONIC = "seed__mnemonic"
SEED__FOUR_LETTER_MNEMONIC = "seed__four_letter_mnemonic"
class QRType(IntEnum):
PSBTBASE64 = 1
PSBTSPECTER = 2
PSBTURLEGACY = 3
PSBTBASE43 = 4
PSBTUR2 = 5
SEEDQR = 6
COMPACTSEEDQR = 10
SEEDUR2 = 7
SEEDMNEMONIC = 8
SEED4LETTERMNEMONIC = 9
XPUBQR = 20
SPECTERXPUBQR = 21
URXPUBQR = 22
BITCOINADDRESSQR = 30
SPECTERWALLETQR = 40
URWALLETQR = 41
BLUEWALLETQR = 42
INVALID = 100
SETTINGS = "settings"
XPUB = "xpub"
XPUB__SPECTER = "xpub__specter"
XPUB__UR = "xpub__ur"
BITCOIN_ADDRESS = "bitcoin_address"
WALLET__SPECTER = "wallet__specter"
WALLET__UR = "wallet__ur"
WALLET__BLUEWALLET = "wallet__bluewallet"
INVALID = "invalid"
+101 -95
View File
@@ -1,108 +1,114 @@
# External Dependencies
from embit import bip39, bip32
import unicodedata
from binascii import hexlify
from embit import bip39, bip32
from embit.networks import NETWORKS
from typing import List
from seedsigner.models.settings import SettingsConstants
class InvalidSeedException(Exception):
pass
class Seed:
def __init__(self,
mnemonic: List[str] = None,
passphrase: str = "",
wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> None:
self.wordlist_language_code = wordlist_language_code
def __init__(self, mnemonic = None, passphrase = "", wordlist=None) -> None:
self._init_complete = False
self._valid = False
self.passphrase = passphrase
self.mnemonic = mnemonic
self._wordlist = wordlist
if self._wordlist == None:
raise Exception('Wordlist Required')
self._valid = self._generate_seed()
self._init_complete = True
def _generate_seed(self) -> bool:
self.seed = None
try:
self.seed = bip39.mnemonic_to_seed(self.mnemonic, password=self.passphrase, wordlist=self._wordlist)
return True
except Exception as e:
return False
if not mnemonic:
raise Exception("Must initialize a Seed with a mnemonic List[str]")
self._mnemonic: List[str] = unicodedata.normalize("NFKD", " ".join(mnemonic).strip()).split()
self._passphrase: str = ""
self.set_passphrase(passphrase, regenerate_seed=False)
self.seed_bytes: bytes = None
self._generate_seed()
### getters and setters
@property
def mnemonic(self):
return self._mnemonic
@property
def mnemonic_list(self):
return self._mnemonic.split()
@property
def mnemonic_display(self):
return unicodedata.normalize("NFC", self._mnemonic)
@property
def mnemonic_display_list(self):
return unicodedata.normalize("NFC", self._mnemonic).split()
@staticmethod
def get_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]:
# TODO: Support other bip-39 wordlist languages!
if wordlist_language_code == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH:
return bip39.WORDLIST
else:
raise Exception(f"Unrecognized wordlist_language_code {wordlist_language_code}")
@mnemonic.setter
def mnemonic(self, value):
if isinstance(value, list):
self._mnemonic = unicodedata.normalize("NFKD", " ".join(value).strip())
elif isinstance(value, str):
self._mnemonic = unicodedata.normalize("NFKD", value.strip())
if self._init_complete:
self._valid = self._generate_seed()
@property
def passphrase(self):
return self._passphrase
@property
def passphrase_display(self):
return unicodedata.normalize("NFC", self._passphrase)
def _generate_seed(self) -> bool:
try:
self.seed_bytes = bip39.mnemonic_to_seed(self.mnemonic_str, password=self._passphrase, wordlist=self.wordlist)
except Exception as e:
print(repr(e))
raise InvalidSeedException(repr(e))
@passphrase.setter
def passphrase(self, value):
if isinstance(value, str):
self._passphrase = unicodedata.normalize("NFKD", value)
if self._init_complete:
self._valid = self._generate_seed()
@property
def mnemonic_str(self) -> str:
return " ".join(self._mnemonic)
@property
def wordlist(self):
return self._wordlist
@property
def mnemonic_list(self) -> List[str]:
return self._mnemonic
@wordlist.setter
def wordlist(self, value):
previous_wordlist = self._wordlist
if isinstance(value, list):
if len(value) == 2048:
self._wordlist = value
else:
raise Exception('Invalid Wordlist')
else:
raise Exception('Wordlist Must be List Type')
if self.mnemonic != None:
# if has mnemonic, convert to new wordlist
previous_mnemonic_str = self._mnemonic
previous_mnemonic_list = previous_mnemonic_str.split()
new_mnemonic_list = []
for word in previous_mnemonic_list:
idx = previous_wordlist.index(word)
new_mnemonic_list.append(self._wordlist[idx])
self._mnemonic = " ".join(new_mnemonic_list)
@property
def mnemonic_display_str(self) -> str:
return unicodedata.normalize("NFC", " ".join(self._mnemonic))
### override operators
def __eq__(self, other):
if isinstance(other, Seed):
return self.seed == other.seed
return False
def __bool__(self):
return self._valid
@property
def mnemonic_display_list(self) -> List[str]:
return unicodedata.normalize("NFC", " ".join(self._mnemonic)).split()
@property
def passphrase(self):
return self._passphrase
@property
def passphrase_display(self):
return unicodedata.normalize("NFC", self._passphrase)
def set_passphrase(self, passphrase: str, regenerate_seed: bool = True):
if passphrase:
self._passphrase = unicodedata.normalize("NFKD", passphrase)
else:
# Passphrase must always have a string value, even if it's just the empty
# string.
self._passphrase = ""
if regenerate_seed:
# Regenerate the internal seed since passphrase changes the result
self._generate_seed()
@property
def wordlist(self) -> List[str]:
return Seed.get_wordlist(self.wordlist_language_code)
def set_wordlist_language_code(self, language_code: str):
# TODO: Support other bip-39 wordlist languages!
raise Exception("Not yet implemented!")
def get_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str:
root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"])
return hexlify(root.child(0).fingerprint).decode('utf-8')
### override operators
def __eq__(self, other):
if isinstance(other, Seed):
return self.seed_bytes == other.seed_bytes
return False
+32 -167
View File
@@ -1,182 +1,47 @@
# External Dependencies
from typing import List
from seedsigner.models import Seed
from seedsigner.models.seed import InvalidSeedException
class SeedStorage:
def __init__(self) -> None:
self.seeds: List[Seed] = []
self.pending_seed: Seed = None
def __init__(self) -> None:
self.seeds = []
# to be removed once the concepts of slots is removed
# this is a list of 3 indexes to the actual seed/mnemonic/passphrase data
# -1 means the slot is empty
self.slots = [-1,-1,-1]
# only adds a seed if it's valid and does not already exist
def add_mnemonic(self, mnemonic, passphrase: str = "", slot_num = -1) -> bool:
self.seeds.append(Seed(mnemonic, passphrase))
# used to track virtual slot number, TODO: remove concept in UI of slots
if slot_num > 0:
self.slots[slot_num - 1] = len(self.seeds) - 1
def add_seed(self, seed: Seed, slot_num = -1):
self.seeds.append(seed)
# used to track virtual slot number, TODO: remove concept in UI of slots
if slot_num > 0:
self.slots[slot_num - 1] = len(self.seeds) - 1
def add_passphrase_to_seed(self, seed, passphrase: str = ""):
if not isinstance(passphrase, str):
raise ValueError("Invalid passphrase format")
# find existing seed to add passphrase to
if seed in self.seeds:
idx = self.seeds.index(seed)
seed.passphrase = passphrase
return True
else:
return False
def remove_passphrase_from_seed(self, seed):
return self.add_passphrase_to_seed(seed, "")
# validates mnemonic part of a seed in list or string format
def validate_mnemonic(self, seed_phrase) -> bool:
try:
seed = Seed(seed_phrase)
except Exception as e:
return False
return True
def seed_count(self):
return len(self.seeds)
def set_pending_seed(self, seed: Seed):
self.pending_seed = seed
#
# Slot related methods to be removed once the concepts of slots is removed
# TODO: remove concept in UI of slots
#
def get_mnemonic_from_slot(self, slot_num = 0):
if self.slots[slot_num - 1] == -1:
raise ValueError("Slot is unexpectedly empty")
return self.seeds[self.slots[slot_num - 1]].mnemonic
def get_passphrase_from_slot(self, slot_num = 0):
if self.slots[slot_num - 1] == -1:
raise ValueError("Slot is unexpectedly empty")
return self.seeds[self.slots[slot_num - 1]].passphrase
def add_passphrase_to_slot(self, passphrase: str = "", slot_num = -1):
if self.slots[slot_num - 1] == -1:
raise ValueError("Slot is unexpectedly empty")
return self.add_passphrase_to_seed(self.seeds[self.slots[slot_num - 1]], passphrase)
def get_pending_seed(self) -> Seed:
return self.pending_seed
def remove_passphrase_from_slot(self, slot_num = -1):
if self.slots[slot_num - 1] == -1:
raise ValueError("Slot is unexpectedly empty")
return self.remove_passphrase_from_seed(self.seeds[self.slots[slot_num - 1]])
def get_first_seed_slot(self):
if self.slots[0] != -1:
return self.slots[0] + 1
elif self.slots[1] != -1:
return self.slots[1] + 1
elif self.slots[2] != -1:
return self.slots[2] + 1
def finalize_pending_seed(self) -> int:
# Finally store the pending seed and return its index
if self.pending_seed in self.seeds:
index = self.seeds.index(self.pending_seed)
else:
self.seeds.append(self.pending_seed)
index = len(self.seeds) - 1
self.pending_seed = None
return index
def check_slot_1(self) -> bool:
if self.slots[0] != -1:
return True
else:
return False
def check_slot_2(self) -> bool:
if self.slots[1] != -1:
return True
else:
return False
def check_slot_3(self) -> bool:
if self.slots[2] != -1:
return True
else:
return False
def check_slot(self, slot_num) -> bool:
if self.slots[slot_num-1] != -1:
return True
else:
return False
def clear_pending_seed(self):
self.pending_seed = None
def check_slot_passphrase(self, slot_num) -> bool:
if self.slots[slot_num-1] != -1:
if self.seeds[self.slots[slot_num-1]].passphrase != "":
return True
return False
def slot_avaliable(self) -> bool:
if self.slots[0] == -1 or self.slots[1] == -1 or self.slots[2] == -1:
return True
return True
def num_of_saved_seeds(self) -> int:
count = 0
if self.slots[0] != -1:
count += 1
if self.slots[1] != -1:
count += 1
if self.slots[2] != -1:
count += 1
return count
def validate_mnemonic(self, mnemonic: List[str]) -> bool:
try:
Seed(mnemonic=mnemonic)
except InvalidSeedException as e:
return False
return True
def num_of_free_slots(self) -> int:
count = 0
if self.slots[0] == -1:
count += 1
if self.slots[1] == -1:
count += 1
if self.slots[2] == -1:
count += 1
return count
def save_seed_phrase(self, seed_phrase = [], slot_num = 0) -> bool:
return self.add_mnemonic(seed_phrase,slot_num=slot_num)
def save_passphrase(self, passphrase, slot_num = 0) -> bool:
return self.add_passphrase_to_slot(passphrase, slot_num=slot_num)
def get_seed_phrase(self, slot_num) -> []:
if self.slots[slot_num - 1] == -1:
return []
return self.seeds[self.slots[slot_num - 1]].mnemonic.split()
def get_passphrase(self, slot_num) -> str:
if self.slots[slot_num - 1] == -1:
return ""
return self.seeds[self.slots[slot_num - 1]].passphrase
def get_seed(self, slot_num) -> Seed:
if self.slots[slot_num - 1] == -1:
return Seed()
return self.seeds[self.slots[slot_num - 1]]
def delete_passphrase(self, slot_num) -> bool:
if self.slots[slot_num - 1] == -1:
return False
return self.remove_passphrase_from_seed(self.seeds[self.slots[slot_num - 1]])
def check_if_seed_valid(self, seed_phrase) -> bool:
return self.validate_mnemonic(seed_phrase)
def num_seeds(self):
return len(self.seeds)
+151 -268
View File
@@ -1,284 +1,167 @@
from seedsigner.helpers import Singleton
from .qr_type import QRType
from .encode_qr_density import EncodeQRDensity
import json
import os
from typing import Any, List
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from .singleton import Singleton
import configparser
from embit import bip39
class Settings(Singleton):
SETTINGS_FILENAME = "settings.json"
@classmethod
def configure_instance(cls, config=None):
# Must be called before the first get_instance() call
if cls._instance:
raise Exception("Instance already configured")
def get_instance(cls):
# This is the only way to access the one and only instance
if cls._instance is None:
# Instantiate the one and only instance
settings = cls.__new__(cls)
cls._instance = settings
# Instantiate the one and only instance
settings = cls.__new__(cls)
cls._instance = settings
settings._data = SettingsDefinition.get_defaults()
# default internal data structure for settings
settings._data = {
'system': {
'debug': False,
'default_language': "en",
'persistent_settings': False,
'wordlist': bip39.WORDLIST
},
'display': {
'text_color': "ORANGE",
'qr_background_color': "FFFFFF",
'camera_rotation': 0
},
'wallet': {
'network': "main",
'software': "Specter Desktop",
'qr_density': EncodeQRDensity.MEDIUM,
'custom_derivation': 'm/0/0',
'compact_seedqr_enabled': False,
}
}
# Read persistent settings file, if it exists
if os.path.exists(Settings.SETTINGS_FILENAME):
with open(Settings.SETTINGS_FILENAME) as settings_file:
settings.update(json.load(settings_file), disable_missing_entries=False)
settings.init_complete = False
return cls._instance
if config is not None:
# read settings.ini typically
settings.__config_to_data(config)
settings.init_complete = True
def __config_to_data(self, config):
# TODO: Make each get resilient to the possibility of the field being missing in
# the `settings.ini`
self.persistent = config.getboolean("system", "persistent_settings")
self._data["system"]["debug"] = config.getboolean("system", "debug")
self._data["system"]["default_language"] = config["system"]["default_language"]
self._data["display"]["text_color"] = config["display"]["text_color"]
self.qr_background_color = config["display"]["qr_background_color"]
self._data["display"]["camera_rotation"] = int(config["display"]["camera_rotation"])
self.network = config["wallet"]["network"]
self.software = config["wallet"]["software"]
self.qr_density = int(config["wallet"]["qr_density"])
self.custom_derivation = config["wallet"]["custom_derivation"]
if "compact_seedqr_enabled"in config["wallet"]:
self.compact_seedqr_enabled = config.getboolean("wallet", "compact_seedqr_enabled")
### persistent settings handling
@property
def persistent(self):
return self._data["system"]["persistent_settings"]
@persistent.setter
def persistent(self, value):
if type(value) == bool:
if value == False and value != self._data["system"]["persistent_settings"]:
# persistence is changed to false, restore defaults
self._data["system"]["persistent_settings"] = value
self.init_complete == False
self.restoreDefault()
self.init_complete == True
else:
self._data["system"]["persistent_settings"] = value
self.__writeConfig()
else:
raise Exception("Unexpected system.persistent_settings settings.ini value")
def restoreDefault(self):
config = configparser.ConfigParser()
config.read("default_settings.ini")
self.__config_to_data(config)
self.__writeSettingsIni(config)
def __writeSettingsIni(self, config):
with open('settings.ini', 'w') as configfile:
config.write(configfile)
configfile.close()
def __generateConfig(self):
config = configparser.ConfigParser()
config['system'] = self._data['system']
config['display'] = self._data['display']
config['wallet'] = self._data['wallet']
return config
def __writeConfig(self):
if self._data["system"]["persistent_settings"] == True and self.init_complete == True:
config = self.__generateConfig()
self.__writeSettingsIni(config)
@property
def persistent_display(self):
if self.persistent:
return "Yes"
else:
return "No"
### system
@property
def debug(self):
return self._data["system"]["debug"]
@property
def language(self):
return self._data["system"]["default_language"]
@property
def wordlist(self):
return self._data["system"]["wordlist"]
### display
@property
def text_color(self):
return self._data["display"]["text_color"]
def __str__(self):
return json.dumps(self._data, indent=4)
@property
def qr_background_color(self):
return self._data["display"]["qr_background_color"]
def save(self):
if self._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] == SettingsConstants.OPTION__ENABLED:
with open(Settings.SETTINGS_FILENAME, 'w') as settings_file:
json.dump(self._data, settings_file, indent=4)
def update(self, new_settings: dict, disable_missing_entries: bool = True):
"""
* disable_missing_entries: The SettingsQR Generator omits any multiselect
fields with zero selections or disabled Enabled/Disabled toggles. So if a
field is missing, interpret it as such. But if this is set to False, keep
the existing value for the field; most likely this is a new setting that
the user may not have a value for when loading their persistent settings,
in which case this would preserve the new field's default value.
"""
for entry in SettingsDefinition.settings_entries:
if entry.attr_name not in new_settings:
if not disable_missing_entries:
# Setting is missing; insert default
new_settings[entry.attr_name] = entry.default_value
elif entry.visibility == SettingsConstants.VISIBILITY__HIDDEN:
# Missing hidden values always get their default
new_settings[entry.attr_name] = entry.default_value
elif entry.type == SettingsConstants.TYPE__MULTISELECT:
# Clear out the multiselect
new_settings[entry.attr_name] = []
elif entry.type in SettingsConstants.ALL_ENABLED_DISABLED_TYPES:
# Set DISABLED for this missing setting
new_settings[entry.attr_name] = SettingsConstants.OPTION__DISABLED
else:
# Clean the incoming data, if necessary
if entry.type == SettingsConstants.TYPE__MULTISELECT:
if type(new_settings[entry.attr_name]) == str:
# Break comma-separated SettingsQR input into List
new_settings[entry.attr_name] = new_settings[entry.attr_name].split(",")
# Can't just merge the _data dict; have to replace keys they have in common
# (otherwise list values will be merged instead of replaced).
for key, value in new_settings.items():
self._data.pop(key, None)
self._data[key] = value
def set_value(self, attr_name: str, value: any):
"""
Updates the attr's current value.
Note that for multiselect, the value must be a List.
"""
if attr_name not in self._data:
raise Exception(f"Setting for {attr_name} not found")
if SettingsDefinition.get_settings_entry(attr_name).type == SettingsConstants.TYPE__MULTISELECT:
if type(value) != list:
raise Exception(f"value must be a List for {attr_name}")
@qr_background_color.setter
def qr_background_color(self, value):
self._data["display"]["qr_background_color"] = value
self.__writeConfig()
# Special handling for toggling persistence
if attr_name == SettingsConstants.SETTING__PERSISTENT_SETTINGS and value == SettingsConstants.OPTION__DISABLED:
os.remove(self.SETTINGS_FILENAME)
print(f"Removed {self.SETTINGS_FILENAME}")
self._data[attr_name] = value
self.save()
def get_value(self, attr_name: str):
"""
Returns the attr's current value.
Note that for multiselect, the current value is a List.
"""
if attr_name not in self._data:
raise Exception(f"Setting for {attr_name} not found")
return self._data[attr_name]
def get_value_display_name(self, attr_name: str) -> str:
"""
Figures out the mapping from value to display_name for the current value's
tuple(value, display_name) definition, if it's defined that way.
If the selection_options are defined as simple strings, we just return the
string.
Cannot be used for multiselect (use get_multiselect_value_display_names
instead) or free entry types (there is no tuple mapping).
"""
if attr_name not in self._data:
raise Exception(f"Setting for {attr_name} not found")
settings_entry = SettingsDefinition.get_settings_entry(attr_name)
if settings_entry.type in [SettingsConstants.TYPE__FREE_ENTRY, SettingsConstants.TYPE__MULTISELECT]:
raise Exception(f"Unsupported SettingsEntry.type: {settings_entry.type}")
return settings_entry.get_selection_option_display_name_by_value(value=self._data[attr_name])
def get_multiselect_value_display_names(self, attr_name: str) -> List[str]:
"""
Returns a List of all the selected values' display_names.
"""
if attr_name not in self._data:
raise Exception(f"Setting for {attr_name} not found")
settings_entry = SettingsDefinition.get_settings_entry(attr_name)
if settings_entry.type != SettingsConstants.TYPE__MULTISELECT:
raise Exception(f"Unsupported SettingsEntry.type: {settings_entry.type}")
display_names = []
for value in self._data[attr_name]:
display_names.append(settings_entry.get_selection_option_display_name_by_value(value))
return display_names
"""
Intentionally keeping the properties very limited to avoid an expectation of
boilerplate property code for every SettingsEntry.
It's more cumbersome, but instead use:
settings.get_value(SettingsConstants.SETTING__MY_SETTING_ATTR)
"""
@property
def camera_rotation(self):
return self._data["display"]["camera_rotation"]
def debug(self) -> bool:
return self._data[SettingsConstants.SETTING__DEBUG] == SettingsConstants.OPTION__ENABLED
@camera_rotation.setter
def camera_rotation(self, value):
if value in [0, 90, 180, 270]:
self._data["display"]["camera_rotation"] = value
self.__writeConfig()
else:
raise Exception("Unexpected display.camera_rotation settings.ini value")
### wallet
@property
def network(self):
return self._data["wallet"]["network"]
@network.setter
def network(self, value):
if value in ("main", "test"):
self._data["wallet"]["network"] = value
self.__writeConfig()
else:
raise Exception("Unexpected wallet.network settings.ini value")
@property
def software(self):
return self._data["wallet"]["software"]
@software.setter
def software(self, value):
if value in ("Specter Desktop", "Blue Wallet", "Sparrow", "Prompt"):
self._data["wallet"]["software"] = value
self.__writeConfig()
else:
raise Exception("Unexpected wallet.software settings.ini value")
@property
def qr_density(self):
return self._data["wallet"]["qr_density"]
@qr_density.setter
def qr_density(self, value):
if value in (EncodeQRDensity.LOW, EncodeQRDensity.MEDIUM, EncodeQRDensity.HIGH, int(EncodeQRDensity.LOW), int(EncodeQRDensity.MEDIUM), int(EncodeQRDensity.HIGH)):
self._data["wallet"]["qr_density"] = int(value)
self.__writeConfig()
else:
raise Exception("Unexpected wallet.qr_density settings.ini value")
@property
def qr_psbt_type(self):
if self.software in ("Specter Desktop"):
return QRType.PSBTSPECTER
else:
return QRType.PSBTUR2
@property
def qr_xpub_type(self):
return Settings.getXPubType(self.software)
@staticmethod
def getXPubType(software):
if software == "Specter Desktop":
return QRType.SPECTERXPUBQR
elif software == "Blue Wallet":
return QRType.XPUBQR
else:
return QRType.URXPUBQR
@property
def qr_density_name(self):
if self.qr_density == EncodeQRDensity.LOW:
return "Low"
elif self.qr_density == EncodeQRDensity.MEDIUM:
return "Medium"
elif self.qr_density == EncodeQRDensity.HIGH:
return "High"
else:
return "Unknown"
@property
def custom_derivation(self):
return self._data["wallet"]["custom_derivation"]
@custom_derivation.setter
def custom_derivation(self, value):
# TODO: parse and validate custom derivation path
self._data["wallet"]["custom_derivation"] = value
self.__writeConfig()
@property
def compact_seedqr_enabled(self):
return self._data["wallet"]["compact_seedqr_enabled"]
@compact_seedqr_enabled.setter
def compact_seedqr_enabled(self, value):
self._data["wallet"]["compact_seedqr_enabled"] = value
self.__writeConfig()
@staticmethod
def calc_derivation(network, wallet_type, script_type):
if network == "main":
if wallet_type.lower() == "single sig":
if script_type.lower() == "native segwit":
return "m/84'/0'/0'"
elif script_type.lower() == "nested segwit":
return "m/49'/0'/0'"
else:
raise Exception("Unexpected script type")
elif wallet_type.lower() == "multisig":
if script_type.lower() == "native segwit":
return "m/48'/0'/0'/2'"
elif script_type.lower() == "nested segwit":
return "m/48'/0'/0'/1'"
else:
raise Exception("Unexpected script type")
else:
raise Exception("Unexpected wallet type")
elif network == "test":
if wallet_type.lower() == "single sig":
if script_type.lower() == "native segwit":
return "m/84'/1'/0'"
elif script_type.lower() == "nested segwit":
return "m/49'/1'/0'"
else:
raise Exception("Unexpected script type")
elif wallet_type.lower() == "multisig":
if script_type.lower() == "native segwit":
return "m/48'/1'/0'/2'"
elif script_type.lower() == "nested segwit":
return "m/48'/1'/0'/1'"
else:
raise Exception("Unexpected script type")
else:
raise Exception("Unexpected wallet type")
else:
raise Exception("Unexpected network type")
@@ -0,0 +1,476 @@
from dataclasses import dataclass
from typing import Any, List
class SettingsConstants:
# Basic defaults
OPTION__ENABLED = "E"
OPTION__DISABLED = "D"
OPTION__PROMPT = "P"
OPTION__REQUIRED = "R"
OPTIONS__ENABLED_DISABLED = [
(OPTION__ENABLED, "Enabled"),
(OPTION__DISABLED, "Disabled"),
]
OPTIONS__ENABLED_DISABLED_PROMPT = OPTIONS__ENABLED_DISABLED + [
(OPTION__PROMPT, "Prompt"),
]
ALL_OPTIONS = OPTIONS__ENABLED_DISABLED_PROMPT + [
(OPTION__REQUIRED, "Required"),
]
# User-facing selection options
COORDINATOR__BLUE_WALLET = "bw"
COORDINATOR__SPARROW = "spa"
COORDINATOR__SPECTER_DESKTOP = "spd"
ALL_COORDINATORS = [
(COORDINATOR__BLUE_WALLET, "BlueWallet"),
(COORDINATOR__SPARROW, "Sparrow"),
(COORDINATOR__SPECTER_DESKTOP, "Specter Desktop"),
]
LANGUAGE__ENGLISH = "en"
ALL_LANGUAGES = [
(LANGUAGE__ENGLISH, "English"),
]
CAMERA_ROTATION__0 = 0
CAMERA_ROTATION__90 = 90
CAMERA_ROTATION__180 = 180
CAMERA_ROTATION__270 = 270
ALL_CAMERA_ROTATIONS = [
(CAMERA_ROTATION__0, "0°"),
(CAMERA_ROTATION__90, "90°"),
(CAMERA_ROTATION__180, "180°"),
(CAMERA_ROTATION__270, "270°"),
]
# QR code constants
DENSITY__LOW = "L"
DENSITY__MEDIUM = "M"
DENSITY__HIGH = "H"
ALL_DENSITIES = [
(DENSITY__LOW, "Low"),
(DENSITY__MEDIUM, "Medium"),
(DENSITY__HIGH, "High"),
]
# Seed-related constants
MAINNET = "M"
TESTNET = "T"
REGTEST = "R"
ALL_NETWORKS = [
(MAINNET, "Mainnet"),
(TESTNET, "Testnet"),
(REGTEST, "Regtest")
]
@classmethod
def map_network_to_embit(cls, network) -> str:
if network == SettingsConstants.MAINNET:
return "main"
elif network == SettingsConstants.TESTNET:
return "test"
if network == SettingsConstants.REGTEST:
return "regtest"
SINGLE_SIG = "ss"
MULTISIG = "ms"
ALL_SIG_TYPES = [
(SINGLE_SIG, "Single Sig"),
(MULTISIG, "Multisig"),
]
NATIVE_SEGWIT = "nat"
NESTED_SEGWIT = "nes"
TAPROOT = "tr"
CUSTOM_DERIVATION = "cus"
ALL_SCRIPT_TYPES = [
(NATIVE_SEGWIT, "Native Segwit"),
(NESTED_SEGWIT, "Nested Segwit (legacy)"),
(TAPROOT, "Taproot"),
(CUSTOM_DERIVATION, "Custom Derivation"),
]
WORDLIST_LANGUAGE__ENGLISH = "en"
WORDLIST_LANGUAGE__CHINESE_SIMPLIFIED = "zh_Hans_CN"
WORDLIST_LANGUAGE__CHINESE_TRADITIONAL = "zh_Hant_TW"
WORDLIST_LANGUAGE__FRENCH = "fr"
WORDLIST_LANGUAGE__ITALIAN = "it"
WORDLIST_LANGUAGE__JAPANESE = "jp"
WORDLIST_LANGUAGE__KOREAN = "kr"
WORDLIST_LANGUAGE__PORTUGUESE = "pt"
ALL_WORDLIST_LANGUAGES = [
(WORDLIST_LANGUAGE__ENGLISH, "English"),
# (WORDLIST_LANGUAGE__CHINESE_SIMPLIFIED, "简体中文"),
# (WORDLIST_LANGUAGE__CHINESE_TRADITIONAL, "繁體中文"),
# (WORDLIST_LANGUAGE__FRENCH, "Français"),
# (WORDLIST_LANGUAGE__ITALIAN, "Italiano"),
# (WORDLIST_LANGUAGE__JAPANESE, "日本語"),
# (WORDLIST_LANGUAGE__KOREAN, "한국어"),
# (WORDLIST_LANGUAGE__PORTUGUESE, "Português"),
]
# Individual SettingsEntry attr_names
SETTING__LANGUAGE = "language"
SETTING__WORDLIST_LANGUAGE = "wordlist_language"
SETTING__PERSISTENT_SETTINGS = "persistent_settings"
SETTING__COORDINATORS = "coordinators"
SETTING__NETWORK = "network"
SETTING__QR_DENSITY = "qr_density"
SETTING__XPUB_EXPORT = "xpub_export"
SETTING__SIG_TYPES = "sig_types"
SETTING__SCRIPT_TYPES = "script_types"
SETTING__XPUB_DETAILS = "xpub_details"
SETTING__PASSPHRASE = "passphrase"
SETTING__CAMERA_ROTATION = "camera_rotation"
SETTING__COMPACT_SEEDQR = "compact_seedqr"
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
SETTING__DIRE_WARNINGS = "dire_warnings"
SETTING__DEBUG = "debug"
# Hidden settings
SETTING__QR_BRIGHTNESS = "qr_background_color"
# Structural constants
# TODO: Not using these for display purposes yet (ever?)
CATEGORY__SYSTEM = "system"
CATEGORY__DISPLAY = "display"
CATEGORY__WALLET = "wallet"
CATEGORY__FEATURES = "features"
VISIBILITY__GENERAL = "general"
VISIBILITY__ADVANCED = "advanced"
VISIBILITY__DEVELOPER = "developer"
VISIBILITY__HIDDEN = "hidden" # For data-only (e.g. custom_derivation), not configurable by the user
# TODO: Is there really a difference between ENABLED and PROMPT?
TYPE__ENABLED_DISABLED = "enabled_disabled"
TYPE__ENABLED_DISABLED_PROMPT = "enabled_disabled_prompt"
TYPE__ENABLED_DISABLED_PROMPT_REQUIRED = "enabled_disabled_prompt_required"
TYPE__SELECT_1 = "select_1"
TYPE__MULTISELECT = "multiselect"
TYPE__FREE_ENTRY = "free_entry"
ALL_ENABLED_DISABLED_TYPES = [
TYPE__ENABLED_DISABLED,
TYPE__ENABLED_DISABLED_PROMPT,
TYPE__ENABLED_DISABLED_PROMPT_REQUIRED,
]
@dataclass
class SettingsEntry:
"""
Defines all the parameters for a single settings entry.
* category: Mostly for organizational purposes when displaying options in the
SettingsQR UI. Potentially an additional sub-level breakout in the menus
on the device itself, too.
* selection_options: May be specified as a List(Any) or List(tuple(Any, str)).
The tuple form is to provide a human-readable display_name. Probably all
entries should shift to using the tuple form.
"""
# TODO: Handle multi-language `display_name` and `help_text`
category: str
attr_name: str
display_name: str
verbose_name: str = None
abbreviated_name: str = None
visibility: str = SettingsConstants.VISIBILITY__GENERAL
type: str = SettingsConstants.TYPE__ENABLED_DISABLED
help_text: str = None
selection_options: List[str] = None
default_value: Any = None
def __post_init__(self):
if self.type == SettingsConstants.TYPE__ENABLED_DISABLED:
self.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED
elif self.type == SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT:
self.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED_PROMPT
elif self.type == SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT_REQUIRED:
self.selection_options = [SettingsConstants.ALL_OPTIONS]
# Account for List[tuple] and tuple formats as default_value
if type(self.default_value) == list and type(self.default_value[0]) == tuple:
self.default_value = [v[0] for v in self.default_value]
elif type(self.default_value) == tuple:
self.default_value = self.default_value[0]
@property
def selection_options_display_names(self) -> List[str]:
if type(self.selection_options[0]) == tuple:
return [v[1] for v in self.selection_options]
else:
# Always return a copy so the original can't be altered
return list(self.selection_options)
def get_selection_option_value(self, i: int):
value = self.selection_options[i]
if type(value) == tuple:
value = value[0]
return value
def get_selection_option_display_name(self, i: int) -> str:
value = self.selection_options[i]
if type(value) == tuple:
value = value[1]
return value
def get_selection_option_display_name_by_value(self, value) -> str:
for option in self.selection_options:
if type(option) == tuple:
option_value = option[0]
display_name = option[1]
else:
option_value = option
display_name = option
if option_value == value:
return display_name
def to_dict(self) -> dict:
if self.selection_options:
selection_options = []
for option in self.selection_options:
if type(option) == tuple:
value = option[0]
display_name = option[1]
else:
display_name = option
value = option
selection_options.append({
"display_name": display_name,
"value": value
})
else:
selection_options = None
return {
"category": self.category,
"attr_name": self.attr_name,
"display_name": self.display_name,
"verbose_name": self.verbose_name,
"abbreviated_name": self.abbreviated_name,
"visibility": self.visibility,
"type": self.type,
"help_text": self.help_text,
"selection_options": selection_options,
"default_value": self.default_value,
}
class SettingsDefinition:
"""
Master list of all settings, their possible options, their defaults, on-device
display strings, and enriched SettingsQR UI options.
Used to auto-build the Settings UI menuing with no repetitive boilerplate code.
Defines the on-disk persistent storage structure and can read that format back
and validate the values.
Used to generate a master json file that documents all these params which can
then be read in by the SettingsQR UI to auto-generate the necessary html inputs.
"""
# Increment if there are any breaking changes; write migrations to bridge from
# incompatible prior versions.
version: int = 1
settings_entries: List[SettingsEntry] = [
# General options
# TODO: Full babel multilanguage support! Until then, type == HIDDEN
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__LANGUAGE,
display_name="Language",
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__HIDDEN,
selection_options=SettingsConstants.ALL_LANGUAGES,
default_value=SettingsConstants.LANGUAGE__ENGLISH),
# TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__WORDLIST_LANGUAGE,
display_name="Mnemonic language",
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__HIDDEN,
selection_options=SettingsConstants.ALL_WORDLIST_LANGUAGES,
default_value=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH),
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__PERSISTENT_SETTINGS,
display_name="Persistent settings",
help_text="Store Settings on SD card",
default_value=SettingsConstants.OPTION__DISABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__WALLET,
attr_name=SettingsConstants.SETTING__COORDINATORS,
display_name="Coordinator software",
type=SettingsConstants.TYPE__MULTISELECT,
selection_options=SettingsConstants.ALL_COORDINATORS,
default_value=SettingsConstants.ALL_COORDINATORS),
# Advanced options
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__NETWORK,
display_name="Bitcoin network",
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_NETWORKS,
default_value=SettingsConstants.REGTEST), # DEBUGGING!
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__QR_DENSITY,
display_name="QR code density",
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_DENSITIES,
default_value=SettingsConstants.DENSITY__MEDIUM),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__XPUB_EXPORT,
display_name="Xpub export",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__SIG_TYPES,
display_name="Sig types",
type=SettingsConstants.TYPE__MULTISELECT,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_SIG_TYPES,
default_value=SettingsConstants.ALL_SIG_TYPES),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__SCRIPT_TYPES,
display_name="Script types",
type=SettingsConstants.TYPE__MULTISELECT,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_SCRIPT_TYPES,
default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT]),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__XPUB_DETAILS,
display_name="Show xpub details",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PASSPHRASE,
display_name="BIP-39 passphrase",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT_REQUIRED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__CAMERA_ROTATION,
display_name="Camera rotation",
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_CAMERA_ROTATIONS,
default_value=SettingsConstants.CAMERA_ROTATION__0),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
display_name="CompactSeedQR",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS,
display_name="Show privacy warnings",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__DIRE_WARNINGS,
display_name="Show dire warnings",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
# Developer options
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__DEBUG,
display_name="Debug",
visibility=SettingsConstants.VISIBILITY__DEVELOPER,
default_value=SettingsConstants.OPTION__DISABLED),
# "Hidden" settings with no UI interaction
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS,
display_name="QR background color",
type=SettingsConstants.TYPE__FREE_ENTRY,
visibility=SettingsConstants.VISIBILITY__HIDDEN,
default_value=189),
]
@classmethod
def get_settings_entries(cls, visibiilty: str = SettingsConstants.VISIBILITY__GENERAL) -> List[SettingsEntry]:
entries = []
for entry in cls.settings_entries:
if entry.visibility == visibiilty:
entries.append(entry)
return entries
@classmethod
def get_settings_entry(cls, attr_name) -> SettingsEntry:
for entry in cls.settings_entries:
if entry.attr_name == attr_name:
return entry
@classmethod
def parse_abbreviated_ini(cls, abbreviated_ini: str) -> dict:
raise Exception("Not implemented, maybe not needed")
@classmethod
def get_defaults(cls) -> dict:
as_dict = {}
for entry in SettingsDefinition.settings_entries:
if type(entry.default_value) == list:
# Must copy the default_value list, otherwise we'll inadvertently change
# defaults when updating these attrs
as_dict[entry.attr_name] = list(entry.default_value)
else:
as_dict[entry.attr_name] = entry.default_value
return as_dict
@classmethod
def to_dict(cls) -> dict:
output = {
"settings_entries": [],
}
for settings_entry in cls.settings_entries:
output["settings_entries"].append(settings_entry.to_dict())
return output
if __name__ == "__main__":
import json
import os
print("Exporting SettingsDefinition to json")
output_file = "settings_definition.json"
with open(output_file, 'w') as json_file:
json.dump(SettingsDefinition.to_dict(), json_file, indent=4)
+33
View File
@@ -0,0 +1,33 @@
class Singleton:
_instance = None
def __init__(self):
# Singleton pattern must prevent normal instantiation
raise Exception("Cannot directly instantiate a Singleton. Access via get_instance()")
@classmethod
def get_instance(cls):
# This is the only way to access the one and only instance
if cls._instance is None:
cls._instance = cls.__new__(cls)
return cls._instance
class ConfigurableSingleton(Singleton):
@classmethod
def get_instance(cls):
# This is the only way to access the one and only instance
if cls._instance:
return cls._instance
else:
raise Exception("Must call %s.configure_instance(config) first" % cls.__name__)
@classmethod
def configure_instance(cls, config: any = None):
# Must be called before the first get_instance() call
if cls._instance:
raise Exception("Instance already configured")
#TODO: Implementation classes should do something with incoming config
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,95 +0,0 @@
Copyright 2020 The Assistant Project Authors (https://github.com/hafontia/Assistant).
Copyright 2010 The Source Sans Pro Authors (https://github.com/adobe-fonts/source-sans-pro), with Reserved Font Name 'Source'.
Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

+1 -2
View File
@@ -1,7 +1,6 @@
from .view import * # base class has to be first
from .io_test_view import *
from .menu_view import *
from .seed_tools_view import *
from .settings_tools_view import *
from .signing_tools_view import *
from .screensaver import OpeningSplashView, ScreensaverView
+16 -16
View File
@@ -77,8 +77,8 @@ class IOTestView(View):
def draw_io_screen(self):
self.redraw_complete = False
self.redraw = False
self.draw.rectangle((0,0,View.canvas_width, View.canvas_height), outline=0, fill=0)
self.draw.text((45, 5), "Input/Output Check:", fill=View.color, font=View.ASSISTANT18)
self.draw.rectangle((0,0,self.renderer.canvas_width, self.renderer.canvas_height), outline=0, fill=0)
self.draw.text((45, 5), "Input/Output Check:", fill=View.color, font=Fonts.get_font("Assistant-Medium", 18))
self.draw.polygon([(61, 89), (80, 46), (99, 89)], outline=View.color, fill=0)
self.draw.polygon([(51, 100), (8, 119), (51, 138)], outline=View.color, fill=0)
self.draw.polygon([(109, 100), (152, 119), (109, 138)], outline=View.color, fill=0)
@@ -86,23 +86,23 @@ class IOTestView(View):
self.draw.ellipse([(61, 99), (99, 141)], outline=View.color, fill=0)
self.draw.ellipse([(198, 40), (238, 80)], outline=View.color, fill=0)
self.draw.ellipse([(198, 95), (238, 135)], outline=View.color, fill=0)
self.draw.text((200, 160), "EXIT", fill=View.color, font=View.ASSISTANT18)
self.draw.text((200, 160), "EXIT", fill=View.color, font=Fonts.get_font("Assistant-Medium", 18))
self.draw.rectangle((30, 205, 210, 235), outline=View.color, fill="BLACK")
tw, th = self.draw.textsize(self.qr_text, font=View.ASSISTANT22)
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill=View.color, font=View.ASSISTANT22)
View.DispShowImage()
tw, th = self.draw.textsize(self.qr_text, font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill=View.color, font=Fonts.get_font("Assistant-Medium", 22))
self.renderer.show_image()
self.redraw_complete = True
def a_button(self):
if self.redraw == False and self.redraw_complete == True:
self.draw.ellipse([(198, 40), (238, 80)], outline=View.color, fill=View.color)
View.DispShowImage()
self.renderer.show_image()
self.redraw = True
def b_button(self):
if self.redraw == False and self.redraw_complete == True:
self.draw.ellipse([(198, 95), (238, 135)], outline=View.color, fill=View.color)
View.DispShowImage()
self.renderer.show_image()
self.redraw = True
def c_button(self):
@@ -113,39 +113,39 @@ class IOTestView(View):
def up_button(self):
if self.redraw == False and self.redraw_complete == True:
self.draw.polygon([(61, 89), (80, 46), (99, 89)], outline=View.color, fill=View.color)
View.DispShowImage()
self.renderer.show_image()
self.redraw = True
def down_button(self):
if self.redraw == False and self.redraw_complete == True:
self.draw.polygon([(61, 151), (80, 193), (99, 151)], outline=View.color, fill=View.color)
View.DispShowImage()
self.renderer.show_image()
self.redraw = True
def left_button(self):
if self.redraw == False and self.redraw_complete == True:
self.draw.polygon([(51, 100), (8, 119), (51, 138)], outline=View.color, fill=View.color)
View.DispShowImage()
self.renderer.show_image()
self.redraw = True
def right_button(self):
if self.redraw == False and self.redraw_complete == True:
self.draw.polygon([(109, 100), (152, 119), (109, 138)], outline=View.color, fill=View.color)
View.DispShowImage()
self.renderer.show_image()
self.redraw = True
def press_button(self):
if self.redraw == False and self.redraw_complete == True:
self.draw.ellipse([(61, 99), (99, 141)], outline=View.color, fill=View.color)
View.DispShowImage()
self.renderer.show_image()
self.redraw = True
def draw_scan_detected(self):
self.qr_text = "QR Scanned"
if self.redraw == False and self.redraw_complete == True:
self.draw.rectangle((30, 205, 210, 235), outline=View.color, fill=View.color)
tw, th = self.draw.textsize(self.qr_text, font=View.ASSISTANT22)
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill="BLACK", font=View.ASSISTANT22)
View.DispShowImage()
tw, th = self.draw.textsize(self.qr_text, font=Fonts.get_font("Assistant-Medium", 22))
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill="BLACK", font=Fonts.get_font("Assistant-Medium", 22))
self.renderer.show_image()
self.redraw = True
-393
View File
@@ -1,393 +0,0 @@
# Internal file class dependencies
from . import View
from seedsigner.helpers import B, Path
from seedsigner.models import SeedStorage, Settings, Seed
# External Dependencies
import time
import re
class MenuView(View):
def __init__(self) -> None:
View.__init__(self)
self.menu_lines = []
self.selected_menu_num = 1
###
### Main Navigation
###
### Main Menu
def display_main_menu(self, sub_menu = None) -> int:
ret_val = 0
input = 0
lines = ["Seed Tools", "Scan QR", "Settings", "Power Off"]
if sub_menu == Path.SEED_TOOLS_SUB_MENU:
return self.display_seed_tools_menu()
elif sub_menu == Path.SIGNING_TOOLS_SUB_MENU:
return Path.SIGN_TRANSACTION
elif sub_menu == Path.SETTINGS_SUB_MENU:
return self.display_settings_menu()
else:
self.draw_menu(lines, 1)
# Wait for Button Input (specifically menu selection/press)
while True:
if ret_val == 0:
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
else:
return ret_val
if input == B.KEY_UP:
self.menu_up()
elif input == B.KEY_DOWN:
self.menu_down()
elif input == B.KEY_PRESS:
if self.selected_menu_num == 1:
ret_val = self.display_seed_tools_menu()
elif self.selected_menu_num == 2:
ret_val = Path.SIGN_TRANSACTION
elif self.selected_menu_num == 3:
ret_val = self.display_settings_menu()
elif self.selected_menu_num == 4:
ret_val = Path.POWER_OFF
if ret_val != Path.MAIN_MENU: # When no main menu, return to controller
return ret_val
else:
self.draw_menu(lines)
### Seed Tools Menu
def display_seed_tools_menu(self) -> int:
seed_storage_line = "Store a Seed (temp)"
if self.controller.storage.num_of_saved_seeds() > 0:
if self.controller.storage.num_of_saved_seeds() < 3:
seed_storage_line = "View/Store Seeds (temp)"
else:
seed_storage_line = "View Seeds (temp)"
lines = ["... [ Return to Main ]", "Temp Seed Storage", "Seed Passphrase", "xPub from Seed", "Calculate Last Word", "Generate Seed with Dice", "Generate Seed with Image"]
self.draw_menu(lines, 1)
input = 0
# Wait for Button Input (specifically menu selection/press)
while True:
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
if input == B.KEY_UP:
self.menu_up()
elif input == B.KEY_DOWN:
self.menu_down()
elif input == B.KEY_PRESS:
if self.selected_menu_num == 1:
return Path.MAIN_MENU
elif self.selected_menu_num == 2:
return Path.SAVE_SEED
elif self.selected_menu_num == 3:
return Path.PASSPHRASE_SEED
elif self.selected_menu_num == 4:
return Path.GEN_XPUB
elif self.selected_menu_num == 5:
return Path.GEN_LAST_WORD
elif self.selected_menu_num == 6:
return Path.DICE_GEN_SEED
elif self.selected_menu_num == 7:
return Path.IMAGE_GEN_SEED
### Signing Tools Menu
def display_signing_tools_menu(self) -> None:
lines = ["... [ Return to Main ]", "Generate xPub", "Sign a Transaction"]
self.draw_menu(lines, 1)
input = 0
# Wait for Button Input (specifically menu selection/press)
while True:
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
if input == B.KEY_UP:
self.menu_up()
elif input == B.KEY_DOWN:
self.menu_down()
elif input == B.KEY_PRESS:
if self.selected_menu_num == 1:
return Path.MAIN_MENU
elif self.selected_menu_num == 2:
return Path.GEN_XPUB
elif self.selected_menu_num == 3:
return Path.SIGN_TRANSACTION
raise Exception("Unhandled case")
### Settings Menu
def display_settings_menu(self) -> int:
lines = [
"... [ Return to Main ]",
f"Wallet: {Settings.get_instance().software}",
f"Network: {Settings.get_instance().network}",
f"QR Density: {Settings.get_instance().qr_density_name}",
"Input / Output Tests",
f"Persistent Settings: {Settings.get_instance().persistent_display}",
f"Camera Rotation: {Settings.get_instance().camera_rotation}°",
f"Compact SeedQR: {'Enabled' if Settings.get_instance().compact_seedqr_enabled else 'Disabled'}",
"Version Info",
"Donate to SeedSigner",
"Reset SeedSigner"
]
input = 0
# Draw Menu
self.selected_menu_num = 1
self.draw_menu(lines, 1, None, None, True)
# Wait for Button Input (specifically menu selection/press)
while True:
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
if input == B.KEY_UP:
self.menu_up()
elif input == B.KEY_DOWN:
self.menu_down()
elif input == B.KEY_PRESS:
if self.selected_menu_num == 1:
return Path.MAIN_MENU
elif self.selected_menu_num == 2:
return Path.WALLET
elif self.selected_menu_num == 3:
return Path.CURRENT_NETWORK
elif self.selected_menu_num == 4:
return Path.QR_DENSITY_SETTING
elif self.selected_menu_num == 5:
return Path.IO_TEST_TOOL
elif self.selected_menu_num == 6:
return Path.PERSISTENT_SETTINGS
elif self.selected_menu_num == 7:
return Path.CAMERA_ROTATION
elif self.selected_menu_num == 8:
return Path.COMPACT_SEEDQR_ENABLED
elif self.selected_menu_num == 9:
return Path.VERSION_INFO
elif self.selected_menu_num == 10:
return Path.DONATE
elif self.selected_menu_num == 11:
return Path.RESET
raise Exception("Unhandled case")
### Generic Single Menu Selection (returns 1,2,3,4,5,6 ...)
def display_generic_selection_menu(self, lines = [], title = None, bottom = None) -> int:
self.selected_menu_num = 1
self.draw_menu(lines, 1, title, bottom, True)
while True:
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
if input == B.KEY_UP:
self.menu_up(title, bottom)
elif input == B.KEY_DOWN:
self.menu_down(title, bottom)
elif input == B.KEY_PRESS:
return self.selected_menu_num
raise Exception("Unhandled case")
### Generic Word 12 or 24 seed phrase menu
# internal method
def draw_12_24_word_menu(self, lines, return_txt = "... [ Return to ... ]") -> int:
self.draw_menu(lines)
# Wait for Button Input (specifically menu selection/press)
while True:
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
if input == B.KEY_UP:
self.menu_up()
elif input == B.KEY_DOWN:
self.menu_down()
elif input == B.KEY_PRESS:
if self.selected_menu_num == 1:
return -1
elif self.selected_menu_num == 2:
return Path.SEED_WORD_12
elif self.selected_menu_num == 3:
return Path.SEED_WORD_24
elif self.selected_menu_num == 4:
return Path.SEED_WORD_QR
def display_12_24_word_menu(self, return_txt = "... [ Return to ... ]") -> int:
lines = [return_txt, "Use a 12 Word Seed", "Use a 24 Word Seed"]
return self.draw_12_24_word_menu(lines, return_txt)
def display_qr_12_24_word_menu(self, return_txt = "... [ Return to ... ]") -> int:
lines = [return_txt, "Enter 12 Word Seed", "Enter 24 Word Seed", "Scan a Seed QR Code"]
return self.draw_12_24_word_menu(lines, return_txt)
### Select a Seed Slot to Save a Seed Menu
def display_saved_seed_menu(self, storage, type = 1, return_sel_txt = "... [ Return to Seed Tools ]") -> int:
lines = []
if return_sel_txt != None:
lines.append(return_sel_txt)
if type == 1:
# Show all slots used and free
lines.extend(["Use Seed Slot #1", "Use Seed Slot #2", "Use Seed Slot #3"])
if storage.check_slot_1():
lines[1] = "Display Seed Slot #1" # replace
if storage.check_slot_2():
lines[2] = "Display Seed Slot #2" # replace
if storage.check_slot_3():
lines[3] = "Display Seed Slot #3" # replace
elif type == 2:
# Show only free slots
if storage.check_slot_1() == False:
lines.append("Use Seed Slot #1")
if storage.check_slot_2() == False:
lines.append("Use Seed Slot #2")
if storage.check_slot_3() == False:
lines.append("Use Seed Slot #3")
if storage.num_of_free_slots() == 0:
return 0
elif type == 3:
# Show only used slots
if storage.check_slot_1():
lines.append("Use Seed Slot #1")
if storage.check_slot_2():
lines.append("Use Seed Slot #2")
if storage.check_slot_3():
lines.append("Use Seed Slot #3")
elif type == 4:
# Show only used slots with passphrase
if storage.check_slot_passphrase(1):
lines.append("Seed Slot #1")
if storage.check_slot_passphrase(2):
lines.append("Seed Slot #2")
if storage.check_slot_passphrase(3):
lines.append("Seed Slot #3")
else:
return 0
self.draw_menu(lines)
# Wait for Button Input (specifically menu selection/press)
while True:
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS], check_release=True, release_keys=[B.KEY_PRESS])
if input == B.KEY_UP:
self.menu_up()
elif input == B.KEY_DOWN:
self.menu_down()
elif input == B.KEY_PRESS:
if lines[self.selected_menu_num-1] == return_sel_txt:
return 0
else:
return int(re.search("#(\d+)", lines[self.selected_menu_num-1], re.IGNORECASE).group(1))
raise Exception("Unhandled case")
###
### Generic Reusable Menu Methods/Functions
###
### Generic Draw Menu Method
# TODO: Optimize updates by just redrawing the no-longer highlighted line and the newly highlighted line
def draw_menu(self, lines, selected_menu_num = 1, title = None, bottom = None, force_redraw = False) -> None:
if title == None:
t = "SeedSigner v" + self.controller.VERSION
else:
t = title
if bottom == None and len(lines) <= 5:
b = "Press Control Stick to Select"
elif bottom == None:
if len(lines) >= 6 and len(lines) <= 10:
if selected_menu_num <= 5:
b = "Page 1 of 2"
elif selected_menu_num >= 6 and selected_menu_num <= 10:
b = "Page 2 of 2"
elif len(lines) >= 11 and len(lines) <= 15:
if selected_menu_num <= 5:
b = "Page 1 of 3"
elif selected_menu_num >= 6 and selected_menu_num <= 10:
b = "Page 2 of 3"
elif selected_menu_num >= 11 and selected_menu_num <= 15:
b = "Page 3 of 3"
else:
b = "Press Control Stick to Select"
else:
b = bottom
if lines != self.menu_lines or selected_menu_num != self.selected_menu_num or force_redraw == True:
#Menu has changed, redraw
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
tw, th = View.draw.textsize(t, font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 2), t, fill=View.color, font=View.ASSISTANT22)
num_of_lines = len(lines)
if selected_menu_num <= 5:
if num_of_lines >= 1:
self.draw_menu_text(15, 43 , lines[0], (True if selected_menu_num == 1 else False))
if num_of_lines >= 2:
self.draw_menu_text(15, 76 , lines[1], (True if selected_menu_num == 2 else False))
if num_of_lines >= 3:
self.draw_menu_text(15, 109, lines[2], (True if selected_menu_num == 3 else False))
if num_of_lines >= 4:
self.draw_menu_text(15, 142, lines[3], (True if selected_menu_num == 4 else False))
if num_of_lines >= 5:
self.draw_menu_text(15, 175, lines[4], (True if selected_menu_num == 5 else False))
elif selected_menu_num >= 6 and selected_menu_num <= 10:
if num_of_lines >= 6:
self.draw_menu_text(15, 43 , lines[5], (True if selected_menu_num == 6 else False))
if num_of_lines >= 7:
self.draw_menu_text(15, 76 , lines[6], (True if selected_menu_num == 7 else False))
if num_of_lines >= 8:
self.draw_menu_text(15, 109, lines[7], (True if selected_menu_num == 8 else False))
if num_of_lines >= 9:
self.draw_menu_text(15, 142, lines[8], (True if selected_menu_num == 9 else False))
if num_of_lines >= 10:
self.draw_menu_text(15, 175, lines[9], (True if selected_menu_num == 10 else False))
elif selected_menu_num >= 11 and selected_menu_num <= 15:
if num_of_lines >= 11:
self.draw_menu_text(15, 43 , lines[10], (True if selected_menu_num == 11 else False))
if num_of_lines >= 12:
self.draw_menu_text(15, 76 , lines[11], (True if selected_menu_num == 12 else False))
if num_of_lines >= 13:
self.draw_menu_text(15, 109, lines[12], (True if selected_menu_num == 13 else False))
if num_of_lines >= 14:
self.draw_menu_text(15, 142, lines[13], (True if selected_menu_num == 14 else False))
if num_of_lines >= 15:
self.draw_menu_text(15, 175, lines[14], (True if selected_menu_num == 15 else False))
tw, th = View.draw.textsize(b, font=View.ASSISTANT18)
View.draw.text(((240 - tw) / 2, 210), b, fill=View.color, font=View.ASSISTANT18)
View.DispShowImage()
# saved update menu lines and selection
self.menu_lines = lines
self.selected_menu_num = selected_menu_num
### Generic Menu Navigation
def menu_up(self, title = None, bottom = None):
if self.selected_menu_num <= 1:
self.draw_menu(self.menu_lines, len(self.menu_lines), title, bottom)
else:
self.draw_menu(self.menu_lines, self.selected_menu_num - 1, title, bottom)
def menu_down(self, title = None, bottom = None):
if self.selected_menu_num >= len(self.menu_lines):
self.draw_menu(self.menu_lines, 1, title, bottom)
else:
self.draw_menu(self.menu_lines, self.selected_menu_num + 1, title, bottom)
### Internal View Method to Display a Line in a Menu Screen
def draw_menu_text(self, x, y, line, selected) -> None:
if selected == True:
View.draw.rectangle((5, y-3, 235, y+28), outline=0, fill=View.color)
View.draw.text((x, y) , line, fill="BLACK", font=View.ASSISTANT20BOLD)
else:
View.draw.text((x, y) , line, fill=View.color, font=View.ASSISTANT20)
return
+446
View File
@@ -0,0 +1,446 @@
import time
from embit.psbt import PSBT
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.qr_type import QRType
from seedsigner.models.settings import SettingsConstants
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
from seedsigner.gui.screens import psbt_screens
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen,
LoadingScreenThread, QRDisplayScreen, WarningScreen)
from seedsigner.models.psbt_parser import PSBTParser
class PSBTSelectSeedView(View):
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.
if not self.controller.psbt:
# Shouldn't be able to get here
raise Exception("No PSBT currently loaded")
seeds = self.controller.storage.seeds
SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE)
ENTER_WORDS = "Enter 12/24 words"
button_data = []
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# Doesn't look like this seed can sign the current PSBT
button_str += " (?)"
if seed.passphrase is not None:
# TODO: Include lock icon on right side of button
pass
button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT))
button_data.append(SCAN_SEED)
button_data.append(ENTER_WORDS)
selected_menu_num = ButtonListScreen(
title="Select Signer",
is_button_text_centered=False,
button_data=button_data
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if len(seeds) > 0 and selected_menu_num < len(seeds):
# User selected one of the n seeds
self.controller.psbt_seed = self.controller.storage.seeds[selected_menu_num]
return Destination(PSBTOverviewView)
elif button_data[selected_menu_num] == SCAN_SEED:
from seedsigner.views.scan_views import ScanView
return Destination(ScanView)
elif button_data[selected_menu_num] == ENTER_WORDS:
# TODO
return None
class PSBTOverviewView(View):
def __init__(self):
super().__init__()
# The PSBTParser takes a while to read the PSBT. Run the loading screen while we
# wait.
self.loading_screen = LoadingScreenThread(text="Parsing PSBT...")
self.loading_screen.start()
if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed:
# Must run the PSBTParser or re-parse
self.controller.psbt_parser = PSBTParser(
self.controller.psbt,
seed=self.controller.psbt_seed,
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
)
def run(self):
psbt_parser = self.controller.psbt_parser
change_data = psbt_parser.change_data
"""
change_data = [
{
'address': 'bc1q............',
'amount': 397621401,
'fingerprint': ['22bde1a9', '73c5da0a'],
'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}, {},
]
"""
num_change_outputs = 0
num_self_transfer_outputs = 0
for change_output in change_data:
print(f"""{change_output["derivation_path"][0]}""")
if change_output["derivation_path"][0].split("/")[-2] == "1":
num_change_outputs += 1
else:
num_self_transfer_outputs += 1
screen = psbt_screens.PSBTOverviewScreen(
spend_amount=psbt_parser.spend_amount,
change_amount=psbt_parser.change_amount,
fee_amount=psbt_parser.fee_amount,
num_inputs=psbt_parser.num_inputs,
num_self_transfer_outputs=num_self_transfer_outputs,
num_change_outputs=num_change_outputs,
destination_addresses=psbt_parser.destination_addresses,
)
# Everything is set. Stop the loading screen
self.loading_screen.stop()
# Run the overview screen
selected_menu_num = screen.display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if psbt_parser.change_amount == 0:
return Destination(PSBTNoChangeWarningView)
else:
return Destination(PSBTMathView)
class PSBTNoChangeWarningView(View):
def run(self):
selected_menu_num = psbt_screens.PSBTNoChangeWarningScreen().display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# Only one exit point
return Destination(PSBTMathView)
class PSBTMathView(View):
"""
Follows the Overview pictogram. Shows:
+ total input value
- recipients' value
- fees
-------------------
+ change value
"""
def run(self):
from seedsigner.gui.screens.psbt_screens import PSBTMathScreen
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
selected_menu_num = PSBTMathScreen(
input_amount=psbt_parser.input_amount,
num_inputs=psbt_parser.num_inputs,
spend_amount=psbt_parser.spend_amount,
num_recipients=psbt_parser.num_destinations,
fee_amount=psbt_parser.fee_amount,
change_amount=psbt_parser.change_amount,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if len(psbt_parser.destination_addresses) > 0:
return Destination(PSBTAddressDetailsView, view_args={"address_num": 0})
else:
# This is a self-transfer
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
class PSBTAddressDetailsView(View):
"""
Shows the recipient's address and amount they will receive
"""
def __init__(self, address_num, is_change=False):
super().__init__()
self.address_num = address_num
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
title = "Will Send"
if psbt_parser.num_destinations > 1:
title += f" (#{self.address_num + 1})"
button_data = []
if self.address_num < psbt_parser.num_destinations - 1:
button_data.append("Next Recipient")
else:
button_data.append("Next")
selected_menu_num = psbt_screens.PSBTAddressDetailsScreen(
title=title,
button_data=button_data,
address=psbt_parser.destination_addresses[self.address_num],
amount=psbt_parser.destination_amounts[self.address_num],
).display()
if selected_menu_num == 0:
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, "is_change": False})
elif psbt_parser.change_amount > 0:
# Move on to display change
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
else:
# There's no change output to verify. Move on to sign the PSBT.
return Destination(PSBTFinalizeView)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class PSBTChangeDetailsView(View):
"""
"""
def __init__(self, change_address_num):
super().__init__()
self.change_address_num = change_address_num
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
# Can we verify this change addr?
change_data = psbt_parser.get_change_data(change_num=self.change_address_num)
"""
change_data:
{
'address': 'bc1q............',
'amount': 397621401,
'fingerprint': ['22bde1a9', '73c5da0a'],
'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
}
"""
# Single-sig verification is easy. We expect to find a single fingerprint
# and derivation path.
seed_fingerprint = self.controller.psbt_seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
print(f"seed fingerprint: {seed_fingerprint}")
print(change_data)
if seed_fingerprint not in change_data.get("fingerprint"):
# TODO: Something is wrong with this psbt(?). Reroute to warning?
return Destination(NotYetImplementedView)
i = change_data.get("fingerprint").index(seed_fingerprint)
derivation_path = change_data.get("derivation_path")[i]
# 'm/84h/1h/0h/1/0' would be a change addr while 'm/84h/1h/0h/0/0' is a self-receive
is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
derivation_path_addr_index = int(derivation_path.split("/")[-1])
NEXT = "Next"
if is_change_derivation_path:
title = "Your Change"
VERIFY_MULTISIG = "Verify Multisig Change"
else:
title = "Self-Transfer"
VERIFY_MULTISIG = "Verify Multisig Addr"
# if psbt_parser.num_change_outputs > 1:
# title += f" (#{self.change_address_num + 1})"
is_change_addr_verified = False
if psbt_parser.is_multisig:
# TODO:
# if the known-good multisig descriptor is already onboard:
# calc change addr...
# is_change_addr_verified = True
# button_data = [VERIFY_MULTISIG, NEXT]
# else:
# Have the Screen offer to load in the multisig descriptor.
# button_data = [VERIFY_MULTISIG, NEXT]
# Temp value while awaiting above
button_data = [VERIFY_MULTISIG, NEXT]
else:
# Single sig
# TODO: Generate address from seed at derivation_path and compare with
# change_data["address"]
# Save for Nick
is_change_addr_verified = True
button_data = [NEXT]
selected_menu_num = psbt_screens.PSBTChangeDetailsScreen(
title=title,
button_data=button_data,
address=change_data.get("address"),
amount=change_data.get("amount"),
is_multisig=psbt_parser.is_multisig,
fingerprint=seed_fingerprint,
derivation_path=derivation_path,
is_change_derivation_path=is_change_derivation_path,
derivation_path_addr_index=derivation_path_addr_index,
is_change_addr_verified=is_change_addr_verified,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == 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 button_data[selected_menu_num] == VERIFY_MULTISIG:
return Destination(NotYetImplementedView)
class PSBTFinalizeView(View):
"""
"""
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
psbt: PSBT = self.controller.psbt
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
selected_menu_num = psbt_screens.PSBTFinalizeScreen(
button_data=["Approve PSBT"]
).display()
if selected_menu_num == 0:
# Sign PSBT
loading_screen = LoadingScreenThread(text="Signing PSBT...")
loading_screen.start()
sig_cnt = PSBTParser.sig_count(psbt)
psbt.sign_with(psbt_parser.root)
trimmed_psbt = PSBTParser.trim(psbt)
loading_screen.stop()
if sig_cnt == PSBTParser.sig_count(trimmed_psbt):
# Signing failed / didn't do anything
# TODO: Reserved for Nick. Are there different failure scenarios that we can detect?
# Would be nice to alter the message on the next screen w/more detail.
return Destination(PSBTSigningErrorView)
else:
self.controller.psbt = trimmed_psbt
if len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)) == 1:
return Destination(PSBTSignedQRDisplayView, view_args={"coordinator": self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0]})
else:
return Destination(PSBTSelectCoordinatorView)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class PSBTSelectCoordinatorView(View):
def run(self):
button_data = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)
selected_menu_num = psbt_screens.PSBTSelectCoordinatorScreen(
button_data=button_data
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
return Destination(PSBTSignedQRDisplayView, view_args={"coordinator": button_data[selected_menu_num]})
class PSBTSignedQRDisplayView(View):
def __init__(self, coordinator: str):
super().__init__()
self.coordinator = coordinator
def run(self):
qr_psbt_type = QRType.PSBT__UR2
if self.coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP:
qr_psbt_type = QRType.PSBT__SPECTER
qr_encoder = EncodeQR(
psbt=self.controller.psbt,
qr_type=qr_psbt_type,
qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY),
wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE),
)
ret = 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
return Destination(MainMenuView, clear_history=True)
class PSBTSigningErrorView(View):
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
return Destination(MainMenuView)
selected_menu_num = WarningScreen(
title="PSBT Error",
warning_icon_name="warning",
warning_headline="Signing Failed",
warning_text="Signing with this seed did not add a valid signature.",
button_label="Select Diff Seed",
).display()
if selected_menu_num == 0:
return Destination(PSBTSelectSeedView, clear_history=True)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
+96
View File
@@ -0,0 +1,96 @@
import json
from seedsigner.models.psbt_parser import PSBTParser
from seedsigner.models.settings import SettingsConstants
from seedsigner.views.psbt_views import PSBTSelectSeedView
from seedsigner.views.seed_views import SeedAddPassphrasePromptView, SeedAddPassphraseView
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.models import DecodeQR, Seed
from seedsigner.models.qr_type import QRType
class ScanView(View):
def run(self):
from seedsigner.gui.screens.scan_screens import ScanScreen
# Run the live preview and QR code capture process
# TODO: Does this belong in its own BaseThread?
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code)
screen = ScanScreen(decoder=self.decoder)
screen.display()
if self.decoder.is_complete:
if self.decoder.is_seed:
seed_mnemonic = self.decoder.get_seed_phrase()
if not seed_mnemonic:
# seed is not valid, Exit if not valid with message
raise Exception("Not yet implemented!")
else:
# Found a valid mnemonic seed! All new seeds should be considered
# pending (might set a passphrase, SeedXOR, etc) until finalized.
from .seed_views import SeedValidView
self.controller.storage.set_pending_seed(
Seed(mnemonic=seed_mnemonic, wordlist_language_code=wordlist_language_code)
)
if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__PROMPT:
return Destination(SeedAddPassphrasePromptView)
elif self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED:
return Destination(SeedAddPassphraseView)
else:
return Destination(SeedValidView)
elif self.decoder.is_psbt:
psbt = self.decoder.get_psbt()
self.controller.psbt = psbt
self.controller.psbt_parser = None
return Destination(PSBTSelectSeedView)
elif self.decoder.is_settings:
from seedsigner.models.settings import Settings
settings = self.decoder.get_settings_data()
Settings.get_instance().update(new_settings=settings)
print(json.dumps(Settings.get_instance()._data, indent=4))
return Destination(SettingsUpdatedView, {"config_name": self.decoder.get_settings_config_name()})
elif self.decoder.is_wallet_descriptor:
# TODO
print("Implement QR scanning for wallet descriptors!")
return Destination(NotYetImplementedView)
elif self.decoder.is_address:
# TODO: Reserved for Nick!
print("Implement QR scanning for Bitcoin addresses!")
return Destination(NotYetImplementedView)
else:
return Destination(NotYetImplementedView)
return Destination(MainMenuView)
class SettingsUpdatedView(View):
def __init__(self, config_name: str):
super().__init__()
self.config_name = config_name
def run(self):
from seedsigner.gui.screens.scan_screens import SettingsUpdatedScreen
screen = SettingsUpdatedScreen(config_name=self.config_name)
selected_menu_num = screen.display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# Only one exit point
return Destination(MainMenuView)
+50 -46
View File
@@ -4,16 +4,18 @@ import time
from PIL import Image, ImageDraw
from . import View
from .view import View
from seedsigner.helpers import B
from seedsigner.gui.components import Fonts, GUIConstants, load_image
class LogoView(View):
# TODO: Should be derived from View?
class LogoView:
def __init__(self):
dirname = os.path.dirname(__file__)
logo_url = os.path.join(dirname, "../../", "seedsigner", "resources", "logo_black_240.png")
self.logo = Image.open(logo_url)
from seedsigner.gui import Renderer
self.renderer = Renderer.get_instance()
self.logo = load_image("logo_black_240.png")
@@ -26,18 +28,18 @@ class OpeningSplashView(LogoView):
for i in range(250, -1, -25):
self.logo.putalpha(255 - i)
background = Image.new("RGBA", self.logo.size, (0,0,0))
View.disp.ShowImage(Image.alpha_composite(background, self.logo), 0, 0)
self.renderer.disp.ShowImage(Image.alpha_composite(background, self.logo), 0, 0)
# Display version num and hold for a few seconds
font = View.ROBOTOCONDENSED_REGULAR_22
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.TOP_NAV_TITLE_FONT_SIZE)
version = f"v{controller.VERSION}"
tw, th = font.getsize(version)
x = int((View.canvas_width - tw) / 2)
y = int(View.canvas_height / 2) + 40
x = int((self.renderer.canvas_width - tw) / 2)
y = int(self.renderer.canvas_height / 2) + 40
draw = ImageDraw.Draw(self.logo)
draw.text((x, y), version, fill="orange", font=font)
View.DispShowImage(self.logo)
draw.text((x, y), version, fill=GUIConstants.ACCENT_COLOR, font=font)
self.renderer.show_image(self.logo)
time.sleep(3)
@@ -55,7 +57,6 @@ class ScreensaverView(LogoView):
self.min_coords = (0, 0)
self.max_coords = (self.logo.size[0], self.logo.size[1])
max_increment = 25
self.increment_x = self.rand_increment()
self.increment_y = self.rand_increment()
self.cur_x = int(self.logo.size[0] / 2)
@@ -65,7 +66,6 @@ class ScreensaverView(LogoView):
self.last_screen = None
@property
def is_running(self):
return self._is_running
@@ -87,49 +87,53 @@ class ScreensaverView(LogoView):
self._is_running = True
# Store the current screen in order to restore it later
self.last_screen = View.canvas.copy()
self.last_screen = self.renderer.canvas.copy()
screensaver_start = int(time.time() * 1000)
while True:
if self.buttons.has_any_input():
return self.stop()
# Screensaver must block any attempts to use the Renderer in another thread so it
# never gives up the lock until it returns.
with self.renderer.lock:
while True:
if self.buttons.has_any_input():
return self.stop()
# Must crop the image to the exact display size
crop = self.image.crop((
self.cur_x, self.cur_y,
self.cur_x + View.canvas_width, self.cur_y + View.canvas_height))
View.disp.ShowImage(crop, 0, 0)
# Must crop the image to the exact display size
crop = self.image.crop((
self.cur_x, self.cur_y,
self.cur_x + self.renderer.canvas_width, self.cur_y + self.renderer.canvas_height))
self.renderer.disp.ShowImage(crop, 0, 0)
self.cur_x += self.increment_x
self.cur_y += self.increment_y
self.cur_x += self.increment_x
self.cur_y += self.increment_y
if self.cur_x < self.min_coords[0]:
self.cur_x = self.min_coords[0]
self.increment_x = self.rand_increment()
if self.increment_x < 0.0:
self.increment_x *= -1.0
elif self.cur_x > self.max_coords[0]:
self.cur_x = self.max_coords[0]
self.increment_x = self.rand_increment()
if self.increment_x > 0.0:
self.increment_x *= -1.0
# At each edge bump, calculate a new random rate of change for that axis
if self.cur_x < self.min_coords[0]:
self.cur_x = self.min_coords[0]
self.increment_x = self.rand_increment()
if self.increment_x < 0.0:
self.increment_x *= -1.0
elif self.cur_x > self.max_coords[0]:
self.cur_x = self.max_coords[0]
self.increment_x = self.rand_increment()
if self.increment_x > 0.0:
self.increment_x *= -1.0
if self.cur_y < self.min_coords[1]:
self.cur_y = self.min_coords[1]
self.increment_y = self.rand_increment()
if self.increment_y < 0.0:
self.increment_y *= -1.0
elif self.cur_y > self.max_coords[1]:
self.cur_y = self.max_coords[1]
self.increment_y = self.rand_increment()
if self.increment_y > 0.0:
self.increment_y *= -1.0
if self.cur_y < self.min_coords[1]:
self.cur_y = self.min_coords[1]
self.increment_y = self.rand_increment()
if self.increment_y < 0.0:
self.increment_y *= -1.0
elif self.cur_y > self.max_coords[1]:
self.cur_y = self.max_coords[1]
self.increment_y = self.rand_increment()
if self.increment_y > 0.0:
self.increment_y *= -1.0
def stop(self):
# Restore the original screen
View.DispShowImage(self.last_screen)
self.renderer.show_image(self.last_screen)
self._is_running = False
+168 -462
View File
@@ -1,25 +1,27 @@
# External Dependencies
from re import L
from PIL import ImageDraw, Image
from PIL.ImageOps import autocontrast
import hashlib
import math
import os
import time
from PIL import ImageDraw, Image
from PIL.ImageOps import autocontrast
from seedsigner.models.settings import SettingsConstants
# Internal file class dependencies
from . import View
from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay, mnemonic_generation
from seedsigner.models import DecodeQR, DecodeQRStatus, QRType, EncodeQR, Settings, Seed
from seedsigner.models import SeedStorage
from seedsigner.helpers import QR, mnemonic_generation
from seedsigner.gui.components import Fonts
from seedsigner.gui.keyboard import B, Keyboard, TextEntryDisplay
from seedsigner.models import (DecodeQR, DecodeQRStatus, QRType, EncodeQR, Settings,
Seed, SeedStorage)
class SeedToolsView(View):
ALPHABET = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]
def __init__(self) -> None:
View.__init__(self)
super().__init__()
# Gather words and seed display information
self.words = []
@@ -37,19 +39,9 @@ class SeedToolsView(View):
self.roll_data = ""
self.dice_seed_phrase = []
# Gather passphrase display information
self.passphrase = ""
self.pass_lower = "abcdefghijklmnopqrstuvwxyz"
self.pass_upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
self.pass_number = "0123456789"
self.pass_symbol = "!\"#$%&'()*+,=./;:<>?@[]|-_`~"
self.pass_letter = ""
self.pass_case_toggle = "lower"
###
### Display Gather Words Screen
###
def display_manual_seed_entry(self, num_words):
self.seed_length = num_words
self.reset()
@@ -87,7 +79,7 @@ class SeedToolsView(View):
(has access to all vars in the parent's context)
"""
# Clear the right panel
View.draw.rectangle((keyboard_width, text_entry_display_height, View.canvas_width, View.canvas_height), fill="black")
self.renderer.draw.rectangle((keyboard_width, text_entry_display_height, self.canvas_width, self.canvas_height), fill="black")
if not self.possible_words:
return
@@ -101,7 +93,7 @@ class SeedToolsView(View):
list_starting_index = self.selected_possible_words_index - highlighted_row
word_font = View.ROBOTOCONDENSED_REGULAR_22
word_font = Fonts.get_font("RobotoCondensed-Regular", 22)
for row, i in enumerate(range(list_starting_index, list_starting_index + num_possible_rows)):
if i < 0:
# We're near the top of the list, not enough items to fill above the highlighted row
@@ -113,20 +105,20 @@ class SeedToolsView(View):
if len(self.possible_words) <= i:
break
View.draw.text((x, y + row * row_height), self.possible_words[i], fill=View.color, font=word_font)
self.renderer.draw.text((x, y + row * row_height), self.possible_words[i], fill=self.color, font=word_font)
# Render the SELECT outline
if highlight_word:
fill_color = View.color
fill_color = self.color
font_color = "black"
else:
fill_color = "#111"
font_color = View.color
View.draw.rounded_rectangle((keyboard_width + 4, y + (3 * row_height) - 2, 250, y + (4 * row_height) + 2), outline=View.color, fill=fill_color, radius=5, width=1)
font_color = self.color
self.renderer.draw.rounded_rectangle((keyboard_width + 4, y + (3 * row_height) - 2, 250, y + (4 * row_height) + 2), outline=self.color, fill=fill_color, radius=5, width=1)
if self.possible_words:
word_font = View.ROBOTOCONDENSED_BOLD_24
View.draw.text((x, y + 3 * row_height), self.possible_words[self.selected_possible_words_index], fill=font_color, font=word_font)
word_font = Fonts.get_font("RobotoCondensed-Regular", 24)
self.renderer.draw.text((x, y + 3 * row_height), self.possible_words[self.selected_possible_words_index], fill=font_color, font=word_font)
render_possible_matches_arrows()
@@ -136,38 +128,37 @@ class SeedToolsView(View):
row_height = 26
arrow_button_width = 25
arrow_padding = 5
key_x = View.canvas_width - arrow_button_width
key_x = self.canvas_width - arrow_button_width
key_y = text_entry_display_height - int(row_height / 2) + int(0.75 * row_height)
background_color = "#111"
arrow_color = View.color
arrow_color = self.color
if arrow_up_is_active:
background_color = View.color
background_color = self.color
arrow_color = "#111"
View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
View.draw.polygon(
self.renderer.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=self.color, fill=background_color, radius=5, width=1)
self.renderer.draw.polygon(
[(key_x + int(arrow_button_width)/2 + 1, key_y + arrow_padding), # centered top point
(View.canvas_width - arrow_padding + 1, key_y + row_height - arrow_padding), # bottom right point
(self.canvas_width - arrow_padding + 1, key_y + row_height - arrow_padding), # bottom right point
(key_x + arrow_padding + 1, key_y + row_height - arrow_padding)], # bottom left point
fill=arrow_color
)
background_color = "#111"
arrow_color = View.color
arrow_color = self.color
if arrow_down_is_active:
background_color = View.color
background_color = self.color
arrow_color = "#111"
key_y = text_entry_display_height - int(row_height / 2) + int(5.25 * row_height)
View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
View.draw.polygon(
self.renderer.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=self.color, fill=background_color, radius=5, width=1)
self.renderer.draw.polygon(
[(key_x + int(arrow_button_width)/2 + 1, key_y + row_height - arrow_padding), # bottom centered point
(View.canvas_width - arrow_padding + 1, key_y + arrow_padding), # right top point
(self.canvas_width - arrow_padding + 1, key_y + arrow_padding), # right top point
(key_x + arrow_padding + 1, key_y + arrow_padding)], # left top point
fill=arrow_color
)
# Clear the screen
View.draw.rectangle((0,0, View.canvas_width,View.canvas_height), fill="black")
self.renderer.draw.rectangle((0,0, self.canvas_width,self.canvas_height), fill="black")
self.render_previous_button()
previous_button_is_active = False
@@ -183,8 +174,7 @@ class SeedToolsView(View):
text_entry_display_height = 39
# TODO: support other BIP39 languages/charsets
keyboard = Keyboard(View.draw,
charset=self.possible_alphabet,
keyboard = Keyboard(charset=self.possible_alphabet,
rows=5,
cols=6,
rect=(0,text_entry_display_height + 1, keyboard_width,240),
@@ -193,10 +183,9 @@ class SeedToolsView(View):
# Render the top text entry display
self.letters = initial_letters
text_entry_display = TextEntryDisplay(
View.draw,
rect=(self.previous_button_width,0, View.canvas_width,text_entry_display_height),
font=View.ROBOTOCONDENSED_BOLD_26,
font_color=View.color,
rect=(self.previous_button_width,0, self.canvas_width,text_entry_display_height),
font=Fonts.get_font("RobotoCondensed-Bold", 26),
font_color=self.color,
cur_text=f"{num_word}: " + "".join(self.letters)
)
text_entry_display.render()
@@ -213,11 +202,11 @@ class SeedToolsView(View):
keyboard.render_keys()
render_possible_matches()
View.DispShowImage()
self.renderer.show_image()
# Start the interactive update loop
while True:
input = View.buttons.wait_for(
input = self.buttons.wait_for(
[B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY1, B.KEY2, B.KEY3],
check_release=True,
release_keys=[B.KEY_PRESS, B.KEY2]
@@ -315,7 +304,7 @@ class SeedToolsView(View):
self.letters = list(final_selection + " ")
render_possible_matches(highlight_word=final_selection)
text_entry_display.render(f"{num_word}: " + "".join(self.letters))
View.DispShowImage()
self.renderer.show_image()
return final_selection
@@ -371,324 +360,36 @@ class SeedToolsView(View):
# Render the text entry display and cursor block
text_entry_display.render(f"{num_word}: " + "".join(self.letters))
View.DispShowImage()
self.renderer.show_image()
def draw_passphrase_keyboard_entry(self, existing_passphrase = ""):
def render_right_panel(button1_text="ABC", button2_text="123"):
# Render the up/down arrow buttons for KEY1 and KEY3
row_height = 28
right_button_left_margin = 10
right_button_width = right_panel_buttons_width - right_button_left_margin
font_padding_right = 2
font_padding_top = 1
key_x = View.canvas_width - right_button_width
key_y = int(View.canvas_height - row_height) / 2 - 1 - 60
background_color = "#111"
font_color = View.color
font = View.ROBOTOCONDENSED_BOLD_24
tw, th = font.getsize(button1_text)
if button1_is_active:
background_color = View.color
font_color = "#111"
View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button1_text, fill=font_color)
background_color = "#111"
font_color = View.color
tw, th = font.getsize(button2_text)
if button2_is_active:
background_color = View.color
font_color = "#111"
key_y = int(View.canvas_height - row_height) / 2 - 1
View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button2_text, fill=font_color)
background_color = "#111"
font_color = View.color
button3_text = "Save"
tw, th = font.getsize(button3_text)
if button3_is_active:
background_color = View.color
font_color = "#111"
key_y = int(View.canvas_height - row_height) / 2 - 1 + 60
View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color)
# Clear the screen
View.draw.rectangle((0,0, View.canvas_width,View.canvas_height), fill="black")
self.render_previous_button()
previous_button_is_active = False
# Have to ensure that we don't carry any effects from a previous run
# TODO: This shouldn't be a member var
if existing_passphrase:
self.passphrase = existing_passphrase
else:
self.passphrase = ""
# Set up the keyboard params
right_panel_buttons_width = 60
# render top title banner
font = View.ROBOTOCONDENSED_REGULAR_20
title = "Enter Passphrase"
title_top_padding = 0
title_bottom_padding = 10
tw, th = font.getsize(title)
View.draw.text((int(View.canvas_width - tw) / 2, title_top_padding), text=title, font=font, fill=View.color)
title_height = th + title_top_padding + title_bottom_padding
# Render the live text entry display
font = View.ROBOTOCONDENSED_REGULAR_28
tw, th = font.getsize("!\"#$%&'()*+,=./;:<>?@[]|-_`~ ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 1234567890") # All possible chars for max range
text_entry_side_padding = 0
text_entry_top_padding = 1
text_entry_bottom_padding = 10
text_entry_top_y = title_height + text_entry_top_padding
text_entry_bottom_y = text_entry_top_y + 3 + th + 3
text_entry_display = TextEntryDisplay(
View.draw,
rect=(text_entry_side_padding,text_entry_top_y, View.canvas_width - right_panel_buttons_width - 1, text_entry_bottom_y),
font=font,
font_color=View.color,
cursor_mode=TextEntryDisplay.CURSOR_MODE__BAR,
is_centered=False,
has_outline=True,
cur_text=''.join(self.passphrase)
)
text_entry_display.render()
cursor_position = len(self.passphrase)
keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding
keyboard_abc = Keyboard(
View.draw,
charset="".join(SeedToolsView.ALPHABET),
rows=4,
cols=9,
rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height),
additional_keys=[Keyboard.KEY_SPACE_5, Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT]
)
keyboard_ABC = Keyboard(
View.draw,
charset="".join(SeedToolsView.ALPHABET).upper(),
rows=4,
cols=9,
rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height),
additional_keys=[Keyboard.KEY_SPACE_5, Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
keyboard_digits = Keyboard(
View.draw,
charset="1234567890",
rows=3,
cols=5,
rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height),
additional_keys=[Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
keyboard_symbols = Keyboard(
View.draw,
charset="!@#$%^&*()-+_=[]{}\\|;:'\",.<>/?`~",
rows=4,
cols=10,
rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height),
additional_keys=[Keyboard.KEY_SPACE_4, Keyboard.KEY_CURSOR_LEFT, Keyboard.KEY_CURSOR_RIGHT, Keyboard.KEY_BACKSPACE],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
button1_is_active = False
button2_is_active = False
button3_is_active = False
KEYBOARD__LOWERCASE = 0
KEYBOARD__UPPERCASE = 1
KEYBOARD__DIGITS = 2
KEYBOARD__SYMBOLS = 3
KEYBOARD__LOWERCASE_BUTTON_TEXT = "abc"
KEYBOARD__UPPERCASE_BUTTON_TEXT = "ABC"
KEYBOARD__DIGITS_BUTTON_TEXT = "123"
KEYBOARD__SYMBOLS_BUTTON_TEXT = "!@#"
cur_keyboard = keyboard_abc
cur_button1_text = KEYBOARD__UPPERCASE_BUTTON_TEXT
cur_button2_text = KEYBOARD__DIGITS_BUTTON_TEXT
render_right_panel()
View.DispShowImage()
# Start the interactive update loop
while True:
input = View.buttons.wait_for(
[B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY1, B.KEY2, B.KEY3],
check_release=True,
release_keys=[B.KEY_PRESS, B.KEY1, B.KEY2, B.KEY3]
)
keyboard_swap = False
# Check our two possible exit conditions
if input == B.KEY3:
# Save!
if len(self.passphrase) > 0:
return self.passphrase.strip()
elif input == B.KEY_PRESS and previous_button_is_active:
# Prev button clicked; return empty string to signal cancel.
return ""
# Check for keyboard swaps
if input == B.KEY1:
# Return to the same button2 keyboard, if applicable
if cur_keyboard == keyboard_digits:
cur_button2_text = KEYBOARD__DIGITS_BUTTON_TEXT
elif cur_keyboard == keyboard_symbols:
cur_button2_text = KEYBOARD__SYMBOLS_BUTTON_TEXT
if cur_button1_text == KEYBOARD__LOWERCASE_BUTTON_TEXT:
keyboard_abc.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"])
cur_keyboard = keyboard_abc
cur_button1_text = KEYBOARD__UPPERCASE_BUTTON_TEXT
render_right_panel(button1_text=cur_button1_text, button2_text=cur_button2_text)
else:
keyboard_ABC.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"])
cur_keyboard = keyboard_ABC
cur_button1_text = KEYBOARD__LOWERCASE_BUTTON_TEXT
render_right_panel(button1_text=cur_button1_text, button2_text=cur_button2_text)
cur_keyboard.render_keys()
keyboard_swap = True
ret_val = None
elif input == B.KEY2:
# Return to the same button1 keyboard, if applicable
if cur_keyboard == keyboard_abc:
cur_button1_text = KEYBOARD__LOWERCASE_BUTTON_TEXT
elif cur_keyboard == keyboard_ABC:
cur_button1_text = KEYBOARD__UPPERCASE_BUTTON_TEXT
if cur_button2_text == KEYBOARD__DIGITS_BUTTON_TEXT:
keyboard_digits.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"])
cur_keyboard = keyboard_digits
cur_keyboard.render_keys()
cur_button2_text = KEYBOARD__SYMBOLS_BUTTON_TEXT
render_right_panel(button1_text=cur_button1_text, button2_text=cur_button2_text)
else:
keyboard_symbols.set_selected_key_indices(x=cur_keyboard.selected_key["x"], y=cur_keyboard.selected_key["y"])
cur_keyboard = keyboard_symbols
cur_keyboard.render_keys()
cur_button2_text = KEYBOARD__DIGITS_BUTTON_TEXT
render_right_panel(button1_text=cur_button1_text, button2_text=cur_button2_text)
cur_keyboard.render_keys()
keyboard_swap = True
ret_val = None
else:
# Process normal input
if input in [B.KEY_UP, B.KEY_DOWN] and previous_button_is_active:
# We're navigating off the previous button
previous_button_is_active = False
self.render_previous_button(highlight=False)
# Override the actual input w/an ENTER signal for the Keyboard
if input == B.KEY_DOWN:
input = Keyboard.ENTER_TOP
else:
input = Keyboard.ENTER_BOTTOM
elif input in [B.KEY_LEFT, B.KEY_RIGHT] and previous_button_is_active:
# ignore
continue
ret_val = cur_keyboard.update_from_input(input)
# Now process the result from the keyboard
if ret_val in Keyboard.EXIT_DIRECTIONS:
self.render_previous_button(highlight=True)
previous_button_is_active = True
elif ret_val in Keyboard.ADDITIONAL_KEYS and input == B.KEY_PRESS:
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
if cursor_position == 0:
pass
elif cursor_position == len(self.passphrase):
self.passphrase = self.passphrase[:-1]
else:
self.passphrase = self.passphrase[:cursor_position - 1] + self.passphrase[cursor_position:]
cursor_position -= 1
elif ret_val == Keyboard.KEY_CURSOR_LEFT["code"]:
cursor_position -= 1
if cursor_position < 0:
cursor_position = 0
elif ret_val == Keyboard.KEY_CURSOR_RIGHT["code"]:
cursor_position += 1
if cursor_position > len(self.passphrase):
cursor_position = len(self.passphrase)
elif ret_val == Keyboard.KEY_SPACE["code"]:
if cursor_position == len(self.passphrase):
self.passphrase += " "
else:
self.passphrase = self.passphrase[:cursor_position] + " " + self.passphrase[cursor_position:]
cursor_position += 1
# Update the text entry display and cursor
text_entry_display.render(self.passphrase, cursor_position)
elif input == B.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
# User has locked in the current letter
if cursor_position == len(self.passphrase):
self.passphrase += ret_val
else:
self.passphrase = self.passphrase[:cursor_position] + ret_val + self.passphrase[cursor_position:]
cursor_position += 1
# Update the text entry display and cursor
text_entry_display.render(self.passphrase, cursor_position)
elif input in [B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN] or keyboard_swap:
# Live joystick movement; haven't locked this new letter in yet.
# Leave current spot blank for now. Only update the active keyboard keys
# when a selection has been locked in (KEY_PRESS) or removed ("del").
pass
View.DispShowImage()
###
### Display Last Word
###
def display_last_word(self, partial_seed_phrase) -> list:
finalseed = mnemonic_generation.calculate_checksum(partial_seed_phrase, wordlist=self.controller.settings.wordlist)
finalseed = mnemonic_generation.calculate_checksum(partial_seed_phrase, wordlist=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
last_word = finalseed[-1]
self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
tw, th = self.draw.textsize("The final word is :", font=View.ASSISTANT23)
self.draw.text(((240 - tw) / 2, 60), "The final word is :", fill=View.color, font=View.ASSISTANT23)
tw, th = self.draw.textsize(last_word, font=View.ASSISTANT50)
self.draw.text(((240 - tw) / 2, 90), last_word, fill=View.color, font=View.ASSISTANT50)
self.renderer.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
tw, th = self.renderer.draw.textsize("The final word is :", font=Fonts.get_font("Assistant-Medium", 23))
self.renderer.draw.text(((240 - tw) / 2, 60), "The final word is :", fill=self.color, font=Fonts.get_font("Assistant-Medium", 23))
tw, th = self.renderer.draw.textsize(last_word, font=Fonts.get_font("Assistant-Medium", 50))
self.renderer.draw.text(((240 - tw) / 2, 90), last_word, fill=self.color, font=Fonts.get_font("Assistant-Medium", 50))
tw, th = View.draw.textsize("Right to Continue", font=View.ASSISTANT18)
View.draw.text(((240 - tw) / 2, 210), "Right to Continue", fill=View.color, font=View.ASSISTANT18)
tw, th = self.renderer.draw.textsize("Right to Continue", font=Fonts.get_font("Assistant-Medium", 18))
self.renderer.draw.text(((240 - tw) / 2, 210), "Right to Continue", fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
View.DispShowImage()
self.renderer.show_image()
input = self.buttons.wait_for([B.KEY_RIGHT])
return finalseed
###
### Display Seed from Dice
###
def display_generate_seed_from_dice(self):
self.roll_number = 1
self.dice_selected = 5
@@ -718,6 +419,7 @@ class SeedToolsView(View):
self.dice_seed_phrase = mnemonic_generation.generate_mnemonic_from_dice(self.roll_data)
return self.dice_seed_phrase[:]
def dice_arrow_up(self):
new_selection = 0
if self.dice_selected == 4:
@@ -732,6 +434,7 @@ class SeedToolsView(View):
return True
def dice_arrow_down(self):
new_selection = 0
if self.dice_selected == 1:
@@ -746,6 +449,7 @@ class SeedToolsView(View):
return True
def dice_arrow_right(self):
new_selection = 0
if self.dice_selected == 1:
@@ -762,6 +466,7 @@ class SeedToolsView(View):
return True
def dice_arrow_left(self):
if self.dice_selected == 1:
self.draw_prompt_custom("Undo ", "Cancel ", "Exit ", ["Action: ", "", ""])
@@ -795,6 +500,7 @@ class SeedToolsView(View):
return True
def dice_arrow_press(self):
self.roll_number += 1
self.roll_data += str(self.dice_selected)
@@ -810,95 +516,95 @@ class SeedToolsView(View):
return True
def draw_dice(self, dice_selected):
self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
self.draw.text((45, 5), "Dice roll: " + str(self.roll_number) + "/99", fill=View.color, font=View.ASSISTANT26)
def draw_dice(self, dice_selected):
self.renderer.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
self.renderer.draw.text((45, 5), "Dice roll: " + str(self.roll_number) + "/99", fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
# when dice is selected, rect fill will be orange and ellipse will be black, ellipse outline will be the black
# when dice is not selected, rect will will be black and ellipse will be orange, ellipse outline will be orange
# dice 1
if dice_selected == 1:
self.draw.rectangle((5, 50, 75, 120), outline=View.color, fill=View.color)
self.draw.ellipse([(34, 79), (46, 91)], outline="BLACK", fill="BLACK")
self.renderer.draw.rectangle((5, 50, 75, 120), outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(34, 79), (46, 91)], outline="BLACK", fill="BLACK")
else:
self.draw.rectangle((5, 50, 75, 120), outline=View.color, fill="BLACK")
self.draw.ellipse([(34, 79), (46, 91)], outline=View.color, fill=View.color)
self.renderer.draw.rectangle((5, 50, 75, 120), outline=self.color, fill="BLACK")
self.renderer.draw.ellipse([(34, 79), (46, 91)], outline=self.color, fill=self.color)
# dice 2
if dice_selected == 2:
self.draw.rectangle((85, 50, 155, 120), outline=View.color, fill=View.color)
self.draw.ellipse([(100, 60), (112, 72)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(128, 98), (140, 110)], outline="BLACK", fill="BLACK")
self.renderer.draw.rectangle((85, 50, 155, 120), outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(100, 60), (112, 72)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(128, 98), (140, 110)], outline="BLACK", fill="BLACK")
else:
self.draw.rectangle((85, 50, 155, 120), outline=View.color, fill="BLACK")
self.draw.ellipse([(100, 60), (112, 72)], outline=View.color, fill=View.color)
self.draw.ellipse([(128, 98), (140, 110)], outline=View.color, fill=View.color)
self.renderer.draw.rectangle((85, 50, 155, 120), outline=self.color, fill="BLACK")
self.renderer.draw.ellipse([(100, 60), (112, 72)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(128, 98), (140, 110)], outline=self.color, fill=self.color)
# dice 3
if dice_selected == 3:
self.draw.rectangle((165, 50, 235, 120), outline=View.color, fill=View.color)
self.draw.ellipse([(180, 60), (192, 72)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(194, 79), (206, 91)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(208, 98), (220, 110)], outline="BLACK", fill="BLACK")
self.renderer.draw.rectangle((165, 50, 235, 120), outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(180, 60), (192, 72)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(194, 79), (206, 91)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(208, 98), (220, 110)], outline="BLACK", fill="BLACK")
else:
self.draw.rectangle((165, 50, 235, 120), outline=View.color, fill="BLACK")
self.draw.ellipse([(180, 60), (192, 72)], outline=View.color, fill=View.color)
self.draw.ellipse([(194, 79), (206, 91)], outline=View.color, fill=View.color)
self.draw.ellipse([(208, 98), (220, 110)], outline=View.color, fill=View.color)
self.renderer.draw.rectangle((165, 50, 235, 120), outline=self.color, fill="BLACK")
self.renderer.draw.ellipse([(180, 60), (192, 72)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(194, 79), (206, 91)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(208, 98), (220, 110)], outline=self.color, fill=self.color)
# dice 4
if dice_selected == 4:
self.draw.rectangle((5, 130, 75, 200), outline=View.color, fill=View.color)
self.draw.ellipse([(20, 140), (32, 152)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(20, 174), (32, 186)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(48, 140), (60, 152)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(48, 174), (60, 186)], outline="BLACK", fill="BLACK")
self.renderer.draw.rectangle((5, 130, 75, 200), outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(20, 140), (32, 152)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(20, 174), (32, 186)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(48, 140), (60, 152)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(48, 174), (60, 186)], outline="BLACK", fill="BLACK")
else:
self.draw.rectangle((5, 130, 75, 200), outline=View.color, fill="BLACK")
self.draw.ellipse([(20, 140), (32, 152)], outline=View.color, fill=View.color)
self.draw.ellipse([(20, 174), (32, 186)], outline=View.color, fill=View.color)
self.draw.ellipse([(48, 140), (60, 152)], outline=View.color, fill=View.color)
self.draw.ellipse([(48, 174), (60, 186)], outline=View.color, fill=View.color)
self.renderer.draw.rectangle((5, 130, 75, 200), outline=self.color, fill="BLACK")
self.renderer.draw.ellipse([(20, 140), (32, 152)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(20, 174), (32, 186)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(48, 140), (60, 152)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(48, 174), (60, 186)], outline=self.color, fill=self.color)
# dice 5
if dice_selected == 5:
self.draw.rectangle((85, 130, 155, 200), outline=View.color, fill=View.color)
self.draw.ellipse([(100, 140), (112, 152)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(100, 178), (112, 190)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(114, 159), (126, 171)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(128, 140), (140, 152)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(128, 178), (140, 190)], outline="BLACK", fill="BLACK")
self.renderer.draw.rectangle((85, 130, 155, 200), outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(100, 140), (112, 152)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(100, 178), (112, 190)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(114, 159), (126, 171)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(128, 140), (140, 152)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(128, 178), (140, 190)], outline="BLACK", fill="BLACK")
else:
self.draw.rectangle((85, 130, 155, 200), outline=View.color, fill="BLACK")
self.draw.ellipse([(100, 140), (112, 152)], outline=View.color, fill=View.color)
self.draw.ellipse([(100, 178), (112, 190)], outline=View.color, fill=View.color)
self.draw.ellipse([(114, 159), (126, 171)], outline=View.color, fill=View.color)
self.draw.ellipse([(128, 140), (140, 152)], outline=View.color, fill=View.color)
self.draw.ellipse([(128, 178), (140, 190)], outline=View.color, fill=View.color)
self.renderer.draw.rectangle((85, 130, 155, 200), outline=self.color, fill="BLACK")
self.renderer.draw.ellipse([(100, 140), (112, 152)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(100, 178), (112, 190)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(114, 159), (126, 171)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(128, 140), (140, 152)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(128, 178), (140, 190)], outline=self.color, fill=self.color)
# dice 6
if dice_selected == 6:
self.draw.rectangle((165, 130, 235, 200), outline=View.color, fill=View.color)
self.draw.ellipse([(180, 140), (192, 152)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(180, 157), (192, 169)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(180, 174), (192, 186)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(208, 140), (220, 152)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(208, 157), (220, 169)], outline="BLACK", fill="BLACK")
self.draw.ellipse([(208, 174), (220, 186)], outline="BLACK", fill="BLACK")
self.renderer.draw.rectangle((165, 130, 235, 200), outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(180, 140), (192, 152)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(180, 157), (192, 169)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(180, 174), (192, 186)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(208, 140), (220, 152)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(208, 157), (220, 169)], outline="BLACK", fill="BLACK")
self.renderer.draw.ellipse([(208, 174), (220, 186)], outline="BLACK", fill="BLACK")
else:
self.draw.rectangle((165, 130, 235, 200), outline=View.color, fill="BLACK")
self.draw.ellipse([(180, 140), (192, 152)], outline=View.color, fill=View.color)
self.draw.ellipse([(180, 157), (192, 169)], outline=View.color, fill=View.color)
self.draw.ellipse([(180, 174), (192, 186)], outline=View.color, fill=View.color)
self.draw.ellipse([(208, 140), (220, 152)], outline=View.color, fill=View.color)
self.draw.ellipse([(208, 157), (220, 169)], outline=View.color, fill=View.color)
self.draw.ellipse([(208, 174), (220, 186)], outline=View.color, fill=View.color)
self.renderer.draw.rectangle((165, 130, 235, 200), outline=self.color, fill="BLACK")
self.renderer.draw.ellipse([(180, 140), (192, 152)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(180, 157), (192, 169)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(180, 174), (192, 186)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(208, 140), (220, 152)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(208, 157), (220, 169)], outline=self.color, fill=self.color)
self.renderer.draw.ellipse([(208, 174), (220, 186)], outline=self.color, fill=self.color)
# bottom text
self.draw.text((18, 210), "Press Control Stick to Select", fill=View.color, font=View.ASSISTANT18)
View.DispShowImage()
self.renderer.draw.text((18, 210), "Press Control Stick to Select", fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
self.renderer.show_image()
self.dice_selected = dice_selected
@@ -911,7 +617,7 @@ class SeedToolsView(View):
def display_seed_phrase_page(draw, seed_phrase, passphrase=None, bottom=bottom, page_num=1):
""" Internal helper method to render 12 words of the seed phrase """
draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
word_positions = [
# Left column
@@ -932,20 +638,20 @@ class SeedToolsView(View):
title = "Seed Phrase (2/2)"
word_index_offset = 12 # Skip ahead one page worth of words
tw, th = View.draw.textsize(title, font=View.ASSISTANT18)
draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT18)
tw, th = self.renderer.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 18))
draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
for i in range(0, max_range):
draw.text(word_positions[i], f"{i + 1 + word_index_offset}: " + seed_phrase[i + word_index_offset] , fill=View.color, font=View.ASSISTANT22)
draw.text(word_positions[i], f"{i + 1 + word_index_offset}: " + seed_phrase[i + word_index_offset] , fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
if passphrase and ((len(seed_phrase) > 12 and page_num == 2) or (len(seed_phrase) <= 12 and page_num == 1)):
disp_passphrase = "Passphrase: ************"
tw, th = View.draw.textsize(disp_passphrase, font=View.ASSISTANT18)
draw.text(((240 - tw) / 2, 185), disp_passphrase, fill=View.color, font=View.ASSISTANT18)
tw, th = self.renderer.draw.textsize(disp_passphrase, font=Fonts.get_font("Assistant-Medium", 18))
draw.text(((240 - tw) / 2, 185), disp_passphrase, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
draw.text(((240 - tw) / 2, 212), bottom, fill=View.color, font=View.ASSISTANT18)
View.DispShowImage()
tw, th = self.renderer.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
draw.text(((240 - tw) / 2, 212), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
self.renderer.show_image()
wait_for_buttons = [B.KEY_RIGHT, B.KEY_LEFT]
@@ -958,7 +664,7 @@ class SeedToolsView(View):
cur_page = 1
while True:
if len(seed_phrase) in (11,12):
display_seed_phrase_page(self.draw, seed_phrase, passphrase, bottom)
display_seed_phrase_page(self.renderer.draw, seed_phrase, passphrase, bottom)
ret_val = self.buttons.wait_for(wait_for_buttons)
if ret_val == B.KEY_LEFT:
@@ -967,7 +673,7 @@ class SeedToolsView(View):
elif show_qr_option and ret_val == B.KEY_RIGHT:
# Show the resulting seed as a transcribable QR code
if self.controller.settings.compact_seedqr_enabled:
if self.settings.compact_seedqr_enabled:
self.display_seed_qr_options(seed_phrase)
else:
self.seed_phrase_as_qr(seed_phrase)
@@ -980,7 +686,7 @@ class SeedToolsView(View):
return True
elif len(seed_phrase) in (23,24):
display_seed_phrase_page(self.draw, seed_phrase, passphrase, bottom, page_num=cur_page)
display_seed_phrase_page(self.renderer.draw, seed_phrase, passphrase, bottom, page_num=cur_page)
ret_val = self.buttons.wait_for(wait_for_buttons)
if cur_page == 1:
@@ -997,7 +703,7 @@ class SeedToolsView(View):
elif show_qr_option and ret_val == B.KEY_RIGHT:
# Show the resulting seed as a transcribable QR code
if self.controller.settings.compact_seedqr_enabled:
if self.settings.compact_seedqr_enabled:
self.display_seed_qr_options(seed_phrase)
else:
self.seed_phrase_as_qr(seed_phrase)
@@ -1033,10 +739,10 @@ class SeedToolsView(View):
def seed_phrase_as_qr(self, seed_phrase, is_compact_seedqr=False):
if is_compact_seedqr:
e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.COMPACTSEEDQR, wordlist=self.controller.settings.wordlist)
e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEED__COMPACTSEEDQR, wordlist=self.settings.wordlist)
else:
e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEEDQR, wordlist=self.controller.settings.wordlist)
data = e.nextPart()
e = EncodeQR(seed_phrase=seed_phrase, qr_type=QRType.SEED__SEEDQR, wordlist=self.settings.wordlist)
data = e.next_part()
qr = QR()
image = qr.qrimage(
data=data,
@@ -1074,7 +780,7 @@ class SeedToolsView(View):
width = (qr_border + num_modules + qr_border) * pixels_per_block
height = width
data = e.nextPart()
data = e.next_part()
qr = QR()
image = qr.qrimage(
data,
@@ -1091,47 +797,47 @@ class SeedToolsView(View):
# Prep the semi-transparent mask overlay
# make a blank image for the overlay, initialized to transparent
block_mask = Image.new("RGBA", (View.canvas_width, View.canvas_height), (255,255,255,0))
block_mask = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
draw = ImageDraw.Draw(block_mask)
mask_width = int((View.canvas_width - qr_blocks_per_zoom * pixels_per_block)/2)
mask_height = int((View.canvas_height - qr_blocks_per_zoom * pixels_per_block)/2)
mask_width = int((self.canvas_width - qr_blocks_per_zoom * pixels_per_block)/2)
mask_height = int((self.canvas_height - qr_blocks_per_zoom * pixels_per_block)/2)
mask_rgba = (0, 0, 0, 226)
draw.rectangle((0, 0, View.canvas_width, mask_height), fill=mask_rgba)
draw.rectangle((0, View.canvas_height - mask_height - 1, View.canvas_width, View.canvas_height), fill=mask_rgba)
draw.rectangle((0, mask_height, mask_width, View.canvas_height - mask_height), fill=mask_rgba)
draw.rectangle((View.canvas_width - mask_width - 1, mask_height, View.canvas_width, View.canvas_height - mask_height), fill=mask_rgba)
draw.rectangle((0, 0, self.canvas_width, mask_height), fill=mask_rgba)
draw.rectangle((0, self.canvas_height - mask_height - 1, self.canvas_width, self.canvas_height), fill=mask_rgba)
draw.rectangle((0, mask_height, mask_width, self.canvas_height - mask_height), fill=mask_rgba)
draw.rectangle((self.canvas_width - mask_width - 1, mask_height, self.canvas_width, self.canvas_height - mask_height), fill=mask_rgba)
# Draw a box around the cutout portion of the mask for better visibility
draw.line((mask_width, mask_height, mask_width, View.canvas_height - mask_height), fill=View.color)
draw.line((View.canvas_width - mask_width, mask_height, View.canvas_width - mask_width, View.canvas_height - mask_height), fill=View.color)
draw.line((mask_width, mask_height, View.canvas_width - mask_width, mask_height), fill=View.color)
draw.line((mask_width, View.canvas_height - mask_height, View.canvas_width - mask_width, View.canvas_height - mask_height), fill=View.color)
draw.line((mask_width, mask_height, mask_width, self.canvas_height - mask_height), fill=self.color)
draw.line((self.canvas_width - mask_width, mask_height, self.canvas_width - mask_width, self.canvas_height - mask_height), fill=self.color)
draw.line((mask_width, mask_height, self.canvas_width - mask_width, mask_height), fill=self.color)
draw.line((mask_width, self.canvas_height - mask_height, self.canvas_width - mask_width, self.canvas_height - mask_height), fill=self.color)
msg = "click to exit"
tw, th = draw.textsize(msg, font=View.ASSISTANT18)
draw.text(((View.canvas_width - tw) / 2, View.canvas_height - th - 2), msg, fill=View.color, font=View.ASSISTANT18)
tw, th = draw.textsize(msg, font=Fonts.get_font("Assistant-Medium", 18))
draw.text(((self.canvas_width - tw) / 2, self.canvas_height - th - 2), msg, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
def draw_block_labels(cur_block_x, cur_block_y):
# Create overlay for block labels (e.g. "D-5")
block_labels_x = ["1", "2", "3", "4", "5", "6"]
block_labels_y = ["A", "B", "C", "D", "E", "F"]
block_labels = Image.new("RGBA", (View.canvas_width, View.canvas_height), (255,255,255,0))
block_labels = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
draw = ImageDraw.Draw(block_labels)
draw.rectangle((mask_width, 0, View.canvas_width - mask_width, pixels_per_block), fill=View.color)
draw.rectangle((0, mask_height, pixels_per_block, View.canvas_height - mask_height), fill=View.color)
draw.rectangle((mask_width, 0, self.canvas_width - mask_width, pixels_per_block), fill=self.color)
draw.rectangle((0, mask_height, pixels_per_block, self.canvas_height - mask_height), fill=self.color)
label_font = View.ASSISTANT26
label_font = Fonts.get_font("Assistant-Medium", 26)
x_label = block_labels_x[cur_block_x]
tw, th = draw.textsize(x_label, font=label_font)
# note: have to nudge the y-coord up (the extra "- 4") for some reason
draw.text(((View.canvas_width - tw) / 2, ((pixels_per_block - th) / 2) - 4), x_label, fill="BLACK", font=label_font)
draw.text(((self.canvas_width - tw) / 2, ((pixels_per_block - th) / 2) - 4), x_label, fill="BLACK", font=label_font)
y_label = block_labels_y[cur_block_y]
tw, th = draw.textsize(y_label, font=label_font)
draw.text(((pixels_per_block - tw) / 2, (View.canvas_height - th) / 2), y_label, fill="BLACK", font=label_font)
draw.text(((pixels_per_block - tw) / 2, (self.canvas_height - th) / 2), y_label, fill="BLACK", font=label_font)
return block_labels
@@ -1145,8 +851,8 @@ class SeedToolsView(View):
next_x = cur_x
next_y = cur_y
View.DispShowImage(
image.crop((cur_x, cur_y, cur_x + View.canvas_width, cur_y + View.canvas_height)),
self.renderer.show_image(
image.crop((cur_x, cur_y, cur_x + self.canvas_width, cur_y + self.canvas_height)),
alpha_overlay=Image.alpha_composite(block_mask, block_labels)
)
@@ -1155,7 +861,7 @@ class SeedToolsView(View):
if input == B.KEY_RIGHT:
next_x = cur_x + qr_blocks_per_zoom * pixels_per_block
cur_block_x += 1
if next_x > width - View.canvas_width:
if next_x > width - self.canvas_width:
next_x = cur_x
cur_block_x -= 1
elif input == B.KEY_LEFT:
@@ -1167,7 +873,7 @@ class SeedToolsView(View):
elif input == B.KEY_DOWN:
next_y = cur_y + qr_blocks_per_zoom * pixels_per_block
cur_block_y += 1
if next_y > height - View.canvas_height:
if next_y > height - self.canvas_height:
next_y = cur_y
cur_block_y -= 1
elif input == B.KEY_UP:
@@ -1183,7 +889,7 @@ class SeedToolsView(View):
block_labels = draw_block_labels(cur_block_x, cur_block_y)
if cur_x != next_x or cur_y != next_y:
View.disp_show_image_pan(
self.renderer.show_image_pan(
image,
cur_x, cur_y, next_x, next_y,
rate=pixels_per_block,
@@ -1196,12 +902,12 @@ class SeedToolsView(View):
self.draw_modal(["Scanning..."], "Seed QR" ,"Right to Exit")
try:
self.controller.camera.start_video_stream_mode(resolution=(480, 480), framerate=12, format="rgb")
decoder = DecodeQR(wordlist=self.controller.settings.wordlist)
decoder = DecodeQR(wordlist=self.settings.wordlist)
while True:
frame = self.controller.camera.read_video_stream(as_image=True)
if frame is not None:
View.DispShowImageWithText(frame.resize((240,240)), "Scan Seed QR", font=View.ASSISTANT22, text_color=View.color, text_background=(0,0,0,225))
status = decoder.addImage(frame)
self.renderer.show_image_with_text(frame.resize((240,240)), "Scan Seed QR", font=Fonts.get_font("Assistant-Medium", 22), text_color=self.color, text_background=(0,0,0,225))
status = decoder.add_image(frame)
if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID):
break
@@ -1211,9 +917,9 @@ class SeedToolsView(View):
self.words = []
return self.words[:]
if decoder.isComplete() and decoder.isSeed():
self.words = decoder.getSeedPhrase()
elif not decoder.isPSBT():
if decoder.is_complete and decoder.is_seed:
self.words = decoder.get_seed_phrase()
elif not decoder.is_psbt:
self.draw_modal(["Not a valid Seed QR"], "", "Right to Exit")
input = self.buttons.wait_for([B.KEY_RIGHT])
else:
@@ -1239,7 +945,7 @@ class SeedToolsView(View):
while True:
frame = self.controller.camera.read_video_stream(as_image=True)
if frame is not None:
View.DispShowImageWithText(frame, "click joystick", text_color=View.color, text_background=(0,0,0,225))
self.renderer.show_image_with_text(frame, "click joystick", text_color=self.color, text_background=(0,0,0,225))
if len(preview_images) < max_entropy_frames:
preview_images.append(frame)
@@ -1270,14 +976,14 @@ class SeedToolsView(View):
).crop(
(120, 0, 600, 480)
).resize(
(View.canvas_width, View.canvas_height), Image.BICUBIC
(self.canvas_width, self.canvas_height), Image.BICUBIC
)
View.DispShowImageWithText(
self.renderer.show_image_with_text(
display_version,
text=" < reshoot | accept > ",
font=View.ROBOTOCONDENSED_REGULAR_22,
text_color=View.color,
font=Fonts.get_font("RobotoCondensed-Regular", 22),
text_color=self.color,
text_background=(0,0,0,225)
)
+781
View File
@@ -0,0 +1,781 @@
import time
import embit
from binascii import hexlify
from embit.networks import NETWORKS
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
from .view import NotYetImplementedView, View, Destination, BackStackView, MainMenuView
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen,
LargeButtonScreen, WarningScreen, DireWarningScreen, seed_screens)
from seedsigner.gui.screens.screen import LoadingScreenThread, QRDisplayScreen
from seedsigner.helpers.threads import BaseThread, ThreadsafeCounter
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.psbt_parser import PSBTParser
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings import SettingsConstants
class SeedsMenuView(View):
def __init__(self):
super().__init__()
self.seeds = []
for seed in self.controller.storage.seeds:
self.seeds.append({
"fingerprint": seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)),
"has_passphrase": seed.passphrase is not None
})
def run(self):
button_data = []
for seed in self.seeds:
button_data.append((seed["fingerprint"], SeedSignerCustomIconConstants.FINGERPRINT))
button_data.append("Load a seed")
selected_menu_num = ButtonListScreen(
title="In-Memory Seeds",
is_button_text_centered=False,
button_data=button_data
).display()
if len(self.seeds) > 0 and selected_menu_num < len(self.seeds):
return Destination(SeedOptionsView, view_args={"seed_num": selected_menu_num})
elif selected_menu_num == len(self.seeds):
return Destination(LoadSeedView)
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class LoadSeedView(View):
def run(self):
SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS)
button_data=[
SEED_QR,
TYPE_24WORD,
TYPE_12WORD,
CREATE,
]
selected_menu_num = ButtonListScreen(
title="Load A Seed",
is_button_text_centered=False,
button_data=button_data
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == SEED_QR:
from .scan_views import ScanView
return Destination(ScanView)
elif button_data[selected_menu_num] == TYPE_24WORD:
return Destination(NotYetImplementedView)
elif button_data[selected_menu_num] == TYPE_12WORD:
return Destination(NotYetImplementedView)
elif button_data[selected_menu_num] == CREATE:
from .tools_views import ToolsMenuView
return Destination(ToolsMenuView)
"""****************************************************************************
Views for actions on individual seeds:
****************************************************************************"""
class SeedOptionsView(View):
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
self.seed = self.controller.get_seed(self.seed_num)
def run(self):
REVIEW_PSBT = "Review PSBT"
VIEW_WORDS = "View Seed Words"
EXPORT_XPUB = "Export Xpub"
EXPORT_SEEDQR = "Export Seed as QR"
button_data = []
if self.controller.psbt:
if not PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# 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)
button_data.append(VIEW_WORDS)
if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED:
button_data.append(EXPORT_XPUB)
button_data.append(EXPORT_SEEDQR)
selected_menu_num = seed_screens.SeedOptionsScreen(
button_data=button_data,
fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)),
has_passphrase=self.seed.passphrase is not None
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == REVIEW_PSBT:
from seedsigner.views.psbt_views import PSBTOverviewView
return Destination(PSBTOverviewView)
elif button_data[selected_menu_num] == VIEW_WORDS:
return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num})
elif button_data[selected_menu_num] == EXPORT_XPUB:
return Destination(SeedExportXpubSigTypeView, view_args={"seed_num": self.seed_num})
elif button_data[selected_menu_num] == EXPORT_SEEDQR:
return Destination(NotYetImplementedView)
# return Destination(SeedExportSeedQRView, view_args={"seed_num": self.seed_num})
"""****************************************************************************
View Seed Words flow
****************************************************************************"""
class SeedWordsWarningView(View):
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
def run(self):
destination = Destination(SeedWordsView, view_args={"seed_num": self.seed_num})
if self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__DISABLED:
# Forward straight to showing the words
destination.skip_current_view = True
return destination
selected_menu_num = DireWarningScreen(
warning_text="""You must keep your seed words private & away from all online devices.""",
).display()
if selected_menu_num == 0:
# User clicked "I Understand"
return destination
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SeedWordsView(View):
def __init__(self, seed_num: int, is_first_page: bool = True):
super().__init__()
self.seed_num = seed_num
self.seed = self.controller.get_seed(self.seed_num)
self.is_first_page = is_first_page
def run(self):
NEXT_12 = "Next"
SEED_OPTIONS = "Seed Options"
button_data = []
if self.is_first_page and len(self.seed.mnemonic_list) == 24:
button_data.append(NEXT_12)
else:
button_data.append(SEED_OPTIONS)
selected_menu_num = seed_screens.SeedWordsScreen(
seed=self.seed,
is_first_page=self.is_first_page,
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == NEXT_12:
# Go on to page 2
return Destination(SeedWordsView, view_args={"seed_num": self.seed_num, "is_first_page": False})
elif button_data[selected_menu_num] == SEED_OPTIONS:
# Back to SeedOptions for this seed; cannot back ("<") to this View.
return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, clear_history=True)
"""****************************************************************************
Export Xpub flow
****************************************************************************"""
class SeedExportXpubSigTypeView(View):
def __init__(self, seed_num: int):
super().__init__()
self.seed_num = seed_num
def run(self):
if len(self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)) == 1:
# Nothing to select; skip this screen
return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)[0]}, skip_current_view=True)
SINGLE_SIG = "Single Sig"
MULTISIG = "Multisig"
button_data=[SINGLE_SIG, MULTISIG]
selected_menu_num = LargeButtonScreen(
title="Export Xpub",
button_data=button_data
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == SINGLE_SIG:
return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.SINGLE_SIG})
elif button_data[selected_menu_num] == MULTISIG:
return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG})
class SeedExportXpubScriptTypeView(View):
def __init__(self, seed_num: int, sig_type: str):
super().__init__()
self.seed_num = seed_num
self.sig_type = sig_type
def run(self):
args = {"seed_num": self.seed_num, "sig_type": self.sig_type}
if len(self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES)) == 1:
# Nothing to select; skip this screen
args["script_type"] = self.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES)[0]
return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True)
selected_menu_num = ButtonListScreen(
title="Export Xpub",
is_button_text_centered=False,
is_bottom_list=True,
button_data=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES),
).display()
if selected_menu_num < len(SettingsConstants.ALL_SCRIPT_TYPES):
args["script_type"] = SettingsConstants.ALL_SCRIPT_TYPES[selected_menu_num][0]
if SettingsConstants.ALL_SCRIPT_TYPES[selected_menu_num][0] == SettingsConstants.CUSTOM_DERIVATION:
return Destination(SeedExportXpubCustomDerivationView, view_args=args)
return Destination(SeedExportXpubCoordinatorView, view_args=args)
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SeedExportXpubCustomDerivationView(View):
def __init__(self, seed_num: int, sig_type: str, script_type: str):
super().__init__()
self.seed_num = seed_num
self.sig_type = sig_type
self.script_type = script_type
self.custom_derivation_path = self.settings.custom_derivation
def run(self):
ret = seed_screens.SeedExportXpubCustomDerivationScreen(
derivation_path=self.custom_derivation_path
).display()
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# ret should be the custom derivation path; store it in Settings
self.settings.custom_derivation = ret
return Destination(
SeedExportXpubCoordinatorView,
view_args={
"seed_num": self.seed_num,
"sig_type": self.sig_type,
"script_type": self.script_type,
}
)
class SeedExportXpubCoordinatorView(View):
def __init__(self, seed_num: int, sig_type: str, script_type: str):
super().__init__()
self.seed_num = seed_num
self.sig_type = sig_type
self.script_type = script_type
def run(self):
args = {
"seed_num": self.seed_num,
"sig_type": self.sig_type,
"script_type": self.script_type,
}
if len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)) == 1:
# Nothing to select; skip this screen
args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0]
return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True)
selected_menu_num = ButtonListScreen(
title="Export Xpub",
is_button_text_centered=False,
is_bottom_list=True,
button_data=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS),
).display()
if selected_menu_num < len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)):
args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[selected_menu_num]
return Destination(SeedExportXpubWarningView, view_args=args)
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SeedExportXpubWarningView(View):
def __init__(self, seed_num: int, sig_type: str, script_type: str, coordinator: str):
super().__init__()
self.seed_num = seed_num
self.sig_type = sig_type
self.script_type = script_type
self.coordinator = coordinator
def run(self):
destination = Destination(
SeedExportXpubDetailsView,
view_args={
"seed_num": self.seed_num,
"sig_type": self.sig_type,
"script_type": self.script_type,
"coordinator": self.coordinator,
}
)
if self.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__DISABLED:
destination.skip_current_view = True
return destination
selected_menu_num = WarningScreen(
warning_headline="Privacy Leak!",
warning_text="""Xpub can be used to view all future transactions.""",
).display()
if selected_menu_num == 0:
# User clicked "I Understand"
return destination
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SeedExportXpubDetailsView(View):
"""
Collects the user input from all the previous screens leading up to this and
finally calculates the xpub and displays the summary view to the user.
"""
def __init__(self, seed_num: int, sig_type: str, script_type: str, coordinator: str):
super().__init__()
self.sig_type = sig_type
self.script_type = script_type
self.coordinator = coordinator
self.seed_num = seed_num
self.seed = self.controller.get_seed(self.seed_num)
def run(self):
# The calc_derivation takes a few moments. Run the loading screen while we wait.
self.loading_screen = LoadingScreenThread(text="Generating xpub...")
self.loading_screen.start()
if self.script_type == SettingsConstants.CUSTOM_DERIVATION:
derivation_path = self.settings.custom_derivation
else:
derivation_path = PSBTParser.calc_derivation(
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK),
wallet_type=self.sig_type,
script_type=self.script_type
)
if self.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED:
embit_network = NETWORKS[SettingsConstants.map_network_to_embit(self.settings.get_value(SettingsConstants.SETTING__NETWORK))]
version = embit.bip32.detect_version(
derivation_path,
default="xpub",
network=embit_network
)
root = embit.bip32.HDKey.from_seed(
self.seed.seed_bytes,
version=embit_network["xprv"]
)
fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8')
xprv = root.derive(derivation_path)
xpub = xprv.to_public()
xpub_base58 = xpub.to_string(version=version)
screen = seed_screens.SeedExportXpubDetailsScreen(
fingerprint=fingerprint,
has_passphrase=self.seed.passphrase is not None,
derivation_path=derivation_path,
xpub=xpub_base58,
)
self.loading_screen.stop()
selected_menu_num = screen.display()
else:
self.loading_screen.stop()
selected_menu_num = 0
if selected_menu_num == 0:
return Destination(
SeedExportXpubQRDisplayView,
{
"seed_num": self.seed_num,
"sig_type": self.sig_type,
"script_type": self.script_type,
"coordinator": self.coordinator,
"derivation_path": derivation_path,
}
)
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SeedExportXpubQRDisplayView(View):
def __init__(self, seed_num: int, sig_type: str, script_type: str, coordinator: str, derivation_path: str):
super().__init__()
self.seed = self.controller.get_seed(seed_num)
if coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP:
qr_type = QRType.XPUB__SPECTER
elif coordinator == SettingsConstants.COORDINATOR__BLUE_WALLET:
qr_type = QRType.XPUB
else:
qr_type = QRType.XPUB__UR
self.qr_encoder = EncodeQR(
seed_phrase=self.seed.mnemonic_list,
passphrase=self.seed.passphrase,
derivation=derivation_path,
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK),
qr_type=qr_type,
qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY),
wordlist_language_code=self.seed.wordlist_language_code
)
def run(self):
QRDisplayScreen(qr_encoder=self.qr_encoder).display()
return Destination(MainMenuView)
"""****************************************************************************
Loading seeds, passphrases, etc
****************************************************************************"""
class SeedValidView(View):
def __init__(self):
super().__init__()
self.seed = self.controller.storage.get_pending_seed()
self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK))
def run(self):
from .psbt_views import PSBTOverviewView
SIGN_PSBT = "Review PSBT"
SCAN_PSBT = ("Scan a PSBT", FontAwesomeIconConstants.QRCODE)
PASSPHRASE = ("Add Passphrase", FontAwesomeIconConstants.UNLOCK)
SEED_TOOLS = "Seed Options"
button_data = []
if self.controller.psbt:
if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# Seed doesn't match any input fingerprints
# TODO: Is there ever a use-case for letting someone try to sign with a
# seed that doesn't match?
SIGN_PSBT += " (?)"
else:
# Don't auto-route to a signable psbt. Just display the button.
pass
button_data.append(SIGN_PSBT)
else:
button_data.append(SCAN_PSBT)
if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) in [
SettingsConstants.OPTION__ENABLED,
SettingsConstants.OPTION__PROMPT,
SettingsConstants.OPTION__REQUIRED]:
if self.seed.passphrase:
PASSPHRASE = "Edit Passphrase"
button_data.append(PASSPHRASE)
button_data.append(SEED_TOOLS)
selected_menu_num = seed_screens.SeedValidScreen(
fingerprint=self.fingerprint,
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
# Warning message that this will discard the pending seed
return Destination(SeedDiscardView)
elif button_data[selected_menu_num] == SIGN_PSBT:
self.controller.storage.finalize_pending_seed()
self.controller.psbt_seed = self.seed
return Destination(PSBTOverviewView, clear_history=True)
elif button_data[selected_menu_num] == SCAN_PSBT:
self.controller.storage.finalize_pending_seed()
# Jump back to the Scan mode, but this time to sign a PSBT
from .scan_views import ScanView
return Destination(ScanView, clear_history=True)
elif button_data[selected_menu_num] == PASSPHRASE:
return Destination(SeedAddPassphraseView)
elif button_data[selected_menu_num] == SEED_TOOLS:
# Jump straight to the Seed Tools for this seed
seed_num = self.controller.storage.finalize_pending_seed()
return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True)
class SeedDiscardView(View):
def run(self):
YES = "Yes"
NO = "No"
button_data = [YES, NO]
selected_menu_num = LargeButtonScreen(
title="Discard Seed?",
button_data=button_data,
show_top_nav_left_button=False,
).display()
if button_data[selected_menu_num] == YES:
self.controller.storage.clear_pending_seed()
return Destination(MainMenuView)
elif button_data[selected_menu_num] == NO:
return Destination(SeedValidView)
class SeedAddPassphrasePromptView(View):
def run(self):
YES = "Yes"
NO = "No"
button_data = [YES, NO]
selected_menu_num = LargeButtonScreen(
title="Add Passphrase?",
button_data=button_data
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == YES:
return Destination(SeedAddPassphraseView)
elif button_data[selected_menu_num] == NO:
return Destination(SeedValidView)
class SeedAddPassphraseView(View):
def __init__(self):
super().__init__()
self.seed = self.controller.storage.get_pending_seed()
def run(self):
ret = seed_screens.SeedAddPassphraseScreen(passphrase=self.seed.passphrase).display()
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# The new passphrase will be the return value
self.seed.set_passphrase(ret)
return Destination(SeedReviewPassphraseView)
class SeedReviewPassphraseView(View):
"""
Display the completed passphrase back to the user.
"""
def __init__(self):
super().__init__()
self.seed = self.controller.storage.get_pending_seed()
print(f"SeedReviewPassphraseView self.seed: {self.seed}")
def run(self):
EDIT = "Edit passphrase"
CONTINUE = "Continue"
button_data = [EDIT, CONTINUE]
# Get the before/after fingerprints
network = self.settings.get_value(SettingsConstants.SETTING__NETWORK)
passphrase = self.seed.passphrase
fingerprint_with = self.seed.get_fingerprint(network=network)
self.seed.set_passphrase("")
fingerprint_without = self.seed.get_fingerprint(network=network)
self.seed.set_passphrase(passphrase)
# Because we have ane explicit "Edit" button, we disable "BACK" to keep the
# routing options sane.
selected_menu_num = seed_screens.SeedReviewPassphraseScreen(
fingerprint_without=fingerprint_without,
fingerprint_with=fingerprint_with,
passphrase=self.seed.passphrase,
button_data=button_data,
show_top_nav_left_button=False,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == EDIT:
return Destination(SeedAddPassphraseView)
elif button_data[selected_menu_num] == CONTINUE:
return Destination(SeedValidView)
class SeedSingleSigAddressVerificationView(View):
"""
TODO: Reserved for Nick.
This was previously part of the PSBT flow but was moved here when we
figured out how to avoid having to brute force the change addr verification.
But instead this code can be altered to be used to brute force single sig
address validation as its own separate flow.
Creates a worker thread to brute force calculate addresses. Writes its
iteration status to a shared `ThreadsafeCounter`.
The `ThreadsafeCounter` is sent to the display Screen which is monitored in
its own `ProgressThread` to show the current iteration onscreen.
"""
def __init__(self, seed_num: int, address: str):
super().__init__()
self.seed = self.controller.get_seed(seed_num)
self.address = address
def run(self):
threadsafe_counter = ThreadsafeCounter()
addr_verification_thread = SeedSingleSigAddressVerificationView.SingleSigAddressVerificationThread(
address=self.address,
seed=self.seed,
threadsafe_counter=threadsafe_counter
)
addr_verification_thread.start()
selected_menu_num = seed_screens.SingleSigAddressVerificationScreen(
address=self.address,
threadsafe_counter=threadsafe_counter,
).display()
addr_verification_thread.stop()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SingleSigAddressVerificationThread(BaseThread):
def __init__(self, address: str, seed: Seed, threadsafe_counter: ThreadsafeCounter):
super().__init__()
self.address = address
self.threadsafe_counter = threadsafe_counter
self.verified_index: int = 0
self.verified_index_is_change: bool = None
def run(self):
while self.keep_running:
# Do work to verify addr
# TODO: Reserved for Nick
# For now mocking that up with time consuming... sleep
time.sleep(0.25)
# Increment our index counter
self.threadsafe_counter.increment()
if self.threadsafe_counter.cur_count % 10 == 0:
print(f"Incremented to {self.threadsafe_counter.cur_count}")
# On successfully verifying addr, set:
# self.verified_index = self.counter.cur_count
# self.verified_index_is_change = True
# break # Will instance stick around if run() exits?
# TODO: This should be in `Seed` or `PSBT` utility class
# def verify_single_sig_addr(self, address:str):
# import embit
# network = embit.NETWORKS[self.settings.get_value(SettingsConstants.SETTING__NETWORK)]
# version = embit.bip32.detect_version(derivation, default="xpub", network=network)
# root = embit.bip32.HDKey.from_seed(seed.seed, version=network["xprv"])
# fingerprint = hexlify(root.child(0).fingerprint).decode('utf-8')
# xprv = root.derive(derivation)
# xpub = xprv.to_public()
# for i in range(500):
# r_pubkey = xpub.derive([0,i]).key
# c_pubkey = xpub.derive([1,i]).key
# recieve_address = ""
# change_address = ""
# if "P2PKH" in address_type:
# recieve_address = embit.script.p2pkh(r_pubkey).address(network=network)
# change_address = embit.script.p2pkh(c_pubkey).address(network=network)
# elif "Bech32" in address_type:
# recieve_address = embit.script.p2wpkh(r_pubkey).address(network=network)
# change_address = embit.script.p2wpkh(c_pubkey).address(network=network)
# elif "P2SH" in address_type:
# recieve_address = embit.script.p2sh(embit.script.p2wpkh(r_pubkey)).address(network=network)
# change_address = embit.script.p2sh(embit.script.p2wpkh(c_pubkey)).address(network=network)
# if address == recieve_address:
# self.menu_view.draw_modal(["Receive Address "+str(i), "Verified"], "", "Right to Exit")
# input = self.buttons.wait_for([B.KEY_RIGHT])
# return Path.MAIN_MENU
# if address == change_address:
# self.menu_view.draw_modal(["Change Address "+str(i), "Verified"], "", "Right to Exit")
# input = self.buttons.wait_for([B.KEY_RIGHT])
# return Path.MAIN_MENU
# else:
# self.menu_view.draw_modal(["Checking Address "+str(i), "..."], "", "Right to Abort")
# if self.buttons.check_for_low(B.KEY_RIGHT) or self.buttons.check_for_low(B.KEY_LEFT):
# return Path.MAIN_MENU
+27 -133
View File
@@ -1,10 +1,12 @@
# SeedSigner file class dependencies
from . import View
from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay
from seedsigner.models import EncodeQRDensity
from seedsigner.helpers import B, QR
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
from seedsigner.models import EncodeQR
class SettingsToolsView(View):
def __init__(self) -> None:
View.__init__(self)
@@ -12,112 +14,20 @@ class SettingsToolsView(View):
self.donate_image = None
self.derivation = None
### Donate Menu Item
### Donate Menu Item
def display_donate_info_screen(self):
self.draw_modal(["You can support", "SeedSigner by donating", "any amount of BTC", "Thank You!!!"], "", "(Press right for a QR code)")
self.renderer.draw_modal(["You can support", "SeedSigner by donating", "any amount of BTC", "Thank You!!!"], "", "(Press right for a QR code)")
return True
def display_donate_qr(self):
self.draw_modal(["Loading..."])
self.renderer.draw_modal(["Loading..."])
self.donate_image = self.qr.qrimage("bc1qphlyv2dde290tqdlnk8uswztnshw3x9rjurexqqhksvu7vdevhtsuw4efe")
View.DispShowImage(self.donate_image)
self.renderer.show_image(self.donate_image)
return True
### Display Network Selection
def display_current_network(self) -> str:
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Mainnet", "Testnet"], "Which Network?")
if r == 2:
return "main"
elif r == 3:
return "test"
else:
return None
### Display Wallet Selection
def display_wallet_selection(self) -> str:
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Prompt", "Specter Desktop", "Blue Wallet", "Sparrow"], "Which Wallet?")
if r == 2:
return "Prompt"
elif r == 3:
return "Specter Desktop"
elif r == 4:
return "Blue Wallet"
elif r == 5:
return "Sparrow"
else:
return None
### Display QR Density Selection
def display_qr_density_selection(self) -> str:
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Low", "Medium", "High"], "Which QR Density?")
if r == 2:
return EncodeQRDensity.LOW
elif r == 3:
return EncodeQRDensity.MEDIUM
elif r == 4:
return EncodeQRDensity.HIGH
else:
return None
def display_persistent_settings(self) -> bool:
lines = ["... [ Return to Settings ]"]
lines.append("Yes")
lines.append("No")
r = self.controller.menu_view.display_generic_selection_menu(lines, "Use Persistent Settings?")
if r == 1:
return None
elif r == 2:
return True
elif r == 3:
return False
else:
return None
def display_camera_rotation(self) -> int:
lines = ["... [ Return to Settings ]"]
lines.append("0° (default)")
lines.append("90°")
lines.append("180°")
lines.append("270°")
r = self.controller.menu_view.display_generic_selection_menu(lines, "Camera Rotation")
if r == 1:
return None
elif r == 2:
return 0
elif r == 3:
return 90
elif r == 4:
return 180
elif r == 5:
return 270
else:
return None
###
### Version Info
###
def display_version_info(self):
line1 = "SeedSigner"
line2 = "Version v" + self.controller.VERSION
line3 = "(Joystick RIGHT to EXIT)"
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
tw, th = View.draw.textsize(line1, font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 20), line1, fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(line2, font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 55), line2, fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(line3, font=View.ASSISTANT18)
View.draw.text(((240 - tw) / 2, 210), line3, fill=View.color, font=View.ASSISTANT18)
View.DispShowImage()
###
### Custom Derivation Path
@@ -129,19 +39,19 @@ class SettingsToolsView(View):
right_button_width = 60
font_padding_right = 2
font_padding_top = 1
key_x = View.canvas_width - right_button_width
key_y = int(View.canvas_height - row_height) / 2 - 1 - 60
font = View.ROBOTOCONDENSED_BOLD_24
key_x = self.renderer.canvas_width - right_button_width
key_y = int(self.renderer.canvas_height - row_height) / 2 - 1 - 60
font = Fonts.get_font("RobotoCondensed-Regular", 24)
background_color = "#111"
font_color = View.color
button3_text = "Save"
tw, th = font.getsize(button3_text)
key_y = int(View.canvas_height - row_height) / 2 - 1 + 60
View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color)
key_y = int(self.renderer.canvas_height - row_height) / 2 - 1 + 60
self.renderer.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
self.renderer.draw.text((self.renderer.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color)
# Clear the screen
View.draw.rectangle((0,0, View.canvas_width,View.canvas_height), fill="black")
self.renderer.draw.rectangle((0,0, self.renderer.canvas_width,self.renderer.canvas_height), fill="black")
self.render_previous_button()
previous_button_is_active = False
@@ -157,16 +67,16 @@ class SettingsToolsView(View):
right_panel_buttons_width = 60
# render top title banner
font = View.ROBOTOCONDENSED_REGULAR_20
font = Fonts.get_font("RobotoCondensed-Regular", 20)
title = "Enter Derivation"
title_top_padding = 0
title_bottom_padding = 10
tw, th = font.getsize(title)
View.draw.text((int(View.canvas_width - tw) / 2, title_top_padding), text=title, font=font, fill=View.color)
self.renderer.draw.text((int(self.renderer.canvas_width - tw) / 2, title_top_padding), text=title, font=font, fill=View.color)
title_height = th + title_top_padding + title_bottom_padding
# Render the live text entry display
font = View.ROBOTOCONDENSED_REGULAR_28
font = Fonts.get_font("RobotoCondensed-Regular", 28)
tw, th = font.getsize("m/1234567890") # All possible chars for max range
text_entry_side_padding = 0
text_entry_top_padding = 1
@@ -174,8 +84,8 @@ class SettingsToolsView(View):
text_entry_top_y = title_height + text_entry_top_padding
text_entry_bottom_y = text_entry_top_y + 3 + th + 3
text_entry_display = TextEntryDisplay(
View.draw,
rect=(text_entry_side_padding,text_entry_top_y, View.canvas_width - right_panel_buttons_width - 1, text_entry_bottom_y),
self.renderer.draw,
rect=(text_entry_side_padding,text_entry_top_y, self.renderer.canvas_width - right_panel_buttons_width - 1, text_entry_bottom_y),
font=font,
font_color=View.color,
cursor_mode=TextEntryDisplay.CURSOR_MODE__BLOCK,
@@ -188,11 +98,11 @@ class SettingsToolsView(View):
keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding
keyboard_digits = Keyboard(
View.draw,
self.renderer.draw,
charset="/'0123456789",
rows=3,
cols=6,
rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height),
rect=(0, keyboard_start_y, self.renderer.canvas_width - right_panel_buttons_width, self.renderer.canvas_height),
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
@@ -201,11 +111,11 @@ class SettingsToolsView(View):
render_right_panel()
text_entry_display.render(self.derivation)
View.DispShowImage()
self.renderer.show_image()
# Start the interactive update loop
while True:
input = View.buttons.wait_for(
input = self.buttons.wait_for(
[B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY3],
check_release=True,
release_keys=[B.KEY_PRESS, B.KEY3]
@@ -271,20 +181,4 @@ class SettingsToolsView(View):
# Render the text entry display and cursor block
text_entry_display.render(self.derivation)
View.DispShowImage()
def display_compact_seedqr_enabled(self) -> bool:
lines = ["... [ Return to Settings ]"]
lines.append("Enable")
lines.append("Disable")
r = self.controller.menu_view.display_generic_selection_menu(lines, "Compact SeedQR")
if r == 1:
return None
elif r == 2:
return True
elif r == 3:
return False
else:
return None
self.renderer.show_image()
+155
View File
@@ -0,0 +1,155 @@
from seedsigner.gui.components import FontAwesomeIconConstants
from .view import View, Destination, BackStackView, MainMenuView
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
from seedsigner.models.settings import SettingsConstants, SettingsDefinition
class SettingsMenuView(View):
def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None):
super().__init__()
self.visibility = visibility
self.selected_attr = selected_attr
def run(self):
settings_entries = SettingsDefinition.get_settings_entries(
visibiilty=self.visibility
)
button_data=[e.display_name for e in settings_entries]
selected_button = 0
if self.selected_attr:
for i, entry in enumerate(settings_entries):
if entry.attr_name == self.selected_attr:
selected_button = i
break
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
title = "Settings"
button_data.append(("Advanced", FontAwesomeIconConstants.CIRCLE_CHEVRON_RIGHT))
next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
button_data.append(("Developer Options", FontAwesomeIconConstants.CIRCLE_CHEVRON_RIGHT))
title = "Advanced"
next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER})
elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER:
title = "Dev Options"
next = None
selected_menu_num = ButtonListScreen(
title=title,
is_button_text_centered=False,
button_data=button_data,
selected_button=selected_button,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
return Destination(MainMenuView)
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
return Destination(SettingsMenuView)
else:
return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
elif selected_menu_num == len(settings_entries):
return next
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})
class SettingsEntryUpdateSelectionView(View):
"""
Handles changes to all selection-type settings (Multiselect, SELECT_1,
Enabled/Disabled, etc).
"""
def __init__(self, attr_name: str):
super().__init__()
self.settings_entry = SettingsDefinition.get_settings_entry(attr_name)
self.selected_button = None
def run(self):
initial_value = self.settings.get_value(self.settings_entry.attr_name)
button_data = []
checked_buttons = []
for i, value in enumerate(self.settings_entry.selection_options):
if type(value) == tuple:
value, display_name = value
else:
display_name = value
button_data.append(display_name)
if (type(initial_value) == list and value in initial_value) or value == initial_value:
checked_buttons.append(i)
if self.selected_button is None:
# Highlight the selection (for multiselect highlight the first
# selected option).
self.selected_button = i
if not self.selected_button:
self.selected_button = 0
ret_value = settings_screens.SettingsEntryUpdateSelectionScreen(
display_name=self.settings_entry.display_name,
help_text=self.settings_entry.help_text,
button_data=button_data,
selected_button=self.selected_button,
checked_buttons=checked_buttons,
settings_entry_type=self.settings_entry.type,
).display()
destination = None
settings_menu_view_destination = Destination(
SettingsMenuView,
view_args={
"visibility": self.settings_entry.visibility,
"selected_attr": self.settings_entry.attr_name
}
)
if ret_value == RET_CODE__BACK_BUTTON:
return settings_menu_view_destination
value = self.settings_entry.get_selection_option_value(ret_value)
if self.settings_entry.type == SettingsConstants.TYPE__FREE_ENTRY:
updated_value = ret_value
destination = settings_menu_view_destination
elif self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT:
updated_value = list(initial_value)
if ret_value not in checked_buttons:
# This is a new selection to add
updated_value.append(value)
else:
# This is a de-select to remove
updated_value.remove(value)
else:
# All other types are single selects (e.g. Enabled/Disabled, SELECT_1)
if value == initial_value:
# No change, return to menu
return settings_menu_view_destination
else:
updated_value = value
self.settings.set_value(
attr_name=self.settings_entry.attr_name,
value=updated_value
)
if destination:
return destination
# All selects stay in place; re-initialize where in the list we left off
self.selected_button = ret_value
return self.run()
@@ -1,89 +0,0 @@
# Internal file class dependencies
from . import View
from seedsigner.helpers import Buttons, B
# External Dependencies
import time
class SigningToolsView(View):
def __init__(self, seed_storage) -> None:
View.__init__(self)
self.seed_storage = seed_storage
###
### XPub
###
def display_xpub_info(self, fingerprint, derivation, xpub):
derivation_display = "Derivation: " + derivation
xpub_display = xpub[0:7] + "..." + xpub[-9:]
self.draw_modal(["Master Fingerprint: ", fingerprint, derivation_display, xpub_display], "Xpub Info", "Right to Continue")
###
### Signing Tx
###
def display_transaction_information(self, p) -> None:
self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
tw, th = self.draw.textsize("Confirm Tx Details", font=View.ASSISTANT25)
self.draw.text(((240 - tw) / 2, 3), "Confirm Tx Details", fill=View.color, font=View.ASSISTANT25)
in_fee_outs_str = str(len(p.psbt.inputs))
in_fee_outs_str += " inputs - fee = " if len(p.psbt.inputs) > 1 else " input - fee = "
in_fee_outs_str += str(len(p.psbt.outputs))
in_fee_outs_str += " outs" if len(p.psbt.outputs) > 1 else " out"
tw, th = self.draw.textsize(in_fee_outs_str, font=View.ASSISTANT22)
self.draw.text(((240 - tw) / 2, 40), in_fee_outs_str, fill=View.color, font=View.ASSISTANT22)
receiving_addr_str1 = ""
receiving_addr_str2 = ""
if len(p.destination_addresses) > 1:
receiving_addr_str1 += "multiple"
receiving_addr_str2 += "receiving addresses"
elif len(p.destination_addresses) == 1:
receiving_addr_str1 += "receiving address"
receiving_addr_str2 += "last 13: ... " + p.destination_addresses[0][-13:]
else:
receiving_addr_str1 += "Self-Transfer"
tw, th = self.draw.textsize(receiving_addr_str1, font=View.ASSISTANT22)
self.draw.text(((240 - tw) / 2, 75), receiving_addr_str1, fill=View.color, font=View.ASSISTANT22)
if len(receiving_addr_str2) > 0:
tw, th = self.draw.textsize(receiving_addr_str2, font=View.ROBOTOCONDENSED_BOLD_18)
self.draw.text(((240 - tw) / 2, 105), receiving_addr_str2, fill=View.color, font=View.ROBOTOCONDENSED_BOLD_18)
if p.spend_amount > 0:
spending_str = "Spend: " + str(p.spend_amount) + " sats"
tw, th = self.draw.textsize(spending_str, font=View.ASSISTANT22)
self.draw.text(((240 - tw) / 2, 130), spending_str, fill=View.color, font=View.ASSISTANT22)
if p.change_amount > 0 and len(p.destination_addresses) == 0:
change_str = "Amount: " + str(p.change_amount) + " sats"
tw, th = self.draw.textsize(change_str, font=View.ASSISTANT22)
self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.ASSISTANT22)
elif p.change_amount > 0:
change_str = "Change: " + str(p.change_amount) + " sats"
tw, th = self.draw.textsize(change_str, font=View.ASSISTANT22)
self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.ASSISTANT22)
fee_str = "Fee: " + str(p.fee_amount) + " sats"
tw, th = self.draw.textsize(fee_str, font=View.ASSISTANT22)
self.draw.text(((240 - tw) / 2, 180), fee_str, fill=View.color, font=View.ASSISTANT22)
tw, th = self.draw.textsize("Left to Exit, Right to Continue", font=View.ASSISTANT18)
self.draw.text(((240 - tw) / 2, 215), "Left to Exit, Right to Continue", fill=View.color, font=View.ASSISTANT18)
View.DispShowImage()
def qr_gen_status(percentage):
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
tw, th = View.draw.textsize("QR Generation", font=View.ASSISTANT25)
View.draw.text(((240 - tw) / 2, 90), "QR Generation", fill=View.color, font=View.ASSISTANT25)
tw, th = View.draw.textsize(str(round(percentage)) + "% Complete", font=View.ASSISTANT25)
View.draw.text(((240 - tw) / 2, 125), str(round(percentage)) + "% Complete", fill=View.color, font=View.ASSISTANT25)
View.DispShowImage()
+32
View File
@@ -0,0 +1,32 @@
from seedsigner.gui.components import FontAwesomeIconConstants
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen)
from .view import NotYetImplementedView, View, Destination, BackStackView, MainMenuView
class ToolsMenuView(View):
def run(self):
IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA)
DICE = ("New seed", FontAwesomeIconConstants.DICE)
KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
button_data = [IMAGE, DICE, KEYBOARD]
screen = ButtonListScreen(
title="Tools",
is_button_text_centered=False,
button_data=button_data
)
selected_menu_num = screen.display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == IMAGE:
return Destination(NotYetImplementedView)
elif button_data[selected_menu_num] == DICE:
return Destination(NotYetImplementedView)
if button_data[selected_menu_num] == KEYBOARD:
return Destination(NotYetImplementedView)
+185 -357
View File
@@ -1,379 +1,207 @@
# External Dependencies
from PIL import Image, ImageDraw, ImageFont
import os
import pathlib
import spidev as SPI
import time
from multiprocessing import Queue
from seedsigner.helpers import B, ST7789
from dataclasses import dataclass
from seedsigner.gui.components import FontAwesomeIconConstants
from seedsigner.gui.screens import (RET_CODE__POWER_BUTTON, TextTopNavScreen)
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, LargeButtonScreen, WarningScreen
from seedsigner.helpers.threads import BaseThread
### Generic View Class to Instatiate Display
### Static Class variables are used for display
### Designed to be inherited for other view classes, but not required
class BackStackView:
"""
Empty class that just signals to the Controller to pop the most recent View off
the back_stack.
"""
pass
"""
Views contain the biz logic to handle discrete tasks, exactly analogous to a Flask
request/response function or a Django View. Each page/screen displayed to the user
should be implemented in its own View.
In a web context, the View would prepare data for the html/css/js presentation
templates. We have to implement our own presentation layer (implemented as `Screen`
objects). For the sake of code cleanliness and separation of concerns, the View code
should not know anything about pixel-level rendering.
Sequences that require multiple pages/screens should be implemented as a series of
separate Views. Exceptions can be made for complex interactive sequences, but in
general, if your View is instantiating multiple Screens, you're probably putting too
much functionality in that View.
As with http requests, Views can receive input vars to inform their behavior. Views
can also prepare the next set of vars to set up the next View that should be
displayed (akin to Flask's `return redirect(url, param1=x, param2=y))`).
"""
class View:
WIDTH = 240
HEIGHT = 240
font_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "fonts")
# Define necessary fonts
ASSISTANT16 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 16)
ASSISTANT18 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 18)
ASSISTANT20 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 20)
ASSISTANT21 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 21)
ASSISTANT22 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 22)
ASSISTANT23 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 23)
ASSISTANT25 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 25)
ASSISTANT26 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 26)
ASSISTANT35 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 35)
ASSISTANT50 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 50)
ASSISTANT16BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 15)
ASSISTANT18BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 17)
ASSISTANT20BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 19)
ASSISTANT21BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 20)
ASSISTANT22BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 21)
ASSISTANT23BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 22)
ASSISTANT25BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 25)
ASSISTANT26BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 26)
ASSISTANT35BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 35)
ASSISTANT50BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 50)
ROBOTOMONO14 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 14)
ROBOTOMONO24 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 24)
ROBOTOMONO38 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 38)
ROBOTOMONO30 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 30)
ROBOTOMONO20 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 20)
ROBOTOCONDENSED_BOLD_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 16)
ROBOTOCONDENSED_BOLD_18 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 18)
ROBOTOCONDENSED_BOLD_20 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 20)
ROBOTOCONDENSED_BOLD_22 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 20)
ROBOTOCONDENSED_BOLD_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 22)
ROBOTOCONDENSED_BOLD_25 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 25)
ROBOTOCONDENSED_BOLD_26 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 26)
ROBOTOCONDENSED_BOLD_28 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 28)
ROBOTOCONDENSED_LIGHT_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Light.ttf"), 16)
ROBOTOCONDENSED_LIGHT_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Light.ttf"), 24)
ROBOTOCONDENSED_REGULAR_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 16)
ROBOTOCONDENSED_REGULAR_20 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 20)
ROBOTOCONDENSED_REGULAR_22 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 22)
ROBOTOCONDENSED_REGULAR_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 24)
ROBOTOCONDENSED_REGULAR_26 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 26)
ROBOTOCONDENSED_REGULAR_28 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 28)
RST = 27
DC = 25
BL = 24
buttons = None
canvas_width = 0
canvas_height = 0
canvas = None
draw = None
bus = 0
device = 0
disp = None
previous_button_width = None
def __init__(self) -> None:
# Import here to avoid circular imports
from seedsigner.controller import Controller
self.controller = Controller.get_instance()
from seedsigner.gui import Renderer
from seedsigner.models import Settings
View.buttons = self.controller.buttons
View.color = self.controller.color
self.controller: Controller = Controller.get_instance()
self.settings = Settings.get_instance()
View.canvas_width = View.WIDTH
View.canvas_height = View.HEIGHT
View.canvas = Image.new('RGB', (View.canvas_width, View.canvas_height))
View.draw = ImageDraw.Draw(View.canvas)
# TODO: Pull all rendering-related code out of Views and into gui.screens implementations
self.renderer = Renderer.get_instance()
self.canvas_width = self.renderer.canvas_width
self.canvas_height = self.renderer.canvas_height
# 240x240 display with hardware SPI:
View.bus = 0
View.device = 0
# TODO: Add `disable_hardware` option for test suite here, too?
View.disp = ST7789(SPI.SpiDev(View.bus, View.device),View.RST, View.DC, View.BL)
View.disp.Init()
self.queue = Queue()
self.buttons = self.controller.buttons
def DispShowImage(image=None, alpha_overlay=None):
if image == None:
image = View.canvas
def run(self, **kwargs):
if hasattr(self, "screen"):
self.screen.display()
else:
# Always keep a copy of the current display in the canvas
View.canvas.paste(image)
if alpha_overlay:
image = Image.alpha_composite(image, alpha_overlay)
# Always keep a copy of the current display in the canvas
View.canvas.paste(image)
View.disp.ShowImage(image, 0, 0)
def disp_show_image_pan(image, start_x, start_y, end_x, end_y, rate, alpha_overlay=None):
cur_x = start_x
cur_y = start_y
rate_x = rate
rate_y = rate
if end_x - start_x < 0:
rate_x = rate_x * -1
if end_y - start_y < 0:
rate_y = rate_y * -1
while (cur_x != end_x or cur_y != end_y) and (rate_x != 0 or rate_y != 0):
cur_x += rate_x
if (rate_x > 0 and cur_x > end_x) or (rate_x < 0 and cur_x < end_x):
# We've moved too far; back up and undo that last move.
cur_x -= rate_x
rate_x = 0
cur_y += rate_y
if (rate_y > 0 and cur_y > end_y) or (rate_y < 0 and cur_y < end_y):
# We've moved too far; back up and undo that last move.
cur_y -= rate_y
rate_y = 0
crop = image.crop((cur_x, cur_y, cur_x + View.canvas_width, cur_y + View.canvas_height))
if alpha_overlay:
crop = Image.alpha_composite(crop, alpha_overlay)
# Always keep a copy of the current display in the canvas
View.canvas.paste(crop)
View.disp.ShowImage(crop, 0, 0)
raise Exception("Must implement in the child class")
def DispShowImageWithText(image, text, font=None, text_color="GREY", text_background=None):
image_copy = image.copy().convert("RGBA")
draw = ImageDraw.Draw(image_copy)
@dataclass
class Destination:
"""
Basic struct to pass back to the Controller to tell it which View the user should
be presented with next.
"""
View_cls: View # The target View to route to
view_args: dict = None # The input args required to instantiate the target View
skip_current_view: bool = False # The current View is just forwarding; omit current View from history
clear_history: bool = False # Optionally clears the back_stack to prevent "back"
text_overlay = Image.new("RGBA", (View.canvas_width, View.canvas_height), (255,255,255,0))
text_overlay_draw = ImageDraw.Draw(text_overlay)
if not font:
font = View.ASSISTANT18
tw, th = text_overlay_draw.textsize(text, font=font)
if text_background:
text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background)
text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font)
View.DispShowImage(image_copy, alpha_overlay=text_overlay)
def draw_modal(self, lines = [], title = "", bottom = "") -> None:
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
if len(title) > 0:
tw, th = View.draw.textsize(title, font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT22)
if len(bottom) > 0:
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
if len(lines) == 1:
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT26)
elif len(lines) == 2:
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.ASSISTANT22)
elif len(lines) == 3:
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.ASSISTANT26)
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
elif len(lines) == 4:
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[3], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.ASSISTANT22)
View.DispShowImage()
return
def draw_address(self, address) -> None:
if address.startswith(tuple(['1','2','3'])):
dividby = 14
def __str__(self):
if self.View_cls is None:
out = "None"
else:
dividby = 18
address_parts = [address[i:i+dividby] for i in range(0, len(address), dividby)]
address_part_1 = address_parts[0]
address_part_2 = address_parts[1] if len(address_parts) >= 2 else ""
address_part_3 = address_parts[2] if len(address_parts) >= 3 else ""
address_part_4 = address_parts[3] if len(address_parts) >= 4 else ""
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
tw, th = View.draw.textsize("Right to Continue", font=View.ASSISTANT18)
View.draw.text(((240 - tw) / 2, 210), "Right to Continue", fill=View.color, font=View.ASSISTANT18)
tw, th = View.draw.textsize("Bitcoin Address", font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 35), "Bitcoin Address", fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(address_part_1, font=View.ROBOTOCONDENSED_REGULAR_22)
View.draw.text(((240 - tw) / 2, 70), address_part_1, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
tw, th = View.draw.textsize(address_part_2, font=View.ROBOTOCONDENSED_REGULAR_22)
View.draw.text(((240 - tw) / 2, 105), address_part_2, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
tw, th = View.draw.textsize(address_part_3, font=View.ROBOTOCONDENSED_REGULAR_22)
View.draw.text(((240 - tw) / 2, 140), address_part_3, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
tw, th = View.draw.textsize(address_part_4, font=View.ROBOTOCONDENSED_REGULAR_22)
View.draw.text(((240 - tw) / 2, 175), address_part_4, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
View.DispShowImage()
return
def draw_passphrase(self, title, passphrase, bottom) -> None:
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
passphrase_part_1 = passphrase[0:20]
passphrase_part_2 = passphrase[20:40]
passphrase_part_3 = passphrase[40:60]
passphrase_part_4 = passphrase[60:1000]
tw, th = View.draw.textsize(title, font=View.ASSISTANT26)
View.draw.text(((240 - tw) / 2, 30), title, fill=View.color, font=View.ASSISTANT26)
tw, th = View.draw.textsize(passphrase_part_1, font=View.ROBOTOCONDENSED_REGULAR_20)
View.draw.text(((240 - tw) / 2, 70), passphrase_part_1, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
tw, th = View.draw.textsize(passphrase_part_2, font=View.ROBOTOCONDENSED_REGULAR_20)
View.draw.text(((240 - tw) / 2, 100), passphrase_part_2, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
tw, th = View.draw.textsize(passphrase_part_3, font=View.ROBOTOCONDENSED_REGULAR_20)
View.draw.text(((240 - tw) / 2, 130), passphrase_part_3, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
tw, th = View.draw.textsize(passphrase_part_4, font=View.ROBOTOCONDENSED_REGULAR_20)
View.draw.text(((240 - tw) / 2, 160), passphrase_part_4, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
View.DispShowImage()
return
def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None:
self.draw_prompt_custom("", "Yes ", "No ", lines, title, bottom)
return
def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None:
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
if len(title) > 0:
tw, th = View.draw.textsize(title, font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT22)
if len(bottom) > 0:
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
if len(lines) == 1:
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT26)
elif len(lines) == 2:
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.ASSISTANT22)
elif len(lines) == 3:
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.ASSISTANT26)
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
elif len(lines) == 4:
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(lines[3], font=View.ASSISTANT22)
View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.ASSISTANT22)
a_x_offset = 240 - View.ASSISTANT25.getsize(a_txt)[0]
View.draw.text((a_x_offset, 39 + 0), a_txt, fill=View.color, font=View.ASSISTANT25)
b_x_offset = 240 - View.ASSISTANT25.getsize(b_txt)[0]
View.draw.text((b_x_offset , 39 + 60), b_txt, fill=View.color, font=View.ASSISTANT25)
c_x_offset = 240 - View.ASSISTANT25.getsize(c_txt)[0]
View.draw.text((c_x_offset , 39 + 120), c_txt, fill=View.color, font=View.ASSISTANT25)
View.DispShowImage()
return
###
### Power Off Screen
###
def display_power_off_screen(self):
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
line1 = "Powering Down..."
line2 = "Please wait about"
line3 = "30 seconds before"
line4 = "disconnecting power."
tw, th = View.draw.textsize(line1, font=View.ASSISTANT22)
View.draw.text(((240-tw)/2, 45), line1, fill=View.color, font=View.ASSISTANT22)
tw, th = View.draw.textsize(line2, font=View.ASSISTANT20)
View.draw.text(((240-tw)/2, 100), line2, fill=View.color, font=View.ASSISTANT20)
tw, th = View.draw.textsize(line3, font=View.ASSISTANT20)
View.draw.text(((240-tw)/2, 130), line3, fill=View.color, font=View.ASSISTANT20)
tw, th = View.draw.textsize(line4, font=View.ASSISTANT20)
View.draw.text(((240-tw)/2, 160), line4, fill=View.color, font=View.ASSISTANT20)
View.DispShowImage()
def display_blank_screen(self):
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
View.DispShowImage()
###
### Reusable components
###
def render_previous_button(self, highlight=False):
# Set up the "back" arrow in the upper left
arrow = "<"
word_font = View.ROBOTOCONDENSED_BOLD_26
top_padding = -3
bottom_padding = 3
side_padding = 3
tw, th = word_font.getsize(arrow)
self.previous_button_width = tw + 2 * side_padding
if highlight:
font_color = "black"
background_color = View.color
out = self.View_cls.__name__
if self.view_args:
out += f"({self.view_args})"
else:
font_color = View.color
background_color = "black"
View.draw.rectangle((0,0, self.previous_button_width, th + top_padding + bottom_padding), fill=background_color)
View.draw.text((side_padding, top_padding), arrow, fill=font_color, font=word_font)
out += "()"
if self.clear_history:
out += f" | clear_history: {self.clear_history}"
return out
def run(self):
if not self.view_args:
# Can't unpack (**) None so we replace with an empty dict
self.view_args = {}
# Instantiate the `View_cls` and run() it with the `view_args` dict
return self.View_cls(**self.view_args).run()
#########################################################################################
#
# Root level Views don't have a sub-module home so they live at the top level here.
#
#########################################################################################
class MainMenuView(View):
def run(self):
from .seed_views import SeedsMenuView
from .settings_views import SettingsMenuView
from .scan_views import ScanView
from .tools_views import ToolsMenuView
from seedsigner.gui.screens import LargeButtonScreen
menu_items = [
(("Scan", FontAwesomeIconConstants.QRCODE), ScanView),
(("Seeds", FontAwesomeIconConstants.KEY), SeedsMenuView),
(("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH), ToolsMenuView),
(("Settings", FontAwesomeIconConstants.GEAR), SettingsMenuView),
]
screen = LargeButtonScreen(
title="Home",
title_font_size=26,
button_data=[entry[0] for entry in menu_items],
show_top_nav_left_button=False,
show_top_nav_right_button=True,
)
selected_menu_num = screen.display()
if selected_menu_num == RET_CODE__POWER_BUTTON:
return Destination(PowerOptionsView)
return Destination(menu_items[selected_menu_num][1])
class PowerOptionsView(View):
def run(self):
RESET = ("Reset", FontAwesomeIconConstants.ROTATE_RIGHT)
POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF)
button_data = [RESET, POWER_OFF]
selected_menu_num = LargeButtonScreen(
title="Reset / Power",
show_top_nav_left_button=True,
button_data=button_data
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == RESET:
return Destination(ResetView)
elif button_data[selected_menu_num] == POWER_OFF:
return Destination(PowerOffView)
class ResetView(View):
def run(self):
thread = ResetView.DoResetThread()
thread.start()
TextTopNavScreen(
title="Resetting",
text="SeedSigner is restarting. All data will be wiped.",
show_top_nav_left_button=False,
).display()
class DoResetThread(BaseThread):
def run(self):
import time
from subprocess import call
while self.keep_running:
time.sleep(5)
# Kill the SeedSigner process; systemd will automatically restart it.
call("kill $(ps aux | grep '[p]ython3 main.py' | awk '{print $2}')", shell=True)
class PowerOffView(View):
def run(self):
thread = PowerOffView.PowerOffThread()
thread.start()
TextTopNavScreen(
title="Powering Off",
text="Please wait about 30 seconds before disconnecting power.",
show_top_nav_left_button=False,
).display()
class PowerOffThread(BaseThread):
def run(self):
import time
from subprocess import call
while self.keep_running:
time.sleep(5)
call("sudo shutdown --poweroff now", shell=True)
class NotYetImplementedView(View):
"""
Temporary View to use during dev.
"""
def run(self):
WarningScreen(
title="Work In Progress",
warning_headline="Not Yet Implemented",
warning_text="This is still on our to-do list!",
button_label="Back to Main Menu",
).display()
return Destination(MainMenuView)
-16
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17 -16
View File
File diff suppressed because one or more lines are too long
+19 -18
View File
@@ -6,6 +6,7 @@ from seedsigner.helpers.ur2.bytewords import decode
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.qr_type import QRType
from seedsigner.models.settings import SettingsConstants
def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
@@ -16,7 +17,7 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
assert len(seed_phrase) == mnemonic_length
e = EncodeQR(seed_phrase=seed_phrase, qr_type=qr_type, wordlist=bip39.WORDLIST)
data = e.nextPart()
data = e.next_part()
print(data)
qr = QR()
@@ -27,11 +28,11 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
border=3
)
decoder = DecodeQR(wordlist=bip39.WORDLIST)
status = decoder.addImage(image)
decoder = DecodeQR(wordlist_language_code=SettingsConstants.LANGUAGE__ENGLISH)
status = decoder.add_image(image)
assert status == DecodeQRStatus.COMPLETE
decoded_seed_phrase = decoder.getSeedPhrase()
decoded_seed_phrase = decoder.get_seed_phrase()
print(decoded_seed_phrase)
assert seed_phrase == decoded_seed_phrase
@@ -42,10 +43,10 @@ def test_standard_seedqr_encode_decode_():
them back again to their original mnemonic seed phrase.
"""
# 24-word seed
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEEDQR)
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEED__SEEDQR)
# 12-word seed
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEEDQR)
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEED__SEEDQR)
@@ -54,10 +55,10 @@ def test_compact_seedqr_encode_decode():
them back again to their original mnemonic seed phrase.
"""
# 24-word seed
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
# 12-word seed
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
@@ -67,40 +68,40 @@ def test_compact_seedqr_handles_null_bytes():
"""
# 24-word seed, null bytes at the front
entropy = b'\x00' + os.urandom(31)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
# 24-word seed, null bytes in the middle
entropy = os.urandom(10) + b'\x00' + os.urandom(21)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
# 24-word seed, null bytes at the end
entropy = os.urandom(31) + b'\x00'
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
# 24-word seed, multiple null bytes
entropy = os.urandom(5) + b'\x00' + os.urandom(5) + b'\x00' + os.urandom(20)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
# 24-word seed, multiple null bytes in a row
entropy = os.urandom(10) + b'\x00\x00' + os.urandom(20)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
# 12-word seed, null bytes at the beginning
entropy = b'\x00' + os.urandom(15)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
# 12-word seed, null bytes in the middle
entropy = os.urandom(5) + b'\x00' + os.urandom(10)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
# 12-word seed, null bytes at the end
entropy = os.urandom(15) + b'\x00'
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
# 12-word seed, multiple null bytes
entropy = os.urandom(5) + b'\x00' + os.urandom(5) + b'\x00' + os.urandom(4)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
# 12-word seed, multiple null bytes in a row
entropy = os.urandom(10) + b'\x00\x00' + os.urandom(4)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)