Merge pull request #620 from kdmukai/initial_multilanguage_0.8.0

Initial l10n / multilanguage support
This commit is contained in:
Nick Klockenga
2024-12-21 19:34:29 -05:00
committed by GitHub
53 changed files with 4879 additions and 1773 deletions
+12 -5
View File
@@ -27,6 +27,9 @@ jobs:
steps:
- uses: actions/checkout@v3
with:
# Needs to also pull the seedsigner-translations repo
submodules: recursive
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
@@ -45,16 +48,20 @@ jobs:
--cov=seedsigner \
--cov-append \
--cov-branch \
--cov-report term \
--cov-report html \
--cov-report html:./artifacts/cov_html \
--cov-report xml \
--durations 5 \
-vv
- name: Generate screenshots
run: |
python -m pytest tests/screenshot_generator/generator.py
python -m pytest tests/screenshot_generator/generator.py \
--color=yes \
--cov=seedsigner \
--cov-append \
--cov-branch \
--cov-report html:./artifacts/cov_html \
-vv
cp -r ./seedsigner-screenshots ./artifacts/
- name: Coverage report
run: coverage report
- name: Archive CI Artifacts
uses: actions/upload-artifact@v3
with:
+4 -3
View File
@@ -5,6 +5,7 @@ src/seedsigner.egg-info/
.vscode
src/seedsigner/models/settings_definition.json
.idea
*.mo
.coverage
seedsigner-screenshots
.coverage*
*.po
*.mo
+9
View File
@@ -0,0 +1,9 @@
[submodule "src/seedsigner/resources/seedsigner-translations"]
path = src/seedsigner/resources/seedsigner-translations
url = https://github.com/SeedSigner/seedsigner-translations.git
branch = dev
[submodule "seedsigner-screenshots"]
path = seedsigner-screenshots
url = https://github.com/SeedSigner/seedsigner-screenshots.git
branch = dev
update = none
+316
View File
@@ -0,0 +1,316 @@
# Localization (l10n) Developer Notes
## High-level overview
1. Python code indicates text that needs to be translated.
1. Those marked strings are extracted into a master `messages.pot` file.
1. That file is uploaded [Transifex](https://app.transifex.com/seedsigner/seedsigner).
1. Translators work within Transifex on their respective languages.
1. Completed translations are downloaded as `messages.po` files for each language.
1. Python "compiles" them into `messages.mo` files ready for use.
1. The `*.po` and `*.mo` files are written to the [seedsigner-translations](https://github.com/SeedSigner/seedsigner-translations) repo.
1. That repo is linked as a submodule here as `seedsigner.resources.seedsigner-translations`.
1. Python code retrieves a translation on demand.
## "Wrapping" text for translation
Any text that we want to be presented in multiple languages needs to "wrapped".
The CORE CONCEPT to understand is that wrapping is used in TWO different contexts:
1. Pre-translation: This is how we identify text that translators need to translate. Any wrapped string literals will appear in translators' Transifex UI.
2. Post-translation: Return the locale-specific translation for that source string (defaults to the English string if no translation is found).
We have three techniques to wrap code, depending on which of the above contexts we're in and where we are in the code:
#### Technique 1: `ButtonOption`
Most `View` classes will render themselves via some variation of the `ButtonListScreen` which takes a `button_data` list as an input. Each entry in
`button_data` must be a `ButtonOption`. The first argument for `ButtonOption` is the `button_label` string. This is the English string literal that
is displayed in that button. If you look at `setup.cfg` you'll see that `ButtonOption` is listed as a keyword in `extract_messages`. That means
that the first argument for `ButtonOption` -- its `button_label` string -- will be marked for translation (by default the `extract_messages`
integration will only look at the first argument of any method listed in `keywords`).
```python
class SomeView(View):
# These string literals will be marked for translation
OPTION_1 = ButtonOption("Option 1!")
OPTION_2 = ButtonOption("Option 2!")
def run(self):
button_data = [self.OPTION_1, self.OPTION_2]
# No way for `extract_messages` to know what's in `some_var`; won't be marked for
# translation unless it's specified elsewhere.
some_var = some_value
button_data.append(ButtonOption(some_var))
```
These `ButtonOption` values are generally specified in class-level attributes, as in the above example. Classes in python are imported once, after
which class-level attributes are never reinterpreted again; **the value at import time for a class-level attribute is its value for the duration of
the program execution.**
This means that we must assume that `ButtonOption.button_label` strings are ALWAYS the original English string. This is crucial because the English
values are the lookup keys for the translations:
* `ButtonOption.button_label` = "Hello!" in the python code.
* Run the code, the class that contains our `ButtonOption` as a class-level attribute is imported.
* Regardless of language selection, that `ButtonOption` will always return "Hello!".
* `Screen` then uses "Hello!" as a key to find the translation "¡Hola!".
* User sees "¡Hola!".
IF `ButtonOption` were wired to return the translated string, we'd have a problem:
* User sets their language to Spanish and enables persistent settings.
* Launch SeedSigner. At import time the `button_label`'s value is translated to "¡Hola!".
* User sees "¡Hola!" in the UI. All good.
* User changes language to English (or any other language).
* Now the `Screen` must find the matching string in a different translation file.
* But the `button_label` value was fixed at import time; it's still providing "¡Hola!" as the lookup key.
* Since all the translation files map English -> translation, no such "¡Hola!" match exists in any translation file.
* So the translation falls back to just displaying the unmatched key: "¡Hola!"
tldr: `ButtonOption` marks its `button_label` English string literal for translation, but NEVER provides a translated value.
---
#### Technique 2: `seedsigner.helpers.l10n.mark_for_translation`
You'll see that `mark_for_translation` is imported as `_mft` for short.
As far as translations are concerned, `_mft` serves the same purpose as `ButtonOption`. The only difference is that `_mft` is for all other
(non-`button_data`) class-level attributes.
```python
from seedsigner.helpers.l10n import mark_for_translation as _mft
@classmethod
class SomeView(View):
title: str = _mft("Default Title")
text: str = _mft("My default body text")
def run(self):
self.run_screen(
SomeScreen,
title=self.title,
text=self.text
)
```
In general we try to avoid using `_mft` at all, but some class-level attributes just can't be avoided.
---
#### Technique 3: `gettext`, aka `_()`
This is the way you'll see text wrapping handled in the vast majority of tutorials.
```python
from gettext import gettext as _
my_text = _("Hello!")
# Specify Spanish
os.environ['LANGUAGE'] = "es"
print(my_text)
>> ¡Hola!
# Specify English
os.environ['LANGUAGE'] = "en"
print(my_text)
>> Hello!
```
This approach marks string literals for translation AND retrieves the translated text.
We do the same in SeedSigner code, but only when the string literal is in a part of the code that is dynamically evaluated:
```python
from gettext import gettext as _
class SomeView(View):
def __init__(self):
# Mark string literal for translation AND dynamically retrieve its translated value
self.some_var = _("I will be dynamically fetched")
```
Though note that there are times when we use `_()` only for the retrieval side:
```python
from seedsigner.helpers.l10n import mark_for_translation as _mft
class SomeView(View):
message = _mft("Hello!") # mark for translation, but always return "Hello!"
def run(self):
self.run_screen(
SomeScreen,
message=self.title
)
# elsewhere...
@dataclass
class SomeScreen(Screen):
message: str = None
def __post_init__(self):
message_display = TextArea(
text=_(self.message) # The _() wrapping here now retrieves the translated value, if one is available
)
```
---
## Basic rules
* English string literals in class-level attributes should be wrapped with either `ButtonOption` (for `button_data` entries) or `_mft` (for misc class-level attrs) so they'll be picked up for translation.
* English string literals anywhere else should be wrapped with `_()` to be marked for translation AND provide the dynamic translated value.
* In general, don't go out of your way to translate text before passing it into `Screen` classes.
* The `Screen` itself should do most of the `_()` calls to fetch translations for final display.
* Minor risk of double-translation weirdness otherwise.
Mark for translation in the `View`. Retrieve translated values in the `Screen`. Pass final display text into the basic gui `Component`s.
---
## Provide translation context hints
In many cases the English string literal on its own does not provide enough context for translators to understand how the word is being used.
For example, is "change" referring to altering a value OR is it the amount coming back to you in a transaction?
Whenever necessary, add explanatory context as a comment. This applies to all three ways of marking strings for translation.
The `extract_messages` command is explictly looking for the exact string: `# TRANSLATOR_NOTE:` in comments.
```python
class SeedAddressVerificationView(View):
# TRANSLATOR_NOTE: Option when scanning for a matching address; skips ten addresses ahead
SKIP_10 = ButtonOption("Skip 10")
```
Note that the comment MUST be on the preceding line of executable code for it to work:
```python
class SettingsConstants
# TRANSLATOR_NOTE: QR code density option: Low, Medium, High <-- ✅ Correct way to add context
density_low = _mft("Low")
ALL_DENSITIES = [
(DENSITY__LOW, density_low),
# TRANSLATOR_NOTE: QR code density option: Low, Medium, High <-- ❌ Note will NOT be picked up
(DENSITY__MEDIUM, "Medium"),
(DENSITY__HIGH, "High"),
]
```
```python
# TRANSLATOR_NOTE: Refers to the user's change output in a psbt
some_var = _("change")
```
---
## `_()` Wrapping syntax details
* Use `.format()` to wrap strings with variable injections. Note that `.format()` is OUTSIDE the `_()` wrapping.
```python
mystr = f"My dad's name is {dad.name} and my name is {self.name}."
mystr = _("My dad's name is {} and my name is {}").format(dad.name, self.name)
```
The translators will only see: "My dad's name is {} and my name is {}" in Transifex. Often the English string literal is
basically incomprehensible on its own so always provide an explanation for what is being injected:
```python
# TRANSLATOR_NOTE: Address verification success message (e.g. "bc1qabc = seed 12345678's receive address #0.")
text = _("{} = {}'s {} address #{}.").format(...)
```
If there are a lot of variables to inject, placeholder names can be used (TODO: how does Transifex display this?):
```python
mystr = _("My dad's name is {dad_name} and my name is {my_name}").format(dad_name=dad.name, my_name=self.name)
```
* Use `ngettext` to dynamically handle singular vs plural forms based on an integer quantity:
```python
n = 1
print(ngettext("apple", "apples", n))
>> apple
n = 5
print(ngettext("apple", "apples", n))
>> apples
```
Transifex will ask translators to provide the singular and plural forms on a language-specific basis (e.g. Arabic as THREE plural forms!).
---
## Set up localization dependencies
```bash
pip install -r l10n/requirements-l10n.txt
```
Make sure that your local repo has fetched the `seedsigner-translations` submodule. It's configured to add it in src/seedsigner/resources.
```bash
# Need --remote in order to respect the target branch listed in .gitmodules
git submodule update --remote
```
### Pre-configured `babel` commands
The `setup.cfg` file in the project root specifies params for the various `babel` commands discussed below.
You should have already added the local code as an editable project in pip:
```bash
# From the repo root
pip install -e .
```
### Rescanning for text that needs translations
Re-generate the `messages.pot` file:
```bash
python setup.py extract_messages
```
This will rescan all wrapped text, picking up new strings as well as updating existings strings that have been edited.
_TODO: Github Action to auto-generate messages.pot and fail a PR update if the PR has an out of date messages.pot?_
### Making new text available to translators
Upload the master `messages.pot` to Transifex. It will automatically update each language with the new or changed source strings.
_TODO: Look into Transifex options to automatically pull updates?_
### Once new translations are complete
The translation file for each language will need to be downloaded via Transifex's "Download for use" option (sends you a `messages.po` file for that language).
This updated `messages.po` should be added to the seedsigner-translations repo in l10n/`{TARGET_LOCALE}`/LC_MESSAGES.
### Compile all the translations
The `messages.po` files must be compiled into `*.mo` files:
```bash
python setup.py compile_catalog
# Or target a specific language code:
python setup.py compile_catalog -l es
```
### Unused babel commands
Transifex eliminates the need for the `init_catalog` and `update_catalog` commands.
## Keep the seedsigner-translations repo up to date
The *.po files for each language and their compiled *.mo files should all be kept up to date in the seedsigner-translations repo.
_TODO: Github Actions automation to regenerate / verify that the *.mo files have been updated after *.po changes._
---
## Generate screenshots in each language
Simply run the screenshot generator:
```bash
pytest tests/screenshot_generator/generator.py
# Or target a specific language code:
pytest tests/screenshot_generator/generator.py --locale es
```
+1489
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
Babel==2.16.0
+3 -4
View File
@@ -13,7 +13,7 @@ description = "Build an offline, airgapped Bitcoin signing device for less than
name = "seedsigner"
readme = "README.md"
requires-python = ">=3.10"
version = "0.8.0"
version = "0.8.5"
[project.urls]
"Bug Tracker" = "https://github.com/SeedSigner/seedsigner/issues"
@@ -47,13 +47,12 @@ exclude_lines = [
omit = [
"*/__init__.py",
"*/tests/*",
"*/pyzbar/*",
"*/gui/*"
]
skip_covered = true
skip_covered = false
skip_empty = true
[tool.coverage.run]
source = ["src"]
branch = true
[tool.pytest.ini_options]
+12
View File
@@ -0,0 +1,12 @@
[extract_messages]
keywords = _ _mft mark_for_translation ButtonOption
add_comments = TRANSLATOR_NOTE:
strip_comments = yes
add_location = file
input_dirs = src
output_file = l10n/messages.pot
[compile_catalog]
use_fuzzy = yes
directory = src/seedsigner/resources/seedsigner-translations/l10n
+8
View File
@@ -0,0 +1,8 @@
"""
Empty setup.py necessary for the python-babel integration (e.g. python setup.py extract_messages).
See the configuration in setup.cfg.
"""
import setuptools
setuptools.setup()
+14 -14
View File
@@ -99,7 +99,7 @@ class Controller(Singleton):
rather than at the top in order avoid circular imports.
"""
VERSION = "0.8.0"
VERSION = "0.8.5"
# Declare class member vars with type hints to enable richer IDE support throughout
# the code.
@@ -147,20 +147,20 @@ class Controller(Singleton):
else:
# Instantiate the one and only Controller instance
return cls.configure_instance()
@classmethod
def reset_instance(cls):
"""
Currently used by the screenshot generator, but could potentially be used to
wipe and reset the state of the device.
"""
cls._instance = None
cls.configure_instance()
@classmethod
def configure_instance(cls, disable_hardware=False):
"""
- `disable_hardware` is only meant to be used by the test suite so that it
can keep re-initializing a Controller in however many tests it needs to. But
this is only possible if the hardware isn't already being reserved. Without
this you get:
RuntimeError: Conflicting edge detection already enabled for this GPIO channel
each time you try to re-initialize a Controller.
"""
def configure_instance(cls):
from seedsigner.gui.renderer import Renderer
from seedsigner.hardware.microsd import MicroSD
@@ -248,10 +248,10 @@ class Controller(Singleton):
used. Only used by the test suite.
"""
from seedsigner.views import MainMenuView, BackStackView
from seedsigner.views.screensaver import OpeningSplashScreen
from seedsigner.views.screensaver import OpeningSplashView
from seedsigner.gui.toast import RemoveSDCardToastManagerThread
OpeningSplashScreen().start()
OpeningSplashView().run()
""" Class references can be stored as variables in python!
File diff suppressed because it is too large Load Diff
+17 -7
View File
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from PIL import Image, ImageDraw, ImageFont
from typing import Tuple
from gettext import gettext as _
from seedsigner.gui.components import Fonts, GUIConstants
from seedsigner.hardware.buttons import HardwareButtonsConstants
@@ -8,6 +9,10 @@ from seedsigner.hardware.buttons import HardwareButtonsConstants
class Keyboard:
"""
Note that it is up to the calling Screen to manage the Renderer.lock. The Keyboard
and its child Keys should NOT attempt to manage the lock.
"""
WRAP_TOP = "wrap_top"
WRAP_BOTTOM = "wrap_bottom"
WRAP_LEFT = "wrap_left"
@@ -27,39 +32,43 @@ class Keyboard:
REGULAR_KEY_FONT = "regular"
COMPACT_KEY_FONT = "compact"
# TRANSLATOR_NOTE: The abbreviated label for the special key <del> on a standard keyboard.
del_label = _("del")
KEY_BACKSPACE = {
"code": "DEL",
"letter": "del",
"letter": del_label,
"font": COMPACT_KEY_FONT,
"size": 2,
}
# TRANSLATOR_NOTE: The abbreviated label for the special key <space> on a standard keyboard.
space_label = _("space")
KEY_SPACE = {
"code": "SPACE",
"letter": "space",
"letter": space_label,
"font": COMPACT_KEY_FONT,
"size": 1,
}
KEY_SPACE_2 = {
"code": "SPACE",
"letter": "space",
"letter": space_label,
"font": COMPACT_KEY_FONT,
"size": 2,
}
KEY_SPACE_3 = {
"code": "SPACE",
"letter": "space",
"letter": space_label,
"font": COMPACT_KEY_FONT,
"size": 3,
}
KEY_SPACE_4 = {
"code": "SPACE",
"letter": "space",
"letter": space_label,
"font": COMPACT_KEY_FONT,
"size": 4,
}
KEY_SPACE_5 = {
"code": "SPACE",
"letter": "space",
"letter": space_label,
"font": COMPACT_KEY_FONT,
"size": 5,
}
@@ -158,7 +167,7 @@ class Keyboard:
self.screen_x + int(self.keyboard.key_width * self.size / 2),
self.screen_y + self.keyboard.key_height - int((self.keyboard.key_height - text_height)/2)
),
self.letter,
_(self.letter),
fill=font_color,
font=font,
anchor="ms"
@@ -208,6 +217,7 @@ class Keyboard:
# Set up the rendering and state params
self.active_keys = list(self.charset)
self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold", 18)
self.x_start = rect[0]
self.y_start = rect[1]
-1
View File
@@ -1,7 +1,6 @@
from PIL import Image, ImageDraw
from threading import Lock
from seedsigner.gui.components import Fonts, GUIConstants
from seedsigner.hardware.ST7789 import ST7789
from seedsigner.models.singleton import ConfigurableSingleton
+79 -54
View File
@@ -1,15 +1,17 @@
from dataclasses import dataclass
import math
from PIL import Image, ImageDraw, ImageFilter
from typing import List
import time
from dataclasses import dataclass
from gettext import gettext as _
from gettext import ngettext
from PIL import Image, ImageDraw, ImageFilter
from seedsigner.gui.components import (BtcAmount, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerIconConstants, TextArea,
calc_bezier_curve, linear_interp)
from seedsigner.gui.renderer import Renderer
from seedsigner.models.threads import BaseThread
from .screen import ButtonListScreen, WarningScreen
from ..components import (BtcAmount, Button, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerIconConstants, TextArea,
calc_bezier_curve, linear_interp)
from .screen import ButtonListScreen, ButtonOption
@@ -21,15 +23,15 @@ class PSBTOverviewScreen(ButtonListScreen):
num_inputs: int = 0
num_self_transfer_outputs: int = 0
num_change_outputs: int = 0
destination_addresses: List[str] = None
destination_addresses: list[str] = None
has_op_return: bool = False
def __post_init__(self):
# Customize defaults
self.title = "Review PSBT"
self.title = _("Review PSBT")
self.is_bottom_list = True
self.button_data = ["Review Details"]
self.button_data = [ButtonOption("Review Details")]
# This screen can take a while to load while parsing the PSBT
self.show_loading_screen = True
@@ -69,7 +71,7 @@ class PSBTOverviewScreen(ButtonListScreen):
draw = ImageDraw.Draw(image)
font_size = GUIConstants.BODY_FONT_MIN_SIZE * ssf
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, font_size)
font = Fonts.get_font(GUIConstants.get_body_font_name(), font_size)
(left, top, right, bottom) = font.getbbox(text="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890[]", anchor="lt")
chart_text_height = bottom
@@ -86,16 +88,18 @@ class PSBTOverviewScreen(ButtonListScreen):
# First calculate how wide the inputs col will be
inputs_column = []
if self.num_inputs == 1:
inputs_column.append("1 input")
inputs_column.append(_("1 input"))
elif self.num_inputs > 5:
inputs_column.append("input 1")
inputs_column.append("input 2")
inputs_column.append("[ ... ]")
inputs_column.append(f"input {self.num_inputs-1}")
inputs_column.append(f"input {self.num_inputs}")
inputs_column.append(_("input 1"))
inputs_column.append(_("input 2"))
# TRANSLATOR_NOTE: Indicates that items have been omitted from a series: e.g. "1, 2, 3, [...], 8"
inputs_column.append(_("[ ... ]"))
# TRANSLATOR_NOTE: Input number will be inserted (e.g. "input 3")
inputs_column.append(_("input {}").format(self.num_inputs-1))
inputs_column.append(_("input {}").format(self.num_inputs))
else:
for i in range(0, self.num_inputs):
inputs_column.append(f"input {i+1}")
inputs_column.append(_("input {}").format(i+1))
max_inputs_text_width = 0
for input in inputs_column:
@@ -123,11 +127,12 @@ class PSBTOverviewScreen(ButtonListScreen):
# Now let's maximize the actual destination col by adjusting our addr truncation
def calculate_destination_col_width(truncate_at: int = 0):
def truncate_destination_addr(addr):
# TODO: Properly handle the ellipsis truncation in different languages
if len(addr) <= truncate_at + len("..."):
# TRANSLATOR_NOTE: Ellipsis ("...") characters used to truncate an address (e.g. "bc1qabc...")
if len(addr) <= truncate_at + len(_("...")):
# No point in truncating
return addr
return f"{addr[:truncate_at]}..."
return addr[:truncate_at] + _("...")
destination_column = []
@@ -136,21 +141,25 @@ class PSBTOverviewScreen(ButtonListScreen):
destination_column.append(truncate_destination_addr(addr))
for i in range(0, self.num_self_transfer_outputs):
destination_column.append(truncate_destination_addr("self-transfer"))
destination_column.append(truncate_destination_addr(_("self-transfer")))
else:
# destination_column.append(f"{len(self.destination_addresses)} recipients")
destination_column.append(f"recipient 1")
destination_column.append(f"[ ... ]")
destination_column.append(f"recipient {len(self.destination_addresses) + self.num_self_transfer_outputs}")
destination_column.append(_("recipient 1"))
# TRANSLATOR_NOTE: Indicates that items have been omitted from a series: e.g. "1, 2, 3, [...], 8"
destination_column.append(_("[ ... ]"))
# TRANSLATOR_NOTE: Inserts the recipient number (e.g. the fifth one is: "recipient 5")
destination_column.append(_("recipient {}").format(len(self.destination_addresses) + self.num_self_transfer_outputs))
destination_column.append(f"fee")
destination_column.append(_("fee"))
if self.has_op_return:
destination_column.append("OP_RETURN")
# TRANSLATOR_NOTE: Technical term, should probably NOT be translated in most languages
destination_column.append(_("OP_RETURN"))
if self.num_change_outputs > 0:
for i in range(0, self.num_change_outputs):
destination_column.append("change")
# TRANSLATOR_NOTE: Label for a change output in the PSBT Overview flow diagram
destination_column.append(_("change"))
max_destination_text_width = 0
for destination in destination_column:
@@ -164,10 +173,16 @@ class PSBTOverviewScreen(ButtonListScreen):
# We're not going to display any destination addrs so truncation doesn't matter
(destination_text_width, destination_column) = calculate_destination_col_width()
else:
destination_text_width = None
destination_column = None
# Steadliy widen out the destination column until we run out of space
for i in range(6, 14):
(new_width, new_col_text) = calculate_destination_col_width(truncate_at=i)
if new_width > max_destination_col_width:
if not destination_text_width:
destination_text_width = new_width
if not destination_column:
destination_column = new_col_text
break
destination_text_width = new_width
destination_column = new_col_text
@@ -349,7 +364,7 @@ class PSBTOverviewScreen(ButtonListScreen):
destination_y += destination_y_spacing
# Resize to target and sharpen final image
image = image.resize((self.canvas_width, chart_height), Image.LANCZOS)
image = image.resize((self.canvas_width, chart_height), Image.Resampling.LANCZOS)
self.paste_images.append((image.filter(ImageFilter.SHARPEN), (self.chart_x, self.chart_y)))
# Pass input and output curves to the animation thread
@@ -463,14 +478,14 @@ class PSBTMathScreen(ButtonListScreen):
def __post_init__(self):
# Customize defaults
self.title = "PSBT Math"
self.button_data = ["Review Recipients"]
self.title = _("PSBT Math")
self.button_data = [ButtonOption("Review Recipients")]
self.is_bottom_list = True
super().__post_init__()
if self.input_amount > 1e6:
denomination = "btc"
denomination = _("btc")
self.input_amount /= 1e8
self.spend_amount /= 1e8
self.change_amount /= 1e8
@@ -482,7 +497,7 @@ class PSBTMathScreen(ButtonListScreen):
# lines up properly.
self.fee_amount = f"{self.fee_amount:10}"
else:
denomination = "sats"
denomination = _("sats")
self.input_amount = f"{self.input_amount:,}"
self.spend_amount = f"{self.spend_amount:,}"
self.fee_amount = f"{self.fee_amount:,}"
@@ -509,9 +524,9 @@ class PSBTMathScreen(ButtonListScreen):
image = Image.new("RGB", (body_width*ssf, body_height*ssf))
draw = ImageDraw.Draw(image)
body_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, (GUIConstants.BODY_FONT_SIZE)*ssf)
fixed_width_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, (GUIConstants.BODY_FONT_SIZE + 6)*ssf)
left, top, right, bottom = fixed_width_font.getbbox(self.input_amount + "Q")
body_font = Fonts.get_font(GUIConstants.get_body_font_name(), (GUIConstants.get_body_font_size())*ssf)
fixed_width_font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, (GUIConstants.get_body_font_size() + 6)*ssf)
left, top, right, bottom = fixed_width_font.getbbox(self.input_amount + "+")
digits_width, digits_height = right - left, bottom - top
# Draw each line of the equation
@@ -524,7 +539,7 @@ class PSBTMathScreen(ButtonListScreen):
# secondary_digit_color = GUIConstants.BODY_FONT_COLOR
# tertiary_digit_color = GUIConstants.BODY_FONT_COLOR
# digit_group_spacing = 0
if denomination == 'btc':
if denomination == _('btc'):
display_str = amount_str
main_zone = display_str[:-6]
mid_zone = display_str[-6:-3]
@@ -538,13 +553,12 @@ class PSBTMathScreen(ButtonListScreen):
draw.text((main_zone_width + digit_group_spacing + mid_zone_width + digit_group_spacing, cur_y), text=end_zone, font=fixed_width_font, fill=tertiary_digit_color)
else:
draw.text((0, cur_y), text=amount_str, font=fixed_width_font, fill=GUIConstants.BODY_FONT_COLOR)
draw.text((digits_width + 2*digit_group_spacing, cur_y), text=info_text, font=body_font, fill=info_text_color)
draw.text((digits_width + 3*digit_group_spacing, cur_y), text=info_text, font=body_font, fill=info_text_color)
render_amount(
cur_y,
f" {self.input_amount}",
# info_text=f""" {self.num_inputs} input{"s" if self.num_inputs > 1 else ""}""",
info_text=f""" input{"s" if self.num_inputs > 1 else ""}""",
info_text=ngettext("input", "inputs", self.num_inputs),
)
# spend_amount will be zero on self-transfers; only display when there's an
@@ -554,15 +568,14 @@ class PSBTMathScreen(ButtonListScreen):
render_amount(
cur_y,
f"-{self.spend_amount}",
# info_text=f""" {self.num_recipients} recipient{"s" if self.num_recipients > 1 else ""}""",
info_text=f""" recipient{"s" if self.num_recipients > 1 else ""}""",
info_text=ngettext("recipient", "recipients", self.num_recipients),
)
cur_y += digits_height + GUIConstants.BODY_LINE_SPACING * ssf
render_amount(
cur_y,
f"-{self.fee_amount}",
info_text=f""" fee""",
info_text=_("fee"),
)
cur_y += digits_height + GUIConstants.BODY_LINE_SPACING * ssf
@@ -572,12 +585,13 @@ class PSBTMathScreen(ButtonListScreen):
render_amount(
cur_y,
f" {self.change_amount}",
info_text=f" {denomination} change",
# TRANSLATOR_NOTE: Denonination is inserted (e.g. your "btc change" or "sats change")
info_text=_("{} change").format(denomination),
info_text_color="darkorange" # super-sampling alters the perceived color
)
# Resize to target and sharpen final image
image = image.resize((body_width, body_height), Image.LANCZOS)
image = image.resize((body_width, body_height), Image.Resampling.LANCZOS)
self.paste_images.append((image.filter(ImageFilter.SHARPEN), (GUIConstants.EDGE_PADDING, self.top_nav.height + GUIConstants.COMPONENT_PADDING)))
@@ -635,7 +649,6 @@ class PSBTAddressDetailsScreen(ButtonListScreen):
@dataclass
class PSBTChangeDetailsScreen(ButtonListScreen):
title: str = "Your Change"
amount: int = 0
address: str = None
is_multisig: bool = False
@@ -662,10 +675,20 @@ class PSBTChangeDetailsScreen(ButtonListScreen):
))
screen_y = self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING
change_type = _("Multisig") if self.is_multisig else self.fingerprint
if self.is_change_derivation_path :
addr_type = _("Change")
else:
# TRANSLATOR_NOTE: Abbreviation for receive address
addr_type = _("Addr")
value_text = "{}: {} #{}".format(change_type, addr_type, self.derivation_path_addr_index)
self.components.append(IconTextLine(
value_text=value_text,
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
value_text=f"""{"Multisig" if self.is_multisig else self.fingerprint}: {"Change" if self.is_change_derivation_path else "Addr"} #{self.derivation_path_addr_index}""",
is_text_centered=False,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=screen_y,
@@ -675,7 +698,7 @@ class PSBTChangeDetailsScreen(ButtonListScreen):
self.components.append(IconTextLine(
icon_name=SeedSignerIconConstants.SUCCESS,
icon_color=GUIConstants.SUCCESS_COLOR,
value_text="Address verified!",
value_text=_("Address verified!"),
is_text_centered=False,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
@@ -697,7 +720,7 @@ class PSBTOpReturnScreen(ButtonListScreen):
# Simple case: display human-readable text
self.components.append(TextArea(
text=self.op_return_data.decode(errors="strict"), # "strict" is a good enough heuristic to decide if it's human readable
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
font_size=GUIConstants.get_top_nav_title_font_size(),
is_text_centered=True,
allow_text_overflow=True,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
@@ -707,7 +730,7 @@ class PSBTOpReturnScreen(ButtonListScreen):
except UnicodeDecodeError:
# Contains data that can't be converted to UTF-8; probably encoded and not
# meant to be human readable.
font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, size=GUIConstants.BODY_FONT_SIZE)
font = Fonts.get_font(GUIConstants.FIXED_WIDTH_FONT_NAME, size=GUIConstants.get_body_font_size())
(left, top, right, bottom) = font.getbbox("X", anchor="ls")
chars_per_line = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / (right - left))
decoded_str = self.op_return_data.hex()
@@ -717,8 +740,10 @@ class PSBTOpReturnScreen(ButtonListScreen):
text += (decoded_str[i*chars_per_line:(i+1)*chars_per_line]) + "\n"
text = text[:-1]
# TRANSLATOR_NOTE: Shown when displaying OP_RETURN as non-human-readable hexadecimal data
hex_label = _("raw hex data")
label = TextArea(
text="raw hex data",
text=hex_label,
font_color=GUIConstants.LABEL_FONT_COLOR,
font_size=GUIConstants.LABEL_FONT_SIZE,
screen_y=self.top_nav.height,
@@ -728,7 +753,7 @@ class PSBTOpReturnScreen(ButtonListScreen):
self.components.append(TextArea(
text=text,
font_name=GUIConstants.FIXED_WIDTH_FONT_NAME,
font_size=GUIConstants.BODY_FONT_SIZE,
font_size=GUIConstants.get_body_font_size(),
screen_y=label.screen_y + label.height + GUIConstants.COMPONENT_PADDING,
))
@@ -738,7 +763,7 @@ class PSBTOpReturnScreen(ButtonListScreen):
class PSBTFinalizeScreen(ButtonListScreen):
def __post_init__(self):
# Customize defaults
self.title = "Sign PSBT"
self.title = _("Sign PSBT")
self.is_bottom_list = True
super().__post_init__()
@@ -752,6 +777,6 @@ class PSBTFinalizeScreen(ButtonListScreen):
self.components.append(icon)
self.components.append(TextArea(
text="Click to authorize this transaction",
screen_y=icon.screen_y + icon.height + GUIConstants.COMPONENT_PADDING
text=_("Click to approve this transaction"),
screen_y=icon.screen_y + icon.height + 2*GUIConstants.COMPONENT_PADDING
))
+19 -12
View File
@@ -1,16 +1,15 @@
import time
from dataclasses import dataclass
from gettext import gettext as _
from PIL import Image, ImageDraw
from seedsigner.gui import renderer
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.hardware.camera import Camera
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
from seedsigner.gui.components import GUIConstants, Fonts
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
from .screen import BaseScreen
from ..components import GUIConstants, Fonts, SeedSignerIconConstants
@@ -56,7 +55,8 @@ class ScanScreen(BaseScreen):
# Initialize the base class
super().__post_init__()
self.instructions_text = "< back | " + self.instructions_text
# TODO: Arrange this with UI elements rather than text
self.instructions_text = "< " + _("back") + " | " + _(self.instructions_text)
self.camera = Camera.get_instance()
self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb")
@@ -65,7 +65,6 @@ class ScanScreen(BaseScreen):
self.frames_decoded_counter = ThreadsafeCounter()
self.threads.append(ScanScreen.LivePreviewThread(
camera=self.camera,
decoder=self.decoder,
renderer=self.renderer,
instructions_text=self.instructions_text,
@@ -76,8 +75,10 @@ class ScanScreen(BaseScreen):
class LivePreviewThread(BaseThread):
def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int], frame_decode_status: ThreadsafeCounter, frames_decoded_counter: ThreadsafeCounter):
self.camera = camera
def __init__(self, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int], frame_decode_status: ThreadsafeCounter, frames_decoded_counter: ThreadsafeCounter):
from seedsigner.hardware.camera import Camera
self.camera = Camera.get_instance()
self.decoder = decoder
self.renderer = renderer
self.instructions_text = instructions_text
@@ -96,10 +97,12 @@ class ScanScreen(BaseScreen):
def run(self):
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
from timeit import default_timer as timer
instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size())
# pre-calculate how big the animated QR percent display can be
left, _, right, _ = instructions_font.getbbox("100%")
left, top, right, bottom = instructions_font.getbbox("100%")
progress_text_width = right - left
start_time = time.time()
@@ -201,11 +204,12 @@ class ScanScreen(BaseScreen):
radius=8
)
# TRANSLATOR_NOTE: Inserts the percentage value of the animated QR scan progress
text = _("{}%").format(progress_percentage)
draw.text(
xy=(rectangle.width - GUIConstants.EDGE_PADDING, int(rectangle.height / 2)),
text=f"{progress_percentage}%",
# text=f"100%",
text=text,
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
anchor="rm", # right-justified, middle
@@ -248,6 +252,9 @@ class ScanScreen(BaseScreen):
Screen. Once interaction starts, the display updates have to be managed in
_run(). The live preview is an extra-complex case.
"""
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.models.decode_qr import DecodeQRStatus
num_frames = 0
start_time = time.time()
while True:
+212 -160
View File
@@ -1,14 +1,15 @@
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
from gettext import gettext as _
from PIL import Image, ImageDraw, ImageColor
from typing import Any, List, Tuple
from seedsigner.helpers.l10n import mark_for_translation as _mft
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.hardware.buttons import HardwareButtonsConstants, HardwareButtons
from seedsigner.models.encode_qr import BaseQrEncoder
from seedsigner.models.settings import SettingsConstants
@@ -43,6 +44,13 @@ class BaseScreen(BaseComponent):
# Tracks position on scrollable pages, determines which elements are visible.
self.scroll_y = 0
def get_threads(self) -> List[BaseThread]:
threads = self.threads.copy()
for component in self.components:
threads += component.threads
return threads
def display(self) -> Any:
@@ -51,15 +59,16 @@ class BaseScreen(BaseComponent):
self._render()
self.renderer.show_image()
for t in self.threads:
t.start()
for t in self.get_threads():
if not t.is_alive():
t.start()
return self._run()
except Exception as e:
repr(e)
raise e
finally:
for t in self.threads:
for t in self.get_threads():
t.stop()
@@ -99,19 +108,17 @@ class BaseScreen(BaseComponent):
For example: A basic menu screen where the user can key up and down. The
Screen can handle the UI updates to light up the currently selected menu item
on its own. Only when the user clicks to make a selection would run() exit
and returns the selected option.
on its own. Only when the user clicks to make a selection would _run() exit
and return the selected option.
But an alternate use case returns immediately after each user input so the
View can update its controlling logic accordingly (e.g. as the user joysticks
over different letters in the keyboard UI, we need to make matching changes
to the list of mnemonic seed words that match the new letter).
In general, _run() will be implemented as a continuous loop waiting for user
input and redrawing the screen as needed. When it redraws, it must claim
the `Renderer.lock` to ensure that its updates don't conflict with any other
threads that might be updating the screen at the same time (e.g. flashing
warning edges, auto-scrolling long titles or buttons, etc).
In this case, it would be called repeatedly in a loop:
* run() and wait for it to handle user input
* run() exits and returns the user input (e.g. KEY_UP)
* View updates its state of the world accordingly
* loop and call run() again
Just note that this loop cannot hold the lock indefinitely! Each iteration
through the loop should claim the lock, render, and then release it.
"""
raise Exception("Must implement in a child class")
@@ -124,6 +131,7 @@ class LoadingScreenThread(BaseThread):
def run(self):
from seedsigner.gui.renderer import Renderer
renderer: Renderer = Renderer.get_instance()
center_image = load_image("btc_logo_60x60.png")
@@ -147,7 +155,7 @@ class LoadingScreenThread(BaseThread):
if self.text:
TextArea(
text=self.text,
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
font_size=GUIConstants.get_top_nav_title_font_size(),
screen_y=int((renderer.canvas_height - bounding_box[3])/2),
).render()
@@ -190,7 +198,7 @@ class BaseTopNavScreen(BaseScreen):
top_nav_icon_name: str = None
top_nav_icon_color: str = None
title: str = "Screen Title"
title_font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE
title_font_size: int = GUIConstants.get_top_nav_title_font_size()
show_back_button: bool = True
show_power_button: bool = False
@@ -199,7 +207,7 @@ class BaseTopNavScreen(BaseScreen):
self.top_nav = TopNav(
icon_name=self.top_nav_icon_name,
icon_color=self.top_nav_icon_color,
text=self.title,
text=_(self.title), # Wrap here for just-in-time translations
font_size=self.title_font_size,
width=self.canvas_width,
height=GUIConstants.TOP_NAV_HEIGHT,
@@ -251,14 +259,29 @@ class BaseTopNavScreen(BaseScreen):
@dataclass
class ButtonOption:
"""
Note: The babel config in setup.cfg will extract the `button_label` string for translation
"""
button_label: str
icon_name: str = None
icon_color: str = None
right_icon_name: str = None
button_label_color: str = None
return_data: Any = None
active_button_label: str = None # Changes displayed button label when button is active
@dataclass
class ButtonListScreen(BaseTopNavScreen):
button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str)
button_data: list[ButtonOption] = None
selected_button: int = 0
is_button_text_centered: bool = True
is_bottom_list: bool = False
button_font_name: str = GUIConstants.BUTTON_FONT_NAME
button_font_size: int = GUIConstants.BUTTON_FONT_SIZE
button_font_name: str = None
button_font_size: int = None
button_selected_color: str = GUIConstants.ACCENT_COLOR
# Params for version of list used for Settings
@@ -270,7 +293,12 @@ class ButtonListScreen(BaseTopNavScreen):
def __post_init__(self):
if not self.button_font_name:
self.button_font_name = GUIConstants.get_button_font_name()
if not self.button_font_size:
self.button_font_size = GUIConstants.get_button_font_size()
super().__post_init__()
button_height = GUIConstants.BUTTON_HEIGHT
if len(self.button_data) == 1:
button_list_height = button_height
@@ -289,29 +317,26 @@ class ButtonListScreen(BaseTopNavScreen):
self.has_scroll_arrows = True
self.buttons: List[Button] = []
for i, button_label in enumerate(self.button_data):
for i, button_option in enumerate(self.button_data):
icon_name = None
icon_color = None
right_icon_name = None
button_label_color = None
# TODO: Define an actual class for button_data?
if type(button_label) == tuple:
if len(button_label) == 2:
(button_label, icon_name) = button_label
icon_color = GUIConstants.BUTTON_FONT_COLOR
elif len(button_label) == 3:
(button_label, icon_name, icon_color) = button_label
elif len(button_label) == 4:
(button_label, icon_name, icon_color, button_label_color) = button_label
elif len(button_label) == 5:
(button_label, icon_name, icon_color, button_label_color, right_icon_name) = button_label
if type(button_option) == ButtonOption:
button_label = button_option.button_label
icon_name = button_option.icon_name
icon_color = button_option.icon_color
right_icon_name = button_option.right_icon_name
button_label_color = button_option.button_label_color
active_button_label = button_option.active_button_label
else:
raise Exception("Refactor needed!")
button_kwargs = dict(
text=button_label,
text=_(button_label), # Wrap here for just-in-time translations
active_text=_(active_button_label), # Wrap here for just-in-time translations
icon_name=icon_name,
icon_color=icon_color if icon_color else GUIConstants.BUTTON_FONT_COLOR,
is_icon_inline=True,
@@ -325,13 +350,14 @@ class ButtonListScreen(BaseTopNavScreen):
font_name=self.button_font_name,
font_size=self.button_font_size,
font_color=button_label_color if button_label_color else GUIConstants.BUTTON_FONT_COLOR,
selected_color=self.button_selected_color
selected_color=self.button_selected_color,
is_scrollable_text=True, # We need to use the ScrollableText class for long button labels
)
if self.checked_buttons and i in self.checked_buttons:
button_kwargs["is_checked"] = True
button = self.Button_cls(**button_kwargs)
self.buttons.append(button)
if self.has_scroll_arrows:
self.arrow_half_width = 10
self.cur_scroll_y = self.scroll_y_initial_offset if self.scroll_y_initial_offset is not None else 0
@@ -352,10 +378,21 @@ class ButtonListScreen(BaseTopNavScreen):
cur_selected_button.is_selected = True
def get_threads(self) -> List[BaseThread]:
threads = super().get_threads()
for button in self.buttons:
if button.is_scrollable_text:
threads += button.threads
return threads
def _render(self):
super()._render()
self._render_visible_buttons()
# Write the screen updates
self.renderer.show_image()
def _render_visible_buttons(self):
if self.has_scroll_arrows:
@@ -510,13 +547,18 @@ class ButtonListScreen(BaseTopNavScreen):
@dataclass
class LargeButtonScreen(BaseTopNavScreen):
button_data: list = None # list can be a mix of str or tuple(label: str, icon_name: str)
button_font_name: str = GUIConstants.BUTTON_FONT_NAME
button_font_size: int = 20
button_data: list = None
button_font_name: str = None
button_font_size: int = None
button_selected_color: str = GUIConstants.ACCENT_COLOR
selected_button: int = 0
def __post_init__(self):
if not self.button_font_name:
self.button_font_name = GUIConstants.get_button_font_name()
if not self.button_font_size:
self.button_font_size = GUIConstants.get_button_font_size() + 2
super().__post_init__()
if len(self.button_data) not in [2, 4]:
@@ -533,11 +575,20 @@ class LargeButtonScreen(BaseTopNavScreen):
button_start_y = self.top_nav.height + int((self.canvas_height - (self.top_nav.height + GUIConstants.COMPONENT_PADDING) - (2 * button_height) - GUIConstants.COMPONENT_PADDING) / 2)
self.buttons = []
for i, button_label in enumerate(self.button_data):
if type(button_label) == tuple:
(button_label, icon_name) = button_label
for i, button_option in enumerate(self.button_data):
if type(button_option) == ButtonOption:
button_label = button_option.button_label
icon_name = button_option.icon_name
else:
icon_name = None
raise Exception("Refactor needed!")
# elif type(button_option) == str:
# button_label = button_option
# icon_name = None
# elif type(button_option) == tuple:
# (button_label, icon_name) = button_option
# else:
# print(type(button_option))
if i % 2 == 0:
button_start_x = GUIConstants.EDGE_PADDING
@@ -545,7 +596,7 @@ class LargeButtonScreen(BaseTopNavScreen):
button_start_x = GUIConstants.EDGE_PADDING + button_width + GUIConstants.COMPONENT_PADDING
button_args = {
"text": button_label,
"text": _(button_label), # Wrap here for just-in-time translations
"screen_x": button_start_x,
"screen_y": button_start_y,
"width": button_width,
@@ -661,12 +712,12 @@ class QRDisplayScreen(BaseScreen):
qr_encoder: BaseQrEncoder = None
class QRDisplayThread(BaseThread):
def __init__(self, qr_encoder: BaseQrEncoder, qr_brightness: ThreadsafeCounter, renderer: Renderer,
tips_start_time: ThreadsafeCounter):
def __init__(self, qr_encoder: BaseQrEncoder, qr_brightness: ThreadsafeCounter, tips_start_time: ThreadsafeCounter):
from seedsigner.gui.renderer import Renderer
super().__init__()
self.qr_encoder = qr_encoder
self.qr_brightness = qr_brightness
self.renderer = renderer
self.renderer = Renderer.get_instance()
self.tips_start_time = tips_start_time
@@ -676,7 +727,7 @@ class QRDisplayScreen(BaseScreen):
# Instantiate a temp Image and ImageDraw object to draw on
rectangle_width = image.width
rectangle_height = GUIConstants.COMPONENT_PADDING * 2 + GUIConstants.BODY_FONT_SIZE * 2 + GUIConstants.BODY_LINE_SPACING
rectangle_height = GUIConstants.COMPONENT_PADDING * 2 + GUIConstants.get_body_font_size() * 2 + GUIConstants.BODY_LINE_SPACING
rectangle = Image.new('RGBA', (rectangle_width, rectangle_height), (0, 0, 0, 0))
img_draw = ImageDraw.Draw(rectangle)
@@ -691,7 +742,7 @@ class QRDisplayScreen(BaseScreen):
screen_x=GUIConstants.EDGE_PADDING*2 + 1,
screen_y=GUIConstants.COMPONENT_PADDING + 4, # +4 fudge factor to account for where the chevron is drawn relative to baseline
icon_name=SeedSignerIconConstants.CHEVRON_UP,
icon_size=GUIConstants.BODY_FONT_SIZE,
icon_size=GUIConstants.get_body_font_size(),
)
chevron_up_icon.render()
@@ -705,12 +756,14 @@ class QRDisplayScreen(BaseScreen):
)
chevron_down_icon.render()
# TRANSLATOR_NOTE: Increase QR code screen brightness
text = _("Brighter")
TextArea(
image_draw=img_draw,
canvas=rectangle,
text="Brighter",
font_size=GUIConstants.BODY_FONT_SIZE,
font_name=GUIConstants.BUTTON_FONT_NAME,
text=text,
font_size=GUIConstants.get_body_font_size(),
font_name=GUIConstants.get_button_font_name(),
background_color=(0, 0, 0, overlay_opacity),
edge_padding=0,
is_text_centered=False,
@@ -721,12 +774,14 @@ class QRDisplayScreen(BaseScreen):
allow_text_overflow=False
).render()
# TRANSLATOR_NOTE: Decrease QR code screen brightness
text = _("Darker")
TextArea(
image_draw=img_draw,
canvas=rectangle,
text="Darker",
font_size=GUIConstants.BODY_FONT_SIZE,
font_name=GUIConstants.BUTTON_FONT_NAME,
text=text,
font_size=GUIConstants.get_body_font_size(),
font_name=GUIConstants.get_button_font_name(),
background_color=(0, 0, 0, overlay_opacity),
edge_padding=0,
is_text_centered=False,
@@ -789,7 +844,6 @@ class QRDisplayScreen(BaseScreen):
self.threads.append(QRDisplayScreen.QRDisplayThread(
qr_encoder=self.qr_encoder,
qr_brightness=self.qr_brightness,
renderer=self.renderer,
tips_start_time=self.tips_start_time
))
@@ -831,11 +885,11 @@ class QRDisplayScreen(BaseScreen):
@dataclass
class LargeIconStatusScreen(ButtonListScreen):
title: str = "Success!"
title: str = _mft("Success!")
status_icon_name: str = SeedSignerIconConstants.SUCCESS
status_icon_size: int = GUIConstants.ICON_PRIMARY_SCREEN_SIZE
status_color: str = GUIConstants.SUCCESS_COLOR
status_headline: str = "Success!" # The colored text under the large icon
status_headline: str = None
text: str = "" # The body text of the screen
text_edge_padding: int = GUIConstants.EDGE_PADDING
button_data: list = None
@@ -843,9 +897,9 @@ class LargeIconStatusScreen(ButtonListScreen):
def __post_init__(self):
self.is_bottom_list: bool = True
if not self.button_data:
self.button_data = ["OK"]
self.button_data = [ButtonOption("OK")]
self.is_bottom_list = True
super().__post_init__()
self.status_icon = Icon(
@@ -860,22 +914,21 @@ class LargeIconStatusScreen(ButtonListScreen):
next_y = self.status_icon.screen_y + self.status_icon.height + int(GUIConstants.COMPONENT_PADDING/2)
if self.status_headline:
self.warning_headline_textarea = TextArea(
text=self.status_headline,
text=_(self.status_headline), # Wrap here for just-in-time translations
width=self.canvas_width,
screen_y=next_y,
font_color=self.status_color,
allow_text_overflow=self.allow_text_overflow,
auto_line_break=False, # Force headline to be on one line
)
self.components.append(self.warning_headline_textarea)
next_y = next_y + self.warning_headline_textarea.height
self.components.append(TextArea(
height=self.buttons[0].screen_y - next_y,
text=self.text,
text=_(self.text),
width=self.canvas_width,
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,
))
@@ -958,22 +1011,17 @@ class WarningEdgesMixin:
@dataclass
class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
title: str = "Caution"
title: str = _mft("Caution")
status_icon_name: str = SeedSignerIconConstants.WARNING
status_color: str = "yellow"
status_headline: str = "Privacy Leak!" # The colored text under the alert icon
def __post_init__(self):
if not self.button_data:
self.button_data = ["I Understand"]
super().__post_init__()
status_headline: str = _mft("Privacy Leak!") # The colored text under the alert icon
button_data: list = field(default_factory=lambda: [ButtonOption("I Understand")])
@dataclass
class DireWarningScreen(WarningScreen):
status_headline: str = "Classified Info!" # The colored text under the alert icon
status_headline: str = _mft("Classified Info!") # The colored text under the alert icon
status_color: str = GUIConstants.DIRE_WARNING_COLOR
@@ -981,12 +1029,12 @@ class DireWarningScreen(WarningScreen):
@dataclass
class ResetScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "Restarting"
self.title = _("Restarting")
self.show_back_button = False
super().__post_init__()
self.components.append(TextArea(
text="SeedSigner is restarting.\n\nAll in-memory data will be wiped.",
text=_("SeedSigner is restarting.\n\nAll in-memory data will be wiped."),
screen_y=self.top_nav.height,
height=self.canvas_height - self.top_nav.height,
))
@@ -996,12 +1044,12 @@ class ResetScreen(BaseTopNavScreen):
@dataclass
class PowerOffScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "Powering Off"
self.title = _("Powering Off")
self.show_back_button = False
super().__post_init__()
self.components.append(TextArea(
text="Please wait about 30 seconds before disconnecting power.",
text=_("Please wait about 30 seconds before disconnecting power."),
screen_y=self.top_nav.height,
height=self.canvas_height - self.top_nav.height,
))
@@ -1011,12 +1059,12 @@ class PowerOffScreen(BaseTopNavScreen):
@dataclass
class PowerOffNotRequiredScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "Just Unplug It"
self.title = _("Just Unplug It")
self.show_back_button = True
super().__post_init__()
self.components.append(TextArea(
text="It is safe to disconnect power at any time.",
text=_("It is safe to disconnect power at any time."),
screen_y=self.top_nav.height,
height=self.canvas_height - self.top_nav.height,
))
@@ -1044,7 +1092,7 @@ class KeyboardScreen(BaseTopNavScreen):
rows: int = None
cols: int = None
keyboard_font_name: str = GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME
keyboard_font_size: int = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 2
keyboard_font_size: int = None
key_height: int = None
keys_charset: str = None
keys_to_values: dict = None
@@ -1053,6 +1101,9 @@ class KeyboardScreen(BaseTopNavScreen):
initial_value: str = ""
def __post_init__(self):
if self.keyboard_font_size is None:
self.keyboard_font_size = GUIConstants.get_top_nav_title_font_size() + 2
super().__post_init__()
if self.initial_value:
@@ -1132,7 +1183,7 @@ class KeyboardScreen(BaseTopNavScreen):
self.text_entry_display.render()
self.renderer.show_image()
def _run(self):
self.cursor_position = len(self.user_input)
@@ -1144,84 +1195,85 @@ class KeyboardScreen(BaseTopNavScreen):
check_release=True,
release_keys=[HardwareButtonsConstants.KEY_PRESS, HardwareButtonsConstants.KEY3]
)
# Check possible exit conditions
if self.top_nav.is_selected and input == HardwareButtonsConstants.KEY_PRESS:
return RET_CODE__BACK_BUTTON
elif self.show_save_button and input == HardwareButtonsConstants.KEY3:
# Save!
if len(self.user_input) == 0:
# Don't try to submit zero input
with self.renderer.lock:
# Check possible exit conditions
if self.top_nav.is_selected and input == HardwareButtonsConstants.KEY_PRESS:
return RET_CODE__BACK_BUTTON
elif self.show_save_button and input == HardwareButtonsConstants.KEY3:
# Save!
if len(self.user_input) == 0:
# Don't try to submit zero input
continue
# First show the save button reacting to the click
self.save_button.is_selected = True
self.save_button.render()
self.renderer.show_image()
# Then return the input to the View
return self.user_input.strip()
# Process normal input
if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected:
# We're navigating off the previous button
self.top_nav.is_selected = False
self.top_nav.render_buttons()
# Override the actual input w/an ENTER signal for the Keyboard
if input == HardwareButtonsConstants.KEY_DOWN:
input = Keyboard.ENTER_TOP
else:
input = Keyboard.ENTER_BOTTOM
elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected:
# ignore
continue
# First show the save button reacting to the click
self.save_button.is_selected = True
self.save_button.render()
self.renderer.show_image()
ret_val = self.keyboard.update_from_input(input)
# Then return the input to the View
return self.user_input.strip()
# Process normal input
if input in [HardwareButtonsConstants.KEY_UP, HardwareButtonsConstants.KEY_DOWN] and self.top_nav.is_selected:
# We're navigating off the previous button
self.top_nav.is_selected = False
self.top_nav.render_buttons()
# Override the actual input w/an ENTER signal for the Keyboard
if input == HardwareButtonsConstants.KEY_DOWN:
input = Keyboard.ENTER_TOP
else:
input = Keyboard.ENTER_BOTTOM
elif input in [HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT] and self.top_nav.is_selected:
# ignore
continue
ret_val = self.keyboard.update_from_input(input)
# Now process the result from the keyboard
if ret_val in Keyboard.EXIT_DIRECTIONS:
self.top_nav.is_selected = True
self.top_nav.render_buttons()
elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS:
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
if len(self.user_input) > 0:
self.user_input = self.user_input[:-1]
self.cursor_position -= 1
elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
# User has locked in the current letter
if self.keys_to_values:
# Map the Key display char to its output value (e.g. dice icon to digit)
ret_val = self.keys_to_values[ret_val]
self.user_input += ret_val
self.cursor_position += 1
if self.cursor_position == self.return_after_n_chars:
return self.user_input
# Render a new TextArea over the TopNav title bar
if self.update_title():
TextArea(
text=self.title,
font_name=GUIConstants.TOP_NAV_TITLE_FONT_NAME,
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
height=self.top_nav.height,
).render()
# Now process the result from the keyboard
if ret_val in Keyboard.EXIT_DIRECTIONS:
self.top_nav.is_selected = True
self.top_nav.render_buttons()
elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN:
# Live joystick movement; haven't locked this new letter in yet.
# Leave current spot blank for now. Only update the active keyboard keys
# when a selection has been locked in (KEY_PRESS) or removed ("del").
pass
# Render the text entry display and cursor block
self.text_entry_display.render(self.user_input)
self.renderer.show_image()
elif ret_val in Keyboard.ADDITIONAL_KEYS and input == HardwareButtonsConstants.KEY_PRESS:
if ret_val == Keyboard.KEY_BACKSPACE["code"]:
if len(self.user_input) > 0:
self.user_input = self.user_input[:-1]
self.cursor_position -= 1
elif input == HardwareButtonsConstants.KEY_PRESS and ret_val not in Keyboard.ADDITIONAL_KEYS:
# User has locked in the current letter
if self.keys_to_values:
# Map the Key display char to its output value (e.g. dice icon to digit)
ret_val = self.keys_to_values[ret_val]
self.user_input += ret_val
self.cursor_position += 1
if self.cursor_position == self.return_after_n_chars:
return self.user_input
# Render a new TextArea over the TopNav title bar
if self.update_title():
TextArea(
text=self.title,
font_name=GUIConstants.get_top_nav_title_font_name(),
font_size=GUIConstants.get_top_nav_title_font_size(),
height=self.top_nav.height,
).render()
self.top_nav.render_buttons()
elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN:
# Live joystick movement; haven't locked this new letter in yet.
# Leave current spot blank for now. Only update the active keyboard keys
# when a selection has been locked in (KEY_PRESS) or removed ("del").
pass
# Render the text entry display and cursor block
self.text_entry_display.render(self.user_input)
self.renderer.show_image()
def update_title(self) -> bool:
@@ -1229,7 +1281,7 @@ class KeyboardScreen(BaseTopNavScreen):
Optionally update the self.title after each completed key input.
e.g. to increment the dice roll count:
self.title = f"Roll {self.cursor_position + 1}"
self.title = _("Roll {}".format(self.cursor_position + 1))
"""
return False
File diff suppressed because it is too large Load Diff
+39 -25
View File
@@ -1,17 +1,20 @@
import time
from dataclasses import dataclass
from gettext import gettext as _
from PIL.ImageOps import autocontrast
from typing import List
from seedsigner.helpers.l10n import mark_for_translation as _mft
from seedsigner.gui.components import Button, CheckboxButton, CheckedSelectionButton, FontAwesomeIconConstants, Fonts, GUIConstants, Icon, IconButton, IconTextLine, TextArea
from seedsigner.gui.screens.scan_screens import ScanScreen
from seedsigner.gui.screens.screen import BaseScreen, BaseTopNavScreen, ButtonListScreen
from seedsigner.gui.screens.screen import BaseScreen, BaseTopNavScreen, ButtonListScreen, ButtonOption
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.hardware.camera import Camera
from seedsigner.models.settings import SettingsConstants
@dataclass
class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
display_name: str = None
@@ -21,7 +24,7 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
selected_button: int = 0
def __post_init__(self):
self.title = "Settings"
self.title = _("Settings")
self.is_bottom_list = True
self.use_checked_selection_buttons = True
if self.settings_entry_type == SettingsConstants.TYPE__MULTISELECT:
@@ -31,20 +34,21 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
super().__post_init__()
self.components.append(TextArea(
text=self.display_name,
text=_(self.display_name),
font_size=GUIConstants.BODY_FONT_MAX_SIZE,
is_text_centered=True,
auto_line_break=False,
auto_line_break=True,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
))
if self.help_text:
prev_component_bottom = self.components[-1].screen_y + self.components[-1].height
self.components.append(TextArea(
text=self.help_text,
text=_(self.help_text),
font_color=GUIConstants.LABEL_FONT_COLOR,
is_text_centered=True,
screen_y=prev_component_bottom + GUIConstants.COMPONENT_PADDING,
auto_line_break=True,
))
@@ -52,7 +56,8 @@ class SettingsEntryUpdateSelectionScreen(ButtonListScreen):
@dataclass
class IOTestScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "I/O Test"
# TRANSLATOR_NOTE: Short for "Input/Output"; screen to make sure the buttons and camera are working properly
self.title = _("I/O Test")
self.show_back_button = False
self.resolution = (96, 96)
self.framerate = 10
@@ -122,8 +127,8 @@ class IOTestScreen(BaseTopNavScreen):
self.components.append(self.joystick_right_button)
# Hardware keys UI
font = Fonts.get_font(GUIConstants.BUTTON_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
(left, top, text_width, bottom) = font.getbbox(text="Clear", anchor="ls")
font = Fonts.get_font(GUIConstants.get_button_font_name(), GUIConstants.get_button_font_size())
(left, top, text_width, bottom) = font.getbbox(text=_("Clear"), anchor="ls")
icon = Icon(
icon_name=FontAwesomeIconConstants.CAMERA,
icon_size=GUIConstants.ICON_INLINE_FONT_SIZE,
@@ -133,12 +138,14 @@ class IOTestScreen(BaseTopNavScreen):
key2_y = int(self.canvas_height/2) - int(key_button_height/2)
self.key2_button = Button(
text="Clear", # Initialize with text to set vertical centering
# TRANSLATOR_NOTE: Blank the screen
text=_("Clear"), # Initialize with text to set vertical centering
width=key_button_width,
height=key_button_height,
screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING,
screen_y=key2_y,
outline_color=GUIConstants.ACCENT_COLOR,
is_scrollable_text=False, # Text has to dynamically update, can't use scrollable Button
)
self.key2_button.text = " " # but default state is empty
self.components.append(self.key2_button)
@@ -154,12 +161,13 @@ class IOTestScreen(BaseTopNavScreen):
self.components.append(self.key1_button)
self.key3_button = Button(
text="Exit",
text=_("Exit"),
width=key_button_width,
height=key_button_height,
screen_x=self.canvas_width - key_button_width + GUIConstants.EDGE_PADDING,
screen_y=key2_y + 3*GUIConstants.COMPONENT_PADDING + key_button_height,
outline_color=GUIConstants.ACCENT_COLOR,
is_scrollable_text=False, # No help for l10n, but currently ScrollableTextLine interferes with the small button's left edge. (TODO:)
)
self.components.append(self.key3_button)
@@ -168,8 +176,8 @@ class IOTestScreen(BaseTopNavScreen):
cur_selected_button = self.key1_button
msg_height = GUIConstants.ICON_LARGE_BUTTON_SIZE + 2*GUIConstants.COMPONENT_PADDING
camera_message = TextArea(
text="Capturing image...",
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE,
text=_("Capturing image..."),
font_size=GUIConstants.get_top_nav_title_font_size(),
is_text_centered=True,
height=msg_height,
screen_y=int((self.canvas_height - msg_height)/ 2),
@@ -178,21 +186,25 @@ class IOTestScreen(BaseTopNavScreen):
input = self.hw_inputs.wait_for(keys=HardwareButtonsConstants.ALL_KEYS, check_release=False)
if input == HardwareButtonsConstants.KEY1:
# Note that there are three distinct screen updates that happen at
# different times, therefore we claim the `Renderer.lock` three separate
# times.
cur_selected_button = self.key1_button
with self.renderer.lock:
cur_selected_button.is_selected = True
cur_selected_button.render()
camera_message.render()
# Render edges around message box
self.image_draw.rectangle(
(
-1, int((self.canvas_height - msg_height)/ 2) - 1,
self.canvas_width + 1, int((self.canvas_height + msg_height)/ 2) + 1
),
fill="black",
outline=GUIConstants.ACCENT_COLOR,
width=1,
)
cur_selected_button.is_selected = True
cur_selected_button.render()
camera_message.render()
self.renderer.show_image()
# Snap a pic, render it as the background, re-render all onscreen elements
@@ -214,7 +226,7 @@ class IOTestScreen(BaseTopNavScreen):
)
with self.renderer.lock:
self.canvas.paste(display_version, (0, self.top_nav.height))
self.key2_button.text = "Clear"
self.key2_button.text = _("Clear")
for component in self.components:
component.render()
self.renderer.show_image()
@@ -281,17 +293,19 @@ class IOTestScreen(BaseTopNavScreen):
@dataclass
class DonateScreen(BaseTopNavScreen):
def __post_init__(self):
self.title = "Donate"
self.title = _("Donate")
super().__post_init__()
self.components.append(TextArea(
text="SeedSigner is 100% free & open source, funded solely by the Bitcoin community.\n\nDonate onchain or LN at:",
# TRANSLATOR_NOTE: If your language uses the percent sign ("%"), your translation must also use two percent signs ("%%") due to python formatting oddities. "100%%" will be rendered as "100%".
text=_("SeedSigner is 100%% free & open source, funded solely by the Bitcoin community.\n\nDonate onchain or LN at:").replace("%%", "%"),
screen_y=self.top_nav.height + 3*GUIConstants.COMPONENT_PADDING,
))
self.components.append(TextArea(
text="seedsigner.com",
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 8,
font_name=GUIConstants.get_body_font_name(),
font_size=28,
font_color=GUIConstants.ACCENT_COLOR,
supersampling_factor=1,
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
@@ -302,20 +316,20 @@ class DonateScreen(BaseTopNavScreen):
@dataclass
class SettingsQRConfirmationScreen(ButtonListScreen):
config_name: str = None
title: str = "Settings QR"
status_message: str = "Settings updated..."
title: str = _mft("Settings QR")
status_message: str = _mft("Settings updated...")
is_bottom_list: bool = True
def __post_init__(self):
# Customize defaults
self.button_data = ["Home"]
self.button_data = [ButtonOption("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}"',
text=f'"{self.config_name}"', # User-supplied string (from SettingsQR); don't wrap to translate
is_text_centered=True,
auto_line_break=True,
screen_y=start_y
@@ -324,7 +338,7 @@ class SettingsQRConfirmationScreen(ButtonListScreen):
start_y = self.config_name_textarea.screen_y + 50
self.components.append(TextArea(
text=self.status_message,
text=_(self.status_message),
is_text_centered=True,
auto_line_break=True,
screen_y=start_y
+92 -65
View File
@@ -1,6 +1,7 @@
import time
from dataclasses import dataclass
from gettext import gettext as _
from typing import Any
from PIL.Image import Image
from seedsigner.hardware.camera import Camera
@@ -15,10 +16,6 @@ from seedsigner.models.settings_definition import SettingsConstants, SettingsDef
@dataclass
class ToolsImageEntropyLivePreviewScreen(BaseScreen):
def __post_init__(self):
# Customize defaults
self.title = "Initializing Camera..."
# Initialize the base class
super().__post_init__()
self.camera = Camera.get_instance()
@@ -29,10 +26,9 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
# save preview image frames to use as additional entropy below
preview_images = []
max_entropy_frames = 50
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size())
while True:
# 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()
@@ -53,6 +49,27 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
self.hw_inputs.update_last_input_time()
self.camera.stop_video_stream_mode()
with self.renderer.lock:
self.renderer.canvas.paste(frame)
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text=_("Capturing image..."),
fill=GUIConstants.ACCENT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
self.renderer.show_image()
return preview_images
# If we're still here, it's just another preview frame loop
with self.renderer.lock:
self.renderer.canvas.paste(frame)
self.renderer.draw.text(
@@ -60,8 +77,8 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text="Capturing image...",
fill=GUIConstants.ACCENT_COLOR,
text="< " + _("back") + " | " + _("click joystick"), # TODO: Render with UI elements instead of text
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
@@ -69,25 +86,6 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
)
self.renderer.show_image()
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.
@@ -101,22 +99,29 @@ class ToolsImageEntropyFinalImageScreen(BaseScreen):
final_image: Image = None
def _run(self):
instructions_font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.BUTTON_FONT_SIZE)
instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size())
self.renderer.canvas.paste(self.final_image)
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text=" < reshoot | accept > ",
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
self.renderer.show_image()
with self.renderer.lock:
self.renderer.canvas.paste(self.final_image)
# TRANSLATOR_NOTE: A prompt to the user to either accept or reshoot the image
reshoot = _("reshoot")
# TRANSLATOR_NOTE: A prompt to the user to either accept or reshoot the image
accept = _("accept")
self.renderer.draw.text(
xy=(
int(self.renderer.canvas_width/2),
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
),
text=" < " + reshoot + " | " + accept + " > ",
fill=GUIConstants.BODY_FONT_COLOR,
font=instructions_font,
stroke_width=4,
stroke_fill=GUIConstants.BACKGROUND_COLOR,
anchor="ms"
)
self.renderer.show_image()
input = self.hw_inputs.wait_for([HardwareButtonsConstants.KEY_LEFT, HardwareButtonsConstants.KEY_RIGHT])
if input == HardwareButtonsConstants.KEY_LEFT:
@@ -126,9 +131,10 @@ class ToolsImageEntropyFinalImageScreen(BaseScreen):
@dataclass
class ToolsDiceEntropyEntryScreen(KeyboardScreen):
def __post_init__(self):
# Override values set by the parent class
self.title = f"Dice Roll 1/{self.return_after_n_chars}"
# TRANSLATOR_NOTE: current roll number vs total rolls (e.g. roll 7 of 50)
self.title = _("Dice Roll {}/{}").format(1, self.return_after_n_chars)
# Specify the keys in the keyboard
self.rows = 3
@@ -159,7 +165,7 @@ class ToolsDiceEntropyEntryScreen(KeyboardScreen):
def update_title(self) -> bool:
self.title = f"Dice Roll {self.cursor_position + 1}/{self.return_after_n_chars}"
self.title = _("Dice Roll {}/{}").format(self.cursor_position + 1, self.return_after_n_chars)
return True
@@ -170,13 +176,15 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
num_entropy_bits: int = None
def __post_init__(self):
self.title = "Build Final Word"
# TRANSLATOR_NOTE: Build the last word in a 12 or 24 word BIP-39 mnemonic seed phrase.
self.title = _("Build Final Word")
self.is_bottom_list = True
self.is_button_text_centered = True
super().__post_init__()
self.components.append(TextArea(
text=f"The {self.mnemonic_length}th word is built from {self.num_entropy_bits} more entropy bits plus auto-calculated checksum.",
# TRANSLATOR_NOTE: Number of BIP-39 seed words, and the entropy -- in bits, contained within.
text=_("The {}th word is built from {} more entropy bits plus auto-calculated checksum.").format(self.mnemonic_length, self.num_entropy_bits),
screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2),
))
@@ -186,29 +194,33 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
class ToolsCoinFlipEntryScreen(KeyboardScreen):
def __post_init__(self):
# Override values set by the parent class
self.title = f"Coin Flip 1/{self.return_after_n_chars}"
# TRANSLATOR_NOTE: current coin-flip number vs total flips (e.g. flip 3 of 4)
self.title = _("Coin Flip {}/{}").format(1, self.return_after_n_chars)
# Specify the keys in the keyboard
self.rows = 1
self.cols = 4
self.key_height = GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 2 + 2*GUIConstants.EDGE_PADDING
self.key_height = GUIConstants.get_top_nav_title_font_size() + 2 + 2*GUIConstants.EDGE_PADDING
self.keys_charset = "10"
# Now initialize the parent class
super().__post_init__()
self.components.append(TextArea(
text="Heads = 1",
# TRANSLATOR_NOTE: How we call the "front" side result during a coin toss.
text=_("Heads = 1"),
screen_y = self.keyboard.rect[3] + 4*GUIConstants.COMPONENT_PADDING,
))
self.components.append(TextArea(
text="Tails = 0",
# TRANSLATOR_NOTE: How we call the "back" side result during a coin toss.
text=_("Tails = 0"),
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
))
def update_title(self) -> bool:
self.title = f"Coin Flip {self.cursor_position + 1}/{self.return_after_n_chars}"
# l10n_note already done.
self.title = _("Coin Flip {}/{}").format(self.cursor_position + 1, self.return_after_n_chars)
return True
@@ -225,7 +237,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
super().__post_init__()
# First what's the total bit display width and where do the checksum bits start?
bit_font_size = GUIConstants.BUTTON_FONT_SIZE + 2
bit_font_size = GUIConstants.get_button_font_size() + 2
font = Fonts.get_font(GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, bit_font_size)
(left, top, bit_display_width, bit_font_height) = font.getbbox("0" * 11, anchor="lt")
(left, top, checksum_x, bottom) = font.getbbox("0" * (11 - len(self.checksum_bits)), anchor="lt")
@@ -249,8 +261,10 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
# significant n bits always rendered in same column)
discard_selected_bits = "_" * (len(self.checksum_bits))
# TRANSLATOR_NOTE: The additional entropy the user supplied (e.g. coin flips)
your_input = _('Your input: "{}"').format(selection_text)
self.components.append(TextArea(
text=f"""Your input: \"{selection_text}\"""",
text=your_input,
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
))
@@ -285,7 +299,8 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
# Show the checksum..
self.components.append(TextArea(
text="Checksum",
# TRANSLATOR_NOTE: A function of "x" to be used for detecting errors in "x"
text=_("Checksum"),
edge_padding=0,
screen_y=first_bits_line.screen_y + first_bits_line.height + 2*GUIConstants.COMPONENT_PADDING,
))
@@ -321,7 +336,8 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
# And now the *actual* final word after merging the bit data
self.components.append(TextArea(
text=f"""Final Word: \"{self.actual_final_word}\"""",
# TRANSLATOR_NOTE: labeled presentation of the last word in a BIP-39 mnemonic seed phrase.
text=_('Final Word: "{}"').format(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
))
@@ -361,15 +377,20 @@ class ToolsCalcFinalWordDoneScreen(ButtonListScreen):
fingerprint: str = None
def __post_init__(self):
# Customize defaults
self.title = f"{self.mnemonic_word_length}th Word"
# Manually specify 12 vs 24 case for easier ordinal translation
if self.mnemonic_word_length == 12:
# TRANSLATOR_NOTE: a label for the last word of a 12-word BIP-39 mnemonic seed phrase
self.title = _("12th Word")
else:
# TRANSLATOR_NOTE: a label for the last word of a 24-word BIP-39 mnemonic seed phrase
self.title = _("24th Word")
self.is_bottom_list = True
super().__post_init__()
self.components.append(TextArea(
text=f"""\"{self.final_word}\"""",
font_size=GUIConstants.TOP_NAV_TITLE_FONT_SIZE + 6,
font_size=26,
is_text_centered=True,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
))
@@ -377,7 +398,8 @@ class ToolsCalcFinalWordDoneScreen(ButtonListScreen):
self.components.append(IconTextLine(
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
label_text="fingerprint",
# TRANSLATOR_NOTE: a label for the shortened Key-id of a BIP-32 master HD wallet
label_text=_("fingerprint"),
value_text=self.fingerprint,
is_text_centered=True,
screen_y=self.components[-1].screen_y + self.components[-1].height + 3*GUIConstants.COMPONENT_PADDING,
@@ -393,7 +415,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
custom_derivation_path: str = None
def __post_init__(self):
self.title = "Address Explorer"
# TRANSLATOR_NOTE: a label for the tool to explore public addresses for this seed.
self.title = _("Address Explorer")
self.is_bottom_list = True
super().__post_init__()
@@ -401,7 +424,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
self.components.append(IconTextLine(
icon_name=SeedSignerIconConstants.FINGERPRINT,
icon_color=GUIConstants.INFO_COLOR,
label_text="Fingerprint",
# TRANSLATOR_NOTE: a label for the shortened Key-id of a BIP-32 master HD wallet
label_text=_("Fingerprint"),
value_text=self.fingerprint,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
@@ -410,7 +434,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
if self.script_type != SettingsConstants.CUSTOM_DERIVATION:
self.components.append(IconTextLine(
icon_name=SeedSignerIconConstants.DERIVATION,
label_text="Derivation",
# TRANSLATOR_NOTE: a label for the derivation-path into a BIP-32 HD wallet
label_text=_("Derivation"),
value_text=SettingsDefinition.get_settings_entry(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(value=self.script_type),
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
@@ -418,7 +443,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
else:
self.components.append(IconTextLine(
icon_name=SeedSignerIconConstants.DERIVATION,
label_text="Derivation",
# l10n_note already exists.
label_text=_("Derivation"),
value_text=self.custom_derivation_path,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
@@ -426,7 +452,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
else:
self.components.append(IconTextLine(
label_text="Wallet descriptor",
# TRANSLATOR_NOTE: a label for a BIP-380-ish Output Descriptor
label_text=_("Wallet descriptor"),
value_text=self.wallet_descriptor_display_name,
is_text_centered=True,
screen_x=GUIConstants.EDGE_PADDING,
+16 -9
View File
@@ -1,6 +1,8 @@
import logging
import time
from dataclasses import dataclass
from gettext import gettext as _
from seedsigner.gui.components import BaseComponent, GUIConstants, Icon, SeedSignerIconConstants, TextArea
from seedsigner.models.threads import BaseThread
@@ -187,21 +189,26 @@ class BaseToastOverlayManagerThread(BaseThread):
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.
def __init__(self, activation_delay: int = 3, duration: int = 1e6):
"""
* activation_delay: configurable so the screenshot generator can get the
toast to immediately render.
* duration: default value is essentially forever. Overrideable for the
screenshot generator.
"""
super().__init__(
activation_delay=activation_delay, # seconds
duration=1e6, # seconds ("forever")
activation_delay=activation_delay,
duration=duration,
)
def instantiate_toast(self) -> ToastOverlay:
body_font_size = GUIConstants.get_body_font_size()
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,
label_text=_("You can remove\nthe SD card now"),
font_size=body_font_size,
height=body_font_size * 2 + GUIConstants.BODY_LINE_SPACING + GUIConstants.EDGE_PADDING,
)
@@ -219,7 +226,7 @@ class SDCardStateChangeToastManagerThread(BaseToastOverlayManagerThread):
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"
self.message = _("SD card removed") if action == MicroSD.ACTION__REMOVED else _("SD card inserted")
super().__init__(*args, **kwargs)
+9
View File
@@ -65,6 +65,15 @@ class HardwareButtons(Singleton):
return cls._instance
@classmethod
def get_instance_no_hardware(cls):
# This is the only way to access the one and only instance
if cls._instance is None:
cls._instance = cls.__new__(cls)
def wait_for(self, keys=[], check_release=True, release_keys=[]) -> int:
# TODO: Refactor to keep control in the Controller and not here
from seedsigner.controller import Controller
+2 -1
View File
@@ -1,6 +1,5 @@
import io
from picamera import PiCamera
from PIL import Image
from seedsigner.hardware.pivideostream import PiVideoStream
from seedsigner.models.settings import Settings, SettingsConstants
@@ -23,6 +22,7 @@ class Camera(Singleton):
def start_video_stream_mode(self, resolution=(512, 384), framerate=12, format="bgr"):
from seedsigner.hardware.pivideostream import PiVideoStream
if self._video_stream is not None:
self.stop_video_stream_mode()
@@ -49,6 +49,7 @@ class Camera(Singleton):
def start_single_frame_mode(self, resolution=(720, 480)):
from picamera import PiCamera
if self._video_stream is not None:
self.stop_video_stream_mode()
if self._picamera is not None:
+1 -1
View File
@@ -50,7 +50,7 @@ def get_standard_derivation_path(network: str = SettingsConstants.MAINNET, walle
elif script_type == SettingsConstants.NATIVE_SEGWIT:
return f"m/48'/{network_path}/0'/2'"
elif script_type == SettingsConstants.TAPROOT:
raise Exception("Taproot multisig/musig not yet supported")
raise Exception("Taproot multisig not yet supported")
else:
raise Exception("Unexpected script type")
else:
+3
View File
@@ -0,0 +1,3 @@
def mark_for_translation(message: str) -> str:
# Wraps the target string literal for translation but does NOT return the translated string.
return message
+1 -1
View File
@@ -102,6 +102,6 @@ class QR:
# if qrencode fails, fall back to only encoder
if rv != 0:
return self.qrimage(data,width,height,border)
img = Image.open("/tmp/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA")
img = Image.open("/tmp/qrcode.png").resize((width,height), Image.Resampling.NEAREST).convert("RGBA")
return img
+5
View File
@@ -78,6 +78,11 @@ class Seed:
return unicodedata.normalize("NFC", " ".join(self._mnemonic)).split()
@property
def has_passphrase(self):
return self._passphrase != ""
@property
def passphrase(self):
return self._passphrase
+34 -7
View File
@@ -1,6 +1,8 @@
import gettext
import logging
import json
import os
import pathlib
import platform
from typing import List
@@ -36,6 +38,19 @@ class Settings(Singleton):
with open(Settings.SETTINGS_FILENAME) as settings_file:
settings.update(json.load(settings_file))
# Setup multilanguage support
path = os.path.join(
pathlib.Path(__file__).parent.resolve().parent.resolve(),
"resources",
"seedsigner-translations",
"l10n"
)
gettext.bindtextdomain('messages', localedir=path)
gettext.textdomain('messages')
# Load default/persistent locale setting
settings.load_locale()
return cls._instance
@@ -141,11 +156,8 @@ class Settings(Singleton):
# Break comma-separated SettingsQR input into List
new_settings[entry.attr_name] = new_settings[entry.attr_name].split(",")
# Can't just merge the _data dict; have to replace keys they have in common
# (otherwise list values will be merged instead of replaced).
for key, value in new_settings.items():
self._data.pop(key, None)
self._data[key] = value
self.set_value(key, value)
def set_value(self, attr_name: str, value: any):
@@ -155,7 +167,9 @@ class Settings(Singleton):
Note that for multiselect, the value must be a List.
"""
if attr_name not in self._data:
raise Exception(f"Setting for {attr_name} not found")
# Outdated settings
print(f"Setting {attr_name} not recognized. Ignoring.")
return
if SettingsDefinition.get_settings_entry(attr_name).type == SettingsConstants.TYPE__MULTISELECT:
if type(value) != list:
@@ -171,7 +185,11 @@ class Settings(Singleton):
self._data[attr_name] = value
self.save()
# Special handling for localization
if attr_name == SettingsConstants.SETTING__LOCALE:
self.load_locale()
def get_value(self, attr_name: str):
"""
@@ -222,13 +240,22 @@ class Settings(Singleton):
return display_names
def load_locale(self):
locale = self.get_value(SettingsConstants.SETTING__LOCALE)
os.environ['LANGUAGE'] = locale
# Re-initialize with the new locale
print(f"Set LANGUAGE locale to {os.environ['LANGUAGE']}")
"""
Intentionally keeping the properties very limited to avoid an expectation of
boilerplate property code for every SettingsEntry.
It's more cumbersome, but instead use:
settings.get_value(SettingsConstants.SETTING__MY_SETTING_ATTR)
Settings.get_instance().get_value(SettingsConstants.SETTING__MY_SETTING_ATTR)
"""
@property
def debug(self) -> bool:
+96 -66
View File
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Any, List
from seedsigner.helpers.l10n import mark_for_translation as _mft
class SettingsConstants:
@@ -10,25 +11,25 @@ class SettingsConstants:
OPTION__PROMPT = "P"
OPTION__REQUIRED = "R"
OPTIONS__ENABLED_DISABLED = [
(OPTION__ENABLED, "Enabled"),
(OPTION__DISABLED, "Disabled"),
(OPTION__ENABLED, _mft("Enabled")),
(OPTION__DISABLED, _mft("Disabled")),
]
OPTIONS__ONLY_DISABLED = [
(OPTION__DISABLED, "Disabled"),
(OPTION__DISABLED, _mft("Disabled")),
]
OPTIONS__PROMPT_REQUIRED_DISABLED = [
(OPTION__PROMPT, "Prompt"),
(OPTION__REQUIRED, "Required"),
(OPTION__DISABLED, "Disabled"),
(OPTION__PROMPT, _mft("Prompt")),
(OPTION__REQUIRED, _mft("Required")),
(OPTION__DISABLED, _mft("Disabled")),
]
OPTIONS__ENABLED_DISABLED_REQUIRED = OPTIONS__ENABLED_DISABLED +[
(OPTION__REQUIRED, "Required"),
(OPTION__REQUIRED, _mft("Required")),
]
OPTIONS__ENABLED_DISABLED_PROMPT = OPTIONS__ENABLED_DISABLED + [
(OPTION__PROMPT, "Prompt"),
(OPTION__PROMPT, _mft("Prompt")),
]
ALL_OPTIONS = OPTIONS__ENABLED_DISABLED_PROMPT + [
(OPTION__REQUIRED, "Required"),
(OPTION__REQUIRED, _mft("Required")),
]
# User-facing selection options
@@ -45,9 +46,30 @@ class SettingsConstants:
(COORDINATOR__KEEPER, "Keeper"),
]
LANGUAGE__ENGLISH = "en"
ALL_LANGUAGES = [
(LANGUAGE__ENGLISH, "English"),
LOCALE__ARABIC = "ar"
LOCALE__CZECH = "cs"
LOCALE__ENGLISH = "en"
LOCALE__FRENCH = "fr"
LOCALE__GERMAN = "de"
LOCALE__HEBREW = "he"
LOCALE__JAPANESE = "ja"
LOCALE__KOREAN = "kr"
LOCALE__PORTUGUESE = "pt"
LOCALE__RUSSIAN = "ru"
LOCALE__SPANISH = "es"
# Do not wrap for translation; always present each language in its native form
ALL_LOCALES = [
# (LOCALE__ARABIC, "Arabic"),
# (LOCALE__CZECH, "čeština"),
(LOCALE__ENGLISH, "English"),
# (LOCALE__FRENCH, "Français"),
# (LOCALE__GERMAN, "Deutsch"),
# (LOCALE__HEBREW, "Hebrew"),
# (LOCALE__JAPANESE, "Japanese"),
# (LOCALE__KOREAN, "Korean"),
# (LOCALE__PORTUGUESE, "Português"),
# (LOCALE__RUSSIAN, "русский"),
(LOCALE__SPANISH, "Español"),
]
BTC_DENOMINATION__BTC = "btc"
@@ -55,10 +77,10 @@ class SettingsConstants:
BTC_DENOMINATION__THRESHOLD = "thr"
BTC_DENOMINATION__BTCSATSHYBRID = "hyb"
ALL_BTC_DENOMINATIONS = [
(BTC_DENOMINATION__BTC, "BTC"),
(BTC_DENOMINATION__SATS, "sats"),
(BTC_DENOMINATION__THRESHOLD, "Threshold at 0.01"),
(BTC_DENOMINATION__BTCSATSHYBRID, "BTC | sats hybrid"),
(BTC_DENOMINATION__BTC, _mft("BTC")),
(BTC_DENOMINATION__SATS, _mft("sats")),
(BTC_DENOMINATION__THRESHOLD, _mft("Threshold at 0.01")),
(BTC_DENOMINATION__BTCSATSHYBRID, _mft("BTC | sats hybrid")),
]
CAMERA_ROTATION__0 = 0
@@ -66,20 +88,26 @@ class SettingsConstants:
CAMERA_ROTATION__180 = 180
CAMERA_ROTATION__270 = 270
ALL_CAMERA_ROTATIONS = [
(CAMERA_ROTATION__0, "0°"),
(CAMERA_ROTATION__90, "90°"),
(CAMERA_ROTATION__180, "180°"),
(CAMERA_ROTATION__270, "270°"),
(CAMERA_ROTATION__0, _mft("0°")),
(CAMERA_ROTATION__90, _mft("90°")),
(CAMERA_ROTATION__180, _mft("180°")),
(CAMERA_ROTATION__270, _mft("270°")),
]
# QR code constants
DENSITY__LOW = "L"
DENSITY__MEDIUM = "M"
DENSITY__HIGH = "H"
# TRANSLATOR_NOTE: QR code density option: Low, Medium, High
density_low = _mft("Low")
# TRANSLATOR_NOTE: QR code density option: Low, Medium, High
density_medium = _mft("Medium")
# TRANSLATOR_NOTE: QR code density option: Low, Medium, High
density_high = _mft("High")
ALL_DENSITIES = [
(DENSITY__LOW, "Low"),
(DENSITY__MEDIUM, "Medium"),
(DENSITY__HIGH, "High"),
(DENSITY__LOW, density_low),
(DENSITY__MEDIUM, density_medium),
(DENSITY__HIGH, density_high),
]
# Seed-related constants
@@ -87,13 +115,14 @@ class SettingsConstants:
TESTNET = "T"
REGTEST = "R"
ALL_NETWORKS = [
(MAINNET, "Mainnet"),
(TESTNET, "Testnet"),
(REGTEST, "Regtest")
(MAINNET, _mft("Mainnet")),
(TESTNET, _mft("Testnet")),
(REGTEST, _mft("Regtest"))
]
@classmethod
def map_network_to_embit(cls, network) -> str:
# Note these are `embit` constants; do not wrap for translation
if network == SettingsConstants.MAINNET:
return "main"
elif network == SettingsConstants.TESTNET:
@@ -101,14 +130,14 @@ 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"
PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT = _mft("Store Settings on SD card")
PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT = _mft("Insert SD card to enable")
SINGLE_SIG = "ss"
MULTISIG = "ms"
ALL_SIG_TYPES = [
(SINGLE_SIG, "Single Sig"),
(MULTISIG, "Multisig"),
(SINGLE_SIG, _mft("Single Sig")),
(MULTISIG, _mft("Multisig")),
]
LEGACY_P2PKH = "leg"
@@ -117,11 +146,11 @@ class SettingsConstants:
TAPROOT = "tr"
CUSTOM_DERIVATION = "cus"
ALL_SCRIPT_TYPES = [
(NATIVE_SEGWIT, "Native Segwit"),
(NESTED_SEGWIT, "Nested Segwit"),
(LEGACY_P2PKH, "Legacy"),
(TAPROOT, "Taproot"),
(CUSTOM_DERIVATION, "Custom Derivation"),
(NATIVE_SEGWIT, _mft("Native Segwit")),
(NESTED_SEGWIT, _mft("Nested Segwit")),
(LEGACY_P2PKH, _mft("Legacy")),
(TAPROOT, _mft("Taproot")),
(CUSTOM_DERIVATION, _mft("Custom Derivation")),
]
WORDLIST_LANGUAGE__ENGLISH = "en"
@@ -145,7 +174,8 @@ class SettingsConstants:
# Individual SettingsEntry attr_names
SETTING__LANGUAGE = "language"
# Note: attr_names are internal constants; do not wrap for translation
SETTING__LOCALE = "locale"
SETTING__WORDLIST_LANGUAGE = "wordlist_language"
SETTING__PERSISTENT_SETTINGS = "persistent_settings"
SETTING__COORDINATORS = "coordinators"
@@ -207,8 +237,10 @@ class SettingsConstants:
ELECTRUM_PBKDF2_ROUNDS=2048
# Label strings
LABEL__BIP39_PASSPHRASE = "BIP-39 Passphrase"
LABEL__CUSTOM_EXTENSION = "Custom Extension" # Terminology used by Electrum seeds
LABEL__BIP39_PASSPHRASE = _mft("BIP-39 Passphrase")
# TRANSLATOR_NOTE: Terminology used by Electrum seeds; equivalent to bip39 passphrase
custom_extension = _mft("Custom Extension")
LABEL__CUSTOM_EXTENSION = custom_extension
@@ -279,7 +311,7 @@ class SettingsEntry:
option_value = option
display_name = option
if option_value == value:
return display_name
return _mft(display_name)
def get_selection_option_value_by_display_name(self, display_name: str):
@@ -345,21 +377,19 @@ class SettingsDefinition:
settings_entries: List[SettingsEntry] = [
# General options
# TODO: Full babel multilanguage support! Until then, type == HIDDEN
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__LANGUAGE,
attr_name=SettingsConstants.SETTING__LOCALE,
abbreviated_name="lang",
display_name="Language",
display_name=_mft("Language"),
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__HIDDEN,
selection_options=SettingsConstants.ALL_LANGUAGES,
default_value=SettingsConstants.LANGUAGE__ENGLISH),
selection_options=SettingsConstants.ALL_LOCALES,
default_value=SettingsConstants.LOCALE__ENGLISH),
# TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__WORDLIST_LANGUAGE,
abbreviated_name="wordlist_lang",
display_name="Mnemonic language",
display_name=_mft("Mnemonic language"),
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__HIDDEN,
selection_options=SettingsConstants.ALL_WORDLIST_LANGUAGES,
@@ -368,14 +398,14 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__PERSISTENT_SETTINGS,
abbreviated_name="persistent",
display_name="Persistent settings",
display_name=_mft("Persistent settings"),
help_text=SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT,
default_value=SettingsConstants.OPTION__DISABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__WALLET,
attr_name=SettingsConstants.SETTING__COORDINATORS,
abbreviated_name="coords",
display_name="Coordinator software",
display_name=_mft("Coordinator software"),
type=SettingsConstants.TYPE__MULTISELECT,
selection_options=SettingsConstants.ALL_COORDINATORS,
default_value=[
@@ -388,7 +418,7 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__BTC_DENOMINATION,
abbreviated_name="denom",
display_name="Denomination display",
display_name=_mft("Denomination display"),
type=SettingsConstants.TYPE__SELECT_1,
selection_options=SettingsConstants.ALL_BTC_DENOMINATIONS,
default_value=SettingsConstants.BTC_DENOMINATION__THRESHOLD),
@@ -397,7 +427,7 @@ class SettingsDefinition:
# Advanced options
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__NETWORK,
display_name="Bitcoin network",
display_name=_mft("Bitcoin network"),
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_NETWORKS,
@@ -405,7 +435,7 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__QR_DENSITY,
display_name="QR code density",
display_name=_mft("QR code density"),
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_DENSITIES,
@@ -413,14 +443,14 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__XPUB_EXPORT,
display_name="Xpub export",
display_name=_mft("Xpub export"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__SIG_TYPES,
abbreviated_name="sigs",
display_name="Sig types",
display_name=_mft("Sig types"),
type=SettingsConstants.TYPE__MULTISELECT,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_SIG_TYPES,
@@ -429,7 +459,7 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__SCRIPT_TYPES,
abbreviated_name="scripts",
display_name="Script types",
display_name=_mft("Script types"),
type=SettingsConstants.TYPE__MULTISELECT,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_SCRIPT_TYPES,
@@ -437,13 +467,13 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__XPUB_DETAILS,
display_name="Show xpub details",
display_name=_mft("Show xpub details"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PASSPHRASE,
display_name="BIP-39 passphrase",
display_name=_mft("BIP-39 passphrase"),
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.OPTIONS__ENABLED_DISABLED_REQUIRED,
@@ -452,7 +482,7 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__CAMERA_ROTATION,
abbreviated_name="camera",
display_name="Camera rotation",
display_name=_mft("Camera rotation"),
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_CAMERA_ROTATIONS,
@@ -460,14 +490,14 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
display_name="CompactSeedQR",
display_name=_mft("Compact SeedQR"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__BIP85_CHILD_SEEDS,
abbreviated_name="bip85",
display_name="BIP-85 child seeds",
display_name=_mft("BIP-85 child seeds"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),
@@ -475,40 +505,40 @@ class SettingsDefinition:
attr_name=SettingsConstants.SETTING__ELECTRUM_SEEDS,
abbreviated_name="electrum",
display_name="Electrum seeds",
help_text="Native Segwit only",
help_text=_mft("Native Segwit only"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__MESSAGE_SIGNING,
display_name="Message signing",
display_name=_mft("Message signing"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__DISABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS,
abbreviated_name="priv_warn",
display_name="Show privacy warnings",
display_name=_mft("Show privacy warnings"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__DIRE_WARNINGS,
abbreviated_name="dire_warn",
display_name="Show dire warnings",
display_name=_mft("Show dire warnings"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS,
display_name="Show QR brightness tips",
display_name=_mft("Show QR brightness tips"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__PARTNER_LOGOS,
abbreviated_name="partners",
display_name="Show partner logos",
display_name=_mft("Show partner logos"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
default_value=SettingsConstants.OPTION__ENABLED),
@@ -524,7 +554,7 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS,
abbreviated_name="qr_brightness",
display_name="QR background color",
display_name=_mft("QR background color"),
type=SettingsConstants.TYPE__FREE_ENTRY,
visibility=SettingsConstants.VISIBILITY__HIDDEN,
default_value=62),
+1 -1
View File
@@ -21,7 +21,7 @@ class ConfigurableSingleton(Singleton):
if cls._instance:
return cls._instance
else:
raise Exception("Must call %s.configure_instance(config) first" % cls.__name__)
raise Exception("Must call %s.configure_instance() first" % cls.__name__)
@classmethod
+65 -48
View File
@@ -1,26 +1,23 @@
from embit.psbt import PSBT
from embit import script
from embit.networks import NETWORKS
from seedsigner.controller import Controller
from gettext import gettext as _
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
from seedsigner.models.encode_qr import UrPsbtQrEncoder
from seedsigner.models.psbt_parser import PSBTParser
from seedsigner.models.settings import SettingsConstants
from seedsigner.gui.screens.psbt_screens import PSBTOpReturnScreen, PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen)
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, ButtonOption, WarningScreen, DireWarningScreen, QRDisplayScreen)
from seedsigner.views.view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
class PSBTSelectSeedView(View):
SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD)
SCAN_SEED = ButtonOption("Scan a seed", SeedSignerIconConstants.QRCODE)
TYPE_12WORD = ButtonOption("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ButtonOption("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_ELECTRUM = ButtonOption("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD)
def run(self):
from seedsigner.controller import Controller
# Note: we can't just autoroute to the PSBT Overview because we might have a
# multisig where we want to sign with more than one key on this device.
if not self.controller.psbt:
@@ -38,9 +35,10 @@ class PSBTSelectSeedView(View):
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# Doesn't look like this seed can sign the current PSBT
button_str += " (?)"
# TRANSLATOR_NOTE: Inserts fingerprint w/"?" to indicate that this seed can't sign the current PSBT
button_str = _("{} (?)").format(button_str)
button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT))
button_data.append(ButtonOption(button_str, SeedSignerIconConstants.FINGERPRINT))
button_data.append(self.SCAN_SEED)
button_data.append(self.TYPE_12WORD)
@@ -50,7 +48,7 @@ class PSBTSelectSeedView(View):
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Select Signer",
title=_("Select Signer"),
is_button_text_centered=False,
button_data=button_data
)
@@ -94,7 +92,7 @@ class PSBTOverviewView(View):
# The PSBTParser takes a while to read the PSBT. Run the loading screen while
# we wait.
from seedsigner.gui.screens.screen import LoadingScreenThread
self.loading_screen = LoadingScreenThread(text="Parsing PSBT...")
self.loading_screen = LoadingScreenThread(text=_("Parsing PSBT..."))
self.loading_screen.start()
try:
@@ -109,6 +107,7 @@ class PSBTOverviewView(View):
def run(self):
from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen
psbt_parser = self.controller.psbt_parser
change_data = psbt_parser.change_data
@@ -168,9 +167,9 @@ class PSBTOverviewView(View):
class PSBTUnsupportedScriptTypeWarningView(View):
def run(self):
selected_menu_num = WarningScreen(
status_headline="Unsupported Script Type!",
text="PSBT has unsupported input script type, please verify your change addresses.",
button_data=["Continue"],
status_headline=_("Unsupported Script Type!"),
text=_("PSBT has unsupported input script type, please verify your change addresses."),
button_data=[ButtonOption("Continue")],
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
@@ -188,9 +187,10 @@ class PSBTUnsupportedScriptTypeWarningView(View):
class PSBTNoChangeWarningView(View):
def run(self):
selected_menu_num = WarningScreen(
status_headline="Full Spend!",
text="This PSBT spends its entire input value. No change is coming back to your wallet.",
button_data=["Continue"],
# TRANSLATOR_NOTE: User will receive no change back; the inputs to this transaction are fully spent
status_headline=_("Full Spend!"),
text=_("This PSBT spends its entire input value. No change is coming back to your wallet."),
button_data=[ButtonOption("Continue")],
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
@@ -214,6 +214,7 @@ class PSBTMathView(View):
+ change value
"""
def run(self):
from seedsigner.gui.screens.psbt_screens import PSBTMathScreen
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
@@ -250,20 +251,24 @@ class PSBTAddressDetailsView(View):
def run(self):
from seedsigner.gui.screens.psbt_screens import PSBTAddressDetailsScreen
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
raise Exception("Routing error")
title = "Will Send"
# TRANSLATOR_NOTE: Future-tense used to indicate that this transaction will send this amount, as opposed to "Send" on its own which could be misread as an instant command (e.g. "Send Now").
title = _("Will Send")
if psbt_parser.num_destinations > 1:
title += f" (#{self.address_num + 1})"
button_data = []
if self.address_num < psbt_parser.num_destinations - 1:
button_data = ["Next Recipient"]
button_data.append(ButtonOption("Next Recipient"))
else:
button_data = ["Next"]
# TRANSLATOR_NOTE: Short for "Next step"
button_data.append(ButtonOption("Next"))
selected_menu_num = self.run_screen(
PSBTAddressDetailsScreen,
@@ -294,10 +299,9 @@ class PSBTAddressDetailsView(View):
class PSBTChangeDetailsView(View):
NEXT = "Next"
SKIP_VERIFICATION = "Skip Verification"
VERIFY_MULTISIG = "Verify Multisig Change"
NEXT = ButtonOption("Next")
SKIP_VERIFICATION = ButtonOption("Skip Verification")
VERIFY_MULTISIG = ButtonOption("Verify Multisig Change")
def __init__(self, change_address_num):
super().__init__()
@@ -305,6 +309,7 @@ class PSBTChangeDetailsView(View):
def run(self):
from seedsigner.gui.screens.psbt_screens import PSBTChangeDetailsScreen
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
@@ -339,11 +344,11 @@ class PSBTChangeDetailsView(View):
derivation_path_addr_index = int(derivation_path.split("/")[-1])
if is_change_derivation_path:
title = "Your Change"
self.VERIFY_MULTISIG = "Verify Multisig Change"
# TRANSLATOR_NOTE: The amount you're receiving back from the transaction
title = _("Your Change")
else:
title = "Self-Transfer"
self.VERIFY_MULTISIG = "Verify Multisig Addr"
title = _("Self-Transfer")
self.VERIFY_MULTISIG.button_label = _("Verify Multisig Addr")
# if psbt_parser.num_change_outputs > 1:
# title += f" (#{self.change_address_num + 1})"
@@ -361,10 +366,13 @@ class PSBTChangeDetailsView(View):
else:
# Single sig
try:
from embit import script
from embit.networks import NETWORKS
if is_change_derivation_path:
loading_screen_text = "Verifying Change..."
loading_screen_text = _("Verifying Change...")
else:
loading_screen_text = "Verifying Self-Transfer..."
loading_screen_text = _("Verifying Self-Transfer...")
from seedsigner.gui.screens.screen import LoadingScreenThread
loading_screen = LoadingScreenThread(text=loading_screen_text)
loading_screen.start()
@@ -436,6 +444,7 @@ class PSBTChangeDetailsView(View):
return Destination(PSBTFinalizeView)
elif button_data[selected_menu_num] == self.VERIFY_MULTISIG:
from seedsigner.controller import Controller
from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView
self.controller.resume_main_flow = Controller.FLOW__PSBT
return Destination(LoadMultisigWalletDescriptorView)
@@ -451,17 +460,19 @@ class PSBTAddressVerificationFailedView(View):
def run(self):
if self.is_multisig:
title = "Caution"
text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be verified with your multisig wallet descriptor."""
title = _("Caution")
# TRANSLATOR_NOTE: Variable is either "change" or "self-transfer".
text = _("PSBT's {} address could not be verified with your multisig wallet descriptor.").format(_("change") if self.is_change else _("self-transfer"))
else:
title = "Suspicious PSBT"
text = f"""PSBT's {"change" if self.is_change else "self-transfer"} address could not be generated from your seed."""
title = _("Suspicious PSBT")
# TRANSLATOR_NOTE: Variable is either "change" or "self-transfer".
text = _("PSBT's {} address could not be generated from your seed.").format(_("change") if self.is_change else _("self-transfer"))
DireWarningScreen(
title=title,
status_headline="Address Verification Failed",
status_headline=_("Address Verification Failed"),
text=text,
button_data=["Discard PSBT"],
button_data=[ButtonOption("Discard PSBT")],
show_back_button=False,
).display()
@@ -476,14 +487,15 @@ class PSBTOpReturnView(View):
Shows the OP_RETURN data
"""
def run(self):
from seedsigner.gui.screens.psbt_screens import PSBTOpReturnScreen
psbt_parser: PSBTParser = self.controller.psbt_parser
if not psbt_parser:
# Should not be able to get here
raise Exception("Routing error")
title = "OP_RETURN"
button_data = ["Next"]
title = _("OP_RETURN")
button_data = [ButtonOption("Next")]
selected_menu_num = self.run_screen(
PSBTOpReturnScreen,
@@ -502,10 +514,13 @@ class PSBTOpReturnView(View):
class PSBTFinalizeView(View):
"""
"""
APPROVE_PSBT = "Approve PSBT"
APPROVE_PSBT = ButtonOption("Approve PSBT")
def run(self):
from embit.psbt import PSBT
from seedsigner.gui.screens.psbt_screens import PSBTFinalizeScreen
psbt_parser: PSBTParser = self.controller.psbt_parser
psbt: PSBT = self.controller.psbt
@@ -541,6 +556,8 @@ class PSBTFinalizeView(View):
class PSBTSignedQRDisplayView(View):
def run(self):
from seedsigner.models.encode_qr import UrPsbtQrEncoder
qr_encoder = UrPsbtQrEncoder(
psbt=self.controller.psbt,
qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY),
@@ -554,7 +571,7 @@ class PSBTSignedQRDisplayView(View):
class PSBTSigningErrorView(View):
SELECT_DIFF_SEED = "Select Diff Seed"
SELECT_DIFF_SEED = ButtonOption("Select Diff Seed")
def run(self):
psbt_parser: PSBTParser = self.controller.psbt_parser
@@ -565,10 +582,10 @@ class PSBTSigningErrorView(View):
# Just a WarningScreen here; only use DireWarningScreen for true security risks.
selected_menu_num = self.run_screen(
WarningScreen,
title="PSBT Error",
title=_("PSBT Error"),
status_icon_name=SeedSignerIconConstants.WARNING,
status_headline="Signing Failed",
text="Signing with this seed did not add a valid signature.",
status_headline=_("Signing Failed"),
text=_("Signing with this seed did not add a valid signature."),
button_data=[self.SELECT_DIFF_SEED]
)
+27 -24
View File
@@ -1,18 +1,15 @@
import logging
import re
from embit.descriptor import Descriptor
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.seed import Seed
from gettext import gettext as _
from seedsigner.helpers.l10n import mark_for_translation as _mft
from seedsigner.models.settings import SettingsConstants
from seedsigner.views.settings_views import SettingsIngestSettingsQRView
from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, OptionDisabledView, View, Destination
from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, View, Destination
logger = logging.getLogger(__name__)
class ScanView(View):
"""
The catch-all generic scanning View that will accept any of our supported QR
@@ -22,11 +19,13 @@ class ScanView(View):
dedicated errors when an unexpected QR type is scanned (e.g. Scan PSBT was
selected but a SeedQR was scanned).
"""
instructions_text = "Scan a QR code"
invalid_qr_type_message = "QRCode not recognized or not yet supported."
instructions_text = _mft("Scan a QR code")
invalid_qr_type_message = _mft("QRCode not recognized or not yet supported.")
def __init__(self):
from seedsigner.models.decode_qr import DecodeQR
super().__init__()
# Define the decoder here to make it available to child classes' is_valid_qr_type
# checks and so we can inject data into it in the test suite's `before_run()`.
@@ -60,10 +59,11 @@ class ScanView(View):
# current flow.
# Report QR types in more human-readable text (e.g. QRType
# `seed__compactseedqr` as "seed: compactseedqr").
# TODO: cleanup l10n presentation
return Destination(ErrorView, view_args=dict(
title="Error",
status_headline="Wrong QR Type",
text=self.invalid_qr_type_message + f""", received "{self.decoder.qr_type.replace("__", ": ").replace("_", " ")}\" format""",
status_headline=_("Wrong QR Type"),
text=_(self.invalid_qr_type_message) + f""", received "{self.decoder.qr_type.replace("__", ": ").replace("_", " ")}\" format""",
button_text="Back",
next_destination=Destination(BackStackView, skip_current_view=True),
))
@@ -73,10 +73,11 @@ class ScanView(View):
if not seed_mnemonic:
# seed is not valid, Exit if not valid with message
raise Exception("Not yet implemented!")
return Destination(NotYetImplementedView)
else:
# Found a valid mnemonic seed! All new seeds should be considered
# pending (might set a passphrase, SeedXOR, etc) until finalized.
from seedsigner.models.seed import Seed
from .seed_views import SeedFinalizeView
self.controller.storage.set_pending_seed(
Seed(mnemonic=seed_mnemonic, wordlist_language_code=self.wordlist_language_code)
@@ -95,10 +96,12 @@ class ScanView(View):
return Destination(PSBTSelectSeedView, skip_current_view=True)
elif self.decoder.is_settings:
from seedsigner.views.settings_views import SettingsIngestSettingsQRView
data = self.decoder.get_settings_data()
return Destination(SettingsIngestSettingsQRView, view_args=dict(data=data))
elif self.decoder.is_wallet_descriptor:
from embit.descriptor import Descriptor
from seedsigner.views.seed_views import MultisigWalletDescriptorView
descriptor_str = self.decoder.get_wallet_descriptor()
@@ -161,10 +164,10 @@ class ScanView(View):
# 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="Done",
title=_("Error"),
status_headline=_("Unknown QR Type"),
text=_("QRCode is invalid or is a data format not yet supported."),
button_text=_("Done"),
next_destination=Destination(MainMenuView, clear_history=True),
))
@@ -173,8 +176,8 @@ class ScanView(View):
class ScanPSBTView(ScanView):
instructions_text = "Scan PSBT"
invalid_qr_type_message = "Expected a PSBT"
instructions_text = _mft("Scan PSBT")
invalid_qr_type_message = _mft("Expected a PSBT")
@property
def is_valid_qr_type(self):
@@ -183,8 +186,8 @@ class ScanPSBTView(ScanView):
class ScanSeedQRView(ScanView):
instructions_text = "Scan SeedQR"
invalid_qr_type_message = f"Expected a SeedQR"
instructions_text = _mft("Scan SeedQR")
invalid_qr_type_message = _mft("Expected a SeedQR")
@property
def is_valid_qr_type(self):
@@ -193,8 +196,8 @@ class ScanSeedQRView(ScanView):
class ScanWalletDescriptorView(ScanView):
instructions_text = "Scan descriptor"
invalid_qr_type_message = "Expected a wallet descriptor QR"
instructions_text = _mft("Scan descriptor")
invalid_qr_type_message = _mft("Expected a wallet descriptor QR")
@property
def is_valid_qr_type(self):
@@ -203,8 +206,8 @@ class ScanWalletDescriptorView(ScanView):
class ScanAddressView(ScanView):
instructions_text = "Scan address QR"
invalid_qr_type_message = "Expected an address QR"
instructions_text = _mft("Scan address QR")
invalid_qr_type_message = _mft("Expected an address QR")
@property
def is_valid_qr_type(self):
+64 -17
View File
@@ -3,16 +3,19 @@ import os
import random
import time
from PIL import Image
from dataclasses import dataclass
from gettext import gettext as _
from seedsigner.gui.components import Fonts, GUIConstants, load_image
from seedsigner.gui.screens.screen import BaseScreen
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.views.view import View
logger = logging.getLogger(__name__)
# TODO: This early code is now outdated vis-a-vis Screen vs View distinctions
class LogoScreen(BaseScreen):
def __init__(self):
@@ -29,49 +32,90 @@ class LogoScreen(BaseScreen):
self.partner_logos[partner] = load_image(logo_url)
def _run(self):
pass
def get_random_partner(self) -> str:
return self.partners[random.randrange(len(self.partners))]
@dataclass
class OpeningSplashView(View):
is_screenshot_renderer: bool = False
force_partner_logos: bool|None = None
def run(self):
self.run_screen(
OpeningSplashScreen,
is_screenshot_renderer=self.is_screenshot_renderer,
force_partner_logos=self.force_partner_logos
)
class OpeningSplashScreen(LogoScreen):
def start(self):
def __init__(self, is_screenshot_renderer=False, force_partner_logos=None):
self.is_screenshot_renderer = is_screenshot_renderer
self.force_partner_logos = force_partner_logos
super().__init__()
def _render(self):
from PIL import Image
from seedsigner.controller import Controller
controller = Controller.get_instance()
# TODO: Fix for the screenshot generator. When generating screenshots for
# multiple locales, there is a button still in the canvas from the previous
# screenshot, even though the Renderer has been reconfigured and re-
# instantiated. This is a hack to clear the screen for now.
self.clear_screen()
show_partner_logos = Settings.get_instance().get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
if self.force_partner_logos is not None:
show_partner_logos = self.force_partner_logos
if show_partner_logos:
logo_offset_y = -56
else:
logo_offset_y = 0
# Fade in alpha
for i in range(250, -1, -25):
self.logo.putalpha(255 - i)
background = Image.new("RGBA", size=self.logo.size, color="black")
self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (0, logo_offset_y))
self.renderer.show_image()
background = Image.new("RGBA", size=self.logo.size, color="black")
if not self.is_screenshot_renderer:
# Fade in alpha
for i in range(250, -1, -25):
self.logo.putalpha(255 - i)
self.renderer.canvas.paste(Image.alpha_composite(background, self.logo), (0, logo_offset_y))
self.renderer.show_image()
else:
# Skip animation for the screenshot generator
self.renderer.canvas.paste(self.logo, (0, logo_offset_y))
# Display version num below SeedSigner logo
font = Fonts.get_font(GUIConstants.BODY_FONT_NAME, GUIConstants.TOP_NAV_TITLE_FONT_SIZE)
font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size())
version = f"v{controller.VERSION}"
(left, top, version_tw, version_th) = font.getbbox(version, anchor="lt")
# The logo png is 240x240, but the actual logo is 70px tall, vertically centered
logo_height = 70
version_x = int(self.renderer.canvas_width/2)
version_y = int(self.canvas_height/2) + 35 + logo_offset_y + GUIConstants.COMPONENT_PADDING
version_y = int(self.canvas_height/2) + int(logo_height/2) + logo_offset_y + GUIConstants.COMPONENT_PADDING
self.renderer.draw.text(xy=(version_x, version_y), text=version, font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt")
self.renderer.show_image()
if not self.is_screenshot_renderer:
self.renderer.show_image()
if show_partner_logos:
# Hold on the version num for a moment
time.sleep(1)
if not self.is_screenshot_renderer:
# Hold on the version num for a moment
time.sleep(1)
# Set up the partner logo
partner_logo: Image.Image = self.partner_logos[self.get_random_partner()]
font = Fonts.get_font(GUIConstants.TOP_NAV_TITLE_FONT_NAME, GUIConstants.BODY_FONT_SIZE)
sponsor_text = "With support from:"
font = Fonts.get_font(GUIConstants.get_top_nav_title_font_name(), GUIConstants.get_body_font_size())
# TRANSLATOR_NOTE: This is on the opening splash screen, displayed above the HRF logo
sponsor_text = _("With support from:")
(left, top, tw, th) = font.getbbox(sponsor_text, anchor="lt")
x = int((self.renderer.canvas_width) / 2)
@@ -87,12 +131,15 @@ class OpeningSplashScreen(LogoScreen):
self.renderer.show_image()
time.sleep(2)
if not self.is_screenshot_renderer:
# Hold on the splash screen for a moment
time.sleep(2)
class ScreensaverScreen(LogoScreen):
def __init__(self, buttons):
from PIL import Image
super().__init__()
self.buttons = buttons
File diff suppressed because it is too large Load Diff
+17 -15
View File
@@ -1,19 +1,20 @@
import logging
from gettext import gettext as _
from seedsigner.gui.components import SeedSignerIconConstants
from seedsigner.hardware.microsd import MicroSD
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
from seedsigner.gui.screens.screen import ButtonOption
from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition
from .view import View, Destination, MainMenuView
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition
logger = logging.getLogger(__name__)
class SettingsMenuView(View):
IO_TEST = "I/O test"
DONATE = "Donate"
IO_TEST = ButtonOption("I/O test")
DONATE = ButtonOption("Donate")
def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0):
super().__init__()
@@ -28,7 +29,7 @@ class SettingsMenuView(View):
settings_entries = SettingsDefinition.get_settings_entries(
visibility=self.visibility
)
button_data=[e.display_name for e in settings_entries]
button_data=[ButtonOption(e.display_name) for e in settings_entries]
selected_button = 0
if self.selected_attr:
@@ -38,17 +39,17 @@ class SettingsMenuView(View):
break
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
title = "Settings"
title = _("Settings")
# Set up the next nested level of menuing
button_data.append(("Advanced", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT))
button_data.append(ButtonOption("Advanced", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT))
next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
button_data.append(self.IO_TEST)
button_data.append(self.DONATE)
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
title = "Advanced"
title = _("Advanced")
# So far there are no real Developer options; disabling for now
# button_data.append(("Developer Options", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT))
@@ -56,7 +57,7 @@ class SettingsMenuView(View):
next_destination = None
elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER:
title = "Dev Options"
title = _("Dev Options")
next_destination = None
selected_menu_num = self.run_screen(
@@ -114,8 +115,7 @@ class SettingsEntryUpdateSelectionView(View):
value, display_name = value
else:
display_name = value
button_data.append(display_name)
button_data.append(ButtonOption(display_name))
if (type(initial_value) == list and value in initial_value) or value == initial_value:
checked_buttons.append(i)
@@ -191,6 +191,7 @@ class SettingsEntryUpdateSelectionView(View):
class SettingsIngestSettingsQRView(View):
def __init__(self, data: str):
from seedsigner.hardware.microsd import MicroSD
super().__init__()
# May raise an Exception which will bubble up to the Controller to display to the
@@ -200,15 +201,16 @@ class SettingsIngestSettingsQRView(View):
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."
self.status_message = _("Persistent Settings enabled. Settings saved to SD card.")
else:
self.status_message = "Settings updated in temporary memory"
self.status_message = _("Settings updated in temporary memory")
def run(self):
from seedsigner.gui.screens.settings_screens import SettingsQRConfirmationScreen
self.run_screen(
SettingsQRConfirmationScreen,
title=_("Settings QR"),
config_name=self.config_name,
status_message=self.status_message,
)
+108 -83
View File
@@ -1,21 +1,14 @@
from dataclasses import dataclass
import hashlib
import logging
import os
import time
from embit.descriptor import Descriptor
from PIL import Image
from PIL.ImageOps import autocontrast
from gettext import gettext as _
from seedsigner.controller import Controller
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen)
from seedsigner.gui.screens.tools_screens import (ToolsCalcFinalWordDoneScreen, ToolsCalcFinalWordFinalizePromptScreen,
ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen,
ToolsImageEntropyLivePreviewScreen, ToolsAddressExplorerAddressTypeScreen)
from seedsigner.helpers import embit_utils, mnemonic_generation
from seedsigner.models.encode_qr import GenericStaticQrEncoder
from seedsigner.gui.screens import RET_CODE__BACK_BUTTON, ButtonListScreen
from seedsigner.gui.screens.screen import ButtonOption
from seedsigner.helpers import mnemonic_generation
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView, SeedExportXpubScriptTypeView
@@ -25,19 +18,20 @@ from .view import View, Destination, BackStackView
logger = logging.getLogger(__name__)
class ToolsMenuView(View):
IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA)
DICE = ("New seed", FontAwesomeIconConstants.DICE)
KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
ADDRESS_EXPLORER = "Address Explorer"
VERIFY_ADDRESS = "Verify address"
IMAGE = ButtonOption("New seed", FontAwesomeIconConstants.CAMERA)
DICE = ButtonOption("New seed", FontAwesomeIconConstants.DICE)
KEYBOARD = ButtonOption("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
ADDRESS_EXPLORER = ButtonOption("Address Explorer")
VERIFY_ADDRESS = ButtonOption("Verify Address")
def run(self):
button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.ADDRESS_EXPLORER, self.VERIFY_ADDRESS]
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Tools",
title=_("Tools"),
is_button_text_centered=False,
button_data=button_data
)
@@ -68,6 +62,7 @@ class ToolsMenuView(View):
****************************************************************************"""
class ToolsImageEntropyLivePreviewView(View):
def run(self):
from seedsigner.gui.screens.tools_screens import ToolsImageEntropyLivePreviewScreen
self.controller.image_entropy_preview_frames = None
ret = ToolsImageEntropyLivePreviewScreen().display()
@@ -81,6 +76,9 @@ class ToolsImageEntropyLivePreviewView(View):
class ToolsImageEntropyFinalImageView(View):
def run(self):
from PIL import Image
from PIL.ImageOps import autocontrast
from seedsigner.gui.screens.tools_screens import ToolsImageEntropyFinalImageScreen
if not self.controller.image_entropy_final_image:
from seedsigner.hardware.camera import Camera
# Take the final full-res image
@@ -99,7 +97,7 @@ class ToolsImageEntropyFinalImageView(View):
).crop(
(120, 0, 600, 480)
).resize(
(self.canvas_width, self.canvas_height), Image.BICUBIC
(self.canvas_width, self.canvas_height), Image.Resampling.BICUBIC
)
ret = ToolsImageEntropyFinalImageScreen(
@@ -116,23 +114,21 @@ class ToolsImageEntropyFinalImageView(View):
class ToolsImageEntropyMnemonicLengthView(View):
TWELVE_WORDS = ButtonOption("12 words", return_data=12)
TWENTYFOUR_WORDS = ButtonOption("24 words", return_data=24)
def run(self):
TWELVE_WORDS = "12 words"
TWENTYFOUR_WORDS = "24 words"
button_data = [TWELVE_WORDS, TWENTYFOUR_WORDS]
button_data = [self.TWELVE_WORDS, self.TWENTYFOUR_WORDS]
selected_menu_num = ButtonListScreen(
title="Mnemonic Length?",
title=_("Mnemonic Length?"),
button_data=button_data,
).display()
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
if button_data[selected_menu_num] == TWELVE_WORDS:
mnemonic_length = 12
else:
mnemonic_length = 24
mnemonic_length = button_data[selected_menu_num].return_data
preview_images = self.controller.image_entropy_preview_frames
seed_entropy_image = self.controller.image_entropy_final_image
@@ -189,12 +185,20 @@ class ToolsImageEntropyMnemonicLengthView(View):
****************************************************************************"""
class ToolsDiceEntropyMnemonicLengthView(View):
def run(self):
TWELVE = f"12 words ({mnemonic_generation.DICE__NUM_ROLLS__12WORD} rolls)"
TWENTY_FOUR = f"24 words ({mnemonic_generation.DICE__NUM_ROLLS__24WORD} rolls)"
# Since we're dynamically building the ButtonOption button_labels here, it's too
# awkward to use the usual class-level attr approach.
# TRANSLATOR_NOTE: Inserts the number of dice rolls needed for a 12-word mnemonic
twelve = _("12 words ({} rolls)").format(mnemonic_generation.DICE__NUM_ROLLS__12WORD)
TWELVE = ButtonOption(twelve, return_data=mnemonic_generation.DICE__NUM_ROLLS__12WORD)
# TRANSLATOR_NOTE: Inserts the number of dice rolls needed for a 24-word mnemonic
twenty_four = _("24 words ({} rolls)").format(mnemonic_generation.DICE__NUM_ROLLS__24WORD)
TWENTY_FOUR = ButtonOption(twenty_four, return_data=mnemonic_generation.DICE__NUM_ROLLS__24WORD)
button_data = [TWELVE, TWENTY_FOUR]
selected_menu_num = ButtonListScreen(
title="Mnemonic Length",
title=_("Mnemonic Length"),
is_bottom_list=True,
is_button_text_centered=True,
button_data=button_data,
@@ -218,6 +222,7 @@ class ToolsDiceEntropyEntryView(View):
def run(self):
from seedsigner.gui.screens.tools_screens import ToolsDiceEntropyEntryScreen
ret = ToolsDiceEntropyEntryScreen(
return_after_n_chars=self.total_rolls,
).display()
@@ -240,15 +245,15 @@ class ToolsDiceEntropyEntryView(View):
Calc final word Views
****************************************************************************"""
class ToolsCalcFinalWordNumWordsView(View):
TWELVE = "12 words"
TWENTY_FOUR = "24 words"
TWELVE = ButtonOption("12 words", return_data=12)
TWENTY_FOUR = ButtonOption("24 words", return_data=24)
def run(self):
button_data = [self.TWELVE, self.TWENTY_FOUR]
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Mnemonic Length",
title=_("Mnemonic Length"),
is_bottom_list=True,
is_button_text_centered=True,
button_data=button_data,
@@ -257,22 +262,24 @@ class ToolsCalcFinalWordNumWordsView(View):
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == self.TWELVE:
self.controller.storage.init_pending_mnemonic(12)
self.controller.storage.init_pending_mnemonic(button_data[selected_menu_num].return_data)
# return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
elif button_data[selected_menu_num] == self.TWENTY_FOUR:
self.controller.storage.init_pending_mnemonic(24)
# return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
class ToolsCalcFinalWordFinalizePromptView(View):
# TRANSLATOR_NOTE: Label to gather entropy through coin tosses
COIN_FLIPS = ButtonOption("Coin flip entropy")
# TRANSLATOR_NOTE: Label to gather entropy through user specified BIP-39 word
SELECT_WORD = ButtonOption("Word selection entropy")
# TRANSLATOR_NOTE: Label to allow user to default entropy as all-zeros
ZEROS = ButtonOption("Finalize with zeros")
def run(self):
from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordFinalizePromptScreen
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_length = len(mnemonic)
if mnemonic_length == 12:
@@ -280,11 +287,7 @@ class ToolsCalcFinalWordFinalizePromptView(View):
else:
num_entropy_bits = 3
COIN_FLIPS = "Coin flip entropy"
SELECT_WORD = f"Word selection entropy"
ZEROS = "Finalize with zeros"
button_data = [COIN_FLIPS, SELECT_WORD, ZEROS]
button_data = [self.COIN_FLIPS, self.SELECT_WORD, self.ZEROS]
selected_menu_num = ToolsCalcFinalWordFinalizePromptScreen(
mnemonic_length=mnemonic_length,
num_entropy_bits=num_entropy_bits,
@@ -294,15 +297,15 @@ class ToolsCalcFinalWordFinalizePromptView(View):
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == COIN_FLIPS:
elif button_data[selected_menu_num] == self.COIN_FLIPS:
return Destination(ToolsCalcFinalWordCoinFlipsView)
elif button_data[selected_menu_num] == SELECT_WORD:
elif button_data[selected_menu_num] == self.SELECT_WORD:
# Clear the final word slot, just in case we're returning via BACK button
self.controller.storage.update_pending_mnemonic(None, mnemonic_length - 1)
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True, cur_word_index=mnemonic_length - 1))
elif button_data[selected_menu_num] == ZEROS:
elif button_data[selected_menu_num] == self.ZEROS:
# User skipped the option to select a final word to provide last bits of
# entropy. We'll insert all zeros and piggy-back on the coin flip attr
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
@@ -313,6 +316,7 @@ class ToolsCalcFinalWordFinalizePromptView(View):
class ToolsCalcFinalWordCoinFlipsView(View):
def run(self):
from seedsigner.gui.screens.tools_screens import ToolsCoinFlipEntryScreen
mnemonic_length = len(self.controller.storage.pending_mnemonic)
if mnemonic_length == 12:
@@ -333,6 +337,8 @@ class ToolsCalcFinalWordCoinFlipsView(View):
class ToolsCalcFinalWordShowFinalWordView(View):
NEXT = ButtonOption("Next")
def __init__(self, coin_flips: str = None):
super().__init__()
# Construct the actual final word. The user's selected_final_word
@@ -385,11 +391,15 @@ class ToolsCalcFinalWordShowFinalWordView(View):
def run(self):
NEXT = "Next"
button_data = [NEXT]
from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordScreen
button_data = [self.NEXT]
# TRANSLATOR_NOTE: label to calculate the last word of a BIP-39 mnemonic seed phrase
title = _("Final Word Calc")
selected_menu_num = self.run_screen(
ToolsCalcFinalWordScreen,
title="Final Word Calc",
title=title,
button_data=button_data,
selected_final_word=self.selected_final_word,
selected_final_bits=self.selected_final_bits,
@@ -400,20 +410,22 @@ class ToolsCalcFinalWordShowFinalWordView(View):
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
elif button_data[selected_menu_num] == NEXT:
elif button_data[selected_menu_num] == self.NEXT:
return Destination(ToolsCalcFinalWordDoneView)
class ToolsCalcFinalWordDoneView(View):
LOAD = ButtonOption("Load seed")
DISCARD = ButtonOption("Discard", button_label_color="red")
def run(self):
from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordDoneScreen
mnemonic = self.controller.storage.pending_mnemonic
mnemonic_word_length = len(mnemonic)
final_word = mnemonic[-1]
LOAD = "Load seed"
DISCARD = ("Discard", None, None, "red")
button_data = [LOAD, DISCARD]
button_data = [self.LOAD, self.DISCARD]
selected_menu_num = ToolsCalcFinalWordDoneScreen(
final_word=final_word,
@@ -427,37 +439,39 @@ class ToolsCalcFinalWordDoneView(View):
self.controller.storage.convert_pending_mnemonic_to_pending_seed()
if button_data[selected_menu_num] == LOAD:
if button_data[selected_menu_num] == self.LOAD:
return Destination(SeedFinalizeView)
elif button_data[selected_menu_num] == DISCARD:
elif button_data[selected_menu_num] == self.DISCARD:
return Destination(SeedDiscardView)
"""****************************************************************************
Address Explorer Views
****************************************************************************"""
class ToolsAddressExplorerSelectSourceView(View):
SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
SCAN_DESCRIPTOR = ("Scan wallet descriptor", SeedSignerIconConstants.QRCODE)
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
TYPE_ELECTRUM = ("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD)
SCAN_SEED = ButtonOption("Scan a seed", SeedSignerIconConstants.QRCODE)
SCAN_DESCRIPTOR = ButtonOption("Scan wallet descriptor", SeedSignerIconConstants.QRCODE)
TYPE_12WORD = ButtonOption("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD, return_data=12)
TYPE_24WORD = ButtonOption("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD, return_data=24)
TYPE_ELECTRUM = ButtonOption("Enter Electrum seed", FontAwesomeIconConstants.KEYBOARD)
def run(self):
from seedsigner.controller import Controller
seeds = self.controller.storage.seeds
button_data = []
for seed in seeds:
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT))
button_data.append(ButtonOption(button_str, SeedSignerIconConstants.FINGERPRINT))
button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD]
if self.settings.get_value(SettingsConstants.SETTING__ELECTRUM_SEEDS) == SettingsConstants.OPTION__ENABLED:
button_data.append(self.TYPE_ELECTRUM)
selected_menu_num = self.run_screen(
ButtonListScreen,
title="Address Explorer",
title=_("Address Explorer"),
button_data=button_data,
is_button_text_centered=False,
is_bottom_list=True,
@@ -491,10 +505,7 @@ class ToolsAddressExplorerSelectSourceView(View):
elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]:
from seedsigner.views.seed_views import SeedMnemonicEntryView
if button_data[selected_menu_num] == self.TYPE_12WORD:
self.controller.storage.init_pending_mnemonic(num_words=12)
else:
self.controller.storage.init_pending_mnemonic(num_words=24)
self.controller.storage.init_pending_mnemonic(num_words=button_data[selected_menu_num].return_data)
return Destination(SeedMnemonicEntryView)
elif button_data[selected_menu_num] == self.TYPE_ELECTRUM:
@@ -504,8 +515,11 @@ class ToolsAddressExplorerSelectSourceView(View):
class ToolsAddressExplorerAddressTypeView(View):
RECEIVE = "Receive Addresses"
CHANGE = "Change Addresses"
# TRANSLATOR_NOTE: label for addresses where others send us incoming payments
RECEIVE = ButtonOption("Receive Addresses")
# TRANSLATOR_NOTE: label for addresses that collect the change from our own outgoing payments
CHANGE = ButtonOption("Change Addresses")
def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None):
@@ -541,6 +555,7 @@ class ToolsAddressExplorerAddressTypeView(View):
elif seed_derivation_override:
derivation_path = seed_derivation_override
else:
from seedsigner.helpers import embit_utils
derivation_path = embit_utils.get_standard_derivation_path(
network=self.settings.get_value(SettingsConstants.SETTING__NETWORK),
wallet_type=SettingsConstants.SINGLE_SIG,
@@ -557,11 +572,13 @@ class ToolsAddressExplorerAddressTypeView(View):
def run(self):
from seedsigner.gui.screens.tools_screens import ToolsAddressExplorerAddressTypeScreen
data = self.controller.address_explorer_data
wallet_descriptor_display_name = None
if "wallet_descriptor" in data:
wallet_descriptor_display_name = data["wallet_descriptor"].brief_policy.replace(" (sorted)", "")
wallet_descriptor_display_name = " / ".join(wallet_descriptor_display_name.split(" of ")) # i18n w/o l10n since coming from non-l10n embit
script_type = data["script_type"] if "script_type" in data else None
@@ -620,7 +637,9 @@ class ToolsAddressExplorerAddressListView(View):
else:
try:
from seedsigner.gui.screens.screen import LoadingScreenThread
self.loading_screen = LoadingScreenThread(text="Calculating addrs...")
from seedsigner.helpers import embit_utils
# TRANSLATOR_NOTE: a status message that our payment addresses are being calculated
self.loading_screen = LoadingScreenThread(text=_("Calculating addrs..."))
self.loading_screen.start()
if addr_storage_key not in data:
@@ -636,9 +655,10 @@ class ToolsAddressExplorerAddressListView(View):
data[addr_storage_key].append(address)
else:
# TODO: Custom derivation path
raise Exception("Custom Derivation address explorer not yet implemented")
raise Exception(_("Custom Derivation address explorer not yet implemented"))
elif "wallet_descriptor" in data:
from embit.descriptor import Descriptor
descriptor: Descriptor = data["wallet_descriptor"]
if descriptor.is_basic_multisig:
for i in range(self.start_index, self.start_index + addrs_per_screen):
@@ -647,7 +667,7 @@ class ToolsAddressExplorerAddressListView(View):
data[addr_storage_key].append(address)
else:
raise Exception("Single sig descriptors not yet supported")
raise Exception(_("Single sig descriptors not yet supported"))
finally:
# Everything is set. Stop the loading screen
self.loading_screen.stop()
@@ -663,16 +683,18 @@ class ToolsAddressExplorerAddressListView(View):
end_digits = -5
else:
end_digits = -4
button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}")
button_data.append(ButtonOption(f"{cur_index}:{address[:8]}...{address[end_digits:]}", active_button_label=f"{cur_index}:{address}"))
button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT))
# TRANSLATOR_NOTE: Insert the number of addrs displayed per screen (e.g. "Next 10")
button_label = _("Next {}").format(addrs_per_screen)
button_data.append(ButtonOption(button_label, right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT))
selected_menu_num = self.run_screen(
ButtonListScreen,
title="{} Addrs".format("Receive" if not self.is_change else "Change"),
title=_("Receive Addrs") if not self.is_change else _("Change Addrs"),
button_data=button_data,
button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
button_font_size=GUIConstants.BUTTON_FONT_SIZE + 4,
button_font_size=GUIConstants.get_button_font_size() + 4,
is_button_text_centered=False,
is_bottom_list=True,
selected_button=self.selected_button_index,
@@ -695,6 +717,7 @@ class ToolsAddressExplorerAddressListView(View):
class ToolsAddressExplorerAddressView(View):
# TODO: pull address str from controller.address_explorer_data and pass addr_storage_key and addr_index instead
def __init__(self, index: int, address: str, is_change: bool, start_index: int, parent_initial_scroll: int = 0):
super().__init__()
self.index = index
@@ -706,6 +729,8 @@ class ToolsAddressExplorerAddressView(View):
def run(self):
from seedsigner.gui.screens.screen import QRDisplayScreen
from seedsigner.models.encode_qr import GenericStaticQrEncoder
qr_encoder = GenericStaticQrEncoder(data=self.address)
self.run_screen(
QRDisplayScreen,
+46 -78
View File
@@ -1,9 +1,11 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
from gettext import gettext as _
from typing import Type
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
from seedsigner.helpers.l10n import mark_for_translation as _mft
from seedsigner.gui.components import SeedSignerIconConstants
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.gui.screens.screen import BaseScreen, ButtonOption, 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
@@ -17,6 +19,7 @@ class BackStackView:
pass
"""
Views contain the biz logic to handle discrete tasks, exactly analogous to a Flask
request/response function or a Django View. Each page/screen displayed to the user
@@ -181,18 +184,17 @@ class Destination:
#
#########################################################################################
class MainMenuView(View):
SCAN = ("Scan", SeedSignerIconConstants.SCAN)
SEEDS = ("Seeds", SeedSignerIconConstants.SEEDS)
TOOLS = ("Tools", SeedSignerIconConstants.TOOLS)
SETTINGS = ("Settings", SeedSignerIconConstants.SETTINGS)
SCAN = ButtonOption("Scan", SeedSignerIconConstants.SCAN)
SEEDS = ButtonOption("Seeds", SeedSignerIconConstants.SEEDS)
TOOLS = ButtonOption("Tools", SeedSignerIconConstants.TOOLS)
SETTINGS = ButtonOption("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(
MainMenuScreen,
title="Home",
title=_("Home"),
button_data=button_data,
)
@@ -218,14 +220,14 @@ class MainMenuView(View):
class PowerOptionsView(View):
RESET = ("Restart", SeedSignerIconConstants.RESTART)
POWER_OFF = ("Power Off", SeedSignerIconConstants.POWER)
RESET = ButtonOption("Restart", SeedSignerIconConstants.RESTART)
POWER_OFF = ButtonOption("Power Off", SeedSignerIconConstants.POWER)
def run(self):
button_data = [self.RESET, self.POWER_OFF]
selected_menu_num = self.run_screen(
LargeButtonScreen,
title="Reset / Power",
title=_("Reset / Power"),
show_back_button=True,
button_data=button_data
)
@@ -268,38 +270,26 @@ class RestartView(View):
class PowerOffView(View):
def run(self):
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
self.run_screen(PowerOffNotRequiredScreen)
return Destination(BackStackView)
else:
thread = PowerOffView.PowerOffThread()
thread.start()
self.run_screen(PowerOffScreen)
class PowerOffThread(BaseThread):
def run(self):
import time
from subprocess import call
while self.keep_running:
time.sleep(5)
call("sudo shutdown --poweroff now", shell=True)
self.run_screen(PowerOffNotRequiredScreen)
return Destination(BackStackView)
@dataclass
class NotYetImplementedView(View):
text: str = "This is still on our to-do list!"
"""
Temporary View to use during dev.
"""
text: str = _mft("This is still on our to-do list!")
def run(self):
self.run_screen(
WarningScreen,
title="Work In Progress",
status_headline="Not Yet Implemented",
title=_("Work In Progress"),
status_headline=_("Not Yet Implemented"),
text=self.text,
button_data=["Back to Main Menu"],
button_data=[ButtonOption("Back to Main Menu")],
)
return Destination(MainMenuView)
@@ -308,13 +298,12 @@ class NotYetImplementedView(View):
@dataclass
class ErrorView(View):
title: str = "Error"
title: str = _mft("Error")
show_back_button: bool = True
status_headline: str = None
text: str = None
button_text: str = None
next_destination: Destination = field(default_factory=lambda: Destination(MainMenuView, clear_history=True))
next_destination: Destination = None
def run(self):
self.run_screen(
@@ -322,30 +311,30 @@ class ErrorView(View):
title=self.title,
status_headline=self.status_headline,
text=self.text,
button_data=[self.button_text],
button_data=[ButtonOption(self.button_text)],
show_back_button=self.show_back_button,
)
return self.next_destination
return self.next_destination if self.next_destination else Destination(MainMenuView, clear_history=True)
@dataclass
class NetworkMismatchErrorView(ErrorView):
title: str = "Network Mismatch"
show_back_button: bool = False
button_text: str = "Change Setting"
next_destination: Destination = None
derivation_path: str = None
def __post_init__(self):
from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView
self.title: str = _("Network Mismatch")
self.show_back_button: bool = False
self.button_text: str = _("Change Setting")
self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True)
super().__post_init__()
if not self.text:
self.text = f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match current action."
if not self.next_destination:
from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView
self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True)
# TRANSLATOR_NOTE: Inserts mainnet/testnet/regtest and derivation path
self.text = _("Current network setting ({}) doesn't match {}.").format(
self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK),
self.derivation_path,
)
@@ -357,11 +346,9 @@ class UnhandledExceptionView(View):
def run(self):
self.run_screen(
DireWarningScreen,
title="System Error",
title=_("System Error"),
status_headline=self.error[0],
text=self.error[1] + "\n" + self.error[2],
button_data=["OK"],
show_back_button=False,
allow_text_overflow=True, # Fit what we can, let the rest go off the edges
)
@@ -371,21 +358,25 @@ class UnhandledExceptionView(View):
@dataclass
class OptionDisabledView(View):
UPDATE_SETTING = "Update Setting"
DONE = "Done"
UPDATE_SETTING = ButtonOption("Update Setting")
DONE = ButtonOption("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."
# TRANSLATOR_NOTE: Inserts the name of a settings option (e.g. "Persistent Settings" is currently...)
self.error_msg = _("\"{}\" is currently disabled in Settings.").format(
_(self.settings_entry.display_name),
)
def run(self):
button_data = [self.UPDATE_SETTING, self.DONE]
selected_menu_num = self.run_screen(
WarningScreen,
title="Option Disabled",
title=_("Option Disabled"),
status_headline=None,
text=self.error_msg,
button_data=button_data,
@@ -398,26 +389,3 @@ class OptionDisabledView(View):
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)
+27 -6
View File
@@ -2,6 +2,7 @@
The tests are designed to be run on non-Raspi hardware.
## Setup
On your testing machine you'll have to install:
```bash
# general dependencies
@@ -16,6 +17,14 @@ Then make the `seedsigner` python module visible/importable to the tests by inst
pip3 install -e .
```
## Running all tests, calculating overall test coverage
tldr: just run the convenience script from the project root:
```bash
./tests/run_full_coverage.sh
```
## Running tests manually
Run the whole test suite:
```
pytest
@@ -45,18 +54,30 @@ Annoying complications:
* Better idea: use a proper logger in the test file and use one of the above options to display logs
### Test Coverage
## Screenshot generator
The screenshot generator is meant to mostly be a utility and not really part of the test suite. However,
it is actually implemented to be run by `pytest`.
see: [Screenshot generator README](screenshot_generator/README.md)
## Generate coverage manually
Run tests and generate test coverage
```
```bash
coverage run -m pytest
```
Show the resulting test coverage details:
The screenshots can generate their own separate coverage report:
```bash
coverage run -m pytest tests/screenshot_generator/generator.py --locale es
```
Show the resulting test coverage details:
```bash
coverage report
```
Generate the html overview:
```
Generate the interactive html report:
```bash
coverage html
```
```
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Clean up any prior coverage results
coverage erase
# Run the full test suite; `--parallel` writes coverage results to a separate file
# (otherwise it'll be overwritten in the next step)
coverage run --parallel -m pytest
# Generate screenshots (only need to run for one locale to assess coverage)
coverage run --parallel -m pytest tests/screenshot_generator/generator.py --locale es
# Combine the above coverage results
coverage combine
# Show the report in the terminal
coverage report
# Generate the interactive html report
coverage html
+14 -1
View File
@@ -1,8 +1,21 @@
# Screenshot Generator
From the project root, run:
```
```bash
# Generate screenshots for a specific locale
pytest tests/screenshot_generator/generator.py --locale es
# Generate screenshots for all supported locales
pytest tests/screenshot_generator/generator.py
```
You can also run a `coverage` report to see exactly what the screenshots are and are not hitting:
```bash
coverage erase
coverage run -m pytest tests/screenshot_generator/generator.py --locale es && coverage combine && coverage report
# Generate the interactive html report
coverage html
```
Writes the screenshots to a dir in the project root: `seedsigner-screenshots`.
+377 -277
View File
@@ -1,47 +1,112 @@
import embit
import pathlib
import pytest
import os
import random
import sys
import time
from unittest.mock import Mock, patch, MagicMock
from seedsigner.helpers import embit_utils
from embit import compact
from embit.psbt import PSBT, OutputScope
from embit.script import Script
from seedsigner.helpers import embit_utils
from seedsigner.models.psbt_parser import OPCODES, PSBTParser
# 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.views.screensaver.ScreensaverScreen'] = MagicMock()
sys.modules['RPi'] = MagicMock()
sys.modules['RPi.GPIO'] = MagicMock()
sys.modules['seedsigner.hardware.camera'] = MagicMock()
sys.modules['seedsigner.hardware.microsd'] = MagicMock()
# Force the screenshots to mimic Pi Zero's output without libraqm
patch('PIL.ImageFont.core.HAVE_RAQM', False).start()
from seedsigner.controller import Controller
from seedsigner.gui.renderer import Renderer
from seedsigner.gui.screens.seed_screens import SeedAddPassphraseScreen
from seedsigner.gui.toast import BaseToastOverlayManagerThread, RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread
from seedsigner.hardware.microsd import MicroSD
from seedsigner.helpers import embit_utils
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.psbt_parser import OPCODES, PSBTParser
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings import Settings
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.screensaver import OpeningSplashView
from seedsigner.views.view import ErrorView, NetworkMismatchErrorView, OptionDisabledView, PowerOffView, View
from .utils import ScreenshotComplete, ScreenshotRenderer
from .utils import ScreenshotComplete, ScreenshotConfig, ScreenshotRenderer
import warnings; warnings.warn = lambda *args, **kwargs: None
def test_generate_screenshots(target_locale):
# Dynamically generate a pytest test run for each locale
@pytest.mark.parametrize("locale", [x for x, y in SettingsConstants.ALL_LOCALES])
def test_generate_all(locale, target_locale):
"""
`target_locale` is a fixture created in conftest.py via the `--locale` command line arg.
Optionally skips all other locales.
"""
if target_locale and locale != target_locale:
pytest.skip(f"Skipping {locale}")
generate_screenshots(locale)
"""**************************************************************************************
Set up global test data that will be re-used across a variety of screenshots and for
all locales.
**************************************************************************************"""
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="""
mnemonic_12b = ["abandon"] * 11 + ["about"]
seed_12b = Seed(mnemonic=mnemonic_12b, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes):
data = (compact.to_bytes(OPCODES.OP_RETURN) +
compact.to_bytes(OPCODES.OP_PUSHDATA1) +
compact.to_bytes(len(raw_payload_data)) +
raw_payload_data)
script = Script(data)
output = OutputScope()
output.script_pubkey = script
output.value = 0
psbt.outputs.append(output)
return psbt.to_string()
# Prep a PSBT with a human-readable OP_RETURN
raw_payload_data = "Chancellor on the brink of third bailout for banks".encode()
psbt = PSBT.from_base64(BASE64_PSBT_1)
# Simplify the output side
output = psbt.outputs[-1]
psbt.outputs.clear()
psbt.outputs.append(output)
assert len(psbt.outputs) == 1
BASE64_PSBT_WITH_OP_RETURN_TEXT = add_op_return_to_psbt(psbt, raw_payload_data)
# Prep a PSBT with a (repeatably) random 80-byte OP_RETURN
random.seed(6102)
BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_PSBT_1), random.randbytes(80))
mnemonic_12 = "forum undo fragile fade shy sign arrest garment culture tube off merit".split()
mnemonic_24 = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split()
seed_12 = Seed(mnemonic=mnemonic_12, passphrase="cap*BRACKET3stove", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
seed_24 = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
seed_24_w_passphrase = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx"""
def generate_screenshots(locale):
"""
The `Renderer` class is mocked so that calls in the normal code are ignored
(necessary to avoid having it trying to wire up hardware dependencies).
@@ -58,323 +123,358 @@ def test_generate_screenshots(target_locale):
Renderer.configure_instance = Mock()
Renderer.get_instance = Mock(return_value=screenshot_renderer)
# Additional mocks needed
PowerOffView.PowerOffThread = Mock() # Don't let this View actually send the `shutdown` command!
controller = Controller.get_instance()
def setup_screenshots(locale: str) -> dict[str, list[ScreenshotConfig]]:
""" Set up some test data that we'll need in the `Controller` for certain Views """
# Must reset the Controller so each locale gets a fresh start
Controller.reset_instance()
controller = Controller.get_instance()
# Set up some test data that we'll need in the `Controller` for certain Views
mnemonic_12 = "forum undo fragile fade shy sign arrest garment culture tube off merit".split()
mnemonic_24 = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split()
mnemonic_12b = ["abandon"] * 11 + ["about"]
seed_12 = Seed(mnemonic=mnemonic_12, passphrase="cap*BRACKET3stove", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
seed_12b = Seed(mnemonic=mnemonic_12b, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
seed_24 = Seed(mnemonic=mnemonic_24, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
seed_24_w_passphrase = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
controller.storage.seeds.append(seed_12)
controller.storage.seeds.append(seed_12b)
controller.storage.seeds.append(seed_24)
controller.storage.set_pending_seed(seed_24_w_passphrase)
UnhandledExceptionViewFood = ["IndexError", "line 1, in some_buggy_code.py", "list index out of range"]
controller.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [attr for attr, name in SettingsConstants.ALL_SIG_TYPES])
controller.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [attr for attr, name in SettingsConstants.ALL_SCRIPT_TYPES])
# 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)
controller.storage.seeds.append(seed_12)
controller.storage.seeds.append(seed_12b)
controller.storage.seeds.append(seed_24)
controller.storage.set_pending_seed(seed_24_w_passphrase)
# 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()
decoder.add_data(BASE64_PSBT_1)
controller.psbt = decoder.get_psbt()
controller.psbt_seed = seed_12b
# 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)
def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes):
data = (compact.to_bytes(OPCODES.OP_RETURN) +
compact.to_bytes(OPCODES.OP_PUSHDATA1) +
compact.to_bytes(len(raw_payload_data)) +
raw_payload_data)
script = Script(data)
output = OutputScope()
output.script_pubkey = script
output.value = 0
psbt.outputs.append(output)
return psbt.to_string()
# Load a PSBT into memory
decoder = DecodeQR()
decoder.add_data(BASE64_PSBT_1)
controller.psbt = decoder.get_psbt()
controller.psbt_seed = seed_12b
# Prep a PSBT with a human-readable OP_RETURN
raw_payload_data = "Chancellor on the brink of third bailout for banks".encode()
psbt = PSBT.from_base64(BASE64_PSBT_1)
# Message signing data
derivation_path = "m/84h/0h/0h/0/0"
controller.sign_message_data = {
"seed_num": 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)
}
# Simplify the output side
output = psbt.outputs[-1]
psbt.outputs.clear()
psbt.outputs.append(output)
assert len(psbt.outputs) == 1
BASE64_PSBT_WITH_OP_RETURN_TEXT = add_op_return_to_psbt(psbt, raw_payload_data)
# Automatically populate all Settings options Views
settings_views_list = []
settings_views_list.append(ScreenshotConfig(settings_views.SettingsMenuView))
settings_views_list.append(
ScreenshotConfig(
settings_views.SettingsMenuView,
dict(
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selected_attr=SettingsConstants.SETTING__ELECTRUM_SEEDS,
initial_scroll=240, # Just guessing how many pixels to scroll down
),
screenshot_name="SettingsMenuView__Advanced"
)
)
# Prep a PSBT with a (repeatably) random 80-byte OP_RETURN
random.seed(6102)
BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_PSBT_1), random.randbytes(80))
# so we get a choice for transcribe seed qr format
controller.settings.set_value(
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
value=SettingsConstants.OPTION__ENABLED
)
for settings_entry in SettingsDefinition.settings_entries:
if settings_entry.visibility == SettingsConstants.VISIBILITY__HIDDEN:
continue
settings_views_list.append(ScreenshotConfig(settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), screenshot_name=f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}"))
settingsqr_data_persistent = f"settings::v1 name=English_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 locale={locale}"
settingsqr_data_not_persistent = f"settings::v1 name=Mode_Ephemeral 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 locale={locale}"
# Multisig wallet descriptor for the multisig in the above PSBT
MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx"""
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": derivation_path,
"message": "I attest that I control this bitcoin address blah blah blah",
"addr_format": embit_utils.parse_derivation_path(derivation_path)
}
# Set up screenshot-specific callbacks to inject data before the View is run and
# reset data after the View is run.
def load_basic_psbt_cb():
decoder = DecodeQR()
decoder.add_data(BASE64_PSBT_1)
controller.psbt = decoder.get_psbt()
controller.psbt_seed = seed_12b
controller.multisig_wallet_descriptor = None
# Automatically populate all Settings options Views
settings_views_list = []
settings_views_list.append(settings_views.SettingsMenuView)
settings_views_list.append((
settings_views.SettingsMenuView,
dict(
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selected_attr=SettingsConstants.SETTING__ELECTRUM_SEEDS,
initial_scroll=240, # Just guessing how many pixels to scroll down
),
"SettingsMenuView__Advanced"
))
# so we get a choice for transcribe seed qr format
controller.settings.set_value(
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
value=SettingsConstants.OPTION__ENABLED
)
for settings_entry in SettingsDefinition.settings_entries:
if settings_entry.visibility == SettingsConstants.VISIBILITY__HIDDEN:
continue
def load_multisig_wallet_descriptor_cb():
controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR)
settings_views_list.append((settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}"))
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"
def load_address_verification_data_cb():
controller.unverified_address = dict(
# These are all totally fake data
address="bc1q6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek",
network=SettingsConstants.MAINNET,
sig_type=SettingsConstants.SINGLE_SIG,
script_type=SettingsConstants.NATIVE_SEGWIT,
derivation_path = "m/84h/0h/0h",
verified_index=5,
verified_index_is_change=False
)
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,
],
"Seed Views": [
seed_views.SeedsMenuView,
seed_views.LoadSeedView,
seed_views.SeedMnemonicEntryView,
seed_views.SeedMnemonicInvalidView,
seed_views.SeedFinalizeView,
seed_views.SeedAddPassphraseView,
seed_views.SeedAddPassphraseExitDialogView,
seed_views.SeedReviewPassphraseView,
(seed_views.SeedOptionsView, dict(seed_num=0)),
(seed_views.SeedBackupView, dict(seed_num=0)),
(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)),
(seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")),
(seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")),
(seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")),
(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")),
(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")),
#SeedExportXpubQRDisplayView,
(seed_views.SeedWordsWarningView, dict(seed_num=0)),
(seed_views.SeedWordsView, dict(seed_num=0)),
(seed_views.SeedWordsView, dict(seed_num=0, page_index=2), "SeedWordsView_2"),
(seed_views.SeedBIP85ApplicationModeView, dict(seed_num=0)),
(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)),
(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)),
(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)),
(seed_views.SeedWordsBackupTestView, dict(seed_num=0)),
(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="unlucky")),
(seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)),
(seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)),
(seed_views.SeedTranscribeSeedQRWarningView, dict(seed_num=0)),
(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), "SeedTranscribeSeedQRWholeQRView_12_Compact"),
(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), "SeedTranscribeSeedQRWholeQRView_12_Standard"),
(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), "SeedTranscribeSeedQRWholeQRView_24_Compact"),
(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), "SeedTranscribeSeedQRWholeQRView_24_Standard"),
# Screenshot doesn't render properly due to how the transparency mask is pre-rendered
# (seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR)),
def PSBTSelectSeedView_cb_before():
# Have to ensure this is cleared out in order to get the seed selection screen
controller.psbt_seed = None
(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)),
# Screenshot can't render live preview screens
# (seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)),
def PSBTOverviewView_op_return_cb_before():
controller.psbt_seed = seed_12b
decoder = DecodeQR()
decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_TEXT)
controller.psbt = decoder.get_psbt()
controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
#(seed_views.AddressVerificationStartView, dict(address=, script_type="nat", network="M")),
#seed_views.AddressVerificationSigTypeView,
#seed_views.SeedSingleSigAddressVerificationSelectSeedView,
#seed_views.SeedAddressVerificationView,
#seed_views.AddressVerificationSuccessView,
def PSBTOpReturnView_raw_hex_data_cb_before():
decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES)
controller.psbt = decoder.get_psbt()
controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
seed_views.LoadMultisigWalletDescriptorView,
seed_views.MultisigWalletDescriptorView,
(seed_views.SeedDiscardView, dict(seed_num=0)),
seed_views.SeedSignMessageConfirmMessageView,
seed_views.SeedSignMessageConfirmAddressView,
screenshot_sections = {
"Main Menu Views": [
ScreenshotConfig(OpeningSplashView, dict(is_screenshot_renderer=True, force_partner_logos=True)),
ScreenshotConfig(OpeningSplashView, dict(is_screenshot_renderer=True, force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos"),
ScreenshotConfig(MainMenuView),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_removed', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED, activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_inserted', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED, activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_RemoveSDCardToast', toast_thread=RemoveSDCardToastManagerThread(activation_delay=0, duration=0)),
ScreenshotConfig(PowerOptionsView),
ScreenshotConfig(RestartView),
ScreenshotConfig(PowerOffView),
],
"Seed Views": [
ScreenshotConfig(seed_views.SeedsMenuView),
ScreenshotConfig(seed_views.LoadSeedView),
ScreenshotConfig(seed_views.SeedMnemonicEntryView),
ScreenshotConfig(seed_views.SeedMnemonicInvalidView),
ScreenshotConfig(seed_views.SeedFinalizeView),
ScreenshotConfig(seed_views.SeedAddPassphraseView, screenshot_name="SeedAddPassphraseView_lowercase"),
ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__UPPERCASE_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_uppercase"),
ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__DIGITS_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_digits"),
ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__SYMBOLS_1_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_symbols_1"),
ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__SYMBOLS_2_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_symbols_2"),
ScreenshotConfig(seed_views.SeedAddPassphraseExitDialogView),
ScreenshotConfig(seed_views.SeedReviewPassphraseView),
ScreenshotConfig(seed_views.SeedOptionsView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedBackupView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")),
ScreenshotConfig(seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")),
ScreenshotConfig(seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")),
ScreenshotConfig(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")),
ScreenshotConfig(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")),
#ScreenshotConfig(SeedExportXpubQRDisplayView),
ScreenshotConfig(seed_views.SeedWordsWarningView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0, page_index=2), screenshot_name="SeedWordsView_2"),
ScreenshotConfig(seed_views.SeedBIP85ApplicationModeView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)),
ScreenshotConfig(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)),
ScreenshotConfig(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsBackupTestView, dict(seed_num=0, rand_seed=6102)),
ScreenshotConfig(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="satoshi")),
ScreenshotConfig(seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWarningView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Compact"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Standard"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Compact"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Standard"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, initial_block_x=1, initial_block_y=1), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Compact"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, initial_block_x=2, initial_block_y=2), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Standard"),
seed_views.SeedElectrumMnemonicStartView,
],
"PSBT Views": [
psbt_views.PSBTSelectSeedView, # this will fail, be rerun below
psbt_views.PSBTOverviewView,
psbt_views.PSBTUnsupportedScriptTypeWarningView,
psbt_views.PSBTNoChangeWarningView,
psbt_views.PSBTMathView,
(psbt_views.PSBTAddressDetailsView, dict(address_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)),
(NotYetImplementedView, {}, "PSBTChangeDetailsView_multisig_unverified"), # Must manually re-run this below
(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), "PSBTChangeDetailsView_multisig_verified"),
# Screenshot can't render live preview screens
# ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)),
(NotYetImplementedView, {}, "PSBTOverviewView_op_return"), # Placeholder
(NotYetImplementedView, {}, "PSBTOpReturnView_text"), # Placeholder
(NotYetImplementedView, {}, "PSBTOpReturnView_raw_hex_data"), # Placeholder
ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), screenshot_name="SeedSelectSeedView_address_verification"),
ScreenshotConfig(seed_views.AddressVerificationSigTypeView),
ScreenshotConfig(seed_views.SeedAddressVerificationView, dict(seed_num=0), run_before=load_address_verification_data_cb),
ScreenshotConfig(seed_views.SeedAddressVerificationSuccessView, dict(seed_num=0)), # Relies on callback above
(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), "PSBTAddressVerificationFailedView_singlesig_change"),
(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), "PSBTAddressVerificationFailedView_singlesig_selftransfer"),
(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_change"),
(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_selftransfer"),
psbt_views.PSBTFinalizeView,
#PSBTSignedQRDisplayView
psbt_views.PSBTSigningErrorView,
],
"Tools Views": [
tools_views.ToolsMenuView,
#ToolsImageEntropyLivePreviewView
#ToolsImageEntropyFinalImageView
tools_views.ToolsImageEntropyMnemonicLengthView,
tools_views.ToolsDiceEntropyMnemonicLengthView,
(tools_views.ToolsDiceEntropyEntryView, dict(total_rolls=50)),
tools_views.ToolsCalcFinalWordNumWordsView,
tools_views.ToolsCalcFinalWordFinalizePromptView,
tools_views.ToolsCalcFinalWordCoinFlipsView,
(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.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",
)),
]
}
ScreenshotConfig(seed_views.LoadMultisigWalletDescriptorView),
ScreenshotConfig(seed_views.MultisigWalletDescriptorView, run_before=load_multisig_wallet_descriptor_cb),
ScreenshotConfig(seed_views.SeedDiscardView, dict(seed_num=0)),
readme = f"""# SeedSigner Screenshots\n"""
ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__SIGN_MESSAGE), screenshot_name="SeedSelectSeedView_sign_message"),
ScreenshotConfig(seed_views.SeedSignMessageConfirmMessageView),
ScreenshotConfig(seed_views.SeedSignMessageConfirmAddressView),
def screencap_view(view_cls: View, view_args: dict = {}, view_name: str = None, toast_thread: BaseToastOverlayManagerThread = None):
if not view_name:
view_name = view_cls.__name__
screenshot_renderer.set_screenshot_filename(f"{view_name}.png")
ScreenshotConfig(seed_views.SeedElectrumMnemonicStartView),
],
"PSBT Views": [
ScreenshotConfig(psbt_views.PSBTSelectSeedView, run_before=PSBTSelectSeedView_cb_before),
ScreenshotConfig(psbt_views.PSBTOverviewView, run_before=load_basic_psbt_cb),
ScreenshotConfig(psbt_views.PSBTUnsupportedScriptTypeWarningView),
ScreenshotConfig(psbt_views.PSBTNoChangeWarningView),
ScreenshotConfig(psbt_views.PSBTMathView),
ScreenshotConfig(psbt_views.PSBTAddressDetailsView, dict(address_num=0)),
ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_unverified", run_before=load_basic_psbt_cb),
ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_verified", run_before=load_multisig_wallet_descriptor_cb),
ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", run_before=PSBTOverviewView_op_return_cb_before),
ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text"), # Relies on callback above
ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", run_before=PSBTOpReturnView_raw_hex_data_cb_before),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_change"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_selftransfer"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_change"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_selftransfer"),
ScreenshotConfig(psbt_views.PSBTFinalizeView),
#ScreenshotConfig(PSBTSignedQRDisplayViewScreenshotConfig),
ScreenshotConfig(psbt_views.PSBTSigningErrorView),
],
"Tools Views": [
ScreenshotConfig(tools_views.ToolsMenuView),
#ScreenshotConfig(ToolsImageEntropyLivePreviewView),
#ScreenshotConfig(ToolsImageEntropyFinalImageView),
ScreenshotConfig(tools_views.ToolsImageEntropyMnemonicLengthView),
ScreenshotConfig(tools_views.ToolsDiceEntropyMnemonicLengthView),
ScreenshotConfig(tools_views.ToolsDiceEntropyEntryView, dict(total_rolls=50)),
ScreenshotConfig(tools_views.ToolsCalcFinalWordNumWordsView),
ScreenshotConfig(tools_views.ToolsCalcFinalWordFinalizePromptView),
ScreenshotConfig(tools_views.ToolsCalcFinalWordCoinFlipsView),
ScreenshotConfig(tools_views.ToolsCalcFinalWordShowFinalWordView, screenshot_name="ToolsCalcFinalWordShowFinalWordView_pick_word"),
ScreenshotConfig(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), screenshot_name="ToolsCalcFinalWordShowFinalWordView_coin_flips"),
ScreenshotConfig(tools_views.ToolsCalcFinalWordDoneView),
ScreenshotConfig(tools_views.ToolsAddressExplorerSelectSourceView),
ScreenshotConfig(tools_views.ToolsAddressExplorerAddressTypeView),
ScreenshotConfig(tools_views.ToolsAddressExplorerAddressListView),
# ScreenshotConfig(tools_views.ToolsAddressExplorerAddressView),
],
"Settings Views": settings_views_list + [
ScreenshotConfig(settings_views.IOTestView),
ScreenshotConfig(settings_views.DonateView),
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"),
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"),
],
"Misc Error Views": [
ScreenshotConfig(NotYetImplementedView),
ScreenshotConfig(UnhandledExceptionView, dict(error=["IndexError", "line 1, in some_buggy_code.py", "list index out of range"])),
ScreenshotConfig(NetworkMismatchErrorView, dict(derivation_path="m/84'/1'/0'")),
ScreenshotConfig(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)),
ScreenshotConfig(ErrorView, dict(
title="Error",
status_headline="Unknown QR Type",
text="QRCode is invalid or is a data format not yet supported.",
button_text="Back",
)),
]
}
return screenshot_sections
def screencap_view(screenshot_config: ScreenshotConfig):
# Block until we have exclusive access to the screenshot renderer. Without this
# we were occasionally running into confusing race conditions where the next
# screenshot would begin rendering over the previous one. Claiming the lock
# guarantees that the previous screenshot has been fully rendered and saved.
with screenshot_renderer.lock:
screenshot_renderer.set_screenshot_filename(f"{screenshot_config.screenshot_name}.png")
controller = Controller.get_instance()
toast_thread = screenshot_config.toast_thread
try:
print(f"Running {view_name}")
print(f"Running {screenshot_config.screenshot_name}")
try:
view_cls(**view_args).run()
screenshot_config.run_callback_before()
screenshot_config.View_cls(**screenshot_config.view_kwargs).run()
except ScreenshotComplete:
# The target View has run and its Screen has rendered what it needs to
if toast_thread is not None:
# Now run the Toast so it can render on top of the current image buffer
controller.activate_toast(toast_thread)
while controller.toast_notification_thread.is_alive():
time.sleep(0.1)
# Give the Toast a moment to complete its work
time.sleep(0.01)
# TODO: Necessary now that the lock is in place?
# Whenever possible, clean up toast thread HERE before killing the
# main thread with ScreenshotComplete.
toast_thread.stop()
toast_thread.join()
raise ScreenshotComplete()
except ScreenshotComplete:
# Slightly hacky way to exit ScreenshotRenderer as expected
pass
print(f"Completed {view_name}")
print(f"Completed {screenshot_config.screenshot_name}")
except Exception as e:
# Something else went wrong
from traceback import print_exc
print_exc()
raise e
finally:
if toast_thread:
if toast_thread and toast_thread.is_alive():
toast_thread.stop()
toast_thread.join()
for section_name, screenshot_list in screenshot_sections.items():
screenshot_config.run_callback_after()
# Parse the main `l10n/messages.pot` for overall stats
messages_source_path = os.path.join(pathlib.Path(__file__).parent.resolve().parent.resolve().parent.resolve(), "l10n", "messages.pot")
with open(messages_source_path, 'r') as messages_source_file:
num_source_messages = messages_source_file.read().count("msgid \"") - 1
locale_tuple_list = [locale_tuple for locale_tuple in SettingsConstants.ALL_LOCALES if locale_tuple[0] == locale]
if not locale_tuple_list:
raise Exception(f"Invalid locale: {locale}")
locale, display_name = locale_tuple_list[0]
Settings.get_instance().set_value(SettingsConstants.SETTING__LOCALE, value=locale)
locale_readme = f"""# SeedSigner Screenshots: {display_name}\n"""
# Report the translation progress
if locale != SettingsConstants.LOCALE__ENGLISH:
try:
translated_messages_path = os.path.join(pathlib.Path(__file__).parent.resolve().parent.resolve().parent.resolve(), "src", "seedsigner", "resources", "seedsigner-translations", "l10n", locale, "LC_MESSAGES", "messages.po")
with open(translated_messages_path, 'r') as translation_file:
locale_translations = translation_file.read()
num_locale_translations = locale_translations.count("msgid \"") - locale_translations.count("""msgstr ""\n\n""") - 1
if locale != "en":
locale_readme += f"## Translation progress: {num_locale_translations / num_source_messages:.1%}\n\n"
locale_readme += "---\n\n"
except Exception as e:
from traceback import print_exc
print_exc()
for section_name, screenshot_list in setup_screenshots(locale).items():
subdir = section_name.lower().replace(" ", "_")
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, subdir))
readme += "\n\n---\n\n"
readme += f"## {section_name}\n\n"
readme += """<table style="border: 0;">"""
readme += f"""<tr><td align="center">\n"""
for screenshot in screenshot_list:
if type(screenshot) == tuple:
if len(screenshot) == 2:
view_cls, view_args = screenshot
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
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, locale, subdir))
locale_readme += "\n\n---\n\n"
locale_readme += f"## {section_name}\n\n"
locale_readme += """<table style="border: 0;">"""
locale_readme += f"""<tr><td align="center">"""
for screenshot_config in screenshot_list:
screencap_view(screenshot_config)
locale_readme += """ <table align="left" style="border: 1px solid gray;">"""
locale_readme += f"""<tr><td align="center">{screenshot_config.screenshot_name}<br/><br/><img src="{subdir}/{screenshot_config.screenshot_name}.png"></td></tr>"""
locale_readme += """</table>\n"""
screencap_view(view_cls, view_args=view_args, view_name=view_name, 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"""
locale_readme += "</td></tr></table>"
readme += "</td></tr></table>"
with open(os.path.join(screenshot_root, locale, "README.md"), 'w') as readme_file:
readme_file.write(locale_readme)
# Re-render some screens that require more manual intervention / setup than the above
# scripting can support.
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, "psbt_views"))
print(f"Done with locale: {locale}.")
# Render the PSBTChangeDetailsView_multisig_unverified screenshot
decoder = DecodeQR()
decoder.add_data(BASE64_PSBT_1)
controller.psbt = decoder.get_psbt()
controller.psbt_seed = seed_12b
controller.multisig_wallet_descriptor = None
screencap_view(psbt_views.PSBTChangeDetailsView, view_name='PSBTChangeDetailsView_multisig_unverified', view_args=dict(change_address_num=0))
# Write the main README; ensure it writes all locales, not just the one that may
# have been specified for this run.
with open(os.path.join("tests", "screenshot_generator", "template.md"), 'r') as readme_template:
main_readme = readme_template.read()
controller.psbt_seed = None
screencap_view(psbt_views.PSBTSelectSeedView, view_name='PSBTSelectSeedView')
# Render OP_RETURN screens for real
controller.psbt_seed = seed_12b
decoder = DecodeQR()
decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_TEXT)
controller.psbt = decoder.get_psbt()
controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
screencap_view(psbt_views.PSBTOverviewView, view_name='PSBTOverviewView_op_return')
screencap_view(psbt_views.PSBTOpReturnView, view_name="PSBTOpReturnView_text")
decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES)
controller.psbt = decoder.get_psbt()
controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b)
screencap_view(psbt_views.PSBTOpReturnView, view_name="PSBTOpReturnView_raw_hex_data")
for locale, display_name in SettingsConstants.ALL_LOCALES:
main_readme += f"* [{display_name}]({locale}/README.md)\n"
with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file:
readme_file.write(readme)
readme_file.write(main_readme)
+16
View File
@@ -0,0 +1,16 @@
# SeedSigner Screenshots
SeedSigner screenshots can be freely used in any tutorial, article, video, etc. As a courtesy, please link back to this repo or the SeedSigner website in your attribution.
![](en/main_menu_views/MainMenuView.png) ![](en/psbt_views/PSBTOverviewView.png)
![](en/seed_views/SeedOptionsView.png) ![](en/tools_views/ToolsMenuView.png)
## Generating screenshots
The screenshot generator is integrated into the SeedSigner test suite and requires a local copy of the SeedSigner repo with the test suite dependencies installed.
see: https://github.com/SeedSigner/seedsigner/blob/dev/tests/screenshot_generator/README.md
## Currently supported or in-progress languages
+33 -1
View File
@@ -1,6 +1,11 @@
import os
from dataclasses import dataclass
from PIL import Image, ImageDraw
from seedsigner.gui.renderer import Renderer
from seedsigner.gui.toast import BaseToastOverlayManagerThread
from seedsigner.views.view import View
@@ -37,7 +42,7 @@ class ScreenshotRenderer(Renderer):
self.screenshot_path = path
def show_image(self, image=None, alpha_overlay=None, is_background_thread: bool = False):
def show_image(self, image=None, alpha_overlay=None, is_background_thread: bool = False):
if is_background_thread:
return
@@ -53,3 +58,30 @@ class ScreenshotRenderer(Renderer):
self.canvas.save(os.path.join(self.screenshot_path, self.screenshot_filename))
raise ScreenshotComplete()
@dataclass
class ScreenshotConfig:
View_cls: View
view_kwargs: dict = None
screenshot_name: str = None
toast_thread: BaseToastOverlayManagerThread = None
run_before: callable = None
run_after: callable = None
def __post_init__(self):
if not self.view_kwargs:
self.view_kwargs = {}
if not self.screenshot_name:
self.screenshot_name = self.View_cls.__name__
def run_callback_before(self):
if self.run_before:
self.run_before()
def run_callback_after(self):
if self.run_after:
self.run_after()
-6
View File
@@ -1,10 +1,4 @@
import pytest
from unittest.mock import MagicMock
from seedsigner.models.seed import Seed
from embit import bip39
from seedsigner.models.settings import SettingsConstants
def test_derive_child_mnemonic():
+1 -1
View File
@@ -88,7 +88,7 @@ class TestController(BaseTest):
controller = Controller.get_instance()
# Settings defaults
assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__ENGLISH
assert controller.settings.get_value(SettingsConstants.SETTING__LOCALE) == SettingsConstants.LOCALE__ENGLISH
assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH
assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED
assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS if i!="kpr"]
+28
View File
@@ -0,0 +1,28 @@
from gettext import gettext as _
# Must import test base before the Controller
from base import FlowTest, FlowStep
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.views import settings_views
from seedsigner.views.view import MainMenuView
class TestL10nFlows(FlowTest):
def test_change_locale(self):
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__LOCALE)
spanish_display_name = [locale_tuple[1] for locale_tuple in SettingsConstants.ALL_LOCALES if locale_tuple[0] == SettingsConstants.LOCALE__SPANISH][0]
# Initially we get English
assert _(MainMenuView.SCAN.button_label) == "Scan"
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=ButtonOption(spanish_display_name)),
])
# Now we don't get English
assert _(MainMenuView.SCAN.button_label) != "Scan"
+1 -1
View File
@@ -115,7 +115,7 @@ class TestPSBTFlows(FlowTest):
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=SettingsConstants.LABEL__BIP39_PASSPHRASE),
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="abc")),
FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
+23 -12
View File
@@ -5,7 +5,7 @@ import pytest
from base import BaseTest, FlowTest, FlowStep
from base import FlowTestInvalidButtonDataSelectionException
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption
from seedsigner.models.settings import Settings, SettingsConstants
from seedsigner.models.seed import ElectrumSeed, Seed
from seedsigner.views.view import ErrorView, MainMenuView, OptionDisabledView, View, NetworkMismatchErrorView
@@ -40,10 +40,10 @@ class TestSeedFlows(FlowTest):
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
FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE),
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase", is_back_button=True)),
FlowStep(seed_views.SeedAddPassphraseExitDialogView, button_data_selection=seed_views.SeedAddPassphraseExitDialogView.DISCARD),
FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE),
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase", is_back_button=True)),
FlowStep(seed_views.SeedAddPassphraseExitDialogView, button_data_selection=seed_views.SeedAddPassphraseExitDialogView.EDIT),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="muhpassphrase")),
@@ -183,15 +183,18 @@ class TestSeedFlows(FlowTest):
"""
Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView
"""
def flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple):
if sig_tuple[0] == SettingsConstants.SINGLE_SIG:
sig_selection = seed_views.SeedExportXpubSigTypeView.SINGLE_SIG
else:
sig_selection = seed_views.SeedExportXpubSigTypeView.MULTISIG
self.run_sequence(
initial_destination_view_args=dict(seed_num=0),
sequence=[
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_tuple[1]),
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_tuple[1]),
FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coord_tuple[1]),
FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_selection),
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=ButtonOption(script_tuple[1], return_data=script_tuple[0])),
FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=ButtonOption(coord_tuple[1], return_data=coord_tuple[0])),
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
@@ -303,10 +306,18 @@ class TestSeedFlows(FlowTest):
SettingsConstants.CUSTOM_DERIVATION
])
# get display names to access button choices in the views (ugh: hardcoding, is there a better way?)
sig_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # single sig
script_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2] # custom derivation
coordinator = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # specter
# Ensure that all coordinators are enabled
self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x, y in SettingsConstants.ALL_COORDINATORS])
# Set up button_data selections
sig_type = seed_views.SeedExportXpubSigTypeView.SINGLE_SIG
custom_derivation = SettingsConstants.CUSTOM_DERIVATION
script_type = ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2], return_data=custom_derivation)
specter = SettingsConstants.COORDINATOR__SPECTER_DESKTOP
assert SettingsConstants.ALL_COORDINATORS[3][0] == specter
coordinator = ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3], return_data=specter)
self.run_sequence(
initial_destination_view_args=dict(seed_num=0),
@@ -377,7 +388,7 @@ class TestSeedFlows(FlowTest):
# Skips past the script type options via redirect
FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0]),
FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0], return_data=SettingsConstants.ALL_COORDINATORS[0][0])),
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
+4 -4
View File
@@ -8,7 +8,7 @@ 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.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption
from seedsigner.hardware.microsd import MicroSD
from seedsigner.views.view import MainMenuView
from seedsigner.views import scan_views, settings_views
@@ -26,8 +26,8 @@ class TestSettingsFlows(FlowTest):
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)),
FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=ButtonOption(settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED))),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON),
FlowStep(settings_views.SettingsMenuView),
])
@@ -43,7 +43,7 @@ class TestSettingsFlows(FlowTest):
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name),
FlowStep(settings_views.SettingsMenuView, button_data_selection=ButtonOption(settings_entry.display_name)),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select/deselect first option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option
+29 -4
View File
@@ -2,7 +2,7 @@
from base import FlowTest, FlowStep
from seedsigner.controller import Controller
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.views.view import ErrorView, MainMenuView
@@ -25,7 +25,7 @@ class TestToolsFlows(FlowTest):
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # ret 1st onboard seed
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT)),
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=ButtonOption(SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT), return_data=SettingsConstants.NATIVE_SEGWIT)),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE),
FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs
FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list
@@ -62,7 +62,7 @@ class TestToolsFlows(FlowTest):
# Finalize the new seed w/passphrase
self.run_sequence(
sequence=[
FlowStep(seed_views.SeedFinalizeView, button_data_selection=SettingsConstants.LABEL__BIP39_PASSPHRASE),
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="mypassphrase")),
FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
@@ -242,6 +242,31 @@ class TestToolsFlows(FlowTest):
FlowStep(scan_views.ScanWalletDescriptorView, before_run=load_descriptor_into_decoder), # simulate read descriptor QR
FlowStep(seed_views.MultisigWalletDescriptorView, screen_return_value=0),
FlowStep(seed_views.SeedAddressVerificationView),
FlowStep(seed_views.AddressVerificationSuccessView),
FlowStep(seed_views.SeedAddressVerificationSuccessView),
])
def test__verify_address__singlesig__flow(self):
"""
Address Explorer should be able to scan a singlesig address and
verify it against a loaded key.
"""
controller = Controller.get_instance()
controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"]))
controller.storage.finalize_pending_seed()
settings = controller.settings
settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST)
def load_address_into_decoder(view: scan_views.ScanView):
# Native segwit regtest receive addr @ index 6
view.decoder.add_data("bcrt1q4e9q5taxnsvc6m0uxv6h75mkzvnkxeqk6l90u2")
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.VERIFY_ADDRESS),
FlowStep(scan_views.ScanAddressView, before_run=load_address_into_decoder), # simulate read address QR
FlowStep(seed_views.AddressVerificationStartView, is_redirect=True),
FlowStep(seed_views.SeedSelectSeedView, screen_return_value=0),
FlowStep(seed_views.SeedAddressVerificationView),
FlowStep(seed_views.SeedAddressVerificationSuccessView),
])
-8
View File
@@ -28,14 +28,6 @@ class TestViewFlows(FlowTest):
"""
Basic flow from MainMenuView to PowerOffView
"""
with patch('seedsigner.views.view.PowerOffView.PowerOffThread'):
self.run_sequence([
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF),
FlowStep(PowerOffView),
])
# And again, but this time as if we were in the SeedSigner OS
Settings.HOSTNAME = Settings.SEEDSIGNER_OS
self.run_sequence([
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
+104
View File
@@ -0,0 +1,104 @@
from gettext import gettext as _
from base import BaseTest
from seedsigner.gui.screens.screen import ButtonOption
from seedsigner.helpers.l10n import mark_for_translation as _mft
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.views.view import MainMenuView
class TestGettext(BaseTest):
def test_english_as_default(self):
# Key is available in other languages, but we get English back
assert _("Home") == "Home"
def test_missing_key_returns_english_key(self):
test_str = "This is not in our translation library"
assert _(test_str) == test_str
def test_basic_spanish(self):
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH)
assert _("Home") != "Home"
def test_locale_changes(self):
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH)
spanish_str = _("Home")
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__ENGLISH)
assert spanish_str != _("Home")
assert _("Home") == "Home"
class TestButtonOption(BaseTest):
def test_english_key_not_translated(self):
""" ButtonOption should always return its English key, regardless of current locale setting. """
button_option = ButtonOption("Home")
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH)
assert button_option.button_label == "Home"
brand_new_button_option = ButtonOption("Tools")
assert brand_new_button_option.button_label == "Tools"
def test_class_level_button_option_english_key_not_translated(self):
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH)
class FooClass:
HOME = ButtonOption("Home")
assert FooClass.HOME.button_label == "Home"
def test_gettext_translates_class_level_button_option(self):
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH)
class BarClass:
HOME = ButtonOption("Home")
assert _(BarClass.HOME.button_label) != "Home"
class TestMarkForTranslation(BaseTest):
def test_english_key_not_translated(self):
""" _mft() should always return its English key, regardless of current locale setting. """
mft_attr = _mft("Home")
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH)
assert mft_attr == "Home"
brand_new_mft_attr = _mft("Tools")
assert brand_new_mft_attr == "Tools"
def test_class_level_mft_attr_english_key_not_translated(self):
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH)
class FooClass:
home = _mft("Home")
assert FooClass.home == "Home"
def test_gettext_translates_class_level_mft_attr(self):
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__LOCALE, SettingsConstants.LOCALE__SPANISH)
class BarClass:
home = _mft("Home")
assert _(BarClass.home) != "Home"