Merge branch 'SeedSigner:dev' into Swap-Photo-in-main-Readme

This commit is contained in:
Conrad Donovan
2023-09-13 17:49:18 -06:00
committed by GitHub
35 changed files with 1040 additions and 466 deletions
+16 -16
View File
@@ -82,27 +82,27 @@ Notes:
## A Special Note On Minimizing Trust
As is the nature of pre-packaged software downloads, downloading and using the prepared SeedSigner release images means implicitly placing trust in the individual preparing those images; in our project the release images are prepared and signed by the eponymous creator of the project, SeedSigner "the person". That individual is additionally the only person in possession of the PGP keys that are used to sign the release images.
However, one of the many advantages of the open source software model is that the need for this kind of trust can be negated by our users' ability to (1) review the project's source code and (2) assemble the operating image necessary to use the software themselves. From our project's inception, instructions to build a SeedSigner operating image (using precisely the same process that is used to create the prepared release images) have been made availabile. We have put a lot of thought and work into making these instructions easy to understand and follow, even for less technical users. These instructions can be found [here](docs/manual_installation.md).
However, one of the many advantages of the open source software model is that the need for this kind of trust can be negated by our users' ability to (1) review the project's source code and (2) assemble the operating image necessary to use the software themselves. From our project's inception, instructions to build a SeedSigner operating image (using precisely the same process that is used to create the prepared release images) have been made available. We have put a lot of thought and work into making these instructions easy to understand and follow, even for less technical users. These instructions can be found [here](docs/manual_installation.md).
## Downloading the Software
Download the current Version (0.6.0) software image that is compatible with your Raspberry Pi Hardware. The Pi Zero 1.3 is the most common and recommended board.
Download the current Version (0.7.0) software image that is compatible with your Raspberry Pi Hardware. The Pi Zero 1.3 is the most common and recommended board.
| Board | Download Image Link/Name |
| --------------------- | --------------------------------- |
|**[Raspberry Pi Zero 1.3](https://www.raspberrypi.com/products/raspberry-pi-zero/)** |[`seedsigner_os.0.6.0.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner_os.0.6.0.pi0.img) |
|[Raspberry Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) |[`seedsigner_os.0.6.0.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner_os.0.6.0.pi0.img) |
|[Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) |[`seedsigner_os.0.6.0.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner_os.0.6.0.pi02w.img) |
|[Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) |[`seedsigner_os.0.6.0.pi2.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner_os.0.6.0.pi2.img) |
|[Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) |[`seedsigner_os.0.6.0.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner_os.0.6.0.pi02w.img) |
|[Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) |[`seedsigner_os.0.6.0.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner_os.0.6.0.pi4.img) |
|[Raspberry Pi 400](https://www.raspberrypi.com/products/raspberry-pi-400-unit/) |[`seedsigner_os.0.6.0.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner_os.0.6.0.pi4.img) |
|**[Raspberry Pi Zero 1.3](https://www.raspberrypi.com/products/raspberry-pi-zero/)** |[`seedsigner_os.0.7.0.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner_os.0.7.0.pi0.img) |
|[Raspberry Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) |[`seedsigner_os.0.7.0.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner_os.0.7.0.pi0.img) |
|[Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) |[`seedsigner_os.0.7.0.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner_os.0.7.0.pi02w.img) |
|[Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) |[`seedsigner_os.0.7.0.pi2.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner_os.0.7.0.pi2.img) |
|[Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) |[`seedsigner_os.0.7.0.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner_os.0.7.0.pi02w.img) |
|[Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) |[`seedsigner_os.0.7.0.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner_os.0.7.0.pi4.img) |
|[Raspberry Pi 400](https://www.raspberrypi.com/products/raspberry-pi-400-unit/) |[`seedsigner_os.0.7.0.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner_os.0.7.0.pi4.img) |
Note: If you have physically removed the WiFi component from your board, you will still use the image file of the original(un-modified) hardware. (Our files are compiled/based on the *processor* architecture). Although it is better to spend a few minutes upfront to determine which specific Pi hardware/model you have, if you are still unsure which hardware you have, you can try using the pi0.img file. Making an incorrect choice here will not ruin your board, because this is software, not firmware.
**also download** these 2 signature verification files to the same folder
[The Plaintext manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner.0.6.0.sha256)
[The Signature of the manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.6.0/seedsigner.0.6.0.sha256.sig)
[The Plaintext manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner.0.7.0.sha256.txt)
[The Signature of the manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.7.0/seedsigner.0.7.0.sha256.txt.sig)
Users familiar with older versions of the SeedSigner software might be surprised with how fast their software downloads now are, because since version 0.6.0 the software image files are now 100x smaller! Each image file is now under 42 Megabytes so your downloads and verifications will be very quick now (and might even seem *too* quick)!
@@ -131,7 +131,7 @@ The result should confirm that 1 key was *either* imported or updated. *Ignore*
Next, you will run the *verify* command on the signature (.sig) file. (*Verify* must be run from inside the same folder that you downloaded the files into earlier. The `*`'s in this command will auto-fill the version from your current folder, so it should be copied and pasted as-is.)
```
gpg --verify seedsigner.0.6.*.sha256.sig
gpg --verify seedsigner.0.7.*.sha256.txt.sig
```
When the verify command completes successfully, it should display output like this:
@@ -201,21 +201,21 @@ Now that you have confirmed that you do have the real SeedSigner Project's Publi
**On Linux or OSX:** Run this command
```
shasum -a 256 --ignore-missing --check seedsigner.0.6.*.sha256
shasum -a 256 --ignore-missing --check seedsigner.0.7.*.sha256.txt
```
**On Windows (inside Powershell):** Run this command
```
CertUtil -hashfile seedsigner_os.0.6.0.Insert_Your_Pi_Models_binary_here_For_Example_pi02w.img SHA256
CertUtil -hashfile seedsigner_os.0.7.0.Insert_Your_Pi_Models_binary_here_For_Example_pi02w.img SHA256
```
On Windows, you must then manually compare the resulting file hash value to the corresponding hash value shown inside the .SHA256 cleartext file.
<BR>
Wait up to 30 seconds for the command to complete, and it should display:
```
seedsigner_os.0.6.x.[Your_Pi_Model_For_Example:pi02w].img: OK
seedsigner_os.0.7.x.[Your_Pi_Model_For_Example:pi02w].img: OK
```
**If you receive the "OK" message** for your **seedsigner_os.0.6.x.[Your_Pi_Model_For_Example:pi02w].img file**, as shown above, then your verification is fully complete!
**If you receive the "OK" message** for your **seedsigner_os.0.7.x.[Your_Pi_Model_For_Example:pi02w].img file**, as shown above, then your verification is fully complete!
**All of your downloaded files have now been confirmed as both authentic and unaltered!** You can proceed to create/write your MicroSD card😄😄 !!
If your file result shows "FAILED", then you must stop here immediately. Do not continue. Contact us for assistance at the Telegram group address above.
+1 -1
View File
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
setuptools.setup(
name="seedsigner",
version="0.6.0",
version="0.7.0",
author="SeedSigner",
author_email="author@example.com",
description="Build an offline, airgapped Bitcoin signing device for less than $50!",
+43 -3
View File
@@ -130,6 +130,7 @@ class Controller(Singleton):
back_stack: BackStack = None
screensaver: 'ScreensaverScreen' = None
toast_notification_thread: 'BaseToastOverlayManagerThread' = None
@classmethod
@@ -181,7 +182,7 @@ class Controller(Singleton):
controller.back_stack = BackStack()
# Other behavior constants
controller.screensaver_activation_ms = 120 * 1000
controller.screensaver_activation_ms = 2 * 60 * 1000 # two minutes
background_import_thread = BackgroundImportThread()
background_import_thread.start()
@@ -240,8 +241,9 @@ class Controller(Singleton):
* initial_destination: The first View to run. If None, the MainMenuView is
used. Only used by the test suite.
"""
from .views import MainMenuView, BackStackView
from .views.screensaver import OpeningSplashScreen
from seedsigner.views import MainMenuView, BackStackView
from seedsigner.views.screensaver import OpeningSplashScreen
from seedsigner.gui.toast import RemoveSDCardToastManagerThread
OpeningSplashScreen().start()
@@ -275,6 +277,10 @@ class Controller(Singleton):
next_destination = initial_destination
else:
next_destination = Destination(MainMenuView)
# Set up our one-time toast notification tip to remove the SD card
self.activate_toast(RemoveSDCardToastManagerThread())
while True:
# Destination(None) is a special case; render the Home screen
if next_destination.View_cls is None:
@@ -354,6 +360,9 @@ class Controller(Singleton):
from seedsigner.gui.renderer import Renderer
if self.is_screensaver_running:
self.screensaver.stop()
if self.toast_notification_thread and self.toast_notification_thread.is_alive():
self.toast_notification_thread.stop()
# Clear the screen when exiting
print("Clearing screen, exiting")
@@ -366,12 +375,43 @@ class Controller(Singleton):
def start_screensaver(self):
# If a toast is running, tell it to give up the Renderer.lock; it will then
# block until the screensaver is done, at which point the toast can re-acquire
# the Renderer.lock and resume where it left off.
if self.toast_notification_thread and self.toast_notification_thread.is_alive():
print(f"Controller: settings toggle_render_lock for {self.toast_notification_thread.__class__.__name__}")
self.toast_notification_thread.toggle_renderer_lock()
print("Controller: Starting screensaver")
if not self.screensaver:
# Do a lazy/late import and instantiation to reduce Controller initial startup time
from seedsigner.views.screensaver import ScreensaverScreen
from seedsigner.hardware.buttons import HardwareButtons
self.screensaver = ScreensaverScreen(HardwareButtons.get_instance())
# Start the screensaver, but it will block until it can acquire the Renderer.lock.
self.screensaver.start()
print("Controller: Screensaver started")
def activate_toast(self, toast_manager_thread: 'BaseToastOverlayManagerThread'):
"""
Ensures that the Controller has explicit control over which processes get to
claim the Renderer.lock and which need to (potentially) release it.
"""
if self.is_screensaver_running:
# New toast notifications break out of the Screensaver
print("Controller: stopping screensaver")
self.screensaver.stop()
if self.toast_notification_thread and self.toast_notification_thread.is_alive():
# Can only run one toast at a time
print(f"Controller: stopping {self.toast_notification_thread.__class__.__name__}")
self.toast_notification_thread.stop()
self.toast_notification_thread = toast_manager_thread
print(f"Controller: starting {self.toast_notification_thread.__class__.__name__}")
self.toast_notification_thread.start()
def handle_exception(self, e) -> Destination:
+32 -105
View File
@@ -35,6 +35,7 @@ class GUIConstants:
ICON_FONT_SIZE = 22
ICON_INLINE_FONT_SIZE = 24
ICON_LARGE_BUTTON_SIZE = 48
ICON_TOAST_FONT_SIZE = 30
ICON_PRIMARY_SCREEN_SIZE = 50
TOP_NAV_TITLE_FONT_NAME = "OpenSans-SemiBold"
@@ -138,7 +139,6 @@ class SeedSignerIconConstants:
BRIGHTNESS = "\ue91d"
MICROSD = "\ue91e"
QRCODE = "\ue91f"
SDCARD = "\ue920"
MIN_VALUE = SCAN
MAX_VALUE = QRCODE
@@ -287,6 +287,7 @@ class TextArea(BaseComponent):
supersampling_factor: int = 1
auto_line_break: bool = True
allow_text_overflow: bool = False
height_ignores_below_baseline: bool = False # If True, characters that render below the baseline (e.g. "pqgy") will not affect the final height calculation
def __post_init__(self):
@@ -316,11 +317,9 @@ class TextArea(BaseComponent):
font = Fonts.get_font(self.font_name, self.font_size)
# Note: from the baseline anchor, `top` is a negative number while `bottom`
# conveys the pixels used below the baseline (e.g. in "py").
# For consistency, ensure we have a full-height character above baseline.
# Also include some "below baseline" chars.
measurement_chars = "Agjpqy"
(left, top, right, bottom) = font.getbbox(self.text + measurement_chars, anchor="ls")
# conveys the height of the pixels that rendered below the baseline, if any
# (e.g. "py" in "python").
(left, top, right, bottom) = font.getbbox(self.text, anchor="ls")
self.text_height_above_baseline = -1 * top
self.text_height_below_baseline = bottom
@@ -333,12 +332,14 @@ class TextArea(BaseComponent):
# Calculate the actual height
if len(self.text_lines) == 1:
total_text_height = self.text_height_above_baseline + self.text_height_below_baseline
total_text_height = self.text_height_above_baseline
if not self.height_ignores_below_baseline:
total_text_height += self.text_height_below_baseline
else:
# Multiply for the number of lines plus the spacer
total_text_height = self.text_height_above_baseline * len(self.text_lines) + self.line_spacing * (len(self.text_lines) - 1)
if re.findall(f"[gjpqy]", self.text_lines[-1]["text"]):
if not self.height_ignores_below_baseline and re.findall(f"[gjpqy]", self.text_lines[-1]["text"]):
# Last line has at least one char that dips below baseline
total_text_height += self.text_height_below_baseline
@@ -356,40 +357,37 @@ class TextArea(BaseComponent):
else:
# Vertically center the text's starting point
if len(self.text_lines) == 1:
# For consistency when used in TopNav and elsewhere, ignore the
# text's pixels below the baseline.
# In other words: "Home" and "Something" will get the same text_y,
# even though the "g" dips below baseline.
self.text_y += int(self.height - (total_text_height - self.text_height_below_baseline))/2
else:
# Vertically center for the full height.
self.text_y += int(self.height - (total_text_height))/2
self.text_y += int(self.height - total_text_height)/2
def render(self):
# Render to a temp img scaled up by self.supersampling_factor, then resize down
# with bicubic resampling.
# TODO: Store resulting super-sampled image as a member var in __post_init__ and
# just re-paste it here.
# Add a `resample_padding` above and below when supersampling to avoid edge
# effects (resized text that's right up against the top/bottom gets slightly
# dimmer at the edge otherwise).
if self.font_size < 20 and (not self.supersampling_factor or self.supersampling_factor == 1):
self.supersampling_factor = 2
actual_text_height = self.height
if self.height_ignores_below_baseline:
# Even though we're ignoring the pixels below the baseline for spacing
# purposes, we have to make sure we don't crop those pixels out during the
# supersampling operations here.
actual_text_height += self.text_height_below_baseline
resample_padding = 10 if self.supersampling_factor > 1.0 else 0
img = Image.new(
"RGBA",
(
self.width * self.supersampling_factor,
(self.height + 2*resample_padding) * self.supersampling_factor
(actual_text_height + 2*resample_padding) * self.supersampling_factor
),
self.background_color
)
draw = ImageDraw.Draw(img)
# draw.line((0, resample_padding * self.supersampling_factor, self.width * self.supersampling_factor, resample_padding * self.supersampling_factor), fill="blue", width=1)
# draw.line((0, (resample_padding + self.height) * self.supersampling_factor, self.width * self.supersampling_factor, (resample_padding + self.height) * self.supersampling_factor), fill="red", width=1)
cur_y = (self.text_y + resample_padding) * self.supersampling_factor
supersampled_font = Fonts.get_font(self.font_name, int(self.supersampling_factor * self.font_size))
if self.is_text_centered:
@@ -409,13 +407,20 @@ class TextArea(BaseComponent):
text_x = self.min_text_x + int(line["text_width"]/2)
draw.text((text_x * self.supersampling_factor, cur_y), line["text"], fill=self.font_color, font=supersampled_font, anchor=anchor)
# Debugging: show the exact vertical extents of each line of text
# draw.line((0, cur_y - self.text_height_above_baseline * self.supersampling_factor, self.width * self.supersampling_factor, cur_y - self.text_height_above_baseline * self.supersampling_factor), fill="red", width=int(self.supersampling_factor))
# draw.line((0, cur_y, self.width * self.supersampling_factor, cur_y), fill="red", width=int(self.supersampling_factor))
cur_y += (self.text_height_above_baseline + self.line_spacing) * self.supersampling_factor
# Crop off the top_padding and resize the result down to onscreen size
if self.supersampling_factor > 1.0:
resized = img.resize((self.width, self.height + 2*resample_padding), Image.LANCZOS)
resized = img.resize((self.width, actual_text_height + 2*resample_padding), Image.LANCZOS)
sharpened = resized.filter(ImageFilter.SHARPEN)
img = sharpened.crop((0, resample_padding, self.width, self.height + 2*resample_padding))
# Crop args are actually (left, top, WIDTH, HEIGHT)
img = sharpened.crop((0, resample_padding, self.width, actual_text_height + resample_padding))
self.canvas.paste(img, (self.screen_x, self.screen_y))
@@ -567,76 +572,6 @@ class IconTextLine(BaseComponent):
@dataclass
class ToastOverlay(BaseComponent):
icon_name: str = None
color: str = None
label_text: str = None
def __post_init__(self):
super().__post_init__()
self.icon = Icon(
image_draw=self.image_draw,
canvas=self.canvas,
screen_x=20,
screen_y=190,
icon_name=self.icon_name,
icon_size=30,
icon_color=self.color
)
self.label = TextArea(
image_draw=self.image_draw,
canvas=self.canvas,
text=self.label_text,
font_size=19,
font_color=self.color,
edge_padding=0,
is_text_centered=False,
auto_line_break=False,
width=160,
height=20,
screen_x=55,
screen_y=195,
allow_text_overflow=False
)
def render(self):
import time
from seedsigner.controller import Controller
from seedsigner.hardware.buttons import HardwareButtons
self.controller: Controller = Controller.get_instance()
self.current_screen = self.renderer.canvas.copy()
buttons = HardwareButtons.get_instance()
# Special case when screensaver is running
if self.controller.is_screensaver_running:
buttons.override_ind = True
self.image_draw.rounded_rectangle(
( GUIConstants.EDGE_PADDING + 2, self.canvas_height - 60, self.canvas_width - GUIConstants.EDGE_PADDING - 2, self.canvas_width - GUIConstants.EDGE_PADDING - 2),
fill=GUIConstants.BACKGROUND_COLOR,
radius=8,
outline=self.color,
width=2,
)
self.icon.render()
self.label.render()
self.renderer.show_image()
t_end = time.time() + 3
while time.time() < t_end:
if buttons.has_any_input():
break
self.renderer.show_image(self.current_screen)
@dataclass
class FormattedAddress(BaseComponent):
"""
@@ -1283,7 +1218,7 @@ class TopNav(BaseComponent):
icon_name=SeedSignerIconConstants.BACK,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=GUIConstants.EDGE_PADDING,
screen_y=GUIConstants.EDGE_PADDING - 1, # Text can't perfectly vertically center relative to the button; shifting it down 1px looks better.
width=GUIConstants.TOP_NAV_BUTTON_SIZE,
height=GUIConstants.TOP_NAV_BUTTON_SIZE,
)
@@ -1327,6 +1262,7 @@ class TopNav(BaseComponent):
is_text_centered=True,
font_name=self.font_name,
font_size=self.font_size,
height_ignores_below_baseline=True, # Consistently vertically center text, ignoring chars that render below baseline (e.g. "pqyj")
)
@@ -1354,15 +1290,6 @@ class TopNav(BaseComponent):
self.right_button.is_selected = self.is_selected
self.right_button.render()
# self.image_draw.text(
# (self.text_x, self.text_y),
# self.text,
# font=self.font,
# fill=self.font_color,
# stroke_width=1,
# stroke_fill=GUIConstants.BACKGROUND_COLOR,
# )
def linear_interp(a, b, t):
@@ -174,36 +174,3 @@ class ScanScreen(BaseScreen):
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"]
self.show_back_button = False
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
))
+17 -43
View File
@@ -3,17 +3,15 @@ import time
from dataclasses import dataclass
from PIL import Image, ImageDraw, ImageColor
from typing import Any, List, Tuple
from seedsigner.gui.components import (GUIConstants,
BaseComponent, Button, Icon, IconButton, LargeIconButton,
SeedSignerIconConstants, TopNav, TextArea, load_image)
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
from seedsigner.gui.renderer import Renderer
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
from seedsigner.models.settings import SettingsConstants
from ..components import (FontAwesomeIconConstants, GUIConstants, BaseComponent, Button, Icon, IconButton,
LargeIconButton, SeedSignerIconConstants, TopNav, TextArea, load_image, ToastOverlay,
Fonts)
from seedsigner.hardware.buttons import HardwareButtonsConstants, HardwareButtons
from seedsigner.models.settings import SettingsConstants
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
# Must be huge numbers to avoid conflicting with the selected_button returned by the
@@ -828,6 +826,7 @@ class LargeIconStatusScreen(ButtonListScreen):
status_color: str = GUIConstants.SUCCESS_COLOR
status_headline: str = "Success!" # The colored text under the large icon
text: str = "" # The body text of the screen
text_edge_padding: int = GUIConstants.EDGE_PADDING
button_data: list = None
allow_text_overflow: bool = False
@@ -863,7 +862,7 @@ class LargeIconStatusScreen(ButtonListScreen):
height=self.buttons[0].screen_y - next_y,
text=self.text,
width=self.canvas_width,
edge_padding=GUIConstants.EDGE_PADDING, # Don't render all the way up to the far left/right edges
edge_padding=self.text_edge_padding, # Don't render all the way up to the far left/right edges
screen_y=next_y,
allow_text_overflow=self.allow_text_overflow,
))
@@ -937,6 +936,7 @@ class WarningEdgesThread(BaseThread):
@dataclass
class WarningEdgesMixin:
status_color: str = GUIConstants.WARNING_COLOR
text_edge_padding: int = 2 * GUIConstants.EDGE_PADDING
def __post_init__(self):
super().__post_init__()
@@ -1222,37 +1222,11 @@ class KeyboardScreen(BaseTopNavScreen):
"""
return False
class MicroSDToastScreen(BaseScreen):
"""
This screen is an overlay with special behavior with the ToastOverlay component. The ToastOverlay component overides all button
input and captures the existing screen content and stashes it to be restored once X second passes or any button is pressed. The
display method on this screen will not complete until after the ToastOverlay render method is complete it's takeover of the screen.
"""
def __init__(self, action):
self.action = action
self.toast = None
super().__init__()
def _run(self):
return
def _render(self):
from seedsigner.hardware.microsd import MicroSD
if self.action == MicroSD.ACTION__REMOVED:
self.toast = ToastOverlay(
icon_name=SeedSignerIconConstants.MICROSD,
color=GUIConstants.NOTIFICATION_COLOR,
label_text="MicroSD removed"
)
elif self.action == MicroSD.ACTION__INSERTED:
self.toast = ToastOverlay(
icon_name=SeedSignerIconConstants.MICROSD,
color=GUIConstants.NOTIFICATION_COLOR,
label_text="MicroSD inserted"
)
self.toast.render()
@dataclass
class MainMenuScreen(LargeButtonScreen):
# Override LargeButtonScreen defaults
title_font_size: int = 26
show_back_button: bool = False
show_power_button: bool = True
@@ -296,3 +296,36 @@ class DonateScreen(BaseTopNavScreen):
supersampling_factor=1,
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
))
@dataclass
class SettingsQRConfirmationScreen(ButtonListScreen):
config_name: str = None
title: str = "Settings QR"
status_message: str = "Settings updated..."
is_bottom_list: bool = True
def __post_init__(self):
# Customize defaults
self.button_data = ["Home"]
self.show_back_button = False
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=self.status_message,
is_text_centered=True,
auto_line_break=True,
screen_y=start_y
))
+49 -33
View File
@@ -1,13 +1,13 @@
import time
from dataclasses import dataclass
from typing import Any
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, FormattedAddress, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea
from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, ButtonListScreen, KeyboardScreen
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.helpers.qr import QR
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
@@ -22,7 +22,7 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
super().__post_init__()
self.camera = Camera.get_instance()
self.camera.start_video_stream_mode(resolution=(240, 240), framerate=24, format="rgb")
self.camera.start_video_stream_mode(resolution=(self.canvas_width, self.canvas_height), framerate=24, format="rgb")
def _run(self):
@@ -32,27 +32,7 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
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)
# Check for BACK button press
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()
@@ -60,7 +40,15 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
self.camera.stop_video_stream_mode()
return RET_CODE__BACK_BUTTON
elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_PRESS):
frame = self.camera.read_video_stream(as_image=True)
if frame is None:
# Camera probably isn't ready yet
time.sleep(0.01)
continue
# Check for joystick click to take final entropy image
if 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()
@@ -83,6 +71,29 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
return preview_images
# If we're still here, it's just another preview frame loop
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:
# Keep a moving window of the last n preview frames; pop the oldest
# before we add the currest frame.
preview_images.pop(0)
preview_images.append(frame)
@dataclass
@@ -240,12 +251,13 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
self.components.append(TextArea(
text=f"""Your input: \"{selection_text}\"""",
screen_y=self.top_nav.height,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING - 2, # Nudge to last line doesn't get too close to "Next" button
height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline
))
# ...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(
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
first_bits_line = TextArea(
text=keeper_selected_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
font_size=bit_font_size,
@@ -253,10 +265,13 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
screen_x=bit_display_x,
screen_y=screen_y,
is_text_centered=False,
))
)
self.components.append(first_bits_line)
# Render the least significant bits that will be replaced by the checksum in a
# de-emphasized font color.
if "_" in discard_selected_bits:
screen_y += int(first_bits_line.height/2) # center the underscores vertically like hypens
self.components.append(TextArea(
text=discard_selected_bits,
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
@@ -272,7 +287,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
self.components.append(TextArea(
text="Checksum",
edge_padding=0,
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
screen_y=first_bits_line.screen_y + first_bits_line.height + 2*GUIConstants.COMPONENT_PADDING,
))
# ...and its actual bits. Prepend spacers to keep vertical alignment
@@ -288,7 +303,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
font_size=bit_font_size,
edge_padding=0,
screen_x=bit_display_x,
screen_y=screen_y,
screen_y=screen_y + int(first_bits_line.height/2), # center the underscores vertically like hypens
is_text_centered=False,
))
@@ -308,6 +323,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
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,
height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline
))
# Once again show the bits that came from the user's entropy...
+230
View File
@@ -0,0 +1,230 @@
import time
from dataclasses import dataclass
from seedsigner.gui.components import BaseComponent, GUIConstants, Icon, SeedSignerIconConstants, TextArea
from seedsigner.models.threads import BaseThread
@dataclass
class ToastOverlay(BaseComponent):
icon_name: str = None
color: str = GUIConstants.NOTIFICATION_COLOR
label_text: str = None
height: int = GUIConstants.ICON_TOAST_FONT_SIZE + 2*GUIConstants.EDGE_PADDING
font_size: int = 19
outline_thickness: int = 2 # pixels
def __post_init__(self):
super().__post_init__()
self.icon = Icon(
image_draw=self.image_draw,
canvas=self.canvas,
screen_x=self.outline_thickness + 2*GUIConstants.EDGE_PADDING, # Push the icon further from the left edge than strictly necessary
icon_name=self.icon_name,
icon_size=GUIConstants.ICON_TOAST_FONT_SIZE,
icon_color=self.color
)
self.icon.screen_y = self.canvas_height - self.height + int((self.height - self.icon.height)/2)
self.label = TextArea(
image_draw=self.image_draw,
canvas=self.canvas,
text=self.label_text,
font_size=self.font_size,
font_color=self.color,
edge_padding=0,
is_text_centered=False,
auto_line_break=True,
width=self.canvas_width - self.icon.screen_x - self.icon.width - GUIConstants.COMPONENT_PADDING - self.outline_thickness,
screen_x=self.icon.screen_x + self.icon.width + GUIConstants.COMPONENT_PADDING,
allow_text_overflow=False,
)
# Vertically center the message within the toast (for single- or multi-line
# messages).
self.label.screen_y = self.canvas_height - self.height + self.outline_thickness + int((self.height - 2*self.outline_thickness - self.label.height)/2)
def render(self):
# Render the toast's solid background
self.image_draw.rounded_rectangle(
(0, self.canvas_height - self.height, self.canvas_width, self.canvas_height),
fill=GUIConstants.BACKGROUND_COLOR,
radius=8,
outline=self.color,
width=self.outline_thickness,
)
# Draw the toast visual elements
self.icon.render()
self.label.render()
self.renderer.show_image()
class BaseToastOverlayManagerThread(BaseThread):
"""
The toast notification popup consists of a gui component (`ToastOverlay`) and this
manager thread that the Controller will use to coordinate handing off resources
between competing toasts, the screensaver, and the current underlying Screen.
Controller should set BaseThread.keep_running = False to terminate the toast when it
needs to be removed or replaced.
Controller should set toggle_renderer_lock = True to make the toast temporarily
release the Renderer.lock so another process (e.g. screensaver) can use it. The toast
thread will immediately try to reacquire the lock, but will have to block and wait
until it's available again. Note that this thread will be unresponsive while it
waits to reacquire the lock!
Note: any process can call lock.release() but it simplifies the logic to try to keep
each process aware of whether it is currently holding the lock or not (i.e. it's
better for the "owner" thread to release the lock itself).
"""
def __init__(self,
activation_delay: int = 0, # seconds before toast is displayed
duration: int = 3, # seconds toast is displayed
):
from seedsigner.controller import Controller
from seedsigner.gui.renderer import Renderer
from seedsigner.hardware.buttons import HardwareButtons
super().__init__()
self.activation_delay: int = activation_delay
self.duration: int = duration
self._toggle_renderer_lock: bool = False
self.renderer = Renderer.get_instance()
self.controller = Controller.get_instance()
self.hw_inputs = HardwareButtons.get_instance()
# Special case when screensaver is running
self.hw_inputs.override_ind = True
self.toast = self.instantiate_toast()
def instantiate_toast(self) -> ToastOverlay:
raise Exception("Must be implemented by subclass")
def should_keep_running(self) -> bool:
""" Placeholder for custom exit conditions """
return True
def toggle_renderer_lock(self):
self._toggle_renderer_lock = True
def run(self):
print(f"{self.__class__.__name__}: started")
start = time.time()
while time.time() - start < self.activation_delay:
if self.hw_inputs.has_any_input():
# User has pressed a button, cancel the toast
print(f"{self.__class__.__name__}: Canceling toast due to user input")
return
time.sleep(0.1)
try:
# Hold onto the Renderer lock so we're guaranteed to restore the original
# screen before any other listener can get a screen write in.
print(f"{self.__class__.__name__}: Acquiring lock")
self.renderer.lock.acquire()
print(f"{self.__class__.__name__}: Lock acquired")
has_rendered = False
previous_screen_state = None
while self.keep_running and self.should_keep_running():
if self.hw_inputs.has_any_input():
# User has pressed a button, hide the toast
print(f"{self.__class__.__name__}: Exiting due to user input")
break
if self._toggle_renderer_lock:
# Controller has notified us that another process needs the lock
print(f"{self.__class__.__name__}: Releasing lock")
self._toggle_renderer_lock = False
self.renderer.lock.release()
# pause to avoid race conditions reacquiring the lock
while not self.renderer.lock.locked():
# Wait for a different process to grab the lock
time.sleep(0.1)
# Block while waiting to reaquire the lock
print(f"{self.__class__.__name__}: Blocking to re-acquire lock")
self.renderer.lock.acquire()
print(f"{self.__class__.__name__}: Lock re-acquired")
if not has_rendered:
previous_screen_state = self.renderer.canvas.copy()
print(f"{self.__class__.__name__}: Showing toast")
self.toast.render()
has_rendered = True
if time.time() - start > self.activation_delay + self.duration and has_rendered:
print(f"{self.__class__.__name__}: Hiding toast")
break
# Free up cpu resources for main thread
time.sleep(0.1)
finally:
print(f"{self.__class__.__name__}: exiting")
if has_rendered and self.renderer.lock.locked():
# As far as we know, we currently hold the Renderer.lock
self.renderer.show_image(previous_screen_state)
print(f"{self.__class__.__name__}: restored previous screen state")
# We're done, release the lock
self.renderer.lock.release()
class RemoveSDCardToastManagerThread(BaseToastOverlayManagerThread):
def __init__(self, activation_delay=3):
# Note: activation_delay is configurable so the screenshot generator can get the
# toast to immediately render.
super().__init__(
activation_delay=activation_delay, # seconds
duration=1e6, # seconds ("forever")
)
def instantiate_toast(self) -> ToastOverlay:
return ToastOverlay(
icon_name=SeedSignerIconConstants.MICROSD,
label_text="You can remove\nthe SD card now",
font_size=GUIConstants.BODY_FONT_SIZE,
height=GUIConstants.BODY_FONT_SIZE * 2 + GUIConstants.BODY_LINE_SPACING + GUIConstants.EDGE_PADDING,
)
def should_keep_running(self) -> bool:
""" Custom exit condition: keep running until the SD card is removed """
from seedsigner.hardware.microsd import MicroSD
return MicroSD.get_instance().is_inserted
class SDCardStateChangeToastManagerThread(BaseToastOverlayManagerThread):
def __init__(self, action: str, *args, **kwargs):
# Note: we could just directly detect the MicroSD status here, but passing it in
# via `action` lets us simulate the state we want in the screenshot generator.
from seedsigner.hardware.microsd import MicroSD
if action not in [MicroSD.ACTION__INSERTED, MicroSD.ACTION__REMOVED]:
raise Exception(f"Invalid MicroSD action: {action}")
self.message = "SD card removed" if action == MicroSD.ACTION__REMOVED else "SD card inserted"
super().__init__(*args, **kwargs)
def instantiate_toast(self) -> ToastOverlay:
print("instantiating toast!")
return ToastOverlay(
icon_name=SeedSignerIconConstants.MICROSD,
label_text=self.message,
)
+63 -47
View File
@@ -1,55 +1,71 @@
import os
import time
from seedsigner.models.singleton import Singleton
from seedsigner.models.threads import BaseThread
from seedsigner.models.settings import Settings
#from seedsigner.views.view import MicroSDToastView
from seedsigner.gui.screens.screen import MicroSDToastScreen
class MicroSD(Singleton, BaseThread):
ACTION__INSERTED = "add"
ACTION__REMOVED = "remove"
MOUNT_POINT = "/mnt/microsd"
FIFO_PATH = "/tmp/mdev_fifo"
FIFO_MODE = 0o600
ACTION__INSERTED = "add"
ACTION__REMOVED = "remove"
settings_handler = None
@classmethod
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
microsd = cls.__new__(cls)
cls._instance = microsd
# explicitly call BaseThread __init__ since multiple class inheritance
BaseThread.__init__(microsd)
return cls._instance
def start_detection(self):
self.start()
def run(self):
import os
FIFO_PATH = "/tmp/mdev_fifo"
FIFO_MODE = 0o600
action = ""
# explicitly only microsd add/remove detection in seedsigner-os
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
if os.path.exists(FIFO_PATH):
os.remove(FIFO_PATH)
os.mkfifo(FIFO_PATH, FIFO_MODE)
while self.keep_running:
with open(FIFO_PATH) as fifo:
action = fifo.read()
print(f"fifo message: {action}")
Settings.microsd_handler(action=action)
toastscreen = MicroSDToastScreen(action=action)
toastscreen.display()
@classmethod
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
microsd = cls.__new__(cls)
cls._instance = microsd
# explicitly call BaseThread __init__ since multiple class inheritance
BaseThread.__init__(microsd)
return cls._instance
@property
def is_inserted(self):
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
return os.path.exists(MicroSD.MOUNT_POINT)
else:
# Always True for Raspi OS
return True
def start_detection(self):
self.start()
def run(self):
from seedsigner.controller import Controller
from seedsigner.gui.toast import SDCardStateChangeToastManagerThread
action = ""
# explicitly only microsd add/remove detection in seedsigner-os
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
# at start-up, get current status and inform Settings
Settings.handle_microsd_state_change(
action=MicroSD.ACTION__INSERTED if self.is_inserted else MicroSD.ACTION__REMOVED
)
if os.path.exists(self.FIFO_PATH):
os.remove(self.FIFO_PATH)
os.mkfifo(self.FIFO_PATH, self.FIFO_MODE)
while self.keep_running:
with open(self.FIFO_PATH) as fifo:
action = fifo.read()
print(f"fifo message: {action}")
Settings.handle_microsd_state_change(action=action)
Controller.get_instance().activate_toast(SDCardStateChangeToastManagerThread(action=action))
time.sleep(0.1)
+18 -2
View File
@@ -146,8 +146,24 @@ def parse_derivation_path(derivation_path: str) -> dict:
if not details["script_type"]:
details["script_type"] = SettingsConstants.CUSTOM_DERIVATION
details["network"] = lookups["networks"].get(sections[2])
details["is_change"] = sections[-2] == "1"
details["index"] = int(sections[-1])
# Check if there's a standard change path
if sections[-2] in ["0", "1"]:
details["is_change"] = sections[-2] == "1"
else:
details["is_change"] = None
# Check if there's a standard address index
if sections[-1].isdigit():
details["index"] = int(sections[-1])
else:
details["index"] = None
if details["is_change"] is not None and details["index"] is not None:
# standard change and addr index; safe to truncate to the wallet level
details["wallet_derivation_path"] = "/".join(sections[:-2])
else:
details["wallet_derivation_path"] = None
details["clean_match"] = True
for k, v in details.items():
+2 -2
View File
@@ -126,7 +126,7 @@ class Bytewords:
elif style == Bytewords_Style_minimal:
return encode_minimal(bytes)
else:
assert(False)
assert False
@staticmethod
def decode(style, str):
@@ -137,4 +137,4 @@ class Bytewords:
elif style == Bytewords_Style_minimal:
return decode(str, 0, 2)
else:
assert(False)
assert False
@@ -255,7 +255,7 @@ class FountainDecoder:
elif self.is_failure():
return 'Exception: {}'.format(self.result)
else:
assert(False)
assert False
def print_part(self, p):
print('part indexes: {}'.format(self.indexes_to_string(p.indexes)))
@@ -84,7 +84,7 @@ class Part:
class FountainEncoder:
def __init__(self, message, max_fragment_len, first_seq_num = 0, min_fragment_len = 10):
assert(len(message) <= MAX_UINT32)
assert len(message) <= MAX_UINT32
self.message_len = len(message)
self.checksum = crc32_int(message)
self.fragment_len = FountainEncoder.find_nominal_fragment_length(self.message_len, min_fragment_len, max_fragment_len)
@@ -93,9 +93,9 @@ class FountainEncoder:
@staticmethod
def find_nominal_fragment_length(message_len, min_fragment_len, max_fragment_len):
assert(message_len > 0)
assert(min_fragment_len > 0)
assert(max_fragment_len >= min_fragment_len)
assert message_len > 0
assert min_fragment_len > 0
assert max_fragment_len >= min_fragment_len
max_fragment_count = message_len // min_fragment_len
fragment_len = None
@@ -104,7 +104,7 @@ class FountainEncoder:
if fragment_len <= max_fragment_len:
break
assert(fragment_len != None)
assert fragment_len != None
return fragment_len
+2 -2
View File
@@ -9,11 +9,11 @@ class RandomSampler:
def __init__(self, probs):
for p in probs:
assert(p > 0)
assert p > 0
# Normalize given probabilities
total = sum(probs)
assert(total > 0)
assert total > 0
n = len(probs)
+1 -1
View File
@@ -57,7 +57,7 @@ def join_bytes(list_of_ba):
def xor_into(target, source):
count = len(target)
assert(count == len(source)) # Must be the same length
assert count == len(source) # Must be the same length
for i in range(count):
target[i] ^= source[i]
+5
View File
@@ -63,6 +63,11 @@ class SeedStorage:
def update_pending_mnemonic(self, word: str, index: int):
"""
Replaces the nth word in the pending mnemonic.
* may specify a negative `index` (e.g. -1 is the last word).
"""
if index >= len(self._pending_mnemonic):
raise Exception(f"index {index} is too high")
self._pending_mnemonic[index] = word
+27 -11
View File
@@ -87,6 +87,12 @@ class Settings(Singleton):
values = value
for v in values:
if v not in [opt[0] for opt in settings_entry.selection_options]:
if settings_entry.attr_name == SettingsConstants.SETTING__PERSISTENT_SETTINGS and v == SettingsConstants.OPTION__ENABLED:
# Special case: trying to enable Persistent Settings when
# DISABLED is the only option allowed (because the SD card is not
# inserted. Explicitly set to DISABLED.
value = SettingsConstants.OPTION__DISABLED
break
raise InvalidSettingsQRData(f"""{abbreviated_name} = '{v}' is not valid""")
updated_settings[settings_entry.attr_name] = value
@@ -213,7 +219,6 @@ class Settings(Singleton):
return display_names
"""
Intentionally keeping the properties very limited to avoid an expectation of
boilerplate property code for every SettingsEntry.
@@ -227,28 +232,39 @@ class Settings(Singleton):
return self._data[SettingsConstants.SETTING__DEBUG] == SettingsConstants.OPTION__ENABLED
def microsd_handler(action):
def handle_microsd_state_change(action: str):
"""
Enables/Disables the Persistent Settings option based on the MicroSD card state.
"""
from seedsigner.hardware.microsd import MicroSD
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
if action == MicroSD.ACTION__INSERTED:
# restore persistent settings back to defaults
# SD card was just inserted.
# Restore persistent settings back to defaults
entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS)
entry.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED
entry.help_text = "Store Settings on SD card."
entry.help_text = SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT
# TODO: Perhaps prompt the user if the current settings (not including persistent
# settings) should overwrite the settings on disk, if they differ:
# - Overwrite settings on the SD?
# - Load settings from SD?
# if Settings file exists (meaning persistent settings was previously enabled), write out current settings to disk
if os.path.exists(Settings.SETTINGS_FILENAME):
# enable persistent settings first, then save
Settings.get_instance()._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] = SettingsConstants.OPTION__ENABLED
Settings.get_instance().save()
elif action == MicroSD.ACTION__REMOVED:
# set persistent settings to disabled value directly
# SD card was just removed.
# Set persistent settings to disabled value directly
Settings.get_instance()._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] = SettingsConstants.OPTION__DISABLED
# set persistent settings to only have disabled as an option, adding additional help text that microSD is removed
entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS)
entry.selection_options = SettingsConstants.OPTIONS__ONLY_DISABLED
entry.help_text = "MicroSD card is removed"
entry.help_text = SettingsConstants.PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT
else:
raise Exception(f"Invalid MicroSD action: {action}")
+3 -1
View File
@@ -101,6 +101,8 @@ class SettingsConstants:
if network == SettingsConstants.REGTEST:
return "regtest"
PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT = "Store Settings on SD card"
PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT = "Insert SD card to enable"
SINGLE_SIG = "ss"
MULTISIG = "ms"
@@ -354,7 +356,7 @@ class SettingsDefinition:
attr_name=SettingsConstants.SETTING__PERSISTENT_SETTINGS,
abbreviated_name="persistent",
display_name="Persistent settings",
help_text="Store Settings on SD card.",
help_text=SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT,
default_value=SettingsConstants.OPTION__DISABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__WALLET,
+8 -3
View File
@@ -513,6 +513,8 @@ class PSBTSignedQRDisplayView(View):
class PSBTSigningErrorView(View):
SELECT_DIFF_SEED = "Select Diff Seed"
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
@@ -520,15 +522,18 @@ class PSBTSigningErrorView(View):
return Destination(MainMenuView)
# Just a WarningScreen here; only use DireWarningScreen for true security risks.
selected_menu_num = WarningScreen(
selected_menu_num = self.run_screen(
WarningScreen,
title="PSBT Error",
status_icon_name=SeedSignerIconConstants.WARNING,
status_headline="Signing Failed",
text="Signing with this seed did not add a valid signature.",
button_data=["Select Diff Seed"],
).display()
button_data=[self.SELECT_DIFF_SEED]
)
if selected_menu_num == 0:
# clear seed selected for psbt signing since it did not add a valid signature
self.controller.psbt_seed = None
return Destination(PSBTSelectSeedView, clear_history=True)
if selected_menu_num == RET_CODE__BACK_BUTTON:
+13 -13
View File
@@ -136,30 +136,30 @@ class ScanView(View):
)
elif self.decoder.is_sign_message:
if self.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__ENABLED:
from seedsigner.views.seed_views import SeedSignMessageStartView
qr_data = self.decoder.get_qr_data()
from seedsigner.views.seed_views import SeedSignMessageStartView
qr_data = self.decoder.get_qr_data()
return Destination(
SeedSignMessageStartView,
view_args=dict(
derivation_path=qr_data["derivation_path"],
message=qr_data["message"],
)
return Destination(
SeedSignMessageStartView,
view_args=dict(
derivation_path=qr_data["derivation_path"],
message=qr_data["message"],
)
else:
return Destination(OptionDisabledView, view_args=dict(error_msg="Message signing is currently disabled in Settings"))
)
else:
return Destination(NotYetImplementedView)
elif self.decoder.is_invalid:
# For now, don't even try to re-do the attempted operation, just reset and
# start everything over.
self.controller.resume_main_flow = None
return Destination(ErrorView, view_args=dict(
title="Error",
status_headline="Unknown QR Type",
text="QRCode is invalid or is a data format not yet supported.",
button_text="Back",
next_destination=Destination(BackStackView, skip_current_view=True),
button_text="Done",
next_destination=Destination(MainMenuView, clear_history=True),
))
return Destination(MainMenuView)
+9 -7
View File
@@ -89,7 +89,6 @@ class OpeningSplashScreen(LogoScreen):
class ScreensaverScreen(LogoScreen):
def __init__(self, buttons):
super().__init__()
@@ -141,9 +140,9 @@ class ScreensaverScreen(LogoScreen):
# never gives up the lock until it returns.
with self.renderer.lock:
try:
while True:
while self._is_running:
if self.buttons.has_any_input() or self.buttons.override_ind:
return self.stop()
break
# Must crop the image to the exact display size
crop = self.image.crop((
@@ -176,20 +175,23 @@ class ScreensaverScreen(LogoScreen):
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
finally:
self._is_running = False
# Restore the original screen
self.renderer.show_image(self.last_screen)
def stop(self):
# Restore the original screen
self.renderer.show_image(self.last_screen)
self._is_running = False
+31 -10
View File
@@ -23,7 +23,7 @@ from seedsigner.models.seed import InvalidSeedException, Seed
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.settings_definition import SettingsDefinition
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
from seedsigner.views.view import NotYetImplementedView, View, Destination, BackStackView, MainMenuView
from seedsigner.views.view import NotYetImplementedView, OptionDisabledView, View, Destination, BackStackView, MainMenuView
@@ -1921,23 +1921,41 @@ class SeedSignMessageStartView(View):
self.derivation_path = derivation_path
self.message = message
if self.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__DISABLED:
self.set_redirect(Destination(OptionDisabledView, view_args=dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)))
return
# calculate the actual receive address
addr_format = embit_utils.parse_derivation_path(derivation_path)
if not addr_format["clean_match"]:
raise NotYetImplementedView("Signing messages for custom derivation paths not supported")
# Note: addr_format["network"] can be MAINNET or [TESTNET, REGTEST]
if self.settings.get_value(SettingsConstants.SETTING__NETWORK) not in addr_format["network"]:
from seedsigner.views.view import NetworkMismatchErrorView
self.set_redirect(Destination(NetworkMismatchErrorView, view_args=dict(text=f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match {self.derivation_path}")))
# cleanup. Note: We could leave this in place so the user can resume the
# flow, but for now we avoid complications and keep things simple.
self.controller.resume_main_flow = None
return
data = self.controller.sign_message_data
if not data:
data = {}
self.controller.sign_message_data = data
data["derivation_path"] = derivation_path
data["message"] = message
data["addr_format"] = addr_format
# May be None
self.seed_num = data.get("seed_num")
def run(self):
if self.seed_num is not None:
# We already know which seed we're signing with
return Destination(SeedSignMessageConfirmMessageView, skip_current_view=True)
self.set_redirect(Destination(SeedSignMessageConfirmMessageView, skip_current_view=True))
else:
return Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__SIGN_MESSAGE), skip_current_view=True)
self.set_redirect(Destination(SeedSelectSeedView, view_args=dict(flow=Controller.FLOW__SIGN_MESSAGE), skip_current_view=True))
@@ -1979,16 +1997,19 @@ class SeedSignMessageConfirmAddressView(View):
def __init__(self):
super().__init__()
data = self.controller.sign_message_data
self.seed_num = data.get("seed_num")
seed_num = data.get("seed_num")
self.derivation_path = data.get("derivation_path")
if self.seed_num is None or not self.derivation_path:
if seed_num is None or not self.derivation_path:
raise Exception("Routing error: sign_message_data hasn't been set")
seed = self.controller.storage.seeds[seed_num]
addr_format = data.get("addr_format")
# calculate the actual receive address
seed = self.controller.storage.seeds[self.seed_num]
seed = self.controller.storage.seeds[seed_num]
addr_format = embit_utils.parse_derivation_path(self.derivation_path)
if not addr_format["clean_match"]:
if not addr_format["clean_match"] or addr_format["script_type"] == SettingsConstants.CUSTOM_DERIVATION:
raise Exception("Signing messages for custom derivation paths not supported")
if addr_format["network"] != SettingsConstants.MAINNET:
@@ -2005,7 +2026,7 @@ class SeedSignMessageConfirmAddressView(View):
self.controller.sign_message_data = None
return
xpub = seed.get_xpub(wallet_path=self.derivation_path, network=addr_format["network"])
xpub = seed.get_xpub(wallet_path=addr_format["wallet_derivation_path"], network=addr_format["network"])
embit_network = embit_utils.get_embit_network_name(addr_format["network"])
self.address = embit_utils.get_single_sig_address(xpub=xpub, script_type=addr_format["script_type"], index=addr_format["index"], is_change=addr_format["is_change"], embit_network=embit_network)
+12 -4
View File
@@ -1,5 +1,6 @@
import logging
from seedsigner.gui.components import SeedSignerIconConstants
from seedsigner.hardware.microsd import MicroSD
from .view import View, Destination, MainMenuView
@@ -195,14 +196,21 @@ class SettingsIngestSettingsQRView(View):
# May raise an Exception which will bubble up to the Controller to display to the
# user.
self.config_name, settings_update_dict = Settings.parse_settingsqr(data)
self.settings.update(settings_update_dict)
if MicroSD.get_instance().is_inserted and self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED:
self.status_message = "Persistent Settings enabled. Settings saved to SD card."
else:
self.status_message = "Settings updated in temporary memory"
def run(self):
from seedsigner.gui.screens.scan_screens import SettingsUpdatedScreen
from seedsigner.gui.screens.settings_screens import SettingsQRConfirmationScreen
self.run_screen(
SettingsUpdatedScreen,
config_name=self.config_name
SettingsQRConfirmationScreen,
config_name=self.config_name,
status_message=self.status_message,
)
# Only one exit point
+43 -39
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
import hashlib
import os
import time
@@ -223,9 +224,7 @@ class ToolsDiceEntropyEntryView(View):
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))
@@ -313,8 +312,7 @@ class ToolsCalcFinalWordFinalizePromptView(View):
class ToolsCalcFinalWordCoinFlipsView(View):
def run(self):
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
mnemonic_length = len(self.controller.storage.pending_mnemonic)
if mnemonic_length == 12:
total_flips = 7
@@ -329,68 +327,74 @@ class ToolsCalcFinalWordCoinFlipsView(View):
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):
def __init__(self, coin_flips: str = 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)
# Prep the user's selected word / coin flips and the actual final word for
# the display.
if coin_flips:
self.selected_final_word = None
self.selected_final_bits = coin_flips
else:
# Convert the user's final word selection into its binary index equivalent
self.selected_final_word = self.controller.storage.pending_mnemonic[-1]
self.selected_final_bits = format(wordlist.index(self.selected_final_word), '011b')
if coin_flips:
# fill the last bits (what will eventually be the checksum) with zeros
binary_string = coin_flips + "0" * (11 - len(coin_flips))
# retrieve the matching word for the resulting index
wordlist_index = int(binary_string, 2)
wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
word = wordlist[wordlist_index]
# update the pending mnemonic with our new "final" (pre-checksum) word
self.controller.storage.update_pending_mnemonic(word, -1)
# Now calculate the REAL final word (has a proper checksum)
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')
# Update our pending mnemonic with the real final word
self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], -1)
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
# 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:]
self.actual_final_word = self.controller.storage.pending_mnemonic[-1]
num_checksum_bits = 4 if mnemonic_length == 12 else 8
self.checksum_bits = format(wordlist.index(self.actual_final_word), '011b')[-num_checksum_bits:]
def run(self):
NEXT = "Next"
button_data = [NEXT]
selected_menu_num = ToolsCalcFinalWordScreen(
selected_menu_num = self.run_screen(
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()
selected_final_word=self.selected_final_word,
selected_final_bits=self.selected_final_bits,
checksum_bits=self.checksum_bits,
actual_final_word=self.actual_final_word,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
+51 -15
View File
@@ -5,6 +5,7 @@ from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconCo
from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON
from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.settings_definition import SettingsDefinition
from seedsigner.models.threads import BaseThread
@@ -185,34 +186,33 @@ class MainMenuView(View):
TOOLS = ("Tools", SeedSignerIconConstants.TOOLS)
SETTINGS = ("Settings", SeedSignerIconConstants.SETTINGS)
def run(self):
from seedsigner.gui.screens.screen import MainMenuScreen
button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS]
selected_menu_num = self.run_screen(
LargeButtonScreen,
MainMenuScreen,
title="Home",
title_font_size=26,
button_data=button_data,
show_back_button=False,
show_power_button=True,
)
if selected_menu_num == RET_CODE__POWER_BUTTON:
return Destination(PowerOptionsView)
if button_data[selected_menu_num] == self.SCAN:
from .scan_views import ScanView
from seedsigner.views.scan_views import ScanView
return Destination(ScanView)
elif button_data[selected_menu_num] == self.SEEDS:
from .seed_views import SeedsMenuView
from seedsigner.views.seed_views import SeedsMenuView
return Destination(SeedsMenuView)
elif button_data[selected_menu_num] == self.TOOLS:
from .tools_views import ToolsMenuView
from seedsigner.views.tools_views import ToolsMenuView
return Destination(ToolsMenuView)
elif button_data[selected_menu_num] == self.SETTINGS:
from .settings_views import SettingsMenuView
from seedsigner.views.settings_views import SettingsMenuView
return Destination(SettingsMenuView)
@@ -332,7 +332,7 @@ class ErrorView(View):
class NetworkMismatchErrorView(ErrorView):
title: str = "Network Mismatch"
show_back_button: bool = False
button_text: str = "Change Settings"
button_text: str = "Change Setting"
next_destination: Destination = None
@@ -369,17 +369,53 @@ class UnhandledExceptionView(View):
@dataclass
class OptionDisabledView(View):
error_msg: str
UPDATE_SETTING = "Update Setting"
DONE = "Done"
settings_attr: str
def __post_init__(self):
super().__post_init__()
self.settings_entry = SettingsDefinition.get_settings_entry(self.settings_attr)
self.error_msg = f"\"{self.settings_entry.display_name}\" is currently disabled in Settings."
def run(self):
WarningScreen(
button_data = [self.UPDATE_SETTING, self.DONE]
selected_menu_num = self.run_screen(
WarningScreen,
title="Option Disabled",
status_headline=None,
text=self.error_msg,
button_data=["OK"],
button_data=button_data,
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)
)
if button_data[selected_menu_num] == self.UPDATE_SETTING:
from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView
return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_attr), clear_history=True)
else:
return Destination(MainMenuView, clear_history=True)
class RemoveMicroSDWarningView(View):
"""
Warning to remove the microsd
"""
def __init__(self, next_view: View):
super().__init__()
self.next_view = next_view
def run(self):
self.run_screen(
WarningScreen,
title="Security Tip",
status_icon_name=FontAwesomeIconConstants.SDCARD,
status_headline="",
text="For maximum security,\nremove the MicroSD card\nbefore continuing.",
show_back_button=False,
button_data=["Continue"],
)
return Destination(self.next_view, clear_history=True)
+23 -3
View File
@@ -1,26 +1,38 @@
import sys
from dataclasses import dataclass
from mock import MagicMock, patch
from mock import MagicMock, Mock, patch
from typing import Callable
# Prevent importing modules w/Raspi hardware dependencies.
# These must precede any SeedSigner imports.
sys.modules['seedsigner.gui.renderer'] = MagicMock()
sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock()
sys.modules['seedsigner.gui.toast'] = MagicMock()
sys.modules['seedsigner.views.screensaver'] = MagicMock()
sys.modules['seedsigner.hardware.buttons'] = MagicMock()
sys.modules['seedsigner.hardware.camera'] = MagicMock()
sys.modules['seedsigner.hardware.microsd'] = MagicMock()
from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON
from seedsigner.hardware.microsd import MicroSD
from seedsigner.models.settings import Settings
from seedsigner.views.view import Destination, MainMenuView, View
class BaseTest:
class MockMicroSD(Mock):
"""
A test suite-friendly replacement for `MicroSD` that gives a test explicit
control over the reported state of the SD card.
"""
# Tests are free to directly manipulate this attribute as needed (it's reset to
# True before each test in `BaseTest.setup_method()`).
is_inserted: bool = True
@classmethod
def setup_class(cls):
# Ensure there are no on-disk artifacts after running tests.
@@ -29,6 +41,13 @@ class BaseTest:
# Mock out the loading screen so it can't spawn. View classes must import locally!
patch('seedsigner.gui.screens.screen.LoadingScreenThread').start()
# Instantiate the mocked MicroSD; hold on to the instance so tests can manipulate
# it later.
cls.mock_microsd = BaseTest.MockMicroSD()
# And mock it over `MicroSD`'s instance
MicroSD.get_instance = Mock(return_value=cls.mock_microsd)
@classmethod
def teardown_class(cls):
@@ -61,11 +80,12 @@ class BaseTest:
def setup_method(self):
""" Guarantee a clean/default Controller and Settings state for each test case """
""" Guarantee a clean/default Controller, Settings, & MicroSD state for each test case """
BaseTest.reset_controller()
BaseTest.reset_settings()
self.controller = Controller.get_instance()
self.settings = Settings.get_instance()
self.mock_microsd.is_inserted = True
def teardown_method(self):
+67 -18
View File
@@ -1,27 +1,37 @@
import embit
import os
import sys
from mock import Mock, MagicMock
import time
from mock import Mock, patch, MagicMock
from seedsigner.helpers import embit_utils
from seedsigner.models.settings import Settings
# Prevent importing modules w/Raspi hardware dependencies.
# These must precede any SeedSigner imports.
sys.modules['seedsigner.hardware.ST7789'] = MagicMock()
sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock()
sys.modules['seedsigner.views.screensaver'] = MagicMock()
sys.modules['seedsigner.hardware.buttons'] = MagicMock()
sys.modules['RPi'] = MagicMock()
sys.modules['RPi.GPIO'] = MagicMock()
sys.modules['seedsigner.hardware.camera'] = MagicMock()
sys.modules['seedsigner.hardware.microsd'] = MagicMock()
from seedsigner.controller import Controller
from seedsigner.gui.renderer import Renderer
from seedsigner.gui.toast import BaseToastOverlayManagerThread, RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread
from seedsigner.hardware.buttons import HardwareButtons
from seedsigner.hardware.camera import Camera
from seedsigner.hardware.microsd import MicroSD
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, NotYetImplementedView, UnhandledExceptionView,
psbt_views, seed_views, settings_views, tools_views)
from seedsigner.views.view import NetworkMismatchErrorView, PowerOffView, View
from seedsigner.views.view import ErrorView, NetworkMismatchErrorView, OptionDisabledView, PowerOffView, View
from .utils import ScreenshotComplete, ScreenshotRenderer
@@ -61,6 +71,12 @@ def test_generate_screenshots(target_locale):
controller.storage.set_pending_seed(seed_24)
UnhandledExceptionViewFood = ["IndexError", "line 1, in some_buggy_code.py", "list index out of range"]
# Pending mnemonic for ToolsCalcFinalWordShowFinalWordView
controller.storage.init_pending_mnemonic(num_words=12)
for i, word in enumerate(mnemonic_12[:11]):
controller.storage.update_pending_mnemonic(word=word, index=i)
controller.storage.update_pending_mnemonic(word="satoshi", index=11) # random last word; not supposed to be a valid checksum (yet)
# Load a PSBT into memory
BASE64_PSBT_1 = """cHNidP8BAP06AQIAAAAC5l4E3oEjI+H0im8t/K2nLmF5iJFdKEiuQs8ESveWJKcAAAAAAP3///8iBZMRhYIq4s/LmnTmKBi79M8ITirmsbO++63evK4utwAAAAAA/f///wZYQuoDAAAAACIAIAW5jm3UnC5fyjKCUZ8LTzjENtb/ioRTaBMXeSXsB3n+bK2fCgAAAAAWABReJY7akT1+d+jx475yBRWORdBd7VxbUgUAAAAAFgAU4wj9I/jB3GjNQudNZAca+7g9R16iWtYOAAAAABYAFIotPApLZlfscg8f3ppKqO3qA5nv7BnMFAAAAAAiACAs6SGc8qv4FwuNl0G0SpMZG8ODUEk5RXiWUcuzzw5iaRSfAhMAAAAAIgAgW0f5QxQIgVCGQqKzsvfkXZjUxdFop5sfez6Pt8mUbmZ1AgAAAAEAkgIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BQIRAgEB/////wJAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQErQL5AJQAAAAAiACCET6KNi75K8K4a2BYS4ZT+N4s8WwOBKOmOohRYkGHV0QEFR1EhArGhNdUqlR4BAOLGTMrY2ZJYTQNRudp7fU7i8crRJqgEIQNDxn7PjUzvsP6KYw4s7dmoZE0qO1K6MaM+2ScRZ7hyxFKuIgYCsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAwAAACIGA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEHCK94akwAACAAQAAgAAAAIACAACAAAAAAAMAAAAAAQCSAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8FAhACAQH/////AkC+QCUAAAAAIgAghE+ijYu+SvCuGtgWEuGU/jeLPFsDgSjpjqIUWJBh1dEAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAStAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAQVHUSECsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQhA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEUq4iBgKxoTXVKpUeAQDixkzK2NmSWE0DUbnae31O4vHK0SaoBBxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgYDQ8Z+z41M77D+imMOLO3ZqGRNKjtSujGjPtknEWe4csQcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAAAwAAAAABAUdRIQJ5XLCBS0hdo4NANq4lNhimzhyHj7dvObmPAwNj8L2xASEC9mwwoH28/WHnxbb6z05sJ/lHuvrLs/wOooHgFn5ulI1SriICAnlcsIFLSF2jg0A2riU2GKbOHIePt285uY8DA2PwvbEBHCK94akwAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgL2bDCgfbz9YefFtvrPTmwn+Ue6+suz/A6igeAWfm6UjRxzxdoKMAAAgAEAAIAAAACAAgAAgAEAAAABAAAAAAAAAAEBR1EhAgpbWcEh7rgvRE5UaCcqzWL/TR1B/DS8UeZsKVEvuKLrIQOwLg0emiQbbxafIh69Xjtpj4eclsMhKq1y/7vYDdE7LVKuIgICCltZwSHuuC9ETlRoJyrNYv9NHUH8NLxR5mwpUS+4ouscc8XaCjAAAIABAACAAAAAgAIAAIAAAAAABQAAACICA7AuDR6aJBtvFp8iHr1eO2mPh5yWwyEqrXL/u9gN0TstHCK94akwAACAAQAAgAAAAIACAACAAAAAAAUAAAAAAQFHUSECk50GLh/YhZaLJkDq/dugU3H/WvE6rTgQuY6N57pI4ykhA/H8MdLVP9SA/Hg8l3hvibSaC1bCBzwz7kTW+rsEZ8uFUq4iAgKTnQYuH9iFlosmQOr926BTcf9a8TqtOBC5jo3nukjjKRxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgID8fwx0tU/1ID8eDyXeG+JtJoLVsIHPDPuRNb6uwRny4UcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAABgAAAAA="""
decoder = DecodeQR()
@@ -73,10 +89,12 @@ def test_generate_screenshots(target_locale):
controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR)
# Message signing data
derivation_path = "m/84h/0h/0h/0/0"
controller.sign_message_data = {
"seed_num": 0,
"derivation_path": "m/84h/0h/0h/0/0",
"derivation_path": derivation_path,
"message": "I attest that I control this bitcoin address blah blah blah",
"addr_format": embit_utils.parse_derivation_path(derivation_path)
}
# Automatically populate all Settings options Views
@@ -92,22 +110,20 @@ def test_generate_screenshots(target_locale):
continue
settings_views_list.append((settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}"))
settings_views_list.append(settings_views.IOTestView)
settings_views_list.append(settings_views.DonateView)
settingsqr_data_persistent = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
settingsqr_data_not_persistent = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
screenshot_sections = {
"Main Menu Views": [
MainMenuView,
(MainMenuView, {}, 'MainMenuView_SDCardStateChangeToast_removed', SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED)),
(MainMenuView, {}, 'MainMenuView_SDCardStateChangeToast_inserted', SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED)),
(MainMenuView, {}, 'MainMenuView_RemoveSDCardToast', RemoveSDCardToastManagerThread(activation_delay=0)),
PowerOptionsView,
RestartView,
PowerOffView,
NotYetImplementedView,
(UnhandledExceptionView, dict(error=UnhandledExceptionViewFood)),
(settings_views.SettingsIngestSettingsQRView, dict(data="settings::v1 name=factory_reset")),
NetworkMismatchErrorView,
],
"Seed Views": [
seed_views.SeedsMenuView,
@@ -191,31 +207,61 @@ def test_generate_screenshots(target_locale):
tools_views.ToolsCalcFinalWordNumWordsView,
tools_views.ToolsCalcFinalWordFinalizePromptView,
tools_views.ToolsCalcFinalWordCoinFlipsView,
#(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips=3)),
(tools_views.ToolsCalcFinalWordShowFinalWordView, {}, "ToolsCalcFinalWordShowFinalWordView_pick_word"),
(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), "ToolsCalcFinalWordShowFinalWordView_coin_flips"),
#tools_views.ToolsCalcFinalWordDoneView,
tools_views.ToolsAddressExplorerSelectSourceView,
tools_views.ToolsAddressExplorerAddressTypeView,
tools_views.ToolsAddressExplorerAddressListView,
#tools_views.ToolsAddressExplorerAddressView,
],
"Settings Views": settings_views_list,
"Settings Views": settings_views_list + [
settings_views.IOTestView,
settings_views.DonateView,
(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), "SettingsIngestSettingsQRView_persistent"),
(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), "SettingsIngestSettingsQRView_not_persistent"),
],
"Misc Error Views": [
NotYetImplementedView,
(UnhandledExceptionView, dict(error=UnhandledExceptionViewFood)),
NetworkMismatchErrorView,
(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)),
(ErrorView, dict(
title="Error",
status_headline="Unknown QR Type",
text="QRCode is invalid or is a data format not yet supported.",
button_text="Back",
)),
]
}
readme = f"""# SeedSigner Screenshots\n"""
def screencap_view(view_cls: View, view_name: str, view_args: dict={}):
def screencap_view(view_cls: View, view_name: str, view_args: dict={}, toast_thread: BaseToastOverlayManagerThread = None):
screenshot_renderer.set_screenshot_filename(f"{view_name}.png")
try:
print(f"Running {view_name}")
view_cls(**view_args).run()
try:
view_cls(**view_args).run()
except ScreenshotComplete:
if toast_thread is not None:
controller.activate_toast(toast_thread)
while controller.toast_notification_thread.is_alive():
time.sleep(0.1)
raise ScreenshotComplete()
except ScreenshotComplete:
# Slightly hacky way to exit ScreenshotRenderer as expected
pass
print(f"Completed {view_name}")
except Exception as e:
# Something else went wrong
print(repr(e))
from traceback import print_exc
print_exc()
raise e
finally:
if toast_thread:
toast_thread.stop()
for section_name, screenshot_list in screenshot_sections.items():
subdir = section_name.lower().replace(" ", "_")
@@ -231,12 +277,15 @@ def test_generate_screenshots(target_locale):
view_name = view_cls.__name__
elif len(screenshot) == 3:
view_cls, view_args, view_name = screenshot
elif len(screenshot) == 4:
view_cls, view_args, view_name, toast_thread = screenshot
else:
view_cls = screenshot
view_args = {}
view_name = view_cls.__name__
toast_thread = None
screencap_view(view_cls, view_name, view_args)
screencap_view(view_cls, view_name, view_args, toast_thread=toast_thread)
readme += """ <table align="left" style="border: 1px solid gray;">"""
readme += f"""<tr><td align="center">{view_name}<br/><br/><img src="{subdir}/{view_name}.png"></td></tr>"""
readme += """</table>\n"""
+26 -16
View File
@@ -331,12 +331,12 @@ def test_parse_derivation_path():
derivation_path = "m/84'/0'/0'/0/0"
result = embit_utils.parse_derivation_path(derivation_path)
assert(result["script_type"] == SC.NATIVE_SEGWIT)
assert(result["network"] == SC.MAINNET)
assert result["script_type"] == SC.NATIVE_SEGWIT
assert result["network"] == SC.MAINNET
result = embit_utils.parse_derivation_path(derivation_path.replace("'", "h"))
assert(result["script_type"] == SC.NATIVE_SEGWIT)
assert(result["network"] == SC.MAINNET)
assert result["script_type"] == SC.NATIVE_SEGWIT
assert result["network"] == SC.MAINNET
# Now exhaustively test supported permutations
vectors_args = {
@@ -361,7 +361,7 @@ def test_parse_derivation_path():
(SC.TESTNET, SC.TAPROOT, True): "m/86'/1'/0'/1/5",
(SC.REGTEST, SC.TAPROOT, True): "m/86'/1'/0'/1/5",
# Try a typical custom derivation path (Unchained Capital)
# Try a typical custom derivation path (Unchained vault keys)
(SC.MAINNET, SC.CUSTOM_DERIVATION, False): "m/45'/0'/0'/0/5",
(SC.TESTNET, SC.CUSTOM_DERIVATION, False): "m/45'/1'/0'/0/5",
(SC.REGTEST, SC.CUSTOM_DERIVATION, False): "m/45'/1'/0'/0/5",
@@ -369,24 +369,34 @@ def test_parse_derivation_path():
(SC.TESTNET, SC.CUSTOM_DERIVATION, True): "m/45'/1'/0'/1/5",
(SC.REGTEST, SC.CUSTOM_DERIVATION, True): "m/45'/1'/0'/1/5",
# CRAZY custom derivation path
(None, SC.CUSTOM_DERIVATION, False): "m/879345978543'/908327034508534983495'/9085098430894380959043'/0/5",
# CRAZY custom derivation paths
(None, SC.CUSTOM_DERIVATION, False, 5): "m/123'/9083270/9083270/9083270/9083270/0/5",
# non-standard change and/or index
(None, SC.CUSTOM_DERIVATION, None, 5): "m/9'/78/5",
(None, SC.CUSTOM_DERIVATION, None, 5): "m/9'/78'/5",
(None, SC.CUSTOM_DERIVATION, None, None): "m/9'/78'/5'",
(None, SC.CUSTOM_DERIVATION, False, None): "m/9'/0/5'",
}
for expected_result, derivation_path in vectors_args.items():
actual_result = embit_utils.parse_derivation_path(derivation_path)
if expected_result[0] == SC.MAINNET:
assert(actual_result["network"] == expected_result[0])
assert(actual_result["clean_match"] is True)
assert actual_result["network"] == expected_result[0]
assert actual_result["clean_match"] is True
elif expected_result[0] is None:
assert(actual_result["network"] is None)
assert(actual_result["clean_match"] is False)
assert actual_result["network"] is None
assert actual_result["clean_match"] is False
else:
# Testnet and regtest are returned as a list since the parser can't tell which is intended
assert(expected_result[0] in actual_result["network"])
assert(actual_result["clean_match"] is True)
assert expected_result[0] in actual_result["network"]
assert actual_result["clean_match"] is True
assert(actual_result["script_type"] == expected_result[1])
assert(actual_result["is_change"] == expected_result[2])
assert(actual_result["index"] == int(derivation_path.split("/")[-1]))
assert actual_result["script_type"] == expected_result[1]
assert actual_result["is_change"] == expected_result[2]
if len(expected_result) == 4:
assert actual_result["index"] == expected_result[3]
else:
assert actual_result["index"] == int(derivation_path.split("/")[-1])
+37
View File
@@ -54,3 +54,40 @@ class TestPSBTFlows(FlowTest):
FlowStep(psbt_views.PSBTSignedQRDisplayView),
FlowStep(MainMenuView)
])
def test_scan_multisig_psbt_seed_already_signed_flow(self):
def load_psbt_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("cHNidP8BAIkCAAAAAc9dCSh2RcRPfHaT5bNVBpbg0jAekRLqOK+bpN/QA0jeAAAAAAD9////AtAHAAAAAAAAIlEg24shYsV3IRCzlgmMKjAsR4Ad9tX896z7zDAi5q0TU9H3CgAAAAAAACIAIByGQg/VP2aRID62ty40E64HYZeRRsKRGLt8J/76R6stQ04FAE8BBDWHzwSLLGdzgAAAAq3q6nR20JnHR+vKrBQdWxN9C7xU8zNX942mVF7AQpl2ArrdLwVlkGxaatQJ4wwkvypNBKbwOq9hXGLNlKi7rZWAFDUxzXUwAACAAQAAgAAAAIACAACATwEENYfPBHOCZmWAAAACmH6KTXIny0vueRgQFBq4M6oMuG8f1QM0I/RzKQ03bCgCHrF0fyUtV0+FD2N34u/woqb8MAt/o+7Ed58RddhY8zYUCUjSaDAAAIABAACAAAAAgAIAAIAAAQEriBMAAAAAAAAiACBY4WsjDgJXLj3VW222jU1tkIIhT26ce/2efH73BWGGBiICAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5RzBEAiAPkQTY84YjFFkpD6MI2cc5rJySqws5fsTQA/8XEZFpbAIgTNVykbEH4Z7bqyzhhy6lty0K8rtCUDCaHNv+47NNIWgBAQMEAQAAAAEFR1IhApL4XO+VE1pPYn5wnRFyJQKVSc9TX2dO6KIBH6jwvgPaIQKsn5K3VDuutkAa3byXEjmTBcZ4g+zNWsJvVNCm+VwpuVKuIgYCkvhc75UTWk9ifnCdEXIlApVJz1NfZ07oogEfqPC+A9ocNTHNdTAAAIABAACAAAAAgAIAAIAAAAAAAAAAACIGAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5HAlI0mgwAACAAQAAgAAAAIACAACAAAAAAAAAAAAAAAEBR1IhApYXaczuYbBM/A+EH639Ir2yIB4PxL46dK/I1V1O9aHgIQLa02HCI/+EP+9gGpxHskjYWFN5hZzXY7RRvwV4UF42ylKuIgIClhdpzO5hsEz8D4Qfrf0ivbIgHg/Evjp0r8jVXU71oeAcNTHNdTAAAIABAACAAAAAgAIAAIABAAAAAAAAACICAtrTYcIj/4Q/72AanEeySNhYU3mFnNdjtFG/BXhQXjbKHAlI0mgwAACAAQAAgAAAAIACAACAAQAAAAAAAAAA")
def load_seed_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("073318950739065415961602009907670428187212261116")
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(psbt_views.PSBTOverviewView),
FlowStep(psbt_views.PSBTMathView),
FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
FlowStep(psbt_views.PSBTSigningErrorView, button_data_selection=psbt_views.PSBTSigningErrorView.SELECT_DIFF_SEED),
FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value="abc"),
FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(psbt_views.PSBTOverviewView),
FlowStep(psbt_views.PSBTMathView),
FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
FlowStep(psbt_views.PSBTSignedQRDisplayView),
FlowStep(MainMenuView),
])
+84 -15
View File
@@ -1,3 +1,4 @@
from typing import Callable
import pytest
# Must import test base before the Controller
@@ -5,10 +6,14 @@ from base import BaseTest, FlowTest, FlowStep
from base import FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.models.settings import SettingsConstants
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.seed import Seed
from seedsigner.views.view import MainMenuView, View, NetworkMismatchErrorView
from seedsigner.views import seed_views, scan_views, settings_views
from seedsigner.views.view import ErrorView, MainMenuView, OptionDisabledView, RemoveMicroSDWarningView, View, NetworkMismatchErrorView
from seedsigner.views import seed_views, scan_views, settings_views, tools_views
def load_seed_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("0000" * 11 + "0003")
@@ -19,9 +24,6 @@ class TestSeedFlows(FlowTest):
Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the
Finalize Seed flow and end at the SeedOptionsView.
"""
def load_seed_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("0000" * 11 + "0003")
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
@@ -36,6 +38,7 @@ class TestSeedFlows(FlowTest):
the SeedOptionsView.
"""
def test_with_mnemonic(mnemonic):
Settings.HOSTNAME = "not seedsigner-os"
sequence = [
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView
@@ -418,19 +421,85 @@ class TestMessageSigningFlows(FlowTest):
# Ensure message signing is enabled
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
# Ensure we're configured for mainnet
def expect_network_mismatch_error(load_message: Callable):
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=load_message), # simulate read message QR; ret val is ignored
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
FlowStep(NetworkMismatchErrorView),
FlowStep(settings_views.SettingsEntryUpdateSelectionView),
])
# MAINNET settings vs TESTNET derivation path with the message
self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.MAINNET)
expect_network_mismatch_error(self.load_testnet_message_into_decoder)
# TESTNET settings vs MAINNET derivation path with the message
self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.TESTNET)
expect_network_mismatch_error(self.load_short_message_into_decoder)
# REGTEST settings vs MAINNET derivation path with the message
self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST)
expect_network_mismatch_error(self.load_short_message_into_decoder)
def test_sign_message_option_disabled(self):
"""
Should redirect to OptionDisabledView if a `signmessage` QR is scanned with
message signing disabled.
Should offer the option to route directly to enable that settings or return to
MainMenuView.
"""
# Ensure message signing is disabled
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__DISABLED)
sequence = [
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
]
# First test routing to update the setting
self.run_sequence(
sequence + [
FlowStep(OptionDisabledView, button_data_selection=OptionDisabledView.UPDATE_SETTING, is_redirect=True),
FlowStep(settings_views.SettingsEntryUpdateSelectionView),
]
)
# Now test exiting to Main Menu
self.run_sequence(
sequence + [
FlowStep(OptionDisabledView, button_data_selection=OptionDisabledView.DONE, is_redirect=True),
FlowStep(MainMenuView),
]
)
def test_sign_message_invalid_qr_flow(self):
"""
Should clear `Controller.resume_main_flow` and redirect to ErrorView if an
invalid signmessage QR is scanned.
The error view should then forward to MainMenuView.
"""
# Ensure message signing is enabled
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
def load_invalid_signmessage_qr(view: scan_views.ScanView):
view.decoder.add_data("this text will not make sense to the decoder")
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=self.load_testnet_message_into_decoder), # simulate read message QR; ret val is ignored
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED),
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0),
FlowStep(seed_views.SeedSignMessageConfirmAddressView, is_redirect=True),
FlowStep(NetworkMismatchErrorView),
FlowStep(settings_views.SettingsEntryUpdateSelectionView),
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.SIGN_MESSAGE),
FlowStep(scan_views.ScanView, before_run=load_invalid_signmessage_qr), # simulate read message QR; ret val is ignored
FlowStep(ErrorView),
FlowStep(MainMenuView),
])
assert self.controller.resume_main_flow is None
+71 -2
View File
@@ -1,4 +1,7 @@
import os
from typing import Callable
from mock import PropertyMock, patch
# Must import test base before the Controller
from base import FlowTest, FlowStep
@@ -6,13 +9,13 @@ from base import FlowTest, FlowStep
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsDefinition, SettingsConstants
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.hardware.microsd import MicroSD
from seedsigner.views.view import MainMenuView
from seedsigner.views import settings_views
from seedsigner.views import scan_views, settings_views
class TestSettingsFlows(FlowTest):
def test_persistent_settings(self):
""" Basic flow from MainMenuView to enable/disable persistent settings """
# Which option are we testing?
@@ -67,3 +70,69 @@ class TestSettingsFlows(FlowTest):
FlowStep(settings_views.DonateView),
FlowStep(settings_views.SettingsMenuView),
])
def test_settingsqr(self):
"""
Scanning a SettingsQR should present the success screen and then return to
MainMenuView.
"""
def load_persistent_settingsqr_into_decoder(view: scan_views.ScanView):
settingsqr_data_persistent: str = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
view.decoder.add_data(settingsqr_data_persistent)
def load_not_persistent_settingsqr_into_decoder(view: scan_views.ScanView):
settingsqr_data_not_persistent: str = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
view.decoder.add_data(settingsqr_data_not_persistent)
def _run_test(initial_setting_state: str, load_settingsqr_into_decoder: Callable, expected_setting_state: str):
self.settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, initial_setting_state)
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=load_settingsqr_into_decoder), # simulate read message QR; ret val is ignored
FlowStep(settings_views.SettingsIngestSettingsQRView), # ret val is ignored
FlowStep(MainMenuView),
])
assert self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == expected_setting_state
# First load a SettingsQR that enables persistent settings
self.mock_microsd.is_inserted = True
assert MicroSD.get_instance().is_inserted is True
_run_test(
initial_setting_state=SettingsConstants.OPTION__DISABLED,
load_settingsqr_into_decoder=load_persistent_settingsqr_into_decoder,
expected_setting_state=SettingsConstants.OPTION__ENABLED
)
# Then one that disables it
_run_test(
initial_setting_state=SettingsConstants.OPTION__ENABLED,
load_settingsqr_into_decoder=load_not_persistent_settingsqr_into_decoder,
expected_setting_state=SettingsConstants.OPTION__DISABLED
)
# Now try to enable persistent settings when the SD card is not inserted
self.mock_microsd.is_inserted = False
assert MicroSD.get_instance().is_inserted is False
# Have to jump through some hoops to completely simulate the SD card being
# removed; we need Settings to restrict Persistent Settings to only allow
# DISABLED.
with patch('seedsigner.models.settings.Settings.HOSTNAME', new_callable=PropertyMock) as mock_hostname:
# Must identify itself as SeedSigner OS to trigger the SD card removal logic
mock_hostname.return_value = Settings.SEEDSIGNER_OS
Settings.handle_microsd_state_change(MicroSD.ACTION__REMOVED)
selection_options = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS).selection_options
assert len(selection_options) == 1
assert selection_options[0][0] == SettingsConstants.OPTION__DISABLED
assert self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED
_run_test(
initial_setting_state=SettingsConstants.OPTION__DISABLED,
load_settingsqr_into_decoder=load_persistent_settingsqr_into_decoder,
expected_setting_state=SettingsConstants.OPTION__DISABLED
)
+1
View File
@@ -65,6 +65,7 @@ class TestViewFlows(FlowTest):
"""
Basic flow from any arbitrary View to the UnhandledExceptionView
"""
Settings.HOSTNAME = "NOT seedsigner-os"
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD),
+12 -11
View File
@@ -8,6 +8,7 @@ from seedsigner.models.settings_definition import SettingsConstants
class TestSettings(BaseTest):
@classmethod
def setup_class(cls):
super().setup_class()
cls.settings = Settings.get_instance()
@@ -37,17 +38,17 @@ class TestSettings(BaseTest):
# Now parse the settingsqr_data
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
assert(config_name == settings_name)
assert config_name == settings_name
self.settings.update(new_settings=settings_update_dict)
# Now verify that the settings were updated correctly
assert(self.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED)
assert(self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED)
assert self.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED
assert self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED
coordinators = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)
assert(SettingsConstants.COORDINATOR__BLUE_WALLET not in coordinators)
assert(SettingsConstants.COORDINATOR__SPARROW in coordinators)
assert(SettingsConstants.COORDINATOR__SPECTER_DESKTOP in coordinators)
assert SettingsConstants.COORDINATOR__BLUE_WALLET not in coordinators
assert SettingsConstants.COORDINATOR__SPARROW in coordinators
assert SettingsConstants.COORDINATOR__SPECTER_DESKTOP in coordinators
def test_settingsqr_version(self):
@@ -61,7 +62,7 @@ class TestSettings(BaseTest):
settingsqr_data = "settings::v2 name=Foo"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
assert("Unsupported SettingsQR version" in str(e.value))
assert "Unsupported SettingsQR version" in str(e.value)
# Should also fail if version omitted
settingsqr_data = "settings name=Foo"
@@ -79,8 +80,8 @@ class TestSettings(BaseTest):
settingsqr_data = "settings::v1 name=Foo favorite_food=bacon xpub_export=D"
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
assert("favorite_food" not in settings_update_dict)
assert("xpub_export" in settings_update_dict)
assert "favorite_food" not in settings_update_dict
assert "xpub_export" in settings_update_dict
# Accepts update with no Exceptions
self.settings.update(new_settings=settings_update_dict)
@@ -91,7 +92,7 @@ class TestSettings(BaseTest):
settingsqr_data = "settings::v1 name=Foo xpub_export=Yep"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
assert("xpub_export" in str(e.value))
assert "xpub_export" in str(e.value)
def test_settingsqr_parses_line_break_separators(self):
@@ -99,7 +100,7 @@ class TestSettings(BaseTest):
settingsqr_data = "settings::v1\nname=Foo\nsigs=ss,ms\nscripts=nat,nes,tr\nxpub_export=E\n"
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
assert(len(settings_update_dict.keys()) == 3)
assert len(settings_update_dict.keys()) == 3
# Accepts update with no Exceptions
self.settings.update(new_settings=settings_update_dict)
+4 -4
View File
@@ -14,11 +14,11 @@ class TestSettingsQRDecoder:
# Now parse the settings_qr_str
decoder = DecodeQR()
status = decoder.add_data(settings_qr_str)
assert(decoder.is_settings)
assert(status == DecodeQRStatus.COMPLETE)
assert decoder.is_settings
assert status == DecodeQRStatus.COMPLETE
data = decoder.get_settings_data()
assert(data == settings_qr_str)
assert data == settings_qr_str
def test_settingsqr_version(self):
@@ -26,4 +26,4 @@ class TestSettingsQRDecoder:
settings_qr_str = "name=Foo"
decoder = DecodeQR()
status = decoder.add_data(settings_qr_str)
assert(status == DecodeQRStatus.INVALID)
assert status == DecodeQRStatus.INVALID