diff --git a/README.md b/README.md
index 9f7c4848..da992928 100644
--- a/README.md
+++ b/README.md
@@ -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.
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.
diff --git a/setup.py b/setup.py
index 82dcfb5d..fb6eff2c 100644
--- a/setup.py
+++ b/setup.py
@@ -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!",
diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py
index 0d8c3cdd..30a50076 100644
--- a/src/seedsigner/controller.py
+++ b/src/seedsigner/controller.py
@@ -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:
diff --git a/src/seedsigner/gui/components.py b/src/seedsigner/gui/components.py
index 737247a2..7290a98e 100644
--- a/src/seedsigner/gui/components.py
+++ b/src/seedsigner/gui/components.py
@@ -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):
diff --git a/src/seedsigner/gui/screens/scan_screens.py b/src/seedsigner/gui/screens/scan_screens.py
index 26a53bae..13d3dcf7 100644
--- a/src/seedsigner/gui/screens/scan_screens.py
+++ b/src/seedsigner/gui/screens/scan_screens.py
@@ -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
- ))
-
diff --git a/src/seedsigner/gui/screens/screen.py b/src/seedsigner/gui/screens/screen.py
index d0005fb9..b229857d 100644
--- a/src/seedsigner/gui/screens/screen.py
+++ b/src/seedsigner/gui/screens/screen.py
@@ -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
diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py
index c09f1e27..55fa5dd0 100644
--- a/src/seedsigner/gui/screens/settings_screens.py
+++ b/src/seedsigner/gui/screens/settings_screens.py
@@ -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
+ ))
diff --git a/src/seedsigner/gui/screens/tools_screens.py b/src/seedsigner/gui/screens/tools_screens.py
index 4e8ad2f6..13fa33be 100644
--- a/src/seedsigner/gui/screens/tools_screens.py
+++ b/src/seedsigner/gui/screens/tools_screens.py
@@ -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...
diff --git a/src/seedsigner/gui/toast.py b/src/seedsigner/gui/toast.py
new file mode 100644
index 00000000..7a07f30b
--- /dev/null
+++ b/src/seedsigner/gui/toast.py
@@ -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,
+ )
diff --git a/src/seedsigner/hardware/microsd.py b/src/seedsigner/hardware/microsd.py
index 247df91b..26065c03 100644
--- a/src/seedsigner/hardware/microsd.py
+++ b/src/seedsigner/hardware/microsd.py
@@ -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)
diff --git a/src/seedsigner/helpers/embit_utils.py b/src/seedsigner/helpers/embit_utils.py
index c33153e8..da8f827a 100644
--- a/src/seedsigner/helpers/embit_utils.py
+++ b/src/seedsigner/helpers/embit_utils.py
@@ -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():
diff --git a/src/seedsigner/helpers/ur2/bytewords.py b/src/seedsigner/helpers/ur2/bytewords.py
index b7e8432a..a1204ed2 100644
--- a/src/seedsigner/helpers/ur2/bytewords.py
+++ b/src/seedsigner/helpers/ur2/bytewords.py
@@ -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
diff --git a/src/seedsigner/helpers/ur2/fountain_decoder.py b/src/seedsigner/helpers/ur2/fountain_decoder.py
index c0b3f873..69eb64e3 100644
--- a/src/seedsigner/helpers/ur2/fountain_decoder.py
+++ b/src/seedsigner/helpers/ur2/fountain_decoder.py
@@ -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)))
diff --git a/src/seedsigner/helpers/ur2/fountain_encoder.py b/src/seedsigner/helpers/ur2/fountain_encoder.py
index d539e460..ac43bb62 100644
--- a/src/seedsigner/helpers/ur2/fountain_encoder.py
+++ b/src/seedsigner/helpers/ur2/fountain_encoder.py
@@ -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
diff --git a/src/seedsigner/helpers/ur2/random_sampler.py b/src/seedsigner/helpers/ur2/random_sampler.py
index 087789e8..d1b80411 100644
--- a/src/seedsigner/helpers/ur2/random_sampler.py
+++ b/src/seedsigner/helpers/ur2/random_sampler.py
@@ -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)
diff --git a/src/seedsigner/helpers/ur2/utils.py b/src/seedsigner/helpers/ur2/utils.py
index 4e88c363..31a0dfe1 100644
--- a/src/seedsigner/helpers/ur2/utils.py
+++ b/src/seedsigner/helpers/ur2/utils.py
@@ -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]
diff --git a/src/seedsigner/models/seed_storage.py b/src/seedsigner/models/seed_storage.py
index dcc30a7a..5b6c3049 100644
--- a/src/seedsigner/models/seed_storage.py
+++ b/src/seedsigner/models/seed_storage.py
@@ -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
diff --git a/src/seedsigner/models/settings.py b/src/seedsigner/models/settings.py
index eaf9c247..f0b1c97f 100644
--- a/src/seedsigner/models/settings.py
+++ b/src/seedsigner/models/settings.py
@@ -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}")
diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py
index 5c9e4b99..76534cec 100644
--- a/src/seedsigner/models/settings_definition.py
+++ b/src/seedsigner/models/settings_definition.py
@@ -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,
diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py
index b9cfa064..9c0d9c60 100644
--- a/src/seedsigner/views/psbt_views.py
+++ b/src/seedsigner/views/psbt_views.py
@@ -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:
diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py
index 2b83d03b..17f71333 100644
--- a/src/seedsigner/views/scan_views.py
+++ b/src/seedsigner/views/scan_views.py
@@ -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)
diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py
index f863c15d..960df6c8 100644
--- a/src/seedsigner/views/screensaver.py
+++ b/src/seedsigner/views/screensaver.py
@@ -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
diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py
index 6de06db8..15c3fde9 100644
--- a/src/seedsigner/views/seed_views.py
+++ b/src/seedsigner/views/seed_views.py
@@ -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)
diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py
index ca9c36dc..e2a742ca 100644
--- a/src/seedsigner/views/settings_views.py
+++ b/src/seedsigner/views/settings_views.py
@@ -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
diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py
index b8249f6f..12a4493b 100644
--- a/src/seedsigner/views/tools_views.py
+++ b/src/seedsigner/views/tools_views.py
@@ -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)
diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py
index dfd59eb3..3f507a73 100644
--- a/src/seedsigner/views/view.py
+++ b/src/seedsigner/views/view.py
@@ -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)
diff --git a/tests/base.py b/tests/base.py
index 0628fb1a..496e1b86 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -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):
diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py
index b498fa2e..83a58135 100644
--- a/tests/screenshot_generator/generator.py
+++ b/tests/screenshot_generator/generator.py
@@ -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 += """
{view_name}![]() |