Merge pull request #183 from SeedSigner/0.5.0

Merge 0.5.0 Upgrade into main
This commit is contained in:
SeedSigner
2022-04-19 09:43:27 -05:00
committed by GitHub
92 changed files with 11423 additions and 6026 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.
+70
View File
@@ -0,0 +1,70 @@
# 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
* Settings: I/O Test
* 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
* Further existing screen refinement
* "Final" bugfixes
## 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(?)
* Sign taproot txs
* 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
* Specify missing entropy for 12th/24th word calc
# v0.6 and Beyond...?
* Alternate hardware profile / touchscreen
* PGP signer
* Liquid?
+4 -2
View File
@@ -190,14 +190,16 @@ Description=Seedsigner
[Service]
User=pi
WorkingDirectory=/home/pi/seedsigner/src/
ExecStart=/home/pi/.envs/seedsigner-env/bin/python main.py
ExecStart=/home/pi/.envs/seedsigner-env/bin/python3 main.py > /dev/null 2>&1
Restart=always
[Install]
WantedBy=multi-user.target
```
_Note: If you'll be testing new code on the SeedSigner, you'll want to omit the `Restart=always` line._
_Note: For local dev you'll want to edit the `Restart=always` line to `Restart=no`. This way when your dev code crashes it won't keep trying to restart itself. Note that the UI "Reset" will no longer work when auto-restarts are disabled._
_Note: Debugging output is completely wiped via routing the output to `/dev/null 2>&1`. When working in local dev, you're better off disabling the `systemd` SeedSigner service and just directly running the app so you can see all the debugging output live._
Use `CTRL-X` and `y` to exit and save changes.
+1 -1
View File
@@ -2,7 +2,7 @@ embit==0.4.10
numpy==1.21.1
picamera==1.13
Pillow==8.2.0
-e git+https://github.com/kdmukai/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar
-e git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar
qrcode==7.3.1
RPi.GPIO==0.7.0
six==1.16.0
+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()
View File
+231 -1088
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
from .renderer import Renderer
File diff suppressed because it is too large Load Diff
@@ -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.hardware.buttons import HardwareButtonsConstants
@@ -112,58 +114,79 @@ class Keyboard:
outline_color = "#333"
if not self.is_active:
rect_color = self.keyboard.background_color
font_color = "#666" # Show the letter but render as gray
rect_color = self.keyboard.deactivated_background_color
font_color = "#333" # Show the letter but render as gray
outline_color = self.keyboard.deactivated_background_color
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)
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.rounded_rectangle(
(
self.screen_x,
self.screen_y,
self.screen_x + self.keyboard.key_width * self.size - 1,
self.screen_y + self.keyboard.key_height
),
outline=outline_color,
fill=rect_color,
radius=4
)
# Fixed-width fonts will all have same height, ignoring below baseline (e.g. "Q" or "q")
(left, top, right, bottom) = font.getbbox("X", anchor="ls")
text_height = -1 * top
self.keyboard.draw.text(
(
self.screen_x + int(self.keyboard.key_width * self.size / 2),
self.screen_y + self.keyboard.key_height - int((self.keyboard.key_height - text_height)/2)
),
self.letter,
fill=font_color,
font=font,
anchor="ms"
)
def __init__(self,
draw,
def __init__(self,
draw: ImageDraw,
charset="1234567890abcdefghijklmnopqrstuvwxyz",
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_size=24,
selected_char="a",
rows=4,
cols=10,
rect=(0,40, 240,240),
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
self.cols = cols
self.rect = rect
if font:
self.font = font
else:
self.font = View.ROBOTOCONDENSED_REGULAR_24
self.font = Fonts.get_font(font_name, font_size)
self.auto_wrap = auto_wrap
self.background_color = "black"
self.highlight_color = View.color
self.background_color = GUIConstants.BUTTON_BACKGROUND_COLOR
self.deactivated_background_color = GUIConstants.BACKGROUND_COLOR
self.highlight_color = highlight_color
# Does the specified layout work?
additional_key_spaces = 0
@@ -177,13 +200,15 @@ 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
self.x_width = int((rect[2] - rect[0]) / cols) - self.x_gap
self.y_gap = 6
self.y_height = int((rect[3] - rect[1]) / rows) - self.y_gap
self.x_gap = 2
self.key_width = int((rect[2] - rect[0]) / cols) - self.x_gap
self.width = cols * (self.key_width) + (cols - 1) * self.x_gap
self.y_gap = 2
self.key_height = int((rect[3] - rect[1]) / rows) - self.y_gap
self.height = rows * (self.key_height) + (rows - 1) * self.y_gap
self.additional_key_entered_from_x = None
# Two-dimensional list of Key obj row data
@@ -209,12 +234,12 @@ class Keyboard:
is_selected=is_selected,
keyboard=self
))
cur_x += self.x_width + self.x_gap
cur_x += self.key_width + self.x_gap
cur_index_x += 1
self.keys.append(cur_row)
if i < rows -1:
if i < rows - 1:
# increment to the next row and continue
cur_y += self.y_height + self.y_gap
cur_y += self.key_height + self.y_gap
else:
# It's the last row; add the additional keys at the end
for additional_key in additional_keys:
@@ -229,7 +254,7 @@ class Keyboard:
size=additional_key["size"],
is_additional_key=True,
))
cur_x += self.x_width * additional_key["size"] + self.x_gap
cur_x += self.key_width * additional_key["size"] + self.x_gap
cur_index_x += additional_key["size"]
if render_now:
@@ -257,7 +282,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 +377,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"])
@@ -360,7 +385,7 @@ class Keyboard:
key.is_selected = False
key.render_key()
if input == B.KEY_RIGHT:
if input == HardwareButtonsConstants.KEY_RIGHT:
self.selected_key["x"] = key.index_x + key.size
new_key = self.get_key_at(self.selected_key["x"], self.selected_key["y"])
if new_key is None:
@@ -373,7 +398,7 @@ class Keyboard:
self.selected_key["x"] -= 1
return Keyboard.EXIT_RIGHT
elif input == B.KEY_LEFT:
elif input == HardwareButtonsConstants.KEY_LEFT:
key = self.get_selected_key()
self.selected_key["x"] = key.index_x - 1
if self.selected_key["x"] < 0:
@@ -386,14 +411,14 @@ class Keyboard:
self.selected_key["x"] += 1
return Keyboard.EXIT_LEFT
elif input == B.KEY_DOWN:
elif input == HardwareButtonsConstants.KEY_DOWN:
new_index_x, new_index_y, keyboard_exit = self.get_key_below(self.selected_key["x"], self.selected_key["y"])
self.selected_key["x"] = new_index_x
self.selected_key["y"] = new_index_y
if keyboard_exit:
return keyboard_exit
elif input == B.KEY_UP:
elif input == HardwareButtonsConstants.KEY_UP:
new_index_x, new_index_y, keyboard_exit = self.get_key_above(self.selected_key["x"], self.selected_key["y"])
self.selected_key["x"] = new_index_x
self.selected_key["y"] = new_index_y
@@ -477,29 +502,33 @@ 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_name: str = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME
font_size: int = 24
accent_color: str = GUIConstants.ACCENT_COLOR
background_color: str = GUIConstants.BUTTON_BACKGROUND_COLOR
cursor_mode: str = TextEntryDisplayConstants.CURSOR_MODE__BLOCK
is_centered: bool = True
has_outline: bool = False
cur_text: str = " "
text_offset = 0
def __post_init__(self):
self.font = Fonts.get_font(self.font_name, self.font_size)
@property
def width(self):
return self.rect[2] - self.rect[0]
@property
def height(self):
return self.rect[3] - self.rect[1]
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
@@ -508,20 +537,23 @@ class TextEntryDisplay(TextEntryDisplayConstants):
image = Image.new("RGB", (self.width + 1, self.height + 1), "black")
draw = ImageDraw.Draw(image)
if self.has_outline:
draw.rectangle((0, 0, self.width, self.height), fill="black", outline=self.font_color)
draw.rounded_rectangle((0, 0, self.width, self.height), fill=self.background_color, radius=4)
(left, top, right, bottom) = self.font.getbbox("X", anchor="ls") # measure from baseline
text_height = -1 * top # "top" is negative when measuring from baseline; ignoring below baseline
if self.cursor_mode == TextEntryDisplay.CURSOR_MODE__BLOCK:
cursor_block_width = 18
cursor_block_height = 33
# Draw n-1 of the selected letters
tw, th = self.font.getsize(self.cur_text[:-1])
(left, top, right, bottom) = self.font.getbbox(self.cur_text[:-1], anchor="ls")
text_width = right
if self.is_centered:
self.text_offset = int(self.width - tw - cursor_block_width)/2
self.text_offset = int(self.width - text_width - cursor_block_width)/2
else:
self.text_offset = 3
cursor_block_offset = self.text_offset + tw - 1
self.text_offset = GUIConstants.COMPONENT_PADDING
cursor_block_offset = self.text_offset + text_width - 1
if cursor_block_offset == 0:
cursor_block_offset = 1
@@ -530,22 +562,24 @@ class TextEntryDisplay(TextEntryDisplayConstants):
# Shift the display left
cursor_block_offset -= end_pos_x - self.width + 1
self.text_offset -= end_pos_x - self.width + 1
draw.text((self.text_offset, 3), self.cur_text[:-1], fill=self.font_color, font=self.font)
draw.text((self.text_offset, self.height - int(text_height/2)), self.cur_text[:-1], fill=GUIConstants.ACCENT_COLOR, font=self.font, anchor="ls")
# Draw the highlighted cursor block
draw.rectangle((cursor_block_offset, 1, cursor_block_offset + cursor_block_width, self.height - 1), fill="#111")
draw.text((cursor_block_offset + 1, 3), self.cur_text[-1], fill=self.font_color, font=self.font)
cursor_color = "#666"
draw.rectangle((cursor_block_offset, 1, cursor_block_offset + cursor_block_width, self.height - 1), fill=cursor_color)
draw.text((cursor_block_offset + 1, self.height - int(text_height/2)), self.cur_text[-1], fill=GUIConstants.ACCENT_COLOR, font=self.font, anchor="ls")
else:
cursor_bar_offset = 1
cursor_bar_serif_half_width = 4
tw, th = self.font.getsize(self.cur_text)
if self.is_centered:
# self.text_offset = int(self.width - tw)/2
raise Exception("Centered cursor bars not fully implemented")
end_pos_x = 3 + tw + cursor_bar_serif_half_width + 3
(left, top, right, bottom) = self.font.getbbox(cur_text if cur_text else "", anchor="ls") # measure from baseline
text_width = right
end_pos_x = 3 + text_width + cursor_bar_serif_half_width + 3
if end_pos_x < self.width:
# The entire cur_text plus the cursor bar fits
self.text_offset = 3 + cursor_bar_serif_half_width
@@ -558,7 +592,6 @@ class TextEntryDisplay(TextEntryDisplayConstants):
# Is the cursor at either extreme?
tw_left, th = self.font.getsize(self.cur_text[:cursor_position])
tw_right, th = self.font.getsize(self.cur_text[cursor_position:])
if self.text_offset + tw_left + cursor_bar_serif_half_width + 3 >= self.width:
# Cursor is at the extreme right; have to push the full tw_right off
@@ -571,7 +604,16 @@ class TextEntryDisplay(TextEntryDisplayConstants):
cursor_bar_x = self.text_offset + tw_left
draw.text((self.text_offset, 3), self.cur_text, fill=self.font_color, font=self.font)
draw.text(
(
self.text_offset,
self.height - int((self.height - text_height)/2)
),
self.cur_text,
fill=self.accent_color,
font=self.font,
anchor="ls"
)
# Render as an "I" bar
cursor_bar_color = "#ccc"
@@ -580,5 +622,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]))
+205
View File
@@ -0,0 +1,205 @@
from PIL import Image, ImageDraw, ImageFont
from threading import Lock
from seedsigner.gui.components import Fonts, GUIConstants
from seedsigner.hardware.ST7789 import ST7789
from seedsigner.models import ConfigurableSingleton
class Renderer(ConfigurableSingleton):
buttons = None
canvas_width = 0
canvas_height = 0
canvas: Image.Image = None
draw: ImageDraw.ImageDraw = None
disp = None
lock = Lock()
@classmethod
def configure_instance(cls):
from seedsigner.models.settings import Settings
# Instantiate the one and only Renderer instance
renderer = cls.__new__(cls)
cls._instance = renderer
# 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 alpha_overlay:
if image == None:
image = self.canvas
image = Image.alpha_composite(image, alpha_overlay)
if image:
# Always write to the current canvas, rather than trying to replace it
self.canvas.paste(image)
self.disp.ShowImage(self.canvas, 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)
# TODO: Remove all references
def show_image_with_text(self, image, text, font=None, text_color="GREY", text_background=None):
image_copy = image.copy().convert("RGBA")
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(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE)
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 *
+710
View File
@@ -0,0 +1,710 @@
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.models.threads import BaseThread
from .screen import ButtonListScreen, WarningScreen
from ..components import (BtcAmount, 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 + GUIConstants.COMPONENT_PADDING
if not self.destination_addresses:
# This is a self-transfer
spend_amount = self.change_amount
else:
spend_amount = self.spend_amount
self.components.append(BtcAmount(
total_sats=spend_amount,
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 + int(GUIConstants.COMPONENT_PADDING/2)
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)
(left, top, right, bottom) = font.getbbox(text="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890[]", anchor="lt")
chart_text_height = bottom
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) + self.num_self_transfer_outputs <= 3:
for addr in self.destination_addresses:
destination_column.append(truncate_destination_addr(addr))
for i in range(0, self.num_self_transfer_outputs):
destination_column.append(f"self-transfer")
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) + self.num_self_transfer_outputs}")
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) + self.num_self_transfer_outputs > 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,
anchor="lt",
)
# 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)
destination_y_spacing = 0
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,
anchor="lt"
)
# 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 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)
btc_amount = BtcAmount(
image_draw=draw,
canvas=center_img,
total_sats=self.amount,
screen_y=int(GUIConstants.COMPONENT_PADDING/2),
)
formatted_address = FormattedAddress(
image_draw=draw,
canvas=center_img,
width=self.canvas_width - 2*GUIConstants.EDGE_PADDING,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=btc_amount.height + GUIConstants.COMPONENT_PADDING,
font_size=24,
address=self.address,
)
# Render each to the temp img we passed in
btc_amount.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(BtcAmount(
total_sats=self.amount,
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,
))
@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=GUIConstants.SUCCESS_COLOR,
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,
))
+160
View File
@@ -0,0 +1,160 @@
import time
from dataclasses import dataclass
from typing import List, Tuple
from seedsigner.gui import renderer
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.hardware.camera import Camera
from seedsigner.models import DecodeQR, DecodeQRStatus
from seedsigner.models.threads import BaseThread
from .screen import BaseScreen, BaseTopNavScreen, ButtonListScreen
from ..components import BaseComponent, Button, GUIConstants, Fonts, IconButton, TextArea, calc_text_centering
@dataclass
class ScanScreen(BaseScreen):
decoder: DecodeQR = None
instructions_text: str = "Scan a QR code"
resolution: Tuple[int,int] = (480, 480)
framerate: int = 12
render_rect: Tuple[int,int,int,int] = None
def __post_init__(self):
from seedsigner.hardware.camera import Camera
# Initialize the base class
super().__post_init__()
self.camera = Camera.get_instance()
self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb")
self.threads.append(ScanScreen.LivePreviewThread(
camera=self.camera,
decoder=self.decoder,
renderer=self.renderer,
instructions_text=self.instructions_text,
render_rect=self.render_rect,
))
class LivePreviewThread(BaseThread):
def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: Tuple[int,int,int,int]):
self.camera = camera
self.decoder = decoder
self.renderer = renderer
self.instructions_text = instructions_text
if render_rect:
self.render_rect = render_rect
else:
self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height)
self.render_width = self.render_rect[2] - self.render_rect[0]
self.render_height = self.render_rect[3] - self.render_rect[1]
print(f"render_width: {self.render_width}")
print(f"render_height: {self.render_height}")
super().__init__()
def run(self):
from timeit import default_timer as timer
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
while self.keep_running:
start = timer()
frame = self.camera.read_video_stream(as_image=True)
if frame is not None:
scan_text = self.instructions_text
if self.decoder and self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt:
scan_text = str(self.decoder.get_percent_complete()) + "% Complete"
with self.renderer.lock:
if frame.width > self.render_width or frame.height > self.render_height:
frame = frame.resize(
(self.render_width, self.render_height)
)
self.renderer.canvas.paste(
frame,
(self.render_rect[0], self.render_rect[1])
)
if scan_text:
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text=scan_text,
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
self.renderer.show_image()
end = timer()
# print(f"{1.0/(end - start)} fps") # Time in seconds, e.g. 5.38091952400282
time.sleep(0.05) # turn this up or down to tune performance while decoding psbt
if self.camera._video_stream is None:
break
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.
"""
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(HardwareButtonsConstants.KEY_RIGHT) or self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
self.camera.stop_video_stream_mode()
break
@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
))
+873
View File
@@ -0,0 +1,873 @@
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.models.threads import BaseThread
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.settings import Settings, SettingsConstants
from ..components import (GUIConstants, BaseComponent, Button, Icon, LargeIconButton, SeedSignerCustomIconConstants, TopNav,
TextArea, load_image)
from seedsigner.hardware.buttons import HardwareButtonsConstants, HardwareButtons
# 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 = HardwareButtons.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:
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_callback(self):
"""
Optional implementation step that's called during each _run() loop.
Loop will continue if it returns None.
If it returns a value, the Screen will exit and relay that return value to
its parent View.
"""
pass
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):
top_nav_icon_name: str = None
top_nav_icon_color: str = None
title: str = "Screen Title"
title_font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE
show_back_button: bool = True
show_power_button: bool = False
def __post_init__(self):
super().__post_init__()
self.top_nav = TopNav(
icon_name=self.top_nav_icon_name,
icon_color=self.top_nav_icon_color,
text=self.title,
font_size=self.title_font_size,
width=self.canvas_width,
height=GUIConstants.TOP_NAV_HEIGHT,
show_back_button=self.show_back_button,
show_power_button=self.show_power_button,
)
self.is_input_in_top_nav = False
self.components.append(self.top_nav)
def _run(self):
while True:
if not self.top_nav.show_back_button and not self.top_nav.show_power_button:
# There's no navigation away from this screen; nothing to do here
time.sleep(0.1)
continue
user_input = self.hw_inputs.wait_for(
HardwareButtonsConstants.ALL_KEYS,
check_release=True,
release_keys=HardwareButtonsConstants.KEYS__ANYCLICK
)
with self.renderer.lock:
if not self.top_nav.is_selected and user_input in [
HardwareButtonsConstants.KEY_LEFT,
HardwareButtonsConstants.KEY_UP
]:
self.top_nav.is_selected = True
self.top_nav.render_buttons()
elif self.top_nav.is_selected and user_input in [
HardwareButtonsConstants.KEY_DOWN,
HardwareButtonsConstants.KEY_RIGHT
]:
self.top_nav.is_selected = False
self.top_nav.render_buttons()
elif self.top_nav.is_selected and user_input in HardwareButtonsConstants.KEYS__ANYCLICK:
return self.top_nav.selected_button
else:
# Nothing to do with this input
continue
# 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):
icon_name = None
icon_color = None
right_icon_name = None
button_label_color = None
# TODO: Define an actual class for 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
elif len(button_label) == 4:
(button_label, icon_name, icon_color, button_label_color) = button_label
elif len(button_label) == 5:
(button_label, icon_name, icon_color, button_label_color, right_icon_name) = button_label
button_kwargs = dict(
text=button_label,
icon_name=icon_name,
icon_color=icon_color if icon_color else GUIConstants.BUTTON_FONT_COLOR,
is_icon_inline=True,
right_icon_name=right_icon_name,
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,
font_color=button_label_color if button_label_color else GUIConstants.BUTTON_FONT_COLOR,
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:
ret = self._run_callback()
if ret is not None:
return ret
user_input = self.hw_inputs.wait_for(
[
HardwareButtonsConstants.KEY_UP,
HardwareButtonsConstants.KEY_DOWN,
HardwareButtonsConstants.KEY_LEFT,
HardwareButtonsConstants.KEY_RIGHT,
] + HardwareButtonsConstants.KEYS__ANYCLICK,
check_release=True,
release_keys=HardwareButtonsConstants.KEYS__ANYCLICK
)
with self.renderer.lock:
if not self.top_nav.is_selected and (
user_input == HardwareButtonsConstants.KEY_LEFT or (
user_input == HardwareButtonsConstants.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_back_button or self.top_nav.show_power_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_buttons()
elif user_input == HardwareButtonsConstants.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 == HardwareButtonsConstants.KEY_DOWN or (
self.top_nav.is_selected and user_input == HardwareButtonsConstants.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_buttons()
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 in HardwareButtonsConstants.KEYS__ANYCLICK:
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
selected_button: int = 0
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[self.selected_button].is_selected = True
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:
ret = self._run_callback()
if ret is not None:
return ret
user_input = self.hw_inputs.wait_for(
[
HardwareButtonsConstants.KEY_UP,
HardwareButtonsConstants.KEY_DOWN,
HardwareButtonsConstants.KEY_LEFT,
HardwareButtonsConstants.KEY_RIGHT
] + HardwareButtonsConstants.KEYS__ANYCLICK,
check_release=True,
release_keys=HardwareButtonsConstants.KEYS__ANYCLICK
)
with self.renderer.lock:
if user_input == HardwareButtonsConstants.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_buttons()
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 == HardwareButtonsConstants.KEY_DOWN:
if self.top_nav.is_selected:
self.top_nav.is_selected = False
self.top_nav.render_buttons()
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 == HardwareButtonsConstants.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 == HardwareButtonsConstants.KEY_RIGHT and
self.top_nav.is_selected and not self.top_nav.show_power_button
):
self.top_nav.is_selected = False
self.top_nav.render_buttons()
self.buttons[self.selected_button].is_selected = True
self.buttons[self.selected_button].render()
elif user_input == HardwareButtonsConstants.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_back_button:
self.top_nav.is_selected = True
self.top_nav.render_buttons()
self.buttons[self.selected_button].is_selected = False
self.buttons[self.selected_button].render()
elif user_input in HardwareButtonsConstants.KEYS__ANYCLICK:
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)
# Loop whether the QR is a single frame or animated; each loop might adjust
# brightness setting.
while True:
ret = self._run_callback()
if ret is not None:
return ret
# 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(HardwareButtonsConstants.KEY_DOWN):
# Reduce QR code background brightness
cur_brightness = max(31, cur_brightness - 31)
elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_UP):
# Incrase QR code background brightness
cur_brightness = min(cur_brightness + 31, 255)
elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT):
break
settings.set_value(SettingsConstants.SETTING__QR_BRIGHTNESS, cur_brightness)
# TODO: handle left as BACK
@dataclass
class LargeIconStatusScreen(ButtonListScreen):
title: str = "Success!"
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_CHECK
status_icon_size: int = GUIConstants.ICON_PRIMARY_SCREEN_SIZE
status_color: str = GUIConstants.SUCCESS_COLOR
status_headline: str = "Success!" # The colored text under the large icon
text: str = "" # The body text of the screen
button_data: list = None
allow_text_overflow: bool = False
def __post_init__(self):
self.is_bottom_list: bool = True
if not self.button_data:
self.button_data = ["OK"]
super().__post_init__()
self.status_icon = Icon(
icon_name=self.status_icon_name,
icon_size=self.status_icon_size,
icon_color=self.status_color,
)
self.status_icon.screen_y = self.top_nav.height - int(GUIConstants.COMPONENT_PADDING/2)
self.status_icon.screen_x = int((self.canvas_width - self.status_icon.width) / 2)
self.components.append(self.status_icon)
next_y = self.status_icon.screen_y + self.status_icon.height + 4
if self.status_headline:
self.warning_headline_textarea = TextArea(
text=self.status_headline,
width=self.canvas_width,
screen_y=next_y,
font_color=self.status_color,
allow_text_overflow=self.allow_text_overflow,
)
self.components.append(self.warning_headline_textarea)
next_y = next_y + self.warning_headline_textarea.height
self.components.append(TextArea(
height=self.buttons[0].screen_y - next_y,
text=self.text,
width=self.canvas_width,
screen_y=next_y,
allow_text_overflow=self.allow_text_overflow,
))
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.status_color)
def render_border(color, width):
screen.image_draw.rectangle(
(0, 0, screen.canvas_width, screen.canvas_height),
fill=None,
outline=color,
width=width,
# radius=5
)
try:
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)
except KeyboardInterrupt as e:
self.stop()
raise e
@dataclass
class WarningEdgesMixin:
status_color: str = GUIConstants.WARNING_COLOR
def __post_init__(self):
super().__post_init__()
self.threads.append(WarningEdgesThread(args=(self,)))
@dataclass
class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
title: str = "Caution"
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
status_color: str = "yellow"
status_headline: str = "Privacy Leak!" # The colored text under the alert icon
def __post_init__(self):
if not self.button_data:
self.button_data = ["I Understand"]
super().__post_init__()
@dataclass
class DireWarningScreen(WarningScreen):
status_headline: str = "Classified Info!" # The colored text under the alert icon
status_color: str = GUIConstants.DIRE_WARNING_COLOR
@dataclass
class ResetScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "Restarting"
self.show_back_button = False
super().__post_init__()
self.components.append(TextArea(
text="SeedSigner is restarting.\n\nAll in-memory data will be wiped.",
screen_y=self.top_nav.height,
height=self.canvas_height - self.top_nav.height,
))
@dataclass
class PowerOffScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "Powering Off"
self.show_back_button = False
super().__post_init__()
self.components.append(TextArea(
text="Please wait about 30 seconds before disconnecting power.",
screen_y=self.top_nav.height,
height=self.canvas_height - self.top_nav.height,
))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
import time
from dataclasses import dataclass
from PIL.ImageOps import autocontrast
from typing import List
from seedsigner.gui.components import Button, CheckboxButton, CheckedSelectionButton, FontAwesomeIconConstants, Fonts, GUIConstants, Icon, IconButton, IconTextLine, TextArea
from seedsigner.gui.screens.scan_screens import ScanScreen
from seedsigner.gui.screens.screen import BaseScreen, BaseTopNavScreen, ButtonListScreen
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.hardware.camera import Camera
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.components.append(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
))
if self.help_text:
prev_component_bottom = self.components[-1].screen_y + self.components[-1].height
self.components.append(TextArea(
text=self.help_text,
font_color=GUIConstants.LABEL_FONT_COLOR,
is_text_centered=True,
screen_y=prev_component_bottom + GUIConstants.COMPONENT_PADDING,
))
@dataclass
class IOTestScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "I/O Test"
self.show_back_button = False
self.resolution = (96, 96)
self.framerate = 10
self.instructions_text = None
super().__post_init__()
# D-pad pictogram
input_button_width = GUIConstants.BUTTON_HEIGHT + 2
input_button_height = input_button_width + 2
dpad_center_x = GUIConstants.EDGE_PADDING + input_button_width + GUIConstants.COMPONENT_PADDING
dpad_center_y = int((self.canvas_height - input_button_height)/2)
self.joystick_click_button = IconButton(
icon_name=FontAwesomeIconConstants.CIRCLE,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE - 6,
width=input_button_width,
height=input_button_height,
screen_x=dpad_center_x,
screen_y=dpad_center_y,
outline_color=GUIConstants.ACCENT_COLOR,
)
self.components.append(self.joystick_click_button)
self.joystick_up_button = IconButton(
icon_name=FontAwesomeIconConstants.ANGLE_UP,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
width=input_button_width,
height=input_button_height,
screen_x=dpad_center_x,
screen_y=dpad_center_y - input_button_height - GUIConstants.COMPONENT_PADDING,
outline_color=GUIConstants.ACCENT_COLOR,
)
self.components.append(self.joystick_up_button)
self.joystick_down_button = IconButton(
icon_name=FontAwesomeIconConstants.ANGLE_DOWN,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
width=input_button_width,
height=input_button_height,
screen_x=dpad_center_x,
screen_y=dpad_center_y + input_button_height + GUIConstants.COMPONENT_PADDING,
outline_color=GUIConstants.ACCENT_COLOR,
)
self.components.append(self.joystick_down_button)
self.joystick_left_button = IconButton(
text=FontAwesomeIconConstants.ANGLE_LEFT,
font_name=GUIConstants.ICON_FONT_NAME__FONT_AWESOME,
font_size=GUIConstants.ICON_INLINE_FONT_SIZE,
width=input_button_width,
height=input_button_height,
screen_x=dpad_center_x - input_button_width - GUIConstants.COMPONENT_PADDING,
screen_y=dpad_center_y,
outline_color=GUIConstants.ACCENT_COLOR,
)
self.components.append(self.joystick_left_button)
self.joystick_right_button = IconButton(
icon_name=FontAwesomeIconConstants.ANGLE_RIGHT,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
width=input_button_width,
height=input_button_height,
screen_x=dpad_center_x + input_button_width + GUIConstants.COMPONENT_PADDING,
screen_y=dpad_center_y,
outline_color=GUIConstants.ACCENT_COLOR,
)
self.components.append(self.joystick_right_button)
# Hardware keys UI
font = Fonts.get_font(GUIConstants.BUTTON_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
(left, top, text_width, bottom) = font.getbbox(text="Clear", anchor="ls")
icon = Icon(
icon_name=FontAwesomeIconConstants.CAMERA,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
)
key_button_width = text_width + 2*GUIConstants.COMPONENT_PADDING + GUIConstants.EDGE_PADDING
key_button_height = icon.height + int(1.5*GUIConstants.COMPONENT_PADDING)
key2_y = int(self.canvas_height/2) - int(key_button_height/2)
self.key2_button = Button(
text="Clear", # Initialize with text to set vertical centering
width=key_button_width,
height=key_button_height,
screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING,
screen_y=key2_y,
outline_color=GUIConstants.ACCENT_COLOR,
)
self.key2_button.text = " " # but default state is empty
self.components.append(self.key2_button)
self.key1_button = IconButton(
icon_name=FontAwesomeIconConstants.CAMERA,
width=key_button_width,
height=key_button_height,
screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING,
screen_y=key2_y - 3*GUIConstants.COMPONENT_PADDING - key_button_height,
outline_color=GUIConstants.ACCENT_COLOR,
)
self.components.append(self.key1_button)
self.key3_button = Button(
text="Exit",
width=key_button_width,
height=key_button_height,
screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING,
screen_y=key2_y + 3*GUIConstants.COMPONENT_PADDING + key_button_height,
outline_color=GUIConstants.ACCENT_COLOR,
)
self.components.append(self.key3_button)
def _run(self):
cur_selected_button = self.key1_button
msg_height = GUIConstants.ICON_LARGE_BUTTON_SIZE + 2*GUIConstants.COMPONENT_PADDING
camera_message = TextArea(
text="Capturing image...",
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
is_text_centered=True,
height=msg_height,
screen_y=int((self.canvas_height - msg_height)/ 2),
)
while True:
input = self.hw_inputs.wait_for(keys=HardwareButtonsConstants.ALL_KEYS, check_release=False)
if input == HardwareButtonsConstants.KEY1:
cur_selected_button = self.key1_button
with self.renderer.lock:
cur_selected_button.is_selected = True
cur_selected_button.render()
camera_message.render()
# Render edges around message box
self.image_draw.rectangle(
(
-1, int((self.canvas_height - msg_height)/ 2) - 1,
self.canvas_width + 1, int((self.canvas_height + msg_height)/ 2) + 1
),
outline=GUIConstants.ACCENT_COLOR,
width=1,
)
self.renderer.show_image()
# Snap a pic, render it as the background, re-render all onscreen elements
camera = Camera.get_instance()
try:
camera.start_single_frame_mode(resolution=(self.canvas_width, self.canvas_height))
# Reset the button state
with self.renderer.lock:
cur_selected_button.is_selected = False
cur_selected_button.render()
self.renderer.show_image()
time.sleep(0.25)
background_frame = camera.capture_frame()
display_version = autocontrast(
background_frame,
cutoff=2
)
with self.renderer.lock:
self.canvas.paste(display_version, (0, self.top_nav.height))
self.key2_button.text = "Clear"
for component in self.components:
component.render()
self.renderer.show_image()
finally:
camera.stop_single_frame_mode()
continue
elif input == HardwareButtonsConstants.KEY2:
cur_selected_button = self.key2_button
# Clear the background
with self.renderer.lock:
cur_selected_button.is_selected = True
self._render()
self.renderer.show_image()
# And then re-render Key2 in its initial state
self.key2_button.text = " "
cur_selected_button.is_selected = False
cur_selected_button.render()
self.renderer.show_image()
continue
elif input == HardwareButtonsConstants.KEY3:
# Exit
cur_selected_button = self.key3_button
cur_selected_button.is_selected = True
with self.renderer.lock:
cur_selected_button.render()
self.renderer.show_image()
return
elif input == HardwareButtonsConstants.KEY_PRESS:
cur_selected_button = self.joystick_click_button
elif input == HardwareButtonsConstants.KEY_UP:
cur_selected_button = self.joystick_up_button
elif input == HardwareButtonsConstants.KEY_DOWN:
cur_selected_button = self.joystick_down_button
elif input == HardwareButtonsConstants.KEY_LEFT:
cur_selected_button = self.joystick_left_button
elif input == HardwareButtonsConstants.KEY_RIGHT:
cur_selected_button = self.joystick_right_button
with self.renderer.lock:
cur_selected_button.is_selected = True
cur_selected_button.render()
self.renderer.show_image()
with self.renderer.lock:
cur_selected_button.is_selected = False
cur_selected_button.render()
self.renderer.show_image()
time.sleep(0.1)
@dataclass
class DonateScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "Donate"
super().__post_init__()
self.components.append(TextArea(
text="SeedSigner is 100% free & open source, funded solely by the Bitcoin community.\n\nDonate onchain or LN at:",
screen_y=self.top_nav.height + 3*GUIConstants.COMPONENT_PADDING,
))
self.components.append(TextArea(
text="seedsigner.com",
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8,
font_color=GUIConstants.ACCENT_COLOR,
supersampling_factor=1,
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
))
+291
View File
@@ -0,0 +1,291 @@
from dataclasses import dataclass
from PIL.Image import Image
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
from seedsigner.hardware.camera import Camera
from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerCustomIconConstants, TextArea
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen
from seedsigner.hardware.buttons import HardwareButtonsConstants
@dataclass
class ToolsImageEntropyLivePreviewScreen(BaseScreen):
def __post_init__(self):
# Customize defaults
self.title = "Initializing Camera..."
# Initialize the base class
super().__post_init__()
self.camera = Camera.get_instance()
self.camera.start_video_stream_mode(resolution=(240, 240), framerate=24, format="rgb")
def _run(self):
# save preview image frames to use as additional entropy below
preview_images = []
max_entropy_frames = 50
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
while True:
frame = self.camera.read_video_stream(as_image=True)
if frame is not None:
self.renderer.canvas.paste(frame)
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text="< back | click joystick",
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
self.renderer.show_image()
if len(preview_images) < max_entropy_frames:
preview_images.append(frame)
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
# Have to manually update last input time since we're not in a wait_for loop
self.hw_inputs.update_last_input_time()
self.words = []
self.camera.stop_video_stream_mode()
return RET_CODE__BACK_BUTTON
elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_PRESS):
# Have to manually update last input time since we're not in a wait_for loop
self.hw_inputs.update_last_input_time()
self.camera.stop_video_stream_mode()
self.renderer.canvas.paste(frame)
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text="Capturing image...",
fill=GUIConstants.ACCENT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
self.renderer.show_image()
return preview_images
@dataclass
class ToolsImageEntropyFinalImageScreen(BaseScreen):
final_image: Image = None
def _run(self):
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
self.renderer.canvas.paste(self.final_image)
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text=" < reshoot | accept > ",
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
self.renderer.show_image()
input = self.hw_inputs.wait_for([HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT])
if input == HardwareButtonsConstants.KEY_LEFT:
return RET_CODE__BACK_BUTTON
@dataclass
class ToolsDiceEntropyEntryScreen(BaseTopNavScreen):
total_rolls: int = None
def __post_init__(self):
self.title = f"Dice Roll 1/{self.total_rolls}"
super().__post_init__()
self.dice_rolls = ""
# Set up the keyboard params
keyboard_width = self.canvas_width - 2*GUIConstants.EDGE_PADDING
text_entry_display_y = self.top_nav.height
text_entry_display_height = 30
keyboard_start_y = text_entry_display_y + text_entry_display_height + GUIConstants.COMPONENT_PADDING
rows = 3
button_height = int((self.canvas_height - GUIConstants.EDGE_PADDING - text_entry_display_y - text_entry_display_height - GUIConstants.COMPONENT_PADDING - (rows - 1) * 2) / rows)
self.keyboard_digits = Keyboard(
draw=self.renderer.draw,
charset="".join([
FontAwesomeIconConstants.DICE_ONE,
FontAwesomeIconConstants.DICE_TWO,
FontAwesomeIconConstants.DICE_THREE,
FontAwesomeIconConstants.DICE_FOUR,
FontAwesomeIconConstants.DICE_FIVE,
FontAwesomeIconConstants.DICE_SIX,
]),
font_name=GUIConstants.ICON_FONT_NAME__FONT_AWESOME,
font_size=button_height - GUIConstants.COMPONENT_PADDING,
rows=rows,
cols=3,
rect=(
GUIConstants.EDGE_PADDING,
keyboard_start_y,
GUIConstants.EDGE_PADDING + keyboard_width,
keyboard_start_y + rows * button_height + (rows - 1) * 2
),
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
self.keyboard_digits.set_selected_key(selected_letter=FontAwesomeIconConstants.DICE_ONE)
self.text_entry_display = TextEntryDisplay(
canvas=self.renderer.canvas,
rect=(
GUIConstants.EDGE_PADDING,
text_entry_display_y,
self.canvas_width - GUIConstants.EDGE_PADDING,
text_entry_display_y + text_entry_display_height
),
cursor_mode=TextEntryDisplay.CURSOR_MODE__BAR,
is_centered=False,
)
def _render(self):
super()._render()
self.keyboard_digits.render_keys()
self.text_entry_display.render()
self.renderer.show_image()
def _run(self):
cursor_position = len(self.dice_rolls)
# Start the interactive update loop
while True:
input = self.hw_inputs.wait_for(
HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN + [HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY3],
check_release=True,
release_keys=[HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY3]
)
# Check possible exit condition
if self.top_nav.is_selected and input == HardwareButtonsConstants.KEY_PRESS:
return RET_CODE__BACK_BUTTON
# Process normal input
if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.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_buttons()
# Override the actual input w/an ENTER signal for the Keyboard
if input == HardwareButtonsConstants.KEY_DOWN:
input = Keyboard.ENTER_TOP
else:
input = Keyboard.ENTER_BOTTOM
elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.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_buttons()
elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS:
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
if len(self.dice_rolls) > 0:
self.dice_rolls = self.dice_rolls[:-1]
cursor_position -= 1
elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
# User has locked in the current letter
if ret_val == FontAwesomeIconConstants.DICE_ONE:
ret_val = "1"
elif ret_val == FontAwesomeIconConstants.DICE_TWO:
ret_val = "2"
elif ret_val == FontAwesomeIconConstants.DICE_THREE:
ret_val = "3"
elif ret_val == FontAwesomeIconConstants.DICE_FOUR:
ret_val = "4"
elif ret_val == FontAwesomeIconConstants.DICE_FIVE:
ret_val = "5"
elif ret_val == FontAwesomeIconConstants.DICE_SIX:
ret_val = "6"
self.dice_rolls += ret_val
cursor_position += 1
if cursor_position == self.total_rolls:
return self.dice_rolls
# Render a new TextArea over the TopNav title bar
TextArea(
text=f"Dice Roll {cursor_position + 1}/{self.total_rolls}",
font_name=GUIConstants.TOP_NAV_TITLE_FONT_NAME,
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
height=self.top_nav.height,
).render()
self.top_nav.render_buttons()
elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_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.dice_rolls)
self.renderer.show_image()
@dataclass
class ToolsCalcFinalWordShowFinalWordScreen(ButtonListScreen):
final_word: str = None
mnemonic_word_length: int = 12
fingerprint: str = None
def __post_init__(self):
# Customize defaults
self.title = f"{self.mnemonic_word_length}th Word"
self.is_bottom_list = True
super().__post_init__()
self.components.append(TextArea(
text=f"""\"{self.final_word}\"""",
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 6,
is_text_centered=True,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
))
self.components.append(IconTextLine(
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
icon_color="blue",
label_text="fingerprint",
value_text=self.fingerprint,
is_text_centered=True,
screen_y=self.components[-1].screen_y + self.components[-1].height + 3*GUIConstants.COMPONENT_PADDING,
))
@@ -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()
View File
@@ -1,9 +1,10 @@
import RPi.GPIO as GPIO
import time
from seedsigner.models.singleton import Singleton
class Buttons:
class HardwareButtons(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(HardwareButtons.KEY_UP_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(HardwareButtons.KEY_DOWN_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(HardwareButtons.KEY_LEFT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(HardwareButtons.KEY_RIGHT_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(HardwareButtons.KEY_PRESS_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(HardwareButtons.KEY1_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(HardwareButtons.KEY2_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Input with pull-up
GPIO.setup(HardwareButtons.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([HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN, HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT, HardwareButtonsConstants.KEY1, HardwareButtonsConstants.KEY2, HardwareButtonsConstants.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 = 225 # 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:
@@ -65,13 +74,13 @@ class Buttons:
continue
for key in keys:
if not check_release or ((check_release and key in release_keys and B.release_lock) or check_release and key not in release_keys):
if not check_release or ((check_release and key in release_keys and HardwareButtonsConstants.release_lock) or check_release and key not in release_keys):
# when check release is False or the release lock is released (True)
if self.GPIO.input(key) == GPIO.LOW or self.override_ind:
B.release_lock = False
HardwareButtonsConstants.release_lock = False
if self.override_ind:
self.override_ind = False
return B.OVERRIDE
return HardwareButtonsConstants.OVERRIDE
if self.cur_input != key:
self.cur_input = key
@@ -116,16 +125,16 @@ class Buttons:
def add_events(self, keys=[]):
for key in keys:
GPIO.add_event_detect(key, self.GPIO.RISING, callback=Buttons.rising_callback)
GPIO.add_event_detect(key, self.GPIO.RISING, callback=HardwareButtons.rising_callback)
def rising_callback(channel):
B.release_lock = True
HardwareButtonsConstants.release_lock = True
def trigger_override(self, force_release = False) -> bool:
if force_release:
B.release_lock = True
HardwareButtonsConstants.release_lock = True
if not self.override_ind:
self.override_ind = True
@@ -133,7 +142,7 @@ class Buttons:
return False
def force_release(self) -> bool:
B.release_lock = True
HardwareButtonsConstants.release_lock = True
return True
def check_for_low(self, key) -> bool:
@@ -144,7 +153,7 @@ class Buttons:
return False
def has_any_input(self) -> bool:
for key in B.ALL_KEYS:
for key in HardwareButtonsConstants.ALL_KEYS:
if self.GPIO.input(key) == GPIO.LOW:
return True
return False
@@ -155,7 +164,7 @@ class Buttons:
# class used as short hand for static button/channel lookup values
# TODO: Implement `release_lock` functionality as a global somewhere. Mixes up design
# patterns to have a static constants class plus a settable global value.
class B:
class HardwareButtonsConstants:
KEY_UP = 6
KEY_DOWN = 19
KEY_LEFT = 5
@@ -177,4 +186,7 @@ class B:
KEY3,
]
KEYS__LEFT_RIGHT_UP_DOWN = [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN]
KEYS__ANYCLICK = [KEY_PRESS, KEY1, KEY2, KEY3]
release_lock = True # released when True, locked when False
@@ -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.hardware.pivideostream 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
-7
View File
@@ -1,7 +0,0 @@
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 *
-181
View File
@@ -1,181 +0,0 @@
### bc-ur encoding stuff
from io import BytesIO
import hashlib
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def bech32_polymod(values):
"""Internal function that computes the Bech32 checksum."""
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1FFFFFF) << 5 ^ value
for i in range(5):
chk ^= generator[i] if ((top >> i) & 1) else 0
return chk
def bech32_hrp_expand(hrp):
"""Expand the HRP into values for checksum computation."""
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
def bech32_verify_checksum(hrp, data):
"""Verify a checksum given HRP and converted data characters."""
return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1
def bech32_create_checksum(hrp, data):
"""Compute the checksum values given HRP and data."""
values = bech32_hrp_expand(hrp) + data
polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
def bech32_encode(hrp, data):
"""Compute a Bech32 string given HRP and data values."""
combined = data + bech32_create_checksum(hrp, data)
return hrp + "1" + "".join([CHARSET[d] for d in combined])
def bech32_decode(bech):
"""Validate a Bech32 string, and determine HRP and data."""
if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or (
bech.lower() != bech and bech.upper() != bech
):
return (None, None)
bech = bech.lower()
pos = bech.rfind("1")
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
return (None, None)
if not all(x in CHARSET for x in bech[pos + 1 :]):
return (None, None)
hrp = bech[:pos]
data = [CHARSET.find(x) for x in bech[pos + 1 :]]
if not bech32_verify_checksum(hrp, data):
return (None, None)
return (hrp, data[:-6])
def convertbits(data, frombits, tobits, pad=True):
"""General power-of-2 base conversion."""
acc = 0
bits = 0
ret = []
maxv = (1 << tobits) - 1
max_acc = (1 << (frombits + tobits - 1)) - 1
for value in data:
if value < 0 or (value >> frombits):
return None
acc = ((acc << frombits) | value) & max_acc
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad:
if bits:
ret.append((acc << (tobits - bits)) & maxv)
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
return None
return ret
def decode(hrp, addr):
"""Decode a segwit address."""
hrpgot, data = bech32_decode(addr)
if hrpgot != hrp:
return (None, None)
decoded = convertbits(data[1:], 5, 8, False)
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
return (None, None)
if data[0] > 16:
return (None, None)
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
return (None, None)
return (data[0], decoded)
def encode(hrp, witver, witprog):
"""Encode a segwit address."""
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5))
if decode(hrp, ret) == (None, None):
return None
return ret
def bc32encode(data: bytes) -> str:
"""
bc32 encoding
see https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-004-bc32.md
"""
dd = convertbits(data, 8, 5)
polymod = bech32_polymod([0] + dd + [0, 0, 0, 0, 0, 0]) ^ 0x3FFFFFFF
chk = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
return "".join([CHARSET[d] for d in dd + chk])
def bc32decode(bc32: str) -> bytes:
"""
bc32 decoding
see https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-004-bc32.md
"""
if bc32.lower() != bc32 and bc32.upper() != bc32:
return None
bc32 = bc32.lower()
if not all([x in CHARSET for x in bc32]):
return None
res = [CHARSET.find(c) for c in bc32.lower()]
if bech32_polymod([0] + res) != 0x3FFFFFFF:
return None
return bytes(convertbits(res[:-6], 5, 8, False))
def cbor_encode(data):
l = len(data)
if l <= 23:
prefix = bytes([0x40 + l])
elif l <= 255:
prefix = bytes([0x58, l])
elif l <= 65535:
prefix = b"\x59" + l.to_bytes(2, "big")
else:
prefix = b"\x60" + l.to_bytes(4, "big")
return prefix + data
def cbor_decode(data):
s = BytesIO(data)
b = s.read(1)[0]
if b >= 0x40 and b < 0x58:
l = b - 0x40
return s.read(l)
if b == 0x58:
l = s.read(1)[0]
return s.read(l)
if b == 0x59:
l = int.from_bytes(s.read(2), "big")
return s.read(l)
if b == 0x60:
l = int.from_bytes(s.read(4), "big")
return s.read(l)
return None
def bcur_encode(data):
"""Returns bcur encoded string and hash digest"""
cbor = cbor_encode(data)
enc = bc32encode(cbor)
h = hashlib.sha256(cbor).digest()
enc_hash = bc32encode(h)
return enc, enc_hash
def bcur_decode(data, checksum=None):
"""Returns decoded data, verifies hash digest if provided"""
cbor = bc32decode(data)
if checksum is not None:
h = bc32decode(checksum)
assert h == hashlib.sha256(cbor).digest()
return cbor_decode(cbor)
+29 -11
View File
@@ -1,35 +1,52 @@
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
from seedsigner.models.seed import Seed
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_language_code: str) -> 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
mnemonic_bytes = bip39.mnemonic_to_bytes(unicodedata.normalize("NFKD", " ".join(mnemonic_copy)), ignore_checksum=True, wordlist=wordlist)
# 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=Seed.get_wordlist(wordlist_language_code))
# 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()
if len(roll_data) == 50:
# 12-word mnemonic; only use 128bits / 16 bytes
entropy_bytes = entropy_bytes[:16]
# Return as a list
return bip39.mnemonic_from_bytes(entropy_bytes).split()
@@ -37,7 +54,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
-38
View File
@@ -1,38 +0,0 @@
# Static Consents used globally to route to different parts of the app making it easier to read
class Path:
# Menu Location Routes
MAIN_MENU = 0
SEED_TOOLS_SUB_MENU = 10
SIGNING_TOOLS_SUB_MENU = 11
SETTINGS_SUB_MENU = 12
SEED_WORD_12 = 20
SEED_WORD_24 = 21
POWER_OFF = 22
SEED_WORD_QR = 23
# Tools / Functions Routes
GEN_LAST_WORD = 40
DICE_GEN_SEED = 41
IMAGE_GEN_SEED = 45
SAVE_SEED = 42
PASSPHRASE_SEED = 43
DELETE_PASSPHRASE = 44
GEN_XPUB = 50
SIGN_TRANSACTION = 51
IO_TEST_TOOL = 60
CURRENT_NETWORK = 61
VERSION_INFO = 62
DONATE = 63
WALLET = 64
QR_DENSITY_SETTING = 65
PERSISTENT_SETTINGS = 67
CAMERA_ROTATION = 69
COMPACT_SEEDQR_ENABLED = 70
RESET = 68
# Seed Slots
SEED_SLOT_1 = 80
SEED_SLOT_2 = 81
SEED_SLOT_3 = 82
+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
+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) -> int:
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
+163 -35
View File
@@ -1,77 +1,110 @@
from binascii import hexlify
from embit import psbt, script, ec, bip32, bip39
from embit.descriptor import Descriptor
from embit.networks import NETWORKS
from embit.psbt import PSBT
from io import BytesIO
from typing import List
from seedsigner.models import Seed
from seedsigner.models.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"])
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
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,33 @@ 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({
"output_index": i,
"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 +179,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 +243,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 +281,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 +304,42 @@ 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
def verify_multisig_output(self, descriptor: Descriptor, change_num: int) -> bool:
change_data = self.get_change_data(change_num)
i = change_data["output_index"]
output = self.psbt.outputs[i]
is_owner = descriptor.owns(output)
# print(f"{self.psbt.tx.vout[i].script_pubkey.address()} | {output.value} | {is_owner}")
return is_owner
+30 -24
View File
@@ -1,26 +1,32 @@
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__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__CONFIGFILE = "wallet__configfile"
WALLET__GENERIC = "wallet__generic"
OUTPUT__UR = "output__ur"
ACCOUNT__UR = "account__ur"
BYTES__UR = "bytes__ur"
INVALID = "invalid"
+106 -95
View File
@@ -1,108 +1,119 @@
# 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')
def get_xpub(self, wallet_path: str = '/', network: str = SettingsConstants.MAINNET):
root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"])
xprv = root.derive(wallet_path)
xpub = xprv.to_public()
return xpub
### override operators
def __eq__(self, other):
if isinstance(other, Seed):
return self.seed_bytes == other.seed_bytes
return False
+66 -156
View File
@@ -1,182 +1,92 @@
# External Dependencies
from typing import List
from seedsigner.models import Seed
from seedsigner.models.seed import InvalidSeedException
from seedsigner.models.settings_definition import SettingsConstants
class SeedStorage:
def __init__(self) -> None:
self.seeds: List[Seed] = []
self.pending_seed: Seed = None
self._pending_mnemonic: List[str] = []
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 num_seeds(self):
return len(self.seeds)
def save_seed_phrase(self, seed_phrase = [], slot_num = 0) -> bool:
return self.add_mnemonic(seed_phrase,slot_num=slot_num)
@property
def pending_mnemonic(self) -> List[str]:
# Always return a copy so that the internal List can't be altered
return list(self._pending_mnemonic)
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 []
@property
def pending_mnemonic_length(self) -> int:
return len(self._pending_mnemonic)
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 ""
def init_pending_mnemonic(self, num_words:int = 12):
self._pending_mnemonic = [None] * num_words
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()
def update_pending_mnemonic(self, word: str, index: int):
if index >= len(self._pending_mnemonic):
raise Exception(f"index {index} is too high")
self._pending_mnemonic[index] = word
return self.seeds[self.slots[slot_num - 1]]
def get_pending_mnemonic_word(self, index: int) -> str:
if index < len(self._pending_mnemonic):
return self._pending_mnemonic[index]
return None
def delete_passphrase(self, slot_num) -> bool:
if self.slots[slot_num - 1] == -1:
return False
def get_pending_mnemonic_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str:
try:
seed = Seed(self._pending_mnemonic)
return seed.get_fingerprint(network)
except InvalidSeedException:
return None
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 convert_pending_mnemonic_to_pending_seed(self):
self.pending_seed = Seed(self._pending_mnemonic)
self.discard_pending_mnemonic()
def discard_pending_mnemonic(self):
self._pending_mnemonic = []
+153 -268
View File
@@ -1,284 +1,169 @@
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(",")
# TODO: If value is not in entry.selection_options...
# 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,526 @@
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__PROMPT_REQUIRED_DISABLED = [
(OPTION__PROMPT, "Prompt"),
(OPTION__REQUIRED, "Required"),
(OPTION__DISABLED, "Disabled"),
]
OPTIONS__ENABLED_DISABLED_REQUIRED = OPTIONS__ENABLED_DISABLED +[
(OPTION__REQUIRED, "Required"),
]
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__NUNCHUK = "nun"
COORDINATOR__SPARROW = "spa"
COORDINATOR__SPECTER_DESKTOP = "spd"
ALL_COORDINATORS = [
(COORDINATOR__BLUE_WALLET, "BlueWallet"),
(COORDINATOR__NUNCHUK, "Nunchuk"),
(COORDINATOR__SPARROW, "Sparrow"),
(COORDINATOR__SPECTER_DESKTOP, "Specter Desktop"),
]
LANGUAGE__ENGLISH = "en"
ALL_LANGUAGES = [
(LANGUAGE__ENGLISH, "English"),
]
BTC_DENOMINATION__BTC = "btc"
BTC_DENOMINATION__SATS = "sats"
BTC_DENOMINATION__THRESHOLD = "thr"
BTC_DENOMINATION__BTCSATSHYBRID = "hyb"
ALL_BTC_DENOMINATIONS = [
(BTC_DENOMINATION__BTC, "Btc-only"),
(BTC_DENOMINATION__SATS, "Sats-only"),
(BTC_DENOMINATION__THRESHOLD, "Threshold at 0.01"),
(BTC_DENOMINATION__BTCSATSHYBRID, "Btc | Sats hybrid"),
]
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"),
]
LEGACY_P2PKH = "leg" # Intentionally excluded from ALL_SCRIPT_TYPES
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__BTC_DENOMINATION = "denomination"
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__PARTNER_LOGOS = "partner_logos"
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 get_selection_option_value_by_display_name(self, display_name: str):
for option in self.selection_options:
if type(option) == tuple:
option_value = option[0]
option_display_name = option[1]
else:
option_value = option
option_display_name = option
if option_display_name == display_name:
return option_value
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),
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__BTC_DENOMINATION,
display_name="Denomination display",
type=SettingsConstants.TYPE__SELECT_1,
selection_options=SettingsConstants.ALL_BTC_DENOMINATIONS,
default_value=SettingsConstants.BTC_DENOMINATION__THRESHOLD),
# 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.MAINNET),
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",
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.OPTIONS__ENABLED_DISABLED_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),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PARTNER_LOGOS,
display_name="Show partner logos",
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
# Developer options
# TODO: No real Developer options needed yet. Disable for now.
# 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
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
+46
View File
@@ -0,0 +1,46 @@
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
def set_value(self, value: int):
with self._lock:
self.count = value
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

-6
View File
@@ -1,7 +1 @@
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
-151
View File
@@ -1,151 +0,0 @@
# External Dependencies
from threading import Thread
from pyzbar import pyzbar
from pyzbar.pyzbar import ZBarSymbol
import time
# Internal file class dependencies
from . import View
from seedsigner.helpers import B
class IOTestView(View):
def __init__(self) -> None:
View.__init__(self)
self.redraw = False
self.redraw_complete = False
self.qr_text = "Scan ANY QR Code"
self.exit = False
def display_io_test_screen(self):
# display loading screen
self.draw_modal(["Initializing I/O Test"])
print("Initializing I/O Test")
self.qr_text = "Scan ANY QR Code"
self.redraw = False
self.redraw_complete = False
self.exit = False
try:
self.controller.get_instance().camera.start_video_stream_mode()
t = Thread(target=self.qr_loop)
t.start()
except:
self.qr_text = "No Camera"
self.controller.get_instance().camera.stop_video_stream_mode()
while True:
self.draw_io_screen()
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS, B.KEY_RIGHT, B.KEY_LEFT, B.KEY1, B.KEY2, B.KEY3], False)
if input == B.KEY_UP:
ret_val = self.up_button()
elif input == B.KEY_DOWN:
ret_val = self.down_button()
elif input == B.KEY_RIGHT:
ret_val = self.right_button()
elif input == B.KEY_LEFT:
ret_val = self.left_button()
elif input == B.KEY_PRESS:
ret_val = self.press_button()
elif input == B.KEY1:
ret_val = self.a_button()
elif input == B.KEY2:
ret_val = self.b_button()
elif input == B.KEY3:
ret_val = self.c_button()
return True
def qr_loop(self):
while True:
frame = self.controller.get_instance().camera.read_video_stream()
if frame is not None:
barcodes = pyzbar.decode(frame, symbols=[ZBarSymbol.QRCODE])
if len(barcodes) > 0:
self.draw_scan_detected()
time.sleep(0.05)
if self.controller.get_instance().camera._video_stream is None:
break
if self.exit == True:
break
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.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)
self.draw.polygon([(61, 151), (80, 193), (99, 151)], outline=View.color, fill=0)
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.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()
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.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.redraw = True
def c_button(self):
self.exit = True
self.controller.get_instance().camera.stop_video_stream_mode()
return
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.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.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.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.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.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()
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
+551
View File
@@ -0,0 +1,551 @@
import logging
from typing import List
from embit.psbt import PSBT
from embit import script
from embit.networks import NETWORKS
from seedsigner.controller import Controller
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
from seedsigner.models.encode_qr import EncodeQR
from seedsigner.models.psbt_parser import PSBTParser
from seedsigner.models.qr_type import QRType
from seedsigner.models.settings import SettingsConstants
from seedsigner.gui.screens import psbt_screens
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen,
LoadingScreenThread, QRDisplayScreen, WarningScreen)
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
logger = logging.getLogger(__name__)
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, "blue"))
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.get_seed(selected_menu_num)
return Destination(PSBTOverviewView)
# The remaining flows are a sub-flow; resume PSBT flow once the seed is loaded.
self.controller.resume_main_flow = Controller.FLOW__PSBT
if 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:
from seedsigner.views.seed_views import SeedMnemonicEntryView
return Destination(SeedMnemonicEntryView)
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)
# expecting p2sh (legacy multisig) and p2pkh to have no policy set
# skip change warning and psbt math view
if psbt_parser.policy == None:
return Destination(PSBTUnsupportedScriptTypeWarningView)
elif psbt_parser.change_amount == 0:
return Destination(PSBTNoChangeWarningView)
else:
return Destination(PSBTMathView)
class PSBTUnsupportedScriptTypeWarningView(View):
def run(self):
selected_menu_num = WarningScreen(
status_headline="Unsupported Script Type!",
text="PSBT has unsupported input script type, please verify your change addresses.",
button_data=["Continue"],
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# Only one exit point
# skip PSBTMathView
return Destination(
PSBTAddressDetailsView, view_args={"address_num": 0},
skip_current_view=True, # Prevent going BACK to WarningViews
)
class PSBTNoChangeWarningView(View):
def run(self):
selected_menu_num = WarningScreen(
status_headline="Full Spend!",
text="This PSBT spends its entire input value. No change is coming back to your wallet.",
button_data=["Continue"],
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# Only one exit point
return Destination(
PSBTMathView,
skip_current_view=True, # Prevent going BACK to WarningViews
)
class PSBTMathView(View):
"""
Follows the Overview pictogram. Shows:
+ total input value
- recipients' value
- fees
-------------------
+ change value
"""
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 = psbt_screens.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))
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:
# if the known-good multisig descriptor is already onboard:
if self.controller.multisig_wallet_descriptor:
is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num)
button_data = [NEXT]
else:
# Have the Screen offer to load in the multisig descriptor.
button_data = [VERIFY_MULTISIG, NEXT]
else:
# Single sig
try:
if is_change_derivation_path:
loading_screen_text = "Verifying Change..."
else:
loading_screen_text = "Verifying Self-Transfer..."
loading_screen = LoadingScreenThread(text=loading_screen_text)
loading_screen.start()
# convert change address to script pubkey to get script type
pubkey = script.address_to_scriptpubkey(change_data["address"])
script_type = pubkey.script_type()
# extract derivation path to get wallet and change derivation
change_path = '/'.join(derivation_path.split("/")[-2:])
wallet_path = '/'.join(derivation_path.split("/")[:-2])
xpub = self.controller.psbt_seed.get_xpub(
wallet_path=wallet_path,
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
)
# take script type and call script method to generate address from seed / derivation
xpub_key = xpub.derive(change_path).key
network = self.settings.get_value(SettingsConstants.SETTING__NETWORK)
scriptcall = getattr(script, script_type)
if script_type == "p2sh":
# single sig only so p2sh is always p2sh-p2wpkh
calc_address = script.p2sh(script.p2wpkh(xpub_key)).address(
network=NETWORKS[SettingsConstants.map_network_to_embit(network)]
)
else:
# single sig so this handles p2wpkh and p2wpkh (and p2tr in the future)
calc_address = scriptcall(xpub_key).address(
network=NETWORKS[SettingsConstants.map_network_to_embit(network)]
)
if change_data["address"] == calc_address:
is_change_addr_verified = True
button_data = [NEXT]
finally:
loading_screen.stop()
if is_change_addr_verified == False and (not psbt_parser.is_multisig or self.controller.multisig_wallet_descriptor is not None):
return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path, is_multisig=psbt_parser.is_multisig), clear_history=True)
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:
from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView
self.controller.resume_main_flow = Controller.FLOW__PSBT
return Destination(LoadMultisigWalletDescriptorView)
class PSBTAddressVerificationFailedView(View):
def __init__(self, is_change: bool = True, is_multisig: bool = False):
super().__init__()
self.is_change = is_change
self.is_multisig = is_multisig
def run(self):
if self.is_multisig:
title = "Caution"
text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be verified with your multisig wallet descriptor."""
else:
title = "Suspicious PSBT"
text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be generated from your seed."""
DireWarningScreen(
title=title,
status_headline="Address Verification Failed",
text=text,
button_data=["Discard PSBT"],
show_back_button=False,
).display()
# Clear out the bad PSBT
self.controller.psbt = None
self.controller.psbt_parser = None
self.controller.psbt_seed = None
return Destination(MainMenuView, clear_history=True)
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),
)
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)
# Just a WarningScreen here; only use DireWarningScreen for true security risks.
selected_menu_num = WarningScreen(
title="PSBT Error",
status_icon_name=SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION,
status_headline="Signing Failed",
text="Signing with this seed did not add a valid signature.",
button_data=["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)
+126
View File
@@ -0,0 +1,126 @@
import json
import re
from embit.descriptor import Descriptor
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.models import DecodeQR, Seed
from seedsigner.models.settings import SettingsConstants
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
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 SeedFinalizeView
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__REQUIRED:
from seedsigner.views.seed_views import SeedAddPassphraseView
return Destination(SeedAddPassphraseView)
else:
return Destination(SeedFinalizeView)
elif self.decoder.is_psbt:
from seedsigner.views.psbt_views import PSBTSelectSeedView
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:
from seedsigner.views.seed_views import MultisigWalletDescriptorView
descriptor_str = self.decoder.get_wallet_descriptor()
try:
# We need to replace `/0/*` wildcards with `/{0,1}/*` in order to use
# the Descriptor to verify change, too.
orig_descriptor_str = descriptor_str
if len(re.findall (r'\[([0-9,a-f,A-F]+?)(\/[0-9,\/,h\']+?)\].*?(\/0\/\*)', descriptor_str)) > 0:
p = re.compile(r'(\[[0-9,a-f,A-F]+?\/[0-9,\/,h\']+?\].*?)(\/0\/\*)')
descriptor_str = p.sub(r'\1/{0,1}/*', descriptor_str)
elif len(re.findall (r'(\[[0-9,a-f,A-F]+?\/[0-9,\/,h,\']+?\][a-z,A-Z,0-9]*?)([\,,\)])', descriptor_str)) > 0:
p = re.compile(r'(\[[0-9,a-f,A-F]+?\/[0-9,\/,h,\']+?\][a-z,A-Z,0-9]*?)([\,,\)])')
descriptor_str = p.sub(r'\1/{0,1}/*\2', descriptor_str)
except Exception as e:
print(repr(e))
descriptor_str = orig_descriptor_str
descriptor = Descriptor.from_string(descriptor_str)
if not descriptor.is_basic_multisig:
# TODO: Handle single-sig descriptors?
print(f"Received single sig descriptor: {descriptor}")
return Destination(NotYetImplementedView)
self.controller.multisig_wallet_descriptor = descriptor
return Destination(MultisigWalletDescriptorView)
elif self.decoder.is_address:
from seedsigner.views.seed_views import AddressVerificationStartView
address = self.decoder.get_address()
(script_type, network) = self.decoder.get_address_type()
return Destination(
AddressVerificationStartView,
view_args={
"address": address,
"script_type": script_type,
"network": network,
}
)
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)
+114 -55
View File
@@ -2,47 +2,95 @@ import os
import random
import time
from PIL import Image, ImageDraw
from PIL import Image
from . import View
from seedsigner.helpers import B
from seedsigner.gui.components import Fonts, GUIConstants, load_image
from seedsigner.gui.screens.screen import BaseScreen
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants
class LogoView(View):
# TODO: This early code is now outdated vis-a-vis Screen vs View distinctions
class LogoScreen(BaseScreen):
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)
super().__init__()
self.logo = load_image("logo_black_240.png")
self.partners = [
"hrf",
]
self.partner_logos: dict = {}
for partner in self.partners:
logo_url = os.path.join("partners", f"{partner}_logo.png")
self.partner_logos[partner] = load_image(logo_url)
def get_random_partner(self) -> str:
return self.partners[random.randrange(len(self.partners))]
class OpeningSplashView(LogoView):
class OpeningSplashScreen(LogoScreen):
def start(self):
from seedsigner.controller import Controller
controller = Controller.get_instance()
show_partner_logos = Settings.get_instance().get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
if show_partner_logos:
logo_offset_y = -56
else:
logo_offset_y = 0
# Fade in alpha
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)
background = Image.new("RGBA", size=self.logo.size, color="black")
self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (0, logo_offset_y))
self.renderer.show_image()
# Display version num and hold for a few seconds
font = View.ROBOTOCONDENSED_REGULAR_22
# Display version num below SeedSigner logo
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
(left, top, version_tw, version_th) = font.getbbox(version, anchor="lt")
draw = ImageDraw.Draw(self.logo)
draw.text((x, y), version, fill="orange", font=font)
View.DispShowImage(self.logo)
time.sleep(3)
# The logo png is 240x240, but the actual logo is 70px tall, vertically centered
version_x = int(self.renderer.canvas_width/2)
version_y = int(self.canvas_height/2) + 35 + logo_offset_y + GUIConstants.COMPONENT_PADDING
self.renderer.draw.text(xy=(version_x, version_y), text=version, font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt")
self.renderer.show_image()
if show_partner_logos:
# Hold on the version num for a moment
time.sleep(1)
# Set up the partner logo
partner_logo: Image.Image = self.partner_logos[self.get_random_partner()]
font = Fonts.get_font(GUIConstants.TOP_NAV_TITLE_FONT_NAME, GUIConstants.BODY_FONT_SIZE)
sponsor_text = "With support from:"
(left, top, tw, th) = font.getbbox(sponsor_text, anchor="lt")
x = int((self.renderer.canvas_width) / 2)
y = self.canvas_height - GUIConstants.COMPONENT_PADDING - partner_logo.height - int(GUIConstants.COMPONENT_PADDING/2) - th
self.renderer.draw.text(xy=(x, y), text=sponsor_text, font=font, fill="#ccc", anchor="mt")
self.renderer.canvas.paste(
partner_logo,
(
int((self.renderer.canvas_width - partner_logo.width) / 2),
y + th + int(GUIConstants.COMPONENT_PADDING/2)
)
)
self.renderer.show_image()
time.sleep(2)
class ScreensaverView(LogoView):
class ScreensaverScreen(LogoScreen):
def __init__(self, buttons):
super().__init__()
@@ -55,7 +103,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 +112,6 @@ class ScreensaverView(LogoView):
self.last_screen = None
@property
def is_running(self):
return self._is_running
@@ -87,49 +133,62 @@ 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:
try:
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
except KeyboardInterrupt as e:
# Exit triggered; close gracefully
print("Shutting down Screensaver")
self.stop()
# Have to let the interrupt bubble up to exit the main app
raise e
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-290
View File
@@ -1,290 +0,0 @@
# SeedSigner file class dependencies
from . import View
from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay
from seedsigner.models import EncodeQRDensity
class SettingsToolsView(View):
def __init__(self) -> None:
View.__init__(self)
self.qr = QR()
self.donate_image = None
self.derivation = None
### 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)")
return True
def display_donate_qr(self):
self.draw_modal(["Loading..."])
self.donate_image = self.qr.qrimage("bc1qphlyv2dde290tqdlnk8uswztnshw3x9rjurexqqhksvu7vdevhtsuw4efe")
View.DispShowImage(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
###
def draw_derivation_keyboard_entry(self, existing_derivation = "m/"):
def render_right_panel():
row_height = 28
right_button_left_margin = 10
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
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)
# 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_derivation:
self.derivation = existing_derivation
else:
self.derivation = "m/"
# Set up the keyboard params
right_panel_buttons_width = 60
# render top title banner
font = View.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)
title_height = th + title_top_padding + title_bottom_padding
# Render the live text entry display
font = View.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 = 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__BLOCK,
is_centered=False,
has_outline=True,
cur_text=''.join(self.derivation)
)
text_entry_display.render()
cursor_position = len(self.derivation)
keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding
keyboard_digits = Keyboard(
View.draw,
charset="/'0123456789",
rows=3,
cols=6,
rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height),
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
keyboard_digits.set_selected_key(selected_letter="/")
keyboard_digits.render_keys()
render_right_panel()
text_entry_display.render(self.derivation)
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.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) > 0:
return self.derivation.strip()
elif input == B.KEY_PRESS and previous_button_is_active:
# Prev button clicked; return empty string to signal cancel.
return ""
# 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 = keyboard_digits.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 len(self.derivation) <= 2:
pass
elif cursor_position == len(self.derivation):
self.derivation = self.derivation[:-1]
cursor_position -= 1
else:
self.derivation = self.derivation[:cursor_position - 1] + self.derivation[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):
self.derivation += ret_val
else:
self.derivation = self.derivation[:cursor_position] + ret_val + self.derivation[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
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
+191
View File
@@ -0,0 +1,191 @@
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
from seedsigner.models.decode_qr import DecodeQR
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):
IO_TEST = "I/O test"
DONATE = "Donate"
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"
# Set up the next nested level of menuing
button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
button_data.append(IO_TEST)
button_data.append(DONATE)
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
title = "Advanced"
# So far there are no real Developer options; disabling for now
# button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
# next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER})
next = None
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
elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == IO_TEST:
return Destination(IOTestView)
elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == DONATE:
return Destination(DonateView)
else:
# TODO: Free-entry types (are there any?) will need their own SettingsEntryUpdateFreeEntryView(?).
return Destination(SettingsEntryUpdateSelectionView, view_args={"attr_name": settings_entries[selected_menu_num].attr_name})
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()
"""****************************************************************************
Misc
****************************************************************************"""
class IOTestView(View):
def run(self):
settings_screens.IOTestScreen().display()
return Destination(SettingsMenuView)
class DonateView(View):
def run(self):
settings_screens.DonateScreen().display()
return Destination(SettingsMenuView)
@@ -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()
+278
View File
@@ -0,0 +1,278 @@
import hashlib
import os
import time
from PIL import Image
from PIL.ImageOps import autocontrast
from seedsigner.hardware.camera import Camera
from seedsigner.gui.components import FontAwesomeIconConstants
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen)
from seedsigner.gui.screens.tools_screens import ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsCalcFinalWordShowFinalWordScreen
from seedsigner.helpers import mnemonic_generation
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedWordsWarningView
from .view import View, Destination, BackStackView
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)
elif button_data[selected_menu_num] == IMAGE:
return Destination(ToolsImageEntropyLivePreviewView)
elif button_data[selected_menu_num] == DICE:
return Destination(ToolsDiceEntropyMnemonicLengthView)
elif button_data[selected_menu_num] == KEYBOARD:
return Destination(ToolsCalcFinalWordNumWordsView)
"""****************************************************************************
Image entropy Views
****************************************************************************"""
class ToolsImageEntropyLivePreviewView(View):
def run(self):
self.controller.image_entropy_preview_frames = None
ret = ToolsImageEntropyLivePreviewScreen().display()
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
self.controller.image_entropy_preview_frames = ret
return Destination(ToolsImageEntropyFinalImageView)
class ToolsImageEntropyFinalImageView(View):
def run(self):
if not self.controller.image_entropy_final_image:
# Take the final full-res image
camera = Camera.get_instance()
camera.start_single_frame_mode(resolution=(720, 480))
time.sleep(0.25)
self.controller.image_entropy_final_image = camera.capture_frame()
camera.stop_single_frame_mode()
# Prep a copy of the image for display. The actual image data is 720x480
# Present just a center crop and resize it to fit the screen and to keep some of
# the data hidden.
display_version = autocontrast(
self.controller.image_entropy_final_image,
cutoff=2
).crop(
(120, 0, 600, 480)
).resize(
(self.canvas_width, self.canvas_height), Image.BICUBIC
)
ret = ToolsImageEntropyFinalImageScreen(
final_image=display_version
).display()
if ret == RET_CODE__BACK_BUTTON:
# Go back to live preview and reshoot
self.controller.image_entropy_final_image = None
return Destination(BackStackView)
return Destination(ToolsImageEntropyMnemonicLengthView)
class ToolsImageEntropyMnemonicLengthView(View):
def run(self):
TWELVE_WORDS = "12 words"
TWENTYFOUR_WORDS = "24 words"
button_data = [TWELVE_WORDS, TWENTYFOUR_WORDS]
selected_menu_num = ButtonListScreen(
title="Mnemonic Length?",
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == TWELVE_WORDS:
mnemonic_length = 12
else:
mnemonic_length = 24
preview_images = self.controller.image_entropy_preview_frames
seed_entropy_image = self.controller.image_entropy_final_image
# Build in some hardware-level uniqueness via CPU unique Serial num
try:
stream = os.popen("cat /proc/cpuinfo | grep Serial")
output = stream.read()
serial_num = output.split(":")[-1].strip().encode('utf-8')
serial_hash = hashlib.sha256(serial_num)
hash_bytes = serial_hash.digest()
except Exception as e:
print(repr(e))
hash_bytes = b'0'
# Build in modest entropy via millis since power on
millis_hash = hashlib.sha256(hash_bytes + str(time.time()).encode('utf-8'))
hash_bytes = millis_hash.digest()
# Build in better entropy by chaining the preview frames
for frame in preview_images:
img_hash = hashlib.sha256(hash_bytes + frame.tobytes())
hash_bytes = img_hash.digest()
# Finally build in our headline entropy via the new full-res image
final_hash = hashlib.sha256(hash_bytes + seed_entropy_image.tobytes()).digest()
if mnemonic_length == 12:
# 12-word mnemonic only uses the first 128 bits / 16 bytes of entropy
final_hash = final_hash[:16]
# Generate the mnemonic
mnemonic = mnemonic_generation.generate_mnemonic_from_bytes(final_hash)
# Image should never get saved nor stick around in memory
seed_entropy_image = None
preview_images = None
final_hash = None
hash_bytes = None
self.controller.image_entropy_preview_frames = None
self.controller.image_entropy_final_image = None
# Add the mnemonic as an in-memory Seed
seed = Seed(mnemonic, wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
self.controller.storage.set_pending_seed(seed)
# Cannot return BACK to this View
return Destination(SeedWordsWarningView, view_args={"seed_num": None}, clear_history=True)
"""****************************************************************************
Dice rolls Views
****************************************************************************"""
class ToolsDiceEntropyMnemonicLengthView(View):
def run(self):
TWELVE = "12 words (50 rolls)"
TWENTY_FOUR = "24 words (99 rolls)"
button_data = [TWELVE, TWENTY_FOUR]
selected_menu_num = ButtonListScreen(
title="Mnemonic Length",
is_bottom_list=True,
is_button_text_centered=True,
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == TWELVE:
return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=50))
elif button_data[selected_menu_num] == TWENTY_FOUR:
return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=99))
class ToolsDiceEntropyEntryView(View):
def __init__(self, total_rolls: int):
super().__init__()
self.total_rolls = total_rolls
def run(self):
ret = ToolsDiceEntropyEntryScreen(
total_rolls=self.total_rolls,
).display()
if ret == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
print(f"Dice rolls: {ret}")
dice_seed_phrase = mnemonic_generation.generate_mnemonic_from_dice(ret)
print(f"""Mnemonic: "{dice_seed_phrase}" """)
# Add the mnemonic as an in-memory Seed
seed = Seed(dice_seed_phrase, wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
self.controller.storage.set_pending_seed(seed)
# Cannot return BACK to this View
return Destination(SeedWordsWarningView, view_args={"seed_num": None}, clear_history=True)
"""****************************************************************************
Calc final word Views
****************************************************************************"""
class ToolsCalcFinalWordNumWordsView(View):
def run(self):
TWELVE = "12 words"
TWENTY_FOUR = "24 words"
button_data = [TWELVE, TWENTY_FOUR]
selected_menu_num = ButtonListScreen(
title="Mnemonic Length",
is_bottom_list=True,
is_button_text_centered=True,
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == TWELVE:
self.controller.storage.init_pending_mnemonic(12)
return Destination(SeedMnemonicEntryView, view_args={"is_calc_final_word": True})
elif button_data[selected_menu_num] == TWENTY_FOUR:
self.controller.storage.init_pending_mnemonic(24)
return Destination(SeedMnemonicEntryView, view_args={"is_calc_final_word": True})
class ToolsCalcFinalWordShowFinalWordView(View):
def run(self):
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_word_length = len(mnemonic)
final_word = mnemonic[-1]
LOAD = "Load seed"
DISCARD = ("Discard", None, None, "red")
button_data = [LOAD, DISCARD]
selected_menu_num = ToolsCalcFinalWordShowFinalWordScreen(
final_word=final_word,
mnemonic_word_length=mnemonic_word_length,
fingerprint=self.controller.storage.get_pending_mnemonic_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)),
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
self.controller.storage.convert_pending_mnemonic_to_pending_seed()
if button_data[selected_menu_num] == LOAD:
return Destination(SeedFinalizeView)
elif button_data[selected_menu_num] == DISCARD:
return Destination(SeedDiscardView)
+223 -356
View File
@@ -1,379 +1,246 @@
# 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 typing import List
from seedsigner.gui.components import FontAwesomeIconConstants
from seedsigner.gui.screens import RET_CODE__POWER_BUTTON
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, LargeButtonScreen, PowerOffScreen, ResetScreen, WarningScreen
from seedsigner.models.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))`).
Navigation guidance:
"Next" - Continue to next step
"Done" - End of flow, return to entry point (non-destructive)
"OK/Close" - Exit current screen (non-destructive)
"Cancel" - End task and return to entry point (destructive)
"""
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)
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)
@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"
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 __repr__(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()
def __eq__(self, obj):
"""
Equality test IGNORES the skip_current_view and clear_history options
"""
return (isinstance(obj, Destination) and
obj.View_cls == self.View_cls and
obj.view_args == self.view_args)
def __ne__(self, obj):
return not obj == self
#########################################################################################
#
# 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_back_button=False,
show_power_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 = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT)
POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF)
button_data = [RESET, POWER_OFF]
selected_menu_num = LargeButtonScreen(
title="Reset / Power",
show_back_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(RestartView)
elif button_data[selected_menu_num] == POWER_OFF:
return Destination(PowerOffView)
class RestartView(View):
def run(self):
thread = RestartView.DoResetThread()
thread.start()
ResetScreen().display()
class DoResetThread(BaseThread):
def run(self):
import time
from subprocess import call
# Give the screen just enough time to display the reset message before
# exiting.
time.sleep(0.25)
# Kill the SeedSigner process; systemd will automatically restart it.
# `.*` is a wildcard to detect either `python`` or `python3` and with or
# without the `-u` flag.
call("kill $(ps aux | grep '[p]ython.*main.py' | awk '{print $2}')", shell=True)
class PowerOffView(View):
def run(self):
thread = PowerOffView.PowerOffThread()
thread.start()
PowerOffScreen().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",
status_headline="Not Yet Implemented",
text="This is still on our to-do list!",
button_data=["Back to Main Menu"],
).display()
return Destination(MainMenuView)
class UnhandledExceptionView(View):
def __init__(self, error: List[str]):
self.error = error
def run(self):
DireWarningScreen(
title="System Error",
status_headline=self.error[0],
text=self.error[1] + "\n" + self.error[2],
button_data=["OK"],
show_back_button=False,
allow_text_overflow=True, # Fit what we can, let the rest go off the edges
).display()
return Destination(MainMenuView, clear_history=True)
-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
+7 -52
View File
@@ -3,6 +3,7 @@ import pytest
from mock import MagicMock
from seedsigner.controller import Controller
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants
@@ -11,10 +12,6 @@ def test_singleton_init_fails():
with pytest.raises(Exception):
c = Controller()
def test_singleton_get_instance_without_configure_fails():
""" Calling get_instance() without first calling configure_instance() should fail """
with pytest.raises(Exception):
c = Controller.get_instance()
def test_singleton_get_instance_preserves_state():
""" Changes to the Controller singleton should be preserved across calls to get_instance() """
@@ -25,38 +22,17 @@ def test_singleton_get_instance_preserves_state():
Settings._instance = None
Controller._instance = None
settings = """
[system]
debug = False
default_language = en
persistent_settings = False
[display]
text_color = ORANGE
qr_background_color = FFFFFF
camera_rotation = 0
[wallet]
network = main
software = Prompt
qr_density = 2
custom_derivation = m/0/0
compact_seedqr_enabled = False
"""
config = configparser.ConfigParser()
config.read_string(settings)
# Initialize the instance and verify that it read the config settings
Controller.configure_instance(config, disable_hardware=True)
Controller.configure_instance(disable_hardware=True)
controller = Controller.get_instance()
assert controller.color == "ORANGE"
assert controller.unverified_address is None
# Change a value in the instance...
controller.color = "purple"
controller.unverified_address = "123abc"
# ...get a new copy of the instance and confirm change
controller = Controller.get_instance()
assert controller.color == "purple"
assert controller.unverified_address == "123abc"
def test_missing_settings_get_defaults():
@@ -69,30 +45,9 @@ def test_missing_settings_get_defaults():
Settings._instance = None
Controller._instance = None
# Intentionally omit `compact_seedqr_enabled` from settings:
settings = """
[system]
debug = False
default_language = en
persistent_settings = False
[display]
text_color = ORANGE
qr_background_color = FFFFFF
camera_rotation = 0
[wallet]
network = main
software = Prompt
qr_density = 2
custom_derivation = m/0/0
"""
config = configparser.ConfigParser()
config.read_string(settings)
# Controller should parse the settings fine, even though a field is missing
Controller.configure_instance(config, disable_hardware=True)
Controller.configure_instance(disable_hardware=True)
# Controller should still have a default value
controller = Controller.get_instance()
assert controller.settings.compact_seedqr_enabled is False
assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__DISABLED
+104 -163
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+39 -3
View File
@@ -2,17 +2,28 @@ import random
from embit import bip39
from seedsigner.helpers import mnemonic_generation
from seedsigner.models.settings_definition import SettingsConstants
def test_dice_rolls():
""" Given 99 random dice rolls, the resulting mnemonic should be valid. """
""" Given random dice rolls, the resulting mnemonic should be valid. """
dice_rolls = ""
for i in range(0, 99):
# Do not need truly rigorous random for this test
dice_rolls += str(random.randint(0, 5))
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
assert len(mnemonic) == 24
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
dice_rolls = ""
for i in range(0, 50):
# Do not need truly rigorous random for this test
dice_rolls += str(random.randint(0, 5))
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
assert len(mnemonic) == 12
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
@@ -23,11 +34,11 @@ def test_calculate_checksum():
"""
# Test mnemonics from https://iancoleman.io/bip39/
partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day"
mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist=bip39.WORDLIST)
mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile"
mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist=bip39.WORDLIST)
mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
@@ -70,3 +81,28 @@ def test_known_dice_rolls():
assert bip39.mnemonic_is_valid(actual)
assert actual == expected
def test_50_dice_rolls():
""" 50 dice roll input should yield the same 12-word mnemonic as iancoleman.io/bip39 """
# Check "Show entropy details", paste in dice_rolls sequence, click "Hex", select "Mnemonic Length" as "12 Words"
dice_rolls = "12345612345612345612345612345612345612345612345612"
expected = "unveil nice picture region tragic fault cream strike tourist control recipe tourist"
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
actual = " ".join(mnemonic)
assert bip39.mnemonic_is_valid(actual)
assert actual == expected
dice_rolls = "11111111111111111111111111111111111111111111111111"
expected = "diet glad hat rural panther lawsuit act drop gallery urge where fit"
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
actual = " ".join(mnemonic)
assert bip39.mnemonic_is_valid(actual)
assert actual == expected
dice_rolls = "66666666666666666666666666666666666666666666666666"
expected = "senior morning song proud recycle toy search apple trigger lend vibrant arrest"
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
actual = " ".join(mnemonic)
assert bip39.mnemonic_is_valid(actual)
assert actual == expected
+19 -21
View File
File diff suppressed because one or more lines are too long
+21 -19
View File
@@ -6,6 +6,8 @@ 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):
@@ -15,8 +17,8 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
print(seed_phrase)
assert len(seed_phrase) == mnemonic_length
e = EncodeQR(seed_phrase=seed_phrase, qr_type=qr_type, wordlist=bip39.WORDLIST)
data = e.nextPart()
e = EncodeQR(seed_phrase=seed_phrase, qr_type=qr_type)
data = e.next_part()
print(data)
qr = QR()
@@ -27,11 +29,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()
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 +44,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 +56,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 +69,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)