Merge branch 'SeedSigner:main' into main
@@ -2,3 +2,5 @@
|
||||
__pycache__/
|
||||
src/seedsigner.egg-info/
|
||||
.nova
|
||||
.vscode
|
||||
src/seedsigner/models/settings_definition.json
|
||||
|
||||
@@ -81,14 +81,16 @@ The quickest and easiest way to install the software is to download the most rec
|
||||
After downloading the .zip file, extract the seedsigner .img file, and write it to a MicroSD card (at least 4GB in size or larger). Then install the MicroSD in the assembled hardware and off you go. If your goal is a more trustless installation, you can follow the [manual installation instructions](docs/manual_installation.md).
|
||||
|
||||
## Verifying Your Software
|
||||
You can verify the data integrity and authenticity of the latest release with as little as three commands (though moving forward you will have to replace the version in the following commands with the version number you are attempting to validate). This process assumes that you have navigated to a folder where you have these four relevant files present:
|
||||
You can verify the data integrity and authenticity of the latest release with as little as three commands. This process assumes that you know [how to navigate on a terminal](https://terminalcheatsheet.com/guides/navigate-terminal) and have navigated to the folder where you have these four relevant files present: (This will most likely be your Downloads folder.)
|
||||
|
||||
* seedsigner_pubkey.gpg (from the main folder of this repo)
|
||||
* seedsigner_0_4_5.img.zip (from the software release)
|
||||
* seedsigner_0_4_5.img.zip.sha256 (from the software release)
|
||||
* seedsigner_0_4_5.img.zip.sha256.sig (from the software release)
|
||||
* seedsigner_0_4_6.img.zip (from the software release)
|
||||
* seedsigner_0_4_6.img.zip.sha256 (from the software release)
|
||||
* seedsigner_0_4_6.img.zip.sha256.sig (from the software release)
|
||||
|
||||
This process also assumes you are running the commands from a system where both GPG and shasum are installed and working.
|
||||
**Note:** The specific version number of the files in your folder might not match the above exactly, but their overall format and amount should be the same.
|
||||
|
||||
This process also assumes you are running the commands from a system where both [GPG](https://gnupg.org/download/index.html) and [shasum](https://command-not-found.com/shasum) are installed and working.
|
||||
|
||||
First make sure that the public key is present in your keychain:
|
||||
```
|
||||
@@ -101,8 +103,10 @@ key <...> not changed
|
||||
|
||||
Now you can verify the authenticity of the small text file containing the release's SHA256 hash with the command:
|
||||
```
|
||||
gpg --verify seedsigner_0_4_5.img.zip.sha256.sig
|
||||
gpg --verify seedsigner_0_*_*.img.zip.sha256.sig
|
||||
```
|
||||
**Note:** The `*`s in the command above allow the terminal to auto-populate the command with the version number you have in the folder you are in. It should be copied and pasted as is.
|
||||
|
||||
The reponse to this command should include the text:
|
||||
```
|
||||
Good signature from "seedsigner <btc.hardware.solutions@gmail.com>" [unknown]
|
||||
@@ -111,11 +115,11 @@ The previous command validates that aforementioned small text file was signed us
|
||||
|
||||
The last step is to make sure the .zip file that you've downloaded, and that contains the released software, is a perfect match to the software that was published by the holder of the private key in the last step. The command for this step is:
|
||||
```
|
||||
shasum -a 256 -c seedsigner_0_4_5.img.zip.sha256
|
||||
shasum -a 256 -c seedsigner_0_*_*.img.zip.sha256
|
||||
```
|
||||
The reponse to this command should include the text:
|
||||
```
|
||||
seedsigner_0_4_5.img.zip: OK
|
||||
seedsigner_0_4_6.img.zip: OK
|
||||
```
|
||||
|
||||
There are other steps you can take to verify the software, including examining the hash value in the .sha256 text file, but this one has been documented here because it seems the simplest for most people to follow. Please recognize that this process can only validate the software to the extent that the entity that first published the key is an honest actor, and assumes the private key has remained uncompromised and is not being used by a malicious actor.
|
||||
@@ -138,6 +142,14 @@ The Orange Pill enclosure design offers a more finished look that includes butto
|
||||
|
||||
The upper and lower portions of the enclosure can be printed using a standard FDM 3D printer, no supports necessary. The buttons and joystick nub should ideally be produced with a SLA/resin printer. An overview of the entire assembly process can be found [here](https://youtu.be/aIIc2DiZYcI). To access the design files and printable models, click [here](https://github.com/SeedSigner/seedsigner/tree/main/enclosures/orange_pill).
|
||||
|
||||
### Community Designs
|
||||
|
||||
* [Lil Pill](https://cults3d.com/en/3d-model/gadget/lil-pill-seedsigner-case) by @_CyberNomad
|
||||
* [OrangeSurf Case](https://github.com/orangesurf/orangesurf-seedsigner-case) by @OrangeSurfBTC
|
||||
* [PS4 Seedsigner](https://www.thingiverse.com/thing:5363525) by @Silexperience
|
||||
* [OpenPill Faceplate](https://www.printables.com/en/model/179924-seedsigner-open-pill-cover-plates-digital-cross-jo) by @Revetuzo
|
||||
* [Waveshare CoverPlate](https://cults3d.com/en/3d-model/various/seedsigner-coverplate-for-waveshare-1-3-inch-lcd-hat-with-240x240-pixel-display) by @Adathome1
|
||||
|
||||
---------------
|
||||
|
||||
# SeedQR Printable Templates
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
embit==0.4.10
|
||||
embit @ git+https://github.com/seedsigner/embit.git@03cba13e0add64b241972c67e2aa970cb2e3a5d2
|
||||
numpy==1.21.1
|
||||
picamera==1.13
|
||||
Pillow==8.2.0
|
||||
-e git+https://github.com/kdmukai/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar
|
||||
-e git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar
|
||||
qrcode==7.3.1
|
||||
RPi.GPIO==0.7.0
|
||||
six==1.16.0
|
||||
|
||||
@@ -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,58 +114,79 @@ class Keyboard:
|
||||
|
||||
outline_color = "#333"
|
||||
if not self.is_active:
|
||||
rect_color = self.keyboard.background_color
|
||||
font_color = "#666" # Show the letter but render as gray
|
||||
rect_color = self.keyboard.deactivated_background_color
|
||||
font_color = "#333" # Show the letter but render as gray
|
||||
outline_color = self.keyboard.deactivated_background_color
|
||||
if self.is_selected:
|
||||
# Inactive, selected just gets highlighted outline
|
||||
outline_color = self.keyboard.highlight_color
|
||||
elif self.is_selected:
|
||||
rect_color = self.keyboard.highlight_color # Render solid background with the UI's hero color
|
||||
font_color = self.keyboard.background_color
|
||||
font_color = "black"
|
||||
else:
|
||||
if self.is_additional_key:
|
||||
rect_color = "#111"
|
||||
# rect_color = "#111"
|
||||
rect_color = self.keyboard.background_color
|
||||
else:
|
||||
rect_color = self.keyboard.background_color
|
||||
font_color = self.keyboard.highlight_color
|
||||
font_color = "#e8e8e8"
|
||||
|
||||
self.keyboard.draw.rounded_rectangle((self.screen_x, self.screen_y, self.screen_x + self.keyboard.x_width * self.size - 1, self.screen_y + self.keyboard.y_height), outline=outline_color, fill=rect_color, radius=4)
|
||||
tw, th = self.keyboard.draw.textsize(self.letter, font=font)
|
||||
self.keyboard.draw.text((self.screen_x + int((self.keyboard.x_width * self.size - tw) / 2), self.screen_y + int((self.keyboard.y_height - th)/2)), self.letter, fill=font_color, font=font)
|
||||
self.keyboard.draw.rounded_rectangle(
|
||||
(
|
||||
self.screen_x,
|
||||
self.screen_y,
|
||||
self.screen_x + self.keyboard.key_width * self.size - 1,
|
||||
self.screen_y + self.keyboard.key_height
|
||||
),
|
||||
outline=outline_color,
|
||||
fill=rect_color,
|
||||
radius=4
|
||||
)
|
||||
|
||||
# Fixed-width fonts will all have same height, ignoring below baseline (e.g. "Q" or "q")
|
||||
(left, top, right, bottom) = font.getbbox("X", anchor="ls")
|
||||
text_height = -1 * top
|
||||
self.keyboard.draw.text(
|
||||
(
|
||||
self.screen_x + int(self.keyboard.key_width * self.size / 2),
|
||||
self.screen_y + self.keyboard.key_height - int((self.keyboard.key_height - text_height)/2)
|
||||
),
|
||||
self.letter,
|
||||
fill=font_color,
|
||||
font=font,
|
||||
anchor="ms"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def __init__(self,
|
||||
draw,
|
||||
def __init__(self,
|
||||
draw: ImageDraw,
|
||||
charset="1234567890abcdefghijklmnopqrstuvwxyz",
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_size=24,
|
||||
selected_char="a",
|
||||
rows=4,
|
||||
cols=10,
|
||||
rect=(0,40, 240,240),
|
||||
font=None,
|
||||
additional_keys=[KEY_BACKSPACE],
|
||||
auto_wrap=[WRAP_TOP, WRAP_BOTTOM, WRAP_LEFT, WRAP_RIGHT],
|
||||
render_now=True):
|
||||
render_now=True,
|
||||
highlight_color: str = GUIConstants.ACCENT_COLOR):
|
||||
"""
|
||||
`auto_wrap` specifies which edges the keyboard is allowed to loop back when
|
||||
navigating past the end.
|
||||
"""
|
||||
|
||||
# Import here to avoid circular import problems
|
||||
from seedsigner.views import View
|
||||
|
||||
self.draw = draw
|
||||
self.charset = charset
|
||||
self.rows = rows
|
||||
self.cols = cols
|
||||
self.rect = rect
|
||||
if font:
|
||||
self.font = font
|
||||
else:
|
||||
self.font = View.ROBOTOCONDENSED_REGULAR_24
|
||||
self.font = Fonts.get_font(font_name, font_size)
|
||||
|
||||
self.auto_wrap = auto_wrap
|
||||
self.background_color = "black"
|
||||
self.highlight_color = View.color
|
||||
self.background_color = GUIConstants.BUTTON_BACKGROUND_COLOR
|
||||
self.deactivated_background_color = GUIConstants.BACKGROUND_COLOR
|
||||
self.highlight_color = highlight_color
|
||||
|
||||
# Does the specified layout work?
|
||||
additional_key_spaces = 0
|
||||
@@ -177,13 +200,15 @@ class Keyboard:
|
||||
|
||||
# Set up the rendering and state params
|
||||
self.active_keys = list(self.charset)
|
||||
self.additonal_key_compact_font = View.ROBOTOCONDENSED_BOLD_18
|
||||
self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold", 18)
|
||||
self.x_start = rect[0]
|
||||
self.y_start = rect[1]
|
||||
self.x_gap = 1
|
||||
self.x_width = int((rect[2] - rect[0]) / cols) - self.x_gap
|
||||
self.y_gap = 6
|
||||
self.y_height = int((rect[3] - rect[1]) / rows) - self.y_gap
|
||||
self.x_gap = 2
|
||||
self.key_width = int((rect[2] - rect[0]) / cols) - self.x_gap
|
||||
self.width = cols * (self.key_width) + (cols - 1) * self.x_gap
|
||||
self.y_gap = 2
|
||||
self.key_height = int((rect[3] - rect[1]) / rows) - self.y_gap
|
||||
self.height = rows * (self.key_height) + (rows - 1) * self.y_gap
|
||||
self.additional_key_entered_from_x = None
|
||||
|
||||
# Two-dimensional list of Key obj row data
|
||||
@@ -209,12 +234,12 @@ class Keyboard:
|
||||
is_selected=is_selected,
|
||||
keyboard=self
|
||||
))
|
||||
cur_x += self.x_width + self.x_gap
|
||||
cur_x += self.key_width + self.x_gap
|
||||
cur_index_x += 1
|
||||
self.keys.append(cur_row)
|
||||
if i < rows -1:
|
||||
if i < rows - 1:
|
||||
# increment to the next row and continue
|
||||
cur_y += self.y_height + self.y_gap
|
||||
cur_y += self.key_height + self.y_gap
|
||||
else:
|
||||
# It's the last row; add the additional keys at the end
|
||||
for additional_key in additional_keys:
|
||||
@@ -229,7 +254,7 @@ class Keyboard:
|
||||
size=additional_key["size"],
|
||||
is_additional_key=True,
|
||||
))
|
||||
cur_x += self.x_width * additional_key["size"] + self.x_gap
|
||||
cur_x += self.key_width * additional_key["size"] + self.x_gap
|
||||
cur_index_x += additional_key["size"]
|
||||
|
||||
if render_now:
|
||||
@@ -257,7 +282,7 @@ class Keyboard:
|
||||
that section, as in when changing `active_keys` or swapping to alternate
|
||||
charsets (e.g. alpha to special symbols).
|
||||
|
||||
Does NOT call View.DispShowImage to avoid multiple calls on the same screen.
|
||||
Does NOT call self.renderer.show_image to avoid multiple calls on the same screen.
|
||||
"""
|
||||
# Start with a clear screen
|
||||
self.draw.rectangle(self.rect, outline=0, fill=0)
|
||||
@@ -352,7 +377,7 @@ class Keyboard:
|
||||
Returns the character currently highlighted or one of the EXIT_* codes if the
|
||||
user has navigated off the keyboard past an edge that is not in `auto_wrap`.
|
||||
|
||||
Does NOT call View.DispShowImage to avoid multiple calls on the same screen.
|
||||
Does NOT call self.renderer.show_image to avoid multiple calls on the same screen.
|
||||
"""
|
||||
key = self.get_key_at(self.selected_key["x"], self.selected_key["y"])
|
||||
|
||||
@@ -360,7 +385,7 @@ class Keyboard:
|
||||
key.is_selected = False
|
||||
key.render_key()
|
||||
|
||||
if input == B.KEY_RIGHT:
|
||||
if input == HardwareButtonsConstants.KEY_RIGHT:
|
||||
self.selected_key["x"] = key.index_x + key.size
|
||||
new_key = self.get_key_at(self.selected_key["x"], self.selected_key["y"])
|
||||
if new_key is None:
|
||||
@@ -373,7 +398,7 @@ class Keyboard:
|
||||
self.selected_key["x"] -= 1
|
||||
return Keyboard.EXIT_RIGHT
|
||||
|
||||
elif input == B.KEY_LEFT:
|
||||
elif input == HardwareButtonsConstants.KEY_LEFT:
|
||||
key = self.get_selected_key()
|
||||
self.selected_key["x"] = key.index_x - 1
|
||||
if self.selected_key["x"] < 0:
|
||||
@@ -386,14 +411,14 @@ class Keyboard:
|
||||
self.selected_key["x"] += 1
|
||||
return Keyboard.EXIT_LEFT
|
||||
|
||||
elif input == B.KEY_DOWN:
|
||||
elif input == HardwareButtonsConstants.KEY_DOWN:
|
||||
new_index_x, new_index_y, keyboard_exit = self.get_key_below(self.selected_key["x"], self.selected_key["y"])
|
||||
self.selected_key["x"] = new_index_x
|
||||
self.selected_key["y"] = new_index_y
|
||||
if keyboard_exit:
|
||||
return keyboard_exit
|
||||
|
||||
elif input == B.KEY_UP:
|
||||
elif input == HardwareButtonsConstants.KEY_UP:
|
||||
new_index_x, new_index_y, keyboard_exit = self.get_key_above(self.selected_key["x"], self.selected_key["y"])
|
||||
self.selected_key["x"] = new_index_x
|
||||
self.selected_key["y"] = new_index_y
|
||||
@@ -477,29 +502,33 @@ class TextEntryDisplayConstants:
|
||||
|
||||
@dataclass
|
||||
class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
draw: any
|
||||
rect: (int,int,int,int)
|
||||
font: any
|
||||
font_color: any
|
||||
canvas: Image
|
||||
rect: Tuple[int,int,int,int]
|
||||
font_name: str = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME
|
||||
font_size: int = 24
|
||||
accent_color: str = GUIConstants.ACCENT_COLOR
|
||||
background_color: str = GUIConstants.BUTTON_BACKGROUND_COLOR
|
||||
cursor_mode: str = TextEntryDisplayConstants.CURSOR_MODE__BLOCK
|
||||
is_centered: bool = True
|
||||
has_outline: bool = False
|
||||
cur_text: str = " "
|
||||
text_offset = 0
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
self.font = Fonts.get_font(self.font_name, self.font_size)
|
||||
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self.rect[2] - self.rect[0]
|
||||
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self.rect[3] - self.rect[1]
|
||||
|
||||
|
||||
def render(self, cur_text=None, cursor_position=None):
|
||||
# Import here to avoid circular import problems
|
||||
from seedsigner.views import View
|
||||
|
||||
""" Render the live text entry display """
|
||||
if cur_text is not None:
|
||||
self.cur_text = cur_text
|
||||
@@ -508,20 +537,23 @@ class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
image = Image.new("RGB", (self.width + 1, self.height + 1), "black")
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
if self.has_outline:
|
||||
draw.rectangle((0, 0, self.width, self.height), fill="black", outline=self.font_color)
|
||||
draw.rounded_rectangle((0, 0, self.width, self.height), fill=self.background_color, radius=4)
|
||||
|
||||
(left, top, right, bottom) = self.font.getbbox("X", anchor="ls") # measure from baseline
|
||||
text_height = -1 * top # "top" is negative when measuring from baseline; ignoring below baseline
|
||||
|
||||
if self.cursor_mode == TextEntryDisplay.CURSOR_MODE__BLOCK:
|
||||
cursor_block_width = 18
|
||||
cursor_block_height = 33
|
||||
|
||||
# Draw n-1 of the selected letters
|
||||
tw, th = self.font.getsize(self.cur_text[:-1])
|
||||
(left, top, right, bottom) = self.font.getbbox(self.cur_text[:-1], anchor="ls")
|
||||
text_width = right
|
||||
if self.is_centered:
|
||||
self.text_offset = int(self.width - tw - cursor_block_width)/2
|
||||
self.text_offset = int(self.width - text_width - cursor_block_width)/2
|
||||
else:
|
||||
self.text_offset = 3
|
||||
cursor_block_offset = self.text_offset + tw - 1
|
||||
self.text_offset = GUIConstants.COMPONENT_PADDING
|
||||
cursor_block_offset = self.text_offset + text_width - 1
|
||||
if cursor_block_offset == 0:
|
||||
cursor_block_offset = 1
|
||||
|
||||
@@ -530,22 +562,24 @@ class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
# Shift the display left
|
||||
cursor_block_offset -= end_pos_x - self.width + 1
|
||||
self.text_offset -= end_pos_x - self.width + 1
|
||||
|
||||
draw.text((self.text_offset, 3), self.cur_text[:-1], fill=self.font_color, font=self.font)
|
||||
|
||||
draw.text((self.text_offset, self.height - int(text_height/2)), self.cur_text[:-1], fill=GUIConstants.ACCENT_COLOR, font=self.font, anchor="ls")
|
||||
|
||||
# Draw the highlighted cursor block
|
||||
draw.rectangle((cursor_block_offset, 1, cursor_block_offset + cursor_block_width, self.height - 1), fill="#111")
|
||||
draw.text((cursor_block_offset + 1, 3), self.cur_text[-1], fill=self.font_color, font=self.font)
|
||||
cursor_color = "#666"
|
||||
draw.rectangle((cursor_block_offset, 1, cursor_block_offset + cursor_block_width, self.height - 1), fill=cursor_color)
|
||||
draw.text((cursor_block_offset + 1, self.height - int(text_height/2)), self.cur_text[-1], fill=GUIConstants.ACCENT_COLOR, font=self.font, anchor="ls")
|
||||
|
||||
else:
|
||||
cursor_bar_offset = 1
|
||||
cursor_bar_serif_half_width = 4
|
||||
tw, th = self.font.getsize(self.cur_text)
|
||||
if self.is_centered:
|
||||
# self.text_offset = int(self.width - tw)/2
|
||||
raise Exception("Centered cursor bars not fully implemented")
|
||||
|
||||
end_pos_x = 3 + tw + cursor_bar_serif_half_width + 3
|
||||
(left, top, right, bottom) = self.font.getbbox(cur_text if cur_text else "", anchor="ls") # measure from baseline
|
||||
text_width = right
|
||||
|
||||
end_pos_x = 3 + text_width + cursor_bar_serif_half_width + 3
|
||||
if end_pos_x < self.width:
|
||||
# The entire cur_text plus the cursor bar fits
|
||||
self.text_offset = 3 + cursor_bar_serif_half_width
|
||||
@@ -558,7 +592,6 @@ class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
|
||||
# Is the cursor at either extreme?
|
||||
tw_left, th = self.font.getsize(self.cur_text[:cursor_position])
|
||||
tw_right, th = self.font.getsize(self.cur_text[cursor_position:])
|
||||
|
||||
if self.text_offset + tw_left + cursor_bar_serif_half_width + 3 >= self.width:
|
||||
# Cursor is at the extreme right; have to push the full tw_right off
|
||||
@@ -571,7 +604,16 @@ class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
|
||||
cursor_bar_x = self.text_offset + tw_left
|
||||
|
||||
draw.text((self.text_offset, 3), self.cur_text, fill=self.font_color, font=self.font)
|
||||
draw.text(
|
||||
(
|
||||
self.text_offset,
|
||||
self.height - int((self.height - text_height)/2)
|
||||
),
|
||||
self.cur_text,
|
||||
fill=self.accent_color,
|
||||
font=self.font,
|
||||
anchor="ls"
|
||||
)
|
||||
|
||||
# Render as an "I" bar
|
||||
cursor_bar_color = "#ccc"
|
||||
@@ -580,5 +622,5 @@ class TextEntryDisplay(TextEntryDisplayConstants):
|
||||
draw.line((cursor_bar_x - cursor_bar_serif_half_width, self.height - 3, cursor_bar_x + cursor_bar_serif_half_width, self.height - 3), fill=cursor_bar_color)
|
||||
|
||||
# Paste the display onto the main canvas
|
||||
View.canvas.paste(image, (self.rect[0], self.rect[1]))
|
||||
self.canvas.paste(image, (self.rect[0], self.rect[1]))
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from threading import Lock
|
||||
|
||||
from seedsigner.gui.components import Fonts, GUIConstants
|
||||
from seedsigner.hardware.ST7789 import ST7789
|
||||
from seedsigner.models import ConfigurableSingleton
|
||||
|
||||
|
||||
|
||||
class Renderer(ConfigurableSingleton):
|
||||
buttons = None
|
||||
canvas_width = 0
|
||||
canvas_height = 0
|
||||
canvas: Image.Image = None
|
||||
draw: ImageDraw.ImageDraw = None
|
||||
disp = None
|
||||
lock = Lock()
|
||||
|
||||
|
||||
@classmethod
|
||||
def configure_instance(cls):
|
||||
from seedsigner.models.settings import Settings
|
||||
|
||||
# Instantiate the one and only Renderer instance
|
||||
renderer = cls.__new__(cls)
|
||||
cls._instance = renderer
|
||||
|
||||
# Eventually we'll be able to plug in other display controllers
|
||||
renderer.disp = ST7789()
|
||||
renderer.canvas_width = renderer.disp.width
|
||||
renderer.canvas_height = renderer.disp.height
|
||||
|
||||
renderer.canvas = Image.new('RGB', (renderer.canvas_width, renderer.canvas_height))
|
||||
renderer.draw = ImageDraw.Draw(renderer.canvas)
|
||||
|
||||
|
||||
def show_image(self, image=None, alpha_overlay=None):
|
||||
if alpha_overlay:
|
||||
if image == None:
|
||||
image = self.canvas
|
||||
image = Image.alpha_composite(image, alpha_overlay)
|
||||
|
||||
if image:
|
||||
# Always write to the current canvas, rather than trying to replace it
|
||||
self.canvas.paste(image)
|
||||
|
||||
self.disp.ShowImage(self.canvas, 0, 0)
|
||||
|
||||
|
||||
def show_image_pan(self, image, start_x, start_y, end_x, end_y, rate, alpha_overlay=None):
|
||||
cur_x = start_x
|
||||
cur_y = start_y
|
||||
rate_x = rate
|
||||
rate_y = rate
|
||||
if end_x - start_x < 0:
|
||||
rate_x = rate_x * -1
|
||||
if end_y - start_y < 0:
|
||||
rate_y = rate_y * -1
|
||||
|
||||
while (cur_x != end_x or cur_y != end_y) and (rate_x != 0 or rate_y != 0):
|
||||
cur_x += rate_x
|
||||
if (rate_x > 0 and cur_x > end_x) or (rate_x < 0 and cur_x < end_x):
|
||||
# We've moved too far; back up and undo that last move.
|
||||
cur_x -= rate_x
|
||||
rate_x = 0
|
||||
|
||||
cur_y += rate_y
|
||||
if (rate_y > 0 and cur_y > end_y) or (rate_y < 0 and cur_y < end_y):
|
||||
# We've moved too far; back up and undo that last move.
|
||||
cur_y -= rate_y
|
||||
rate_y = 0
|
||||
|
||||
crop = image.crop((cur_x, cur_y, cur_x + self.canvas_width, cur_y + self.canvas_height))
|
||||
|
||||
if alpha_overlay:
|
||||
crop = Image.alpha_composite(crop, alpha_overlay)
|
||||
|
||||
# Always keep a copy of the current display in the canvas
|
||||
self.canvas.paste(crop)
|
||||
|
||||
self.disp.ShowImage(crop, 0, 0)
|
||||
|
||||
|
||||
# TODO: Remove all references
|
||||
def show_image_with_text(self, image, text, font=None, text_color="GREY", text_background=None):
|
||||
image_copy = image.copy().convert("RGBA")
|
||||
|
||||
text_overlay = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
|
||||
text_overlay_draw = ImageDraw.Draw(text_overlay)
|
||||
if not font:
|
||||
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BODY_FONT_SIZE)
|
||||
tw, th = text_overlay_draw.textsize(text, font=font)
|
||||
if text_background:
|
||||
text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background)
|
||||
text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font)
|
||||
|
||||
self.show_image(image_copy, alpha_overlay=text_overlay)
|
||||
|
||||
|
||||
# TODO: Should probably move this to screens.py
|
||||
def draw_modal(self, lines = [], title = "", bottom = "") -> None:
|
||||
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
|
||||
|
||||
if len(title) > 0:
|
||||
tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
|
||||
if len(bottom) > 0:
|
||||
tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
|
||||
if len(lines) == 1:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
elif len(lines) == 2:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
elif len(lines) == 3:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
|
||||
self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
elif len(lines) == 4:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
|
||||
self.show_image()
|
||||
|
||||
return
|
||||
|
||||
|
||||
# TODO: Should probably move this to templates.py
|
||||
def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None:
|
||||
self.draw_prompt_custom("", "Yes ", "No ", lines, title, bottom)
|
||||
return
|
||||
|
||||
|
||||
# TODO: Should probably move this to templates.py
|
||||
def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None:
|
||||
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
|
||||
|
||||
if len(title) > 0:
|
||||
tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
|
||||
if len(bottom) > 0:
|
||||
tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
|
||||
if len(lines) == 1:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
elif len(lines) == 2:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
elif len(lines) == 3:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
|
||||
self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
elif len(lines) == 4:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
|
||||
a_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(a_txt)[0]
|
||||
self.draw.text((a_x_offset, 39 + 0), a_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
|
||||
|
||||
b_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(b_txt)[0]
|
||||
self.draw.text((b_x_offset , 39 + 60), b_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
|
||||
|
||||
c_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(c_txt)[0]
|
||||
self.draw.text((c_x_offset , 39 + 120), c_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
|
||||
|
||||
self.show_image()
|
||||
|
||||
return
|
||||
|
||||
|
||||
def display_blank_screen(self):
|
||||
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
|
||||
self.show_image()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .screen import *
|
||||
@@ -0,0 +1,714 @@
|
||||
from dataclasses import dataclass
|
||||
from lzma import is_check_supported
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
from typing import List
|
||||
|
||||
from seedsigner.gui.renderer import Renderer
|
||||
from seedsigner.models.threads import BaseThread
|
||||
|
||||
from .screen import ButtonListScreen, WarningScreen
|
||||
from ..components import (BtcAmount, Button, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerCustomIconConstants, TextArea,
|
||||
calc_bezier_curve, linear_interp)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PSBTOverviewScreen(ButtonListScreen):
|
||||
spend_amount: int = 0
|
||||
change_amount: int = 0
|
||||
fee_amount: int = 0
|
||||
num_inputs: int = 0
|
||||
num_self_transfer_outputs: int = 0
|
||||
num_change_outputs: int = 0
|
||||
destination_addresses: List[str] = None
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.title = "Review PSBT"
|
||||
self.is_bottom_list = True
|
||||
self.button_data = ["Review Details"]
|
||||
|
||||
# This screen can take a while to load while parsing the PSBT
|
||||
self.show_loading_screen = True
|
||||
|
||||
super().__post_init__()
|
||||
|
||||
# Prep the headline amount being spent in large callout
|
||||
# icon_text_lines_y = self.components[-1].screen_y + self.components[-1].height
|
||||
icon_text_lines_y = self.top_nav.height + GUIConstants.COMPONENT_PADDING
|
||||
|
||||
if not self.destination_addresses:
|
||||
# This is a self-transfer
|
||||
spend_amount = self.change_amount
|
||||
else:
|
||||
spend_amount = self.spend_amount
|
||||
|
||||
self.components.append(BtcAmount(
|
||||
total_sats=spend_amount,
|
||||
screen_y=icon_text_lines_y,
|
||||
))
|
||||
|
||||
# Prep the transaction flow chart
|
||||
self.chart_x = 0
|
||||
self.chart_y = self.components[-1].screen_y + self.components[-1].height + int(GUIConstants.COMPONENT_PADDING/2)
|
||||
chart_height = self.buttons[0].screen_y - self.chart_y - GUIConstants.COMPONENT_PADDING
|
||||
|
||||
# We need to supersample the whole panel so that small/thin elements render
|
||||
# clearly.
|
||||
ssf = 4 # super-sampling factor
|
||||
|
||||
# Set up our temp supersampled rendering surface
|
||||
image = Image.new(
|
||||
"RGB",
|
||||
(self.canvas_width * ssf, chart_height * ssf),
|
||||
GUIConstants.BACKGROUND_COLOR
|
||||
)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
font_size = GUIConstants.BODY_FONT_MIN_SIZE * ssf
|
||||
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, font_size)
|
||||
|
||||
(left, top, right, bottom) = font.getbbox(text="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890[]", anchor="lt")
|
||||
chart_text_height = bottom
|
||||
vertical_center = int(image.height/2)
|
||||
# Supersampling renders thin elements poorly if they land on an even line before scaling down
|
||||
if vertical_center % 2 == 1:
|
||||
vertical_center += 1
|
||||
|
||||
association_line_color = "#666"
|
||||
association_line_width = 3*ssf
|
||||
curve_steps = 4
|
||||
chart_font_color = "#ddd"
|
||||
|
||||
# First calculate how wide the inputs col will be
|
||||
inputs_column = []
|
||||
if self.num_inputs == 1:
|
||||
inputs_column.append("1 input")
|
||||
elif self.num_inputs > 5:
|
||||
inputs_column.append("input 1")
|
||||
inputs_column.append("input 2")
|
||||
inputs_column.append("[ ... ]")
|
||||
inputs_column.append(f"input {self.num_inputs-1}")
|
||||
inputs_column.append(f"input {self.num_inputs}")
|
||||
else:
|
||||
for i in range(0, self.num_inputs):
|
||||
inputs_column.append(f"input {i+1}")
|
||||
|
||||
max_inputs_text_width = 0
|
||||
for input in inputs_column:
|
||||
tw, th = font.getsize(input)
|
||||
max_inputs_text_width = max(tw, max_inputs_text_width)
|
||||
|
||||
# Given how wide we want our curves on each side to be...
|
||||
curve_width = 4*GUIConstants.COMPONENT_PADDING*ssf
|
||||
|
||||
# ...and the minimum center divider width...
|
||||
center_bar_width = 2*GUIConstants.COMPONENT_PADDING*ssf
|
||||
|
||||
# We can calculate how wide the destination col can be
|
||||
max_destination_col_width = image.width - (GUIConstants.EDGE_PADDING*ssf + max_inputs_text_width + \
|
||||
int(GUIConstants.COMPONENT_PADDING*ssf/4) + curve_width + \
|
||||
center_bar_width + \
|
||||
curve_width + int(GUIConstants.COMPONENT_PADDING*ssf/4) + \
|
||||
GUIConstants.EDGE_PADDING*ssf)
|
||||
|
||||
# if self.num_inputs == 1:
|
||||
# # Use up more of the space on the input side
|
||||
# max_destination_col_width += curve_width
|
||||
|
||||
# Now let's maximize the actual destination col by adjusting our addr truncation
|
||||
def calculate_destination_col_width(truncate_at: int = 0):
|
||||
def truncate_destination_addr(addr):
|
||||
# TODO: Properly handle the ellipsis truncation in different languages
|
||||
if len(addr) <= truncate_at + len("..."):
|
||||
# No point in truncating
|
||||
return addr
|
||||
return f"{addr[:truncate_at]}..."
|
||||
|
||||
destination_column = []
|
||||
|
||||
if len(self.destination_addresses) + self.num_self_transfer_outputs <= 3:
|
||||
for addr in self.destination_addresses:
|
||||
destination_column.append(truncate_destination_addr(addr))
|
||||
|
||||
for i in range(0, self.num_self_transfer_outputs):
|
||||
destination_column.append(truncate_destination_addr("self-transfer"))
|
||||
else:
|
||||
# destination_column.append(f"{len(self.destination_addresses)} recipients")
|
||||
destination_column.append(f"recipient 1")
|
||||
destination_column.append(f"[ ... ]")
|
||||
destination_column.append(f"recipient {len(self.destination_addresses) + self.num_self_transfer_outputs}")
|
||||
|
||||
destination_column.append(f"fee")
|
||||
|
||||
if self.num_change_outputs > 0:
|
||||
for i in range(0, self.num_change_outputs):
|
||||
destination_column.append("change")
|
||||
|
||||
max_destination_text_width = 0
|
||||
for destination in destination_column:
|
||||
tw, th = font.getsize(destination)
|
||||
max_destination_text_width = max(tw, max_destination_text_width)
|
||||
|
||||
return (max_destination_text_width, destination_column)
|
||||
|
||||
if len(self.destination_addresses) + self.num_self_transfer_outputs > 3:
|
||||
# We're not going to display any destination addrs so truncation doesn't matter
|
||||
(destination_text_width, destination_column) = calculate_destination_col_width()
|
||||
else:
|
||||
# Steadliy widen out the destination column until we run out of space
|
||||
for i in range(6, 14):
|
||||
(new_width, new_col_text) = calculate_destination_col_width(truncate_at=i)
|
||||
if new_width > max_destination_col_width:
|
||||
break
|
||||
destination_text_width = new_width
|
||||
destination_column = new_col_text
|
||||
|
||||
destination_col_x = image.width - (destination_text_width + GUIConstants.EDGE_PADDING*ssf)
|
||||
|
||||
# Now we can finalize our center bar values
|
||||
center_bar_x = GUIConstants.EDGE_PADDING*ssf + max_inputs_text_width + int(GUIConstants.COMPONENT_PADDING*ssf/4) + curve_width
|
||||
|
||||
# Center bar stretches to fill any excess width
|
||||
center_bar_width = destination_col_x - int(GUIConstants.COMPONENT_PADDING*ssf/4) - curve_width - center_bar_x
|
||||
|
||||
# Position each input row
|
||||
num_rendered_inputs = len(inputs_column)
|
||||
if self.num_inputs == 1:
|
||||
inputs_y = vertical_center - int(chart_text_height/2)
|
||||
inputs_y_spacing = 0 # Not used
|
||||
else:
|
||||
inputs_y = int((image.height - num_rendered_inputs*chart_text_height) / (num_rendered_inputs + 1))
|
||||
inputs_y_spacing = inputs_y + chart_text_height
|
||||
|
||||
# Don't render lines from an odd number
|
||||
if inputs_y % 2 == 1:
|
||||
inputs_y += 1
|
||||
if inputs_y_spacing % 2 == 1:
|
||||
inputs_y_spacing += 1
|
||||
|
||||
inputs_conjunction_x = center_bar_x
|
||||
inputs_x = GUIConstants.EDGE_PADDING*ssf
|
||||
|
||||
input_curves = []
|
||||
for input in inputs_column:
|
||||
# Calculate right-justified input display
|
||||
tw, th = font.getsize(input)
|
||||
cur_x = inputs_x + max_inputs_text_width - tw
|
||||
draw.text(
|
||||
(cur_x, inputs_y),
|
||||
text=input,
|
||||
font=font,
|
||||
fill=chart_font_color,
|
||||
anchor="lt",
|
||||
)
|
||||
|
||||
# Render the association line to the conjunction point
|
||||
# First calculate a bezier curve to an inflection point
|
||||
start_pt = (
|
||||
inputs_x + max_inputs_text_width + int(GUIConstants.COMPONENT_PADDING*ssf/4),
|
||||
inputs_y + int(chart_text_height/2)
|
||||
)
|
||||
conjunction_pt = (inputs_conjunction_x, vertical_center)
|
||||
mid_pt = (
|
||||
int(start_pt[0]*0.5 + conjunction_pt[0]*0.5),
|
||||
int(start_pt[1]*0.5 + conjunction_pt[1]*0.5)
|
||||
)
|
||||
|
||||
if len(inputs_column) == 1:
|
||||
# Use fewer segments for single input straight line
|
||||
bezier_points = [
|
||||
start_pt,
|
||||
linear_interp(start_pt, conjunction_pt, 0.33),
|
||||
linear_interp(start_pt, conjunction_pt, 0.66),
|
||||
conjunction_pt
|
||||
]
|
||||
else:
|
||||
bezier_points = calc_bezier_curve(
|
||||
start_pt,
|
||||
(mid_pt[0], start_pt[1]),
|
||||
mid_pt,
|
||||
curve_steps
|
||||
)
|
||||
# We don't need the "final" point as it's repeated below
|
||||
bezier_points.pop()
|
||||
|
||||
# Now render the second half after the inflection point
|
||||
bezier_points += calc_bezier_curve(
|
||||
mid_pt,
|
||||
(mid_pt[0], conjunction_pt[1]),
|
||||
conjunction_pt,
|
||||
curve_steps
|
||||
)
|
||||
|
||||
input_curves.append(bezier_points)
|
||||
|
||||
prev_pt = bezier_points[0]
|
||||
for pt in bezier_points[1:]:
|
||||
draw.line(
|
||||
(prev_pt[0], prev_pt[1], pt[0], pt[1]),
|
||||
fill=association_line_color,
|
||||
width=association_line_width + 1,
|
||||
joint="curve",
|
||||
)
|
||||
prev_pt = pt
|
||||
|
||||
inputs_y += inputs_y_spacing
|
||||
|
||||
# Render center bar
|
||||
draw.line(
|
||||
(
|
||||
center_bar_x,
|
||||
vertical_center,
|
||||
center_bar_x + center_bar_width,
|
||||
vertical_center
|
||||
),
|
||||
fill=association_line_color,
|
||||
width=association_line_width
|
||||
)
|
||||
|
||||
# Position each destination
|
||||
num_rendered_destinations = len(destination_column)
|
||||
if num_rendered_destinations == 1:
|
||||
destination_y = vertical_center - int(chart_text_height/2)
|
||||
destination_y_spacing = 0
|
||||
else:
|
||||
destination_y = int((image.height - num_rendered_destinations*chart_text_height) / (num_rendered_destinations + 1))
|
||||
destination_y_spacing = destination_y + chart_text_height
|
||||
|
||||
# Don't render lines from an odd number
|
||||
if destination_y % 2 == 1:
|
||||
destination_y += 1
|
||||
if destination_y_spacing % 2 == 1:
|
||||
destination_y_spacing += 1
|
||||
|
||||
destination_conjunction_x = center_bar_x + center_bar_width
|
||||
recipients_text_x = destination_col_x
|
||||
|
||||
output_curves = []
|
||||
for destination in destination_column:
|
||||
draw.text(
|
||||
(recipients_text_x, destination_y),
|
||||
text=destination,
|
||||
font=font,
|
||||
fill=chart_font_color,
|
||||
anchor="lt"
|
||||
)
|
||||
|
||||
# Render the association line from the conjunction point
|
||||
# First calculate a bezier curve to an inflection point
|
||||
conjunction_pt = (destination_conjunction_x, vertical_center)
|
||||
end_pt = (
|
||||
conjunction_pt[0] + curve_width,
|
||||
destination_y + int(chart_text_height/2)
|
||||
)
|
||||
mid_pt = (
|
||||
int(conjunction_pt[0]*0.5 + end_pt[0]*0.5),
|
||||
int(conjunction_pt[1]*0.5 + end_pt[1]*0.5)
|
||||
)
|
||||
|
||||
bezier_points = calc_bezier_curve(
|
||||
conjunction_pt,
|
||||
(mid_pt[0], conjunction_pt[1]),
|
||||
mid_pt,
|
||||
curve_steps
|
||||
)
|
||||
# We don't need the "final" point as it's repeated below
|
||||
bezier_points.pop()
|
||||
|
||||
# Now render the second half after the inflection point
|
||||
curve_bias = 1.0
|
||||
bezier_points += calc_bezier_curve(
|
||||
mid_pt,
|
||||
(int(mid_pt[0]*curve_bias + end_pt[0]*(1.0-curve_bias)), end_pt[1]),
|
||||
end_pt,
|
||||
curve_steps
|
||||
)
|
||||
|
||||
output_curves.append(bezier_points)
|
||||
|
||||
prev_pt = bezier_points[0]
|
||||
for pt in bezier_points[1:]:
|
||||
draw.line(
|
||||
(prev_pt[0], prev_pt[1], pt[0], pt[1]),
|
||||
fill=association_line_color,
|
||||
width=association_line_width + 1,
|
||||
joint="curve",
|
||||
)
|
||||
prev_pt = pt
|
||||
|
||||
destination_y += destination_y_spacing
|
||||
|
||||
# Resize to target and sharpen final image
|
||||
image = image.resize((self.canvas_width, chart_height), Image.LANCZOS)
|
||||
self.paste_images.append((image.filter(ImageFilter.SHARPEN), (self.chart_x, self.chart_y)))
|
||||
|
||||
# Pass input and output curves to the animation thread
|
||||
self.threads.append(
|
||||
PSBTOverviewScreen.TxExplorerAnimationThread(
|
||||
inputs=input_curves,
|
||||
outputs=output_curves,
|
||||
supersampling_factor=ssf,
|
||||
offset_y=self.chart_y,
|
||||
renderer=self.renderer
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
class TxExplorerAnimationThread(BaseThread):
|
||||
def __init__(self, inputs, outputs, supersampling_factor, offset_y, renderer: Renderer):
|
||||
super().__init__()
|
||||
|
||||
# Translate the point coords into renderer space
|
||||
ssf = supersampling_factor
|
||||
self.inputs = [[(int(i[0]/ssf), int(i[1]/ssf + offset_y)) for i in curve] for curve in inputs]
|
||||
self.outputs = [[(int(i[0]/ssf), int(i[1]/ssf + offset_y)) for i in curve] for curve in outputs]
|
||||
self.renderer = renderer
|
||||
|
||||
|
||||
def run(self):
|
||||
pulse_color = GUIConstants.ACCENT_COLOR
|
||||
reset_color = "#666"
|
||||
line_width = 3
|
||||
|
||||
pulses = []
|
||||
|
||||
# The center bar needs to be segmented to support animation across it
|
||||
start_pt = self.inputs[0][-1]
|
||||
end_pt = self.outputs[0][0]
|
||||
if start_pt == end_pt:
|
||||
# In single input the center bar width can be zeroed out.
|
||||
# Ugly hack: Insert this line segment that will be skipped otherwise.
|
||||
center_bar_pts = [end_pt, self.outputs[0][1]]
|
||||
else:
|
||||
center_bar_pts = [
|
||||
start_pt,
|
||||
linear_interp(start_pt, end_pt, 0.25),
|
||||
linear_interp(start_pt, end_pt, 0.50),
|
||||
linear_interp(start_pt, end_pt, 0.75),
|
||||
end_pt,
|
||||
]
|
||||
|
||||
def draw_line_segment(curves, i, j, color):
|
||||
# print(f"draw: {curves[0][i]} to {curves[0][j]}")
|
||||
for points in curves:
|
||||
pt1 = points[i]
|
||||
pt2 = points[j]
|
||||
self.renderer.draw.line(
|
||||
(pt1[0], pt1[1], pt2[0], pt2[1]),
|
||||
fill=color,
|
||||
width=line_width
|
||||
)
|
||||
|
||||
prev_color = reset_color
|
||||
while self.keep_running:
|
||||
with self.renderer.lock:
|
||||
# Only generate one new pulse at a time; trailing "reset_color" pulse
|
||||
# erases the most recent pulse.
|
||||
if not pulses or (
|
||||
prev_color == pulse_color and pulses[-1][0] == 10):
|
||||
# Create a new pulse
|
||||
if prev_color == pulse_color:
|
||||
pulses.append([0, reset_color])
|
||||
else:
|
||||
pulses.append([0, pulse_color])
|
||||
prev_color = pulses[-1][1]
|
||||
|
||||
for pulse_num, pulse in enumerate(pulses):
|
||||
i = pulse[0]
|
||||
color = pulse[1]
|
||||
if i < len(self.inputs[0]) - 1:
|
||||
# We're in the input curves
|
||||
draw_line_segment(self.inputs, i, i+1, color)
|
||||
elif i < len(self.inputs[0]) + len(center_bar_pts) - 2:
|
||||
# We're in the center bar
|
||||
index = i - len(self.inputs[0]) + 1
|
||||
draw_line_segment([center_bar_pts], index, index+1, color)
|
||||
elif i < len(self.inputs[0]) + len(center_bar_pts) - 2 + len(self.outputs[0]) - 1:
|
||||
index = i - (len(self.inputs[0]) + len(center_bar_pts) - 2)
|
||||
draw_line_segment(self.outputs, index, index+1, color)
|
||||
else:
|
||||
# This pulse is done
|
||||
del pulses[pulse_num]
|
||||
continue
|
||||
|
||||
pulse[0] += 1
|
||||
|
||||
self.renderer.show_image()
|
||||
|
||||
# No need to CPU limit when running in its own thread?
|
||||
# time.sleep(0.02)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PSBTMathScreen(ButtonListScreen):
|
||||
input_amount: int = 0
|
||||
num_inputs: int = 0
|
||||
spend_amount: int = 0
|
||||
num_recipients: int = 0
|
||||
fee_amount: int = 0
|
||||
change_amount: int = 0
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.title = "PSBT Math"
|
||||
self.button_data = ["Review Recipients"]
|
||||
self.is_bottom_list = True
|
||||
|
||||
super().__post_init__()
|
||||
|
||||
if self.input_amount > 1e6:
|
||||
denomination = "btc"
|
||||
self.input_amount /= 1e8
|
||||
self.spend_amount /= 1e8
|
||||
self.change_amount /= 1e8
|
||||
self.input_amount = f"{self.input_amount:,.8f}"
|
||||
self.spend_amount = f"{self.spend_amount:,.8f}"
|
||||
self.change_amount = f"{self.change_amount:,.8f}"
|
||||
|
||||
# Note: We keep the fee denominated in sats; just left pad it so it still
|
||||
# lines up properly.
|
||||
self.fee_amount = f"{self.fee_amount:10}"
|
||||
else:
|
||||
denomination = "sats"
|
||||
self.input_amount = f"{self.input_amount:,}"
|
||||
self.spend_amount = f"{self.spend_amount:,}"
|
||||
self.fee_amount = f"{self.fee_amount:,}"
|
||||
self.change_amount = f"{self.change_amount:,}"
|
||||
|
||||
longest_amount = max(len(self.input_amount), len(self.spend_amount), len(self.fee_amount), len(self.change_amount))
|
||||
if len(self.input_amount) < longest_amount:
|
||||
self.input_amount = " " * (longest_amount - len(self.input_amount)) + self.input_amount
|
||||
|
||||
if len(self.spend_amount) < longest_amount:
|
||||
self.spend_amount = " " * (longest_amount - len(self.spend_amount)) + self.spend_amount
|
||||
|
||||
if len(self.fee_amount) < longest_amount:
|
||||
self.fee_amount = " " * (longest_amount - len(self.fee_amount)) + self.fee_amount
|
||||
|
||||
if len(self.change_amount) < longest_amount:
|
||||
self.change_amount = " " * (longest_amount - len(self.change_amount)) + self.change_amount
|
||||
|
||||
# Render the info to temp Image
|
||||
# TODO: Test rendering the numeric amounts without the supersampling
|
||||
body_width = self.canvas_width - 2*GUIConstants.EDGE_PADDING
|
||||
body_height = self.buttons[0].screen_y - self.top_nav.height - 2*GUIConstants.COMPONENT_PADDING
|
||||
ssf = 2 # Super-sampling factor
|
||||
image = Image.new("RGB", (body_width*ssf, body_height*ssf))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
body_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, (GUIConstants.BODY_FONT_SIZE)*ssf)
|
||||
fixed_width_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, (GUIConstants.BODY_FONT_SIZE + 6)*ssf)
|
||||
digits_width, digits_height = fixed_width_font.getsize(self.input_amount + "+")
|
||||
|
||||
# Draw each line of the equation
|
||||
cur_y = 0
|
||||
|
||||
def render_amount(cur_y, amount_str, info_text, info_text_color=GUIConstants.BODY_FONT_COLOR):
|
||||
secondary_digit_color = "#888"
|
||||
tertiary_digit_color = "#666"
|
||||
digit_group_spacing = 2 * ssf
|
||||
# secondary_digit_color = GUIConstants.BODY_FONT_COLOR
|
||||
# tertiary_digit_color = GUIConstants.BODY_FONT_COLOR
|
||||
# digit_group_spacing = 0
|
||||
if denomination == 'btc':
|
||||
display_str = amount_str
|
||||
main_zone = display_str[:-6]
|
||||
mid_zone = display_str[-6:-3]
|
||||
end_zone = display_str[-3:]
|
||||
main_zone_width, th = fixed_width_font.getsize(main_zone)
|
||||
mid_zone_width, th = fixed_width_font.getsize(end_zone)
|
||||
draw.text((0, cur_y), text=main_zone, font=fixed_width_font, fill=GUIConstants.BODY_FONT_COLOR)
|
||||
draw.text((main_zone_width + digit_group_spacing, cur_y), text=mid_zone, font=fixed_width_font, fill=secondary_digit_color)
|
||||
draw.text((main_zone_width + digit_group_spacing + mid_zone_width + digit_group_spacing, cur_y), text=end_zone, font=fixed_width_font, fill=tertiary_digit_color)
|
||||
else:
|
||||
draw.text((0, cur_y), text=amount_str, font=fixed_width_font, fill=GUIConstants.BODY_FONT_COLOR)
|
||||
draw.text((digits_width + 2*digit_group_spacing, cur_y), text=info_text, font=body_font, fill=info_text_color)
|
||||
|
||||
render_amount(
|
||||
cur_y,
|
||||
f" {self.input_amount}",
|
||||
# info_text=f""" {self.num_inputs} input{"s" if self.num_inputs > 1 else ""}""",
|
||||
info_text=f""" input{"s" if self.num_inputs > 1 else ""}""",
|
||||
)
|
||||
|
||||
# spend_amount will be zero on self-transfers; only display when there's an
|
||||
# external recipient.
|
||||
if self.num_recipients > 0:
|
||||
cur_y += int(digits_height * 1.2)
|
||||
render_amount(
|
||||
cur_y,
|
||||
f"-{self.spend_amount}",
|
||||
# info_text=f""" {self.num_recipients} recipient{"s" if self.num_recipients > 1 else ""}""",
|
||||
info_text=f""" recipient{"s" if self.num_recipients > 1 else ""}""",
|
||||
)
|
||||
|
||||
cur_y += int(digits_height * 1.2)
|
||||
render_amount(
|
||||
cur_y,
|
||||
f"-{self.fee_amount}",
|
||||
info_text=f""" fee""",
|
||||
)
|
||||
|
||||
cur_y += int(digits_height * 1.2) + 4 * ssf
|
||||
draw.line((0, cur_y, image.width, cur_y), fill=GUIConstants.BODY_FONT_COLOR, width=1)
|
||||
cur_y += 8 * ssf
|
||||
|
||||
render_amount(
|
||||
cur_y,
|
||||
f" {self.change_amount}",
|
||||
info_text=f" {denomination} change",
|
||||
info_text_color="darkorange" # super-sampling alters the perceived color
|
||||
)
|
||||
|
||||
# Resize to target and sharpen final image
|
||||
image = image.resize((body_width, body_height), Image.LANCZOS)
|
||||
self.paste_images.append((image.filter(ImageFilter.SHARPEN), (GUIConstants.EDGE_PADDING, self.top_nav.height + GUIConstants.COMPONENT_PADDING)))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PSBTAddressDetailsScreen(ButtonListScreen):
|
||||
address: str = None
|
||||
amount: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.is_bottom_list = True
|
||||
|
||||
super().__post_init__()
|
||||
|
||||
center_img_height = self.buttons[0].screen_y - self.top_nav.height
|
||||
|
||||
# Figuring out how to vertically center the sats and the address is
|
||||
# difficult so we just render to a temp image and paste it in place.
|
||||
center_img = Image.new("RGB", (self.canvas_width, center_img_height), GUIConstants.BACKGROUND_COLOR)
|
||||
draw = ImageDraw.Draw(center_img)
|
||||
|
||||
btc_amount = BtcAmount(
|
||||
image_draw=draw,
|
||||
canvas=center_img,
|
||||
total_sats=self.amount,
|
||||
screen_y=int(GUIConstants.COMPONENT_PADDING/2),
|
||||
)
|
||||
|
||||
formatted_address = FormattedAddress(
|
||||
image_draw=draw,
|
||||
canvas=center_img,
|
||||
width=self.canvas_width - 2*GUIConstants.EDGE_PADDING,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=btc_amount.height + GUIConstants.COMPONENT_PADDING,
|
||||
font_size=24,
|
||||
address=self.address,
|
||||
)
|
||||
|
||||
# Render each to the temp img we passed in
|
||||
btc_amount.render()
|
||||
formatted_address.render()
|
||||
|
||||
self.body_img = center_img.crop((
|
||||
0,
|
||||
0,
|
||||
self.canvas_width,
|
||||
formatted_address.screen_y + formatted_address.height
|
||||
))
|
||||
body_img_y = self.top_nav.height + int((center_img_height - self.body_img.height - GUIConstants.COMPONENT_PADDING)/2)
|
||||
|
||||
self.paste_images.append((self.body_img, (0, body_img_y)))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PSBTChangeDetailsScreen(ButtonListScreen):
|
||||
title: str = "Your Change"
|
||||
amount: int = 0
|
||||
address: str = None
|
||||
is_multisig: bool = False
|
||||
fingerprint: str = None
|
||||
derivation_path: str = None
|
||||
is_change_derivation_path: bool = True
|
||||
derivation_path_addr_index: int = 0
|
||||
is_change_addr_verified: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.is_bottom_list = True
|
||||
super().__post_init__()
|
||||
|
||||
self.components.append(BtcAmount(
|
||||
total_sats=self.amount,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
self.components.append(FormattedAddress(
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
|
||||
address=self.address,
|
||||
max_lines=1,
|
||||
))
|
||||
|
||||
screen_y = self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING
|
||||
if self.is_multisig and not self.is_change_addr_verified:
|
||||
# Adjust the vertical spacing
|
||||
screen_y -= GUIConstants.COMPONENT_PADDING
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
|
||||
icon_color="blue",
|
||||
value_text=f"""{"Multisig" if self.is_multisig else self.fingerprint}: {"Change" if self.is_change_derivation_path else "Addr"} #{self.derivation_path_addr_index}""",
|
||||
is_text_centered=False,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=screen_y,
|
||||
))
|
||||
|
||||
if self.is_change_addr_verified:
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.CIRCLE_CHECK,
|
||||
icon_color="#00dd00",
|
||||
value_text="Address verified!",
|
||||
is_text_centered=False,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PSBTFinalizeScreen(ButtonListScreen):
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.title = "Sign PSBT"
|
||||
self.is_bottom_list = True
|
||||
super().__post_init__()
|
||||
|
||||
icon = Icon(
|
||||
icon_name=FontAwesomeIconConstants.PAPER_PLANE,
|
||||
icon_color=GUIConstants.SUCCESS_COLOR,
|
||||
icon_size=GUIConstants.ICON_LARGE_BUTTON_SIZE,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
|
||||
)
|
||||
icon.screen_x = int((self.canvas_width - icon.width)/2)
|
||||
self.components.append(icon)
|
||||
|
||||
self.components.append(TextArea(
|
||||
text="Click to authorize this transaction",
|
||||
screen_y=icon.screen_y + icon.height + GUIConstants.COMPONENT_PADDING
|
||||
))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class PSBTSelectCoordinatorScreen(ButtonListScreen):
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.title = "Signed PSBT"
|
||||
self.is_bottom_list = True
|
||||
super().__post_init__()
|
||||
|
||||
self.components.append(TextArea(
|
||||
text="Export as a QR code for:",
|
||||
is_text_centered=True,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
@@ -0,0 +1,160 @@
|
||||
import time
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple
|
||||
|
||||
from seedsigner.gui import renderer
|
||||
from seedsigner.hardware.buttons import HardwareButtonsConstants
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.models import DecodeQR, DecodeQRStatus
|
||||
from seedsigner.models.threads import BaseThread
|
||||
|
||||
from .screen import BaseScreen, BaseTopNavScreen, ButtonListScreen
|
||||
from ..components import BaseComponent, Button, GUIConstants, Fonts, IconButton, TextArea, calc_text_centering
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanScreen(BaseScreen):
|
||||
decoder: DecodeQR = None
|
||||
instructions_text: str = "Scan a QR code"
|
||||
resolution: Tuple[int,int] = (480, 480)
|
||||
framerate: int = 12
|
||||
render_rect: Tuple[int,int,int,int] = None
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
from seedsigner.hardware.camera import Camera
|
||||
# Initialize the base class
|
||||
super().__post_init__()
|
||||
|
||||
self.camera = Camera.get_instance()
|
||||
self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb")
|
||||
|
||||
self.threads.append(ScanScreen.LivePreviewThread(
|
||||
camera=self.camera,
|
||||
decoder=self.decoder,
|
||||
renderer=self.renderer,
|
||||
instructions_text=self.instructions_text,
|
||||
render_rect=self.render_rect,
|
||||
))
|
||||
|
||||
|
||||
class LivePreviewThread(BaseThread):
|
||||
def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: Tuple[int,int,int,int]):
|
||||
self.camera = camera
|
||||
self.decoder = decoder
|
||||
self.renderer = renderer
|
||||
self.instructions_text = instructions_text
|
||||
if render_rect:
|
||||
self.render_rect = render_rect
|
||||
else:
|
||||
self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height)
|
||||
self.render_width = self.render_rect[2] - self.render_rect[0]
|
||||
self.render_height = self.render_rect[3] - self.render_rect[1]
|
||||
|
||||
print(f"render_width: {self.render_width}")
|
||||
print(f"render_height: {self.render_height}")
|
||||
|
||||
super().__init__()
|
||||
|
||||
|
||||
def run(self):
|
||||
from timeit import default_timer as timer
|
||||
|
||||
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
|
||||
while self.keep_running:
|
||||
start = timer()
|
||||
frame = self.camera.read_video_stream(as_image=True)
|
||||
if frame is not None:
|
||||
scan_text = self.instructions_text
|
||||
if self.decoder and self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt:
|
||||
scan_text = str(self.decoder.get_percent_complete()) + "% Complete"
|
||||
|
||||
with self.renderer.lock:
|
||||
if frame.width > self.render_width or frame.height > self.render_height:
|
||||
frame = frame.resize(
|
||||
(self.render_width, self.render_height)
|
||||
)
|
||||
self.renderer.canvas.paste(
|
||||
frame,
|
||||
(self.render_rect[0], self.render_rect[1])
|
||||
)
|
||||
|
||||
if scan_text:
|
||||
self.renderer.draw.text(
|
||||
xy=(
|
||||
int(self.renderer.canvas_width/2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
|
||||
),
|
||||
text=scan_text,
|
||||
fill=GUIConstants.BODY_FONT_COLOR,
|
||||
font=instructions_font,
|
||||
stroke_width=4,
|
||||
stroke_fill=GUIConstants.BACKGROUND_COLOR,
|
||||
anchor="ms"
|
||||
)
|
||||
|
||||
self.renderer.show_image()
|
||||
|
||||
end = timer()
|
||||
# print(f"{1.0/(end - start)} fps") # Time in seconds, e.g. 5.38091952400282
|
||||
|
||||
time.sleep(0.05) # turn this up or down to tune performance while decoding psbt
|
||||
if self.camera._video_stream is None:
|
||||
break
|
||||
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
_render() is mostly meant to be a one-time initial drawing call to set up the
|
||||
Screen. Once interaction starts, the display updates have to be managed in
|
||||
_run(). The live preview is an extra-complex case.
|
||||
"""
|
||||
while True:
|
||||
frame = self.camera.read_video_stream()
|
||||
if frame is not None:
|
||||
status = self.decoder.add_image(frame)
|
||||
|
||||
if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID):
|
||||
self.camera.stop_video_stream_mode()
|
||||
break
|
||||
|
||||
# TODO: KEY_UP gives control to NavBar; use its back arrow to cancel
|
||||
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT) or self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
|
||||
self.camera.stop_video_stream_mode()
|
||||
break
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsUpdatedScreen(ButtonListScreen):
|
||||
config_name: str = None
|
||||
title: str = "Settings QR"
|
||||
is_bottom_list: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.button_data = ["Home"]
|
||||
|
||||
super().__post_init__()
|
||||
|
||||
start_y = self.top_nav.height + 20
|
||||
if self.config_name:
|
||||
self.config_name_textarea = TextArea(
|
||||
text=f'"{self.config_name}"',
|
||||
is_text_centered=True,
|
||||
auto_line_break=True,
|
||||
screen_y=start_y
|
||||
)
|
||||
self.components.append(self.config_name_textarea)
|
||||
start_y = self.config_name_textarea.screen_y + 50
|
||||
|
||||
self.components.append(TextArea(
|
||||
text="Settings imported successfully!",
|
||||
is_text_centered=True,
|
||||
auto_line_break=True,
|
||||
screen_y=start_y
|
||||
))
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import time
|
||||
|
||||
from dataclasses import dataclass
|
||||
from PIL.ImageOps import autocontrast
|
||||
from typing import List
|
||||
from seedsigner.gui.components import Button, CheckboxButton, CheckedSelectionButton, FontAwesomeIconConstants, Fonts, GUIConstants, Icon, IconButton, IconTextLine, TextArea
|
||||
from seedsigner.gui.screens.scan_screens import ScanScreen
|
||||
|
||||
from seedsigner.gui.screens.screen import BaseScreen, BaseTopNavScreen, ButtonListScreen
|
||||
from seedsigner.hardware.buttons import HardwareButtonsConstants
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
|
||||
display_name: str = None
|
||||
help_text: str = None
|
||||
checked_buttons: List[int] = None
|
||||
settings_entry_type: str = SettingsConstants.TYPE__ENABLED_DISABLED
|
||||
selected_button: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self.title = "Settings"
|
||||
self.is_bottom_list = True
|
||||
self.use_checked_selection_buttons = True
|
||||
if self.settings_entry_type == SettingsConstants.TYPE__MULTISELECT:
|
||||
self.Button_cls = CheckboxButton
|
||||
else:
|
||||
self.Button_cls = CheckedSelectionButton
|
||||
super().__post_init__()
|
||||
|
||||
self.components.append(TextArea(
|
||||
text=self.display_name,
|
||||
font_size=GUIConstants.BODY_FONT_MAX_SIZE,
|
||||
is_text_centered=True,
|
||||
auto_line_break=False,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
|
||||
))
|
||||
|
||||
if self.help_text:
|
||||
prev_component_bottom = self.components[-1].screen_y + self.components[-1].height
|
||||
self.components.append(TextArea(
|
||||
text=self.help_text,
|
||||
font_color=GUIConstants.LABEL_FONT_COLOR,
|
||||
is_text_centered=True,
|
||||
screen_y=prev_component_bottom + GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class IOTestScreen(BaseTopNavScreen):
|
||||
def __post_init__(self):
|
||||
self.title = "I/O Test"
|
||||
self.show_back_button = False
|
||||
self.resolution = (96, 96)
|
||||
self.framerate = 10
|
||||
self.instructions_text = None
|
||||
super().__post_init__()
|
||||
|
||||
# D-pad pictogram
|
||||
input_button_width = GUIConstants.BUTTON_HEIGHT + 2
|
||||
input_button_height = input_button_width + 2
|
||||
dpad_center_x = GUIConstants.EDGE_PADDING + input_button_width + GUIConstants.COMPONENT_PADDING
|
||||
dpad_center_y = int((self.canvas_height - input_button_height)/2)
|
||||
|
||||
self.joystick_click_button = IconButton(
|
||||
icon_name=FontAwesomeIconConstants.CIRCLE,
|
||||
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE - 6,
|
||||
width=input_button_width,
|
||||
height=input_button_height,
|
||||
screen_x=dpad_center_x,
|
||||
screen_y=dpad_center_y,
|
||||
outline_color=GUIConstants.ACCENT_COLOR,
|
||||
)
|
||||
self.components.append(self.joystick_click_button)
|
||||
|
||||
self.joystick_up_button = IconButton(
|
||||
icon_name=FontAwesomeIconConstants.ANGLE_UP,
|
||||
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
|
||||
width=input_button_width,
|
||||
height=input_button_height,
|
||||
screen_x=dpad_center_x,
|
||||
screen_y=dpad_center_y - input_button_height - GUIConstants.COMPONENT_PADDING,
|
||||
outline_color=GUIConstants.ACCENT_COLOR,
|
||||
)
|
||||
self.components.append(self.joystick_up_button)
|
||||
|
||||
self.joystick_down_button = IconButton(
|
||||
icon_name=FontAwesomeIconConstants.ANGLE_DOWN,
|
||||
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
|
||||
width=input_button_width,
|
||||
height=input_button_height,
|
||||
screen_x=dpad_center_x,
|
||||
screen_y=dpad_center_y + input_button_height + GUIConstants.COMPONENT_PADDING,
|
||||
outline_color=GUIConstants.ACCENT_COLOR,
|
||||
)
|
||||
self.components.append(self.joystick_down_button)
|
||||
|
||||
self.joystick_left_button = IconButton(
|
||||
text=FontAwesomeIconConstants.ANGLE_LEFT,
|
||||
font_name=GUIConstants.ICON_FONT_NAME__FONT_AWESOME,
|
||||
font_size=GUIConstants.ICON_INLINE_FONT_SIZE,
|
||||
width=input_button_width,
|
||||
height=input_button_height,
|
||||
screen_x=dpad_center_x - input_button_width - GUIConstants.COMPONENT_PADDING,
|
||||
screen_y=dpad_center_y,
|
||||
outline_color=GUIConstants.ACCENT_COLOR,
|
||||
)
|
||||
self.components.append(self.joystick_left_button)
|
||||
|
||||
self.joystick_right_button = IconButton(
|
||||
icon_name=FontAwesomeIconConstants.ANGLE_RIGHT,
|
||||
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
|
||||
width=input_button_width,
|
||||
height=input_button_height,
|
||||
screen_x=dpad_center_x + input_button_width + GUIConstants.COMPONENT_PADDING,
|
||||
screen_y=dpad_center_y,
|
||||
outline_color=GUIConstants.ACCENT_COLOR,
|
||||
)
|
||||
self.components.append(self.joystick_right_button)
|
||||
|
||||
# Hardware keys UI
|
||||
font = Fonts.get_font(GUIConstants.BUTTON_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
|
||||
(left, top, text_width, bottom) = font.getbbox(text="Clear", anchor="ls")
|
||||
icon = Icon(
|
||||
icon_name=FontAwesomeIconConstants.CAMERA,
|
||||
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
|
||||
)
|
||||
key_button_width = text_width + 2*GUIConstants.COMPONENT_PADDING + GUIConstants.EDGE_PADDING
|
||||
key_button_height = icon.height + int(1.5*GUIConstants.COMPONENT_PADDING)
|
||||
key2_y = int(self.canvas_height/2) - int(key_button_height/2)
|
||||
|
||||
self.key2_button = Button(
|
||||
text="Clear", # Initialize with text to set vertical centering
|
||||
width=key_button_width,
|
||||
height=key_button_height,
|
||||
screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING,
|
||||
screen_y=key2_y,
|
||||
outline_color=GUIConstants.ACCENT_COLOR,
|
||||
)
|
||||
self.key2_button.text = " " # but default state is empty
|
||||
self.components.append(self.key2_button)
|
||||
|
||||
self.key1_button = IconButton(
|
||||
icon_name=FontAwesomeIconConstants.CAMERA,
|
||||
width=key_button_width,
|
||||
height=key_button_height,
|
||||
screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING,
|
||||
screen_y=key2_y - 3*GUIConstants.COMPONENT_PADDING - key_button_height,
|
||||
outline_color=GUIConstants.ACCENT_COLOR,
|
||||
)
|
||||
self.components.append(self.key1_button)
|
||||
|
||||
self.key3_button = Button(
|
||||
text="Exit",
|
||||
width=key_button_width,
|
||||
height=key_button_height,
|
||||
screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING,
|
||||
screen_y=key2_y + 3*GUIConstants.COMPONENT_PADDING + key_button_height,
|
||||
outline_color=GUIConstants.ACCENT_COLOR,
|
||||
)
|
||||
self.components.append(self.key3_button)
|
||||
|
||||
|
||||
def _run(self):
|
||||
cur_selected_button = self.key1_button
|
||||
msg_height = GUIConstants.ICON_LARGE_BUTTON_SIZE + 2*GUIConstants.COMPONENT_PADDING
|
||||
camera_message = TextArea(
|
||||
text="Capturing image...",
|
||||
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
|
||||
is_text_centered=True,
|
||||
height=msg_height,
|
||||
screen_y=int((self.canvas_height - msg_height)/ 2),
|
||||
)
|
||||
while True:
|
||||
input = self.hw_inputs.wait_for(keys=HardwareButtonsConstants.ALL_KEYS, check_release=False)
|
||||
|
||||
if input == HardwareButtonsConstants.KEY1:
|
||||
cur_selected_button = self.key1_button
|
||||
|
||||
with self.renderer.lock:
|
||||
cur_selected_button.is_selected = True
|
||||
cur_selected_button.render()
|
||||
camera_message.render()
|
||||
# Render edges around message box
|
||||
self.image_draw.rectangle(
|
||||
(
|
||||
-1, int((self.canvas_height - msg_height)/ 2) - 1,
|
||||
self.canvas_width + 1, int((self.canvas_height + msg_height)/ 2) + 1
|
||||
),
|
||||
outline=GUIConstants.ACCENT_COLOR,
|
||||
width=1,
|
||||
)
|
||||
self.renderer.show_image()
|
||||
|
||||
# Snap a pic, render it as the background, re-render all onscreen elements
|
||||
camera = Camera.get_instance()
|
||||
try:
|
||||
camera.start_single_frame_mode(resolution=(self.canvas_width, self.canvas_height))
|
||||
|
||||
# Reset the button state
|
||||
with self.renderer.lock:
|
||||
cur_selected_button.is_selected = False
|
||||
cur_selected_button.render()
|
||||
self.renderer.show_image()
|
||||
|
||||
time.sleep(0.25)
|
||||
background_frame = camera.capture_frame()
|
||||
display_version = autocontrast(
|
||||
background_frame,
|
||||
cutoff=2
|
||||
)
|
||||
with self.renderer.lock:
|
||||
self.canvas.paste(display_version, (0, self.top_nav.height))
|
||||
self.key2_button.text = "Clear"
|
||||
for component in self.components:
|
||||
component.render()
|
||||
self.renderer.show_image()
|
||||
finally:
|
||||
camera.stop_single_frame_mode()
|
||||
|
||||
continue
|
||||
|
||||
elif input == HardwareButtonsConstants.KEY2:
|
||||
cur_selected_button = self.key2_button
|
||||
|
||||
# Clear the background
|
||||
with self.renderer.lock:
|
||||
cur_selected_button.is_selected = True
|
||||
self._render()
|
||||
self.renderer.show_image()
|
||||
|
||||
# And then re-render Key2 in its initial state
|
||||
self.key2_button.text = " "
|
||||
cur_selected_button.is_selected = False
|
||||
cur_selected_button.render()
|
||||
self.renderer.show_image()
|
||||
|
||||
continue
|
||||
|
||||
elif input == HardwareButtonsConstants.KEY3:
|
||||
# Exit
|
||||
cur_selected_button = self.key3_button
|
||||
cur_selected_button.is_selected = True
|
||||
with self.renderer.lock:
|
||||
cur_selected_button.render()
|
||||
self.renderer.show_image()
|
||||
return
|
||||
|
||||
elif input == HardwareButtonsConstants.KEY_PRESS:
|
||||
cur_selected_button = self.joystick_click_button
|
||||
|
||||
elif input == HardwareButtonsConstants.KEY_UP:
|
||||
cur_selected_button = self.joystick_up_button
|
||||
|
||||
elif input == HardwareButtonsConstants.KEY_DOWN:
|
||||
cur_selected_button = self.joystick_down_button
|
||||
|
||||
elif input == HardwareButtonsConstants.KEY_LEFT:
|
||||
cur_selected_button = self.joystick_left_button
|
||||
|
||||
elif input == HardwareButtonsConstants.KEY_RIGHT:
|
||||
cur_selected_button = self.joystick_right_button
|
||||
|
||||
with self.renderer.lock:
|
||||
cur_selected_button.is_selected = True
|
||||
cur_selected_button.render()
|
||||
self.renderer.show_image()
|
||||
|
||||
with self.renderer.lock:
|
||||
cur_selected_button.is_selected = False
|
||||
cur_selected_button.render()
|
||||
self.renderer.show_image()
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class DonateScreen(BaseTopNavScreen):
|
||||
def __post_init__(self):
|
||||
self.title = "Donate"
|
||||
super().__post_init__()
|
||||
|
||||
self.components.append(TextArea(
|
||||
text="SeedSigner is 100% free & open source, funded solely by the Bitcoin community.\n\nDonate onchain or LN at:",
|
||||
screen_y=self.top_nav.height + 3*GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
self.components.append(TextArea(
|
||||
text="seedsigner.com",
|
||||
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8,
|
||||
font_color=GUIConstants.ACCENT_COLOR,
|
||||
supersampling_factor=1,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
))
|
||||
@@ -0,0 +1,368 @@
|
||||
from dataclasses import dataclass
|
||||
from PIL.Image import Image
|
||||
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerCustomIconConstants, TextArea
|
||||
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen
|
||||
from seedsigner.hardware.buttons import HardwareButtonsConstants
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsImageEntropyLivePreviewScreen(BaseScreen):
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.title = "Initializing Camera..."
|
||||
|
||||
# Initialize the base class
|
||||
super().__post_init__()
|
||||
|
||||
self.camera = Camera.get_instance()
|
||||
self.camera.start_video_stream_mode(resolution=(240, 240), framerate=24, format="rgb")
|
||||
|
||||
|
||||
def _run(self):
|
||||
# save preview image frames to use as additional entropy below
|
||||
preview_images = []
|
||||
max_entropy_frames = 50
|
||||
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
|
||||
|
||||
while True:
|
||||
frame = self.camera.read_video_stream(as_image=True)
|
||||
if frame is not None:
|
||||
self.renderer.canvas.paste(frame)
|
||||
|
||||
self.renderer.draw.text(
|
||||
xy=(
|
||||
int(self.renderer.canvas_width/2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
|
||||
),
|
||||
text="< back | click joystick",
|
||||
fill=GUIConstants.BODY_FONT_COLOR,
|
||||
font=instructions_font,
|
||||
stroke_width=4,
|
||||
stroke_fill=GUIConstants.BACKGROUND_COLOR,
|
||||
anchor="ms"
|
||||
)
|
||||
self.renderer.show_image()
|
||||
|
||||
if len(preview_images) < max_entropy_frames:
|
||||
preview_images.append(frame)
|
||||
|
||||
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
|
||||
# Have to manually update last input time since we're not in a wait_for loop
|
||||
self.hw_inputs.update_last_input_time()
|
||||
self.words = []
|
||||
self.camera.stop_video_stream_mode()
|
||||
return RET_CODE__BACK_BUTTON
|
||||
|
||||
elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_PRESS):
|
||||
# Have to manually update last input time since we're not in a wait_for loop
|
||||
self.hw_inputs.update_last_input_time()
|
||||
self.camera.stop_video_stream_mode()
|
||||
|
||||
self.renderer.canvas.paste(frame)
|
||||
|
||||
self.renderer.draw.text(
|
||||
xy=(
|
||||
int(self.renderer.canvas_width/2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
|
||||
),
|
||||
text="Capturing image...",
|
||||
fill=GUIConstants.ACCENT_COLOR,
|
||||
font=instructions_font,
|
||||
stroke_width=4,
|
||||
stroke_fill=GUIConstants.BACKGROUND_COLOR,
|
||||
anchor="ms"
|
||||
)
|
||||
self.renderer.show_image()
|
||||
|
||||
return preview_images
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsImageEntropyFinalImageScreen(BaseScreen):
|
||||
final_image: Image = None
|
||||
|
||||
def _run(self):
|
||||
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
|
||||
|
||||
self.renderer.canvas.paste(self.final_image)
|
||||
self.renderer.draw.text(
|
||||
xy=(
|
||||
int(self.renderer.canvas_width/2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
|
||||
),
|
||||
text=" < reshoot | accept > ",
|
||||
fill=GUIConstants.BODY_FONT_COLOR,
|
||||
font=instructions_font,
|
||||
stroke_width=4,
|
||||
stroke_fill=GUIConstants.BACKGROUND_COLOR,
|
||||
anchor="ms"
|
||||
)
|
||||
self.renderer.show_image()
|
||||
|
||||
input = self.hw_inputs.wait_for([HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT])
|
||||
if input == HardwareButtonsConstants.KEY_LEFT:
|
||||
return RET_CODE__BACK_BUTTON
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsDiceEntropyEntryScreen(KeyboardScreen):
|
||||
def __post_init__(self):
|
||||
# Override values set by the parent class
|
||||
self.title = f"Dice Roll 1/{self.return_after_n_chars}"
|
||||
|
||||
# Specify the keys in the keyboard
|
||||
self.rows = 3
|
||||
self.cols = 3
|
||||
self.keyboard_font_name = GUIConstants.ICON_FONT_NAME__FONT_AWESOME
|
||||
self.keyboard_font_size = None # Force auto-scaling to Key height
|
||||
self.keys_charset = "".join([
|
||||
FontAwesomeIconConstants.DICE_ONE,
|
||||
FontAwesomeIconConstants.DICE_TWO,
|
||||
FontAwesomeIconConstants.DICE_THREE,
|
||||
FontAwesomeIconConstants.DICE_FOUR,
|
||||
FontAwesomeIconConstants.DICE_FIVE,
|
||||
FontAwesomeIconConstants.DICE_SIX,
|
||||
])
|
||||
|
||||
# Map Key display chars to actual output values
|
||||
self.keys_to_values = {
|
||||
FontAwesomeIconConstants.DICE_ONE: "1",
|
||||
FontAwesomeIconConstants.DICE_TWO: "2",
|
||||
FontAwesomeIconConstants.DICE_THREE: "3",
|
||||
FontAwesomeIconConstants.DICE_FOUR: "4",
|
||||
FontAwesomeIconConstants.DICE_FIVE: "5",
|
||||
FontAwesomeIconConstants.DICE_SIX: "6",
|
||||
}
|
||||
|
||||
# Now initialize the parent class
|
||||
super().__post_init__()
|
||||
|
||||
|
||||
def update_title(self) -> bool:
|
||||
self.title = f"Dice Roll {self.cursor_position + 1}/{self.return_after_n_chars}"
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
|
||||
mnemonic_length: int = None
|
||||
num_entropy_bits: int = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.title = "Build Final Word"
|
||||
self.is_bottom_list = True
|
||||
self.is_button_text_centered = True
|
||||
super().__post_init__()
|
||||
|
||||
self.components.append(TextArea(
|
||||
text=f"The {self.mnemonic_length}th word is built from {self.num_entropy_bits} more entropy bits plus auto-calculated checksum.",
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsCoinFlipEntryScreen(KeyboardScreen):
|
||||
def __post_init__(self):
|
||||
# Override values set by the parent class
|
||||
self.title = f"Coin Flip 1/{self.return_after_n_chars}"
|
||||
|
||||
# Specify the keys in the keyboard
|
||||
self.rows = 1
|
||||
self.cols = 4
|
||||
self.key_height = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 2 + 2*GUIConstants.EDGE_PADDING
|
||||
self.keys_charset = "10"
|
||||
|
||||
# Now initialize the parent class
|
||||
super().__post_init__()
|
||||
|
||||
self.components.append(TextArea(
|
||||
text="Heads = 1",
|
||||
screen_y = self.keyboard.rect[3] + 4*GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
self.components.append(TextArea(
|
||||
text="Tails = 0",
|
||||
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
|
||||
def update_title(self) -> bool:
|
||||
self.title = f"Coin Flip {self.cursor_position + 1}/{self.return_after_n_chars}"
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsCalcFinalWordScreen(ButtonListScreen):
|
||||
selected_final_word: str = None
|
||||
selected_final_bits: str = None
|
||||
checksum_bits: str = None
|
||||
actual_final_word: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.is_bottom_list = True
|
||||
super().__post_init__()
|
||||
|
||||
# First what's the total bit display width and where do the checksum bits start?
|
||||
bit_font_size = GUIConstants.BUTTON_FONT_SIZE + 2
|
||||
font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, bit_font_size)
|
||||
(left, top, bit_display_width, bit_font_height) = font.getbbox("0" * 11, anchor="lt")
|
||||
(left, top, checksum_x, bottom) = font.getbbox("0" * (11 - len(self.checksum_bits)), anchor="lt")
|
||||
bit_display_x = int((self.canvas_width - bit_display_width)/2)
|
||||
checksum_x += bit_display_x
|
||||
|
||||
# Display the user's additional entropy input
|
||||
if self.selected_final_word:
|
||||
selection_text = self.selected_final_word
|
||||
keeper_selected_bits = self.selected_final_bits[:11 - len(self.checksum_bits)]
|
||||
|
||||
# The word's least significant bits will be rendered differently to convey
|
||||
# the fact that they're being discarded.
|
||||
discard_selected_bits = self.selected_final_bits[-1*len(self.checksum_bits):]
|
||||
else:
|
||||
# User entered coin flips or all zeros
|
||||
selection_text = self.selected_final_bits
|
||||
keeper_selected_bits = self.selected_final_bits
|
||||
|
||||
# We'll append spacer chars to preserve the vertical alignment (most
|
||||
# significant n bits always rendered in same column)
|
||||
discard_selected_bits = "_" * (len(self.checksum_bits))
|
||||
|
||||
self.components.append(TextArea(
|
||||
text=f"""Your input: \"{selection_text}\"""",
|
||||
screen_y=self.top_nav.height,
|
||||
))
|
||||
|
||||
# ...and that entropy's associated 11 bits
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
self.components.append(TextArea(
|
||||
text=keeper_selected_bits,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_size=bit_font_size,
|
||||
edge_padding=0,
|
||||
screen_x=bit_display_x,
|
||||
screen_y=screen_y,
|
||||
height=bit_font_height,
|
||||
is_text_centered=False,
|
||||
))
|
||||
|
||||
# Render the least significant bits that will be replaced by the checksum in a
|
||||
# de-emphasized font color.
|
||||
self.components.append(TextArea(
|
||||
text=discard_selected_bits,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_color=GUIConstants.LABEL_FONT_COLOR,
|
||||
font_size=bit_font_size,
|
||||
edge_padding=0,
|
||||
screen_x=checksum_x,
|
||||
screen_y=screen_y,
|
||||
height=bit_font_height,
|
||||
is_text_centered=False,
|
||||
))
|
||||
|
||||
# Show the checksum..
|
||||
self.components.append(TextArea(
|
||||
text="Checksum",
|
||||
edge_padding=0,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
# ...and its actual bits. Prepend spacers to keep vertical alignment
|
||||
checksum_spacer = "_" * (11 - len(self.checksum_bits))
|
||||
|
||||
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
|
||||
# This time we de-emphasize the prepended spacers that are irrelevant
|
||||
self.components.append(TextArea(
|
||||
text=checksum_spacer,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_color=GUIConstants.LABEL_FONT_COLOR,
|
||||
font_size=bit_font_size,
|
||||
edge_padding=0,
|
||||
screen_x=bit_display_x,
|
||||
screen_y=screen_y,
|
||||
height=bit_font_height,
|
||||
is_text_centered=False,
|
||||
))
|
||||
|
||||
# And especially highlight (orange!) the actual checksum bits
|
||||
self.components.append(TextArea(
|
||||
text=self.checksum_bits,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_size=bit_font_size,
|
||||
font_color=GUIConstants.ACCENT_COLOR,
|
||||
edge_padding=0,
|
||||
screen_x=checksum_x,
|
||||
screen_y=screen_y,
|
||||
is_text_centered=False,
|
||||
))
|
||||
|
||||
# And now the *actual* final word after merging the bit data
|
||||
self.components.append(TextArea(
|
||||
text=f"""Final Word: \"{self.actual_final_word}\"""",
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
# Once again show the bits that came from the user's entropy...
|
||||
num_checksum_bits = len(self.checksum_bits)
|
||||
user_component = self.selected_final_bits[:11 - num_checksum_bits]
|
||||
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
self.components.append(TextArea(
|
||||
text=user_component,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_size=bit_font_size,
|
||||
edge_padding=0,
|
||||
screen_x=bit_display_x,
|
||||
screen_y=screen_y,
|
||||
is_text_centered=False,
|
||||
))
|
||||
|
||||
# ...and append the checksum's bits, still highlighted in orange
|
||||
self.components.append(TextArea(
|
||||
text=self.checksum_bits,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_color=GUIConstants.ACCENT_COLOR,
|
||||
font_size=bit_font_size,
|
||||
edge_padding=0,
|
||||
screen_x=checksum_x,
|
||||
screen_y=screen_y,
|
||||
is_text_centered=False,
|
||||
))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolsCalcFinalWordDoneScreen(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,7 @@ class B:
|
||||
KEY3,
|
||||
]
|
||||
|
||||
KEYS__LEFT_RIGHT_UP_DOWN = [KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN]
|
||||
KEYS__ANYCLICK = [KEY_PRESS, KEY1, KEY2, KEY3]
|
||||
|
||||
release_lock = True # released when True, locked when False
|
||||
@@ -3,7 +3,9 @@ import numpy
|
||||
|
||||
from picamera import PiCamera
|
||||
from PIL import Image
|
||||
from seedsigner.helpers import PiVideoStream, Singleton
|
||||
from seedsigner.models import Singleton
|
||||
from seedsigner.hardware.pivideostream import PiVideoStream
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +20,7 @@ class Camera(Singleton):
|
||||
from seedsigner.models import Settings
|
||||
if cls._instance is None:
|
||||
cls._instance = cls.__new__(cls)
|
||||
cls._instance._camera_rotation = Settings.get_instance().camera_rotation
|
||||
cls._instance._camera_rotation = int(Settings.get_instance().get_value(SettingsConstants.SETTING__CAMERA_ROTATION))
|
||||
return cls._instance
|
||||
|
||||
|
||||
@@ -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,181 +0,0 @@
|
||||
### bc-ur encoding stuff
|
||||
from io import BytesIO
|
||||
import hashlib
|
||||
|
||||
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
|
||||
def bech32_polymod(values):
|
||||
"""Internal function that computes the Bech32 checksum."""
|
||||
generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = (chk & 0x1FFFFFF) << 5 ^ value
|
||||
for i in range(5):
|
||||
chk ^= generator[i] if ((top >> i) & 1) else 0
|
||||
return chk
|
||||
|
||||
|
||||
def bech32_hrp_expand(hrp):
|
||||
"""Expand the HRP into values for checksum computation."""
|
||||
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
|
||||
|
||||
|
||||
def bech32_verify_checksum(hrp, data):
|
||||
"""Verify a checksum given HRP and converted data characters."""
|
||||
return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1
|
||||
|
||||
|
||||
def bech32_create_checksum(hrp, data):
|
||||
"""Compute the checksum values given HRP and data."""
|
||||
values = bech32_hrp_expand(hrp) + data
|
||||
polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def bech32_encode(hrp, data):
|
||||
"""Compute a Bech32 string given HRP and data values."""
|
||||
combined = data + bech32_create_checksum(hrp, data)
|
||||
return hrp + "1" + "".join([CHARSET[d] for d in combined])
|
||||
|
||||
|
||||
def bech32_decode(bech):
|
||||
"""Validate a Bech32 string, and determine HRP and data."""
|
||||
if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or (
|
||||
bech.lower() != bech and bech.upper() != bech
|
||||
):
|
||||
return (None, None)
|
||||
bech = bech.lower()
|
||||
pos = bech.rfind("1")
|
||||
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
|
||||
return (None, None)
|
||||
if not all(x in CHARSET for x in bech[pos + 1 :]):
|
||||
return (None, None)
|
||||
hrp = bech[:pos]
|
||||
data = [CHARSET.find(x) for x in bech[pos + 1 :]]
|
||||
if not bech32_verify_checksum(hrp, data):
|
||||
return (None, None)
|
||||
return (hrp, data[:-6])
|
||||
|
||||
|
||||
def convertbits(data, frombits, tobits, pad=True):
|
||||
"""General power-of-2 base conversion."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret = []
|
||||
maxv = (1 << tobits) - 1
|
||||
max_acc = (1 << (frombits + tobits - 1)) - 1
|
||||
for value in data:
|
||||
if value < 0 or (value >> frombits):
|
||||
return None
|
||||
acc = ((acc << frombits) | value) & max_acc
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if pad:
|
||||
if bits:
|
||||
ret.append((acc << (tobits - bits)) & maxv)
|
||||
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
|
||||
return None
|
||||
return ret
|
||||
|
||||
|
||||
def decode(hrp, addr):
|
||||
"""Decode a segwit address."""
|
||||
hrpgot, data = bech32_decode(addr)
|
||||
if hrpgot != hrp:
|
||||
return (None, None)
|
||||
decoded = convertbits(data[1:], 5, 8, False)
|
||||
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
|
||||
return (None, None)
|
||||
if data[0] > 16:
|
||||
return (None, None)
|
||||
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
|
||||
return (None, None)
|
||||
return (data[0], decoded)
|
||||
|
||||
|
||||
def encode(hrp, witver, witprog):
|
||||
"""Encode a segwit address."""
|
||||
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5))
|
||||
if decode(hrp, ret) == (None, None):
|
||||
return None
|
||||
return ret
|
||||
|
||||
|
||||
def bc32encode(data: bytes) -> str:
|
||||
"""
|
||||
bc32 encoding
|
||||
see https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-004-bc32.md
|
||||
"""
|
||||
dd = convertbits(data, 8, 5)
|
||||
polymod = bech32_polymod([0] + dd + [0, 0, 0, 0, 0, 0]) ^ 0x3FFFFFFF
|
||||
chk = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
|
||||
return "".join([CHARSET[d] for d in dd + chk])
|
||||
|
||||
|
||||
def bc32decode(bc32: str) -> bytes:
|
||||
"""
|
||||
bc32 decoding
|
||||
see https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-004-bc32.md
|
||||
"""
|
||||
if bc32.lower() != bc32 and bc32.upper() != bc32:
|
||||
return None
|
||||
bc32 = bc32.lower()
|
||||
if not all([x in CHARSET for x in bc32]):
|
||||
return None
|
||||
res = [CHARSET.find(c) for c in bc32.lower()]
|
||||
if bech32_polymod([0] + res) != 0x3FFFFFFF:
|
||||
return None
|
||||
return bytes(convertbits(res[:-6], 5, 8, False))
|
||||
|
||||
|
||||
def cbor_encode(data):
|
||||
l = len(data)
|
||||
if l <= 23:
|
||||
prefix = bytes([0x40 + l])
|
||||
elif l <= 255:
|
||||
prefix = bytes([0x58, l])
|
||||
elif l <= 65535:
|
||||
prefix = b"\x59" + l.to_bytes(2, "big")
|
||||
else:
|
||||
prefix = b"\x60" + l.to_bytes(4, "big")
|
||||
return prefix + data
|
||||
|
||||
|
||||
def cbor_decode(data):
|
||||
s = BytesIO(data)
|
||||
b = s.read(1)[0]
|
||||
if b >= 0x40 and b < 0x58:
|
||||
l = b - 0x40
|
||||
return s.read(l)
|
||||
if b == 0x58:
|
||||
l = s.read(1)[0]
|
||||
return s.read(l)
|
||||
if b == 0x59:
|
||||
l = int.from_bytes(s.read(2), "big")
|
||||
return s.read(l)
|
||||
if b == 0x60:
|
||||
l = int.from_bytes(s.read(4), "big")
|
||||
return s.read(l)
|
||||
return None
|
||||
|
||||
|
||||
def bcur_encode(data):
|
||||
"""Returns bcur encoded string and hash digest"""
|
||||
cbor = cbor_encode(data)
|
||||
enc = bc32encode(cbor)
|
||||
h = hashlib.sha256(cbor).digest()
|
||||
enc_hash = bc32encode(h)
|
||||
return enc, enc_hash
|
||||
|
||||
|
||||
def bcur_decode(data, checksum=None):
|
||||
"""Returns decoded data, verifies hash digest if provided"""
|
||||
cbor = bc32decode(data)
|
||||
if checksum is not None:
|
||||
h = bc32decode(checksum)
|
||||
assert h == hashlib.sha256(cbor).digest()
|
||||
return cbor_decode(cbor)
|
||||
@@ -1,35 +1,54 @@
|
||||
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
|
||||
if len(partial_mnemonic) not in [11, 23]:
|
||||
raise Exception("Pass in a 11- or 23-word mnemonic")
|
||||
def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]:
|
||||
"""
|
||||
Provide 12- or 24-word mnemonic, returns complete mnemonic w/checksum as a list.
|
||||
|
||||
If 11- or 23-words are provided, append word `0000` to end of list as temp final
|
||||
word.
|
||||
"""
|
||||
if len(mnemonic) in [11, 23]:
|
||||
mnemonic.append(Seed.get_wordlist(wordlist_language_code)[0])
|
||||
|
||||
if len(mnemonic) not in [12, 24]:
|
||||
raise Exception("Pass in a 12- or 24-word mnemonic")
|
||||
|
||||
# Work on a copy of the input list
|
||||
mnemonic_copy = partial_mnemonic.copy()
|
||||
mnemonic_copy.append("abandon")
|
||||
mnemonic_copy = mnemonic.copy()
|
||||
|
||||
# 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 assume it's incorrect since we either let the user select their own
|
||||
# final word OR we injected the 0000 word from the wordlist.
|
||||
mnemonic_bytes = bip39.mnemonic_to_bytes(unicodedata.normalize("NFKD", " ".join(mnemonic_copy)), ignore_checksum=True, wordlist=Seed.get_wordlist(wordlist_language_code))
|
||||
|
||||
# Return as a list
|
||||
# This function will convert the bytes back into a mnemonic, but it will also
|
||||
# calculate the proper checksum bits while doing so. For a 12-word seed it will just
|
||||
# overwrite the last 4 bits from the above result with the checksum; for a 24-word
|
||||
# seed it'll overwrite the last 8 bits.
|
||||
return bip39.mnemonic_from_bytes(mnemonic_bytes).split()
|
||||
|
||||
|
||||
|
||||
def generate_mnemonic_from_bytes(entropy_bytes):
|
||||
# Return as a list
|
||||
def generate_mnemonic_from_bytes(entropy_bytes) -> List[str]:
|
||||
return bip39.mnemonic_from_bytes(entropy_bytes).split()
|
||||
|
||||
|
||||
|
||||
def generate_mnemonic_from_dice(roll_data: str):
|
||||
def generate_mnemonic_from_dice(roll_data: str) -> List[str]:
|
||||
entropy_bytes = hashlib.sha256(roll_data.encode()).digest()
|
||||
|
||||
if len(roll_data) == 50:
|
||||
# 12-word mnemonic; only use 128bits / 16 bytes
|
||||
entropy_bytes = entropy_bytes[:16]
|
||||
|
||||
# Return as a list
|
||||
return bip39.mnemonic_from_bytes(entropy_bytes).split()
|
||||
|
||||
@@ -37,7 +56,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.descriptor import Descriptor
|
||||
from embit.networks import NETWORKS
|
||||
from embit.psbt import PSBT
|
||||
from io import BytesIO
|
||||
from typing import List
|
||||
|
||||
from seedsigner.models import Seed
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
|
||||
class PSBTParser():
|
||||
|
||||
def __init__(self, p, seed: Seed, network="main"):
|
||||
def __init__(self, p: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET):
|
||||
self.psbt: PSBT = p
|
||||
self.seed = seed
|
||||
self.network = network
|
||||
self.psbt = p
|
||||
|
||||
self.policy = None
|
||||
self.spend_amount = 0
|
||||
self.change_amount = 0
|
||||
self.change_data = []
|
||||
self.fee_amount = 0
|
||||
self.input_amount = 0
|
||||
self.num_inputs = 0
|
||||
self.destination_addresses = []
|
||||
self.self_addresses = []
|
||||
self.destination_amounts = []
|
||||
|
||||
self.root = None
|
||||
|
||||
if self.seed:
|
||||
self.parse(self.psbt,self.seed,self.network)
|
||||
if self.seed is not None:
|
||||
self.parse()
|
||||
|
||||
def __setRoot(self, seed: Seed, network):
|
||||
self.root = bip32.HDKey.from_seed(seed.seed, version=NETWORKS[network]["xprv"])
|
||||
|
||||
def parse(self, p, seed: Seed, network="main"):
|
||||
is_psbt_empty = False
|
||||
try:
|
||||
if p == None:
|
||||
is_psbt_empty = True
|
||||
except:
|
||||
pass
|
||||
def get_change_data(self, change_num: int) -> dict:
|
||||
if change_num < len(self.change_data):
|
||||
return self.change_data[change_num]
|
||||
|
||||
if is_psbt_empty:
|
||||
|
||||
@property
|
||||
def num_change_outputs(self):
|
||||
return len(self.change_data)
|
||||
|
||||
|
||||
@property
|
||||
def is_multisig(self):
|
||||
"""
|
||||
Multisig psbts will have "m" and "n" defined in policy
|
||||
"""
|
||||
return "m" in self.policy
|
||||
|
||||
|
||||
@property
|
||||
def num_destinations(self):
|
||||
return len(self.destination_addresses)
|
||||
|
||||
|
||||
def _set_root(self):
|
||||
self.root = bip32.HDKey.from_seed(self.seed.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(self.network)]["xprv"])
|
||||
|
||||
|
||||
def parse(self):
|
||||
if self.psbt is None:
|
||||
print(f"self.psbt is None!!")
|
||||
return False
|
||||
|
||||
if not seed:
|
||||
if not self.seed:
|
||||
print("self.seed is None!")
|
||||
return False
|
||||
|
||||
self.__setRoot(seed, network)
|
||||
self._set_root()
|
||||
|
||||
rt = self.__parseInputs()
|
||||
rt = self._parse_inputs()
|
||||
if rt == False:
|
||||
return False
|
||||
|
||||
rt = self.__parseOutputs()
|
||||
rt = self._parse_outputs()
|
||||
if rt == False:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def __parseInputs(self):
|
||||
|
||||
def _parse_inputs(self):
|
||||
self.input_amount = 0
|
||||
self.num_inputs = len(self.psbt.inputs)
|
||||
for inp in self.psbt.inputs:
|
||||
if inp.witness_utxo:
|
||||
self.input_amount += inp.witness_utxo.value
|
||||
inp_policy = PSBTParser.__get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs)
|
||||
inp_policy = PSBTParser._get_policy(inp, inp.witness_utxo.script_pubkey, self.psbt.xpubs)
|
||||
if self.policy == None:
|
||||
self.policy = inp_policy
|
||||
else:
|
||||
if self.policy != inp_policy:
|
||||
raise RuntimeError("Mixed inputs in the transaction")
|
||||
|
||||
def __parseOutputs(self):
|
||||
|
||||
def _parse_outputs(self):
|
||||
self.spend_amount = 0
|
||||
self.change_amount = 0
|
||||
self.change_data = []
|
||||
self.fee_amount = 0
|
||||
self.destination_addresses = []
|
||||
self.self_addresses = []
|
||||
self.destination_amounts = []
|
||||
for i, out in enumerate(self.psbt.outputs):
|
||||
out_policy = PSBTParser.__get_policy(out, self.psbt.tx.vout[i].script_pubkey, self.psbt.xpubs)
|
||||
out_policy = PSBTParser._get_policy(out, self.psbt.tx.vout[i].script_pubkey, self.psbt.xpubs)
|
||||
is_change = False
|
||||
|
||||
# if policy is the same - probably change
|
||||
@@ -109,15 +142,33 @@ class PSBTParser():
|
||||
if sc.data == self.psbt.tx.vout[i].script_pubkey.data:
|
||||
is_change = True
|
||||
if is_change:
|
||||
addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
|
||||
fingerprints = None
|
||||
derivation_paths = None
|
||||
if len(self.psbt.outputs[i].bip32_derivations) > 0:
|
||||
fingerprints = []
|
||||
derivation_paths = []
|
||||
for d, derivation_path in self.psbt.outputs[i].bip32_derivations.items():
|
||||
fingerprints.append(hexlify(derivation_path.fingerprint).decode())
|
||||
derivation_paths.append(bip32.path_to_str(derivation_path.derivation))
|
||||
self.change_data.append({
|
||||
"output_index": i,
|
||||
"address": addr,
|
||||
"amount": self.psbt.tx.vout[i].value,
|
||||
"fingerprint": fingerprints,
|
||||
"derivation_path": derivation_paths,
|
||||
})
|
||||
self.change_amount += self.psbt.tx.vout[i].value
|
||||
self.self_addresses.append(self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[self.network]))
|
||||
else:
|
||||
addr = self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[SettingsConstants.map_network_to_embit(self.network)])
|
||||
self.destination_addresses.append(addr)
|
||||
self.destination_amounts.append(self.psbt.tx.vout[i].value)
|
||||
self.spend_amount += self.psbt.tx.vout[i].value
|
||||
self.destination_addresses.append(self.psbt.tx.vout[i].script_pubkey.address(NETWORKS[self.network]))
|
||||
|
||||
self.fee_amount = self.psbt.fee()
|
||||
return True
|
||||
|
||||
|
||||
@staticmethod
|
||||
def trim(tx):
|
||||
trimmed_psbt = psbt.PSBT(tx.tx)
|
||||
@@ -128,17 +179,54 @@ class PSBTParser():
|
||||
|
||||
return trimmed_psbt
|
||||
|
||||
|
||||
@staticmethod
|
||||
def sigCount(tx):
|
||||
def sig_count(tx):
|
||||
cnt = 0
|
||||
for i, inp in enumerate(tx.inputs):
|
||||
cnt += len(list(inp.partial_sigs.keys()))
|
||||
|
||||
return cnt
|
||||
|
||||
# checks that all inputs are from the same wallet
|
||||
|
||||
@staticmethod
|
||||
def __get_policy(scope, scriptpubkey, xpubs):
|
||||
def calc_derivation(network, wallet_type, script_type):
|
||||
# TODO: Move this to Seed?
|
||||
if network == SettingsConstants.MAINNET:
|
||||
network_path = "0'"
|
||||
elif network == SettingsConstants.TESTNET:
|
||||
network_path = "1'"
|
||||
elif network == SettingsConstants.REGTEST:
|
||||
# TODO: Is this right?
|
||||
network_path = "1'"
|
||||
else:
|
||||
raise Exception("Unexpected network")
|
||||
|
||||
if wallet_type == SettingsConstants.SINGLE_SIG:
|
||||
if script_type == SettingsConstants.NATIVE_SEGWIT:
|
||||
return f"m/84'/{network_path}/0'"
|
||||
elif script_type == SettingsConstants.NESTED_SEGWIT:
|
||||
return f"m/49'/{network_path}/0'"
|
||||
elif script_type == SettingsConstants.TAPROOT:
|
||||
return f"m/86'/{network_path}/0'"
|
||||
else:
|
||||
raise Exception("Unexpected script type")
|
||||
|
||||
elif wallet_type == SettingsConstants.MULTISIG:
|
||||
if script_type == SettingsConstants.NATIVE_SEGWIT:
|
||||
return f"m/48'/{network_path}/0'/2'"
|
||||
elif script_type == SettingsConstants.NESTED_SEGWIT:
|
||||
return f"m/48'/{network_path}/0'/1'"
|
||||
elif script_type == SettingsConstants.TAPROOT:
|
||||
raise Exception("Taproot multisig/musig not yet supported")
|
||||
else:
|
||||
raise Exception("Unexpected script type")
|
||||
else:
|
||||
raise Exception("Unexpected wallet type") # checks that all inputs are from the same wallet
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _get_policy(scope, scriptpubkey, xpubs):
|
||||
"""Parse scope and get policy"""
|
||||
# we don't know the policy yet, let's parse it
|
||||
script_type = scriptpubkey.script_type()
|
||||
@@ -155,18 +243,18 @@ class PSBTParser():
|
||||
policy = {"type": script_type}
|
||||
# expected multisig
|
||||
if "p2wsh" in script_type and scope.witness_script is not None:
|
||||
m, n, pubkeys = PSBTParser.__parse_multisig(scope.witness_script)
|
||||
m, n, pubkeys = PSBTParser._parse_multisig(scope.witness_script)
|
||||
# check pubkeys are derived from cosigners
|
||||
try:
|
||||
cosigners = PSBTParser.__get_cosigners(pubkeys, scope.bip32_derivations, xpubs)
|
||||
cosigners = PSBTParser._get_cosigners(pubkeys, scope.bip32_derivations, xpubs)
|
||||
policy.update({"m": m, "n": n, "cosigners": cosigners})
|
||||
except:
|
||||
policy.update({"m": m, "n": n})
|
||||
return policy
|
||||
|
||||
# returns m, n, and pubkeys from multisig script
|
||||
|
||||
@staticmethod
|
||||
def __parse_multisig(sc):
|
||||
def _parse_multisig(sc):
|
||||
"""Takes a script and extracts m,n and pubkeys from it"""
|
||||
# OP_m <len:pubkey> ... <len:pubkey> OP_n OP_CHECKMULTISIG
|
||||
# check min size
|
||||
@@ -193,8 +281,9 @@ class PSBTParser():
|
||||
raise ValueError("Invalid multisig script")
|
||||
return m, n, pubkeys
|
||||
|
||||
|
||||
@staticmethod
|
||||
def __get_cosigners(pubkeys, derivations, xpubs):
|
||||
def _get_cosigners(pubkeys, derivations, xpubs):
|
||||
"""Returns xpubs used to derive pubkeys using global xpub field from psbt"""
|
||||
cosigners = []
|
||||
for i, pubkey in enumerate(pubkeys):
|
||||
@@ -215,3 +304,42 @@ class PSBTParser():
|
||||
if len(cosigners) != len(pubkeys):
|
||||
raise RuntimeError("Can't get all cosigners")
|
||||
return sorted(cosigners)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_input_fingerprints(psbt: PSBT) -> List[str]:
|
||||
"""
|
||||
Exctracts the fingerprint from each input's derivation path.
|
||||
|
||||
TODO: It's unclear if these derivations/fingerprints would ever be missing.
|
||||
Research on PSBT standard and known wallet coordinator implementations
|
||||
needed.
|
||||
"""
|
||||
fingerprints = set()
|
||||
for input in psbt.inputs:
|
||||
for pub, derivation_path in input.bip32_derivations.items():
|
||||
fingerprints.add(hexlify(derivation_path.fingerprint).decode())
|
||||
return list(fingerprints)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def has_matching_input_fingerprint(psbt: PSBT, seed: Seed, network: str = SettingsConstants.MAINNET):
|
||||
"""
|
||||
Extracts the fingerprint from each psbt input utxo. Returns True if any match
|
||||
the current seed.
|
||||
"""
|
||||
seed_fingerprint = seed.get_fingerprint(network)
|
||||
for input in psbt.inputs:
|
||||
for pub, derivation_path in input.bip32_derivations.items():
|
||||
if seed_fingerprint == hexlify(derivation_path.fingerprint).decode():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_multisig_output(self, descriptor: Descriptor, change_num: int) -> bool:
|
||||
change_data = self.get_change_data(change_num)
|
||||
i = change_data["output_index"]
|
||||
output = self.psbt.outputs[i]
|
||||
is_owner = descriptor.owns(output)
|
||||
# print(f"{self.psbt.tx.vout[i].script_pubkey.address()} | {output.value} | {is_owner}")
|
||||
return is_owner
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
from enum import IntEnum
|
||||
class QRType:
|
||||
"""
|
||||
Used with DecodeQR and EncodeQR to communicate qr encoding type
|
||||
"""
|
||||
PSBT__BASE64 = "psbt__base64"
|
||||
PSBT__SPECTER = "psbt__specter"
|
||||
PSBT__BASE43 = "psbt__base43"
|
||||
PSBT__UR2 = "psbt__ur2"
|
||||
|
||||
###
|
||||
### QRType Class IntEum
|
||||
### Purpose: used with DecodeQR and EncodeQR to communicate qr encoding type
|
||||
###
|
||||
SEED__SEEDQR = "seed__seedqr"
|
||||
SEED__COMPACTSEEDQR = "seed__compactseedqr"
|
||||
SEED__UR2 = "seed__ur2"
|
||||
SEED__MNEMONIC = "seed__mnemonic"
|
||||
SEED__FOUR_LETTER_MNEMONIC = "seed__four_letter_mnemonic"
|
||||
|
||||
class QRType(IntEnum):
|
||||
PSBTBASE64 = 1
|
||||
PSBTSPECTER = 2
|
||||
PSBTURLEGACY = 3
|
||||
PSBTBASE43 = 4
|
||||
PSBTUR2 = 5
|
||||
SEEDQR = 6
|
||||
COMPACTSEEDQR = 10
|
||||
SEEDUR2 = 7
|
||||
SEEDMNEMONIC = 8
|
||||
SEED4LETTERMNEMONIC = 9
|
||||
XPUBQR = 20
|
||||
SPECTERXPUBQR = 21
|
||||
URXPUBQR = 22
|
||||
BITCOINADDRESSQR = 30
|
||||
SPECTERWALLETQR = 40
|
||||
URWALLETQR = 41
|
||||
BLUEWALLETQR = 42
|
||||
INVALID = 100
|
||||
SETTINGS = "settings"
|
||||
|
||||
XPUB = "xpub"
|
||||
XPUB__SPECTER = "xpub__specter"
|
||||
XPUB__UR = "xpub__ur"
|
||||
|
||||
BITCOIN_ADDRESS = "bitcoin_address"
|
||||
|
||||
WALLET__SPECTER = "wallet__specter"
|
||||
WALLET__UR = "wallet__ur"
|
||||
WALLET__CONFIGFILE = "wallet__configfile"
|
||||
WALLET__GENERIC = "wallet__generic"
|
||||
OUTPUT__UR = "output__ur"
|
||||
ACCOUNT__UR = "account__ur"
|
||||
BYTES__UR = "bytes__ur"
|
||||
|
||||
INVALID = "invalid"
|
||||
@@ -1,108 +1,119 @@
|
||||
# External Dependencies
|
||||
from embit import bip39, bip32
|
||||
import unicodedata
|
||||
|
||||
from binascii import hexlify
|
||||
from embit import bip39, bip32
|
||||
from embit.networks import NETWORKS
|
||||
from typing import List
|
||||
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
|
||||
class InvalidSeedException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class Seed:
|
||||
def __init__(self,
|
||||
mnemonic: List[str] = None,
|
||||
passphrase: str = "",
|
||||
wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> None:
|
||||
self.wordlist_language_code = wordlist_language_code
|
||||
|
||||
def __init__(self, mnemonic = None, passphrase = "", wordlist=None) -> None:
|
||||
self._init_complete = False
|
||||
self._valid = False
|
||||
self.passphrase = passphrase
|
||||
self.mnemonic = mnemonic
|
||||
self._wordlist = wordlist
|
||||
|
||||
if self._wordlist == None:
|
||||
raise Exception('Wordlist Required')
|
||||
|
||||
self._valid = self._generate_seed()
|
||||
self._init_complete = True
|
||||
|
||||
def _generate_seed(self) -> bool:
|
||||
self.seed = None
|
||||
try:
|
||||
self.seed = bip39.mnemonic_to_seed(self.mnemonic, password=self.passphrase, wordlist=self._wordlist)
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
if not mnemonic:
|
||||
raise Exception("Must initialize a Seed with a mnemonic List[str]")
|
||||
self._mnemonic: List[str] = unicodedata.normalize("NFKD", " ".join(mnemonic).strip()).split()
|
||||
|
||||
self._passphrase: str = ""
|
||||
self.set_passphrase(passphrase, regenerate_seed=False)
|
||||
|
||||
self.seed_bytes: bytes = None
|
||||
self._generate_seed()
|
||||
|
||||
|
||||
### getters and setters
|
||||
|
||||
@property
|
||||
def mnemonic(self):
|
||||
return self._mnemonic
|
||||
|
||||
@property
|
||||
def mnemonic_list(self):
|
||||
return self._mnemonic.split()
|
||||
|
||||
@property
|
||||
def mnemonic_display(self):
|
||||
return unicodedata.normalize("NFC", self._mnemonic)
|
||||
|
||||
@property
|
||||
def mnemonic_display_list(self):
|
||||
return unicodedata.normalize("NFC", self._mnemonic).split()
|
||||
@staticmethod
|
||||
def get_wordlist(wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH) -> List[str]:
|
||||
# TODO: Support other bip-39 wordlist languages!
|
||||
if wordlist_language_code == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH:
|
||||
return bip39.WORDLIST
|
||||
else:
|
||||
raise Exception(f"Unrecognized wordlist_language_code {wordlist_language_code}")
|
||||
|
||||
@mnemonic.setter
|
||||
def mnemonic(self, value):
|
||||
if isinstance(value, list):
|
||||
self._mnemonic = unicodedata.normalize("NFKD", " ".join(value).strip())
|
||||
elif isinstance(value, str):
|
||||
self._mnemonic = unicodedata.normalize("NFKD", value.strip())
|
||||
|
||||
if self._init_complete:
|
||||
self._valid = self._generate_seed()
|
||||
|
||||
@property
|
||||
def passphrase(self):
|
||||
return self._passphrase
|
||||
|
||||
@property
|
||||
def passphrase_display(self):
|
||||
return unicodedata.normalize("NFC", self._passphrase)
|
||||
def _generate_seed(self) -> bool:
|
||||
try:
|
||||
self.seed_bytes = bip39.mnemonic_to_seed(self.mnemonic_str, password=self._passphrase, wordlist=self.wordlist)
|
||||
except Exception as e:
|
||||
print(repr(e))
|
||||
raise InvalidSeedException(repr(e))
|
||||
|
||||
@passphrase.setter
|
||||
def passphrase(self, value):
|
||||
if isinstance(value, str):
|
||||
self._passphrase = unicodedata.normalize("NFKD", value)
|
||||
|
||||
if self._init_complete:
|
||||
self._valid = self._generate_seed()
|
||||
@property
|
||||
def mnemonic_str(self) -> str:
|
||||
return " ".join(self._mnemonic)
|
||||
|
||||
|
||||
@property
|
||||
def wordlist(self):
|
||||
return self._wordlist
|
||||
@property
|
||||
def mnemonic_list(self) -> List[str]:
|
||||
return self._mnemonic
|
||||
|
||||
|
||||
@wordlist.setter
|
||||
def wordlist(self, value):
|
||||
previous_wordlist = self._wordlist
|
||||
|
||||
if isinstance(value, list):
|
||||
if len(value) == 2048:
|
||||
self._wordlist = value
|
||||
else:
|
||||
raise Exception('Invalid Wordlist')
|
||||
else:
|
||||
raise Exception('Wordlist Must be List Type')
|
||||
|
||||
if self.mnemonic != None:
|
||||
# if has mnemonic, convert to new wordlist
|
||||
previous_mnemonic_str = self._mnemonic
|
||||
previous_mnemonic_list = previous_mnemonic_str.split()
|
||||
new_mnemonic_list = []
|
||||
for word in previous_mnemonic_list:
|
||||
idx = previous_wordlist.index(word)
|
||||
new_mnemonic_list.append(self._wordlist[idx])
|
||||
|
||||
self._mnemonic = " ".join(new_mnemonic_list)
|
||||
@property
|
||||
def mnemonic_display_str(self) -> str:
|
||||
return unicodedata.normalize("NFC", " ".join(self._mnemonic))
|
||||
|
||||
|
||||
### override operators
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Seed):
|
||||
return self.seed == other.seed
|
||||
return False
|
||||
|
||||
def __bool__(self):
|
||||
return self._valid
|
||||
@property
|
||||
def mnemonic_display_list(self) -> List[str]:
|
||||
return unicodedata.normalize("NFC", " ".join(self._mnemonic)).split()
|
||||
|
||||
|
||||
@property
|
||||
def passphrase(self):
|
||||
return self._passphrase
|
||||
|
||||
|
||||
@property
|
||||
def passphrase_display(self):
|
||||
return unicodedata.normalize("NFC", self._passphrase)
|
||||
|
||||
|
||||
def set_passphrase(self, passphrase: str, regenerate_seed: bool = True):
|
||||
if passphrase:
|
||||
self._passphrase = unicodedata.normalize("NFKD", passphrase)
|
||||
else:
|
||||
# Passphrase must always have a string value, even if it's just the empty
|
||||
# string.
|
||||
self._passphrase = ""
|
||||
|
||||
if regenerate_seed:
|
||||
# Regenerate the internal seed since passphrase changes the result
|
||||
self._generate_seed()
|
||||
|
||||
|
||||
@property
|
||||
def wordlist(self) -> List[str]:
|
||||
return Seed.get_wordlist(self.wordlist_language_code)
|
||||
|
||||
|
||||
def set_wordlist_language_code(self, language_code: str):
|
||||
# TODO: Support other bip-39 wordlist languages!
|
||||
raise Exception("Not yet implemented!")
|
||||
|
||||
|
||||
def get_fingerprint(self, network: str = SettingsConstants.MAINNET) -> str:
|
||||
root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"])
|
||||
return hexlify(root.child(0).fingerprint).decode('utf-8')
|
||||
|
||||
def get_xpub(self, wallet_path: str = '/', network: str = SettingsConstants.MAINNET):
|
||||
root = bip32.HDKey.from_seed(self.seed_bytes, version=NETWORKS[SettingsConstants.map_network_to_embit(network)]["xprv"])
|
||||
xprv = root.derive(wallet_path)
|
||||
xpub = xprv.to_public()
|
||||
return xpub
|
||||
|
||||
### override operators
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Seed):
|
||||
return self.seed_bytes == other.seed_bytes
|
||||
return False
|
||||
|
||||
@@ -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,526 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List
|
||||
|
||||
|
||||
|
||||
class SettingsConstants:
|
||||
# Basic defaults
|
||||
OPTION__ENABLED = "E"
|
||||
OPTION__DISABLED = "D"
|
||||
OPTION__PROMPT = "P"
|
||||
OPTION__REQUIRED = "R"
|
||||
OPTIONS__ENABLED_DISABLED = [
|
||||
(OPTION__ENABLED, "Enabled"),
|
||||
(OPTION__DISABLED, "Disabled"),
|
||||
]
|
||||
OPTIONS__PROMPT_REQUIRED_DISABLED = [
|
||||
(OPTION__PROMPT, "Prompt"),
|
||||
(OPTION__REQUIRED, "Required"),
|
||||
(OPTION__DISABLED, "Disabled"),
|
||||
]
|
||||
OPTIONS__ENABLED_DISABLED_REQUIRED = OPTIONS__ENABLED_DISABLED +[
|
||||
(OPTION__REQUIRED, "Required"),
|
||||
]
|
||||
OPTIONS__ENABLED_DISABLED_PROMPT = OPTIONS__ENABLED_DISABLED + [
|
||||
(OPTION__PROMPT, "Prompt"),
|
||||
]
|
||||
ALL_OPTIONS = OPTIONS__ENABLED_DISABLED_PROMPT + [
|
||||
(OPTION__REQUIRED, "Required"),
|
||||
]
|
||||
|
||||
# User-facing selection options
|
||||
COORDINATOR__BLUE_WALLET = "bw"
|
||||
COORDINATOR__NUNCHUK = "nun"
|
||||
COORDINATOR__SPARROW = "spa"
|
||||
COORDINATOR__SPECTER_DESKTOP = "spd"
|
||||
ALL_COORDINATORS = [
|
||||
(COORDINATOR__BLUE_WALLET, "BlueWallet"),
|
||||
(COORDINATOR__NUNCHUK, "Nunchuk"),
|
||||
(COORDINATOR__SPARROW, "Sparrow"),
|
||||
(COORDINATOR__SPECTER_DESKTOP, "Specter Desktop"),
|
||||
]
|
||||
|
||||
LANGUAGE__ENGLISH = "en"
|
||||
ALL_LANGUAGES = [
|
||||
(LANGUAGE__ENGLISH, "English"),
|
||||
]
|
||||
|
||||
BTC_DENOMINATION__BTC = "btc"
|
||||
BTC_DENOMINATION__SATS = "sats"
|
||||
BTC_DENOMINATION__THRESHOLD = "thr"
|
||||
BTC_DENOMINATION__BTCSATSHYBRID = "hyb"
|
||||
ALL_BTC_DENOMINATIONS = [
|
||||
(BTC_DENOMINATION__BTC, "Btc-only"),
|
||||
(BTC_DENOMINATION__SATS, "Sats-only"),
|
||||
(BTC_DENOMINATION__THRESHOLD, "Threshold at 0.01"),
|
||||
(BTC_DENOMINATION__BTCSATSHYBRID, "Btc | Sats hybrid"),
|
||||
]
|
||||
|
||||
CAMERA_ROTATION__0 = 0
|
||||
CAMERA_ROTATION__90 = 90
|
||||
CAMERA_ROTATION__180 = 180
|
||||
CAMERA_ROTATION__270 = 270
|
||||
ALL_CAMERA_ROTATIONS = [
|
||||
(CAMERA_ROTATION__0, "0°"),
|
||||
(CAMERA_ROTATION__90, "90°"),
|
||||
(CAMERA_ROTATION__180, "180°"),
|
||||
(CAMERA_ROTATION__270, "270°"),
|
||||
]
|
||||
|
||||
# QR code constants
|
||||
DENSITY__LOW = "L"
|
||||
DENSITY__MEDIUM = "M"
|
||||
DENSITY__HIGH = "H"
|
||||
ALL_DENSITIES = [
|
||||
(DENSITY__LOW, "Low"),
|
||||
(DENSITY__MEDIUM, "Medium"),
|
||||
(DENSITY__HIGH, "High"),
|
||||
]
|
||||
|
||||
# Seed-related constants
|
||||
MAINNET = "M"
|
||||
TESTNET = "T"
|
||||
REGTEST = "R"
|
||||
ALL_NETWORKS = [
|
||||
(MAINNET, "Mainnet"),
|
||||
(TESTNET, "Testnet"),
|
||||
(REGTEST, "Regtest")
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def map_network_to_embit(cls, network) -> str:
|
||||
if network == SettingsConstants.MAINNET:
|
||||
return "main"
|
||||
elif network == SettingsConstants.TESTNET:
|
||||
return "test"
|
||||
if network == SettingsConstants.REGTEST:
|
||||
return "regtest"
|
||||
|
||||
|
||||
SINGLE_SIG = "ss"
|
||||
MULTISIG = "ms"
|
||||
ALL_SIG_TYPES = [
|
||||
(SINGLE_SIG, "Single Sig"),
|
||||
(MULTISIG, "Multisig"),
|
||||
]
|
||||
|
||||
LEGACY_P2PKH = "leg" # Intentionally excluded from ALL_SCRIPT_TYPES
|
||||
NATIVE_SEGWIT = "nat"
|
||||
NESTED_SEGWIT = "nes"
|
||||
TAPROOT = "tr"
|
||||
CUSTOM_DERIVATION = "cus"
|
||||
ALL_SCRIPT_TYPES = [
|
||||
(NATIVE_SEGWIT, "Native Segwit"),
|
||||
(NESTED_SEGWIT, "Nested Segwit (legacy)"),
|
||||
(TAPROOT, "Taproot"),
|
||||
(CUSTOM_DERIVATION, "Custom Derivation"),
|
||||
]
|
||||
|
||||
WORDLIST_LANGUAGE__ENGLISH = "en"
|
||||
WORDLIST_LANGUAGE__CHINESE_SIMPLIFIED = "zh_Hans_CN"
|
||||
WORDLIST_LANGUAGE__CHINESE_TRADITIONAL = "zh_Hant_TW"
|
||||
WORDLIST_LANGUAGE__FRENCH = "fr"
|
||||
WORDLIST_LANGUAGE__ITALIAN = "it"
|
||||
WORDLIST_LANGUAGE__JAPANESE = "jp"
|
||||
WORDLIST_LANGUAGE__KOREAN = "kr"
|
||||
WORDLIST_LANGUAGE__PORTUGUESE = "pt"
|
||||
ALL_WORDLIST_LANGUAGES = [
|
||||
(WORDLIST_LANGUAGE__ENGLISH, "English"),
|
||||
# (WORDLIST_LANGUAGE__CHINESE_SIMPLIFIED, "简体中文"),
|
||||
# (WORDLIST_LANGUAGE__CHINESE_TRADITIONAL, "繁體中文"),
|
||||
# (WORDLIST_LANGUAGE__FRENCH, "Français"),
|
||||
# (WORDLIST_LANGUAGE__ITALIAN, "Italiano"),
|
||||
# (WORDLIST_LANGUAGE__JAPANESE, "日本語"),
|
||||
# (WORDLIST_LANGUAGE__KOREAN, "한국어"),
|
||||
# (WORDLIST_LANGUAGE__PORTUGUESE, "Português"),
|
||||
]
|
||||
|
||||
|
||||
# Individual SettingsEntry attr_names
|
||||
SETTING__LANGUAGE = "language"
|
||||
SETTING__WORDLIST_LANGUAGE = "wordlist_language"
|
||||
SETTING__PERSISTENT_SETTINGS = "persistent_settings"
|
||||
SETTING__COORDINATORS = "coordinators"
|
||||
SETTING__BTC_DENOMINATION = "denomination"
|
||||
|
||||
SETTING__NETWORK = "network"
|
||||
SETTING__QR_DENSITY = "qr_density"
|
||||
SETTING__XPUB_EXPORT = "xpub_export"
|
||||
SETTING__SIG_TYPES = "sig_types"
|
||||
SETTING__SCRIPT_TYPES = "script_types"
|
||||
SETTING__XPUB_DETAILS = "xpub_details"
|
||||
SETTING__PASSPHRASE = "passphrase"
|
||||
SETTING__CAMERA_ROTATION = "camera_rotation"
|
||||
SETTING__COMPACT_SEEDQR = "compact_seedqr"
|
||||
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
|
||||
SETTING__DIRE_WARNINGS = "dire_warnings"
|
||||
SETTING__PARTNER_LOGOS = "partner_logos"
|
||||
|
||||
SETTING__DEBUG = "debug"
|
||||
|
||||
# Hidden settings
|
||||
SETTING__QR_BRIGHTNESS = "qr_background_color"
|
||||
|
||||
|
||||
# Structural constants
|
||||
# TODO: Not using these for display purposes yet (ever?)
|
||||
CATEGORY__SYSTEM = "system"
|
||||
CATEGORY__DISPLAY = "display"
|
||||
CATEGORY__WALLET = "wallet"
|
||||
CATEGORY__FEATURES = "features"
|
||||
|
||||
VISIBILITY__GENERAL = "general"
|
||||
VISIBILITY__ADVANCED = "advanced"
|
||||
VISIBILITY__DEVELOPER = "developer"
|
||||
VISIBILITY__HIDDEN = "hidden" # For data-only (e.g. custom_derivation), not configurable by the user
|
||||
|
||||
# TODO: Is there really a difference between ENABLED and PROMPT?
|
||||
TYPE__ENABLED_DISABLED = "enabled_disabled"
|
||||
TYPE__ENABLED_DISABLED_PROMPT = "enabled_disabled_prompt"
|
||||
TYPE__ENABLED_DISABLED_PROMPT_REQUIRED = "enabled_disabled_prompt_required"
|
||||
TYPE__SELECT_1 = "select_1"
|
||||
TYPE__MULTISELECT = "multiselect"
|
||||
TYPE__FREE_ENTRY = "free_entry"
|
||||
|
||||
ALL_ENABLED_DISABLED_TYPES = [
|
||||
TYPE__ENABLED_DISABLED,
|
||||
TYPE__ENABLED_DISABLED_PROMPT,
|
||||
TYPE__ENABLED_DISABLED_PROMPT_REQUIRED,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsEntry:
|
||||
"""
|
||||
Defines all the parameters for a single settings entry.
|
||||
|
||||
* category: Mostly for organizational purposes when displaying options in the
|
||||
SettingsQR UI. Potentially an additional sub-level breakout in the menus
|
||||
on the device itself, too.
|
||||
|
||||
* selection_options: May be specified as a List(Any) or List(tuple(Any, str)).
|
||||
The tuple form is to provide a human-readable display_name. Probably all
|
||||
entries should shift to using the tuple form.
|
||||
"""
|
||||
# TODO: Handle multi-language `display_name` and `help_text`
|
||||
category: str
|
||||
attr_name: str
|
||||
display_name: str
|
||||
verbose_name: str = None
|
||||
abbreviated_name: str = None
|
||||
visibility: str = SettingsConstants.VISIBILITY__GENERAL
|
||||
type: str = SettingsConstants.TYPE__ENABLED_DISABLED
|
||||
help_text: str = None
|
||||
selection_options: List[str] = None
|
||||
default_value: Any = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.type == SettingsConstants.TYPE__ENABLED_DISABLED:
|
||||
self.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED
|
||||
|
||||
elif self.type == SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT:
|
||||
self.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED_PROMPT
|
||||
|
||||
elif self.type == SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT_REQUIRED:
|
||||
self.selection_options = [SettingsConstants.ALL_OPTIONS]
|
||||
|
||||
# Account for List[tuple] and tuple formats as default_value
|
||||
if type(self.default_value) == list and type(self.default_value[0]) == tuple:
|
||||
self.default_value = [v[0] for v in self.default_value]
|
||||
elif type(self.default_value) == tuple:
|
||||
self.default_value = self.default_value[0]
|
||||
|
||||
|
||||
@property
|
||||
def selection_options_display_names(self) -> List[str]:
|
||||
if type(self.selection_options[0]) == tuple:
|
||||
return [v[1] for v in self.selection_options]
|
||||
else:
|
||||
# Always return a copy so the original can't be altered
|
||||
return list(self.selection_options)
|
||||
|
||||
|
||||
def get_selection_option_value(self, i: int):
|
||||
value = self.selection_options[i]
|
||||
if type(value) == tuple:
|
||||
value = value[0]
|
||||
return value
|
||||
|
||||
|
||||
def get_selection_option_display_name(self, i: int) -> str:
|
||||
value = self.selection_options[i]
|
||||
if type(value) == tuple:
|
||||
value = value[1]
|
||||
return value
|
||||
|
||||
|
||||
def get_selection_option_display_name_by_value(self, value) -> str:
|
||||
for option in self.selection_options:
|
||||
if type(option) == tuple:
|
||||
option_value = option[0]
|
||||
display_name = option[1]
|
||||
else:
|
||||
option_value = option
|
||||
display_name = option
|
||||
if option_value == value:
|
||||
return display_name
|
||||
|
||||
|
||||
def get_selection_option_value_by_display_name(self, display_name: str):
|
||||
for option in self.selection_options:
|
||||
if type(option) == tuple:
|
||||
option_value = option[0]
|
||||
option_display_name = option[1]
|
||||
else:
|
||||
option_value = option
|
||||
option_display_name = option
|
||||
if option_display_name == display_name:
|
||||
return option_value
|
||||
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
if self.selection_options:
|
||||
selection_options = []
|
||||
for option in self.selection_options:
|
||||
if type(option) == tuple:
|
||||
value = option[0]
|
||||
display_name = option[1]
|
||||
else:
|
||||
display_name = option
|
||||
value = option
|
||||
selection_options.append({
|
||||
"display_name": display_name,
|
||||
"value": value
|
||||
})
|
||||
else:
|
||||
selection_options = None
|
||||
|
||||
return {
|
||||
"category": self.category,
|
||||
"attr_name": self.attr_name,
|
||||
"display_name": self.display_name,
|
||||
"verbose_name": self.verbose_name,
|
||||
"abbreviated_name": self.abbreviated_name,
|
||||
"visibility": self.visibility,
|
||||
"type": self.type,
|
||||
"help_text": self.help_text,
|
||||
"selection_options": selection_options,
|
||||
"default_value": self.default_value,
|
||||
}
|
||||
|
||||
|
||||
|
||||
class SettingsDefinition:
|
||||
"""
|
||||
Master list of all settings, their possible options, their defaults, on-device
|
||||
display strings, and enriched SettingsQR UI options.
|
||||
|
||||
Used to auto-build the Settings UI menuing with no repetitive boilerplate code.
|
||||
|
||||
Defines the on-disk persistent storage structure and can read that format back
|
||||
and validate the values.
|
||||
|
||||
Used to generate a master json file that documents all these params which can
|
||||
then be read in by the SettingsQR UI to auto-generate the necessary html inputs.
|
||||
"""
|
||||
# Increment if there are any breaking changes; write migrations to bridge from
|
||||
# incompatible prior versions.
|
||||
version: int = 1
|
||||
|
||||
settings_entries: List[SettingsEntry] = [
|
||||
# General options
|
||||
|
||||
# TODO: Full babel multilanguage support! Until then, type == HIDDEN
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__LANGUAGE,
|
||||
display_name="Language",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__HIDDEN,
|
||||
selection_options=SettingsConstants.ALL_LANGUAGES,
|
||||
default_value=SettingsConstants.LANGUAGE__ENGLISH),
|
||||
|
||||
# TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__WORDLIST_LANGUAGE,
|
||||
display_name="Mnemonic language",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__HIDDEN,
|
||||
selection_options=SettingsConstants.ALL_WORDLIST_LANGUAGES,
|
||||
default_value=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__PERSISTENT_SETTINGS,
|
||||
display_name="Persistent settings",
|
||||
help_text="Store Settings on SD card.",
|
||||
default_value=SettingsConstants.OPTION__DISABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__WALLET,
|
||||
attr_name=SettingsConstants.SETTING__COORDINATORS,
|
||||
display_name="Coordinator software",
|
||||
type=SettingsConstants.TYPE__MULTISELECT,
|
||||
selection_options=SettingsConstants.ALL_COORDINATORS,
|
||||
default_value=SettingsConstants.ALL_COORDINATORS),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__BTC_DENOMINATION,
|
||||
display_name="Denomination display",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
selection_options=SettingsConstants.ALL_BTC_DENOMINATIONS,
|
||||
default_value=SettingsConstants.BTC_DENOMINATION__THRESHOLD),
|
||||
|
||||
|
||||
# Advanced options
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__NETWORK,
|
||||
display_name="Bitcoin network",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_NETWORKS,
|
||||
default_value=SettingsConstants.MAINNET),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__QR_DENSITY,
|
||||
display_name="QR code density",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_DENSITIES,
|
||||
default_value=SettingsConstants.DENSITY__MEDIUM),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__XPUB_EXPORT,
|
||||
display_name="Xpub export",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__SIG_TYPES,
|
||||
display_name="Sig types",
|
||||
type=SettingsConstants.TYPE__MULTISELECT,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_SIG_TYPES,
|
||||
default_value=SettingsConstants.ALL_SIG_TYPES),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__SCRIPT_TYPES,
|
||||
display_name="Script types",
|
||||
type=SettingsConstants.TYPE__MULTISELECT,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_SCRIPT_TYPES,
|
||||
default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT]),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__XPUB_DETAILS,
|
||||
display_name="Show xpub details",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__PASSPHRASE,
|
||||
display_name="BIP-39 passphrase",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.OPTIONS__ENABLED_DISABLED_REQUIRED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__CAMERA_ROTATION,
|
||||
display_name="Camera rotation",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_CAMERA_ROTATIONS,
|
||||
default_value=SettingsConstants.CAMERA_ROTATION__0),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
|
||||
display_name="CompactSeedQR",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__DISABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS,
|
||||
display_name="Show privacy warnings",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__DIRE_WARNINGS,
|
||||
display_name="Show dire warnings",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__PARTNER_LOGOS,
|
||||
display_name="Show partner logos",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
# Developer options
|
||||
# TODO: No real Developer options needed yet. Disable for now.
|
||||
# SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
# attr_name=SettingsConstants.SETTING__DEBUG,
|
||||
# display_name="Debug",
|
||||
# visibility=SettingsConstants.VISIBILITY__DEVELOPER,
|
||||
# default_value=SettingsConstants.OPTION__DISABLED),
|
||||
|
||||
# "Hidden" settings with no UI interaction
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS,
|
||||
display_name="QR background color",
|
||||
type=SettingsConstants.TYPE__FREE_ENTRY,
|
||||
visibility=SettingsConstants.VISIBILITY__HIDDEN,
|
||||
default_value=189),
|
||||
]
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_settings_entries(cls, visibiilty: str = SettingsConstants.VISIBILITY__GENERAL) -> List[SettingsEntry]:
|
||||
entries = []
|
||||
for entry in cls.settings_entries:
|
||||
if entry.visibility == visibiilty:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_settings_entry(cls, attr_name) -> SettingsEntry:
|
||||
for entry in cls.settings_entries:
|
||||
if entry.attr_name == attr_name:
|
||||
return entry
|
||||
|
||||
|
||||
@classmethod
|
||||
def parse_abbreviated_ini(cls, abbreviated_ini: str) -> dict:
|
||||
raise Exception("Not implemented, maybe not needed")
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_defaults(cls) -> dict:
|
||||
as_dict = {}
|
||||
for entry in SettingsDefinition.settings_entries:
|
||||
if type(entry.default_value) == list:
|
||||
# Must copy the default_value list, otherwise we'll inadvertently change
|
||||
# defaults when updating these attrs
|
||||
as_dict[entry.attr_name] = list(entry.default_value)
|
||||
else:
|
||||
as_dict[entry.attr_name] = entry.default_value
|
||||
return as_dict
|
||||
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls) -> dict:
|
||||
output = {
|
||||
"settings_entries": [],
|
||||
}
|
||||
for settings_entry in cls.settings_entries:
|
||||
output["settings_entries"].append(settings_entry.to_dict())
|
||||
|
||||
return output
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
output_file = "settings_definition.json"
|
||||
with open(output_file, 'w') as json_file:
|
||||
json.dump(SettingsDefinition.to_dict(), json_file, indent=4)
|
||||
@@ -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,46 @@
|
||||
import logging
|
||||
from threading import Thread, Lock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseThread(Thread):
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True)
|
||||
|
||||
def start(self):
|
||||
logger.debug(f"{self.__class__.__name__} STARTING")
|
||||
self.keep_running = True
|
||||
super().start()
|
||||
|
||||
def stop(self):
|
||||
logger.debug(f"{self.__class__.__name__} EXITING")
|
||||
self.keep_running = False
|
||||
|
||||
def run(self):
|
||||
while self.keep_running:
|
||||
# Do something
|
||||
raise Exception(f"Must implement run() in {self.__class__.__name__}")
|
||||
|
||||
|
||||
|
||||
class ThreadsafeCounter:
|
||||
def __init__(self, initial_value: int = 0):
|
||||
self.count = initial_value
|
||||
self._lock = Lock()
|
||||
|
||||
@property
|
||||
def cur_count(self):
|
||||
# Reads don't require the lock
|
||||
return self.count
|
||||
|
||||
def increment(self, step: int = 1):
|
||||
# Updates must be locked
|
||||
with self._lock:
|
||||
self.count += step
|
||||
|
||||
def set_value(self, value: int):
|
||||
with self._lock:
|
||||
self.count = value
|
||||
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 7.1 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,151 +0,0 @@
|
||||
# External Dependencies
|
||||
from threading import Thread
|
||||
from pyzbar import pyzbar
|
||||
from pyzbar.pyzbar import ZBarSymbol
|
||||
import time
|
||||
|
||||
# Internal file class dependencies
|
||||
from . import View
|
||||
from seedsigner.helpers import B
|
||||
|
||||
class IOTestView(View):
|
||||
|
||||
def __init__(self) -> None:
|
||||
View.__init__(self)
|
||||
self.redraw = False
|
||||
self.redraw_complete = False
|
||||
self.qr_text = "Scan ANY QR Code"
|
||||
self.exit = False
|
||||
|
||||
def display_io_test_screen(self):
|
||||
|
||||
# display loading screen
|
||||
self.draw_modal(["Initializing I/O Test"])
|
||||
print("Initializing I/O Test")
|
||||
self.qr_text = "Scan ANY QR Code"
|
||||
self.redraw = False
|
||||
self.redraw_complete = False
|
||||
self.exit = False
|
||||
|
||||
try:
|
||||
self.controller.get_instance().camera.start_video_stream_mode()
|
||||
t = Thread(target=self.qr_loop)
|
||||
t.start()
|
||||
except:
|
||||
self.qr_text = "No Camera"
|
||||
self.controller.get_instance().camera.stop_video_stream_mode()
|
||||
|
||||
while True:
|
||||
|
||||
self.draw_io_screen()
|
||||
|
||||
input = self.buttons.wait_for([B.KEY_UP, B.KEY_DOWN, B.KEY_PRESS, B.KEY_RIGHT, B.KEY_LEFT, B.KEY1, B.KEY2, B.KEY3], False)
|
||||
if input == B.KEY_UP:
|
||||
ret_val = self.up_button()
|
||||
elif input == B.KEY_DOWN:
|
||||
ret_val = self.down_button()
|
||||
elif input == B.KEY_RIGHT:
|
||||
ret_val = self.right_button()
|
||||
elif input == B.KEY_LEFT:
|
||||
ret_val = self.left_button()
|
||||
elif input == B.KEY_PRESS:
|
||||
ret_val = self.press_button()
|
||||
elif input == B.KEY1:
|
||||
ret_val = self.a_button()
|
||||
elif input == B.KEY2:
|
||||
ret_val = self.b_button()
|
||||
elif input == B.KEY3:
|
||||
ret_val = self.c_button()
|
||||
return True
|
||||
|
||||
def qr_loop(self):
|
||||
while True:
|
||||
frame = self.controller.get_instance().camera.read_video_stream()
|
||||
if frame is not None:
|
||||
barcodes = pyzbar.decode(frame, symbols=[ZBarSymbol.QRCODE])
|
||||
if len(barcodes) > 0:
|
||||
self.draw_scan_detected()
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
if self.controller.get_instance().camera._video_stream is None:
|
||||
break
|
||||
|
||||
if self.exit == True:
|
||||
break
|
||||
|
||||
def draw_io_screen(self):
|
||||
self.redraw_complete = False
|
||||
self.redraw = False
|
||||
self.draw.rectangle((0,0,View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
self.draw.text((45, 5), "Input/Output Check:", fill=View.color, font=View.ASSISTANT18)
|
||||
self.draw.polygon([(61, 89), (80, 46), (99, 89)], outline=View.color, fill=0)
|
||||
self.draw.polygon([(51, 100), (8, 119), (51, 138)], outline=View.color, fill=0)
|
||||
self.draw.polygon([(109, 100), (152, 119), (109, 138)], outline=View.color, fill=0)
|
||||
self.draw.polygon([(61, 151), (80, 193), (99, 151)], outline=View.color, fill=0)
|
||||
self.draw.ellipse([(61, 99), (99, 141)], outline=View.color, fill=0)
|
||||
self.draw.ellipse([(198, 40), (238, 80)], outline=View.color, fill=0)
|
||||
self.draw.ellipse([(198, 95), (238, 135)], outline=View.color, fill=0)
|
||||
self.draw.text((200, 160), "EXIT", fill=View.color, font=View.ASSISTANT18)
|
||||
self.draw.rectangle((30, 205, 210, 235), outline=View.color, fill="BLACK")
|
||||
tw, th = self.draw.textsize(self.qr_text, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill=View.color, font=View.ASSISTANT22)
|
||||
View.DispShowImage()
|
||||
self.redraw_complete = True
|
||||
|
||||
def a_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.ellipse([(198, 40), (238, 80)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.redraw = True
|
||||
|
||||
def b_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.ellipse([(198, 95), (238, 135)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.redraw = True
|
||||
|
||||
def c_button(self):
|
||||
self.exit = True
|
||||
self.controller.get_instance().camera.stop_video_stream_mode()
|
||||
return
|
||||
|
||||
def up_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.polygon([(61, 89), (80, 46), (99, 89)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.redraw = True
|
||||
|
||||
def down_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.polygon([(61, 151), (80, 193), (99, 151)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.redraw = True
|
||||
|
||||
def left_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.polygon([(51, 100), (8, 119), (51, 138)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.redraw = True
|
||||
|
||||
def right_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.polygon([(109, 100), (152, 119), (109, 138)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.redraw = True
|
||||
|
||||
def press_button(self):
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.ellipse([(61, 99), (99, 141)], outline=View.color, fill=View.color)
|
||||
View.DispShowImage()
|
||||
self.redraw = True
|
||||
|
||||
def draw_scan_detected(self):
|
||||
self.qr_text = "QR Scanned"
|
||||
if self.redraw == False and self.redraw_complete == True:
|
||||
self.draw.rectangle((30, 205, 210, 235), outline=View.color, fill=View.color)
|
||||
tw, th = self.draw.textsize(self.qr_text, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 205), self.qr_text, fill="BLACK", font=View.ASSISTANT22)
|
||||
View.DispShowImage()
|
||||
self.redraw = True
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,565 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from embit.psbt import PSBT
|
||||
from embit import script
|
||||
from embit.networks import NETWORKS
|
||||
from seedsigner.controller import Controller
|
||||
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
|
||||
from seedsigner.models.encode_qr import EncodeQR
|
||||
from seedsigner.models.psbt_parser import PSBTParser
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
from seedsigner.gui.screens import psbt_screens
|
||||
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen,
|
||||
LoadingScreenThread, QRDisplayScreen, WarningScreen)
|
||||
|
||||
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class PSBTSelectSeedView(View):
|
||||
def run(self):
|
||||
# Note: we can't just autoroute to the PSBT Overview because we might have a
|
||||
# multisig where we want to sign with more than one key on this device.
|
||||
if not self.controller.psbt:
|
||||
# Shouldn't be able to get here
|
||||
raise Exception("No PSBT currently loaded")
|
||||
|
||||
seeds = self.controller.storage.seeds
|
||||
|
||||
SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE)
|
||||
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
|
||||
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
|
||||
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(TYPE_12WORD)
|
||||
button_data.append(TYPE_24WORD)
|
||||
|
||||
selected_menu_num = ButtonListScreen(
|
||||
title="Select Signer",
|
||||
is_button_text_centered=False,
|
||||
button_data=button_data
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
if len(seeds) > 0 and selected_menu_num < len(seeds):
|
||||
# User selected one of the n seeds
|
||||
self.controller.psbt_seed = self.controller.get_seed(selected_menu_num)
|
||||
return Destination(PSBTOverviewView)
|
||||
|
||||
# The remaining flows are a sub-flow; resume PSBT flow once the seed is loaded.
|
||||
self.controller.resume_main_flow = Controller.FLOW__PSBT
|
||||
|
||||
if button_data[selected_menu_num] == SCAN_SEED:
|
||||
from seedsigner.views.scan_views import ScanView
|
||||
return Destination(ScanView)
|
||||
|
||||
elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]:
|
||||
from seedsigner.views.seed_views import SeedMnemonicEntryView
|
||||
if button_data[selected_menu_num] == TYPE_12WORD:
|
||||
self.controller.storage.init_pending_mnemonic(num_words=12)
|
||||
else:
|
||||
self.controller.storage.init_pending_mnemonic(num_words=24)
|
||||
return Destination(SeedMnemonicEntryView)
|
||||
|
||||
|
||||
|
||||
class PSBTOverviewView(View):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.loading_screen = None
|
||||
|
||||
if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed:
|
||||
# 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()
|
||||
try:
|
||||
self.controller.psbt_parser = PSBTParser(
|
||||
self.controller.psbt,
|
||||
seed=self.controller.psbt_seed,
|
||||
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
|
||||
)
|
||||
except Exception as e:
|
||||
self.loading_screen.stop()
|
||||
raise e
|
||||
|
||||
|
||||
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
|
||||
if self.loading_screen:
|
||||
self.loading_screen.stop()
|
||||
|
||||
# Run the overview screen
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
# expecting p2sh (legacy multisig) and p2pkh to have no policy set
|
||||
# skip change warning and psbt math view
|
||||
if psbt_parser.policy == None:
|
||||
return Destination(PSBTUnsupportedScriptTypeWarningView)
|
||||
|
||||
elif psbt_parser.change_amount == 0:
|
||||
return Destination(PSBTNoChangeWarningView)
|
||||
|
||||
else:
|
||||
return Destination(PSBTMathView)
|
||||
|
||||
|
||||
|
||||
class PSBTUnsupportedScriptTypeWarningView(View):
|
||||
def run(self):
|
||||
selected_menu_num = WarningScreen(
|
||||
status_headline="Unsupported Script Type!",
|
||||
text="PSBT has unsupported input script type, please verify your change addresses.",
|
||||
button_data=["Continue"],
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
# Only one exit point
|
||||
# skip PSBTMathView
|
||||
return Destination(
|
||||
PSBTAddressDetailsView, view_args={"address_num": 0},
|
||||
skip_current_view=True, # Prevent going BACK to WarningViews
|
||||
)
|
||||
|
||||
class PSBTNoChangeWarningView(View):
|
||||
def run(self):
|
||||
selected_menu_num = WarningScreen(
|
||||
status_headline="Full Spend!",
|
||||
text="This PSBT spends its entire input value. No change is coming back to your wallet.",
|
||||
button_data=["Continue"],
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
# Only one exit point
|
||||
return Destination(
|
||||
PSBTMathView,
|
||||
skip_current_view=True, # Prevent going BACK to WarningViews
|
||||
)
|
||||
|
||||
|
||||
|
||||
class PSBTMathView(View):
|
||||
"""
|
||||
Follows the Overview pictogram. Shows:
|
||||
+ total input value
|
||||
- recipients' value
|
||||
- fees
|
||||
-------------------
|
||||
+ change value
|
||||
"""
|
||||
def run(self):
|
||||
psbt_parser: PSBTParser = self.controller.psbt_parser
|
||||
if not psbt_parser:
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTMathScreen(
|
||||
input_amount=psbt_parser.input_amount,
|
||||
num_inputs=psbt_parser.num_inputs,
|
||||
spend_amount=psbt_parser.spend_amount,
|
||||
num_recipients=psbt_parser.num_destinations,
|
||||
fee_amount=psbt_parser.fee_amount,
|
||||
change_amount=psbt_parser.change_amount,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
if len(psbt_parser.destination_addresses) > 0:
|
||||
return Destination(PSBTAddressDetailsView, view_args={"address_num": 0})
|
||||
else:
|
||||
# This is a self-transfer
|
||||
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
|
||||
|
||||
|
||||
|
||||
class PSBTAddressDetailsView(View):
|
||||
"""
|
||||
Shows the recipient's address and amount they will receive
|
||||
"""
|
||||
def __init__(self, address_num, is_change=False):
|
||||
super().__init__()
|
||||
self.address_num = address_num
|
||||
|
||||
|
||||
def run(self):
|
||||
psbt_parser: PSBTParser = self.controller.psbt_parser
|
||||
|
||||
if not psbt_parser:
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
|
||||
title = "Will Send"
|
||||
if psbt_parser.num_destinations > 1:
|
||||
title += f" (#{self.address_num + 1})"
|
||||
|
||||
button_data = []
|
||||
if self.address_num < psbt_parser.num_destinations - 1:
|
||||
button_data.append("Next Recipient")
|
||||
else:
|
||||
button_data.append("Next")
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTAddressDetailsScreen(
|
||||
title=title,
|
||||
button_data=button_data,
|
||||
address=psbt_parser.destination_addresses[self.address_num],
|
||||
amount=psbt_parser.destination_amounts[self.address_num],
|
||||
).display()
|
||||
|
||||
if selected_menu_num == 0:
|
||||
if self.address_num < len(psbt_parser.destination_addresses) - 1:
|
||||
# Show the next receive addr
|
||||
return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1, "is_change": False})
|
||||
|
||||
elif psbt_parser.change_amount > 0:
|
||||
# Move on to display change
|
||||
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
|
||||
|
||||
else:
|
||||
# There's no change output to verify. Move on to sign the PSBT.
|
||||
return Destination(PSBTFinalizeView)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
|
||||
|
||||
class PSBTChangeDetailsView(View):
|
||||
"""
|
||||
"""
|
||||
def __init__(self, change_address_num):
|
||||
super().__init__()
|
||||
self.change_address_num = change_address_num
|
||||
|
||||
|
||||
def run(self):
|
||||
psbt_parser: PSBTParser = self.controller.psbt_parser
|
||||
|
||||
if not psbt_parser:
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
|
||||
# Can we verify this change addr?
|
||||
change_data = psbt_parser.get_change_data(change_num=self.change_address_num)
|
||||
"""
|
||||
change_data:
|
||||
{
|
||||
'address': 'bc1q............',
|
||||
'amount': 397621401,
|
||||
'fingerprint': ['22bde1a9', '73c5da0a'],
|
||||
'derivation_path': ['m/48h/1h/0h/2h/1/0', 'm/48h/1h/0h/2h/1/0']
|
||||
}
|
||||
"""
|
||||
|
||||
# Single-sig verification is easy. We expect to find a single fingerprint
|
||||
# and derivation path.
|
||||
seed_fingerprint = self.controller.psbt_seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
|
||||
|
||||
if seed_fingerprint not in change_data.get("fingerprint"):
|
||||
# TODO: Something is wrong with this psbt(?). Reroute to warning?
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
i = change_data.get("fingerprint").index(seed_fingerprint)
|
||||
derivation_path = change_data.get("derivation_path")[i]
|
||||
|
||||
# 'm/84h/1h/0h/1/0' would be a change addr while 'm/84h/1h/0h/0/0' is a self-receive
|
||||
is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
|
||||
derivation_path_addr_index = int(derivation_path.split("/")[-1])
|
||||
|
||||
NEXT = "Next"
|
||||
|
||||
if is_change_derivation_path:
|
||||
title = "Your Change"
|
||||
VERIFY_MULTISIG = "Verify Multisig Change"
|
||||
else:
|
||||
title = "Self-Transfer"
|
||||
VERIFY_MULTISIG = "Verify Multisig Addr"
|
||||
# if psbt_parser.num_change_outputs > 1:
|
||||
# title += f" (#{self.change_address_num + 1})"
|
||||
|
||||
is_change_addr_verified = False
|
||||
if psbt_parser.is_multisig:
|
||||
# if the known-good multisig descriptor is already onboard:
|
||||
if self.controller.multisig_wallet_descriptor:
|
||||
is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num)
|
||||
button_data = [NEXT]
|
||||
|
||||
else:
|
||||
# Have the Screen offer to load in the multisig descriptor.
|
||||
button_data = [VERIFY_MULTISIG, NEXT]
|
||||
|
||||
else:
|
||||
# Single sig
|
||||
try:
|
||||
if is_change_derivation_path:
|
||||
loading_screen_text = "Verifying Change..."
|
||||
else:
|
||||
loading_screen_text = "Verifying Self-Transfer..."
|
||||
loading_screen = LoadingScreenThread(text=loading_screen_text)
|
||||
loading_screen.start()
|
||||
|
||||
# convert change address to script pubkey to get script type
|
||||
pubkey = script.address_to_scriptpubkey(change_data["address"])
|
||||
script_type = pubkey.script_type()
|
||||
|
||||
# extract derivation path to get wallet and change derivation
|
||||
change_path = '/'.join(derivation_path.split("/")[-2:])
|
||||
wallet_path = '/'.join(derivation_path.split("/")[:-2])
|
||||
|
||||
xpub = self.controller.psbt_seed.get_xpub(
|
||||
wallet_path=wallet_path,
|
||||
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)
|
||||
)
|
||||
|
||||
# take script type and call script method to generate address from seed / derivation
|
||||
xpub_key = xpub.derive(change_path).key
|
||||
network = self.settings.get_value(SettingsConstants.SETTING__NETWORK)
|
||||
scriptcall = getattr(script, script_type)
|
||||
if script_type == "p2sh":
|
||||
# single sig only so p2sh is always p2sh-p2wpkh
|
||||
calc_address = script.p2sh(script.p2wpkh(xpub_key)).address(
|
||||
network=NETWORKS[SettingsConstants.map_network_to_embit(network)]
|
||||
)
|
||||
else:
|
||||
# single sig so this handles p2wpkh and p2wpkh (and p2tr in the future)
|
||||
calc_address = scriptcall(xpub_key).address(
|
||||
network=NETWORKS[SettingsConstants.map_network_to_embit(network)]
|
||||
)
|
||||
|
||||
if change_data["address"] == calc_address:
|
||||
is_change_addr_verified = True
|
||||
button_data = [NEXT]
|
||||
|
||||
finally:
|
||||
loading_screen.stop()
|
||||
|
||||
if is_change_addr_verified == False and (not psbt_parser.is_multisig or self.controller.multisig_wallet_descriptor is not None):
|
||||
return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path, is_multisig=psbt_parser.is_multisig), clear_history=True)
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTChangeDetailsScreen(
|
||||
title=title,
|
||||
button_data=button_data,
|
||||
address=change_data.get("address"),
|
||||
amount=change_data.get("amount"),
|
||||
is_multisig=psbt_parser.is_multisig,
|
||||
fingerprint=seed_fingerprint,
|
||||
derivation_path=derivation_path,
|
||||
is_change_derivation_path=is_change_derivation_path,
|
||||
derivation_path_addr_index=derivation_path_addr_index,
|
||||
is_change_addr_verified=is_change_addr_verified,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == NEXT:
|
||||
if self.change_address_num < psbt_parser.num_change_outputs - 1:
|
||||
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1})
|
||||
else:
|
||||
# There's no more change to verify. Move on to sign the PSBT.
|
||||
return Destination(PSBTFinalizeView)
|
||||
|
||||
elif button_data[selected_menu_num] == VERIFY_MULTISIG:
|
||||
from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView
|
||||
self.controller.resume_main_flow = Controller.FLOW__PSBT
|
||||
return Destination(LoadMultisigWalletDescriptorView)
|
||||
|
||||
|
||||
|
||||
class PSBTAddressVerificationFailedView(View):
|
||||
def __init__(self, is_change: bool = True, is_multisig: bool = False):
|
||||
super().__init__()
|
||||
self.is_change = is_change
|
||||
self.is_multisig = is_multisig
|
||||
|
||||
|
||||
def run(self):
|
||||
if self.is_multisig:
|
||||
title = "Caution"
|
||||
text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be verified with your multisig wallet descriptor."""
|
||||
else:
|
||||
title = "Suspicious PSBT"
|
||||
text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be generated from your seed."""
|
||||
|
||||
DireWarningScreen(
|
||||
title=title,
|
||||
status_headline="Address Verification Failed",
|
||||
text=text,
|
||||
button_data=["Discard PSBT"],
|
||||
show_back_button=False,
|
||||
).display()
|
||||
|
||||
# Clear out the bad PSBT
|
||||
self.controller.psbt = None
|
||||
self.controller.psbt_parser = None
|
||||
self.controller.psbt_seed = None
|
||||
|
||||
return Destination(MainMenuView, clear_history=True)
|
||||
|
||||
|
||||
|
||||
class PSBTFinalizeView(View):
|
||||
"""
|
||||
"""
|
||||
def run(self):
|
||||
psbt_parser: PSBTParser = self.controller.psbt_parser
|
||||
psbt: PSBT = self.controller.psbt
|
||||
|
||||
if not psbt_parser:
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTFinalizeScreen(
|
||||
button_data=["Approve PSBT"]
|
||||
).display()
|
||||
|
||||
if selected_menu_num == 0:
|
||||
# Sign PSBT
|
||||
loading_screen = LoadingScreenThread(text="Signing PSBT...")
|
||||
loading_screen.start()
|
||||
|
||||
try:
|
||||
sig_cnt = PSBTParser.sig_count(psbt)
|
||||
psbt.sign_with(psbt_parser.root)
|
||||
trimmed_psbt = PSBTParser.trim(psbt)
|
||||
finally:
|
||||
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,126 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from embit.descriptor import Descriptor
|
||||
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
|
||||
from seedsigner.models import DecodeQR, Seed
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
|
||||
|
||||
|
||||
|
||||
class ScanView(View):
|
||||
def run(self):
|
||||
from seedsigner.gui.screens.scan_screens import ScanScreen
|
||||
|
||||
# Run the live preview and QR code capture process
|
||||
# TODO: Does this belong in its own BaseThread?
|
||||
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
|
||||
self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code)
|
||||
screen = ScanScreen(decoder=self.decoder)
|
||||
screen.display()
|
||||
|
||||
if self.decoder.is_complete:
|
||||
if self.decoder.is_seed:
|
||||
seed_mnemonic = self.decoder.get_seed_phrase()
|
||||
if not seed_mnemonic:
|
||||
# seed is not valid, Exit if not valid with message
|
||||
raise Exception("Not yet implemented!")
|
||||
else:
|
||||
# Found a valid mnemonic seed! All new seeds should be considered
|
||||
# pending (might set a passphrase, SeedXOR, etc) until finalized.
|
||||
from .seed_views import SeedFinalizeView
|
||||
self.controller.storage.set_pending_seed(
|
||||
Seed(mnemonic=seed_mnemonic, wordlist_language_code=wordlist_language_code)
|
||||
)
|
||||
if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED:
|
||||
from seedsigner.views.seed_views import SeedAddPassphraseView
|
||||
return Destination(SeedAddPassphraseView)
|
||||
else:
|
||||
return Destination(SeedFinalizeView)
|
||||
|
||||
elif self.decoder.is_psbt:
|
||||
from seedsigner.views.psbt_views import PSBTSelectSeedView
|
||||
psbt = self.decoder.get_psbt()
|
||||
self.controller.psbt = psbt
|
||||
self.controller.psbt_parser = None
|
||||
return Destination(PSBTSelectSeedView)
|
||||
|
||||
elif self.decoder.is_settings:
|
||||
from seedsigner.models.settings import Settings
|
||||
settings = self.decoder.get_settings_data()
|
||||
Settings.get_instance().update(new_settings=settings)
|
||||
|
||||
print(json.dumps(Settings.get_instance()._data, indent=4))
|
||||
|
||||
return Destination(SettingsUpdatedView, {"config_name": self.decoder.get_settings_config_name()})
|
||||
|
||||
elif self.decoder.is_wallet_descriptor:
|
||||
from seedsigner.views.seed_views import MultisigWalletDescriptorView
|
||||
descriptor_str = self.decoder.get_wallet_descriptor()
|
||||
|
||||
try:
|
||||
# We need to replace `/0/*` wildcards with `/{0,1}/*` in order to use
|
||||
# the Descriptor to verify change, too.
|
||||
orig_descriptor_str = descriptor_str
|
||||
if len(re.findall (r'\[([0-9,a-f,A-F]+?)(\/[0-9,\/,h\']+?)\].*?(\/0\/\*)', descriptor_str)) > 0:
|
||||
p = re.compile(r'(\[[0-9,a-f,A-F]+?\/[0-9,\/,h\']+?\].*?)(\/0\/\*)')
|
||||
descriptor_str = p.sub(r'\1/{0,1}/*', descriptor_str)
|
||||
elif len(re.findall (r'(\[[0-9,a-f,A-F]+?\/[0-9,\/,h,\']+?\][a-z,A-Z,0-9]*?)([\,,\)])', descriptor_str)) > 0:
|
||||
p = re.compile(r'(\[[0-9,a-f,A-F]+?\/[0-9,\/,h,\']+?\][a-z,A-Z,0-9]*?)([\,,\)])')
|
||||
descriptor_str = p.sub(r'\1/{0,1}/*\2', descriptor_str)
|
||||
except Exception as e:
|
||||
print(repr(e))
|
||||
descriptor_str = orig_descriptor_str
|
||||
|
||||
descriptor = Descriptor.from_string(descriptor_str)
|
||||
|
||||
if not descriptor.is_basic_multisig:
|
||||
# TODO: Handle single-sig descriptors?
|
||||
print(f"Received single sig descriptor: {descriptor}")
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
self.controller.multisig_wallet_descriptor = descriptor
|
||||
return Destination(MultisigWalletDescriptorView)
|
||||
|
||||
elif self.decoder.is_address:
|
||||
from seedsigner.views.seed_views import AddressVerificationStartView
|
||||
address = self.decoder.get_address()
|
||||
(script_type, network) = self.decoder.get_address_type()
|
||||
|
||||
return Destination(
|
||||
AddressVerificationStartView,
|
||||
view_args={
|
||||
"address": address,
|
||||
"script_type": script_type,
|
||||
"network": network,
|
||||
}
|
||||
)
|
||||
|
||||
else:
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
return Destination(MainMenuView)
|
||||
|
||||
|
||||
|
||||
class SettingsUpdatedView(View):
|
||||
def __init__(self, config_name: str):
|
||||
super().__init__()
|
||||
|
||||
self.config_name = config_name
|
||||
|
||||
|
||||
def run(self):
|
||||
from seedsigner.gui.screens.scan_screens import SettingsUpdatedScreen
|
||||
screen = SettingsUpdatedScreen(config_name=self.config_name)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
# Only one exit point
|
||||
return Destination(MainMenuView)
|
||||
|
||||
@@ -2,47 +2,95 @@ import os
|
||||
import random
|
||||
import time
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL import Image
|
||||
|
||||
from . import View
|
||||
|
||||
from seedsigner.helpers import B
|
||||
from seedsigner.gui.components import Fonts, GUIConstants, load_image
|
||||
from seedsigner.gui.screens.screen import BaseScreen
|
||||
from seedsigner.models.settings import Settings
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
|
||||
|
||||
class LogoView(View):
|
||||
|
||||
# TODO: This early code is now outdated vis-a-vis Screen vs View distinctions
|
||||
class LogoScreen(BaseScreen):
|
||||
def __init__(self):
|
||||
dirname = os.path.dirname(__file__)
|
||||
logo_url = os.path.join(dirname, "../../", "seedsigner", "resources", "logo_black_240.png")
|
||||
self.logo = Image.open(logo_url)
|
||||
super().__init__()
|
||||
self.logo = load_image("logo_black_240.png")
|
||||
|
||||
self.partners = [
|
||||
"hrf",
|
||||
]
|
||||
|
||||
self.partner_logos: dict = {}
|
||||
for partner in self.partners:
|
||||
logo_url = os.path.join("partners", f"{partner}_logo.png")
|
||||
self.partner_logos[partner] = load_image(logo_url)
|
||||
|
||||
|
||||
def get_random_partner(self) -> str:
|
||||
return self.partners[random.randrange(len(self.partners))]
|
||||
|
||||
|
||||
|
||||
class OpeningSplashView(LogoView):
|
||||
class OpeningSplashScreen(LogoScreen):
|
||||
def start(self):
|
||||
from seedsigner.controller import Controller
|
||||
controller = Controller.get_instance()
|
||||
|
||||
show_partner_logos = Settings.get_instance().get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
|
||||
|
||||
if show_partner_logos:
|
||||
logo_offset_y = -56
|
||||
else:
|
||||
logo_offset_y = 0
|
||||
|
||||
# Fade in alpha
|
||||
for i in range(250, -1, -25):
|
||||
self.logo.putalpha(255 - i)
|
||||
background = Image.new("RGBA", self.logo.size, (0,0,0))
|
||||
View.disp.ShowImage(Image.alpha_composite(background, self.logo), 0, 0)
|
||||
background = Image.new("RGBA", size=self.logo.size, color="black")
|
||||
self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (0, logo_offset_y))
|
||||
self.renderer.show_image()
|
||||
|
||||
# Display version num and hold for a few seconds
|
||||
font = View.ROBOTOCONDENSED_REGULAR_22
|
||||
# Display version num below SeedSigner logo
|
||||
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.TOP_NAV_TITLE_FONT_SIZE)
|
||||
version = f"v{controller.VERSION}"
|
||||
tw, th = font.getsize(version)
|
||||
x = int((View.canvas_width - tw) / 2)
|
||||
y = int(View.canvas_height / 2) + 40
|
||||
(left, top, version_tw, version_th) = font.getbbox(version, anchor="lt")
|
||||
|
||||
draw = ImageDraw.Draw(self.logo)
|
||||
draw.text((x, y), version, fill="orange", font=font)
|
||||
View.DispShowImage(self.logo)
|
||||
time.sleep(3)
|
||||
# The logo png is 240x240, but the actual logo is 70px tall, vertically centered
|
||||
version_x = int(self.renderer.canvas_width/2)
|
||||
version_y = int(self.canvas_height/2) + 35 + logo_offset_y + GUIConstants.COMPONENT_PADDING
|
||||
self.renderer.draw.text(xy=(version_x, version_y), text=version, font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt")
|
||||
self.renderer.show_image()
|
||||
|
||||
if show_partner_logos:
|
||||
# Hold on the version num for a moment
|
||||
time.sleep(1)
|
||||
|
||||
# Set up the partner logo
|
||||
partner_logo: Image.Image = self.partner_logos[self.get_random_partner()]
|
||||
font = Fonts.get_font(GUIConstants.TOP_NAV_TITLE_FONT_NAME, GUIConstants.BODY_FONT_SIZE)
|
||||
sponsor_text = "With support from:"
|
||||
(left, top, tw, th) = font.getbbox(sponsor_text, anchor="lt")
|
||||
|
||||
x = int((self.renderer.canvas_width) / 2)
|
||||
y = self.canvas_height - GUIConstants.COMPONENT_PADDING - partner_logo.height - int(GUIConstants.COMPONENT_PADDING/2) - th
|
||||
self.renderer.draw.text(xy=(x, y), text=sponsor_text, font=font, fill="#ccc", anchor="mt")
|
||||
self.renderer.canvas.paste(
|
||||
partner_logo,
|
||||
(
|
||||
int((self.renderer.canvas_width - partner_logo.width) / 2),
|
||||
y + th + int(GUIConstants.COMPONENT_PADDING/2)
|
||||
)
|
||||
)
|
||||
|
||||
self.renderer.show_image()
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
|
||||
class ScreensaverView(LogoView):
|
||||
|
||||
class ScreensaverScreen(LogoScreen):
|
||||
def __init__(self, buttons):
|
||||
super().__init__()
|
||||
|
||||
@@ -55,7 +103,6 @@ class ScreensaverView(LogoView):
|
||||
self.min_coords = (0, 0)
|
||||
self.max_coords = (self.logo.size[0], self.logo.size[1])
|
||||
|
||||
max_increment = 25
|
||||
self.increment_x = self.rand_increment()
|
||||
self.increment_y = self.rand_increment()
|
||||
self.cur_x = int(self.logo.size[0] / 2)
|
||||
@@ -65,7 +112,6 @@ class ScreensaverView(LogoView):
|
||||
self.last_screen = None
|
||||
|
||||
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
return self._is_running
|
||||
@@ -87,49 +133,62 @@ class ScreensaverView(LogoView):
|
||||
self._is_running = True
|
||||
|
||||
# Store the current screen in order to restore it later
|
||||
self.last_screen = View.canvas.copy()
|
||||
self.last_screen = self.renderer.canvas.copy()
|
||||
|
||||
screensaver_start = int(time.time() * 1000)
|
||||
|
||||
while True:
|
||||
if self.buttons.has_any_input():
|
||||
return self.stop()
|
||||
# Screensaver must block any attempts to use the Renderer in another thread so it
|
||||
# never gives up the lock until it returns.
|
||||
with self.renderer.lock:
|
||||
try:
|
||||
while True:
|
||||
if self.buttons.has_any_input():
|
||||
return self.stop()
|
||||
|
||||
# Must crop the image to the exact display size
|
||||
crop = self.image.crop((
|
||||
self.cur_x, self.cur_y,
|
||||
self.cur_x + View.canvas_width, self.cur_y + View.canvas_height))
|
||||
View.disp.ShowImage(crop, 0, 0)
|
||||
# Must crop the image to the exact display size
|
||||
crop = self.image.crop((
|
||||
self.cur_x, self.cur_y,
|
||||
self.cur_x + self.renderer.canvas_width, self.cur_y + self.renderer.canvas_height))
|
||||
self.renderer.disp.ShowImage(crop, 0, 0)
|
||||
|
||||
self.cur_x += self.increment_x
|
||||
self.cur_y += self.increment_y
|
||||
self.cur_x += self.increment_x
|
||||
self.cur_y += self.increment_y
|
||||
|
||||
if self.cur_x < self.min_coords[0]:
|
||||
self.cur_x = self.min_coords[0]
|
||||
self.increment_x = self.rand_increment()
|
||||
if self.increment_x < 0.0:
|
||||
self.increment_x *= -1.0
|
||||
elif self.cur_x > self.max_coords[0]:
|
||||
self.cur_x = self.max_coords[0]
|
||||
self.increment_x = self.rand_increment()
|
||||
if self.increment_x > 0.0:
|
||||
self.increment_x *= -1.0
|
||||
# At each edge bump, calculate a new random rate of change for that axis
|
||||
if self.cur_x < self.min_coords[0]:
|
||||
self.cur_x = self.min_coords[0]
|
||||
self.increment_x = self.rand_increment()
|
||||
if self.increment_x < 0.0:
|
||||
self.increment_x *= -1.0
|
||||
elif self.cur_x > self.max_coords[0]:
|
||||
self.cur_x = self.max_coords[0]
|
||||
self.increment_x = self.rand_increment()
|
||||
if self.increment_x > 0.0:
|
||||
self.increment_x *= -1.0
|
||||
|
||||
if self.cur_y < self.min_coords[1]:
|
||||
self.cur_y = self.min_coords[1]
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y < 0.0:
|
||||
self.increment_y *= -1.0
|
||||
elif self.cur_y > self.max_coords[1]:
|
||||
self.cur_y = self.max_coords[1]
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y > 0.0:
|
||||
self.increment_y *= -1.0
|
||||
except KeyboardInterrupt as e:
|
||||
# Exit triggered; close gracefully
|
||||
print("Shutting down Screensaver")
|
||||
self.stop()
|
||||
|
||||
# Have to let the interrupt bubble up to exit the main app
|
||||
raise e
|
||||
|
||||
if self.cur_y < self.min_coords[1]:
|
||||
self.cur_y = self.min_coords[1]
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y < 0.0:
|
||||
self.increment_y *= -1.0
|
||||
elif self.cur_y > self.max_coords[1]:
|
||||
self.cur_y = self.max_coords[1]
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y > 0.0:
|
||||
self.increment_y *= -1.0
|
||||
|
||||
|
||||
def stop(self):
|
||||
# Restore the original screen
|
||||
View.DispShowImage(self.last_screen)
|
||||
self.renderer.show_image(self.last_screen)
|
||||
|
||||
self._is_running = False
|
||||
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
# SeedSigner file class dependencies
|
||||
from . import View
|
||||
from seedsigner.helpers import B, QR, Keyboard, TextEntryDisplay
|
||||
from seedsigner.models import EncodeQRDensity
|
||||
|
||||
class SettingsToolsView(View):
|
||||
|
||||
def __init__(self) -> None:
|
||||
View.__init__(self)
|
||||
|
||||
self.qr = QR()
|
||||
self.donate_image = None
|
||||
self.derivation = None
|
||||
|
||||
### Donate Menu Item
|
||||
|
||||
def display_donate_info_screen(self):
|
||||
self.draw_modal(["You can support", "SeedSigner by donating", "any amount of BTC", "Thank You!!!"], "", "(Press right for a QR code)")
|
||||
return True
|
||||
|
||||
def display_donate_qr(self):
|
||||
self.draw_modal(["Loading..."])
|
||||
self.donate_image = self.qr.qrimage("bc1qphlyv2dde290tqdlnk8uswztnshw3x9rjurexqqhksvu7vdevhtsuw4efe")
|
||||
View.DispShowImage(self.donate_image)
|
||||
return True
|
||||
|
||||
### Display Network Selection
|
||||
|
||||
def display_current_network(self) -> str:
|
||||
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Mainnet", "Testnet"], "Which Network?")
|
||||
if r == 2:
|
||||
return "main"
|
||||
elif r == 3:
|
||||
return "test"
|
||||
else:
|
||||
return None
|
||||
|
||||
### Display Wallet Selection
|
||||
|
||||
def display_wallet_selection(self) -> str:
|
||||
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Prompt", "Specter Desktop", "Blue Wallet", "Sparrow"], "Which Wallet?")
|
||||
if r == 2:
|
||||
return "Prompt"
|
||||
elif r == 3:
|
||||
return "Specter Desktop"
|
||||
elif r == 4:
|
||||
return "Blue Wallet"
|
||||
elif r == 5:
|
||||
return "Sparrow"
|
||||
else:
|
||||
return None
|
||||
|
||||
### Display QR Density Selection
|
||||
|
||||
def display_qr_density_selection(self) -> str:
|
||||
r = self.controller.menu_view.display_generic_selection_menu(["... [ Return to Settings ]", "Low", "Medium", "High"], "Which QR Density?")
|
||||
if r == 2:
|
||||
return EncodeQRDensity.LOW
|
||||
elif r == 3:
|
||||
return EncodeQRDensity.MEDIUM
|
||||
elif r == 4:
|
||||
return EncodeQRDensity.HIGH
|
||||
else:
|
||||
return None
|
||||
|
||||
def display_persistent_settings(self) -> bool:
|
||||
lines = ["... [ Return to Settings ]"]
|
||||
lines.append("Yes")
|
||||
lines.append("No")
|
||||
|
||||
r = self.controller.menu_view.display_generic_selection_menu(lines, "Use Persistent Settings?")
|
||||
if r == 1:
|
||||
return None
|
||||
elif r == 2:
|
||||
return True
|
||||
elif r == 3:
|
||||
return False
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def display_camera_rotation(self) -> int:
|
||||
lines = ["... [ Return to Settings ]"]
|
||||
lines.append("0° (default)")
|
||||
lines.append("90°")
|
||||
lines.append("180°")
|
||||
lines.append("270°")
|
||||
|
||||
r = self.controller.menu_view.display_generic_selection_menu(lines, "Camera Rotation")
|
||||
if r == 1:
|
||||
return None
|
||||
elif r == 2:
|
||||
return 0
|
||||
elif r == 3:
|
||||
return 90
|
||||
elif r == 4:
|
||||
return 180
|
||||
elif r == 5:
|
||||
return 270
|
||||
else:
|
||||
return None
|
||||
|
||||
###
|
||||
### Version Info
|
||||
###
|
||||
|
||||
def display_version_info(self):
|
||||
|
||||
line1 = "SeedSigner"
|
||||
line2 = "Version v" + self.controller.VERSION
|
||||
line3 = "(Joystick RIGHT to EXIT)"
|
||||
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
tw, th = View.draw.textsize(line1, font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 20), line1, fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(line2, font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 55), line2, fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(line3, font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), line3, fill=View.color, font=View.ASSISTANT18)
|
||||
View.DispShowImage()
|
||||
|
||||
###
|
||||
### Custom Derivation Path
|
||||
###
|
||||
def draw_derivation_keyboard_entry(self, existing_derivation = "m/"):
|
||||
def render_right_panel():
|
||||
row_height = 28
|
||||
right_button_left_margin = 10
|
||||
right_button_width = 60
|
||||
font_padding_right = 2
|
||||
font_padding_top = 1
|
||||
key_x = View.canvas_width - right_button_width
|
||||
key_y = int(View.canvas_height - row_height) / 2 - 1 - 60
|
||||
font = View.ROBOTOCONDENSED_BOLD_24
|
||||
background_color = "#111"
|
||||
font_color = View.color
|
||||
button3_text = "Save"
|
||||
tw, th = font.getsize(button3_text)
|
||||
key_y = int(View.canvas_height - row_height) / 2 - 1 + 60
|
||||
View.draw.rounded_rectangle((key_x, key_y, 250, key_y + row_height), outline=View.color, fill=background_color, radius=5, width=1)
|
||||
View.draw.text((View.canvas_width - tw - font_padding_right, key_y + font_padding_top), font=font, text=button3_text, fill=font_color)
|
||||
|
||||
# Clear the screen
|
||||
View.draw.rectangle((0,0, View.canvas_width,View.canvas_height), fill="black")
|
||||
|
||||
self.render_previous_button()
|
||||
previous_button_is_active = False
|
||||
|
||||
# Have to ensure that we don't carry any effects from a previous run
|
||||
# TODO: This shouldn't be a member var
|
||||
if existing_derivation:
|
||||
self.derivation = existing_derivation
|
||||
else:
|
||||
self.derivation = "m/"
|
||||
|
||||
# Set up the keyboard params
|
||||
right_panel_buttons_width = 60
|
||||
|
||||
# render top title banner
|
||||
font = View.ROBOTOCONDENSED_REGULAR_20
|
||||
title = "Enter Derivation"
|
||||
title_top_padding = 0
|
||||
title_bottom_padding = 10
|
||||
tw, th = font.getsize(title)
|
||||
View.draw.text((int(View.canvas_width - tw) / 2, title_top_padding), text=title, font=font, fill=View.color)
|
||||
title_height = th + title_top_padding + title_bottom_padding
|
||||
|
||||
# Render the live text entry display
|
||||
font = View.ROBOTOCONDENSED_REGULAR_28
|
||||
tw, th = font.getsize("m/1234567890") # All possible chars for max range
|
||||
text_entry_side_padding = 0
|
||||
text_entry_top_padding = 1
|
||||
text_entry_bottom_padding = 10
|
||||
text_entry_top_y = title_height + text_entry_top_padding
|
||||
text_entry_bottom_y = text_entry_top_y + 3 + th + 3
|
||||
text_entry_display = TextEntryDisplay(
|
||||
View.draw,
|
||||
rect=(text_entry_side_padding,text_entry_top_y, View.canvas_width - right_panel_buttons_width - 1, text_entry_bottom_y),
|
||||
font=font,
|
||||
font_color=View.color,
|
||||
cursor_mode=TextEntryDisplay.CURSOR_MODE__BLOCK,
|
||||
is_centered=False,
|
||||
has_outline=True,
|
||||
cur_text=''.join(self.derivation)
|
||||
)
|
||||
text_entry_display.render()
|
||||
cursor_position = len(self.derivation)
|
||||
|
||||
keyboard_start_y = text_entry_bottom_y + text_entry_bottom_padding
|
||||
keyboard_digits = Keyboard(
|
||||
View.draw,
|
||||
charset="/'0123456789",
|
||||
rows=3,
|
||||
cols=6,
|
||||
rect=(0, keyboard_start_y, View.canvas_width - right_panel_buttons_width, View.canvas_height),
|
||||
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
|
||||
render_now=False
|
||||
)
|
||||
keyboard_digits.set_selected_key(selected_letter="/")
|
||||
keyboard_digits.render_keys()
|
||||
render_right_panel()
|
||||
|
||||
text_entry_display.render(self.derivation)
|
||||
View.DispShowImage()
|
||||
|
||||
# Start the interactive update loop
|
||||
while True:
|
||||
input = View.buttons.wait_for(
|
||||
[B.KEY_UP, B.KEY_DOWN, B.KEY_RIGHT, B.KEY_LEFT, B.KEY_PRESS, B.KEY3],
|
||||
check_release=True,
|
||||
release_keys=[B.KEY_PRESS, B.KEY3]
|
||||
)
|
||||
|
||||
# Check our two possible exit conditions
|
||||
if input == B.KEY3:
|
||||
# Save!
|
||||
if len(self.derivation) > 0:
|
||||
return self.derivation.strip()
|
||||
|
||||
elif input == B.KEY_PRESS and previous_button_is_active:
|
||||
# Prev button clicked; return empty string to signal cancel.
|
||||
return ""
|
||||
|
||||
# Process normal input
|
||||
if input in [B.KEY_UP, B.KEY_DOWN] and previous_button_is_active:
|
||||
# We're navigating off the previous button
|
||||
previous_button_is_active = False
|
||||
self.render_previous_button(highlight=False)
|
||||
|
||||
# Override the actual input w/an ENTER signal for the Keyboard
|
||||
if input == B.KEY_DOWN:
|
||||
input = Keyboard.ENTER_TOP
|
||||
else:
|
||||
input = Keyboard.ENTER_BOTTOM
|
||||
elif input in [B.KEY_LEFT, B.KEY_RIGHT] and previous_button_is_active:
|
||||
# ignore
|
||||
continue
|
||||
|
||||
ret_val = keyboard_digits.update_from_input(input)
|
||||
|
||||
# Now process the result from the keyboard
|
||||
if ret_val in Keyboard.EXIT_DIRECTIONS:
|
||||
self.render_previous_button(highlight=True)
|
||||
previous_button_is_active = True
|
||||
|
||||
elif ret_val in Keyboard.ADDITIONAL_KEYS and input == B.KEY_PRESS:
|
||||
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
|
||||
if len(self.derivation) <= 2:
|
||||
pass
|
||||
elif cursor_position == len(self.derivation):
|
||||
self.derivation = self.derivation[:-1]
|
||||
cursor_position -= 1
|
||||
else:
|
||||
self.derivation = self.derivation[:cursor_position - 1] + self.derivation[cursor_position:]
|
||||
cursor_position -= 1
|
||||
|
||||
elif input == B.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
|
||||
# User has locked in the current letter
|
||||
if cursor_position == len(self.derivation):
|
||||
self.derivation += ret_val
|
||||
else:
|
||||
self.derivation = self.derivation[:cursor_position] + ret_val + self.derivation[cursor_position:]
|
||||
cursor_position += 1
|
||||
|
||||
elif input in [B.KEY_RIGHT, B.KEY_LEFT, B.KEY_UP, B.KEY_DOWN]:
|
||||
# Live joystick movement; haven't locked this new letter in yet.
|
||||
# Leave current spot blank for now. Only update the active keyboard keys
|
||||
# when a selection has been locked in (KEY_PRESS) or removed ("del").
|
||||
pass
|
||||
|
||||
# Render the text entry display and cursor block
|
||||
text_entry_display.render(self.derivation)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
|
||||
def display_compact_seedqr_enabled(self) -> bool:
|
||||
lines = ["... [ Return to Settings ]"]
|
||||
lines.append("Enable")
|
||||
lines.append("Disable")
|
||||
|
||||
r = self.controller.menu_view.display_generic_selection_menu(lines, "Compact SeedQR")
|
||||
if r == 1:
|
||||
return None
|
||||
elif r == 2:
|
||||
return True
|
||||
elif r == 3:
|
||||
return False
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,191 @@
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
|
||||
from seedsigner.models.decode_qr import DecodeQR
|
||||
|
||||
from .view import View, Destination, BackStackView, MainMenuView
|
||||
|
||||
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
|
||||
from seedsigner.models.settings import SettingsConstants, SettingsDefinition
|
||||
|
||||
|
||||
|
||||
class SettingsMenuView(View):
|
||||
def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None):
|
||||
super().__init__()
|
||||
self.visibility = visibility
|
||||
self.selected_attr = selected_attr
|
||||
|
||||
|
||||
def run(self):
|
||||
IO_TEST = "I/O test"
|
||||
DONATE = "Donate"
|
||||
|
||||
settings_entries = SettingsDefinition.get_settings_entries(
|
||||
visibiilty=self.visibility
|
||||
)
|
||||
button_data=[e.display_name for e in settings_entries]
|
||||
|
||||
selected_button = 0
|
||||
if self.selected_attr:
|
||||
for i, entry in enumerate(settings_entries):
|
||||
if entry.attr_name == self.selected_attr:
|
||||
selected_button = i
|
||||
break
|
||||
|
||||
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
|
||||
title = "Settings"
|
||||
|
||||
# Set up the next nested level of menuing
|
||||
button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
|
||||
next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
|
||||
|
||||
button_data.append(IO_TEST)
|
||||
button_data.append(DONATE)
|
||||
|
||||
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
|
||||
title = "Advanced"
|
||||
|
||||
# So far there are no real Developer options; disabling for now
|
||||
# button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
|
||||
# next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER})
|
||||
next = None
|
||||
|
||||
elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER:
|
||||
title = "Dev Options"
|
||||
next = None
|
||||
|
||||
selected_menu_num = ButtonListScreen(
|
||||
title=title,
|
||||
is_button_text_centered=False,
|
||||
button_data=button_data,
|
||||
selected_button=selected_button,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
|
||||
return Destination(MainMenuView)
|
||||
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
|
||||
return Destination(SettingsMenuView)
|
||||
else:
|
||||
return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
|
||||
|
||||
elif selected_menu_num == len(settings_entries):
|
||||
return next
|
||||
|
||||
elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == IO_TEST:
|
||||
return Destination(IOTestView)
|
||||
|
||||
elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == DONATE:
|
||||
return Destination(DonateView)
|
||||
|
||||
else:
|
||||
# TODO: Free-entry types (are there any?) will need their own SettingsEntryUpdateFreeEntryView(?).
|
||||
return Destination(SettingsEntryUpdateSelectionView, view_args={"attr_name": settings_entries[selected_menu_num].attr_name})
|
||||
|
||||
|
||||
|
||||
class SettingsEntryUpdateSelectionView(View):
|
||||
"""
|
||||
Handles changes to all selection-type settings (Multiselect, SELECT_1,
|
||||
Enabled/Disabled, etc).
|
||||
"""
|
||||
def __init__(self, attr_name: str):
|
||||
super().__init__()
|
||||
self.settings_entry = SettingsDefinition.get_settings_entry(attr_name)
|
||||
self.selected_button = None
|
||||
|
||||
|
||||
def run(self):
|
||||
initial_value = self.settings.get_value(self.settings_entry.attr_name)
|
||||
button_data = []
|
||||
checked_buttons = []
|
||||
for i, value in enumerate(self.settings_entry.selection_options):
|
||||
if type(value) == tuple:
|
||||
value, display_name = value
|
||||
else:
|
||||
display_name = value
|
||||
button_data.append(display_name)
|
||||
if (type(initial_value) == list and value in initial_value) or value == initial_value:
|
||||
checked_buttons.append(i)
|
||||
|
||||
if self.selected_button is None:
|
||||
# Highlight the selection (for multiselect highlight the first
|
||||
# selected option).
|
||||
self.selected_button = i
|
||||
|
||||
if not self.selected_button:
|
||||
self.selected_button = 0
|
||||
|
||||
ret_value = settings_screens.SettingsEntryUpdateSelectionScreen(
|
||||
display_name=self.settings_entry.display_name,
|
||||
help_text=self.settings_entry.help_text,
|
||||
button_data=button_data,
|
||||
selected_button=self.selected_button,
|
||||
checked_buttons=checked_buttons,
|
||||
settings_entry_type=self.settings_entry.type,
|
||||
).display()
|
||||
|
||||
destination = None
|
||||
settings_menu_view_destination = Destination(
|
||||
SettingsMenuView,
|
||||
view_args={
|
||||
"visibility": self.settings_entry.visibility,
|
||||
"selected_attr": self.settings_entry.attr_name
|
||||
}
|
||||
)
|
||||
|
||||
if ret_value == RET_CODE__BACK_BUTTON:
|
||||
return settings_menu_view_destination
|
||||
|
||||
value = self.settings_entry.get_selection_option_value(ret_value)
|
||||
|
||||
if self.settings_entry.type == SettingsConstants.TYPE__FREE_ENTRY:
|
||||
updated_value = ret_value
|
||||
destination = settings_menu_view_destination
|
||||
|
||||
elif self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT:
|
||||
updated_value = list(initial_value)
|
||||
if ret_value not in checked_buttons:
|
||||
# This is a new selection to add
|
||||
updated_value.append(value)
|
||||
else:
|
||||
# This is a de-select to remove
|
||||
updated_value.remove(value)
|
||||
|
||||
else:
|
||||
# All other types are single selects (e.g. Enabled/Disabled, SELECT_1)
|
||||
if value == initial_value:
|
||||
# No change, return to menu
|
||||
return settings_menu_view_destination
|
||||
else:
|
||||
updated_value = value
|
||||
|
||||
self.settings.set_value(
|
||||
attr_name=self.settings_entry.attr_name,
|
||||
value=updated_value
|
||||
)
|
||||
|
||||
if destination:
|
||||
return destination
|
||||
|
||||
# All selects stay in place; re-initialize where in the list we left off
|
||||
self.selected_button = ret_value
|
||||
return self.run()
|
||||
|
||||
|
||||
|
||||
"""****************************************************************************
|
||||
Misc
|
||||
****************************************************************************"""
|
||||
class IOTestView(View):
|
||||
def run(self):
|
||||
settings_screens.IOTestScreen().display()
|
||||
|
||||
return Destination(SettingsMenuView)
|
||||
|
||||
|
||||
|
||||
class DonateView(View):
|
||||
def run(self):
|
||||
settings_screens.DonateScreen().display()
|
||||
|
||||
return Destination(SettingsMenuView)
|
||||
@@ -1,89 +0,0 @@
|
||||
# Internal file class dependencies
|
||||
from . import View
|
||||
from seedsigner.helpers import Buttons, B
|
||||
|
||||
# External Dependencies
|
||||
import time
|
||||
|
||||
|
||||
class SigningToolsView(View):
|
||||
|
||||
def __init__(self, seed_storage) -> None:
|
||||
View.__init__(self)
|
||||
self.seed_storage = seed_storage
|
||||
|
||||
###
|
||||
### XPub
|
||||
###
|
||||
|
||||
def display_xpub_info(self, fingerprint, derivation, xpub):
|
||||
derivation_display = "Derivation: " + derivation
|
||||
xpub_display = xpub[0:7] + "..." + xpub[-9:]
|
||||
self.draw_modal(["Master Fingerprint: ", fingerprint, derivation_display, xpub_display], "Xpub Info", "Right to Continue")
|
||||
|
||||
###
|
||||
### Signing Tx
|
||||
###
|
||||
|
||||
def display_transaction_information(self, p) -> None:
|
||||
self.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
tw, th = self.draw.textsize("Confirm Tx Details", font=View.ASSISTANT25)
|
||||
self.draw.text(((240 - tw) / 2, 3), "Confirm Tx Details", fill=View.color, font=View.ASSISTANT25)
|
||||
|
||||
in_fee_outs_str = str(len(p.psbt.inputs))
|
||||
in_fee_outs_str += " inputs - fee = " if len(p.psbt.inputs) > 1 else " input - fee = "
|
||||
in_fee_outs_str += str(len(p.psbt.outputs))
|
||||
in_fee_outs_str += " outs" if len(p.psbt.outputs) > 1 else " out"
|
||||
tw, th = self.draw.textsize(in_fee_outs_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 40), in_fee_outs_str, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
receiving_addr_str1 = ""
|
||||
receiving_addr_str2 = ""
|
||||
if len(p.destination_addresses) > 1:
|
||||
receiving_addr_str1 += "multiple"
|
||||
receiving_addr_str2 += "receiving addresses"
|
||||
elif len(p.destination_addresses) == 1:
|
||||
receiving_addr_str1 += "receiving address"
|
||||
receiving_addr_str2 += "last 13: ... " + p.destination_addresses[0][-13:]
|
||||
else:
|
||||
receiving_addr_str1 += "Self-Transfer"
|
||||
|
||||
tw, th = self.draw.textsize(receiving_addr_str1, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 75), receiving_addr_str1, fill=View.color, font=View.ASSISTANT22)
|
||||
if len(receiving_addr_str2) > 0:
|
||||
tw, th = self.draw.textsize(receiving_addr_str2, font=View.ROBOTOCONDENSED_BOLD_18)
|
||||
self.draw.text(((240 - tw) / 2, 105), receiving_addr_str2, fill=View.color, font=View.ROBOTOCONDENSED_BOLD_18)
|
||||
|
||||
|
||||
if p.spend_amount > 0:
|
||||
spending_str = "Spend: " + str(p.spend_amount) + " sats"
|
||||
tw, th = self.draw.textsize(spending_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 130), spending_str, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
if p.change_amount > 0 and len(p.destination_addresses) == 0:
|
||||
change_str = "Amount: " + str(p.change_amount) + " sats"
|
||||
tw, th = self.draw.textsize(change_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.ASSISTANT22)
|
||||
elif p.change_amount > 0:
|
||||
change_str = "Change: " + str(p.change_amount) + " sats"
|
||||
tw, th = self.draw.textsize(change_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 155), change_str, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
fee_str = "Fee: " + str(p.fee_amount) + " sats"
|
||||
tw, th = self.draw.textsize(fee_str, font=View.ASSISTANT22)
|
||||
self.draw.text(((240 - tw) / 2, 180), fee_str, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
tw, th = self.draw.textsize("Left to Exit, Right to Continue", font=View.ASSISTANT18)
|
||||
self.draw.text(((240 - tw) / 2, 215), "Left to Exit, Right to Continue", fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
def qr_gen_status(percentage):
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
tw, th = View.draw.textsize("QR Generation", font=View.ASSISTANT25)
|
||||
View.draw.text(((240 - tw) / 2, 90), "QR Generation", fill=View.color, font=View.ASSISTANT25)
|
||||
tw, th = View.draw.textsize(str(round(percentage)) + "% Complete", font=View.ASSISTANT25)
|
||||
View.draw.text(((240 - tw) / 2, 125), str(round(percentage)) + "% Complete", fill=View.color, font=View.ASSISTANT25)
|
||||
View.DispShowImage()
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
from PIL.ImageOps import autocontrast
|
||||
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants
|
||||
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen)
|
||||
from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordFinalizePromptScreen, ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsCalcFinalWordDoneScreen
|
||||
from seedsigner.helpers import mnemonic_generation
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedWordsWarningView
|
||||
|
||||
from .view import View, Destination, BackStackView
|
||||
|
||||
|
||||
|
||||
class ToolsMenuView(View):
|
||||
def run(self):
|
||||
IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA)
|
||||
DICE = ("New seed", FontAwesomeIconConstants.DICE)
|
||||
KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
|
||||
button_data = [IMAGE, DICE, KEYBOARD]
|
||||
screen = ButtonListScreen(
|
||||
title="Tools",
|
||||
is_button_text_centered=False,
|
||||
button_data=button_data
|
||||
)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == IMAGE:
|
||||
return Destination(ToolsImageEntropyLivePreviewView)
|
||||
|
||||
elif button_data[selected_menu_num] == DICE:
|
||||
return Destination(ToolsDiceEntropyMnemonicLengthView)
|
||||
|
||||
elif button_data[selected_menu_num] == KEYBOARD:
|
||||
return Destination(ToolsCalcFinalWordNumWordsView)
|
||||
|
||||
|
||||
|
||||
"""****************************************************************************
|
||||
Image entropy Views
|
||||
****************************************************************************"""
|
||||
class ToolsImageEntropyLivePreviewView(View):
|
||||
def run(self):
|
||||
self.controller.image_entropy_preview_frames = None
|
||||
ret = ToolsImageEntropyLivePreviewScreen().display()
|
||||
|
||||
if ret == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
self.controller.image_entropy_preview_frames = ret
|
||||
return Destination(ToolsImageEntropyFinalImageView)
|
||||
|
||||
|
||||
|
||||
class ToolsImageEntropyFinalImageView(View):
|
||||
def run(self):
|
||||
if not self.controller.image_entropy_final_image:
|
||||
# Take the final full-res image
|
||||
camera = Camera.get_instance()
|
||||
camera.start_single_frame_mode(resolution=(720, 480))
|
||||
time.sleep(0.25)
|
||||
self.controller.image_entropy_final_image = camera.capture_frame()
|
||||
camera.stop_single_frame_mode()
|
||||
|
||||
# Prep a copy of the image for display. The actual image data is 720x480
|
||||
# Present just a center crop and resize it to fit the screen and to keep some of
|
||||
# the data hidden.
|
||||
display_version = autocontrast(
|
||||
self.controller.image_entropy_final_image,
|
||||
cutoff=2
|
||||
).crop(
|
||||
(120, 0, 600, 480)
|
||||
).resize(
|
||||
(self.canvas_width, self.canvas_height), Image.BICUBIC
|
||||
)
|
||||
|
||||
ret = ToolsImageEntropyFinalImageScreen(
|
||||
final_image=display_version
|
||||
).display()
|
||||
|
||||
if ret == RET_CODE__BACK_BUTTON:
|
||||
# Go back to live preview and reshoot
|
||||
self.controller.image_entropy_final_image = None
|
||||
return Destination(BackStackView)
|
||||
|
||||
return Destination(ToolsImageEntropyMnemonicLengthView)
|
||||
|
||||
|
||||
|
||||
class ToolsImageEntropyMnemonicLengthView(View):
|
||||
def run(self):
|
||||
TWELVE_WORDS = "12 words"
|
||||
TWENTYFOUR_WORDS = "24 words"
|
||||
button_data = [TWELVE_WORDS, TWENTYFOUR_WORDS]
|
||||
|
||||
selected_menu_num = ButtonListScreen(
|
||||
title="Mnemonic Length?",
|
||||
button_data=button_data,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
if button_data[selected_menu_num] == TWELVE_WORDS:
|
||||
mnemonic_length = 12
|
||||
else:
|
||||
mnemonic_length = 24
|
||||
|
||||
preview_images = self.controller.image_entropy_preview_frames
|
||||
seed_entropy_image = self.controller.image_entropy_final_image
|
||||
|
||||
# Build in some hardware-level uniqueness via CPU unique Serial num
|
||||
try:
|
||||
stream = os.popen("cat /proc/cpuinfo | grep Serial")
|
||||
output = stream.read()
|
||||
serial_num = output.split(":")[-1].strip().encode('utf-8')
|
||||
serial_hash = hashlib.sha256(serial_num)
|
||||
hash_bytes = serial_hash.digest()
|
||||
except Exception as e:
|
||||
print(repr(e))
|
||||
hash_bytes = b'0'
|
||||
|
||||
# Build in modest entropy via millis since power on
|
||||
millis_hash = hashlib.sha256(hash_bytes + str(time.time()).encode('utf-8'))
|
||||
hash_bytes = millis_hash.digest()
|
||||
|
||||
# Build in better entropy by chaining the preview frames
|
||||
for frame in preview_images:
|
||||
img_hash = hashlib.sha256(hash_bytes + frame.tobytes())
|
||||
hash_bytes = img_hash.digest()
|
||||
|
||||
# Finally build in our headline entropy via the new full-res image
|
||||
final_hash = hashlib.sha256(hash_bytes + seed_entropy_image.tobytes()).digest()
|
||||
|
||||
if mnemonic_length == 12:
|
||||
# 12-word mnemonic only uses the first 128 bits / 16 bytes of entropy
|
||||
final_hash = final_hash[:16]
|
||||
|
||||
# Generate the mnemonic
|
||||
mnemonic = mnemonic_generation.generate_mnemonic_from_bytes(final_hash)
|
||||
|
||||
# Image should never get saved nor stick around in memory
|
||||
seed_entropy_image = None
|
||||
preview_images = None
|
||||
final_hash = None
|
||||
hash_bytes = None
|
||||
self.controller.image_entropy_preview_frames = None
|
||||
self.controller.image_entropy_final_image = None
|
||||
|
||||
# Add the mnemonic as an in-memory Seed
|
||||
seed = Seed(mnemonic, wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
|
||||
self.controller.storage.set_pending_seed(seed)
|
||||
|
||||
# Cannot return BACK to this View
|
||||
return Destination(SeedWordsWarningView, view_args={"seed_num": None}, clear_history=True)
|
||||
|
||||
|
||||
|
||||
"""****************************************************************************
|
||||
Dice rolls Views
|
||||
****************************************************************************"""
|
||||
class ToolsDiceEntropyMnemonicLengthView(View):
|
||||
def run(self):
|
||||
TWELVE = "12 words (50 rolls)"
|
||||
TWENTY_FOUR = "24 words (99 rolls)"
|
||||
|
||||
button_data = [TWELVE, TWENTY_FOUR]
|
||||
selected_menu_num = ButtonListScreen(
|
||||
title="Mnemonic Length",
|
||||
is_bottom_list=True,
|
||||
is_button_text_centered=True,
|
||||
button_data=button_data,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == TWELVE:
|
||||
return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=50))
|
||||
|
||||
elif button_data[selected_menu_num] == TWENTY_FOUR:
|
||||
return Destination(ToolsDiceEntropyEntryView, view_args=dict(total_rolls=99))
|
||||
|
||||
|
||||
|
||||
class ToolsDiceEntropyEntryView(View):
|
||||
def __init__(self, total_rolls: int):
|
||||
super().__init__()
|
||||
self.total_rolls = total_rolls
|
||||
|
||||
|
||||
def run(self):
|
||||
ret = ToolsDiceEntropyEntryScreen(
|
||||
return_after_n_chars=self.total_rolls,
|
||||
).display()
|
||||
|
||||
if ret == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
print(f"Dice rolls: {ret}")
|
||||
dice_seed_phrase = mnemonic_generation.generate_mnemonic_from_dice(ret)
|
||||
print(f"""Mnemonic: "{dice_seed_phrase}" """)
|
||||
|
||||
# Add the mnemonic as an in-memory Seed
|
||||
seed = Seed(dice_seed_phrase, wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
|
||||
self.controller.storage.set_pending_seed(seed)
|
||||
|
||||
# Cannot return BACK to this View
|
||||
return Destination(SeedWordsWarningView, view_args={"seed_num": None}, clear_history=True)
|
||||
|
||||
|
||||
|
||||
"""****************************************************************************
|
||||
Calc final word Views
|
||||
****************************************************************************"""
|
||||
class ToolsCalcFinalWordNumWordsView(View):
|
||||
def run(self):
|
||||
TWELVE = "12 words"
|
||||
TWENTY_FOUR = "24 words"
|
||||
|
||||
button_data = [TWELVE, TWENTY_FOUR]
|
||||
selected_menu_num = ButtonListScreen(
|
||||
title="Mnemonic Length",
|
||||
is_bottom_list=True,
|
||||
is_button_text_centered=True,
|
||||
button_data=button_data,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == TWELVE:
|
||||
self.controller.storage.init_pending_mnemonic(12)
|
||||
|
||||
# return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
|
||||
return Destination(SeedMnemonicEntryView, view_args=dict(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=dict(is_calc_final_word=True))
|
||||
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
|
||||
|
||||
|
||||
|
||||
class ToolsCalcFinalWordFinalizePromptView(View):
|
||||
def run(self):
|
||||
mnemonic = self.controller.storage.pending_mnemonic
|
||||
mnemonic_length = len(mnemonic)
|
||||
if mnemonic_length == 12:
|
||||
num_entropy_bits = 7
|
||||
else:
|
||||
num_entropy_bits = 3
|
||||
|
||||
COIN_FLIPS = "Coin flip entropy"
|
||||
SELECT_WORD = f"Word selection entropy"
|
||||
ZEROS = "Finalize with zeros"
|
||||
|
||||
button_data = [COIN_FLIPS, SELECT_WORD, ZEROS]
|
||||
selected_menu_num = ToolsCalcFinalWordFinalizePromptScreen(
|
||||
mnemonic_length=mnemonic_length,
|
||||
num_entropy_bits=num_entropy_bits,
|
||||
button_data=button_data,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == COIN_FLIPS:
|
||||
return Destination(ToolsCalcFinalWordCoinFlipsView)
|
||||
|
||||
elif button_data[selected_menu_num] == SELECT_WORD:
|
||||
# Clear the final word slot, just in case we're returning via BACK button
|
||||
self.controller.storage.update_pending_mnemonic(None, mnemonic_length - 1)
|
||||
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True, cur_word_index=mnemonic_length - 1))
|
||||
|
||||
elif button_data[selected_menu_num] == ZEROS:
|
||||
# User skipped the option to select a final word to provide last bits of
|
||||
# entropy. We'll insert all zeros and piggy-back on the coin flip attr
|
||||
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
|
||||
self.controller.storage.update_pending_mnemonic(Seed.get_wordlist(wordlist_language_code)[0], mnemonic_length - 1)
|
||||
return Destination(ToolsCalcFinalWordShowFinalWordView, view_args=dict(coin_flips="0" * num_entropy_bits))
|
||||
|
||||
|
||||
|
||||
class ToolsCalcFinalWordCoinFlipsView(View):
|
||||
def run(self):
|
||||
mnemonic = self.controller.storage.pending_mnemonic
|
||||
mnemonic_length = len(mnemonic)
|
||||
|
||||
if mnemonic_length == 12:
|
||||
total_flips = 7
|
||||
else:
|
||||
total_flips = 3
|
||||
|
||||
ret_val = ToolsCoinFlipEntryScreen(
|
||||
return_after_n_chars=total_flips,
|
||||
).display()
|
||||
|
||||
if ret_val == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
else:
|
||||
print(ret_val)
|
||||
binary_string = ret_val + "0" * (11 - total_flips)
|
||||
wordlist_index = int(binary_string, 2)
|
||||
wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
|
||||
word = wordlist[wordlist_index]
|
||||
self.controller.storage.update_pending_mnemonic(word, mnemonic_length - 1)
|
||||
|
||||
return Destination(ToolsCalcFinalWordShowFinalWordView, view_args=dict(coin_flips=ret_val))
|
||||
|
||||
|
||||
|
||||
class ToolsCalcFinalWordShowFinalWordView(View):
|
||||
def __init__(self, coin_flips=None):
|
||||
super().__init__()
|
||||
self.coin_flips = coin_flips
|
||||
|
||||
|
||||
def run(self):
|
||||
# Construct the actual final word. The user's selected_final_word
|
||||
# contributes:
|
||||
# * 3 bits to a 24-word seed (plus 8-bit checksum)
|
||||
# * 7 bits to a 12-word seed (plus 4-bit checksum)
|
||||
from seedsigner.helpers import mnemonic_generation
|
||||
|
||||
mnemonic = self.controller.storage.pending_mnemonic
|
||||
mnemonic_length = len(mnemonic)
|
||||
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
|
||||
wordlist = Seed.get_wordlist(wordlist_language_code)
|
||||
|
||||
final_mnemonic = mnemonic_generation.calculate_checksum(
|
||||
mnemonic=self.controller.storage.pending_mnemonic,
|
||||
wordlist_language_code=wordlist_language_code,
|
||||
)
|
||||
self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], mnemonic_length - 1)
|
||||
|
||||
# Prep the user's selected word (if there was one) and the actual final word for
|
||||
# the display.
|
||||
if self.coin_flips:
|
||||
selected_final_word = None
|
||||
selected_final_bits = self.coin_flips
|
||||
else:
|
||||
# Convert the user's final word selection into its binary index equivalent
|
||||
selected_final_word = mnemonic[-1]
|
||||
selected_final_bits = format(wordlist.index(selected_final_word), '011b')
|
||||
|
||||
# And grab the actual final word's checksum bits
|
||||
actual_final_word = self.controller.storage.pending_mnemonic[-1]
|
||||
if mnemonic_length == 12:
|
||||
checksum_bits = format(wordlist.index(actual_final_word), '011b')[-4:]
|
||||
else:
|
||||
checksum_bits = format(wordlist.index(actual_final_word), '011b')[-8:]
|
||||
|
||||
NEXT = "Next"
|
||||
button_data = [NEXT]
|
||||
selected_menu_num = ToolsCalcFinalWordScreen(
|
||||
title="Final Word Calc",
|
||||
button_data=button_data,
|
||||
selected_final_word=selected_final_word,
|
||||
selected_final_bits=selected_final_bits,
|
||||
checksum_bits=checksum_bits,
|
||||
actual_final_word=actual_final_word,
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == NEXT:
|
||||
return Destination(ToolsCalcFinalWordDoneView)
|
||||
|
||||
|
||||
|
||||
class ToolsCalcFinalWordDoneView(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 = ToolsCalcFinalWordDoneScreen(
|
||||
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,246 @@
|
||||
# External Dependencies
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
import pathlib
|
||||
import spidev as SPI
|
||||
import time
|
||||
from multiprocessing import Queue
|
||||
from seedsigner.helpers import B, ST7789
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants
|
||||
from seedsigner.gui.screens import RET_CODE__POWER_BUTTON
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, LargeButtonScreen, PowerOffScreen, ResetScreen, WarningScreen
|
||||
from seedsigner.models.threads import BaseThread
|
||||
|
||||
|
||||
### Generic View Class to Instatiate Display
|
||||
### Static Class variables are used for display
|
||||
### Designed to be inherited for other view classes, but not required
|
||||
|
||||
class BackStackView:
|
||||
"""
|
||||
Empty class that just signals to the Controller to pop the most recent View off
|
||||
the back_stack.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
"""
|
||||
Views contain the biz logic to handle discrete tasks, exactly analogous to a Flask
|
||||
request/response function or a Django View. Each page/screen displayed to the user
|
||||
should be implemented in its own View.
|
||||
|
||||
In a web context, the View would prepare data for the html/css/js presentation
|
||||
templates. We have to implement our own presentation layer (implemented as `Screen`
|
||||
objects). For the sake of code cleanliness and separation of concerns, the View code
|
||||
should not know anything about pixel-level rendering.
|
||||
|
||||
Sequences that require multiple pages/screens should be implemented as a series of
|
||||
separate Views. Exceptions can be made for complex interactive sequences, but in
|
||||
general, if your View is instantiating multiple Screens, you're probably putting too
|
||||
much functionality in that View.
|
||||
|
||||
As with http requests, Views can receive input vars to inform their behavior. Views
|
||||
can also prepare the next set of vars to set up the next View that should be
|
||||
displayed (akin to Flask's `return redirect(url, param1=x, param2=y))`).
|
||||
|
||||
Navigation guidance:
|
||||
"Next" - Continue to next step
|
||||
"Done" - End of flow, return to entry point (non-destructive)
|
||||
"OK/Close" - Exit current screen (non-destructive)
|
||||
"Cancel" - End task and return to entry point (destructive)
|
||||
"""
|
||||
class View:
|
||||
|
||||
WIDTH = 240
|
||||
HEIGHT = 240
|
||||
|
||||
font_path = os.path.join(pathlib.Path(__file__).parent.resolve(), "..", "resources", "fonts")
|
||||
|
||||
# Define necessary fonts
|
||||
ASSISTANT16 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 16)
|
||||
ASSISTANT18 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 18)
|
||||
ASSISTANT20 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 20)
|
||||
ASSISTANT21 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 21)
|
||||
ASSISTANT22 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 22)
|
||||
ASSISTANT23 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 23)
|
||||
ASSISTANT25 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 25)
|
||||
ASSISTANT26 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 26)
|
||||
ASSISTANT35 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 35)
|
||||
ASSISTANT50 = ImageFont.truetype(os.path.join(font_path, 'Assistant-Medium.ttf'), 50)
|
||||
ASSISTANT16BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 15)
|
||||
ASSISTANT18BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 17)
|
||||
ASSISTANT20BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 19)
|
||||
ASSISTANT21BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 20)
|
||||
ASSISTANT22BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 21)
|
||||
ASSISTANT23BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 22)
|
||||
ASSISTANT25BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 25)
|
||||
ASSISTANT26BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 26)
|
||||
ASSISTANT35BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 35)
|
||||
ASSISTANT50BOLD = ImageFont.truetype(os.path.join(font_path, 'Assistant-Bold.ttf'), 50)
|
||||
ROBOTOMONO14 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 14)
|
||||
ROBOTOMONO24 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 24)
|
||||
ROBOTOMONO38 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 38)
|
||||
ROBOTOMONO30 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 30)
|
||||
ROBOTOMONO20 = ImageFont.truetype(os.path.join(font_path, 'RobotoMono-Medium.ttf'), 20)
|
||||
|
||||
ROBOTOCONDENSED_BOLD_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 16)
|
||||
ROBOTOCONDENSED_BOLD_18 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 18)
|
||||
ROBOTOCONDENSED_BOLD_20 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 20)
|
||||
ROBOTOCONDENSED_BOLD_22 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 20)
|
||||
ROBOTOCONDENSED_BOLD_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 22)
|
||||
ROBOTOCONDENSED_BOLD_25 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 25)
|
||||
ROBOTOCONDENSED_BOLD_26 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 26)
|
||||
ROBOTOCONDENSED_BOLD_28 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Bold.ttf"), 28)
|
||||
ROBOTOCONDENSED_LIGHT_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Light.ttf"), 16)
|
||||
ROBOTOCONDENSED_LIGHT_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Light.ttf"), 24)
|
||||
ROBOTOCONDENSED_REGULAR_16 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 16)
|
||||
ROBOTOCONDENSED_REGULAR_20 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 20)
|
||||
ROBOTOCONDENSED_REGULAR_22 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 22)
|
||||
ROBOTOCONDENSED_REGULAR_24 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 24)
|
||||
ROBOTOCONDENSED_REGULAR_26 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 26)
|
||||
ROBOTOCONDENSED_REGULAR_28 = ImageFont.truetype(os.path.join(font_path, "RobotoCondensed-Regular.ttf"), 28)
|
||||
|
||||
RST = 27
|
||||
DC = 25
|
||||
BL = 24
|
||||
|
||||
buttons = None
|
||||
canvas_width = 0
|
||||
canvas_height = 0
|
||||
canvas = None
|
||||
draw = None
|
||||
bus = 0
|
||||
device = 0
|
||||
disp = None
|
||||
previous_button_width = None
|
||||
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Import here to avoid circular imports
|
||||
from seedsigner.controller import Controller
|
||||
self.controller = Controller.get_instance()
|
||||
from seedsigner.gui import Renderer
|
||||
from seedsigner.models import Settings
|
||||
|
||||
View.buttons = self.controller.buttons
|
||||
View.color = self.controller.color
|
||||
self.controller: Controller = Controller.get_instance()
|
||||
self.settings = Settings.get_instance()
|
||||
|
||||
View.canvas_width = View.WIDTH
|
||||
View.canvas_height = View.HEIGHT
|
||||
View.canvas = Image.new('RGB', (View.canvas_width, View.canvas_height))
|
||||
View.draw = ImageDraw.Draw(View.canvas)
|
||||
# TODO: Pull all rendering-related code out of Views and into gui.screens implementations
|
||||
self.renderer = Renderer.get_instance()
|
||||
self.canvas_width = self.renderer.canvas_width
|
||||
self.canvas_height = self.renderer.canvas_height
|
||||
|
||||
# 240x240 display with hardware SPI:
|
||||
View.bus = 0
|
||||
View.device = 0
|
||||
|
||||
# TODO: Add `disable_hardware` option for test suite here, too?
|
||||
View.disp = ST7789(SPI.SpiDev(View.bus, View.device),View.RST, View.DC, View.BL)
|
||||
View.disp.Init()
|
||||
|
||||
self.queue = Queue()
|
||||
self.buttons = self.controller.buttons
|
||||
|
||||
|
||||
def DispShowImage(image=None, alpha_overlay=None):
|
||||
if image == None:
|
||||
image = View.canvas
|
||||
def run(self, **kwargs):
|
||||
if hasattr(self, "screen"):
|
||||
self.screen.display()
|
||||
else:
|
||||
# Always keep a copy of the current display in the canvas
|
||||
View.canvas.paste(image)
|
||||
|
||||
if alpha_overlay:
|
||||
image = Image.alpha_composite(image, alpha_overlay)
|
||||
|
||||
# Always keep a copy of the current display in the canvas
|
||||
View.canvas.paste(image)
|
||||
|
||||
View.disp.ShowImage(image, 0, 0)
|
||||
|
||||
|
||||
def disp_show_image_pan(image, start_x, start_y, end_x, end_y, rate, alpha_overlay=None):
|
||||
cur_x = start_x
|
||||
cur_y = start_y
|
||||
rate_x = rate
|
||||
rate_y = rate
|
||||
if end_x - start_x < 0:
|
||||
rate_x = rate_x * -1
|
||||
if end_y - start_y < 0:
|
||||
rate_y = rate_y * -1
|
||||
|
||||
while (cur_x != end_x or cur_y != end_y) and (rate_x != 0 or rate_y != 0):
|
||||
cur_x += rate_x
|
||||
if (rate_x > 0 and cur_x > end_x) or (rate_x < 0 and cur_x < end_x):
|
||||
# We've moved too far; back up and undo that last move.
|
||||
cur_x -= rate_x
|
||||
rate_x = 0
|
||||
|
||||
cur_y += rate_y
|
||||
if (rate_y > 0 and cur_y > end_y) or (rate_y < 0 and cur_y < end_y):
|
||||
# We've moved too far; back up and undo that last move.
|
||||
cur_y -= rate_y
|
||||
rate_y = 0
|
||||
|
||||
crop = image.crop((cur_x, cur_y, cur_x + View.canvas_width, cur_y + View.canvas_height))
|
||||
|
||||
if alpha_overlay:
|
||||
crop = Image.alpha_composite(crop, alpha_overlay)
|
||||
|
||||
# Always keep a copy of the current display in the canvas
|
||||
View.canvas.paste(crop)
|
||||
|
||||
View.disp.ShowImage(crop, 0, 0)
|
||||
raise Exception("Must implement in the child class")
|
||||
|
||||
|
||||
|
||||
def DispShowImageWithText(image, text, font=None, text_color="GREY", text_background=None):
|
||||
image_copy = image.copy().convert("RGBA")
|
||||
draw = ImageDraw.Draw(image_copy)
|
||||
|
||||
text_overlay = Image.new("RGBA", (View.canvas_width, View.canvas_height), (255,255,255,0))
|
||||
text_overlay_draw = ImageDraw.Draw(text_overlay)
|
||||
if not font:
|
||||
font = View.ASSISTANT18
|
||||
tw, th = text_overlay_draw.textsize(text, font=font)
|
||||
if text_background:
|
||||
text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background)
|
||||
text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font)
|
||||
|
||||
View.DispShowImage(image_copy, alpha_overlay=text_overlay)
|
||||
@dataclass
|
||||
class Destination:
|
||||
"""
|
||||
Basic struct to pass back to the Controller to tell it which View the user should
|
||||
be presented with next.
|
||||
"""
|
||||
View_cls: View # The target View to route to
|
||||
view_args: dict = None # The input args required to instantiate the target View
|
||||
skip_current_view: bool = False # The current View is just forwarding; omit current View from history
|
||||
clear_history: bool = False # Optionally clears the back_stack to prevent "back"
|
||||
|
||||
|
||||
def draw_modal(self, lines = [], title = "", bottom = "") -> None:
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
if len(title) > 0:
|
||||
tw, th = View.draw.textsize(title, font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
if len(bottom) > 0:
|
||||
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
if len(lines) == 1:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT26)
|
||||
elif len(lines) == 2:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
elif len(lines) == 3:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.ASSISTANT26)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
|
||||
elif len(lines) == 4:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 55), lines[0], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[3], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
return
|
||||
|
||||
def draw_address(self, address) -> None:
|
||||
|
||||
if address.startswith(tuple(['1','2','3'])):
|
||||
dividby = 14
|
||||
def __repr__(self):
|
||||
if self.View_cls is None:
|
||||
out = "None"
|
||||
else:
|
||||
dividby = 18
|
||||
address_parts = [address[i:i+dividby] for i in range(0, len(address), dividby)]
|
||||
address_part_1 = address_parts[0]
|
||||
address_part_2 = address_parts[1] if len(address_parts) >= 2 else ""
|
||||
address_part_3 = address_parts[2] if len(address_parts) >= 3 else ""
|
||||
address_part_4 = address_parts[3] if len(address_parts) >= 4 else ""
|
||||
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
tw, th = View.draw.textsize("Right to Continue", font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), "Right to Continue", fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
tw, th = View.draw.textsize("Bitcoin Address", font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 35), "Bitcoin Address", fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(address_part_1, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
View.draw.text(((240 - tw) / 2, 70), address_part_1, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
tw, th = View.draw.textsize(address_part_2, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
View.draw.text(((240 - tw) / 2, 105), address_part_2, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
tw, th = View.draw.textsize(address_part_3, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
View.draw.text(((240 - tw) / 2, 140), address_part_3, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
tw, th = View.draw.textsize(address_part_4, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
View.draw.text(((240 - tw) / 2, 175), address_part_4, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_22)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
return
|
||||
|
||||
def draw_passphrase(self, title, passphrase, bottom) -> None:
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
passphrase_part_1 = passphrase[0:20]
|
||||
passphrase_part_2 = passphrase[20:40]
|
||||
passphrase_part_3 = passphrase[40:60]
|
||||
passphrase_part_4 = passphrase[60:1000]
|
||||
|
||||
tw, th = View.draw.textsize(title, font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 30), title, fill=View.color, font=View.ASSISTANT26)
|
||||
|
||||
tw, th = View.draw.textsize(passphrase_part_1, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
View.draw.text(((240 - tw) / 2, 70), passphrase_part_1, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
tw, th = View.draw.textsize(passphrase_part_2, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
View.draw.text(((240 - tw) / 2, 100), passphrase_part_2, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
tw, th = View.draw.textsize(passphrase_part_3, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
View.draw.text(((240 - tw) / 2, 130), passphrase_part_3, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
tw, th = View.draw.textsize(passphrase_part_4, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
View.draw.text(((240 - tw) / 2, 160), passphrase_part_4, fill=View.color, font=View.ROBOTOCONDENSED_REGULAR_20)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
return
|
||||
|
||||
def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None:
|
||||
|
||||
self.draw_prompt_custom("", "Yes ", "No ", lines, title, bottom)
|
||||
return
|
||||
|
||||
|
||||
def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None:
|
||||
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
if len(title) > 0:
|
||||
tw, th = View.draw.textsize(title, font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 2), title, fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
if len(bottom) > 0:
|
||||
tw, th = View.draw.textsize(bottom, font=View.ASSISTANT18)
|
||||
View.draw.text(((240 - tw) / 2, 210), bottom, fill=View.color, font=View.ASSISTANT18)
|
||||
|
||||
if len(lines) == 1:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT26)
|
||||
elif len(lines) == 2:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[0], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
elif len(lines) == 3:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT26)
|
||||
View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.ASSISTANT26)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
|
||||
elif len(lines) == 4:
|
||||
tw, th = View.draw.textsize(lines[0], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 20), lines[0], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[1], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 90), lines[1], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[2], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 125), lines[2], fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(lines[3], font=View.ASSISTANT22)
|
||||
View.draw.text(((240 - tw) / 2, 160), lines[3], fill=View.color, font=View.ASSISTANT22)
|
||||
|
||||
a_x_offset = 240 - View.ASSISTANT25.getsize(a_txt)[0]
|
||||
View.draw.text((a_x_offset, 39 + 0), a_txt, fill=View.color, font=View.ASSISTANT25)
|
||||
|
||||
b_x_offset = 240 - View.ASSISTANT25.getsize(b_txt)[0]
|
||||
View.draw.text((b_x_offset , 39 + 60), b_txt, fill=View.color, font=View.ASSISTANT25)
|
||||
|
||||
c_x_offset = 240 - View.ASSISTANT25.getsize(c_txt)[0]
|
||||
View.draw.text((c_x_offset , 39 + 120), c_txt, fill=View.color, font=View.ASSISTANT25)
|
||||
|
||||
View.DispShowImage()
|
||||
|
||||
return
|
||||
|
||||
|
||||
###
|
||||
### Power Off Screen
|
||||
###
|
||||
|
||||
def display_power_off_screen(self):
|
||||
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
|
||||
line1 = "Powering Down..."
|
||||
line2 = "Please wait about"
|
||||
line3 = "30 seconds before"
|
||||
line4 = "disconnecting power."
|
||||
|
||||
tw, th = View.draw.textsize(line1, font=View.ASSISTANT22)
|
||||
View.draw.text(((240-tw)/2, 45), line1, fill=View.color, font=View.ASSISTANT22)
|
||||
tw, th = View.draw.textsize(line2, font=View.ASSISTANT20)
|
||||
View.draw.text(((240-tw)/2, 100), line2, fill=View.color, font=View.ASSISTANT20)
|
||||
tw, th = View.draw.textsize(line3, font=View.ASSISTANT20)
|
||||
View.draw.text(((240-tw)/2, 130), line3, fill=View.color, font=View.ASSISTANT20)
|
||||
tw, th = View.draw.textsize(line4, font=View.ASSISTANT20)
|
||||
View.draw.text(((240-tw)/2, 160), line4, fill=View.color, font=View.ASSISTANT20)
|
||||
View.DispShowImage()
|
||||
|
||||
|
||||
def display_blank_screen(self):
|
||||
View.draw.rectangle((0, 0, View.canvas_width, View.canvas_height), outline=0, fill=0)
|
||||
View.DispShowImage()
|
||||
|
||||
|
||||
###
|
||||
### Reusable components
|
||||
###
|
||||
def render_previous_button(self, highlight=False):
|
||||
# Set up the "back" arrow in the upper left
|
||||
arrow = "<"
|
||||
word_font = View.ROBOTOCONDENSED_BOLD_26
|
||||
top_padding = -3
|
||||
bottom_padding = 3
|
||||
side_padding = 3
|
||||
tw, th = word_font.getsize(arrow)
|
||||
self.previous_button_width = tw + 2 * side_padding
|
||||
if highlight:
|
||||
font_color = "black"
|
||||
background_color = View.color
|
||||
out = self.View_cls.__name__
|
||||
if self.view_args:
|
||||
out += f"({self.view_args})"
|
||||
else:
|
||||
font_color = View.color
|
||||
background_color = "black"
|
||||
View.draw.rectangle((0,0, self.previous_button_width, th + top_padding + bottom_padding), fill=background_color)
|
||||
View.draw.text((side_padding, top_padding), arrow, fill=font_color, font=word_font)
|
||||
out += "()"
|
||||
if self.clear_history:
|
||||
out += f" | clear_history: {self.clear_history}"
|
||||
return out
|
||||
|
||||
|
||||
def run(self):
|
||||
if not self.view_args:
|
||||
# Can't unpack (**) None so we replace with an empty dict
|
||||
self.view_args = {}
|
||||
# Instantiate the `View_cls` and run() it with the `view_args` dict
|
||||
return self.View_cls(**self.view_args).run()
|
||||
|
||||
|
||||
def __eq__(self, obj):
|
||||
"""
|
||||
Equality test IGNORES the skip_current_view and clear_history options
|
||||
"""
|
||||
return (isinstance(obj, Destination) and
|
||||
obj.View_cls == self.View_cls and
|
||||
obj.view_args == self.view_args)
|
||||
|
||||
|
||||
def __ne__(self, obj):
|
||||
return not obj == self
|
||||
|
||||
|
||||
|
||||
#########################################################################################
|
||||
#
|
||||
# Root level Views don't have a sub-module home so they live at the top level here.
|
||||
#
|
||||
#########################################################################################
|
||||
class MainMenuView(View):
|
||||
def run(self):
|
||||
from .seed_views import SeedsMenuView
|
||||
from .settings_views import SettingsMenuView
|
||||
from .scan_views import ScanView
|
||||
from .tools_views import ToolsMenuView
|
||||
from seedsigner.gui.screens import LargeButtonScreen
|
||||
menu_items = [
|
||||
(("Scan", FontAwesomeIconConstants.QRCODE), ScanView),
|
||||
(("Seeds", FontAwesomeIconConstants.KEY), SeedsMenuView),
|
||||
(("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH), ToolsMenuView),
|
||||
(("Settings", FontAwesomeIconConstants.GEAR), SettingsMenuView),
|
||||
]
|
||||
|
||||
screen = LargeButtonScreen(
|
||||
title="Home",
|
||||
title_font_size=26,
|
||||
button_data=[entry[0] for entry in menu_items],
|
||||
show_back_button=False,
|
||||
show_power_button=True,
|
||||
)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__POWER_BUTTON:
|
||||
return Destination(PowerOptionsView)
|
||||
|
||||
return Destination(menu_items[selected_menu_num][1])
|
||||
|
||||
|
||||
|
||||
class PowerOptionsView(View):
|
||||
def run(self):
|
||||
RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT)
|
||||
POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF)
|
||||
button_data = [RESET, POWER_OFF]
|
||||
selected_menu_num = LargeButtonScreen(
|
||||
title="Reset / Power",
|
||||
show_back_button=True,
|
||||
button_data=button_data
|
||||
).display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == RESET:
|
||||
return Destination(RestartView)
|
||||
|
||||
elif button_data[selected_menu_num] == POWER_OFF:
|
||||
return Destination(PowerOffView)
|
||||
|
||||
|
||||
|
||||
class RestartView(View):
|
||||
def run(self):
|
||||
thread = RestartView.DoResetThread()
|
||||
thread.start()
|
||||
ResetScreen().display()
|
||||
|
||||
|
||||
class DoResetThread(BaseThread):
|
||||
def run(self):
|
||||
import time
|
||||
from subprocess import call
|
||||
|
||||
# Give the screen just enough time to display the reset message before
|
||||
# exiting.
|
||||
time.sleep(0.25)
|
||||
|
||||
# Kill the SeedSigner process; systemd will automatically restart it.
|
||||
# `.*` is a wildcard to detect either `python`` or `python3` and with or
|
||||
# without the `-u` flag.
|
||||
call("kill $(ps aux | grep '[p]ython.*main.py' | awk '{print $2}')", shell=True)
|
||||
|
||||
|
||||
|
||||
class PowerOffView(View):
|
||||
def run(self):
|
||||
thread = PowerOffView.PowerOffThread()
|
||||
thread.start()
|
||||
PowerOffScreen().display()
|
||||
|
||||
|
||||
class PowerOffThread(BaseThread):
|
||||
def run(self):
|
||||
import time
|
||||
from subprocess import call
|
||||
while self.keep_running:
|
||||
time.sleep(5)
|
||||
call("sudo shutdown --poweroff now", shell=True)
|
||||
|
||||
|
||||
|
||||
class NotYetImplementedView(View):
|
||||
"""
|
||||
Temporary View to use during dev.
|
||||
"""
|
||||
def run(self):
|
||||
WarningScreen(
|
||||
title="Work In Progress",
|
||||
status_headline="Not Yet Implemented",
|
||||
text="This is still on our to-do list!",
|
||||
button_data=["Back to Main Menu"],
|
||||
).display()
|
||||
|
||||
return Destination(MainMenuView)
|
||||
|
||||
|
||||
|
||||
class UnhandledExceptionView(View):
|
||||
def __init__(self, error: List[str]):
|
||||
self.error = error
|
||||
|
||||
|
||||
def run(self):
|
||||
DireWarningScreen(
|
||||
title="System Error",
|
||||
status_headline=self.error[0],
|
||||
text=self.error[1] + "\n" + self.error[2],
|
||||
button_data=["OK"],
|
||||
show_back_button=False,
|
||||
allow_text_overflow=True, # Fit what we can, let the rest go off the edges
|
||||
).display()
|
||||
|
||||
return Destination(MainMenuView, clear_history=True)
|
||||
@@ -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
|
||||
@@ -3,6 +3,7 @@ import pytest
|
||||
from mock import MagicMock
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.models.settings import Settings
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
|
||||
|
||||
|
||||
@@ -11,10 +12,6 @@ def test_singleton_init_fails():
|
||||
with pytest.raises(Exception):
|
||||
c = Controller()
|
||||
|
||||
def test_singleton_get_instance_without_configure_fails():
|
||||
""" Calling get_instance() without first calling configure_instance() should fail """
|
||||
with pytest.raises(Exception):
|
||||
c = Controller.get_instance()
|
||||
|
||||
def test_singleton_get_instance_preserves_state():
|
||||
""" Changes to the Controller singleton should be preserved across calls to get_instance() """
|
||||
@@ -25,38 +22,17 @@ def test_singleton_get_instance_preserves_state():
|
||||
Settings._instance = None
|
||||
Controller._instance = None
|
||||
|
||||
settings = """
|
||||
[system]
|
||||
debug = False
|
||||
default_language = en
|
||||
persistent_settings = False
|
||||
|
||||
[display]
|
||||
text_color = ORANGE
|
||||
qr_background_color = FFFFFF
|
||||
camera_rotation = 0
|
||||
|
||||
[wallet]
|
||||
network = main
|
||||
software = Prompt
|
||||
qr_density = 2
|
||||
custom_derivation = m/0/0
|
||||
compact_seedqr_enabled = False
|
||||
"""
|
||||
config = configparser.ConfigParser()
|
||||
config.read_string(settings)
|
||||
|
||||
# Initialize the instance and verify that it read the config settings
|
||||
Controller.configure_instance(config, disable_hardware=True)
|
||||
Controller.configure_instance(disable_hardware=True)
|
||||
controller = Controller.get_instance()
|
||||
assert controller.color == "ORANGE"
|
||||
assert controller.unverified_address is None
|
||||
|
||||
# Change a value in the instance...
|
||||
controller.color = "purple"
|
||||
controller.unverified_address = "123abc"
|
||||
|
||||
# ...get a new copy of the instance and confirm change
|
||||
controller = Controller.get_instance()
|
||||
assert controller.color == "purple"
|
||||
assert controller.unverified_address == "123abc"
|
||||
|
||||
|
||||
def test_missing_settings_get_defaults():
|
||||
@@ -69,30 +45,9 @@ def test_missing_settings_get_defaults():
|
||||
Settings._instance = None
|
||||
Controller._instance = None
|
||||
|
||||
# Intentionally omit `compact_seedqr_enabled` from settings:
|
||||
settings = """
|
||||
[system]
|
||||
debug = False
|
||||
default_language = en
|
||||
persistent_settings = False
|
||||
|
||||
[display]
|
||||
text_color = ORANGE
|
||||
qr_background_color = FFFFFF
|
||||
camera_rotation = 0
|
||||
|
||||
[wallet]
|
||||
network = main
|
||||
software = Prompt
|
||||
qr_density = 2
|
||||
custom_derivation = m/0/0
|
||||
"""
|
||||
config = configparser.ConfigParser()
|
||||
config.read_string(settings)
|
||||
|
||||
# Controller should parse the settings fine, even though a field is missing
|
||||
Controller.configure_instance(config, disable_hardware=True)
|
||||
Controller.configure_instance(disable_hardware=True)
|
||||
|
||||
# Controller should still have a default value
|
||||
controller = Controller.get_instance()
|
||||
assert controller.settings.compact_seedqr_enabled is False
|
||||
assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__DISABLED
|
||||
|
||||
@@ -2,17 +2,28 @@ import random
|
||||
|
||||
from embit import bip39
|
||||
from seedsigner.helpers import mnemonic_generation
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
|
||||
|
||||
|
||||
def test_dice_rolls():
|
||||
""" Given 99 random dice rolls, the resulting mnemonic should be valid. """
|
||||
""" Given random dice rolls, the resulting mnemonic should be valid. """
|
||||
dice_rolls = ""
|
||||
for i in range(0, 99):
|
||||
# Do not need truly rigorous random for this test
|
||||
dice_rolls += str(random.randint(0, 5))
|
||||
|
||||
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
|
||||
assert len(mnemonic) == 24
|
||||
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
|
||||
|
||||
dice_rolls = ""
|
||||
for i in range(0, 50):
|
||||
# Do not need truly rigorous random for this test
|
||||
dice_rolls += str(random.randint(0, 5))
|
||||
|
||||
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
|
||||
assert len(mnemonic) == 12
|
||||
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
|
||||
|
||||
|
||||
@@ -23,15 +34,35 @@ def test_calculate_checksum():
|
||||
"""
|
||||
# Test mnemonics from https://iancoleman.io/bip39/
|
||||
partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day"
|
||||
mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist=bip39.WORDLIST)
|
||||
mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
|
||||
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
|
||||
|
||||
partial_mnemonic = "bubble father debate ankle injury fence mesh evolve section wet coyote violin pyramid flower rent arrow round clutch myth safe base skin mobile"
|
||||
mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist=bip39.WORDLIST)
|
||||
mnemonic = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
|
||||
assert bip39.mnemonic_is_valid(" ".join(mnemonic))
|
||||
|
||||
|
||||
|
||||
def test_calculate_checksum_with_default_final_word():
|
||||
""" 11-word and 23-word mnemonics use word `0000` as a temp final word to complete
|
||||
the mnemonic.
|
||||
"""
|
||||
partial_mnemonic = "crawl focus rescue cable view pledge rather dinner cousin unfair day"
|
||||
mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
|
||||
|
||||
partial_mnemonic += " abandon"
|
||||
mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
|
||||
assert mnemonic1 == mnemonic2
|
||||
|
||||
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"
|
||||
mnemonic1 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
|
||||
|
||||
partial_mnemonic += " abandon"
|
||||
mnemonic2 = mnemonic_generation.calculate_checksum(partial_mnemonic.split(" "), wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
|
||||
assert mnemonic1 == mnemonic2
|
||||
|
||||
|
||||
|
||||
def test_verify_against_coldcard_sample():
|
||||
""" https://coldcard.com/docs/verifying-dice-roll-math """
|
||||
dice_rolls = "123456"
|
||||
@@ -70,3 +101,28 @@ def test_known_dice_rolls():
|
||||
assert bip39.mnemonic_is_valid(actual)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
|
||||
def test_50_dice_rolls():
|
||||
""" 50 dice roll input should yield the same 12-word mnemonic as iancoleman.io/bip39 """
|
||||
# Check "Show entropy details", paste in dice_rolls sequence, click "Hex", select "Mnemonic Length" as "12 Words"
|
||||
dice_rolls = "12345612345612345612345612345612345612345612345612"
|
||||
expected = "unveil nice picture region tragic fault cream strike tourist control recipe tourist"
|
||||
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
|
||||
actual = " ".join(mnemonic)
|
||||
assert bip39.mnemonic_is_valid(actual)
|
||||
assert actual == expected
|
||||
|
||||
dice_rolls = "11111111111111111111111111111111111111111111111111"
|
||||
expected = "diet glad hat rural panther lawsuit act drop gallery urge where fit"
|
||||
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
|
||||
actual = " ".join(mnemonic)
|
||||
assert bip39.mnemonic_is_valid(actual)
|
||||
assert actual == expected
|
||||
|
||||
dice_rolls = "66666666666666666666666666666666666666666666666666"
|
||||
expected = "senior morning song proud recycle toy search apple trigger lend vibrant arrest"
|
||||
mnemonic = mnemonic_generation.generate_mnemonic_from_dice(dice_rolls)
|
||||
actual = " ".join(mnemonic)
|
||||
assert bip39.mnemonic_is_valid(actual)
|
||||
assert actual == expected
|
||||
|
||||
@@ -6,6 +6,8 @@ from seedsigner.helpers.ur2.bytewords import decode
|
||||
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
|
||||
from seedsigner.models.encode_qr import EncodeQR
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
|
||||
def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
|
||||
@@ -15,8 +17,8 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
|
||||
print(seed_phrase)
|
||||
assert len(seed_phrase) == mnemonic_length
|
||||
|
||||
e = EncodeQR(seed_phrase=seed_phrase, qr_type=qr_type, wordlist=bip39.WORDLIST)
|
||||
data = e.nextPart()
|
||||
e = EncodeQR(seed_phrase=seed_phrase, qr_type=qr_type)
|
||||
data = e.next_part()
|
||||
print(data)
|
||||
|
||||
qr = QR()
|
||||
@@ -27,11 +29,11 @@ def run_encode_decode_test(entropy: bytes, mnemonic_length, qr_type):
|
||||
border=3
|
||||
)
|
||||
|
||||
decoder = DecodeQR(wordlist=bip39.WORDLIST)
|
||||
status = decoder.addImage(image)
|
||||
decoder = DecodeQR()
|
||||
status = decoder.add_image(image)
|
||||
assert status == DecodeQRStatus.COMPLETE
|
||||
|
||||
decoded_seed_phrase = decoder.getSeedPhrase()
|
||||
decoded_seed_phrase = decoder.get_seed_phrase()
|
||||
print(decoded_seed_phrase)
|
||||
assert seed_phrase == decoded_seed_phrase
|
||||
|
||||
@@ -42,10 +44,10 @@ def test_standard_seedqr_encode_decode_():
|
||||
them back again to their original mnemonic seed phrase.
|
||||
"""
|
||||
# 24-word seed
|
||||
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEEDQR)
|
||||
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEED__SEEDQR)
|
||||
|
||||
# 12-word seed
|
||||
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEEDQR)
|
||||
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEED__SEEDQR)
|
||||
|
||||
|
||||
|
||||
@@ -54,10 +56,10 @@ def test_compact_seedqr_encode_decode():
|
||||
them back again to their original mnemonic seed phrase.
|
||||
"""
|
||||
# 24-word seed
|
||||
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(os.urandom(32), mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed
|
||||
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(os.urandom(16), mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
|
||||
|
||||
@@ -67,40 +69,40 @@ def test_compact_seedqr_handles_null_bytes():
|
||||
"""
|
||||
# 24-word seed, null bytes at the front
|
||||
entropy = b'\x00' + os.urandom(31)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 24-word seed, null bytes in the middle
|
||||
entropy = os.urandom(10) + b'\x00' + os.urandom(21)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 24-word seed, null bytes at the end
|
||||
entropy = os.urandom(31) + b'\x00'
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 24-word seed, multiple null bytes
|
||||
entropy = os.urandom(5) + b'\x00' + os.urandom(5) + b'\x00' + os.urandom(20)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 24-word seed, multiple null bytes in a row
|
||||
entropy = os.urandom(10) + b'\x00\x00' + os.urandom(20)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=24, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, null bytes at the beginning
|
||||
entropy = b'\x00' + os.urandom(15)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, null bytes in the middle
|
||||
entropy = os.urandom(5) + b'\x00' + os.urandom(10)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, null bytes at the end
|
||||
entropy = os.urandom(15) + b'\x00'
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, multiple null bytes
|
||||
entropy = os.urandom(5) + b'\x00' + os.urandom(5) + b'\x00' + os.urandom(4)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||
# 12-word seed, multiple null bytes in a row
|
||||
entropy = os.urandom(10) + b'\x00\x00' + os.urandom(4)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.COMPACTSEEDQR)
|
||||
run_encode_decode_test(entropy, mnemonic_length=12, qr_type=QRType.SEED__COMPACTSEEDQR)
|
||||
|
||||