@@ -2,3 +2,5 @@
|
||||
__pycache__/
|
||||
src/seedsigner.egg-info/
|
||||
.nova
|
||||
.vscode
|
||||
src/seedsigner/models/settings_definition.json
|
||||
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .renderer import Renderer
|
||||
@@ -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,29 +114,43 @@ 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)
|
||||
|
||||
# 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")
|
||||
self.keyboard.draw.text(
|
||||
(
|
||||
self.screen_x + int(self.keyboard.x_width * self.size / 2),
|
||||
self.screen_y + self.keyboard.y_height + int(top/2) # "top" is negative when measuring from baseline
|
||||
),
|
||||
self.letter,
|
||||
fill=font_color,
|
||||
font=font,
|
||||
anchor="ms"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def __init__(self,
|
||||
draw,
|
||||
def __init__(self,
|
||||
draw: ImageDraw,
|
||||
charset="1234567890abcdefghijklmnopqrstuvwxyz",
|
||||
selected_char="a",
|
||||
rows=4,
|
||||
@@ -143,15 +159,12 @@ class Keyboard:
|
||||
font=None,
|
||||
additional_keys=[KEY_BACKSPACE],
|
||||
auto_wrap=[WRAP_TOP, WRAP_BOTTOM, WRAP_LEFT, WRAP_RIGHT],
|
||||
render_now=True):
|
||||
render_now=True,
|
||||
highlight_color: str = GUIConstants.ACCENT_COLOR):
|
||||
"""
|
||||
`auto_wrap` specifies which edges the keyboard is allowed to loop back when
|
||||
navigating past the end.
|
||||
"""
|
||||
|
||||
# Import here to avoid circular import problems
|
||||
from seedsigner.views import View
|
||||
|
||||
self.draw = draw
|
||||
self.charset = charset
|
||||
self.rows = rows
|
||||
@@ -160,10 +173,11 @@ class Keyboard:
|
||||
if font:
|
||||
self.font = font
|
||||
else:
|
||||
self.font = View.ROBOTOCONDENSED_REGULAR_24
|
||||
self.font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, 22)
|
||||
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,12 +191,12 @@ 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_gap = 2
|
||||
self.x_width = int((rect[2] - rect[0]) / cols) - self.x_gap
|
||||
self.y_gap = 6
|
||||
self.y_gap = 2
|
||||
self.y_height = int((rect[3] - rect[1]) / rows) - self.y_gap
|
||||
self.additional_key_entered_from_x = None
|
||||
|
||||
@@ -257,7 +271,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 +366,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 +374,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 +387,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 +400,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,13 +491,14 @@ class TextEntryDisplayConstants:
|
||||
|
||||
@dataclass
|
||||
class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
draw: any
|
||||
rect: (int,int,int,int)
|
||||
font: any
|
||||
font_color: any
|
||||
canvas: Image
|
||||
rect: Tuple[int,int,int,int]
|
||||
font: ImageFont
|
||||
font_color: str = GUIConstants.BODY_FONT_COLOR
|
||||
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
|
||||
|
||||
@@ -497,9 +512,6 @@ class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
|
||||
|
||||
def render(self, cur_text=None, cursor_position=None):
|
||||
# Import here to avoid circular import problems
|
||||
from seedsigner.views import View
|
||||
|
||||
""" Render the live text entry display """
|
||||
if cur_text is not None:
|
||||
self.cur_text = cur_text
|
||||
@@ -508,8 +520,10 @@ 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
|
||||
@@ -520,7 +534,7 @@ class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
if self.is_centered:
|
||||
self.text_offset = int(self.width - tw - cursor_block_width)/2
|
||||
else:
|
||||
self.text_offset = 3
|
||||
self.text_offset = GUIConstants.COMPONENT_PADDING
|
||||
cursor_block_offset = self.text_offset + tw - 1
|
||||
if cursor_block_offset == 0:
|
||||
cursor_block_offset = 1
|
||||
@@ -530,12 +544,14 @@ 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=self.font_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"
|
||||
cursor_color = self.accent_color
|
||||
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.BUTTON_SELECTED_FONT_COLOR, font=self.font, anchor="ls")
|
||||
|
||||
else:
|
||||
cursor_bar_offset = 1
|
||||
@@ -571,7 +587,7 @@ 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, int((self.height - text_height)/2)), self.cur_text, fill=self.font_color, font=self.font)
|
||||
|
||||
# Render as an "I" bar
|
||||
cursor_bar_color = "#ccc"
|
||||
@@ -580,5 +596,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]))
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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 = None
|
||||
draw: ImageDraw = None
|
||||
disp = None
|
||||
lock = Lock()
|
||||
|
||||
|
||||
@classmethod
|
||||
def configure_instance(cls, config={}):
|
||||
from seedsigner.models.settings import Settings
|
||||
super().configure_instance(config)
|
||||
|
||||
# Instantiate the one and only Renderer instance
|
||||
renderer = cls.__new__(cls)
|
||||
cls._instance = renderer
|
||||
|
||||
# TODO: Use Settings values to wire up diff hardware params
|
||||
settings = Settings.get_instance()
|
||||
|
||||
# Eventually we'll be able to plug in other display controllers
|
||||
renderer.disp = ST7789()
|
||||
renderer.canvas_width = renderer.disp.width
|
||||
renderer.canvas_height = renderer.disp.height
|
||||
|
||||
renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height))
|
||||
renderer.draw = ImageDraw.Draw(renderer.canvas)
|
||||
|
||||
|
||||
def show_image(self, image=None, alpha_overlay=None):
|
||||
if 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()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .screen import *
|
||||
@@ -0,0 +1,734 @@
|
||||
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 (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
|
||||
|
||||
if spend_amount <= 1e6:
|
||||
amount_display = f"{spend_amount:,} sats"
|
||||
else:
|
||||
amount_display = f"{spend_amount/1e8:,} btc"
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.BITCOIN_LOGO,
|
||||
icon_color=GUIConstants.ACCENT_COLOR,
|
||||
icon_size=34,
|
||||
is_text_centered=True,
|
||||
value_text=f"{amount_display}",
|
||||
font_size=24,
|
||||
screen_y=icon_text_lines_y,
|
||||
))
|
||||
|
||||
# Prep the transaction flow chart
|
||||
self.chart_x = 0
|
||||
self.chart_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
chart_height = self.buttons[0].screen_y - self.chart_y - GUIConstants.COMPONENT_PADDING
|
||||
|
||||
# We need to supersample the whole panel so that small/thin elements render
|
||||
# clearly.
|
||||
ssf = 4 # super-sampling factor
|
||||
|
||||
# Set up our temp supersampled rendering surface
|
||||
image = Image.new(
|
||||
"RGB",
|
||||
(self.canvas_width * ssf, chart_height * ssf),
|
||||
GUIConstants.BACKGROUND_COLOR
|
||||
)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
font_size = GUIConstants.BODY_FONT_MIN_SIZE * ssf
|
||||
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, font_size)
|
||||
|
||||
tw, chart_text_height = font.getsize("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890") # All possible chars for max range
|
||||
vertical_center = int(image.height/2)
|
||||
# Supersampling renders thin elements poorly if they land on an even line before scaling down
|
||||
if vertical_center % 2 == 1:
|
||||
vertical_center += 1
|
||||
|
||||
association_line_color = "#666"
|
||||
association_line_width = 3*ssf
|
||||
curve_steps = 4
|
||||
chart_font_color = "#ddd"
|
||||
|
||||
# First calculate how wide the inputs col will be
|
||||
inputs_column = []
|
||||
if self.num_inputs == 1:
|
||||
inputs_column.append("1 input")
|
||||
elif self.num_inputs > 5:
|
||||
inputs_column.append("input 1")
|
||||
inputs_column.append("input 2")
|
||||
inputs_column.append("[ ... ]")
|
||||
inputs_column.append(f"input {self.num_inputs-1}")
|
||||
inputs_column.append(f"input {self.num_inputs}")
|
||||
else:
|
||||
for i in range(0, self.num_inputs):
|
||||
inputs_column.append(f"input {i+1}")
|
||||
|
||||
max_inputs_text_width = 0
|
||||
for input in inputs_column:
|
||||
tw, th = font.getsize(input)
|
||||
max_inputs_text_width = max(tw, max_inputs_text_width)
|
||||
|
||||
# Given how wide we want our curves on each side to be...
|
||||
curve_width = 4*GUIConstants.COMPONENT_PADDING*ssf
|
||||
|
||||
# ...and the minimum center divider width...
|
||||
center_bar_width = 2*GUIConstants.COMPONENT_PADDING*ssf
|
||||
|
||||
# We can calculate how wide the destination col can be
|
||||
max_destination_col_width = image.width - (GUIConstants.EDGE_PADDING*ssf + max_inputs_text_width + \
|
||||
int(GUIConstants.COMPONENT_PADDING*ssf/4) + curve_width + \
|
||||
center_bar_width + \
|
||||
curve_width + int(GUIConstants.COMPONENT_PADDING*ssf/4) + \
|
||||
GUIConstants.EDGE_PADDING*ssf)
|
||||
|
||||
# if self.num_inputs == 1:
|
||||
# # Use up more of the space on the input side
|
||||
# max_destination_col_width += curve_width
|
||||
|
||||
# Now let's maximize the actual destination col by adjusting our addr truncation
|
||||
def calculate_destination_col_width(truncate_at: int):
|
||||
def truncate_destination_addr(addr):
|
||||
return f"{addr[:truncate_at]}..."
|
||||
|
||||
destination_column = []
|
||||
|
||||
if len(self.destination_addresses) <= 3:
|
||||
for addr in self.destination_addresses:
|
||||
destination_column.append(truncate_destination_addr(addr))
|
||||
else:
|
||||
# destination_column.append(f"{len(self.destination_addresses)} recipients")
|
||||
destination_column.append(f"recipient 1")
|
||||
destination_column.append(f"[ ... ]")
|
||||
destination_column.append(f"recipient {len(self.destination_addresses)}")
|
||||
|
||||
if self.num_self_transfer_outputs > 0:
|
||||
for i in range(0, self.num_self_transfer_outputs):
|
||||
destination_column.append(f"self-transfer")
|
||||
|
||||
destination_column.append(f"fee")
|
||||
|
||||
if self.num_change_outputs > 0:
|
||||
for i in range(0, self.num_change_outputs):
|
||||
destination_column.append("change")
|
||||
|
||||
max_destination_text_width = 0
|
||||
for destination in destination_column:
|
||||
tw, th = font.getsize(destination)
|
||||
max_destination_text_width = max(tw, max_destination_text_width)
|
||||
|
||||
return (max_destination_text_width, destination_column)
|
||||
|
||||
if len(self.destination_addresses) > 3:
|
||||
# We're not going to display any destination addrs so truncation doesn't matter
|
||||
(destination_text_width, destination_column) = calculate_destination_col_width(truncate_at=0)
|
||||
else:
|
||||
# Steadliy widen out the destination column until we run out of space
|
||||
for i in range(6, 13):
|
||||
(new_width, new_col_text) = calculate_destination_col_width(truncate_at=i)
|
||||
if new_width > max_destination_col_width:
|
||||
break
|
||||
destination_text_width = new_width
|
||||
destination_column = new_col_text
|
||||
|
||||
destination_col_x = image.width - (destination_text_width + GUIConstants.EDGE_PADDING*ssf)
|
||||
|
||||
# Now we can finalize our center bar values
|
||||
center_bar_x = GUIConstants.EDGE_PADDING*ssf + max_inputs_text_width + int(GUIConstants.COMPONENT_PADDING*ssf/4) + curve_width
|
||||
|
||||
# Center bar stretches to fill any excess width
|
||||
center_bar_width = destination_col_x - int(GUIConstants.COMPONENT_PADDING*ssf/4) - curve_width - center_bar_x
|
||||
|
||||
# Position each input row
|
||||
num_rendered_inputs = len(inputs_column)
|
||||
if self.num_inputs == 1:
|
||||
inputs_y = vertical_center - int(chart_text_height/2)
|
||||
inputs_y_spacing = 0 # Not used
|
||||
else:
|
||||
inputs_y = int((image.height - num_rendered_inputs*chart_text_height) / (num_rendered_inputs + 1))
|
||||
inputs_y_spacing = inputs_y + chart_text_height
|
||||
|
||||
# Don't render lines from an odd number
|
||||
if inputs_y % 2 == 1:
|
||||
inputs_y += 1
|
||||
if inputs_y_spacing % 2 == 1:
|
||||
inputs_y_spacing += 1
|
||||
|
||||
inputs_conjunction_x = center_bar_x
|
||||
inputs_x = GUIConstants.EDGE_PADDING*ssf
|
||||
|
||||
input_curves = []
|
||||
for input in inputs_column:
|
||||
# Calculate right-justified input display
|
||||
tw, th = font.getsize(input)
|
||||
cur_x = inputs_x + max_inputs_text_width - tw
|
||||
draw.text(
|
||||
(cur_x, inputs_y),
|
||||
text=input,
|
||||
font=font,
|
||||
fill=chart_font_color,
|
||||
)
|
||||
|
||||
# Render the association line to the conjunction point
|
||||
# First calculate a bezier curve to an inflection point
|
||||
start_pt = (
|
||||
inputs_x + max_inputs_text_width + int(GUIConstants.COMPONENT_PADDING*ssf/4),
|
||||
inputs_y + int(chart_text_height/2)
|
||||
)
|
||||
conjunction_pt = (inputs_conjunction_x, vertical_center)
|
||||
mid_pt = (
|
||||
int(start_pt[0]*0.5 + conjunction_pt[0]*0.5),
|
||||
int(start_pt[1]*0.5 + conjunction_pt[1]*0.5)
|
||||
)
|
||||
|
||||
if len(inputs_column) == 1:
|
||||
# Use fewer segments for single input straight line
|
||||
bezier_points = [
|
||||
start_pt,
|
||||
linear_interp(start_pt, conjunction_pt, 0.33),
|
||||
linear_interp(start_pt, conjunction_pt, 0.66),
|
||||
conjunction_pt
|
||||
]
|
||||
else:
|
||||
bezier_points = calc_bezier_curve(
|
||||
start_pt,
|
||||
(mid_pt[0], start_pt[1]),
|
||||
mid_pt,
|
||||
curve_steps
|
||||
)
|
||||
# We don't need the "final" point as it's repeated below
|
||||
bezier_points.pop()
|
||||
|
||||
# Now render the second half after the inflection point
|
||||
bezier_points += calc_bezier_curve(
|
||||
mid_pt,
|
||||
(mid_pt[0], conjunction_pt[1]),
|
||||
conjunction_pt,
|
||||
curve_steps
|
||||
)
|
||||
|
||||
input_curves.append(bezier_points)
|
||||
|
||||
prev_pt = bezier_points[0]
|
||||
for pt in bezier_points[1:]:
|
||||
draw.line(
|
||||
(prev_pt[0], prev_pt[1], pt[0], pt[1]),
|
||||
fill=association_line_color,
|
||||
width=association_line_width + 1,
|
||||
joint="curve",
|
||||
)
|
||||
prev_pt = pt
|
||||
|
||||
inputs_y += inputs_y_spacing
|
||||
|
||||
# Render center bar
|
||||
draw.line(
|
||||
(
|
||||
center_bar_x,
|
||||
vertical_center,
|
||||
center_bar_x + center_bar_width,
|
||||
vertical_center
|
||||
),
|
||||
fill=association_line_color,
|
||||
width=association_line_width
|
||||
)
|
||||
|
||||
# Position each destination
|
||||
num_rendered_destinations = len(destination_column)
|
||||
if num_rendered_destinations == 1:
|
||||
destination_y = vertical_center - int(chart_text_height/2)
|
||||
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,
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
if self.amount <= 1e7:
|
||||
amount_display = f"{self.amount:,} sats"
|
||||
else:
|
||||
amount_display = f"{self.amount/1e8:,} btc"
|
||||
|
||||
icon_text_line = IconTextLine(
|
||||
image_draw=draw,
|
||||
canvas=center_img,
|
||||
icon_name=SeedSignerCustomIconConstants.BITCOIN_LOGO,
|
||||
icon_color=GUIConstants.ACCENT_COLOR,
|
||||
icon_size=28,
|
||||
is_text_centered=True,
|
||||
value_text=f"{amount_display}",
|
||||
font_size=22,
|
||||
screen_y=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=icon_text_line.height + GUIConstants.COMPONENT_PADDING,
|
||||
font_size=24,
|
||||
address=self.address,
|
||||
)
|
||||
|
||||
# Render each to the temp img we passed in
|
||||
icon_text_line.render()
|
||||
formatted_address.render()
|
||||
|
||||
self.body_img = center_img.crop((
|
||||
0,
|
||||
0,
|
||||
self.canvas_width,
|
||||
formatted_address.screen_y + formatted_address.height
|
||||
))
|
||||
body_img_y = self.top_nav.height + int((center_img_height - self.body_img.height - GUIConstants.COMPONENT_PADDING)/2)
|
||||
|
||||
self.paste_images.append((self.body_img, (0, body_img_y)))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PSBTChangeDetailsScreen(ButtonListScreen):
|
||||
title: str = "Your Change"
|
||||
amount: int = 0
|
||||
address: str = None
|
||||
is_multisig: bool = False
|
||||
fingerprint: str = None
|
||||
derivation_path: str = None
|
||||
is_change_derivation_path: bool = True
|
||||
derivation_path_addr_index: int = 0
|
||||
is_change_addr_verified: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.is_bottom_list = True
|
||||
super().__post_init__()
|
||||
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.BITCOIN_LOGO,
|
||||
icon_color=GUIConstants.ACCENT_COLOR,
|
||||
icon_size=28,
|
||||
value_text=f"{self.amount} sats" if self.amount < 1e6 else f"{self.amount/1e8:0.8f} btc",
|
||||
font_size=22,
|
||||
is_text_centered=True,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
|
||||
))
|
||||
self.components.append(FormattedAddress(
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
|
||||
address=self.address,
|
||||
max_lines=1,
|
||||
))
|
||||
|
||||
screen_y = self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING
|
||||
if self.is_multisig and not self.is_change_addr_verified:
|
||||
# Adjust the vertical spacing
|
||||
screen_y -= GUIConstants.COMPONENT_PADDING
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
|
||||
icon_color="blue",
|
||||
value_text=f"""{"Multisig" if self.is_multisig else self.fingerprint}: {"Change" if self.is_change_derivation_path else "Addr"} #{self.derivation_path_addr_index}""",
|
||||
is_text_centered=False,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=screen_y,
|
||||
))
|
||||
|
||||
if self.is_change_addr_verified:
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.CIRCLE_CHECK,
|
||||
icon_color="#00dd00",
|
||||
value_text="Address verified!",
|
||||
is_text_centered=False,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PSBTFinalizeScreen(ButtonListScreen):
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.title = "Sign PSBT"
|
||||
self.is_bottom_list = True
|
||||
super().__post_init__()
|
||||
|
||||
icon = Icon(
|
||||
icon_name=FontAwesomeIconConstants.PAPER_PLANE,
|
||||
icon_color=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,
|
||||
))
|
||||
@@ -0,0 +1,127 @@
|
||||
import time
|
||||
|
||||
from dataclasses import dataclass
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from threading import Thread
|
||||
|
||||
from .screen import BaseTopNavScreen, ButtonListScreen
|
||||
from ..components import GUIConstants, Fonts, TextArea, calc_text_centering
|
||||
|
||||
from seedsigner.hardware.buttons import HardwareButtonsConstants
|
||||
from seedsigner.models import DecodeQR, DecodeQRStatus
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanScreen(BaseTopNavScreen):
|
||||
decoder: DecodeQR = None
|
||||
instructions_text: str = "Scan a QR code"
|
||||
|
||||
def __post_init__(self):
|
||||
from seedsigner.hardware.camera import Camera
|
||||
|
||||
# Customize defaults
|
||||
self.title = "Scan"
|
||||
|
||||
# Initialize the base class
|
||||
super().__post_init__()
|
||||
|
||||
self.camera = Camera.get_instance()
|
||||
self.camera.start_video_stream_mode(resolution=(480, 480), framerate=12, format="rgb")
|
||||
|
||||
# Prep the bottom semi-transparent instruction bar
|
||||
self.instructions_background = Image.new("RGBA", (self.canvas_width, 40), color="black")
|
||||
self.instructions_background_y = self.canvas_height - self.instructions_background.height
|
||||
|
||||
# Pre-calc where the instruction text goes
|
||||
self.instructions_font = Fonts.get_font(GUIConstants.BUTTON_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
|
||||
|
||||
# TODO: Add the QR code icon and adjust start_x
|
||||
(self.instructions_text_x, self.instructions_text_y) = calc_text_centering(
|
||||
font=self.instructions_font,
|
||||
text=self.instructions_text,
|
||||
is_text_centered=True,
|
||||
total_width=self.canvas_width,
|
||||
total_height=self.instructions_background.height,
|
||||
start_x=0,
|
||||
start_y=0
|
||||
)
|
||||
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
_render() is mostly meant to be a one-time initial drawing call to set up the
|
||||
Screen. Once interaction starts, the display updates have to be managed in
|
||||
_run(). The live preview is an extra-complex case.
|
||||
"""
|
||||
def live_preview():
|
||||
while True:
|
||||
frame = self.camera.read_video_stream(as_image=True)
|
||||
if frame is not None:
|
||||
scan_text = self.instructions_text
|
||||
if self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt:
|
||||
scan_text = str(self.decoder.get_percent_complete()) + "% Complete"
|
||||
|
||||
# TODO: Render TopNav & instructions_background w/transparency
|
||||
# img = Image.new(mode='RGBA', size=(self.canvas_width, self.canvas_height))
|
||||
# img.paste(frame.resize((self.canvas_width, self.canvas_height)))
|
||||
self.renderer.show_image_with_text(frame.resize((self.canvas_width, self.canvas_height), resample=Image.NEAREST), scan_text, font=self.instructions_font, text_color="white", text_background=(0,0,0,225))
|
||||
# self.top_nav.render()
|
||||
# self.renderer.show_image()
|
||||
|
||||
time.sleep(0.1) # turn this up or down to tune performance while decoding psbt
|
||||
if self.camera._video_stream is None:
|
||||
break
|
||||
|
||||
# putting live preview in its own thread to improve psbt decoding performance
|
||||
t = Thread(target=live_preview)
|
||||
t.start()
|
||||
|
||||
while True:
|
||||
frame = self.camera.read_video_stream()
|
||||
if frame is not None:
|
||||
status = self.decoder.add_image(frame)
|
||||
|
||||
if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID):
|
||||
self.camera.stop_video_stream_mode()
|
||||
break
|
||||
|
||||
# TODO: KEY_UP gives control to NavBar; use its back arrow to cancel
|
||||
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT) or self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
|
||||
self.camera.stop_video_stream_mode()
|
||||
break
|
||||
|
||||
time.sleep(0.2) # time to let live preview thread complete to avoid race condition on display
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsUpdatedScreen(ButtonListScreen):
|
||||
config_name: str = None
|
||||
title: str = "Settings QR"
|
||||
is_bottom_list: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.button_data = ["Home"]
|
||||
|
||||
super().__post_init__()
|
||||
|
||||
start_y = self.top_nav.height + 20
|
||||
if self.config_name:
|
||||
self.config_name_textarea = TextArea(
|
||||
text=f'"{self.config_name}"',
|
||||
is_text_centered=True,
|
||||
auto_line_break=True,
|
||||
screen_y=start_y
|
||||
)
|
||||
self.components.append(self.config_name_textarea)
|
||||
start_y = self.config_name_textarea.screen_y + 50
|
||||
|
||||
self.components.append(TextArea(
|
||||
text="Settings imported successfully!",
|
||||
is_text_centered=True,
|
||||
auto_line_break=True,
|
||||
screen_y=start_y
|
||||
))
|
||||
|
||||
@@ -0,0 +1,829 @@
|
||||
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 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:
|
||||
print(e)
|
||||
print("------")
|
||||
repr(e)
|
||||
raise e
|
||||
finally:
|
||||
for t in self.threads:
|
||||
t.stop()
|
||||
|
||||
|
||||
def clear_screen(self):
|
||||
# Clear the whole canvas
|
||||
self.image_draw.rectangle(
|
||||
(0, 0, self.canvas_width, self.canvas_height),
|
||||
fill=0,
|
||||
)
|
||||
|
||||
|
||||
def _render(self):
|
||||
self.clear_screen()
|
||||
|
||||
# TODO: Check self.scroll_y and only render visible elements
|
||||
for component in self.components:
|
||||
component.render()
|
||||
|
||||
for img, coords in self.paste_images:
|
||||
self.canvas.paste(img, coords)
|
||||
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
Screen can run on its own until it returns a final exit input from the user.
|
||||
|
||||
For example: A basic menu screen where the user can key up and down. The
|
||||
Screen can handle the UI updates to light up the currently selected menu item
|
||||
on its own. Only when the user clicks to make a selection would run() exit
|
||||
and returns the selected option.
|
||||
|
||||
But an alternate use case returns immediately after each user input so the
|
||||
View can update its controlling logic accordingly (e.g. as the user joysticks
|
||||
over different letters in the keyboard UI, we need to make matching changes
|
||||
to the list of mnemonic seed words that match the new letter).
|
||||
|
||||
In this case, it would be called repeatedly in a loop:
|
||||
* run() and wait for it to handle user input
|
||||
* run() exits and returns the user input (e.g. KEY_UP)
|
||||
* View updates its state of the world accordingly
|
||||
* loop and call run() again
|
||||
"""
|
||||
raise Exception("Must implement in a child class")
|
||||
|
||||
|
||||
|
||||
class LoadingScreenThread(BaseThread):
|
||||
def __init__(self, text: str = None):
|
||||
super().__init__()
|
||||
self.text =text
|
||||
|
||||
|
||||
def run(self):
|
||||
renderer: Renderer = Renderer.get_instance()
|
||||
|
||||
center_image = load_image("btc_logo_60x60.png")
|
||||
orbit_gap = 2*GUIConstants.COMPONENT_PADDING
|
||||
bounding_box = (
|
||||
int((renderer.canvas_width - center_image.width)/2 - orbit_gap),
|
||||
int((renderer.canvas_height - center_image.height)/2 - orbit_gap),
|
||||
int((renderer.canvas_width + center_image.width)/2 + orbit_gap),
|
||||
int((renderer.canvas_height + center_image.height)/2 + orbit_gap),
|
||||
)
|
||||
position = 0
|
||||
arc_sweep = 45
|
||||
arc_color = "#ff9416"
|
||||
arc_trailing_color = "#80490b"
|
||||
|
||||
# Need to flush the screen
|
||||
with renderer.lock:
|
||||
renderer.draw.rectangle((0, 0, renderer.canvas_width, renderer.canvas_height), fill=GUIConstants.BACKGROUND_COLOR)
|
||||
renderer.canvas.paste(center_image, (bounding_box[0] + orbit_gap, bounding_box[1] + orbit_gap))
|
||||
|
||||
if self.text:
|
||||
TextArea(
|
||||
text=self.text,
|
||||
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
|
||||
screen_y=int((renderer.canvas_height - bounding_box[3])/2),
|
||||
).render()
|
||||
|
||||
while self.keep_running:
|
||||
with renderer.lock:
|
||||
# Render leading arc
|
||||
renderer.draw.arc(
|
||||
bounding_box,
|
||||
start=position,
|
||||
end=position + arc_sweep,
|
||||
fill=arc_color,
|
||||
width=GUIConstants.COMPONENT_PADDING
|
||||
)
|
||||
|
||||
# Render trailing arc
|
||||
renderer.draw.arc(
|
||||
bounding_box,
|
||||
start=position - arc_sweep,
|
||||
end=position,
|
||||
fill=arc_trailing_color,
|
||||
width=GUIConstants.COMPONENT_PADDING
|
||||
)
|
||||
|
||||
# Erase previous trailing arc leading arc
|
||||
renderer.draw.arc(
|
||||
bounding_box,
|
||||
start=position - 2*arc_sweep,
|
||||
end=position - arc_sweep,
|
||||
fill=GUIConstants.BACKGROUND_COLOR,
|
||||
width=GUIConstants.COMPONENT_PADDING
|
||||
)
|
||||
|
||||
renderer.show_image()
|
||||
position += arc_sweep
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseTopNavScreen(BaseScreen):
|
||||
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):
|
||||
raise Exception("Must implement in a child class")
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextTopNavScreen(BaseTopNavScreen):
|
||||
text: str = "Body text"
|
||||
is_text_centered: bool = True
|
||||
text_font_name: str = GUIConstants.BODY_FONT_NAME
|
||||
text_font_size: int = GUIConstants.BODY_FONT_SIZE
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
|
||||
self.text_area = TextArea(
|
||||
text=self.text,
|
||||
screen_x=0,
|
||||
screen_y=self.top_nav.height,
|
||||
width=self.canvas_width,
|
||||
height=self.canvas_height - self.top_nav.height,
|
||||
font_name=self.text_font_name,
|
||||
font_size=self.text_font_size,
|
||||
is_text_centered=self.is_text_centered
|
||||
)
|
||||
self.components.append(self.text_area)
|
||||
|
||||
|
||||
def _run(self):
|
||||
while True:
|
||||
user_input = self.hw_inputs.wait_for(
|
||||
[
|
||||
HardwareButtonsConstants.KEY_UP,
|
||||
HardwareButtonsConstants.KEY_DOWN,
|
||||
HardwareButtonsConstants.KEY_PRESS
|
||||
],
|
||||
check_release=True,
|
||||
release_keys=[HardwareButtonsConstants.KEY_PRESS]
|
||||
)
|
||||
|
||||
with self.renderer.lock:
|
||||
if user_input == HardwareButtonsConstants.KEY_UP:
|
||||
if not self.top_nav.is_selected:
|
||||
# Only move navigation up there if there's something to select
|
||||
if self.top_nav.show_back_button or self.top_nav.show_power_button:
|
||||
self.top_nav.is_selected = True
|
||||
self.top_nav.render()
|
||||
|
||||
elif user_input == HardwareButtonsConstants.KEY_DOWN:
|
||||
if self.top_nav.is_selected:
|
||||
self.top_nav.is_selected = False
|
||||
self.top_nav.render()
|
||||
|
||||
elif user_input == HardwareButtonsConstants.KEY_PRESS:
|
||||
if self.top_nav.is_selected:
|
||||
return self.top_nav.selected_button
|
||||
|
||||
# Write the screen updates
|
||||
self.renderer.show_image()
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonListScreen(BaseTopNavScreen):
|
||||
button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str)
|
||||
selected_button: int = 0
|
||||
is_button_text_centered: bool = True
|
||||
is_bottom_list: bool = False
|
||||
button_font_name: str = GUIConstants.BUTTON_FONT_NAME
|
||||
button_font_size: int = GUIConstants.BUTTON_FONT_SIZE
|
||||
button_selected_color: str = GUIConstants.ACCENT_COLOR
|
||||
|
||||
# Params for version of list used for Settings
|
||||
Button_cls = Button
|
||||
checked_buttons: List[int] = None
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
button_height = GUIConstants.BUTTON_HEIGHT
|
||||
if len(self.button_data) == 1:
|
||||
button_list_height = button_height
|
||||
else:
|
||||
button_list_height = (len(self.button_data) * button_height) + (GUIConstants.COMPONENT_PADDING * (len(self.button_data) - 1))
|
||||
|
||||
if self.is_bottom_list:
|
||||
button_list_y = self.canvas_height - (button_list_height + GUIConstants.EDGE_PADDING)
|
||||
else:
|
||||
button_list_y = self.top_nav.height + int((self.canvas_height - self.top_nav.height - button_list_height) / 2)
|
||||
|
||||
self.has_scroll_arrows = False
|
||||
if button_list_y < self.top_nav.height:
|
||||
# The button list is too long; force it to run off the bottom of the screen.
|
||||
button_list_y = self.top_nav.height
|
||||
self.has_scroll_arrows = True
|
||||
|
||||
self.buttons: List[Button] = []
|
||||
for i, button_label in enumerate(self.button_data):
|
||||
icon_name = None
|
||||
icon_color = None
|
||||
button_label_color = GUIConstants.BUTTON_FONT_COLOR
|
||||
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
|
||||
|
||||
button_kwargs = dict(
|
||||
text=button_label,
|
||||
icon_name=icon_name,
|
||||
icon_color=icon_color,
|
||||
is_icon_inline=True,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=button_list_y + i * (button_height + GUIConstants.LIST_ITEM_PADDING),
|
||||
width=self.canvas_width - (2 * GUIConstants.EDGE_PADDING),
|
||||
height=button_height,
|
||||
is_text_centered=self.is_button_text_centered,
|
||||
font_name=self.button_font_name,
|
||||
font_size=self.button_font_size,
|
||||
font_color=button_label_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:
|
||||
user_input = self.hw_inputs.wait_for(
|
||||
[
|
||||
HardwareButtonsConstants.KEY_UP,
|
||||
HardwareButtonsConstants.KEY_DOWN,
|
||||
HardwareButtonsConstants.KEY_LEFT,
|
||||
HardwareButtonsConstants.KEY_RIGHT,
|
||||
HardwareButtonsConstants.KEY_PRESS
|
||||
],
|
||||
check_release=True,
|
||||
release_keys=[HardwareButtonsConstants.KEY_PRESS]
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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 == HardwareButtonsConstants.KEY_PRESS:
|
||||
if self.top_nav.is_selected:
|
||||
return self.top_nav.selected_button
|
||||
return self.selected_button
|
||||
|
||||
# Write the screen updates
|
||||
self.renderer.show_image()
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class LargeButtonScreen(BaseTopNavScreen):
|
||||
button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str)
|
||||
button_font_name: str = GUIConstants.BUTTON_FONT_NAME
|
||||
button_font_size: int = 20
|
||||
button_selected_color: str = GUIConstants.ACCENT_COLOR
|
||||
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:
|
||||
user_input = self.hw_inputs.wait_for(
|
||||
[
|
||||
HardwareButtonsConstants.KEY_UP,
|
||||
HardwareButtonsConstants.KEY_DOWN,
|
||||
HardwareButtonsConstants.KEY_LEFT,
|
||||
HardwareButtonsConstants.KEY_RIGHT,
|
||||
HardwareButtonsConstants.KEY_PRESS
|
||||
],
|
||||
check_release=True,
|
||||
release_keys=[HardwareButtonsConstants.KEY_PRESS]
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
self.buttons[self.selected_button].is_selected = False
|
||||
self.buttons[self.selected_button].render()
|
||||
|
||||
elif user_input == HardwareButtonsConstants.KEY_PRESS:
|
||||
if self.top_nav.is_selected:
|
||||
return self.top_nav.selected_button
|
||||
return self.selected_button
|
||||
|
||||
# Write the screen updates
|
||||
self.renderer.show_image()
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class QRDisplayScreen(BaseScreen):
|
||||
qr_encoder: EncodeQR = None
|
||||
|
||||
def _run(self):
|
||||
from seedsigner.models.settings import Settings
|
||||
settings = Settings.get_instance()
|
||||
cur_brightness = settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS)
|
||||
|
||||
# Loop whether the QR is a single frame or animated; each loop might adjust
|
||||
# brightness setting.
|
||||
while True:
|
||||
# convert the cur_brightness integer (31-255) into hex triplets
|
||||
hex_color = (hex(cur_brightness).split('x')[1]) * 3
|
||||
image = self.qr_encoder.next_part_image(240,240, border=2, background_color=hex_color)
|
||||
self.renderer.show_image(image)
|
||||
|
||||
# Target n held frames per second before rendering next QR image
|
||||
time.sleep(5/30.0)
|
||||
|
||||
if self.hw_inputs.check_for_low(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
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
))
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
while self.keep_running:
|
||||
with screen.renderer.lock:
|
||||
# Ramp the edges from a darker version out to full color
|
||||
inhale_scalar = inhale_factor * int(255/inhale_max)
|
||||
for index, n in enumerate(range(4, -1, -1)):
|
||||
# Reverse range steadily increases rgb in brightness until reaching full.
|
||||
# 34 == 0x22; just eyeballed a good step size
|
||||
|
||||
r = max(0, rgb[0] - 34*n - inhale_scalar)
|
||||
g = max(0, rgb[1] - 34*n - inhale_scalar)
|
||||
b = max(0, rgb[2] - 34*n - inhale_scalar)
|
||||
|
||||
# `index` shrinks the border at each step
|
||||
render_border((r, g, b), GUIConstants.EDGE_PADDING - 2 - index)
|
||||
|
||||
# Write the screen updates
|
||||
screen.renderer.show_image()
|
||||
|
||||
if inhale_factor == inhale_max:
|
||||
inhale_step = -1
|
||||
elif inhale_factor == 0 and inhale_step == -1:
|
||||
cur_inhale_hold += 1
|
||||
if cur_inhale_hold > inhale_hold:
|
||||
inhale_step = 1
|
||||
cur_inhale_hold = 0
|
||||
else:
|
||||
# It's about to be decremented below zero
|
||||
inhale_factor = 1
|
||||
inhale_factor += inhale_step
|
||||
|
||||
# Target ~10fps
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class 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
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
from seedsigner.gui.components import CheckboxButton, CheckedSelectionButton, GUIConstants, TextArea
|
||||
|
||||
from seedsigner.gui.screens.screen import ButtonListScreen
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
|
||||
display_name: str = None
|
||||
help_text: str = None
|
||||
checked_buttons: List[int] = None
|
||||
settings_entry_type: str = SettingsConstants.TYPE__ENABLED_DISABLED
|
||||
selected_button: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self.title = "Settings"
|
||||
self.is_bottom_list = True
|
||||
self.use_checked_selection_buttons = True
|
||||
if self.settings_entry_type == SettingsConstants.TYPE__MULTISELECT:
|
||||
self.Button_cls = CheckboxButton
|
||||
else:
|
||||
self.Button_cls = CheckedSelectionButton
|
||||
super().__post_init__()
|
||||
|
||||
self.title_textarea = TextArea(
|
||||
text=self.display_name,
|
||||
font_size=GUIConstants.BODY_FONT_MAX_SIZE,
|
||||
is_text_centered=True,
|
||||
auto_line_break=False,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
|
||||
)
|
||||
self.components.append(self.title_textarea)
|
||||
|
||||
if self.help_text:
|
||||
self.title_textarea = TextArea(
|
||||
text=self.help_text,
|
||||
font_color=GUIConstants.LABEL_FONT_COLOR,
|
||||
is_text_centered=True,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
)
|
||||
self.components.append(self.title_textarea)
|
||||
@@ -0,0 +1,98 @@
|
||||
import time
|
||||
|
||||
from dataclasses import dataclass
|
||||
from PIL.Image import Image
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.gui.components import Fonts, GUIConstants, IconTextLine, SeedSignerCustomIconConstants, TextArea
|
||||
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, 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
|
||||
|
||||
while True:
|
||||
frame = self.camera.read_video_stream(as_image=True)
|
||||
if frame is not None:
|
||||
self.renderer.show_image_with_text(frame, "click joystick", text_color=GUIConstants.BODY_FONT_COLOR, text_background=(0,0,0,225))
|
||||
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()
|
||||
return preview_images
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsImageEntropyFinalImageScreen(BaseScreen):
|
||||
final_image: Image = None
|
||||
|
||||
def _run(self):
|
||||
self.renderer.show_image_with_text(
|
||||
self.final_image,
|
||||
text=" < reshoot | accept > ",
|
||||
font=Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE),
|
||||
text_color=GUIConstants.BODY_FONT_COLOR,
|
||||
text_background=(0,0,0,225)
|
||||
)
|
||||
|
||||
input = self.hw_inputs.wait_for([HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT])
|
||||
if input == HardwareButtonsConstants.KEY_LEFT:
|
||||
return RET_CODE__BACK_BUTTON
|
||||
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
@@ -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,6 @@ class B:
|
||||
KEY3,
|
||||
]
|
||||
|
||||
KEYS__LEFT_RIGHT_UP_DOWN = [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN]
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 *
|
||||
@@ -1,33 +1,46 @@
|
||||
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()
|
||||
|
||||
# Return as a list
|
||||
@@ -37,7 +50,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 *
|
||||
|
||||
@@ -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
|
||||
@@ -1,77 +1,110 @@
|
||||
from binascii import hexlify
|
||||
from embit import psbt, script, ec, bip32, bip39
|
||||
from embit.networks import NETWORKS
|
||||
from embit.psbt import PSBT
|
||||
from io import BytesIO
|
||||
from typing import List
|
||||
from seedsigner.models import Seed
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
|
||||
class PSBTParser():
|
||||
|
||||
def __init__(self, p, seed: Seed, network="main"):
|
||||
def __init__(self, p: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET):
|
||||
self.psbt: PSBT = p
|
||||
self.seed = seed
|
||||
self.network = network
|
||||
self.psbt = p
|
||||
|
||||
self.policy = None
|
||||
self.spend_amount = 0
|
||||
self.change_amount = 0
|
||||
self.change_data = []
|
||||
self.fee_amount = 0
|
||||
self.input_amount = 0
|
||||
self.num_inputs = 0
|
||||
self.destination_addresses = []
|
||||
self.self_addresses = []
|
||||
self.destination_amounts = []
|
||||
|
||||
self.root = None
|
||||
|
||||
if self.seed:
|
||||
self.parse(self.psbt,self.seed,self.network)
|
||||
if self.seed is not None:
|
||||
self.parse()
|
||||
|
||||
def __setRoot(self, seed: Seed, network):
|
||||
self.root = bip32.HDKey.from_seed(seed.seed, version=NETWORKS[network]["xprv"])
|
||||
|
||||
def parse(self, p, seed: Seed, network="main"):
|
||||
is_psbt_empty = False
|
||||
try:
|
||||
if p == None:
|
||||
is_psbt_empty = True
|
||||
except:
|
||||
pass
|
||||
def get_change_data(self, change_num: int) -> dict:
|
||||
if change_num < len(self.change_data):
|
||||
return self.change_data[change_num]
|
||||
|
||||
if is_psbt_empty:
|
||||
|
||||
@property
|
||||
def num_change_outputs(self):
|
||||
return len(self.change_data)
|
||||
|
||||
|
||||
@property
|
||||
def is_multisig(self):
|
||||
"""
|
||||
Multisig psbts will have "m" and "n" defined in policy
|
||||
"""
|
||||
return "m" in self.policy
|
||||
|
||||
|
||||
@property
|
||||
def num_destinations(self):
|
||||
return len(self.destination_addresses)
|
||||
|
||||
|
||||
def _set_root(self):
|
||||
self.root = bip32.HDKey.from_seed(self.seed.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]["xprv"])
|
||||
print(f"root: {self.root}")
|
||||
|
||||
|
||||
def parse(self):
|
||||
if self.psbt is None:
|
||||
print(f"self.psbt is None!!")
|
||||
return False
|
||||
|
||||
if not seed:
|
||||
if not self.seed:
|
||||
print("self.seed is None!")
|
||||
return False
|
||||
|
||||
self.__setRoot(seed, network)
|
||||
self._set_root()
|
||||
|
||||
rt = self.__parseInputs()
|
||||
rt = self._parse_inputs()
|
||||
if rt == False:
|
||||
return False
|
||||
|
||||
rt = self.__parseOutputs()
|
||||
rt = self._parse_outputs()
|
||||
if rt == False:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def __parseInputs(self):
|
||||
|
||||
def _parse_inputs(self):
|
||||
self.input_amount = 0
|
||||
print(f"psbt.inputs: {self.psbt.inputs}")
|
||||
self.num_inputs = len(self.psbt.inputs)
|
||||
for inp in self.psbt.inputs:
|
||||
if inp.witness_utxo:
|
||||
self.input_amount += inp.witness_utxo.value
|
||||
inp_policy = PSBTParser.__get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs)
|
||||
inp_policy = PSBTParser._get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs)
|
||||
if self.policy == None:
|
||||
self.policy = inp_policy
|
||||
else:
|
||||
if self.policy != inp_policy:
|
||||
raise RuntimeError("Mixed inputs in the transaction")
|
||||
|
||||
def __parseOutputs(self):
|
||||
|
||||
def _parse_outputs(self):
|
||||
self.spend_amount = 0
|
||||
self.change_amount = 0
|
||||
self.change_data = []
|
||||
self.fee_amount = 0
|
||||
self.destination_addresses = []
|
||||
self.self_addresses = []
|
||||
self.destination_amounts = []
|
||||
for i, out in enumerate(self.psbt.outputs):
|
||||
out_policy = PSBTParser.__get_policy(out, self.psbt.tx.vout[i].script_pubkey, self.psbt.xpubs)
|
||||
out_policy = PSBTParser._get_policy(out, self.psbt.tx.vout[i].script_pubkey, self.psbt.xpubs)
|
||||
is_change = False
|
||||
|
||||
# if policy is the same - probably change
|
||||
@@ -109,15 +142,32 @@ class PSBTParser():
|
||||
if sc.data == self.psbt.tx.vout[i].script_pubkey.data:
|
||||
is_change = True
|
||||
if is_change:
|
||||
addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
|
||||
fingerprints = None
|
||||
derivation_paths = None
|
||||
if len(self.psbt.outputs[i].bip32_derivations) > 0:
|
||||
fingerprints = []
|
||||
derivation_paths = []
|
||||
for d, derivation_path in self.psbt.outputs[i].bip32_derivations.items():
|
||||
fingerprints.append(hexlify(derivation_path.fingerprint).decode())
|
||||
derivation_paths.append(bip32.path_to_str(derivation_path.derivation))
|
||||
self.change_data.append({
|
||||
"address": addr,
|
||||
"amount": self.psbt.tx.vout[i].value,
|
||||
"fingerprint": fingerprints,
|
||||
"derivation_path": derivation_paths,
|
||||
})
|
||||
self.change_amount += self.psbt.tx.vout[i].value
|
||||
self.self_addresses.append(self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[self.network]))
|
||||
else:
|
||||
addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
|
||||
self.destination_addresses.append(addr)
|
||||
self.destination_amounts.append(self.psbt.tx.vout[i].value)
|
||||
self.spend_amount += self.psbt.tx.vout[i].value
|
||||
self.destination_addresses.append(self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[self.network]))
|
||||
|
||||
self.fee_amount = self.psbt.fee()
|
||||
return True
|
||||
|
||||
|
||||
@staticmethod
|
||||
def trim(tx):
|
||||
trimmed_psbt = psbt.PSBT(tx.tx)
|
||||
@@ -128,17 +178,54 @@ class PSBTParser():
|
||||
|
||||
return trimmed_psbt
|
||||
|
||||
|
||||
@staticmethod
|
||||
def sigCount(tx):
|
||||
def sig_count(tx):
|
||||
cnt = 0
|
||||
for i, inp in enumerate(tx.inputs):
|
||||
cnt += len(list(inp.partial_sigs.keys()))
|
||||
|
||||
return cnt
|
||||
|
||||
# checks that all inputs are from the same wallet
|
||||
|
||||
@staticmethod
|
||||
def __get_policy(scope, scriptpubkey, xpubs):
|
||||
def calc_derivation(network, wallet_type, script_type):
|
||||
# TODO: Move this to Seed?
|
||||
if network == SettingsConstants.MAINNET:
|
||||
network_path = "0'"
|
||||
elif network == SettingsConstants.TESTNET:
|
||||
network_path = "1'"
|
||||
elif network == SettingsConstants.REGTEST:
|
||||
# TODO: Is this right?
|
||||
network_path = "1'"
|
||||
else:
|
||||
raise Exception("Unexpected network")
|
||||
|
||||
if wallet_type == SettingsConstants.SINGLE_SIG:
|
||||
if script_type == SettingsConstants.NATIVE_SEGWIT:
|
||||
return f"m/84'/{network_path}/0'"
|
||||
elif script_type == SettingsConstants.NESTED_SEGWIT:
|
||||
return f"m/49'/{network_path}/0'"
|
||||
elif script_type == SettingsConstants.TAPROOT:
|
||||
return f"m/86'/{network_path}/0'"
|
||||
else:
|
||||
raise Exception("Unexpected script type")
|
||||
|
||||
elif wallet_type == SettingsConstants.MULTISIG:
|
||||
if script_type == SettingsConstants.NATIVE_SEGWIT:
|
||||
return f"m/48'/{network_path}/0'/2'"
|
||||
elif script_type == SettingsConstants.NESTED_SEGWIT:
|
||||
return f"m/48'/{network_path}/0'/1'"
|
||||
elif script_type == SettingsConstants.TAPROOT:
|
||||
raise Exception("Taproot multisig/musig not yet supported")
|
||||
else:
|
||||
raise Exception("Unexpected script type")
|
||||
else:
|
||||
raise Exception("Unexpected wallet type") # checks that all inputs are from the same wallet
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_policy(scope, scriptpubkey, xpubs):
|
||||
"""Parse scope and get policy"""
|
||||
# we don't know the policy yet, let's parse it
|
||||
script_type = scriptpubkey.script_type()
|
||||
@@ -155,18 +242,18 @@ class PSBTParser():
|
||||
policy = {"type": script_type}
|
||||
# expected multisig
|
||||
if "p2wsh" in script_type and scope.witness_script is not None:
|
||||
m, n, pubkeys = PSBTParser.__parse_multisig(scope.witness_script)
|
||||
m, n, pubkeys = PSBTParser._parse_multisig(scope.witness_script)
|
||||
# check pubkeys are derived from cosigners
|
||||
try:
|
||||
cosigners = PSBTParser.__get_cosigners(pubkeys, scope.bip32_derivations, xpubs)
|
||||
cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs)
|
||||
policy.update({"m": m, "n": n, "cosigners": cosigners})
|
||||
except:
|
||||
policy.update({"m": m, "n": n})
|
||||
return policy
|
||||
|
||||
# returns m, n, and pubkeys from multisig script
|
||||
|
||||
@staticmethod
|
||||
def __parse_multisig(sc):
|
||||
def _parse_multisig(sc):
|
||||
"""Takes a script and extracts m,n and pubkeys from it"""
|
||||
# OP_m <len:pubkey> ... <len:pubkey> OP_n OP_CHECKMULTISIG
|
||||
# check min size
|
||||
@@ -193,8 +280,9 @@ class PSBTParser():
|
||||
raise ValueError("Invalid multisig script")
|
||||
return m, n, pubkeys
|
||||
|
||||
|
||||
@staticmethod
|
||||
def __get_cosigners(pubkeys, derivations, xpubs):
|
||||
def _get_cosigners(pubkeys, derivations, xpubs):
|
||||
"""Returns xpubs used to derive pubkeys using global xpub field from psbt"""
|
||||
cosigners = []
|
||||
for i, pubkey in enumerate(pubkeys):
|
||||
@@ -215,3 +303,33 @@ class PSBTParser():
|
||||
if len(cosigners) != len(pubkeys):
|
||||
raise RuntimeError("Can't get all cosigners")
|
||||
return sorted(cosigners)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_input_fingerprints(psbt: PSBT) -> List[str]:
|
||||
"""
|
||||
Exctracts the fingerprint from each input's derivation path.
|
||||
|
||||
TODO: It's unclear if these derivations/fingerprints would ever be missing.
|
||||
Research on PSBT standard and known wallet coordinator implementations
|
||||
needed.
|
||||
"""
|
||||
fingerprints = set()
|
||||
for input in psbt.inputs:
|
||||
for pub, derivation_path in input.bip32_derivations.items():
|
||||
fingerprints.add(hexlify(derivation_path.fingerprint).decode())
|
||||
return list(fingerprints)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def has_matching_input_fingerprint(psbt: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET):
|
||||
"""
|
||||
Extracts the fingerprint from each psbt input utxo. Returns True if any match
|
||||
the current seed.
|
||||
"""
|
||||
seed_fingerprint = seed.get_fingerprint(network)
|
||||
for input in psbt.inputs:
|
||||
for pub, derivation_path in input.bip32_derivations.items():
|
||||
if seed_fingerprint == hexlify(derivation_path.fingerprint).decode():
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
from enum import IntEnum
|
||||
class QRType:
|
||||
"""
|
||||
Used with DecodeQR and EncodeQR to communicate qr encoding type
|
||||
"""
|
||||
PSBT__BASE64 = "psbt__base64"
|
||||
PSBT__SPECTER = "psbt__specter"
|
||||
PSBT__LEGACY_UR = "psbt__legacy_ur"
|
||||
PSBT__BASE43 = "psbt__base43"
|
||||
PSBT__UR2 = "psbt__ur2"
|
||||
|
||||
###
|
||||
### QRType Class IntEum
|
||||
### Purpose: used with DecodeQR and EncodeQR to communicate qr encoding type
|
||||
###
|
||||
SEED__SEEDQR = "seed__seedqr"
|
||||
SEED__COMPACTSEEDQR = "seed__compactseedqr"
|
||||
SEED__UR2 = "seed__ur2"
|
||||
SEED__MNEMONIC = "seed__mnemonic"
|
||||
SEED__FOUR_LETTER_MNEMONIC = "seed__four_letter_mnemonic"
|
||||
|
||||
class QRType(IntEnum):
|
||||
PSBTBASE64 = 1
|
||||
PSBTSPECTER = 2
|
||||
PSBTURLEGACY = 3
|
||||
PSBTBASE43 = 4
|
||||
PSBTUR2 = 5
|
||||
SEEDQR = 6
|
||||
COMPACTSEEDQR = 10
|
||||
SEEDUR2 = 7
|
||||
SEEDMNEMONIC = 8
|
||||
SEED4LETTERMNEMONIC = 9
|
||||
XPUBQR = 20
|
||||
SPECTERXPUBQR = 21
|
||||
URXPUBQR = 22
|
||||
BITCOINADDRESSQR = 30
|
||||
SPECTERWALLETQR = 40
|
||||
URWALLETQR = 41
|
||||
BLUEWALLETQR = 42
|
||||
INVALID = 100
|
||||
SETTINGS = "settings"
|
||||
|
||||
XPUB = "xpub"
|
||||
XPUB__SPECTER = "xpub__specter"
|
||||
XPUB__UR = "xpub__ur"
|
||||
|
||||
BITCOIN_ADDRESS = "bitcoin_address"
|
||||
|
||||
WALLET__SPECTER = "wallet__specter"
|
||||
WALLET__UR = "wallet__ur"
|
||||
WALLET__BLUEWALLET = "wallet__bluewallet"
|
||||
|
||||
INVALID = "invalid"
|
||||
@@ -1,108 +1,114 @@
|
||||
# External Dependencies
|
||||
from embit import bip39, bip32
|
||||
import unicodedata
|
||||
|
||||
from binascii import hexlify
|
||||
from embit import bip39, bip32
|
||||
from embit.networks import NETWORKS
|
||||
from typing import List
|
||||
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
|
||||
class InvalidSeedException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class Seed:
|
||||
def __init__(self,
|
||||
mnemonic: List[str] = None,
|
||||
passphrase: str = "",
|
||||
wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> None:
|
||||
self.wordlist_language_code = wordlist_language_code
|
||||
|
||||
def __init__(self, mnemonic = None, passphrase = "", wordlist=None) -> None:
|
||||
self._init_complete = False
|
||||
self._valid = False
|
||||
self.passphrase = passphrase
|
||||
self.mnemonic = mnemonic
|
||||
self._wordlist = wordlist
|
||||
|
||||
if self._wordlist == None:
|
||||
raise Exception('Wordlist Required')
|
||||
|
||||
self._valid = self._generate_seed()
|
||||
self._init_complete = True
|
||||
|
||||
def _generate_seed(self) -> bool:
|
||||
self.seed = None
|
||||
try:
|
||||
self.seed = bip39.mnemonic_to_seed(self.mnemonic, password=self.passphrase, wordlist=self._wordlist)
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
if not mnemonic:
|
||||
raise Exception("Must initialize a Seed with a mnemonic List[str]")
|
||||
self._mnemonic: List[str] = unicodedata.normalize("NFKD", " ".join(mnemonic).strip()).split()
|
||||
|
||||
self._passphrase: str = ""
|
||||
self.set_passphrase(passphrase, regenerate_seed=False)
|
||||
|
||||
self.seed_bytes: bytes = None
|
||||
self._generate_seed()
|
||||
|
||||
|
||||
### getters and setters
|
||||
|
||||
@property
|
||||
def mnemonic(self):
|
||||
return self._mnemonic
|
||||
|
||||
@property
|
||||
def mnemonic_list(self):
|
||||
return self._mnemonic.split()
|
||||
|
||||
@property
|
||||
def mnemonic_display(self):
|
||||
return unicodedata.normalize("NFC", self._mnemonic)
|
||||
|
||||
@property
|
||||
def mnemonic_display_list(self):
|
||||
return unicodedata.normalize("NFC", self._mnemonic).split()
|
||||
@staticmethod
|
||||
def get_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]:
|
||||
# TODO: Support other bip-39 wordlist languages!
|
||||
if wordlist_language_code == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH:
|
||||
return bip39.WORDLIST
|
||||
else:
|
||||
raise Exception(f"Unrecognized wordlist_language_code {wordlist_language_code}")
|
||||
|
||||
@mnemonic.setter
|
||||
def mnemonic(self, value):
|
||||
if isinstance(value, list):
|
||||
self._mnemonic = unicodedata.normalize("NFKD", " ".join(value).strip())
|
||||
elif isinstance(value, str):
|
||||
self._mnemonic = unicodedata.normalize("NFKD", value.strip())
|
||||
|
||||
if self._init_complete:
|
||||
self._valid = self._generate_seed()
|
||||
|
||||
@property
|
||||
def passphrase(self):
|
||||
return self._passphrase
|
||||
|
||||
@property
|
||||
def passphrase_display(self):
|
||||
return unicodedata.normalize("NFC", self._passphrase)
|
||||
def _generate_seed(self) -> bool:
|
||||
try:
|
||||
self.seed_bytes = bip39.mnemonic_to_seed(self.mnemonic_str, password=self._passphrase, wordlist=self.wordlist)
|
||||
except Exception as e:
|
||||
print(repr(e))
|
||||
raise InvalidSeedException(repr(e))
|
||||
|
||||
@passphrase.setter
|
||||
def passphrase(self, value):
|
||||
if isinstance(value, str):
|
||||
self._passphrase = unicodedata.normalize("NFKD", value)
|
||||
|
||||
if self._init_complete:
|
||||
self._valid = self._generate_seed()
|
||||
@property
|
||||
def mnemonic_str(self) -> str:
|
||||
return " ".join(self._mnemonic)
|
||||
|
||||
|
||||
@property
|
||||
def wordlist(self):
|
||||
return self._wordlist
|
||||
@property
|
||||
def mnemonic_list(self) -> List[str]:
|
||||
return self._mnemonic
|
||||
|
||||
|
||||
@wordlist.setter
|
||||
def wordlist(self, value):
|
||||
previous_wordlist = self._wordlist
|
||||
|
||||
if isinstance(value, list):
|
||||
if len(value) == 2048:
|
||||
self._wordlist = value
|
||||
else:
|
||||
raise Exception('Invalid Wordlist')
|
||||
else:
|
||||
raise Exception('Wordlist Must be List Type')
|
||||
|
||||
if self.mnemonic != None:
|
||||
# if has mnemonic, convert to new wordlist
|
||||
previous_mnemonic_str = self._mnemonic
|
||||
previous_mnemonic_list = previous_mnemonic_str.split()
|
||||
new_mnemonic_list = []
|
||||
for word in previous_mnemonic_list:
|
||||
idx = previous_wordlist.index(word)
|
||||
new_mnemonic_list.append(self._wordlist[idx])
|
||||
|
||||
self._mnemonic = " ".join(new_mnemonic_list)
|
||||
@property
|
||||
def mnemonic_display_str(self) -> str:
|
||||
return unicodedata.normalize("NFC", " ".join(self._mnemonic))
|
||||
|
||||
|
||||
### override operators
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Seed):
|
||||
return self.seed == other.seed
|
||||
return False
|
||||
|
||||
def __bool__(self):
|
||||
return self._valid
|
||||
@property
|
||||
def mnemonic_display_list(self) -> List[str]:
|
||||
return unicodedata.normalize("NFC", " ".join(self._mnemonic)).split()
|
||||
|
||||
|
||||
@property
|
||||
def passphrase(self):
|
||||
return self._passphrase
|
||||
|
||||
|
||||
@property
|
||||
def passphrase_display(self):
|
||||
return unicodedata.normalize("NFC", self._passphrase)
|
||||
|
||||
|
||||
def set_passphrase(self, passphrase: str, regenerate_seed: bool = True):
|
||||
if passphrase:
|
||||
self._passphrase = unicodedata.normalize("NFKD", passphrase)
|
||||
else:
|
||||
# Passphrase must always have a string value, even if it's just the empty
|
||||
# string.
|
||||
self._passphrase = ""
|
||||
|
||||
if regenerate_seed:
|
||||
# Regenerate the internal seed since passphrase changes the result
|
||||
self._generate_seed()
|
||||
|
||||
|
||||
@property
|
||||
def wordlist(self) -> List[str]:
|
||||
return Seed.get_wordlist(self.wordlist_language_code)
|
||||
|
||||
|
||||
def set_wordlist_language_code(self, language_code: str):
|
||||
# TODO: Support other bip-39 wordlist languages!
|
||||
raise Exception("Not yet implemented!")
|
||||
|
||||
|
||||
def get_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str:
|
||||
root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"])
|
||||
return hexlify(root.child(0).fingerprint).decode('utf-8')
|
||||
|
||||
|
||||
### override operators
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Seed):
|
||||
return self.seed_bytes == other.seed_bytes
|
||||
return False
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,499 @@
|
||||
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"),
|
||||
]
|
||||
|
||||
CAMERA_ROTATION__0 = 0
|
||||
CAMERA_ROTATION__90 = 90
|
||||
CAMERA_ROTATION__180 = 180
|
||||
CAMERA_ROTATION__270 = 270
|
||||
ALL_CAMERA_ROTATIONS = [
|
||||
(CAMERA_ROTATION__0, "0°"),
|
||||
(CAMERA_ROTATION__90, "90°"),
|
||||
(CAMERA_ROTATION__180, "180°"),
|
||||
(CAMERA_ROTATION__270, "270°"),
|
||||
]
|
||||
|
||||
# QR code constants
|
||||
DENSITY__LOW = "L"
|
||||
DENSITY__MEDIUM = "M"
|
||||
DENSITY__HIGH = "H"
|
||||
ALL_DENSITIES = [
|
||||
(DENSITY__LOW, "Low"),
|
||||
(DENSITY__MEDIUM, "Medium"),
|
||||
(DENSITY__HIGH, "High"),
|
||||
]
|
||||
|
||||
# Seed-related constants
|
||||
MAINNET = "M"
|
||||
TESTNET = "T"
|
||||
REGTEST = "R"
|
||||
ALL_NETWORKS = [
|
||||
(MAINNET, "Mainnet"),
|
||||
(TESTNET, "Testnet"),
|
||||
(REGTEST, "Regtest")
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def map_network_to_embit(cls, network) -> str:
|
||||
if network == SettingsConstants.MAINNET:
|
||||
return "main"
|
||||
elif network == SettingsConstants.TESTNET:
|
||||
return "test"
|
||||
if network == SettingsConstants.REGTEST:
|
||||
return "regtest"
|
||||
|
||||
|
||||
SINGLE_SIG = "ss"
|
||||
MULTISIG = "ms"
|
||||
ALL_SIG_TYPES = [
|
||||
(SINGLE_SIG, "Single Sig"),
|
||||
(MULTISIG, "Multisig"),
|
||||
]
|
||||
|
||||
NATIVE_SEGWIT = "nat"
|
||||
NESTED_SEGWIT = "nes"
|
||||
TAPROOT = "tr"
|
||||
CUSTOM_DERIVATION = "cus"
|
||||
ALL_SCRIPT_TYPES = [
|
||||
(NATIVE_SEGWIT, "Native Segwit"),
|
||||
(NESTED_SEGWIT, "Nested Segwit (legacy)"),
|
||||
(TAPROOT, "Taproot"),
|
||||
(CUSTOM_DERIVATION, "Custom Derivation"),
|
||||
]
|
||||
|
||||
WORDLIST_LANGUAGE__ENGLISH = "en"
|
||||
WORDLIST_LANGUAGE__CHINESE_SIMPLIFIED = "zh_Hans_CN"
|
||||
WORDLIST_LANGUAGE__CHINESE_TRADITIONAL = "zh_Hant_TW"
|
||||
WORDLIST_LANGUAGE__FRENCH = "fr"
|
||||
WORDLIST_LANGUAGE__ITALIAN = "it"
|
||||
WORDLIST_LANGUAGE__JAPANESE = "jp"
|
||||
WORDLIST_LANGUAGE__KOREAN = "kr"
|
||||
WORDLIST_LANGUAGE__PORTUGUESE = "pt"
|
||||
ALL_WORDLIST_LANGUAGES = [
|
||||
(WORDLIST_LANGUAGE__ENGLISH, "English"),
|
||||
# (WORDLIST_LANGUAGE__CHINESE_SIMPLIFIED, "简体中文"),
|
||||
# (WORDLIST_LANGUAGE__CHINESE_TRADITIONAL, "繁體中文"),
|
||||
# (WORDLIST_LANGUAGE__FRENCH, "Français"),
|
||||
# (WORDLIST_LANGUAGE__ITALIAN, "Italiano"),
|
||||
# (WORDLIST_LANGUAGE__JAPANESE, "日本語"),
|
||||
# (WORDLIST_LANGUAGE__KOREAN, "한국어"),
|
||||
# (WORDLIST_LANGUAGE__PORTUGUESE, "Português"),
|
||||
]
|
||||
|
||||
|
||||
# Individual SettingsEntry attr_names
|
||||
SETTING__LANGUAGE = "language"
|
||||
SETTING__WORDLIST_LANGUAGE = "wordlist_language"
|
||||
SETTING__PERSISTENT_SETTINGS = "persistent_settings"
|
||||
SETTING__COORDINATORS = "coordinators"
|
||||
|
||||
SETTING__NETWORK = "network"
|
||||
SETTING__QR_DENSITY = "qr_density"
|
||||
SETTING__XPUB_EXPORT = "xpub_export"
|
||||
SETTING__SIG_TYPES = "sig_types"
|
||||
SETTING__SCRIPT_TYPES = "script_types"
|
||||
SETTING__XPUB_DETAILS = "xpub_details"
|
||||
SETTING__PASSPHRASE = "passphrase"
|
||||
SETTING__CAMERA_ROTATION = "camera_rotation"
|
||||
SETTING__COMPACT_SEEDQR = "compact_seedqr"
|
||||
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
|
||||
SETTING__DIRE_WARNINGS = "dire_warnings"
|
||||
|
||||
SETTING__DEBUG = "debug"
|
||||
|
||||
# Hidden settings
|
||||
SETTING__QR_BRIGHTNESS = "qr_background_color"
|
||||
|
||||
|
||||
# Structural constants
|
||||
# TODO: Not using these for display purposes yet (ever?)
|
||||
CATEGORY__SYSTEM = "system"
|
||||
CATEGORY__DISPLAY = "display"
|
||||
CATEGORY__WALLET = "wallet"
|
||||
CATEGORY__FEATURES = "features"
|
||||
|
||||
VISIBILITY__GENERAL = "general"
|
||||
VISIBILITY__ADVANCED = "advanced"
|
||||
VISIBILITY__DEVELOPER = "developer"
|
||||
VISIBILITY__HIDDEN = "hidden" # For data-only (e.g. custom_derivation), not configurable by the user
|
||||
|
||||
# TODO: Is there really a difference between ENABLED and PROMPT?
|
||||
TYPE__ENABLED_DISABLED = "enabled_disabled"
|
||||
TYPE__ENABLED_DISABLED_PROMPT = "enabled_disabled_prompt"
|
||||
TYPE__ENABLED_DISABLED_PROMPT_REQUIRED = "enabled_disabled_prompt_required"
|
||||
TYPE__SELECT_1 = "select_1"
|
||||
TYPE__MULTISELECT = "multiselect"
|
||||
TYPE__FREE_ENTRY = "free_entry"
|
||||
|
||||
ALL_ENABLED_DISABLED_TYPES = [
|
||||
TYPE__ENABLED_DISABLED,
|
||||
TYPE__ENABLED_DISABLED_PROMPT,
|
||||
TYPE__ENABLED_DISABLED_PROMPT_REQUIRED,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsEntry:
|
||||
"""
|
||||
Defines all the parameters for a single settings entry.
|
||||
|
||||
* category: Mostly for organizational purposes when displaying options in the
|
||||
SettingsQR UI. Potentially an additional sub-level breakout in the menus
|
||||
on the device itself, too.
|
||||
|
||||
* selection_options: May be specified as a List(Any) or List(tuple(Any, str)).
|
||||
The tuple form is to provide a human-readable display_name. Probably all
|
||||
entries should shift to using the tuple form.
|
||||
"""
|
||||
# TODO: Handle multi-language `display_name` and `help_text`
|
||||
category: str
|
||||
attr_name: str
|
||||
display_name: str
|
||||
verbose_name: str = None
|
||||
abbreviated_name: str = None
|
||||
visibility: str = SettingsConstants.VISIBILITY__GENERAL
|
||||
type: str = SettingsConstants.TYPE__ENABLED_DISABLED
|
||||
help_text: str = None
|
||||
selection_options: List[str] = None
|
||||
default_value: Any = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.type == SettingsConstants.TYPE__ENABLED_DISABLED:
|
||||
self.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED
|
||||
|
||||
elif self.type == SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT:
|
||||
self.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED_PROMPT
|
||||
|
||||
elif self.type == SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT_REQUIRED:
|
||||
self.selection_options = [SettingsConstants.ALL_OPTIONS]
|
||||
|
||||
# Account for List[tuple] and tuple formats as default_value
|
||||
if type(self.default_value) == list and type(self.default_value[0]) == tuple:
|
||||
self.default_value = [v[0] for v in self.default_value]
|
||||
elif type(self.default_value) == tuple:
|
||||
self.default_value = self.default_value[0]
|
||||
|
||||
|
||||
@property
|
||||
def selection_options_display_names(self) -> List[str]:
|
||||
if type(self.selection_options[0]) == tuple:
|
||||
return [v[1] for v in self.selection_options]
|
||||
else:
|
||||
# Always return a copy so the original can't be altered
|
||||
return list(self.selection_options)
|
||||
|
||||
|
||||
def get_selection_option_value(self, i: int):
|
||||
value = self.selection_options[i]
|
||||
if type(value) == tuple:
|
||||
value = value[0]
|
||||
return value
|
||||
|
||||
|
||||
def get_selection_option_display_name(self, i: int) -> str:
|
||||
value = self.selection_options[i]
|
||||
if type(value) == tuple:
|
||||
value = value[1]
|
||||
return value
|
||||
|
||||
|
||||
def get_selection_option_display_name_by_value(self, value) -> str:
|
||||
for option in self.selection_options:
|
||||
if type(option) == tuple:
|
||||
option_value = option[0]
|
||||
display_name = option[1]
|
||||
else:
|
||||
option_value = option
|
||||
display_name = option
|
||||
if option_value == value:
|
||||
return display_name
|
||||
|
||||
|
||||
def 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),
|
||||
|
||||
# Advanced options
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__NETWORK,
|
||||
display_name="Bitcoin network",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_NETWORKS,
|
||||
default_value=SettingsConstants.REGTEST), # DEBUGGING!
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__QR_DENSITY,
|
||||
display_name="QR code density",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_DENSITIES,
|
||||
default_value=SettingsConstants.DENSITY__MEDIUM),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__XPUB_EXPORT,
|
||||
display_name="Xpub export",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__SIG_TYPES,
|
||||
display_name="Sig types",
|
||||
type=SettingsConstants.TYPE__MULTISELECT,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_SIG_TYPES,
|
||||
default_value=SettingsConstants.ALL_SIG_TYPES),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__SCRIPT_TYPES,
|
||||
display_name="Script types",
|
||||
type=SettingsConstants.TYPE__MULTISELECT,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_SCRIPT_TYPES,
|
||||
default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT]),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__XPUB_DETAILS,
|
||||
display_name="Show xpub details",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__PASSPHRASE,
|
||||
display_name="BIP-39 passphrase",
|
||||
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),
|
||||
|
||||
# Developer options
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__DEBUG,
|
||||
display_name="Debug",
|
||||
visibility=SettingsConstants.VISIBILITY__DEVELOPER,
|
||||
default_value=SettingsConstants.OPTION__DISABLED),
|
||||
|
||||
# "Hidden" settings with no UI interaction
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS,
|
||||
display_name="QR background color",
|
||||
type=SettingsConstants.TYPE__FREE_ENTRY,
|
||||
visibility=SettingsConstants.VISIBILITY__HIDDEN,
|
||||
default_value=189),
|
||||
]
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_settings_entries(cls, visibiilty: str = SettingsConstants.VISIBILITY__GENERAL) -> List[SettingsEntry]:
|
||||
entries = []
|
||||
for entry in cls.settings_entries:
|
||||
if entry.visibility == visibiilty:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_settings_entry(cls, attr_name) -> SettingsEntry:
|
||||
for entry in cls.settings_entries:
|
||||
if entry.attr_name == attr_name:
|
||||
return entry
|
||||
|
||||
|
||||
@classmethod
|
||||
def parse_abbreviated_ini(cls, abbreviated_ini: str) -> dict:
|
||||
raise Exception("Not implemented, maybe not needed")
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_defaults(cls) -> dict:
|
||||
as_dict = {}
|
||||
for entry in SettingsDefinition.settings_entries:
|
||||
if type(entry.default_value) == list:
|
||||
# Must copy the default_value list, otherwise we'll inadvertently change
|
||||
# defaults when updating these attrs
|
||||
as_dict[entry.attr_name] = list(entry.default_value)
|
||||
else:
|
||||
as_dict[entry.attr_name] = entry.default_value
|
||||
return as_dict
|
||||
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls) -> dict:
|
||||
output = {
|
||||
"settings_entries": [],
|
||||
}
|
||||
for settings_entry in cls.settings_entries:
|
||||
output["settings_entries"].append(settings_entry.to_dict())
|
||||
|
||||
return output
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import os
|
||||
print("Exporting SettingsDefinition to json")
|
||||
|
||||
output_file = "settings_definition.json"
|
||||
with open(output_file, 'w') as json_file:
|
||||
json.dump(SettingsDefinition.to_dict(), json_file, indent=4)
|
||||
@@ -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
|
||||
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
from threading import Thread, Lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseThread(Thread):
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True)
|
||||
|
||||
def start(self):
|
||||
logger.debug(f"{self.__class__.__name__} STARTING")
|
||||
self.keep_running = True
|
||||
super().start()
|
||||
|
||||
def stop(self):
|
||||
logger.debug(f"{self.__class__.__name__} EXITING")
|
||||
self.keep_running = False
|
||||
|
||||
def run(self):
|
||||
while self.keep_running:
|
||||
# Do something
|
||||
raise Exception(f"Must implement run() in {self.__class__.__name__}")
|
||||
|
||||
|
||||
|
||||
class ThreadsafeCounter:
|
||||
def __init__(self, initial_value: int = 0):
|
||||
self.count = initial_value
|
||||
self._lock = Lock()
|
||||
|
||||
@property
|
||||
def cur_count(self):
|
||||
# Reads don't require the lock
|
||||
return self.count
|
||||
|
||||
def increment(self, step: int = 1):
|
||||
# Updates must be locked
|
||||
with self._lock:
|
||||
self.count += step
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 140 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 140 B |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 216 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 893 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -77,8 +77,8 @@ class IOTestView(View):
|
||||
def draw_io_screen(self):
|
||||
self.redraw_complete = False
|
||||
self.redraw = False
|
||||
self.draw.rectangle((0,0,View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
self.draw.text((45, 5), "Input/Output Check:", fill=View.color, font=View.ASSISTANT18)
|
||||
self.draw.rectangle((0,0,self.renderer.canvas_width, self.renderer.canvas_height), outline=0, fill=0)
|
||||
self.draw.text((45, 5), "Input/Output Check:", fill=View.color, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
self.draw.polygon([(61, 89), (80, 46), (99, 89)], outline=View.color, fill=0)
|
||||
self.draw.polygon([(51, 100), (8, 119), (51, 138)], outline=View.color, fill=0)
|
||||
self.draw.polygon([(109, 100), (152, 119), (109, 138)], outline=View.color, fill=0)
|
||||
@@ -86,23 +86,23 @@ class IOTestView(View):
|
||||
self.draw.ellipse([(61, 99), (99, 141)], outline=View.color, fill=0)
|
||||
self.draw.ellipse([(198, 40), (238, 80)], outline=View.color, fill=0)
|
||||
self.draw.ellipse([(198, 95), (238, 135)], outline=View.color, fill=0)
|
||||
self.draw.text((200, 160), "EXIT", fill=View.color, font=View.ASSISTANT18)
|
||||
self.draw.text((200, 160), "EXIT", fill=View.color, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
self.draw.rectangle((30, 205, 210, 235), outline=View.color, fill="BLACK")
|
||||
tw, th = self.draw.textsize(self.qr_text, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill=View.color, font=View.ASSISTANT22)
|
||||
View.DispShowImage()
|
||||
tw, th = self.draw.textsize(self.qr_text, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill=View.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.renderer.show_image()
|
||||
self.redraw_complete = True
|
||||
|
||||
def a_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.ellipse([(198, 40), (238, 80)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.renderer.show_image()
|
||||
self.redraw = True
|
||||
|
||||
def b_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.ellipse([(198, 95), (238, 135)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.renderer.show_image()
|
||||
self.redraw = True
|
||||
|
||||
def c_button(self):
|
||||
@@ -113,39 +113,39 @@ class IOTestView(View):
|
||||
def up_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.polygon([(61, 89), (80, 46), (99, 89)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.renderer.show_image()
|
||||
self.redraw = True
|
||||
|
||||
def down_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.polygon([(61, 151), (80, 193), (99, 151)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.renderer.show_image()
|
||||
self.redraw = True
|
||||
|
||||
def left_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.polygon([(51, 100), (8, 119), (51, 138)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.renderer.show_image()
|
||||
self.redraw = True
|
||||
|
||||
def right_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.polygon([(109, 100), (152, 119), (109, 138)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.renderer.show_image()
|
||||
self.redraw = True
|
||||
|
||||
def press_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.ellipse([(61, 99), (99, 141)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.renderer.show_image()
|
||||
self.redraw = True
|
||||
|
||||
def draw_scan_detected(self):
|
||||
self.qr_text = "QR Scanned"
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.rectangle((30, 205, 210, 235), outline=View.color, fill=View.color)
|
||||
tw, th = self.draw.textsize(self.qr_text, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill="BLACK", font=View.ASSISTANT22)
|
||||
View.DispShowImage()
|
||||
tw, th = self.draw.textsize(self.qr_text, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill="BLACK", font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.renderer.show_image()
|
||||
self.redraw = True
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import math
|
||||
import time
|
||||
from PIL import ImageDraw, Image
|
||||
|
||||
from . import View
|
||||
|
||||
from seedsigner.helpers import QR, mnemonic_generation
|
||||
from seedsigner.gui.components import Fonts
|
||||
from seedsigner.models import DecodeQR, DecodeQRStatus, QRType, EncodeQR
|
||||
|
||||
|
||||
|
||||
class SeedToolsView(View):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
# Gather words and seed display information
|
||||
self.words = []
|
||||
self.letters = []
|
||||
self.possible_alphabet = []
|
||||
self.possible_words = []
|
||||
self.selected_possible_words_index = 0
|
||||
self.seed_length = 12 # Default to 12, Valid values are 11, 12, 23 and 24
|
||||
self.seed_qr_image = None
|
||||
self.seed_entropy_image = None
|
||||
|
||||
# Dice information
|
||||
self.roll_number = 1
|
||||
self.dice_selected = 0
|
||||
self.roll_data = ""
|
||||
self.dice_seed_phrase = []
|
||||
|
||||
|
||||
|
||||
###
|
||||
### Display Seed from Dice
|
||||
###
|
||||
def display_generate_seed_from_dice(self):
|
||||
self.roll_number = 1
|
||||
self.dice_selected = 5
|
||||
self.roll_data = ""
|
||||
|
||||
self.draw_dice(self.dice_selected)
|
||||
time.sleep(1) # pause for 1 second before accepting input
|
||||
|
||||
# Wait for Button Input (specifically menu selection/press)
|
||||
while True:
|
||||
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS, B.KEY_RIGHT, B.KEY_LEFT])
|
||||
if input == B.KEY_UP:
|
||||
ret_val = self.dice_arrow_up()
|
||||
elif input == B.KEY_DOWN:
|
||||
ret_val = self.dice_arrow_down()
|
||||
elif input == B.KEY_RIGHT:
|
||||
ret_val = self.dice_arrow_right()
|
||||
elif input == B.KEY_LEFT:
|
||||
ret_val = self.dice_arrow_left()
|
||||
elif input == B.KEY_PRESS:
|
||||
ret_val = self.dice_arrow_press()
|
||||
|
||||
if ret_val == False:
|
||||
return []
|
||||
|
||||
if self.roll_number >= 100:
|
||||
self.dice_seed_phrase = mnemonic_generation.generate_mnemonic_from_dice(self.roll_data)
|
||||
return self.dice_seed_phrase[:]
|
||||
|
||||
|
||||
def dice_arrow_up(self):
|
||||
new_selection = 0
|
||||
if self.dice_selected == 4:
|
||||
new_selection = 1
|
||||
elif self.dice_selected == 5:
|
||||
new_selection = 2
|
||||
elif self.dice_selected == 6:
|
||||
new_selection = 3
|
||||
|
||||
if self.dice_selected != new_selection and new_selection != 0:
|
||||
self.draw_dice(new_selection)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def dice_arrow_down(self):
|
||||
new_selection = 0
|
||||
if self.dice_selected == 1:
|
||||
new_selection = 4
|
||||
elif self.dice_selected == 2:
|
||||
new_selection = 5
|
||||
elif self.dice_selected == 3:
|
||||
new_selection = 6
|
||||
|
||||
if self.dice_selected != new_selection and new_selection != 0:
|
||||
self.draw_dice(new_selection)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def dice_arrow_right(self):
|
||||
new_selection = 0
|
||||
if self.dice_selected == 1:
|
||||
new_selection = 2
|
||||
elif self.dice_selected == 2:
|
||||
new_selection = 3
|
||||
elif self.dice_selected == 4:
|
||||
new_selection = 5
|
||||
elif self.dice_selected == 5:
|
||||
new_selection = 6
|
||||
|
||||
if self.dice_selected != new_selection and new_selection != 0:
|
||||
self.draw_dice(new_selection)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def dice_arrow_left(self):
|
||||
if self.dice_selected == 1:
|
||||
self.draw_prompt_custom("Undo ", "Cancel ", "Exit ", ["Action: ", "", ""])
|
||||
input = self.buttons.wait_for([B.KEY1, B.KEY2, B.KEY3])
|
||||
if input == B.KEY1: #Undo
|
||||
self.roll_number = self.roll_number - 1
|
||||
self.roll_data = self.roll_data[:-1] # remove last character from string
|
||||
if self.roll_number >= 1:
|
||||
self.draw_dice(self.dice_selected)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
elif input == B.KEY2: # Cancel
|
||||
self.draw_dice(self.dice_selected)
|
||||
return True
|
||||
elif input == B.KEY3: # Exit
|
||||
return False
|
||||
|
||||
new_selection = 0
|
||||
if self.dice_selected == 3:
|
||||
new_selection = 2
|
||||
elif self.dice_selected == 2:
|
||||
new_selection = 1
|
||||
elif self.dice_selected == 6:
|
||||
new_selection = 5
|
||||
elif self.dice_selected == 5:
|
||||
new_selection = 4
|
||||
|
||||
if self.dice_selected != new_selection and new_selection != 0:
|
||||
self.draw_dice(new_selection)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def dice_arrow_press(self):
|
||||
self.roll_number += 1
|
||||
self.roll_data += str(self.dice_selected)
|
||||
|
||||
# Reset for the next UI render
|
||||
if self.roll_number > 45:
|
||||
self.dice_selected = 5
|
||||
else:
|
||||
self.dice_selected = 2
|
||||
|
||||
if self.roll_number < 100:
|
||||
self.draw_dice(self.dice_selected)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def draw_dice(self, dice_selected):
|
||||
self.renderer.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
|
||||
self.renderer.draw.text((45, 5), "Dice roll: " + str(self.roll_number) + "/99", fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
|
||||
# when dice is selected, rect fill will be orange and ellipse will be black, ellipse outline will be the black
|
||||
# when dice is not selected, rect will will be black and ellipse will be orange, ellipse outline will be orange
|
||||
|
||||
# dice 1
|
||||
if dice_selected == 1:
|
||||
self.renderer.draw.rectangle((5, 50, 75, 120), outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(34, 79), (46, 91)], outline="BLACK", fill="BLACK")
|
||||
else:
|
||||
self.renderer.draw.rectangle((5, 50, 75, 120), outline=self.color, fill="BLACK")
|
||||
self.renderer.draw.ellipse([(34, 79), (46, 91)], outline=self.color, fill=self.color)
|
||||
|
||||
# dice 2
|
||||
if dice_selected == 2:
|
||||
self.renderer.draw.rectangle((85, 50, 155, 120), outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(100, 60), (112, 72)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(128, 98), (140, 110)], outline="BLACK", fill="BLACK")
|
||||
else:
|
||||
self.renderer.draw.rectangle((85, 50, 155, 120), outline=self.color, fill="BLACK")
|
||||
self.renderer.draw.ellipse([(100, 60), (112, 72)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(128, 98), (140, 110)], outline=self.color, fill=self.color)
|
||||
|
||||
# dice 3
|
||||
if dice_selected == 3:
|
||||
self.renderer.draw.rectangle((165, 50, 235, 120), outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(180, 60), (192, 72)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(194, 79), (206, 91)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(208, 98), (220, 110)], outline="BLACK", fill="BLACK")
|
||||
else:
|
||||
self.renderer.draw.rectangle((165, 50, 235, 120), outline=self.color, fill="BLACK")
|
||||
self.renderer.draw.ellipse([(180, 60), (192, 72)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(194, 79), (206, 91)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(208, 98), (220, 110)], outline=self.color, fill=self.color)
|
||||
|
||||
# dice 4
|
||||
if dice_selected == 4:
|
||||
self.renderer.draw.rectangle((5, 130, 75, 200), outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(20, 140), (32, 152)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(20, 174), (32, 186)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(48, 140), (60, 152)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(48, 174), (60, 186)], outline="BLACK", fill="BLACK")
|
||||
else:
|
||||
self.renderer.draw.rectangle((5, 130, 75, 200), outline=self.color, fill="BLACK")
|
||||
self.renderer.draw.ellipse([(20, 140), (32, 152)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(20, 174), (32, 186)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(48, 140), (60, 152)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(48, 174), (60, 186)], outline=self.color, fill=self.color)
|
||||
|
||||
# dice 5
|
||||
if dice_selected == 5:
|
||||
self.renderer.draw.rectangle((85, 130, 155, 200), outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(100, 140), (112, 152)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(100, 178), (112, 190)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(114, 159), (126, 171)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(128, 140), (140, 152)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(128, 178), (140, 190)], outline="BLACK", fill="BLACK")
|
||||
else:
|
||||
self.renderer.draw.rectangle((85, 130, 155, 200), outline=self.color, fill="BLACK")
|
||||
self.renderer.draw.ellipse([(100, 140), (112, 152)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(100, 178), (112, 190)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(114, 159), (126, 171)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(128, 140), (140, 152)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(128, 178), (140, 190)], outline=self.color, fill=self.color)
|
||||
|
||||
# dice 6
|
||||
if dice_selected == 6:
|
||||
self.renderer.draw.rectangle((165, 130, 235, 200), outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(180, 140), (192, 152)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(180, 157), (192, 169)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(180, 174), (192, 186)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(208, 140), (220, 152)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(208, 157), (220, 169)], outline="BLACK", fill="BLACK")
|
||||
self.renderer.draw.ellipse([(208, 174), (220, 186)], outline="BLACK", fill="BLACK")
|
||||
else:
|
||||
self.renderer.draw.rectangle((165, 130, 235, 200), outline=self.color, fill="BLACK")
|
||||
self.renderer.draw.ellipse([(180, 140), (192, 152)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(180, 157), (192, 169)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(180, 174), (192, 186)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(208, 140), (220, 152)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(208, 157), (220, 169)], outline=self.color, fill=self.color)
|
||||
self.renderer.draw.ellipse([(208, 174), (220, 186)], outline=self.color, fill=self.color)
|
||||
|
||||
# bottom text
|
||||
self.renderer.draw.text((18, 210), "Press Control Stick to Select", fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
self.renderer.show_image()
|
||||
|
||||
self.dice_selected = dice_selected
|
||||
@@ -1,10 +1,12 @@
|
||||
# SeedSigner file class dependencies
|
||||
from . import View
|
||||
from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay
|
||||
from seedsigner.models import EncodeQRDensity
|
||||
from seedsigner.helpers import B, QR
|
||||
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
|
||||
from seedsigner.models import EncodeQR
|
||||
|
||||
|
||||
|
||||
class SettingsToolsView(View):
|
||||
|
||||
def __init__(self) -> None:
|
||||
View.__init__(self)
|
||||
|
||||
@@ -12,112 +14,20 @@ class SettingsToolsView(View):
|
||||
self.donate_image = None
|
||||
self.derivation = None
|
||||
|
||||
### Donate Menu Item
|
||||
|
||||
### Donate Menu Item
|
||||
def display_donate_info_screen(self):
|
||||
self.draw_modal(["You can support", "SeedSigner by donating", "any amount of BTC", "Thank You!!!"], "", "(Press right for a QR code)")
|
||||
self.renderer.draw_modal(["You can support", "SeedSigner by donating", "any amount of BTC", "Thank You!!!"], "", "(Press right for a QR code)")
|
||||
return True
|
||||
|
||||
|
||||
def display_donate_qr(self):
|
||||
self.draw_modal(["Loading..."])
|
||||
self.renderer.draw_modal(["Loading..."])
|
||||
self.donate_image = self.qr.qrimage("bc1qphlyv2dde290tqdlnk8uswztnshw3x9rjurexqqhksvu7vdevhtsuw4efe")
|
||||
View.DispShowImage(self.donate_image)
|
||||
self.renderer.show_image(self.donate_image)
|
||||
return True
|
||||
|
||||
### Display Network Selection
|
||||
|
||||
def display_current_network(self) -> str:
|
||||
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Mainnet", "Testnet"], "Which Network?")
|
||||
if r == 2:
|
||||
return "main"
|
||||
elif r == 3:
|
||||
return "test"
|
||||
else:
|
||||
return None
|
||||
|
||||
### Display Wallet Selection
|
||||
|
||||
def display_wallet_selection(self) -> str:
|
||||
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Prompt", "Specter Desktop", "Blue Wallet", "Sparrow"], "Which Wallet?")
|
||||
if r == 2:
|
||||
return "Prompt"
|
||||
elif r == 3:
|
||||
return "Specter Desktop"
|
||||
elif r == 4:
|
||||
return "Blue Wallet"
|
||||
elif r == 5:
|
||||
return "Sparrow"
|
||||
else:
|
||||
return None
|
||||
|
||||
### Display QR Density Selection
|
||||
|
||||
def display_qr_density_selection(self) -> str:
|
||||
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Low", "Medium", "High"], "Which QR Density?")
|
||||
if r == 2:
|
||||
return EncodeQRDensity.LOW
|
||||
elif r == 3:
|
||||
return EncodeQRDensity.MEDIUM
|
||||
elif r == 4:
|
||||
return EncodeQRDensity.HIGH
|
||||
else:
|
||||
return None
|
||||
|
||||
def display_persistent_settings(self) -> bool:
|
||||
lines = ["... [ Return to Settings ]"]
|
||||
lines.append("Yes")
|
||||
lines.append("No")
|
||||
|
||||
r = self.controller.menu_view.display_generic_selection_menu(lines, "Use Persistent Settings?")
|
||||
if r == 1:
|
||||
return None
|
||||
elif r == 2:
|
||||
return True
|
||||
elif r == 3:
|
||||
return False
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def display_camera_rotation(self) -> int:
|
||||
lines = ["... [ Return to Settings ]"]
|
||||
lines.append("0° (default)")
|
||||
lines.append("90°")
|
||||
lines.append("180°")
|
||||
lines.append("270°")
|
||||
|
||||
r = self.controller.menu_view.display_generic_selection_menu(lines, "Camera Rotation")
|
||||
if r == 1:
|
||||
return None
|
||||
elif r == 2:
|
||||
return 0
|
||||
elif r == 3:
|
||||
return 90
|
||||
elif r == 4:
|
||||
return 180
|
||||
elif r == 5:
|
||||
return 270
|
||||
else:
|
||||
return None
|
||||
|
||||
###
|
||||
### Version Info
|
||||
###
|
||||
|
||||
def display_version_info(self):
|
||||
|
||||
line1 = "SeedSigner"
|
||||
line2 = "Version v" + self.controller.VERSION
|
||||
line3 = "(Joystick RIGHT to EXIT)"
|
||||
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
tw, th = View.draw.textsize(line1, font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 20), line1, fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(line2, font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 55), line2, fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(line3, font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), line3, fill=View.color, font=View.ASSISTANT18)
|
||||
View.DispShowImage()
|
||||
|
||||
###
|
||||
### Custom Derivation Path
|
||||
@@ -129,19 +39,19 @@ class SettingsToolsView(View):
|
||||
right_button_width = 60
|
||||
font_padding_right = 2
|
||||
font_padding_top = 1
|
||||
key_x = View.canvas_width - right_button_width
|
||||
key_y = int(View.canvas_height - row_height) / 2 - 1 - 60
|
||||
font = View.ROBOTOCONDENSED_BOLD_24
|
||||
key_x = self.renderer.canvas_width - right_button_width
|
||||
key_y = int(self.renderer.canvas_height - row_height) / 2 - 1 - 60
|
||||
font = Fonts.get_font("RobotoCondensed-Regular", 24)
|
||||
background_color = "#111"
|
||||
font_color = View.color
|
||||
button3_text = "Save"
|
||||
tw, th = font.getsize(button3_text)
|
||||
key_y = int(View.canvas_height - row_height) / 2 - 1 + 60
|
||||
View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
|
||||
View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color)
|
||||
key_y = int(self.renderer.canvas_height - row_height) / 2 - 1 + 60
|
||||
self.renderer.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
|
||||
self.renderer.draw.text((self.renderer.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color)
|
||||
|
||||
# Clear the screen
|
||||
View.draw.rectangle((0,0, View.canvas_width,View.canvas_height), fill="black")
|
||||
self.renderer.draw.rectangle((0,0, self.renderer.canvas_width,self.renderer.canvas_height), fill="black")
|
||||
|
||||
self.render_previous_button()
|
||||
previous_button_is_active = False
|
||||
@@ -157,16 +67,16 @@ class SettingsToolsView(View):
|
||||
right_panel_buttons_width = 60
|
||||
|
||||
# render top title banner
|
||||
font = View.ROBOTOCONDENSED_REGULAR_20
|
||||
font = Fonts.get_font("RobotoCondensed-Regular", 20)
|
||||
title = "Enter Derivation"
|
||||
title_top_padding = 0
|
||||
title_bottom_padding = 10
|
||||
tw, th = font.getsize(title)
|
||||
View.draw.text((int(View.canvas_width - tw) / 2, title_top_padding), text=title, font=font, fill=View.color)
|
||||
self.renderer.draw.text((int(self.renderer.canvas_width - tw) / 2, title_top_padding), text=title, font=font, fill=View.color)
|
||||
title_height = th + title_top_padding + title_bottom_padding
|
||||
|
||||
# Render the live text entry display
|
||||
font = View.ROBOTOCONDENSED_REGULAR_28
|
||||
font = Fonts.get_font("RobotoCondensed-Regular", 28)
|
||||
tw, th = font.getsize("m/1234567890") # All possible chars for max range
|
||||
text_entry_side_padding = 0
|
||||
text_entry_top_padding = 1
|
||||
@@ -174,8 +84,8 @@ class SettingsToolsView(View):
|
||||
text_entry_top_y = title_height + text_entry_top_padding
|
||||
text_entry_bottom_y = text_entry_top_y + 3 + th + 3
|
||||
text_entry_display = TextEntryDisplay(
|
||||
View.draw,
|
||||
rect=(text_entry_side_padding,text_entry_top_y, View.canvas_width - right_panel_buttons_width - 1, text_entry_bottom_y),
|
||||
self.renderer.draw,
|
||||
rect=(text_entry_side_padding,text_entry_top_y, self.renderer.canvas_width - right_panel_buttons_width - 1, text_entry_bottom_y),
|
||||
font=font,
|
||||
font_color=View.color,
|
||||
cursor_mode=TextEntryDisplay.CURSOR_MODE__BLOCK,
|
||||
@@ -188,11 +98,11 @@ class SettingsToolsView(View):
|
||||
|
||||
keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding
|
||||
keyboard_digits = Keyboard(
|
||||
View.draw,
|
||||
self.renderer.draw,
|
||||
charset="/'0123456789",
|
||||
rows=3,
|
||||
cols=6,
|
||||
rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height),
|
||||
rect=(0, keyboard_start_y, self.renderer.canvas_width - right_panel_buttons_width, self.renderer.canvas_height),
|
||||
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
|
||||
render_now=False
|
||||
)
|
||||
@@ -201,11 +111,11 @@ class SettingsToolsView(View):
|
||||
render_right_panel()
|
||||
|
||||
text_entry_display.render(self.derivation)
|
||||
View.DispShowImage()
|
||||
self.renderer.show_image()
|
||||
|
||||
# Start the interactive update loop
|
||||
while True:
|
||||
input = View.buttons.wait_for(
|
||||
input = self.buttons.wait_for(
|
||||
[B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY3],
|
||||
check_release=True,
|
||||
release_keys=[B.KEY_PRESS, B.KEY3]
|
||||
@@ -271,20 +181,4 @@ class SettingsToolsView(View):
|
||||
# Render the text entry display and cursor block
|
||||
text_entry_display.render(self.derivation)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
|
||||
def display_compact_seedqr_enabled(self) -> bool:
|
||||
lines = ["... [ Return to Settings ]"]
|
||||
lines.append("Enable")
|
||||
lines.append("Disable")
|
||||
|
||||
r = self.controller.menu_view.display_generic_selection_menu(lines, "Compact SeedQR")
|
||||
if r == 1:
|
||||
return None
|
||||
elif r == 2:
|
||||
return True
|
||||
elif r == 3:
|
||||
return False
|
||||
else:
|
||||
return None
|
||||
self.renderer.show_image()
|
||||
@@ -0,0 +1,448 @@
|
||||
from embit.psbt import PSBT
|
||||
|
||||
from seedsigner.models.encode_qr import EncodeQR
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
|
||||
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
|
||||
from seedsigner.gui.screens import psbt_screens
|
||||
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen,
|
||||
LoadingScreenThread, QRDisplayScreen, WarningScreen)
|
||||
from seedsigner.models.psbt_parser import PSBTParser
|
||||
|
||||
|
||||
|
||||
class PSBTSelectSeedView(View):
|
||||
def run(self):
|
||||
# Note: we can't just autoroute to the PSBT Overview because we might have a
|
||||
# multisig where we want to sign with more than one key on this device.
|
||||
if not self.controller.psbt:
|
||||
# Shouldn't be able to get here
|
||||
raise Exception("No PSBT currently loaded")
|
||||
|
||||
seeds = self.controller.storage.seeds
|
||||
|
||||
SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE)
|
||||
ENTER_WORDS = "Enter 12/24 words"
|
||||
button_data = []
|
||||
for seed in seeds:
|
||||
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
|
||||
if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
|
||||
# Doesn't look like this seed can sign the current PSBT
|
||||
button_str += " (?)"
|
||||
|
||||
if seed.passphrase is not None:
|
||||
# TODO: Include lock icon on right side of button
|
||||
pass
|
||||
button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "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)
|
||||
|
||||
elif button_data[selected_menu_num] == SCAN_SEED:
|
||||
from seedsigner.views.scan_views import ScanView
|
||||
return Destination(ScanView)
|
||||
|
||||
elif button_data[selected_menu_num] == ENTER_WORDS:
|
||||
# TODO
|
||||
return None
|
||||
|
||||
|
||||
|
||||
class PSBTOverviewView(View):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# The PSBTParser takes a while to read the PSBT. Run the loading screen while we
|
||||
# wait.
|
||||
self.loading_screen = LoadingScreenThread(text="Parsing PSBT...")
|
||||
self.loading_screen.start()
|
||||
|
||||
if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed:
|
||||
# Must run the PSBTParser or re-parse
|
||||
self.controller.psbt_parser = PSBTParser(
|
||||
self.controller.psbt,
|
||||
seed=self.controller.psbt_seed,
|
||||
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
|
||||
)
|
||||
|
||||
|
||||
def run(self):
|
||||
psbt_parser = self.controller.psbt_parser
|
||||
|
||||
change_data = psbt_parser.change_data
|
||||
"""
|
||||
change_data = [
|
||||
{
|
||||
'address': 'bc1q............',
|
||||
'amount': 397621401,
|
||||
'fingerprint': ['22bde1a9', '73c5da0a'],
|
||||
'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
|
||||
}, {},
|
||||
]
|
||||
"""
|
||||
num_change_outputs = 0
|
||||
num_self_transfer_outputs = 0
|
||||
for change_output in change_data:
|
||||
print(f"""{change_output["derivation_path"][0]}""")
|
||||
if change_output["derivation_path"][0].split("/")[-2] == "1":
|
||||
num_change_outputs += 1
|
||||
else:
|
||||
num_self_transfer_outputs += 1
|
||||
|
||||
screen = psbt_screens.PSBTOverviewScreen(
|
||||
spend_amount=psbt_parser.spend_amount,
|
||||
change_amount=psbt_parser.change_amount,
|
||||
fee_amount=psbt_parser.fee_amount,
|
||||
num_inputs=psbt_parser.num_inputs,
|
||||
num_self_transfer_outputs=num_self_transfer_outputs,
|
||||
num_change_outputs=num_change_outputs,
|
||||
destination_addresses=psbt_parser.destination_addresses,
|
||||
)
|
||||
|
||||
# Everything is set. Stop the loading screen
|
||||
self.loading_screen.stop()
|
||||
|
||||
# Run the overview screen
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
if psbt_parser.change_amount == 0:
|
||||
return Destination(PSBTNoChangeWarningView)
|
||||
|
||||
else:
|
||||
return Destination(PSBTMathView)
|
||||
|
||||
|
||||
|
||||
class PSBTNoChangeWarningView(View):
|
||||
def run(self):
|
||||
selected_menu_num = 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)
|
||||
|
||||
|
||||
|
||||
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))
|
||||
print(f"seed fingerprint: {seed_fingerprint}")
|
||||
print(change_data)
|
||||
|
||||
if seed_fingerprint not in change_data.get("fingerprint"):
|
||||
# TODO: Something is wrong with this psbt(?). Reroute to warning?
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
i = change_data.get("fingerprint").index(seed_fingerprint)
|
||||
derivation_path = change_data.get("derivation_path")[i]
|
||||
|
||||
# 'm/84h/1h/0h/1/0' would be a change addr while 'm/84h/1h/0h/0/0' is a self-receive
|
||||
is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
|
||||
derivation_path_addr_index = int(derivation_path.split("/")[-1])
|
||||
|
||||
NEXT = "Next"
|
||||
|
||||
if is_change_derivation_path:
|
||||
title = "Your Change"
|
||||
VERIFY_MULTISIG = "Verify Multisig Change"
|
||||
else:
|
||||
title = "Self-Transfer"
|
||||
VERIFY_MULTISIG = "Verify Multisig Addr"
|
||||
# if psbt_parser.num_change_outputs > 1:
|
||||
# title += f" (#{self.change_address_num + 1})"
|
||||
|
||||
is_change_addr_verified = False
|
||||
if psbt_parser.is_multisig:
|
||||
# TODO:
|
||||
# if the known-good multisig descriptor is already onboard:
|
||||
# calc change addr...
|
||||
# is_change_addr_verified = True
|
||||
# button_data = [NEXT]
|
||||
|
||||
# else:
|
||||
# Have the Screen offer to load in the multisig descriptor.
|
||||
# button_data = [VERIFY_MULTISIG, NEXT]
|
||||
|
||||
# Temp value while awaiting above
|
||||
button_data = [VERIFY_MULTISIG, NEXT]
|
||||
else:
|
||||
# Single sig
|
||||
# TODO: Generate address from seed at derivation_path and compare with
|
||||
# change_data["address"]
|
||||
# Save for Nick
|
||||
is_change_addr_verified = True
|
||||
button_data = [NEXT]
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTChangeDetailsScreen(
|
||||
title=title,
|
||||
button_data=button_data,
|
||||
address=change_data.get("address"),
|
||||
amount=change_data.get("amount"),
|
||||
is_multisig=psbt_parser.is_multisig,
|
||||
fingerprint=seed_fingerprint,
|
||||
derivation_path=derivation_path,
|
||||
is_change_derivation_path=is_change_derivation_path,
|
||||
derivation_path_addr_index=derivation_path_addr_index,
|
||||
is_change_addr_verified=is_change_addr_verified,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == NEXT:
|
||||
if self.change_address_num < psbt_parser.num_change_outputs - 1:
|
||||
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1})
|
||||
else:
|
||||
# There's no more change to verify. Move on to sign the PSBT.
|
||||
return Destination(PSBTFinalizeView)
|
||||
|
||||
elif button_data[selected_menu_num] == VERIFY_MULTISIG:
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
|
||||
|
||||
class PSBTFinalizeView(View):
|
||||
"""
|
||||
"""
|
||||
def run(self):
|
||||
psbt_parser: PSBTParser = self.controller.psbt_parser
|
||||
psbt: PSBT = self.controller.psbt
|
||||
|
||||
if not psbt_parser:
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTFinalizeScreen(
|
||||
button_data=["Approve PSBT"]
|
||||
).display()
|
||||
|
||||
if selected_menu_num == 0:
|
||||
# Sign PSBT
|
||||
loading_screen = LoadingScreenThread(text="Signing PSBT...")
|
||||
loading_screen.start()
|
||||
|
||||
sig_cnt = PSBTParser.sig_count(psbt)
|
||||
psbt.sign_with(psbt_parser.root)
|
||||
trimmed_psbt = PSBTParser.trim(psbt)
|
||||
|
||||
loading_screen.stop()
|
||||
|
||||
if sig_cnt == PSBTParser.sig_count(trimmed_psbt):
|
||||
# Signing failed / didn't do anything
|
||||
# TODO: Reserved for Nick. Are there different failure scenarios that we can detect?
|
||||
# Would be nice to alter the message on the next screen w/more detail.
|
||||
return Destination(PSBTSigningErrorView)
|
||||
|
||||
else:
|
||||
self.controller.psbt = trimmed_psbt
|
||||
|
||||
if len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)) == 1:
|
||||
return Destination(PSBTSignedQRDisplayView, view_args={"coordinator": self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0]})
|
||||
else:
|
||||
return Destination(PSBTSelectCoordinatorView)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
|
||||
|
||||
class PSBTSelectCoordinatorView(View):
|
||||
def run(self):
|
||||
button_data = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)
|
||||
selected_menu_num = psbt_screens.PSBTSelectCoordinatorScreen(
|
||||
button_data=button_data
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
return Destination(PSBTSignedQRDisplayView, view_args={"coordinator": button_data[selected_menu_num]})
|
||||
|
||||
|
||||
|
||||
class PSBTSignedQRDisplayView(View):
|
||||
def __init__(self, coordinator: str):
|
||||
super().__init__()
|
||||
self.coordinator = coordinator
|
||||
|
||||
def run(self):
|
||||
qr_psbt_type = QRType.PSBT__UR2
|
||||
if self.coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP:
|
||||
qr_psbt_type = QRType.PSBT__SPECTER
|
||||
|
||||
qr_encoder = EncodeQR(
|
||||
psbt=self.controller.psbt,
|
||||
qr_type=qr_psbt_type,
|
||||
qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY),
|
||||
wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE),
|
||||
)
|
||||
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)
|
||||
@@ -0,0 +1,94 @@
|
||||
import json
|
||||
from seedsigner.models.psbt_parser import PSBTParser
|
||||
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
from seedsigner.views.psbt_views import PSBTSelectSeedView
|
||||
from seedsigner.views.seed_views import SeedAddPassphraseView
|
||||
|
||||
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
|
||||
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
|
||||
from seedsigner.models import DecodeQR, Seed
|
||||
from seedsigner.models.qr_type import QRType
|
||||
|
||||
|
||||
|
||||
class ScanView(View):
|
||||
def run(self):
|
||||
from seedsigner.gui.screens.scan_screens import ScanScreen
|
||||
|
||||
# Run the live preview and QR code capture process
|
||||
# TODO: Does this belong in its own BaseThread?
|
||||
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
|
||||
self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code)
|
||||
screen = ScanScreen(decoder=self.decoder)
|
||||
screen.display()
|
||||
|
||||
if self.decoder.is_complete:
|
||||
if self.decoder.is_seed:
|
||||
seed_mnemonic = self.decoder.get_seed_phrase()
|
||||
if not seed_mnemonic:
|
||||
# seed is not valid, Exit if not valid with message
|
||||
raise Exception("Not yet implemented!")
|
||||
else:
|
||||
# Found a valid mnemonic seed! All new seeds should be considered
|
||||
# pending (might set a passphrase, SeedXOR, etc) until finalized.
|
||||
from .seed_views import 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:
|
||||
return Destination(SeedAddPassphraseView)
|
||||
else:
|
||||
return Destination(SeedFinalizeView)
|
||||
|
||||
elif self.decoder.is_psbt:
|
||||
psbt = self.decoder.get_psbt()
|
||||
self.controller.psbt = psbt
|
||||
self.controller.psbt_parser = None
|
||||
return Destination(PSBTSelectSeedView)
|
||||
|
||||
elif self.decoder.is_settings:
|
||||
from seedsigner.models.settings import Settings
|
||||
settings = self.decoder.get_settings_data()
|
||||
Settings.get_instance().update(new_settings=settings)
|
||||
|
||||
print(json.dumps(Settings.get_instance()._data, indent=4))
|
||||
|
||||
return Destination(SettingsUpdatedView, {"config_name": self.decoder.get_settings_config_name()})
|
||||
|
||||
elif self.decoder.is_wallet_descriptor:
|
||||
# TODO
|
||||
print("Implement QR scanning for wallet descriptors!")
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
elif self.decoder.is_address:
|
||||
# TODO: Reserved for Nick!
|
||||
print("Implement QR scanning for Bitcoin addresses!")
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
else:
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
return Destination(MainMenuView)
|
||||
|
||||
|
||||
|
||||
class SettingsUpdatedView(View):
|
||||
def __init__(self, config_name: str):
|
||||
super().__init__()
|
||||
|
||||
self.config_name = config_name
|
||||
|
||||
|
||||
def run(self):
|
||||
from seedsigner.gui.screens.scan_screens import SettingsUpdatedScreen
|
||||
screen = SettingsUpdatedScreen(config_name=self.config_name)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
# Only one exit point
|
||||
return Destination(MainMenuView)
|
||||
|
||||
@@ -4,16 +4,18 @@ import time
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from . import View
|
||||
from .view import View
|
||||
|
||||
from seedsigner.helpers import B
|
||||
from seedsigner.gui.components import Fonts, GUIConstants, load_image
|
||||
|
||||
|
||||
class LogoView(View):
|
||||
|
||||
# TODO: Should be derived from View?
|
||||
class LogoView:
|
||||
def __init__(self):
|
||||
dirname = os.path.dirname(__file__)
|
||||
logo_url = os.path.join(dirname, "../../", "seedsigner", "resources", "logo_black_240.png")
|
||||
self.logo = Image.open(logo_url)
|
||||
from seedsigner.gui import Renderer
|
||||
self.renderer = Renderer.get_instance()
|
||||
self.logo = load_image("logo_black_240.png")
|
||||
|
||||
|
||||
|
||||
@@ -26,18 +28,18 @@ class OpeningSplashView(LogoView):
|
||||
for i in range(250, -1, -25):
|
||||
self.logo.putalpha(255 - i)
|
||||
background = Image.new("RGBA", self.logo.size, (0,0,0))
|
||||
View.disp.ShowImage(Image.alpha_composite(background, self.logo), 0, 0)
|
||||
self.renderer.disp.ShowImage(Image.alpha_composite(background, self.logo), 0, 0)
|
||||
|
||||
# Display version num and hold for a few seconds
|
||||
font = View.ROBOTOCONDENSED_REGULAR_22
|
||||
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.TOP_NAV_TITLE_FONT_SIZE)
|
||||
version = f"v{controller.VERSION}"
|
||||
tw, th = font.getsize(version)
|
||||
x = int((View.canvas_width - tw) / 2)
|
||||
y = int(View.canvas_height / 2) + 40
|
||||
x = int((self.renderer.canvas_width - tw) / 2)
|
||||
y = int(self.renderer.canvas_height / 2) + 40
|
||||
|
||||
draw = ImageDraw.Draw(self.logo)
|
||||
draw.text((x, y), version, fill="orange", font=font)
|
||||
View.DispShowImage(self.logo)
|
||||
draw.text((x, y), version, fill=GUIConstants.ACCENT_COLOR, font=font)
|
||||
self.renderer.show_image(self.logo)
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
@@ -55,7 +57,6 @@ class ScreensaverView(LogoView):
|
||||
self.min_coords = (0, 0)
|
||||
self.max_coords = (self.logo.size[0], self.logo.size[1])
|
||||
|
||||
max_increment = 25
|
||||
self.increment_x = self.rand_increment()
|
||||
self.increment_y = self.rand_increment()
|
||||
self.cur_x = int(self.logo.size[0] / 2)
|
||||
@@ -65,7 +66,6 @@ class ScreensaverView(LogoView):
|
||||
self.last_screen = None
|
||||
|
||||
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
return self._is_running
|
||||
@@ -87,49 +87,53 @@ class ScreensaverView(LogoView):
|
||||
self._is_running = True
|
||||
|
||||
# Store the current screen in order to restore it later
|
||||
self.last_screen = View.canvas.copy()
|
||||
self.last_screen = self.renderer.canvas.copy()
|
||||
|
||||
screensaver_start = int(time.time() * 1000)
|
||||
|
||||
while True:
|
||||
if self.buttons.has_any_input():
|
||||
return self.stop()
|
||||
# Screensaver must block any attempts to use the Renderer in another thread so it
|
||||
# never gives up the lock until it returns.
|
||||
with self.renderer.lock:
|
||||
while True:
|
||||
if self.buttons.has_any_input():
|
||||
return self.stop()
|
||||
|
||||
# Must crop the image to the exact display size
|
||||
crop = self.image.crop((
|
||||
self.cur_x, self.cur_y,
|
||||
self.cur_x + View.canvas_width, self.cur_y + View.canvas_height))
|
||||
View.disp.ShowImage(crop, 0, 0)
|
||||
# Must crop the image to the exact display size
|
||||
crop = self.image.crop((
|
||||
self.cur_x, self.cur_y,
|
||||
self.cur_x + self.renderer.canvas_width, self.cur_y + self.renderer.canvas_height))
|
||||
self.renderer.disp.ShowImage(crop, 0, 0)
|
||||
|
||||
self.cur_x += self.increment_x
|
||||
self.cur_y += self.increment_y
|
||||
self.cur_x += self.increment_x
|
||||
self.cur_y += self.increment_y
|
||||
|
||||
if self.cur_x < self.min_coords[0]:
|
||||
self.cur_x = self.min_coords[0]
|
||||
self.increment_x = self.rand_increment()
|
||||
if self.increment_x < 0.0:
|
||||
self.increment_x *= -1.0
|
||||
elif self.cur_x > self.max_coords[0]:
|
||||
self.cur_x = self.max_coords[0]
|
||||
self.increment_x = self.rand_increment()
|
||||
if self.increment_x > 0.0:
|
||||
self.increment_x *= -1.0
|
||||
# At each edge bump, calculate a new random rate of change for that axis
|
||||
if self.cur_x < self.min_coords[0]:
|
||||
self.cur_x = self.min_coords[0]
|
||||
self.increment_x = self.rand_increment()
|
||||
if self.increment_x < 0.0:
|
||||
self.increment_x *= -1.0
|
||||
elif self.cur_x > self.max_coords[0]:
|
||||
self.cur_x = self.max_coords[0]
|
||||
self.increment_x = self.rand_increment()
|
||||
if self.increment_x > 0.0:
|
||||
self.increment_x *= -1.0
|
||||
|
||||
if self.cur_y < self.min_coords[1]:
|
||||
self.cur_y = self.min_coords[1]
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y < 0.0:
|
||||
self.increment_y *= -1.0
|
||||
elif self.cur_y > self.max_coords[1]:
|
||||
self.cur_y = self.max_coords[1]
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y > 0.0:
|
||||
self.increment_y *= -1.0
|
||||
if self.cur_y < self.min_coords[1]:
|
||||
self.cur_y = self.min_coords[1]
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y < 0.0:
|
||||
self.increment_y *= -1.0
|
||||
elif self.cur_y > self.max_coords[1]:
|
||||
self.cur_y = self.max_coords[1]
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y > 0.0:
|
||||
self.increment_y *= -1.0
|
||||
|
||||
|
||||
def stop(self):
|
||||
# Restore the original screen
|
||||
View.DispShowImage(self.last_screen)
|
||||
self.renderer.show_image(self.last_screen)
|
||||
|
||||
self._is_running = False
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants
|
||||
|
||||
from .view import View, Destination, BackStackView, MainMenuView
|
||||
|
||||
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
|
||||
from seedsigner.models.settings import SettingsConstants, SettingsDefinition
|
||||
|
||||
|
||||
|
||||
class SettingsMenuView(View):
|
||||
def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None):
|
||||
super().__init__()
|
||||
self.visibility = visibility
|
||||
self.selected_attr = selected_attr
|
||||
|
||||
|
||||
def run(self):
|
||||
settings_entries = SettingsDefinition.get_settings_entries(
|
||||
visibiilty=self.visibility
|
||||
)
|
||||
button_data=[e.display_name for e in settings_entries]
|
||||
|
||||
selected_button = 0
|
||||
if self.selected_attr:
|
||||
for i, entry in enumerate(settings_entries):
|
||||
if entry.attr_name == self.selected_attr:
|
||||
selected_button = i
|
||||
break
|
||||
|
||||
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
|
||||
title = "Settings"
|
||||
button_data.append(("Advanced", FontAwesomeIconConstants.CIRCLE_CHEVRON_RIGHT))
|
||||
next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
|
||||
|
||||
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
|
||||
button_data.append(("Developer Options", FontAwesomeIconConstants.CIRCLE_CHEVRON_RIGHT))
|
||||
title = "Advanced"
|
||||
next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER})
|
||||
|
||||
elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER:
|
||||
title = "Dev Options"
|
||||
next = None
|
||||
|
||||
selected_menu_num = ButtonListScreen(
|
||||
title=title,
|
||||
is_button_text_centered=False,
|
||||
button_data=button_data,
|
||||
selected_button=selected_button,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
|
||||
return Destination(MainMenuView)
|
||||
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
|
||||
return Destination(SettingsMenuView)
|
||||
else:
|
||||
return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
|
||||
|
||||
elif selected_menu_num == len(settings_entries):
|
||||
return next
|
||||
|
||||
else:
|
||||
# TODO: Free-entry types (are there any?) will need their own SettingsEntryUpdateFreeEntryView(?).
|
||||
return Destination(SettingsEntryUpdateSelectionView, view_args={"attr_name": settings_entries[selected_menu_num].attr_name})
|
||||
|
||||
|
||||
|
||||
class SettingsEntryUpdateSelectionView(View):
|
||||
"""
|
||||
Handles changes to all selection-type settings (Multiselect, SELECT_1,
|
||||
Enabled/Disabled, etc).
|
||||
"""
|
||||
def __init__(self, attr_name: str):
|
||||
super().__init__()
|
||||
self.settings_entry = SettingsDefinition.get_settings_entry(attr_name)
|
||||
self.selected_button = None
|
||||
|
||||
|
||||
def run(self):
|
||||
initial_value = self.settings.get_value(self.settings_entry.attr_name)
|
||||
button_data = []
|
||||
checked_buttons = []
|
||||
for i, value in enumerate(self.settings_entry.selection_options):
|
||||
if type(value) == tuple:
|
||||
value, display_name = value
|
||||
else:
|
||||
display_name = value
|
||||
button_data.append(display_name)
|
||||
if (type(initial_value) == list and value in initial_value) or value == initial_value:
|
||||
checked_buttons.append(i)
|
||||
|
||||
if self.selected_button is None:
|
||||
# Highlight the selection (for multiselect highlight the first
|
||||
# selected option).
|
||||
self.selected_button = i
|
||||
|
||||
if not self.selected_button:
|
||||
self.selected_button = 0
|
||||
|
||||
ret_value = settings_screens.SettingsEntryUpdateSelectionScreen(
|
||||
display_name=self.settings_entry.display_name,
|
||||
help_text=self.settings_entry.help_text,
|
||||
button_data=button_data,
|
||||
selected_button=self.selected_button,
|
||||
checked_buttons=checked_buttons,
|
||||
settings_entry_type=self.settings_entry.type,
|
||||
).display()
|
||||
|
||||
destination = None
|
||||
settings_menu_view_destination = Destination(
|
||||
SettingsMenuView,
|
||||
view_args={
|
||||
"visibility": self.settings_entry.visibility,
|
||||
"selected_attr": self.settings_entry.attr_name
|
||||
}
|
||||
)
|
||||
|
||||
if ret_value == RET_CODE__BACK_BUTTON:
|
||||
return settings_menu_view_destination
|
||||
|
||||
value = self.settings_entry.get_selection_option_value(ret_value)
|
||||
|
||||
if self.settings_entry.type == SettingsConstants.TYPE__FREE_ENTRY:
|
||||
updated_value = ret_value
|
||||
destination = settings_menu_view_destination
|
||||
|
||||
elif self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT:
|
||||
updated_value = list(initial_value)
|
||||
if ret_value not in checked_buttons:
|
||||
# This is a new selection to add
|
||||
updated_value.append(value)
|
||||
else:
|
||||
# This is a de-select to remove
|
||||
updated_value.remove(value)
|
||||
|
||||
else:
|
||||
# All other types are single selects (e.g. Enabled/Disabled, SELECT_1)
|
||||
if value == initial_value:
|
||||
# No change, return to menu
|
||||
return settings_menu_view_destination
|
||||
else:
|
||||
updated_value = value
|
||||
|
||||
self.settings.set_value(
|
||||
attr_name=self.settings_entry.attr_name,
|
||||
value=updated_value
|
||||
)
|
||||
|
||||
if destination:
|
||||
return destination
|
||||
|
||||
# All selects stay in place; re-initialize where in the list we left off
|
||||
self.selected_button = ret_value
|
||||
return self.run()
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# Internal file class dependencies
|
||||
from . import View
|
||||
from seedsigner.helpers import Buttons, B
|
||||
|
||||
# External Dependencies
|
||||
import time
|
||||
|
||||
|
||||
class SigningToolsView(View):
|
||||
|
||||
def __init__(self, seed_storage) -> None:
|
||||
View.__init__(self)
|
||||
self.seed_storage = seed_storage
|
||||
|
||||
###
|
||||
### XPub
|
||||
###
|
||||
|
||||
def display_xpub_info(self, fingerprint, derivation, xpub):
|
||||
derivation_display = "Derivation: " + derivation
|
||||
xpub_display = xpub[0:7] + "..." + xpub[-9:]
|
||||
self.draw_modal(["Master Fingerprint: ", fingerprint, derivation_display, xpub_display], "Xpub Info", "Right to Continue")
|
||||
|
||||
###
|
||||
### Signing Tx
|
||||
###
|
||||
|
||||
def display_transaction_information(self, p) -> None:
|
||||
self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
tw, th = self.draw.textsize("Confirm Tx Details", font=View.ASSISTANT25)
|
||||
self.draw.text(((240 - tw) / 2, 3), "Confirm Tx Details", fill=View.color, font=View.ASSISTANT25)
|
||||
|
||||
in_fee_outs_str = str(len(p.psbt.inputs))
|
||||
in_fee_outs_str += " inputs - fee = " if len(p.psbt.inputs) > 1 else " input - fee = "
|
||||
in_fee_outs_str += str(len(p.psbt.outputs))
|
||||
in_fee_outs_str += " outs" if len(p.psbt.outputs) > 1 else " out"
|
||||
tw, th = self.draw.textsize(in_fee_outs_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 40), in_fee_outs_str, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
receiving_addr_str1 = ""
|
||||
receiving_addr_str2 = ""
|
||||
if len(p.destination_addresses) > 1:
|
||||
receiving_addr_str1 += "multiple"
|
||||
receiving_addr_str2 += "receiving addresses"
|
||||
elif len(p.destination_addresses) == 1:
|
||||
receiving_addr_str1 += "receiving address"
|
||||
receiving_addr_str2 += "last 13: ... " + p.destination_addresses[0][-13:]
|
||||
else:
|
||||
receiving_addr_str1 += "Self-Transfer"
|
||||
|
||||
tw, th = self.draw.textsize(receiving_addr_str1, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 75), receiving_addr_str1, fill=View.color, font=View.ASSISTANT22)
|
||||
if len(receiving_addr_str2) > 0:
|
||||
tw, th = self.draw.textsize(receiving_addr_str2, font=View.ROBOTOCONDENSED_BOLD_18)
|
||||
self.draw.text(((240 - tw) / 2, 105), receiving_addr_str2, fill=View.color, font=View.ROBOTOCONDENSED_BOLD_18)
|
||||
|
||||
|
||||
if p.spend_amount > 0:
|
||||
spending_str = "Spend: " + str(p.spend_amount) + " sats"
|
||||
tw, th = self.draw.textsize(spending_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 130), spending_str, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
if p.change_amount > 0 and len(p.destination_addresses) == 0:
|
||||
change_str = "Amount: " + str(p.change_amount) + " sats"
|
||||
tw, th = self.draw.textsize(change_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.ASSISTANT22)
|
||||
elif p.change_amount > 0:
|
||||
change_str = "Change: " + str(p.change_amount) + " sats"
|
||||
tw, th = self.draw.textsize(change_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
fee_str = "Fee: " + str(p.fee_amount) + " sats"
|
||||
tw, th = self.draw.textsize(fee_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 180), fee_str, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
tw, th = self.draw.textsize("Left to Exit, Right to Continue", font=View.ASSISTANT18)
|
||||
self.draw.text(((240 - tw) / 2, 215), "Left to Exit, Right to Continue", fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
def qr_gen_status(percentage):
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
tw, th = View.draw.textsize("QR Generation", font=View.ASSISTANT25)
|
||||
View.draw.text(((240 - tw) / 2, 90), "QR Generation", fill=View.color, font=View.ASSISTANT25)
|
||||
tw, th = View.draw.textsize(str(round(percentage)) + "% Complete", font=View.ASSISTANT25)
|
||||
View.draw.text(((240 - tw) / 2, 125), str(round(percentage)) + "% Complete", fill=View.color, font=View.ASSISTANT25)
|
||||
View.DispShowImage()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
from PIL.ImageOps import autocontrast
|
||||
from seedsigner.gui.screens.screen import LargeButtonScreen
|
||||
|
||||
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 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, SeedWordsView, SeedWordsWarningView
|
||||
|
||||
from .view import NotYetImplementedView, View, Destination, BackStackView, MainMenuView
|
||||
|
||||
|
||||
|
||||
class ToolsMenuView(View):
|
||||
def run(self):
|
||||
IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA)
|
||||
DICE = ("New seed", FontAwesomeIconConstants.DICE)
|
||||
KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
|
||||
button_data = [IMAGE, DICE, KEYBOARD]
|
||||
screen = ButtonListScreen(
|
||||
title="Tools",
|
||||
is_button_text_centered=False,
|
||||
button_data=button_data
|
||||
)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == IMAGE:
|
||||
return Destination(ToolsImageEntropyLivePreviewView)
|
||||
|
||||
elif button_data[selected_menu_num] == DICE:
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
"""****************************************************************************
|
||||
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)
|
||||
@@ -1,379 +1,218 @@
|
||||
# External Dependencies
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
import pathlib
|
||||
import spidev as SPI
|
||||
import time
|
||||
from multiprocessing import Queue
|
||||
from seedsigner.helpers import B, ST7789
|
||||
from dataclasses import dataclass
|
||||
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants
|
||||
from seedsigner.gui.screens import (RET_CODE__POWER_BUTTON, TextTopNavScreen)
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, LargeButtonScreen, WarningScreen
|
||||
from seedsigner.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)
|
||||
@dataclass
|
||||
class Destination:
|
||||
"""
|
||||
Basic struct to pass back to the Controller to tell it which View the user should
|
||||
be presented with next.
|
||||
"""
|
||||
View_cls: View # The target View to route to
|
||||
view_args: dict = None # The input args required to instantiate the target View
|
||||
skip_current_view: bool = False # The current View is just forwarding; omit current View from history
|
||||
clear_history: bool = False # Optionally clears the back_stack to prevent "back"
|
||||
|
||||
text_overlay = Image.new("RGBA", (View.canvas_width, View.canvas_height), (255,255,255,0))
|
||||
text_overlay_draw = ImageDraw.Draw(text_overlay)
|
||||
if not font:
|
||||
font = View.ASSISTANT18
|
||||
tw, th = text_overlay_draw.textsize(text, font=font)
|
||||
if text_background:
|
||||
text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background)
|
||||
text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font)
|
||||
|
||||
View.DispShowImage(image_copy, alpha_overlay=text_overlay)
|
||||
|
||||
|
||||
def draw_modal(self, lines = [], title = "", bottom = "") -> None:
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
if len(title) > 0:
|
||||
tw, th = View.draw.textsize(title, font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
if len(bottom) > 0:
|
||||
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
if len(lines) == 1:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT26)
|
||||
elif len(lines) == 2:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
elif len(lines) == 3:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.ASSISTANT26)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
|
||||
elif len(lines) == 4:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[3], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
return
|
||||
|
||||
def draw_address(self, address) -> None:
|
||||
|
||||
if address.startswith(tuple(['1','2','3'])):
|
||||
dividby = 14
|
||||
def __str__(self):
|
||||
if self.View_cls is None:
|
||||
out = "None"
|
||||
else:
|
||||
dividby = 18
|
||||
address_parts = [address[i:i+dividby] for i in range(0, len(address), dividby)]
|
||||
address_part_1 = address_parts[0]
|
||||
address_part_2 = address_parts[1] if len(address_parts) >= 2 else ""
|
||||
address_part_3 = address_parts[2] if len(address_parts) >= 3 else ""
|
||||
address_part_4 = address_parts[3] if len(address_parts) >= 4 else ""
|
||||
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
tw, th = View.draw.textsize("Right to Continue", font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), "Right to Continue", fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
tw, th = View.draw.textsize("Bitcoin Address", font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 35), "Bitcoin Address", fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(address_part_1, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
View.draw.text(((240 - tw) / 2, 70), address_part_1, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
tw, th = View.draw.textsize(address_part_2, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
View.draw.text(((240 - tw) / 2, 105), address_part_2, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
tw, th = View.draw.textsize(address_part_3, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
View.draw.text(((240 - tw) / 2, 140), address_part_3, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
tw, th = View.draw.textsize(address_part_4, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
View.draw.text(((240 - tw) / 2, 175), address_part_4, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
return
|
||||
|
||||
def draw_passphrase(self, title, passphrase, bottom) -> None:
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
passphrase_part_1 = passphrase[0:20]
|
||||
passphrase_part_2 = passphrase[20:40]
|
||||
passphrase_part_3 = passphrase[40:60]
|
||||
passphrase_part_4 = passphrase[60:1000]
|
||||
|
||||
tw, th = View.draw.textsize(title, font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 30), title, fill=View.color, font=View.ASSISTANT26)
|
||||
|
||||
tw, th = View.draw.textsize(passphrase_part_1, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
View.draw.text(((240 - tw) / 2, 70), passphrase_part_1, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
tw, th = View.draw.textsize(passphrase_part_2, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
View.draw.text(((240 - tw) / 2, 100), passphrase_part_2, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
tw, th = View.draw.textsize(passphrase_part_3, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
View.draw.text(((240 - tw) / 2, 130), passphrase_part_3, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
tw, th = View.draw.textsize(passphrase_part_4, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
View.draw.text(((240 - tw) / 2, 160), passphrase_part_4, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
return
|
||||
|
||||
def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None:
|
||||
|
||||
self.draw_prompt_custom("", "Yes ", "No ", lines, title, bottom)
|
||||
return
|
||||
|
||||
|
||||
def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None:
|
||||
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
if len(title) > 0:
|
||||
tw, th = View.draw.textsize(title, font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
if len(bottom) > 0:
|
||||
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
if len(lines) == 1:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT26)
|
||||
elif len(lines) == 2:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
elif len(lines) == 3:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.ASSISTANT26)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
|
||||
elif len(lines) == 4:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[3], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
a_x_offset = 240 - View.ASSISTANT25.getsize(a_txt)[0]
|
||||
View.draw.text((a_x_offset, 39 + 0), a_txt, fill=View.color, font=View.ASSISTANT25)
|
||||
|
||||
b_x_offset = 240 - View.ASSISTANT25.getsize(b_txt)[0]
|
||||
View.draw.text((b_x_offset , 39 + 60), b_txt, fill=View.color, font=View.ASSISTANT25)
|
||||
|
||||
c_x_offset = 240 - View.ASSISTANT25.getsize(c_txt)[0]
|
||||
View.draw.text((c_x_offset , 39 + 120), c_txt, fill=View.color, font=View.ASSISTANT25)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
return
|
||||
|
||||
|
||||
###
|
||||
### Power Off Screen
|
||||
###
|
||||
|
||||
def display_power_off_screen(self):
|
||||
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
line1 = "Powering Down..."
|
||||
line2 = "Please wait about"
|
||||
line3 = "30 seconds before"
|
||||
line4 = "disconnecting power."
|
||||
|
||||
tw, th = View.draw.textsize(line1, font=View.ASSISTANT22)
|
||||
View.draw.text(((240-tw)/2, 45), line1, fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(line2, font=View.ASSISTANT20)
|
||||
View.draw.text(((240-tw)/2, 100), line2, fill=View.color, font=View.ASSISTANT20)
|
||||
tw, th = View.draw.textsize(line3, font=View.ASSISTANT20)
|
||||
View.draw.text(((240-tw)/2, 130), line3, fill=View.color, font=View.ASSISTANT20)
|
||||
tw, th = View.draw.textsize(line4, font=View.ASSISTANT20)
|
||||
View.draw.text(((240-tw)/2, 160), line4, fill=View.color, font=View.ASSISTANT20)
|
||||
View.DispShowImage()
|
||||
|
||||
|
||||
def display_blank_screen(self):
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
View.DispShowImage()
|
||||
|
||||
|
||||
###
|
||||
### Reusable components
|
||||
###
|
||||
def render_previous_button(self, highlight=False):
|
||||
# Set up the "back" arrow in the upper left
|
||||
arrow = "<"
|
||||
word_font = View.ROBOTOCONDENSED_BOLD_26
|
||||
top_padding = -3
|
||||
bottom_padding = 3
|
||||
side_padding = 3
|
||||
tw, th = word_font.getsize(arrow)
|
||||
self.previous_button_width = tw + 2 * side_padding
|
||||
if highlight:
|
||||
font_color = "black"
|
||||
background_color = View.color
|
||||
out = self.View_cls.__name__
|
||||
if self.view_args:
|
||||
out += f"({self.view_args})"
|
||||
else:
|
||||
font_color = View.color
|
||||
background_color = "black"
|
||||
View.draw.rectangle((0,0, self.previous_button_width, th + top_padding + bottom_padding), fill=background_color)
|
||||
View.draw.text((side_padding, top_padding), arrow, fill=font_color, font=word_font)
|
||||
out += "()"
|
||||
if self.clear_history:
|
||||
out += f" | clear_history: {self.clear_history}"
|
||||
return out
|
||||
|
||||
def run(self):
|
||||
if not self.view_args:
|
||||
# Can't unpack (**) None so we replace with an empty dict
|
||||
self.view_args = {}
|
||||
# Instantiate the `View_cls` and run() it with the `view_args` dict
|
||||
return self.View_cls(**self.view_args).run()
|
||||
|
||||
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Root level Views don't have a sub-module home so they live at the top level here.
|
||||
#
|
||||
#########################################################################################
|
||||
class MainMenuView(View):
|
||||
def run(self):
|
||||
from .seed_views import SeedsMenuView
|
||||
from .settings_views import SettingsMenuView
|
||||
from .scan_views import ScanView
|
||||
from .tools_views import ToolsMenuView
|
||||
from seedsigner.gui.screens import LargeButtonScreen
|
||||
menu_items = [
|
||||
(("Scan", FontAwesomeIconConstants.QRCODE), ScanView),
|
||||
(("Seeds", FontAwesomeIconConstants.KEY), SeedsMenuView),
|
||||
(("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH), ToolsMenuView),
|
||||
(("Settings", FontAwesomeIconConstants.GEAR), SettingsMenuView),
|
||||
]
|
||||
screen = LargeButtonScreen(
|
||||
title="Home",
|
||||
title_font_size=26,
|
||||
button_data=[entry[0] for entry in menu_items],
|
||||
show_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 = ("Reset", 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(ResetView)
|
||||
|
||||
elif button_data[selected_menu_num] == POWER_OFF:
|
||||
return Destination(PowerOffView)
|
||||
|
||||
|
||||
|
||||
class ResetView(View):
|
||||
def run(self):
|
||||
thread = ResetView.DoResetThread()
|
||||
thread.start()
|
||||
TextTopNavScreen(
|
||||
title="Resetting",
|
||||
text="SeedSigner is restarting. All data will be wiped.",
|
||||
show_back_button=False,
|
||||
).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()
|
||||
TextTopNavScreen(
|
||||
title="Powering Off",
|
||||
text="Please wait about 30 seconds before disconnecting power.",
|
||||
show_back_button=False,
|
||||
).display()
|
||||
|
||||
|
||||
class PowerOffThread(BaseThread):
|
||||
def run(self):
|
||||
import time
|
||||
from subprocess import call
|
||||
while self.keep_running:
|
||||
time.sleep(5)
|
||||
call("sudo shutdown --poweroff now", shell=True)
|
||||
|
||||
|
||||
|
||||
class NotYetImplementedView(View):
|
||||
"""
|
||||
Temporary View to use during dev.
|
||||
"""
|
||||
def run(self):
|
||||
WarningScreen(
|
||||
title="Work In Progress",
|
||||
status_headline="Not Yet Implemented",
|
||||
text="This is still on our to-do list!",
|
||||
button_data=["Back to Main Menu"],
|
||||
).display()
|
||||
|
||||
return Destination(MainMenuView)
|
||||
|
||||
@@ -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
|
||||
@@ -23,11 +23,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=bip39.WORDLIST)
|
||||
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=bip39.WORDLIST)
|
||||
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from seedsigner.helpers.ur2.bytewords import decode
|
||||
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
|
||||
from seedsigner.models.encode_qr import EncodeQR
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
|
||||
@@ -16,7 +17,7 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
|
||||
assert len(seed_phrase) == mnemonic_length
|
||||
|
||||
e = EncodeQR(seed_phrase=seed_phrase, qr_type=qr_type, wordlist=bip39.WORDLIST)
|
||||
data = e.nextPart()
|
||||
data = e.next_part()
|
||||
print(data)
|
||||
|
||||
qr = QR()
|
||||
@@ -27,11 +28,11 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
|
||||
border=3
|
||||
)
|
||||
|
||||
decoder = DecodeQR(wordlist=bip39.WORDLIST)
|
||||
status = decoder.addImage(image)
|
||||
decoder = DecodeQR(wordlist_language_code=SettingsConstants.LANGUAGE__ENGLISH)
|
||||
status = decoder.add_image(image)
|
||||
assert status == DecodeQRStatus.COMPLETE
|
||||
|
||||
decoded_seed_phrase = decoder.getSeedPhrase()
|
||||
decoded_seed_phrase = decoder.get_seed_phrase()
|
||||
print(decoded_seed_phrase)
|
||||
assert seed_phrase == decoded_seed_phrase
|
||||
|
||||
@@ -42,10 +43,10 @@ def test_standard_seedqr_encode_decode_():
|
||||
them back again to their original mnemonic seed phrase.
|
||||
"""
|
||||
# 24-word seed
|
||||
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEEDQR)
|
||||
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEED__SEEDQR)
|
||||
|
||||
# 12-word seed
|
||||
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEEDQR)
|
||||
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEED__SEEDQR)
|
||||
|
||||
|
||||
|
||||
@@ -54,10 +55,10 @@ def test_compact_seedqr_encode_decode():
|
||||
them back again to their original mnemonic seed phrase.
|
||||
"""
|
||||
# 24-word seed
|
||||
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed
|
||||
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
|
||||
|
||||
@@ -67,40 +68,40 @@ def test_compact_seedqr_handles_null_bytes():
|
||||
"""
|
||||
# 24-word seed, null bytes at the front
|
||||
entropy = b'\x00' + os.urandom(31)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 24-word seed, null bytes in the middle
|
||||
entropy = os.urandom(10) + b'\x00' + os.urandom(21)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 24-word seed, null bytes at the end
|
||||
entropy = os.urandom(31) + b'\x00'
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 24-word seed, multiple null bytes
|
||||
entropy = os.urandom(5) + b'\x00' + os.urandom(5) + b'\x00' + os.urandom(20)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 24-word seed, multiple null bytes in a row
|
||||
entropy = os.urandom(10) + b'\x00\x00' + os.urandom(20)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, null bytes at the beginning
|
||||
entropy = b'\x00' + os.urandom(15)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, null bytes in the middle
|
||||
entropy = os.urandom(5) + b'\x00' + os.urandom(10)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, null bytes at the end
|
||||
entropy = os.urandom(15) + b'\x00'
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, multiple null bytes
|
||||
entropy = os.urandom(5) + b'\x00' + os.urandom(5) + b'\x00' + os.urandom(4)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, multiple null bytes in a row
|
||||
entropy = os.urandom(10) + b'\x00\x00' + os.urandom(4)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||