Merge branch 'dev' into version_screen_with_git_info

This commit is contained in:
Nick Klockenga
2026-07-16 23:54:59 -04:00
committed by GitHub
49 changed files with 1088 additions and 498 deletions
+67 -12
View File
@@ -1,8 +1,33 @@
## Description
_Describe the change simply. Provide a reason for the change._
### Problem or Issue being addressed
_Include screenshots of any new or modified screens (or at least explain why they were omitted)_
<!--
Describe the problem this PR solves.
Include background context, links to relevant issues, BIPs, discussions, etc.
-->
### Solution
<!--
Describe your approach and key technical implementation details.
Explain why this approach was chosen.
-->
### Additional Information
<!--
Tradeoffs, follow-ups, limitations, or anything reviewers should be aware of.
-->
### Screenshots
<!--
Include screenshots for any new or modified screens.
If omitted, explain why.
-->
---
This pull request is categorized as a:
@@ -12,21 +37,51 @@ This pull request is categorized as a:
- [ ] Documentation
- [ ] Other
---
## Checklist
- [ ] Ive run `pytest` and made sure all unit tests pass before submitting the PR
If you modified or added functionality/workflow, did you add new unit tests?
- [ ] No, Im a fool
- [ ] Yes
#### I ran `pytest` locally
- [ ] All tests passed before submitting the PR
- [ ] I couldn't run the tests
- [ ] N/A
I have tested this PR on the following platforms/os:
---
- [ ] Raspberry Pi OS [Manual Build](https://github.com/SeedSigner/seedsigner/blob/dev/docs/manual_installation.md)
#### I included screenshots of any new or modified screens
Should be part of the PR description above.
- [ ] Yes
- [ ] No
- [ ] N/A
---
#### I added or updated tests
Any new or altered functionality should be covered in a unit test. Any new or updated sequences require FlowTests.
- [ ] Yes
- [ ] No, Im a fool
- [ ] N/A
---
#### I tested this PR hands-on on the following platform(s):
- [ ] Raspberry Pi OS [Manual Build](https://github.com/SeedSigner/seedsigner/blob/dev/docs/raspberry_pi_os_build_instructions.md)
- [ ] [SeedSigner OS](https://github.com/SeedSigner/seedsigner-os) on a Pi0/Pi0W board
- [ ] Other
- [ ] Emulator
---
Note: Keep your changes limited in scope; if you uncover other issues or improvements along the way, ideally submit those as a separate PR. The more complicated the PR the harder to review, test, and merge.
#### I have reviewed these notes:
* Keep your changes limited in scope.
* If you uncover other issues or improvements along the way, ideally submit those as a separate PR.
* The more complicated the PR, the harder it is to review, test, and merge.
* We appreciate your efforts, but we're a small team of volunteers so PR review can be a very slow process.
* Please only "@" mention a contributor if their input is truly needed to enable further progress.
- [ ] I understand
---
Thank you! Please join our [Devs' Telegram group](https://t.me/seedsigner_new_devs) to get more involved.
+17 -4
View File
@@ -16,6 +16,18 @@ on:
default: main
required: true
# Explicitly restrict the auto-provisioned GITHUB_TOKEN to the least privilege required
# for this workflow.
permissions:
contents: read
# Cancel superseded runs on the same branch/PR. Intermediate builds on dev/main
# have no downstream consumers (official releases are built offline), so only
# the latest commit per ref needs to be built.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Increment this number as part of a PR to trigger an image build for the PR
# trigger = 0
@@ -23,6 +35,7 @@ jobs:
build:
name: build
runs-on: ubuntu-latest
timeout-minutes: 120
# Prevent resource consuming cron triggered runs in forks
if: (!github.event.repository.fork || github.event_name == 'workflow_dispatch')
strategy:
@@ -71,6 +84,9 @@ jobs:
fi
- name: delete unnecessary files
# The seedsigner-os buildroot overlay only needs src/; drop everything
# else from the seedsigner checkout (tests, docs, tooling, .git, etc.)
# to keep the overlay minimal.
run: |
cd seedsigner-os/opt/rootfs-overlay/opt
find . -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} +
@@ -151,10 +167,7 @@ jobs:
ls -lRa images
- name: get seedsigner latest commit hash
id: get-seedsigner-hash
run: |
git init
echo "source_hash=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_ENV
run: echo "source_hash=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: write sha256sum
run: |
+16 -5
View File
@@ -4,6 +4,11 @@ on:
push:
pull_request:
# Explicitly restrict the auto-provisioned GITHUB_TOKEN to the least privilege required
# for this workflow.
permissions:
contents: read
concurrency:
# Concurrency group that uses the workflow name and PR number if available
# or commit SHA as a fallback. If a new build is triggered under that
@@ -24,6 +29,7 @@ env:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
# 3.10: currently used by Seedsigner
@@ -39,19 +45,24 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: |
requirements.txt
tests/requirements.txt
- name: Install dependencies
run: |
sudo apt-get install libzbar0
python -m pip install --upgrade pip
pip install -r requirements.txt -r tests/requirements.txt
pip install .
pip install -r requirements.txt -r tests/requirements.txt -r l10n/requirements-l10n.txt
pip install -e .
- name: Compile translations
run: python setup.py compile_catalog
- name: Test with pytest
run: |
mkdir artifacts
mkdir -p artifacts
python -m pytest \
--color=yes \
--cov=seedsigner \
--cov-append \
--cov-branch \
--durations 5 \
-vv
@@ -74,5 +85,5 @@ jobs:
path: artifacts/**
retention-days: 10
# Upload also when tests fail. The workflow result (red/green) will
# be not effected by this.
# not be affected by this.
if: always()
+16 -16
View File
@@ -112,23 +112,23 @@ Instructions to build a SeedSigner OS image (using precisely the same process th
## Downloading the Software
Download the current Version (0.8.6) software image that is compatible with your Raspberry Pi Hardware. The Pi Zero 1.3 is the most common and recommended board.
Download the current Version (0.8.7) software image that is compatible with your Raspberry Pi Hardware. The Pi Zero 1.3 is the most common and recommended board.
| Board | Download Image Link/Name |
| --------------------- | --------------------------------- |
|**[Raspberry Pi Zero 1.3](https://www.raspberrypi.com/products/raspberry-pi-zero/)** |[`seedsigner_os.0.8.6.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi0.img) |
|[Raspberry Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) |[`seedsigner_os.0.8.6.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi0.img) |
|[Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) |[`seedsigner_os.0.8.6.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi02w.img) |
|[Raspberry Pi 1 Model B/B+](https://www.raspberrypi.com/products/raspberry-pi-1-model-b-plus/) |[`seedsigner_os.0.8.6.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi0.img) |
|[Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) |[`seedsigner_os.0.8.6.pi2.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi2.img) |
|[Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) |[`seedsigner_os.0.8.6.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi02w.img) |
|[Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) |[`seedsigner_os.0.8.6.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi4.img) |
|[Raspberry Pi 400](https://www.raspberrypi.com/products/raspberry-pi-400-unit/) |[`seedsigner_os.0.8.6.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner_os.0.8.6.pi4.img) |
|**[Raspberry Pi Zero 1.3](https://www.raspberrypi.com/products/raspberry-pi-zero/)** |[`seedsigner_os.0.8.7.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner_os.0.8.7.pi0.img) |
|[Raspberry Pi Zero W](https://www.raspberrypi.com/products/raspberry-pi-zero-w/) |[`seedsigner_os.0.8.7.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner_os.0.8.7.pi0.img) |
|[Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) |[`seedsigner_os.0.8.7.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner_os.0.8.7.pi02w.img) |
|[Raspberry Pi 1 Model B/B+](https://www.raspberrypi.com/products/raspberry-pi-1-model-b-plus/) |[`seedsigner_os.0.8.7.pi0.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner_os.0.8.7.pi0.img) |
|[Raspberry Pi 2 Model B](https://www.raspberrypi.com/products/raspberry-pi-2-model-b/) |[`seedsigner_os.0.8.7.pi2.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner_os.0.8.7.pi2.img) |
|[Raspberry Pi 3 Model B](https://www.raspberrypi.com/products/raspberry-pi-3-model-b/) |[`seedsigner_os.0.8.7.pi02w.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner_os.0.8.7.pi02w.img) |
|[Raspberry Pi 4 Model B](https://www.raspberrypi.com/products/raspberry-pi-4-model-b/) |[`seedsigner_os.0.8.7.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner_os.0.8.7.pi4.img) |
|[Raspberry Pi 400](https://www.raspberrypi.com/products/raspberry-pi-400-unit/) |[`seedsigner_os.0.8.7.pi4.img`](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner_os.0.8.7.pi4.img) |
Note: If you have physically removed the WiFi component from your board, you will still use the image file of the original (un-modified) hardware. (Our files are compiled/based on the *processor* architecture). Although it is better to spend a few minutes upfront to determine which specific Pi hardware/model you have, if you are still unsure which hardware you have, you can try using the pi0.img file. Making an incorrect choice here will not ruin your board, because this is software, not firmware.
**Also download** these 2 signature verification files to the same folder
[The Plaintext manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner.0.8.6.sha256.txt)
[The Signature of the manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.6/seedsigner.0.8.6.sha256.txt.sig)
[The Plaintext manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner.0.8.7.sha256.txt)
[The Signature of the manifest file](https://github.com/SeedSigner/seedsigner/releases/download/0.8.7/seedsigner.0.8.7.sha256.txt.sig)
Users familiar with older versions of the SeedSigner software might be surprised with how fast their software downloads now are, because since version 0.6.0 the software image files are now 100x smaller! Each image file is now under 42 Megabytes so your downloads and verifications will be very quick now (and might even seem *too* quick)!
@@ -160,7 +160,7 @@ The result should confirm that 1 key was *either* imported or updated. *Ignore*
Next, you will run the *verify* command on the signature (.sig) file. (*Verify* must be run from inside the same folder that you downloaded the files into earlier.)
```
gpg --verify seedsigner.0.8.6.sha256.txt.sig
gpg --verify seedsigner.0.8.7.sha256.txt.sig
```
When the verify command completes successfully, it should display output like this:
@@ -230,22 +230,22 @@ Now that you have confirmed that you do have the real SeedSigner Project's Publi
**On Linux or OSX:** Run this command
```
shasum -a 256 --ignore-missing --check seedsigner.0.8.6.sha256.txt
shasum -a 256 --ignore-missing --check seedsigner.0.8.7.sha256.txt
```
Note: macOS versions earlier than v11 (Big Sur) do not support the `--ignore-missing` flag. You can omit it and disregard any missing file warnings.
**On Windows (inside Powershell):** Run this command
```
CertUtil -hashfile seedsigner_os.0.8.6.Insert_Your_Pi_Models_binary_here_For_Example_pi02w.img SHA256
CertUtil -hashfile seedsigner_os.0.8.7.Insert_Your_Pi_Models_binary_here_For_Example_pi02w.img SHA256
```
On Windows, you must then manually compare the resulting file hash value to the corresponding hash value shown inside the .SHA256 cleartext file.
<BR>
Wait up to 30 seconds for the command to complete, and it should display:
```
seedsigner_os.0.8.6.[Your_Pi_Model_For_Example:pi02w].img: OK
seedsigner_os.0.8.7.[Your_Pi_Model_For_Example:pi02w].img: OK
```
**If you receive the "OK" message** for your **seedsigner_os.0.8.6.[Your_Pi_Model_For_Example:pi02w].img file**, as shown above, then your verification is fully complete!
**If you receive the "OK" message** for your **seedsigner_os.0.8.7.[Your_Pi_Model_For_Example:pi02w].img file**, as shown above, then your verification is fully complete!
**All of your downloaded files have now been confirmed as both authentic and unaltered!** You can proceed to create/write your MicroSD card😄😄 !!
If your file result shows "FAILED", then you must stop here immediately. Do not continue. Contact us for assistance at the Telegram group address above.
+35 -7
View File
@@ -139,19 +139,47 @@ see: https://www.circuitbasics.com/raspberry-pi-zero-ethernet-gadget/
### Linux
To enable IP forwarding on your Host:
```bash
nano /etc/sysctl.conf
```
and uncomment the following line:
To enable internet sharing on your Linux host:
#### Step 1: Enable IP Forwarding
```bash
# Check current IP forwarding status
cat /proc/sys/net/ipv4/ip_forward
# Enable IP forwarding temporarily
sudo sh -c "echo 1 > /proc/sys/net/ipv4/ip_forward"
# Enable permanently
sudo nano /etc/sysctl.conf
```
Uncomment or add:
```
net.ipv4.ip_forward = 1
```
Save and apply:
```bash
sudo sysctl -p
```
Then run `sudo sysctl -p` to have immediate effect.
#### Step 2: Configure Network Sharing
```bash
# Assuming eth0 is your internet-connected interface and usb0 is the USB interface
sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
sudo iptables -A FORWARD -i usb0 -o eth0 -j ACCEPT
sudo iptables -A FORWARD -i eth0 -o usb0 -m state --state RELATED,ESTABLISHED -j ACCEPT
You should now be able to SSH into your internet connected Pi Zero 1.3, as described in the ["Get started"](#get-started) section.
# Save the rules
sudo apt install iptables-persistent
sudo netfilter-persistent save
sudo netfilter-persistent reload
```
#### Step 3: Test the Connection
```bash
ping 8.8.8.8
```
If you see responses, the Pi has internet access.
## Set a static IP on Linux
+125 -68
View File
@@ -1,14 +1,14 @@
# Translations template for seedsigner.
# Copyright (C) 2025 ORGANIZATION
# Copyright (C) 2026 ORGANIZATION
# This file is distributed under the same license as the seedsigner project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: seedsigner 0.8.6\n"
"Project-Id-Version: seedsigner 0.8.7\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-11-21 14:39-0600\n"
"POT-Creation-Date: 2026-03-05 22:02+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -141,19 +141,21 @@ msgstr[1] ""
msgid "{} change"
msgstr ""
#. Describes the address type (change or receive)
#: src/seedsigner/gui/screens/psbt_screens.py
#: src/seedsigner/models/settings_definition.py
#: src/seedsigner/views/seed_views.py
msgid "Multisig"
#: src/seedsigner/gui/screens/seed_screens.py
msgid "change address"
msgstr ""
#. Describes the address type (change or receive)
#: src/seedsigner/gui/screens/psbt_screens.py
msgid "Change"
#: src/seedsigner/gui/screens/seed_screens.py
msgid "receive address"
msgstr ""
#. Abbreviation for receive address
#. Symbol for index number, e.g. "address #3"
#: src/seedsigner/gui/screens/psbt_screens.py
msgid "Addr"
msgid "#"
msgstr ""
#: src/seedsigner/gui/screens/psbt_screens.py
@@ -237,14 +239,6 @@ msgid ""
"All in-memory data will be wiped."
msgstr ""
#: src/seedsigner/gui/screens/screen.py
msgid "Powering Off"
msgstr ""
#: src/seedsigner/gui/screens/screen.py
msgid "Please wait about 30 seconds before disconnecting power."
msgstr ""
#: src/seedsigner/gui/screens/screen.py
msgid "Just Unplug It"
msgstr ""
@@ -264,17 +258,6 @@ msgstr ""
msgid "fingerprint"
msgstr ""
#: src/seedsigner/gui/screens/seed_screens.py
#: src/seedsigner/views/seed_views.py
msgid "Backup Seed"
msgstr ""
#. Additional explainer for the two seed backup options (mnemonic phrase and
#. SeedQR).
#: src/seedsigner/gui/screens/seed_screens.py
msgid "Backups do not include your passphrase."
msgstr ""
#. Displays the page number and total: (e.g. page 1 of 6)
#: src/seedsigner/gui/screens/seed_screens.py
msgid "Seed Words: {}/{}"
@@ -392,16 +375,6 @@ msgstr ""
msgid "Address Verified"
msgstr ""
#. Describes the address type (change or receive)
#: src/seedsigner/gui/screens/seed_screens.py
msgid "change address"
msgstr ""
#. Describes the address type (change or receive)
#: src/seedsigner/gui/screens/seed_screens.py
msgid "receive address"
msgstr ""
#. Describes the address index (e.g. "index 7")
#: src/seedsigner/gui/screens/seed_screens.py
msgid "index {}"
@@ -613,6 +586,21 @@ msgstr ""
msgid "Required"
msgstr ""
#. QR code format option; "default" = this is the format most wallets use
#: src/seedsigner/models/settings_definition.py
msgid "Animated (default)"
msgstr ""
#. QR code format option (static = single frame, not animated)
#: src/seedsigner/models/settings_definition.py
msgid "Static"
msgstr ""
#. QR code format option: old format that Specter Desktop used to use
#: src/seedsigner/models/settings_definition.py
msgid "Specter legacy"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "BTC"
msgstr ""
@@ -681,6 +669,11 @@ msgstr ""
msgid "Single Sig"
msgstr ""
#: src/seedsigner/models/settings_definition.py
#: src/seedsigner/views/seed_views.py
msgid "Multisig"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "Native Segwit"
msgstr ""
@@ -701,6 +694,17 @@ msgstr ""
msgid "Custom Derivation"
msgstr ""
#. MicroSD notification duration setting - Display notification for 5 seconds
#: src/seedsigner/models/settings_definition.py
msgid "5 seconds"
msgstr ""
#. MicroSD notification duration setting - Display notification until the SD
#. card is removed
#: src/seedsigner/models/settings_definition.py
msgid "Until SD removed"
msgstr ""
#. Terminology used by Electrum seeds; equivalent to BIP-39 passphrase
#: src/seedsigner/models/settings_definition.py
msgid "Custom Extension"
@@ -718,10 +722,6 @@ msgstr ""
msgid "Persistent settings"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "Coordinator software"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "Denomination display"
msgstr ""
@@ -734,10 +734,6 @@ msgstr ""
msgid "QR code density"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "Xpub export"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "Sig types"
msgstr ""
@@ -746,6 +742,10 @@ msgstr ""
msgid "Script types"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "Xpub QR format"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "Show xpub details"
msgstr ""
@@ -774,6 +774,10 @@ msgstr ""
msgid "Native Segwit only"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "MicroSD notification duration"
msgstr ""
#: src/seedsigner/models/settings_definition.py
msgid "Message signing"
msgstr ""
@@ -853,7 +857,7 @@ msgid ""
"addresses."
msgstr ""
#: src/seedsigner/views/psbt_views.py
#: src/seedsigner/views/psbt_views.py src/seedsigner/views/view.py
msgid "Continue"
msgstr ""
@@ -898,7 +902,7 @@ msgid "Self-Transfer"
msgstr ""
#: src/seedsigner/views/psbt_views.py
msgid "Verify Multisig Addr"
msgid "Verify multisig addr"
msgstr ""
#: src/seedsigner/views/psbt_views.py
@@ -936,7 +940,7 @@ msgid "Approve transaction"
msgstr ""
#: src/seedsigner/views/psbt_views.py
msgid "Select diff seed"
msgid "Select different seed"
msgstr ""
#: src/seedsigner/views/psbt_views.py
@@ -1003,9 +1007,8 @@ msgstr ""
msgid "QRCode is invalid or is a data format not yet supported."
msgstr ""
#: src/seedsigner/views/scan_views.py src/seedsigner/views/seed_views.py
#: src/seedsigner/views/view.py
msgid "Done"
#: src/seedsigner/views/scan_views.py src/seedsigner/views/view.py
msgid "Back to Main Menu"
msgstr ""
#. This is on the opening splash screen, displayed above the HRF logo
@@ -1050,7 +1053,7 @@ msgid "Create a seed"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Load A Seed"
msgid "Load a Seed"
msgstr ""
#. Inserts the word number (e.g. "Seed Word #6")
@@ -1074,6 +1077,10 @@ msgstr ""
msgid "Checksum failure; not a valid seed phrase."
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Done"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Edit passphrase"
msgstr ""
@@ -1116,7 +1123,7 @@ msgid "Discard Seed?"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Electrum warning"
msgid "Electrum Warning"
msgstr ""
#: src/seedsigner/views/seed_views.py
@@ -1127,10 +1134,6 @@ msgstr ""
msgid "Scan transaction"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Verify addr"
msgstr ""
#: src/seedsigner/views/seed_views.py src/seedsigner/views/tools_views.py
msgid "Address explorer"
msgstr ""
@@ -1155,10 +1158,18 @@ msgstr ""
msgid "Export as SeedQR"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Backup Seed"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Export Xpub"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Xpub QR Format"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Xpub can be used to view all future transactions."
msgstr ""
@@ -1322,11 +1333,16 @@ msgid "Cancel"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Can't validate a single sig addr without specifying a seed"
msgid "Return to transaction"
msgstr ""
#: src/seedsigner/views/seed_views.py
msgid "Return to transaction"
msgid "Verify addr"
msgstr ""
#. Multisig policy. For a "2 of 3" policy, "threshold" = 2; "n" = 3
#: src/seedsigner/views/seed_views.py
msgid "{threshold} of {n}"
msgstr ""
#: src/seedsigner/views/seed_views.py
@@ -1349,6 +1365,24 @@ msgstr ""
msgid "Dev Options"
msgstr ""
#. Title of a warning dialog when configuring a setting that requires at least
#. one option to be selected.
#: src/seedsigner/views/settings_views.py
msgid "Selection Required"
msgstr ""
#. The name of the setting being configured (e.g. "Script types") will be
#. inserted.
#: src/seedsigner/views/settings_views.py
msgid "At least one option must be selected for \"{}\"."
msgstr ""
#. Text for the button that returns the user to the setting configuration
#. screen.
#: src/seedsigner/views/settings_views.py
msgid "Return to setting"
msgstr ""
#: src/seedsigner/views/settings_views.py
msgid "Persistent Settings enabled. Settings saved to SD card."
msgstr ""
@@ -1382,7 +1416,7 @@ msgid "24 words"
msgstr ""
#: src/seedsigner/views/tools_views.py
msgid "Mnemonic Length?"
msgid "Mnemonic Length"
msgstr ""
#: src/seedsigner/views/tools_views.py
@@ -1399,10 +1433,6 @@ msgstr ""
msgid "24 words ({} rolls)"
msgstr ""
#: src/seedsigner/views/tools_views.py
msgid "Mnemonic Length"
msgstr ""
#. Label to gather entropy through coin tosses
#: src/seedsigner/views/tools_views.py
msgid "Coin flip entropy"
@@ -1441,6 +1471,11 @@ msgstr ""
msgid "Change addresses"
msgstr ""
#. Multisig policy. For a "2 / 3 multisig" policy, "threshold" = 2; "n" = 3
#: src/seedsigner/views/tools_views.py
msgid "{threshold} / {n} multisig"
msgstr ""
#. a status message that our payment addresses are being calculated
#: src/seedsigner/views/tools_views.py
msgid "Calculating addrs..."
@@ -1509,15 +1544,27 @@ msgstr ""
msgid "Change Setting"
msgstr ""
#. Inserts mainnet/testnet/regtest and derivation path
#. "network" will be mainnet/testnet/regtest.
#: src/seedsigner/views/view.py
msgid "Current network setting ({}) doesn't match {}."
msgid "Current network setting ({network}) doesn't match {derivation_path}."
msgstr ""
#: src/seedsigner/views/view.py
msgid "System Error"
msgstr ""
#: src/seedsigner/views/view.py
msgid "Hardware Error"
msgstr ""
#: src/seedsigner/views/view.py
msgid "Cannot access camera"
msgstr ""
#: src/seedsigner/views/view.py
msgid "Disconnect power and check for a loose camera connection."
msgstr ""
#: src/seedsigner/views/view.py
msgid "Update setting"
msgstr ""
@@ -1532,3 +1579,13 @@ msgstr ""
msgid "Option Disabled"
msgstr ""
#: src/seedsigner/views/view.py
msgid "Action Required"
msgstr ""
#: src/seedsigner/views/view.py
msgid ""
"You must remove the\n"
"MicroSD card to continue."
msgstr ""
+1
View File
@@ -1 +1,2 @@
Babel==2.16.0
setuptools>=82.0.0
+1 -1
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.6"
version = "0.8.7"
[project.urls]
"Bug Tracker" = "https://github.com/SeedSigner/seedsigner/issues"
+1 -1
View File
@@ -1,5 +1,5 @@
embit==0.8.0
Pillow==10.0.1
Pillow==10.3.0
pyzbar @ git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03
qrcode==7.3.1
urtypes==1.0.1
+16 -1
View File
@@ -17,7 +17,7 @@ from seedsigner.models.settings import SettingsConstants
from seedsigner.models.singleton import Singleton
from seedsigner.models.threads import BaseThread
from seedsigner.views.screensaver import ScreensaverScreen
from seedsigner.views.view import Destination
from seedsigner.views.view import Destination, View
logger = logging.getLogger(__name__)
@@ -475,3 +475,18 @@ class Controller(Singleton):
exception_msg,
]
return Destination(UnhandledExceptionView, view_args={"error": error}, clear_history=True)
@property
def is_screensaver_start_allowed(self) -> bool:
"""
Determines whether the screensaver is allowed to start.
The screensaver can start only if:
- It is not currently running.
- The current active view allows screensaver activity.
"""
from seedsigner.views import MainMenuView
# Confusingly, the top item in the `BackStack` is actually the *current* View
active_view = self.back_stack[-1].view if self.back_stack else MainMenuView()
return not self.is_screensaver_running and active_view.is_screensaver_allowed
+108 -47
View File
@@ -52,7 +52,7 @@ class GUIConstants:
"default": "OpenSans-Regular",
SettingsConstants.LOCALE__ARABIC: "NotoSansAR-Regular",
SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: "NotoSansSC-Regular",
# SettingsConstants.LOCALE__CHINESE_TRADITIONAL: "NotoSansTC-Regular",
SettingsConstants.LOCALE__HINDI: "NotoSansDevanagari-Regular",
SettingsConstants.LOCALE__JAPANESE: "NotoSansJP-Regular",
SettingsConstants.LOCALE__KOREAN: "NotoSansKR-Regular",
SettingsConstants.LOCALE__PERSIAN: "NotoSansAR-Regular", # Uses the Arabic font
@@ -63,9 +63,10 @@ class GUIConstants:
TOP_NAV_TITLE_FONT_NAME["default"] = "OpenSans-SemiBold"
TOP_NAV_TITLE_FONT_SIZE = {
"default": 20,
SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 23, # Some chars won't render below 23px
SettingsConstants.LOCALE__HINDI: 22,
SettingsConstants.LOCALE__JAPANESE: 22, # Titles won't render below 22px
SettingsConstants.LOCALE__KOREAN: 23, # Titles won't render below 23px
SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 23, # Some chars won't render below 23px
}
TOP_NAV_HEIGHT = 48
TOP_NAV_BUTTON_SIZE = 32
@@ -73,9 +74,10 @@ class GUIConstants:
BODY_FONT_NAME = BASE_LOCALE_FONTS.copy()
BODY_FONT_SIZE = {
"default": 17,
SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 18,
SettingsConstants.LOCALE__HINDI: 18,
SettingsConstants.LOCALE__JAPANESE: 18,
SettingsConstants.LOCALE__KOREAN: 18,
SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 18,
}
BODY_FONT_MAX_SIZE = TOP_NAV_TITLE_FONT_SIZE["default"]
BODY_FONT_MIN_SIZE = 15
@@ -95,6 +97,7 @@ class GUIConstants:
"default": 18,
"ar": 18,
"fa": 18,
SettingsConstants.LOCALE__HINDI: 20,
SettingsConstants.LOCALE__JAPANESE: 20,
SettingsConstants.LOCALE__KOREAN: 20,
SettingsConstants.LOCALE__CHINESE_SIMPLIFIED: 20,
@@ -247,11 +250,19 @@ def load_image(image_name: str) -> Image.Image:
class Fonts(Singleton):
font_path = os.path.join(
pathlib.Path(__file__).parent.resolve().parent.resolve(),
"resources",
"fonts"
)
font_paths = [
os.path.join(
pathlib.Path(__file__).parent.resolve().parent.resolve(),
"resources",
"fonts"
),
os.path.join(
pathlib.Path(__file__).parent.resolve().parent.resolve(),
"resources",
"seedsigner-translations",
"fonts"
)
]
fonts = {}
@classmethod
@@ -264,13 +275,24 @@ class Fonts(Singleton):
file_extension = "otf"
if size not in cls.fonts[font_name]:
try:
cls.fonts[font_name][size] = ImageFont.truetype(os.path.join(cls.font_path, f"{font_name}.{file_extension}"), size)
except OSError as e:
if "cannot open resource" in str(e):
raise Exception(f"Font {font_name}.{file_extension} not found: {repr(e)}")
# loop over possible font locations and attempt to load font object
# TODO: single location for all font files
captured_exception = None
for font_path in cls.font_paths:
try:
cls.fonts[font_name][size] = ImageFont.truetype(os.path.join(font_path, f"{font_name}.{file_extension}"), size)
break
except OSError as e:
captured_exception = e
continue
# throw error at this point if font object was unable to be loaded
if size not in cls.fonts[font_name]:
if "cannot open resource" in str(captured_exception):
raise Exception(f"Font {font_name}.{file_extension} not found: {repr(captured_exception)}")
else:
raise e
raise captured_exception
return cls.fonts[font_name][size]
@@ -389,7 +411,13 @@ class TextArea(BaseComponent):
# Guaranteed to be a single line of text, possibly wider than self.width
self.text_lines = [{"text": self.text, "text_width": full_text_width}]
self.text_width = full_text_width
if self.text_width > self.visible_width:
# Technically, the math says that we should enable scrolling for as little as
# 1px beyond the available width, but it's not worth having the text twitch
# back and forth by such a small amount.
min_scrollable_diff = 1
if self.text_width > self.visible_width + min_scrollable_diff:
# We'll have to left justify the text and scroll it (if scrolling is enabled,
# otherwise it'll just run off the right edge).
self.is_text_centered = False
@@ -566,7 +594,7 @@ class TextArea(BaseComponent):
self.horizontal_scroll_position = 0
self.scroll_increment_sign = 1 # flip to negative to scroll text to the right
self.renderer = Renderer.get_instance()
self.renderer = Renderer.get_instance()
def stop_scrolling(self):
@@ -587,45 +615,57 @@ class TextArea(BaseComponent):
readability. 45px/sec is better but still perceptually a bit stuttery.
"""
max_scroll = self.rendered_text_img.width - self.visible_width
last_render_time = None
# The scrolling pauses at the start and end of the text line. These vars track
# when we started holding and how long we should hold for.
hold_started_at = None
cur_hold_duration = None
while self.keep_running:
if not self.scrolling_active:
time.sleep(0.1)
continue
with self.renderer.lock:
if not self.scrolling_active:
# We were stopped while waiting for the lock
if cur_hold_duration is not None:
# We're currently holding; see if we've held long enough
hold_time_elapsed = time.time() - hold_started_at
if hold_time_elapsed < cur_hold_duration:
# Still have to hold longer; skip scrolling logic
time.sleep(0.1)
continue
else:
# We've held long enough; reset the vars and resume scrolling
hold_started_at = None
cur_hold_duration = None
else:
# We're not holding, but if we've reached either end, we need to start
# holding.
if self.horizontal_scroll_position == 0:
# Pause on initial (left-justified) position...
hold_started_at = time.time()
cur_hold_duration = self.begin_hold_secs
# Next scroll direction will be left
self.scroll_increment_sign = 1
# Don't count those pause seconds
last_render_time = None
continue
img = self.rendered_text_img.crop((self.horizontal_scroll_position, 0, self.horizontal_scroll_position + self.visible_width, self.rendered_text_img.height))
self.renderer.canvas.paste(img, (self.screen_x, self.screen_y - self.scroll_y))
self.renderer.show_image()
elif self.horizontal_scroll_position == max_scroll:
# ...and slight pause at end of scroll
hold_started_at = time.time()
cur_hold_duration = self.end_hold_secs
if self.horizontal_scroll_position == 0:
# Pause on initial (left-justified) position...
time.sleep(self.begin_hold_secs)
# Don't count those pause seconds
last_render_time = None
# Don't count those pause seconds
last_render_time = None
# Scroll will be to the right
self.scroll_increment_sign = -1
continue
# Scroll the text left
self.scroll_increment_sign = 1
elif self.horizontal_scroll_position == max_scroll:
# ...and slight pause at end of scroll
time.sleep(self.end_hold_secs)
# Don't count those pause seconds
last_render_time = None
# Scroll the text right
self.scroll_increment_sign = -1
else:
# No need to CPU limit when running in its own thread?
time.sleep(0.02)
next_render_time = time.time()
if not last_render_time:
@@ -633,18 +673,39 @@ class TextArea(BaseComponent):
# "get off zero" for the real increment calc logic to kick in.
scroll_position_increment = 1 * self.scroll_increment_sign
else:
# Calculate how far to scroll based on time elapsed since last render
scroll_position_increment = int(self.horizontal_scroll_speed * (next_render_time - last_render_time) * self.scroll_increment_sign)
# Only render an update if we're going to move at least 1px
if abs(scroll_position_increment) > 0:
self.horizontal_scroll_position += scroll_position_increment
self.horizontal_scroll_position = max(0, min(self.horizontal_scroll_position, max_scroll))
# max: Don't over-scroll when returning to the left edge (0)
# min: Don't over-scroll when revealing the right edge (max_scroll)
self.horizontal_scroll_position = max(
0,
min(self.horizontal_scroll_position + scroll_position_increment, max_scroll)
)
# Render the scroll update
with self.renderer.lock:
if not self.scrolling_active:
# We were stopped while waiting for the lock
continue
# The pre-rendered text img slides within a cropping window
img = self.rendered_text_img.crop((self.horizontal_scroll_position, 0, self.horizontal_scroll_position + self.visible_width, self.rendered_text_img.height))
self.renderer.canvas.paste(img, (self.screen_x, self.screen_y - self.scroll_y))
self.renderer.show_image()
last_render_time = next_render_time
else:
# Wait to accumulate more time before scrolling
# Wait to accumulate more time so we can scroll at least 1px
pass
# Free up the processor for a bit each loop
time.sleep(0.02)
def render(self):
"""
Even if we need to animate for scrolling, all instances should explicitly render
+27 -3
View File
@@ -34,10 +34,34 @@ class Keyboard:
KEY_BACKSPACE = {
"code": "DEL",
"letter": SeedSignerIconConstants.DELETE,
"font": ICON_KEY_FONT,
"size": 2,
"letter": SeedSignerIconConstants.DELETE,
"font": ICON_KEY_FONT,
"size": 3,
}
KEY_BACKSPACE_2 = {
"code": "DEL",
"letter": SeedSignerIconConstants.DELETE,
"font": ICON_KEY_FONT,
"size": 2,
}
KEY_BACKSPACE_4 = {
"code": "DEL",
"letter": SeedSignerIconConstants.DELETE,
"font": ICON_KEY_FONT,
"size": 4,
}
KEY_BACKSPACE_5 = {
"code": "DEL",
"letter": SeedSignerIconConstants.DELETE,
"font": ICON_KEY_FONT,
"size": 5,
}
KEY_BACKSPACE_6 = {
"code": "DEL",
"letter": SeedSignerIconConstants.DELETE,
"font": ICON_KEY_FONT,
"size": 6,
}
KEY_SPACE = {
"code": "SPACE",
+7 -4
View File
@@ -1,9 +1,7 @@
from PIL import Image, ImageDraw
from threading import Lock
# from seedsigner.hardware.st7789_mpy import ST7789
from seedsigner.hardware.displays.display_driver import ALL_DISPLAY_TYPES, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486, DISPLAY_TYPE__ST7789, DisplayDriver
from seedsigner.hardware.displays.ili9341 import ILI9341, ILI9341_TFTWIDTH, ILI9341_TFTHEIGHT
from seedsigner.hardware.displays.display_driver import ALL_DISPLAY_TYPES, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486, DISPLAY_TYPE__ST7789, DisplayDriverFactory
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.models.singleton import ConfigurableSingleton
@@ -45,7 +43,12 @@ class Renderer(ConfigurableSingleton):
raise Exception(f"Invalid display type: {self.display_type}")
width, height = display_config.split("_")[1].split("x")
self.disp = DisplayDriver(self.display_type, width=int(width), height=int(height))
if self.disp:
# Existing instances might need to close resources like pwm
self.disp.cleanup()
self.disp = DisplayDriverFactory.instantiate_display_driver(self.display_type, width=int(width), height=int(height))
if Settings.get_instance().get_value(SettingsConstants.SETTING__DISPLAY_COLOR_INVERTED, default_if_none=True) == SettingsConstants.OPTION__ENABLED:
self.disp.invert()
+3
View File
@@ -1129,6 +1129,8 @@ class KeyboardScreen(BaseTopNavScreen):
return_after_n_chars: int = None
show_save_button: bool = False
initial_value: str = ""
from dataclasses import dataclass, field
custom_additional_keys: dict = field(default_factory=lambda: Keyboard.ADDITIONAL_KEYS)
def __post_init__(self):
if self.keyboard_font_size is None:
@@ -1187,6 +1189,7 @@ class KeyboardScreen(BaseTopNavScreen):
GUIConstants.EDGE_PADDING + self.keyboard_width,
keyboard_start_y + self.rows * self.key_height + (self.rows - 1) * 2
),
additional_keys=self.custom_additional_keys,
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
)
+5 -2
View File
@@ -60,6 +60,7 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen):
GUIConstants.EDGE_PADDING + self.keyboard_width,
self.canvas_height
),
additional_keys=[Keyboard.KEY_BACKSPACE_4],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT]
)
@@ -547,6 +548,7 @@ class SeedBIP85SelectChildIndexScreen(KeyboardScreen):
self.cols = 5
self.keys_charset = "0123456789"
self.show_save_button = True
self.custom_additional_keys = [Keyboard.KEY_BACKSPACE_5]
super().__post_init__()
@@ -579,6 +581,7 @@ class SeedExportXpubCustomDerivationScreen(KeyboardScreen):
self.cols = 6
self.keys_charset = "/'0123456789"
self.show_save_button = True
self.custom_additional_keys = [Keyboard.KEY_BACKSPACE_6]
super().__post_init__()
@@ -757,7 +760,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen):
Keyboard.KEY_SPACE_2,
Keyboard.KEY_CURSOR_LEFT,
Keyboard.KEY_CURSOR_RIGHT,
Keyboard.KEY_BACKSPACE
Keyboard.KEY_BACKSPACE_2
],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
@@ -778,7 +781,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen):
Keyboard.KEY_SPACE_2,
Keyboard.KEY_CURSOR_LEFT,
Keyboard.KEY_CURSOR_RIGHT,
Keyboard.KEY_BACKSPACE
Keyboard.KEY_BACKSPACE_2
],
auto_wrap=[Keyboard.WRAP_LEFT, Keyboard.WRAP_RIGHT],
render_now=False
@@ -145,7 +145,11 @@ class IOTestScreen(BaseTopNavScreen):
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
if not self.renderer.is_screenshot_generator:
# The button text should be empty for its initial state ("Clear" doesn't make
# any sense until a test image is captured). But the screenshot generator
# should show the text so its translation can be reviewed.
self.key2_button.text = " "
self.components.append(self.key2_button)
self.key1_button = IconButton(
+4 -1
View File
@@ -11,6 +11,7 @@ from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConsta
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, ButtonListScreen, ButtonOption, KeyboardScreen
from seedsigner.hardware.buttons import HardwareButtonsConstants
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.gui.keyboard import Keyboard
@@ -163,6 +164,7 @@ class ToolsDiceEntropyEntryScreen(KeyboardScreen):
def __post_init__(self):
# 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)
self.custom_additional_keys = [Keyboard.KEY_BACKSPACE]
# Specify the keys in the keyboard
self.rows = 3
@@ -226,6 +228,7 @@ class ToolsCoinFlipEntryScreen(KeyboardScreen):
# Override values set by the parent class
# 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)
self.custom_additional_keys = [Keyboard.KEY_BACKSPACE_2]
# Specify the keys in the keyboard
self.rows = 1
@@ -489,7 +492,7 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
self.components.append(IconTextLine(
# TRANSLATOR_NOTE: a label for a BIP-380-ish Output Descriptor
label_text=_("Wallet descriptor"),
value_text=self.wallet_descriptor_display_name, # TODO: English text from embit (e.g. "1 / 2 multisig"); make l10 friendly
value_text=self.wallet_descriptor_display_name,
is_text_centered=True,
screen_x=GUIConstants.EDGE_PADDING,
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
+3 -3
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
class HardwareButtons(Singleton):
if GPIO.RPI_INFO['P1_REVISION'] == 3: #This indicates that we have revision 3 GPIO
logger.info("Detected 40pin GPIO (Rasbperry Pi 2 and above)")
logger.info("Detected 40pin GPIO (Raspberry Pi 2 and above)")
KEY_UP_PIN = 31
KEY_DOWN_PIN = 35
KEY_LEFT_PIN = 29
@@ -22,7 +22,7 @@ class HardwareButtons(Singleton):
KEY3_PIN = 36
else:
logger.info("Assuming 26 Pin GPIO (Raspberry P1 1)")
logger.info("Assuming 26 Pin GPIO (Raspberry Pi 1)")
KEY_UP_PIN = 5
KEY_DOWN_PIN = 11
KEY_LEFT_PIN = 3
@@ -82,7 +82,7 @@ class HardwareButtons(Singleton):
return HardwareButtonsConstants.OVERRIDE
cur_time = int(time.time() * 1000)
if cur_time - self.last_input_time > controller.screensaver_activation_ms and not controller.is_screensaver_running:
if cur_time - self.last_input_time > controller.screensaver_activation_ms and controller.is_screensaver_start_allowed:
# Start the screensaver. Will block execution until input detected.
controller.start_screensaver()
+1 -3
View File
@@ -9,9 +9,7 @@ from seedsigner.models.singleton import Singleton
class CameraConnectionError(Exception):
def __init__(self, *args, **kwargs):
message = _("Camera error. Check camera connections.")
super().__init__(message)
pass
+13 -6
View File
@@ -2,16 +2,23 @@ import spidev
import RPi.GPIO as GPIO
import time
import array
from dataclasses import dataclass
from seedsigner.hardware.displays.display_driver import BaseDisplayDriver
class ST7789(object):
"""class for ST7789 240*240 1.3inch OLED displays."""
@dataclass
class ST7789(BaseDisplayDriver):
"""
The original SeedSigner display driver.
def __init__(self):
self.width = 240
self.height = 240
Note that self._width and self._height are provided by the parent DisplayDriver class
and are set during instantiation via the DisplayDriverFactory.
class for ST7789 240*240 1.3inch OLED displays.
"""
def __post_init__(self):
#Initialize DC RST pin
self._dc = 22
self._rst = 13
@@ -41,7 +48,7 @@ class ST7789(object):
self._spi.writebytes([val])
def init(self):
"""Initialize dispaly"""
"""Initialize display"""
self.reset()
self.command(0x36)
@@ -1,17 +1,71 @@
from dataclasses import dataclass
DISPLAY_TYPE__ST7789 = "st7789"
DISPLAY_TYPE__ILI9341 = "ili9341"
DISPLAY_TYPE__ILI9486 = "ili9486"
ALL_DISPLAY_TYPES = [DISPLAY_TYPE__ST7789, DISPLAY_TYPE__ILI9341, DISPLAY_TYPE__ILI9486]
class DisplayDriver:
def __init__(self, display_type: str = DISPLAY_TYPE__ST7789, width: int = None, height: int = None):
@dataclass
class BaseDisplayDriver:
_width: int
_height: int
def __str__(self):
return f"DisplayDriver(display_type={getattr(self, 'display_type', None)}, width={self.width}, height={self.height})"
@property
def width(self):
return self._width
@property
def height(self):
return self._height
def invert(self, enabled: bool = True):
"""
Invert how the display interprets colors.
Implementation in child classes is optional.
"""
pass
def show_image(self, image, x_start: int = 0, y_start: int = 0):
"""
The main rendering call to the display driver.
Must be implemented in child classes.
"""
raise Exception("show_image() must be implemented in child classes")
def cleanup(self):
"""
Cleanup resources used by the display driver.
Implementation in child classes is optional.
"""
pass
class DisplayDriverFactory:
"""
Manages all logic related to instantiating display drivers based on type and resolution.
Imports for specific display drivers are done within this class to avoid circular imports.
"""
@classmethod
def instantiate_display_driver(cls, display_type: str = DISPLAY_TYPE__ST7789, width: int = None, height: int = None) -> BaseDisplayDriver:
if display_type not in ALL_DISPLAY_TYPES:
raise ValueError(f"Invalid display type: {display_type}")
self.display_type = display_type
if self.display_type == DISPLAY_TYPE__ST7789:
if display_type == DISPLAY_TYPE__ST7789:
if width not in [240, 320] or height != 240:
raise ValueError("ST7789 display only supports 240x240 or 320x240 resolutions")
@@ -19,42 +73,20 @@ class DisplayDriver:
# TODO: For now the original ST7789 driver has to be used for 240x240.
# The mpy version below renders incorrectly (almost like each row of pixels
# is one pixel short, so the entire screen exhibits a diagonal skew).
from seedsigner.hardware.displays.ST7789 import ST7789
self.display = ST7789()
from seedsigner.hardware.displays.ST7789 import ST7789 as original_ST7789
return original_ST7789(_width=width, _height=height)
elif width == 320:
from seedsigner.hardware.displays.st7789_mpy import ST7789
from seedsigner.hardware.displays.st7789_mpy import ST7789 as mpy_ST7789
# Have to swap width and height; screen is natively 240x320
self.display = ST7789(width=height, height=width)
elif self.display_type == DISPLAY_TYPE__ILI9341:
return mpy_ST7789(_width=height, _height=width)
elif display_type == DISPLAY_TYPE__ILI9341:
from seedsigner.hardware.displays.ili9341 import ILI9341
self.display = ILI9341()
self.display.begin()
display = ILI9341(_width=width, _height=height)
display.begin()
return display
elif self.display_type == DISPLAY_TYPE__ILI9486:
elif display_type == DISPLAY_TYPE__ILI9486:
# TODO: improve performance of ili9486 driver
raise Exception("ILI9486 display not implemented yet")
def __str__(self):
return f"DisplayDriver(display_type={self.display_type}, width={self.width}, height={self.height})"
@property
def width(self):
return self.display.width
@property
def height(self):
return self.display.height
def invert(self, enabled: bool = True):
"""Invert how the display interprets colors"""
self.display.invert(enabled)
def show_image(self, image, x_start: int = 0, y_start: int = 0):
self.display.show_image(image, x_start, y_start)
+15 -22
View File
@@ -29,12 +29,16 @@ import time
# import numpy as np
import array
from dataclasses import dataclass
from PIL import Image
from PIL import ImageDraw
import RPi.GPIO as GPIO
from spidev import SpiDev
from seedsigner.hardware.displays.display_driver import BaseDisplayDriver
# Constants for interacting with display registers.
ILI9341_TFTWIDTH = 240
@@ -129,16 +133,15 @@ def image_to_data(image):
return arr.tobytes()
class ILI9341(object):
@dataclass
class ILI9341(BaseDisplayDriver):
"""Representation of an ILI9341 TFT LCD."""
def __init__(self, dc=22, rst=13, led=12, width=ILI9341_TFTWIDTH,
height=ILI9341_TFTHEIGHT, rotation=90):
"""Create an instance of the display using SPI communication. Must
provide the GPIO pin number for the D/C pin and the SPI driver. Can
optionally provide the GPIO pin number for the reset pin as the rst
parameter.
"""
def __post_init__(self):
dc=22
rst=13
led=12
rotation=90
spi = SpiDev(0, 0)
# spi.mode = 0b10 # [CPOL|CPHA] -> polarity 1, phase 0
spi.max_speed_hz = 64_000_000
@@ -146,8 +149,6 @@ class ILI9341(object):
self._dc = dc
self._rst = rst
self._spi = spi
self.width = width
self.height = height
self.rotation = rotation
self.inverted = False
# if self._gpio is None:
@@ -165,15 +166,7 @@ class ILI9341(object):
GPIO.output(self._rst, GPIO.HIGH)
# Create an image buffer.
self.buffer = Image.new('RGB', (width, height))
# @property
# def width(self):
# return self.width
# @property
# def height(self):
# return self.height
self.buffer = Image.new('RGB', (self.width, self.height))
def send(self, data, is_data=True, chunk_size=4096):
@@ -310,15 +303,15 @@ class ILI9341(object):
self.reset()
self._init()
def invert(self, state: bool = True):
def invert(self, enabled: bool = True):
"""Sets display inversion to the specified state. If not provided, state
is True, which inverts the display. If state is False, the display turns
back into normal mode."""
if state:
if enabled:
self.command(ILI9341_INVON)
else:
self.command(ILI9341_INVOFF)
self.inverted = state
self.inverted = enabled
return self
def set_window(self, x0=0, y0=0, x1=None, y1=None):
+21 -21
View File
@@ -54,8 +54,11 @@ import array
import spidev
import RPi.GPIO as GPIO
from dataclasses import dataclass
from math import sin, cos
from seedsigner.hardware.displays.display_driver import BaseDisplayDriver
#
# This allows sphinx to build the docs
#
@@ -228,7 +231,9 @@ def color565(red, green=0, blue=0):
return (red & 0xF8) << 8 | (green & 0xFC) << 3 | blue >> 3
class ST7789:
@dataclass
class ST7789(BaseDisplayDriver):
"""
ST7789 driver class
@@ -262,20 +267,15 @@ class ST7789:
"""
def __init__(
self,
# spi,
width,
height,
reset=13,
dc=22,
cs=None,
backlight=18,
rotation=1,
color_order=BGR,
custom_init=None,
custom_rotations=None,
):
def __post_init__(self):
reset=13
dc=22
cs=None
backlight=18
rotation=1
color_order=BGR
custom_init=None
custom_rotations=None
GPIO.setmode(GPIO.BOARD)
GPIO.setwarnings(False)
@@ -290,20 +290,20 @@ class ST7789:
"""
Initialize display.
"""
self.rotations = custom_rotations or self._find_rotations(width, height)
self.rotations = custom_rotations or self._find_rotations(self.width, self.height)
if not self.rotations:
supported_displays = ", ".join(
[f"{display[0]}x{display[1]}" for display in _SUPPORTED_DISPLAYS]
)
raise ValueError(
f"Unsupported {width}x{height} display. Supported displays: {supported_displays}"
f"Unsupported {self.width}x{self.height} display. Supported displays: {supported_displays}"
)
if dc is None:
raise ValueError("dc pin is required.")
self.physical_width = self.width = width
self.physical_height = self.height = height
self.physical_width = self.width
self.physical_height = self.height
self.xstart = 0
self.ystart = 0
self.spi = spi
@@ -445,8 +445,8 @@ class ST7789:
self._rotation = rotation
(
madctl,
self.width,
self.height,
self._width, # have to use the settable internal vars
self._height, # have to use the settable internal vars
self.xstart,
self.ystart,
self.needs_swap,
+1 -1
View File
@@ -42,7 +42,7 @@ class PiVideoStream:
self.rawCapture.truncate(0)
# if the thread indicator variable is set, stop the thread
# and resource camera resources
# and release camera resources
if self.should_stop:
logger.info("PiVideoStream: closing everything")
self.stream.close()
+8
View File
@@ -104,6 +104,14 @@ def get_multisig_address(descriptor: Descriptor, index: int = 0, is_change: bool
def get_multisig_policy(descriptor: Descriptor) -> tuple:
"""Extract (threshold, n) from a basic multisig descriptor."""
if not descriptor.is_basic_multisig:
raise ValueError(f"Expected a basic multisig descriptor, got: {descriptor.brief_policy}")
return (str(descriptor.miniscript.args[0]), str(len(descriptor.keys)))
def get_embit_network_name(settings_name):
""" Convert SeedSigner SettingsConstants for `network` to embit's NETWORK key """
lookup = {
+5 -1
View File
@@ -238,7 +238,11 @@ class BaseSimpleAnimatedQREncoder(BaseQrEncoder):
@dataclass
class SpecterXPubQrEncoder(BaseSimpleAnimatedQREncoder, BaseXpubQrEncoder):
class SpecterLegacyXPubQrEncoder(BaseSimpleAnimatedQREncoder, BaseXpubQrEncoder):
"""
Legacy "pXofY" format. Included here for compatibility with much older versions of
Specter Desktop. Can probably eventually be removed.
"""
@property
def qr_max_fragment_size(self):
density_mapping = {
+12 -2
View File
@@ -81,6 +81,10 @@ class Settings(Singleton):
for entry in data.split()[split_index:]:
abbreviated_name, value = entry.split("=")
# Empty values ("some_setting= other_setting=E") are invalid
if value == "":
raise InvalidSettingsQRData(f"{abbreviated_name} cannot be empty")
# Parse multi-value settings; integer-ize where needed
if "," in value:
values_updated = []
@@ -103,6 +107,7 @@ class Settings(Singleton):
values = [value]
else:
values = value
for v in values:
if v not in [opt[0] for opt in settings_entry.selection_options]:
if settings_entry.attr_name == SettingsConstants.SETTING__PERSISTENT_SETTINGS and v == SettingsConstants.OPTION__ENABLED:
@@ -154,8 +159,13 @@ class Settings(Singleton):
# Clean the incoming data, if necessary
if entry.type == SettingsConstants.TYPE__MULTISELECT:
if type(new_settings[entry.attr_name]) == str:
# Break comma-separated SettingsQR input into List
new_settings[entry.attr_name] = new_settings[entry.attr_name].split(",")
# Break comma-separated multiselect options into List; avoid empty
# values.
new_settings[entry.attr_name] = [value for value in new_settings[entry.attr_name].split(",") if value.strip()]
if not new_settings[entry.attr_name]:
# Multiselect cannot be empty; load defaults to avoid issues
new_settings[entry.attr_name] = entry.default_value
for key, value in new_settings.items():
self.set_value(key, value)
+67 -74
View File
@@ -1,4 +1,5 @@
import os
import pathlib
from dataclasses import dataclass
from typing import Any, List
@@ -38,17 +39,18 @@ class SettingsConstants:
]
# User-facing selection options
COORDINATOR__BLUE_WALLET = "bw"
COORDINATOR__NUNCHUK = "nun"
COORDINATOR__SPARROW = "spa"
COORDINATOR__SPECTER_DESKTOP = "spd"
COORDINATOR__KEEPER = "kpr"
ALL_COORDINATORS = [
(COORDINATOR__BLUE_WALLET, "BlueWallet"),
(COORDINATOR__NUNCHUK, "Nunchuk"),
(COORDINATOR__SPARROW, "Sparrow"),
(COORDINATOR__SPECTER_DESKTOP, "Specter Desktop"),
(COORDINATOR__KEEPER, "Keeper"),
XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT = "urca"
XPUB_QR_FORMAT__STATIC = "sta"
XPUB_QR_FORMAT__SPECTER_LEGACY = "spl"
ALL_XPUB_QR_FORMATS = [
# TRANSLATOR_NOTE: QR code format option; "default" = this is the format most wallets use
(XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT, _mft("Animated (default)")),
# TRANSLATOR_NOTE: QR code format option (static = single frame, not animated)
(XPUB_QR_FORMAT__STATIC, _mft("Static")),
# TRANSLATOR_NOTE: QR code format option: old format that Specter Desktop used to use
(XPUB_QR_FORMAT__SPECTER_LEGACY, _mft("Specter legacy")),
]
# Over-specifying current and possible future locales to reduce/eliminate main repo
@@ -128,10 +130,23 @@ class SettingsConstants:
LOCALE__DUTCH: "Nederlands",
# --------- Beta languages ------------------------------------------------------
LOCALE__CZECH: "(beta) Čeština",
LOCALE__CHINESE_SIMPLIFIED: "(beta) 简体中文 (Chinese Simplified)",
LOCALE__HINDI: "(beta) हिन्दी (Hindi)",
LOCALE__INDONESIAN: "(beta) Indonesia (Indonesian)",
LOCALE__JAPANESE: "(beta) 日本語 (Japanese)",
LOCALE__KOREAN: "(beta) 한국어 (Korean)",
LOCALE__PERSIAN: "(beta) فارسی (Persian)",
LOCALE__POLISH: "(beta) Polski",
LOCALE__PORTUGUESE_BR: "(beta) Português (Brasil)",
LOCALE__THAI: "(beta) ไทย (Thai)",
LOCALE__VIETNAMESE: "(beta) Tiếng Việt (Vietnamese)",
# --------- In Progress Languages -----------------------------------------------
LOCALE__GREEK: "(incomplete) Ελληνικά (Greek)", # OpenSans includes Greek chars
LOCALE__NORWEGIAN: "(incomplete) Norsk (Norwegian)",
LOCALE__RUSSIAN: "(incomplete) русский (Russian)", # OpenSans includes cyrillic chars
LOCALE__TURKISH: "(incomplete) Türkçe (Turkish)",
# --------- Placeholders / Coming soon ------------------------------------------
# Commented out options require explicit additional font support.
@@ -139,19 +154,15 @@ class SettingsConstants:
LOCALE__ARABIC: "العربية (Arabic)",
# LOCALE__BENGALI: "বাংলা (Bengali)",
LOCALE__BULGARIAN: "български (Bulgarian)", # OpenSans includes cyrillic chars
LOCALE__CZECH: "čeština",
# LOCALE__CHINESE_TRADITIONAL: "繁體中文 (Chinese Traditional)",
LOCALE__DANISH: "Dansk",
LOCALE__ESTONIAN: "Eesti",
# LOCALE__EGYPTIAN: "مصرى (Egyptian)",
LOCALE__GAELIC: "Gaeilge",
LOCALE__GREEK: "Ελληνικά (Greek)", # OpenSans includes Greek chars
# LOCALE__GUJARATI: "ગુજરાતી (Gujarati)",
LOCALE__HAUSA: "Hausa",
# LOCALE__HEBREW: "עברית (Hebrew)",
# LOCALE__HINDI: "हिन्दी (Hindi)",
LOCALE__CROATIAN: "Hrvatski",
LOCALE__INDONESIAN: "Indonesia",
LOCALE__JAVANESE: "Jawa (Javanese)",
# LOCALE__LAO: "ລາວ (Lao)",
LOCALE__LATVIAN: "Latviešu",
@@ -160,14 +171,9 @@ class SettingsConstants:
LOCALE__MALAY: "Melayu",
LOCALE__MALTESE: "Malti",
# LOCALE__MARATHI: "मराठी (Marathi)",
LOCALE__NORWEGIAN: "Norsk",
LOCALE__PERSIAN: "فارسی (Persian)",
LOCALE__POLISH: "Polski",
LOCALE__PORTUGUESE_BR: "Português (Brasil)",
LOCALE__PORTUGUESE_PT: "Português (Portugal)",
# LOCALE__PUNJABI: "ਪੰਜਾਬੀ (Punjabi)",
LOCALE__ROMANIAN: "Română",
LOCALE__RUSSIAN: "русский (Russian)", # OpenSans includes cyrillic chars
LOCALE__SLOVAK: "Slovenčina",
LOCALE__SLOVENIAN: "Slovenščina",
LOCALE__FINNISH: "Suomi",
@@ -175,12 +181,11 @@ class SettingsConstants:
LOCALE__TAGALOG: "Tagalog",
# LOCALE__TAMIL: "தமிழ் (Tamil)",
# LOCALE__TELUGU: "తెలుగు (Telugu)",
LOCALE__TURKISH: "Türkçe",
LOCALE__UKRANIAN: "українська (Ukranian)", # OpenSans includes cyrillic chars
# LOCALE__URDU: "اردو (Urdu)",
LOCALE__VIETNAMESE: "Tiếng Việt (Vietnamese)",
}
@classmethod
def get_detected_languages(cls) -> list[tuple[str, str]]:
"""
@@ -188,22 +193,20 @@ class SettingsConstants:
Scans the filesystem to autodiscover which language codes are onboard.
"""
# Will normally be the launch dir (where main.py is located)...
cwd = os.getcwd()
# ...except when running the tests which happens one dir higher
if "src" not in cwd:
cwd = os.path.join(cwd, "src")
# Back out from the models/ dir to reach the seedsigner root
models_dir = pathlib.Path(__file__).parent.resolve()
seedsigner_root = models_dir.parent.resolve()
# Pre-load English since there's no "en" entry in the translations folder; also
# it should always appear first in the list anyway.
detected_languages = [(cls.LOCALE__ENGLISH, cls.ALL_LOCALES[cls.LOCALE__ENGLISH])]
locales_present = set()
for root, dirs, files in os.walk(os.path.join(cwd, "seedsigner", "resources", "seedsigner-translations", "l10n")):
for root, dirs, files in os.walk(os.path.join(seedsigner_root, "resources", "seedsigner-translations", "l10n")):
for file in [f for f in files if f.endswith(".mo")]:
# `root` will be [...]seedsigner/resources/seedsigner-translations/l10n/pt_BR/LC_MESSAGES
locales_present.add(root.split(f"l10n{ os.sep }")[1].split(os.sep)[0])
# Isolate the language code from the path
locales_present.add(root.rsplit(os.sep, 2)[-2])
for locale in cls.ALL_LOCALES.keys():
if locale in locales_present:
@@ -297,9 +300,11 @@ class SettingsConstants:
MICROSD_TOAST_TIMER_FIVE_SECONDS = "E"
MICROSD_TOAST_TIMER_FOREVER = "inf"
ALL_MICROSD_TOAST_TIMERS = [
(MICROSD_TOAST_TIMER_DISABLED, "Disabled"),
(MICROSD_TOAST_TIMER_FIVE_SECONDS, "5 seconds"),
(MICROSD_TOAST_TIMER_FOREVER, "Until SD removed")
(MICROSD_TOAST_TIMER_DISABLED, _mft("Disabled")),
# TRANSLATOR_NOTE: MicroSD notification duration setting - Display notification for 5 seconds
(MICROSD_TOAST_TIMER_FIVE_SECONDS, _mft("5 seconds")),
# TRANSLATOR_NOTE: MicroSD notification duration setting - Display notification until the SD card is removed
(MICROSD_TOAST_TIMER_FOREVER, _mft("Until SD removed"))
]
WORDLIST_LANGUAGE__ENGLISH = "en"
@@ -326,7 +331,7 @@ class SettingsConstants:
SETTING__LOCALE = "locale"
SETTING__WORDLIST_LANGUAGE = "wordlist_language"
SETTING__PERSISTENT_SETTINGS = "persistent_settings"
SETTING__COORDINATORS = "coordinators"
SETTING__XPUB_QR_FORMAT = "xpub_qr"
SETTING__BTC_DENOMINATION = "denomination"
SETTING__DISPLAY_CONFIGURATION = "display_config"
@@ -334,7 +339,6 @@ class SettingsConstants:
SETTING__NETWORK = "network"
SETTING__QR_DENSITY = "qr_density"
SETTING__XPUB_EXPORT = "xpub_export"
SETTING__SIG_TYPES = "sig_types"
SETTING__SCRIPT_TYPES = "script_types"
SETTING__XPUB_DETAILS = "xpub_details"
@@ -451,6 +455,9 @@ class SettingsEntry:
elif type(self.default_value) == tuple:
self.default_value = self.default_value[0]
if not self.abbreviated_name:
self.abbreviated_name = self.attr_name
@property
def selection_options_display_names(self) -> List[str]:
@@ -569,19 +576,6 @@ class SettingsDefinition:
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=_mft("Coordinator software"),
type=SettingsConstants.TYPE__MULTISELECT,
selection_options=SettingsConstants.ALL_COORDINATORS,
default_value=[
SettingsConstants.COORDINATOR__BLUE_WALLET,
SettingsConstants.COORDINATOR__NUNCHUK,
SettingsConstants.COORDINATOR__SPARROW,
SettingsConstants.COORDINATOR__SPECTER_DESKTOP,
]),
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
attr_name=SettingsConstants.SETTING__BTC_DENOMINATION,
abbreviated_name="denom",
@@ -608,12 +602,6 @@ class SettingsDefinition:
selection_options=SettingsConstants.ALL_DENSITIES,
default_value=SettingsConstants.DENSITY__MEDIUM),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__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",
@@ -632,6 +620,17 @@ class SettingsDefinition:
selection_options=SettingsConstants.ALL_SCRIPT_TYPES,
default_value=[SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TAPROOT]),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__XPUB_QR_FORMAT,
display_name=_mft("Xpub QR format"),
visibility=SettingsConstants.VISIBILITY__ADVANCED,
type=SettingsConstants.TYPE__MULTISELECT,
selection_options=SettingsConstants.ALL_XPUB_QR_FORMATS,
default_value=[
SettingsConstants.XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT,
SettingsConstants.XPUB_QR_FORMAT__STATIC,
]),
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__XPUB_DETAILS,
display_name=_mft("Show xpub details"),
@@ -678,7 +677,7 @@ class SettingsDefinition:
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
attr_name=SettingsConstants.SETTING__MICROSD_TOAST_TIMER,
display_name="MicroSD toast timer",
display_name=_mft("MicroSD notification duration"),
type=SettingsConstants.TYPE__SELECT_1,
visibility=SettingsConstants.VISIBILITY__ADVANCED,
selection_options=SettingsConstants.ALL_MICROSD_TOAST_TIMERS,
@@ -782,15 +781,25 @@ class SettingsDefinition:
@classmethod
def get_defaults(cls) -> dict:
def get_defaults(cls, use_abbreviated_name: bool = False, skip_hidden: bool = False) -> dict:
"""
* use_abbreviated_name: Only used by the test suite.
* skip_hidden: Only used by the test suite.
"""
as_dict = {}
for entry in SettingsDefinition.settings_entries:
if skip_hidden and entry.visibility == SettingsConstants.VISIBILITY__HIDDEN:
continue
if not use_abbreviated_name:
attr_name = entry.attr_name
else:
attr_name = entry.abbreviated_name
if type(entry.default_value) == list:
# Must copy the default_value list, otherwise we'll inadvertently change
# defaults when updating these attrs
as_dict[entry.attr_name] = list(entry.default_value)
as_dict[attr_name] = list(entry.default_value)
else:
as_dict[entry.attr_name] = entry.default_value
as_dict[attr_name] = entry.default_value
return as_dict
@@ -803,19 +812,3 @@ class SettingsDefinition:
output["settings_entries"].append(settings_entry.to_dict())
return output
if __name__ == "__main__":
import json
import os
hostname = os.uname()[1]
if hostname == "seedsigner-os":
output_file = "/mnt/microsd/settings_definition.json"
else:
output_file = "settings_definition.json"
with open(output_file, 'w') as json_file:
json.dump(SettingsDefinition.to_dict(), json_file, indent=4)
+1 -1
View File
@@ -26,7 +26,7 @@ class PSBTSelectSeedView(View):
if self.controller.psbt_seed:
if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)):
# skip the seed prompt if a seed was previous selected and has matching input fingerprint
# skip the seed prompt if a seed was previously selected and has matching input fingerprint
return Destination(PSBTOverviewView)
seeds = self.controller.storage.seeds
+39 -39
View File
@@ -11,7 +11,7 @@ from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconCo
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen,
WarningScreen, DireWarningScreen, seed_screens)
from seedsigner.gui.screens.screen import ButtonOption, ButtonOptionWithoutTranslation
from seedsigner.models.encode_qr import CompactSeedQrEncoder, GenericStaticQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrXpubQrEncoder
from seedsigner.models.encode_qr import CompactSeedQrEncoder, GenericStaticQrEncoder, SeedQrEncoder, SpecterLegacyXPubQrEncoder, StaticXpubQrEncoder, UrXpubQrEncoder
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings import Settings, SettingsConstants
@@ -226,11 +226,13 @@ class SeedMnemonicEntryView(View):
)
if ret == RET_CODE__BACK_BUTTON:
if self.cur_word_index > 0:
return Destination(BackStackView)
else:
# This handles two possible scenarios:
# 1. Backing out of the first word cancels the mnemonic entry process;
# return to whichever `View` routed us here initially.
# 2. Backing out of the current word returns to the previous word.
if self.cur_word_index == 0:
self.controller.storage.discard_pending_mnemonic()
return Destination(MainMenuView)
return Destination(BackStackView)
# ret will be our new mnemonic word
self.controller.storage.update_pending_mnemonic(ret, self.cur_word_index)
@@ -305,8 +307,9 @@ class SeedFinalizeView(View):
super().__init__()
self.seed = self.controller.storage.get_pending_seed()
if self.seed.get_fingerprint == "":
# Expected normal user flow
if not self.seed.has_passphrase:
# Expected normal user flow. A freshly-loaded seed has no passphrase yet, so
# we can just get the fingerprint directly.
self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK))
else:
@@ -339,9 +342,6 @@ class SeedFinalizeView(View):
elif button_data[selected_menu_num] == self.PASSPHRASE:
return Destination(SeedAddPassphraseView)
elif selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
class SeedAddPassphraseView(View):
@@ -572,8 +572,7 @@ class SeedOptionsView(View):
button_data.append(self.SCAN_PSBT)
if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED:
button_data.append(self.EXPORT_XPUB)
button_data.append(self.EXPORT_XPUB)
button_data.append(self.EXPLORER)
button_data.append(self.BACKUP)
@@ -720,7 +719,7 @@ class SeedExportXpubScriptTypeView(View):
del args["sig_type"]
return Destination(ToolsAddressExplorerAddressTypeView, view_args=args, skip_current_view=True)
else:
return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True)
return Destination(SeedExportXpubQRFormatView, view_args=args, skip_current_view=True)
title = _("Export Xpub")
if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER:
@@ -755,7 +754,7 @@ class SeedExportXpubScriptTypeView(View):
del args["sig_type"]
return Destination(ToolsAddressExplorerAddressTypeView, view_args=args)
else:
return Destination(SeedExportXpubCoordinatorView, view_args=args)
return Destination(SeedExportXpubQRFormatView, view_args=args)
@@ -786,7 +785,7 @@ class SeedExportXpubCustomDerivationView(View):
return Destination(ToolsAddressExplorerAddressTypeView, view_args=dict(seed_num=self.seed_num, script_type=self.script_type, custom_derivation=custom_derivation))
return Destination(
SeedExportXpubCoordinatorView,
SeedExportXpubQRFormatView,
view_args={
"seed_num": self.seed_num,
"sig_type": self.sig_type,
@@ -797,7 +796,7 @@ class SeedExportXpubCustomDerivationView(View):
class SeedExportXpubCoordinatorView(View):
class SeedExportXpubQRFormatView(View):
def __init__(self, seed_num: int, sig_type: str, script_type: str, custom_derivation: str = None):
super().__init__()
self.seed_num = seed_num
@@ -813,41 +812,39 @@ class SeedExportXpubCoordinatorView(View):
"script_type": self.script_type,
"custom_derivation": self.custom_derivation,
}
if len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)) == 1:
if len(self.settings.get_value(SettingsConstants.SETTING__XPUB_QR_FORMAT)) == 1:
# Nothing to select; skip this screen
args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0]
args["xpub_qr_format"] = self.settings.get_value(SettingsConstants.SETTING__XPUB_QR_FORMAT)[0]
return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True)
button_data = []
for display_name, setting_option in zip(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS), self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)):
for display_name, setting_option in zip(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__XPUB_QR_FORMAT), self.settings.get_value(SettingsConstants.SETTING__XPUB_QR_FORMAT)):
button_data.append(ButtonOption(display_name, return_data=setting_option))
selected_menu_num = self.run_screen(
ButtonListScreen,
title=_("Export Xpub"),
title=_("Xpub QR Format"),
is_button_text_centered=False,
button_data=button_data,
is_bottom_list=True,
)
if selected_menu_num == RET_CODE__BACK_BUTTON:
return Destination(BackStackView)
# coordinators_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS)
# selected_display_name = button_data[selected_menu_num]
# args["coordinator"] = coordinators_settings_entry.get_selection_option_value_by_display_name(selected_display_name)
args["coordinator"] = button_data[selected_menu_num].return_data
args["xpub_qr_format"] = button_data[selected_menu_num].return_data
return Destination(SeedExportXpubWarningView, view_args=args)
class SeedExportXpubWarningView(View):
def __init__(self, seed_num: int, sig_type: str, script_type: str, coordinator: str, custom_derivation: str):
def __init__(self, seed_num: int, sig_type: str, script_type: str, xpub_qr_format: str, custom_derivation: str):
super().__init__()
self.seed_num = seed_num
self.sig_type = sig_type
self.script_type = script_type
self.coordinator = coordinator
self.xpub_qr_format = xpub_qr_format
self.custom_derivation = custom_derivation
@@ -858,7 +855,7 @@ class SeedExportXpubWarningView(View):
"seed_num": self.seed_num,
"sig_type": self.sig_type,
"script_type": self.script_type,
"coordinator": self.coordinator,
"xpub_qr_format": self.xpub_qr_format,
"custom_derivation": self.custom_derivation,
},
skip_current_view=True, # Prevent going BACK to WarningViews
@@ -888,11 +885,11 @@ class SeedExportXpubDetailsView(View):
Collects the user input from all the previous screens leading up to this and
finally calculates the xpub and displays the summary view to the user.
"""
def __init__(self, seed_num: int, sig_type: str, script_type: str, coordinator: str, custom_derivation: str):
def __init__(self, seed_num: int, sig_type: str, script_type: str, xpub_qr_format: str, custom_derivation: str):
super().__init__()
self.sig_type = sig_type
self.script_type = script_type
self.coordinator = coordinator
self.xpub_qr_format = xpub_qr_format
self.custom_derivation = custom_derivation
self.seed_num = seed_num
@@ -955,7 +952,7 @@ class SeedExportXpubDetailsView(View):
return Destination(
SeedExportXpubQRDisplayView,
dict(seed_num=self.seed_num,
coordinator=self.coordinator,
xpub_qr_format=self.xpub_qr_format,
derivation_path=derivation_path,
sig_type=self.sig_type
)
@@ -967,7 +964,7 @@ class SeedExportXpubDetailsView(View):
class SeedExportXpubQRDisplayView(View):
def __init__(self, seed_num: int, coordinator: str, derivation_path: str, sig_type: str = SettingsConstants.SINGLE_SIG):
def __init__(self, seed_num: int, xpub_qr_format: str, derivation_path: str, sig_type: str = SettingsConstants.SINGLE_SIG):
super().__init__()
self.seed = self.controller.get_seed(seed_num)
@@ -979,14 +976,14 @@ class SeedExportXpubQRDisplayView(View):
sig_type=sig_type
)
if coordinator == SettingsConstants.COORDINATOR__SPECTER_DESKTOP:
self.qr_encoder = SpecterXPubQrEncoder(**encoder_args)
elif coordinator in [SettingsConstants.COORDINATOR__BLUE_WALLET,
SettingsConstants.COORDINATOR__KEEPER]:
if xpub_qr_format == SettingsConstants.XPUB_QR_FORMAT__STATIC:
self.qr_encoder = StaticXpubQrEncoder(**encoder_args)
elif xpub_qr_format == SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY:
self.qr_encoder = SpecterLegacyXPubQrEncoder(**encoder_args)
else:
# Default: UR crypto-address
self.qr_encoder = UrXpubQrEncoder(**encoder_args)
@@ -1555,7 +1552,8 @@ class SeedTranscribeSeedQRZoomedInView(View):
self.seedqr_format = seedqr_format
self.seed = self.controller.get_seed(seed_num)
self.initial_zone_x = initial_zone_x
self.initial_zone_y = initial_zone_y
self.initial_zone_y = initial_zone_y
self.is_screensaver_allowed = False
def run(self):
@@ -2070,8 +2068,10 @@ class MultisigWalletDescriptorView(View):
fingerprint = hexlify(key.fingerprint).decode()
fingerprints.append(fingerprint)
policy = descriptor.brief_policy.split("multisig")[0].strip()
# policy = " / ".join(policy.split(" of ")) # i18n w/o l10n since coming from non-l10n embit
from seedsigner.helpers.embit_utils import get_multisig_policy
threshold, n = get_multisig_policy(descriptor)
# TRANSLATOR_NOTE: Multisig policy. For a "2 of 3" policy, "threshold" = 2; "n" = 3
policy = _("{threshold} of {n}").format(threshold=threshold, n=n)
button_data = [self.OK]
if self.controller.resume_main_flow:
+38
View File
@@ -212,6 +212,13 @@ class SettingsEntryUpdateSelectionView(View):
)
if ret_value == RET_CODE__BACK_BUTTON:
if self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT:
# After the user finishes toggling multiselect options, initial_value will
# have their final selections when they hit BACK to exit. All current
# multiselect settings require at least one option to be selected.
if not initial_value:
return Destination(SettingsSelectionRequiredWarningView, view_args={"attr_name": self.settings_entry.attr_name})
if self.blocking_view:
return Destination(self.blocking_view, clear_history=True)
return settings_menu_view_destination
@@ -269,6 +276,37 @@ class SettingsEntryUpdateSelectionView(View):
class SettingsSelectionRequiredWarningView(View):
def __init__(self, attr_name: str):
super().__init__()
self.settings_entry = SettingsDefinition.get_settings_entry(attr_name)
def run(self):
from seedsigner.gui.screens.screen import WarningScreen
# TRANSLATOR_NOTE: Title of a warning dialog when configuring a setting that requires at least one option to be selected.
title = _("Selection Required")
# TRANSLATOR_NOTE: The name of the setting being configured (e.g. "Script types") will be inserted.
text = _("At least one option must be selected for \"{}\".").format(self.settings_entry.display_name)
# TRANSLATOR_NOTE: Text for the button that returns the user to the setting configuration screen.
button_text = _("Return to setting")
self.run_screen(
WarningScreen,
title=title,
status_headline=None,
text=text,
button_data=[ButtonOption(button_text)],
show_back_button=False,
)
return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name))
class SettingsIngestSettingsQRView(View):
def __init__(self, data: str):
from seedsigner.hardware.microsd import MicroSD
+12 -6
View File
@@ -1,6 +1,5 @@
import hashlib
import logging
import os
import time
from gettext import gettext as _
@@ -148,9 +147,12 @@ class ToolsImageEntropyMnemonicLengthView(View):
# Build in some hardware-level uniqueness via CPU unique Serial num
try:
stream = os.popen("cat /proc/cpuinfo | grep Serial")
output = stream.read()
serial_num = output.split(":")[-1].strip().encode('utf-8')
serial_num = b''
with open("/proc/cpuinfo", "r") as f:
for line in f:
if "Serial" in line:
serial_num = line.split(":")[-1].strip().encode('utf-8')
break
serial_hash = hashlib.sha256(serial_num)
hash_bytes = serial_hash.digest()
except Exception as e:
@@ -599,8 +601,12 @@ class ToolsAddressExplorerAddressTypeView(View):
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
from seedsigner.helpers.embit_utils import get_multisig_policy
threshold, n = get_multisig_policy(data["wallet_descriptor"])
# TRANSLATOR_NOTE: Multisig policy. For a "2 / 3 multisig" policy, "threshold" = 2; "n" = 3
wallet_descriptor_display_name = _("{threshold} / {n} multisig").format(
threshold=threshold, n=n
)
script_type = data["script_type"] if "script_type" in data else None
+7 -4
View File
@@ -71,6 +71,7 @@ class View:
self.screen = None
self._redirect: 'Destination' = None
self.is_screensaver_allowed = True
def __init__(self):
@@ -349,10 +350,12 @@ class NetworkMismatchErrorView(ErrorView):
self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True)
super().__post_init__()
# 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,
network = _(self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK))
# TRANSLATOR_NOTE: "network" will be mainnet/testnet/regtest.
self.text = _("Current network setting ({network}) doesn't match {derivation_path}.").format(
network=network,
derivation_path=self.derivation_path,
)
+9 -8
View File
@@ -247,8 +247,8 @@ def generate_screenshots(locale):
)
add_settings_entries(SettingsConstants.VISIBILITY__HARDWARE)
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}"
settingsqr_data_persistent = f"settings::v1 name=English_noob_mode persistent=E xpub_qr=urca,sta denom=thr network=M qr_density=M 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 xpub_qr=urca,sta denom=thr network=M qr_density=M 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}"
# Set up mocks to provide whatever temporary data/state a particular screenshot
# might need.
@@ -376,13 +376,13 @@ def generate_screenshots(locale):
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.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(SeedExportXpubQR_ScreenBrightnessView, dict(seed_num=0, coordinator="bw", derivation_path="m/84'/0'/0'")),
ScreenshotConfig(seed_views.SeedExportXpubQRFormatView, dict(seed_num=0, sig_type="ss", script_type="nat")),
ScreenshotConfig(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", xpub_qr_format="urca", custom_derivation="")),
ScreenshotConfig(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", xpub_qr_format="urca", custom_derivation="")),
ScreenshotConfig(SeedExportXpubQR_ScreenBrightnessView, dict(seed_num=0, xpub_qr_format="urca", derivation_path="m/84'/0'/0'")),
ScreenshotConfig(seed_views.SeedWordsWarningView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0)),
@@ -474,6 +474,7 @@ def generate_screenshots(locale):
ScreenshotConfig(settings_views.VersionView, screenshot_name="VersionView_current_git_state"),
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"),
ScreenshotConfig(settings_views.SettingsSelectionRequiredWarningView, dict(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES)),
],
"Misc Error Views": [
ScreenshotConfig(NotYetImplementedView),
-36
View File
@@ -4,7 +4,6 @@ import pytest
from base import BaseTest
from seedsigner.controller import Controller
from seedsigner.models.settings_definition import SettingsConstants
class TestController(BaseTest):
@@ -80,38 +79,3 @@ class TestController(BaseTest):
# ...get a new copy of the instance and confirm change
controller = Controller.get_instance()
assert controller.unverified_address == "123abc"
def test_missing_settings_get_defaults(self):
""" Should gracefully handle all missing fields from `settings.json` """
controller = Controller.get_instance()
# Settings defaults
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"]
assert controller.settings.get_value(SettingsConstants.SETTING__BTC_DENOMINATION) == SettingsConstants.BTC_DENOMINATION__THRESHOLD
# Advanced Settings defaults
assert controller.settings.get_value(SettingsConstants.SETTING__NETWORK) == SettingsConstants.MAINNET
assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM
assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES]
assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT, SettingsConstants.TAPROOT]
assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__180
assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__DISABLED
assert controller.settings.get_value(SettingsConstants.SETTING__MESSAGE_SIGNING) == SettingsConstants.OPTION__DISABLED
assert controller.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
assert controller.settings.get_value(SettingsConstants.SETTING__MICROSD_TOAST_TIMER) == SettingsConstants.MICROSD_TOAST_TIMER_FIVE_SECONDS
# Hidden Settings defaults
assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 62
+29
View File
@@ -345,6 +345,35 @@ def test_get_multisig_address():
func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3])
def test_get_multisig_policy():
"""
tests seedsigner.helpers.embit_utils.get_multisig_policy()
"""
from embit.descriptor import Descriptor
# Reuses the same 2-of-3 multisig descriptors from test_get_multisig_address
vectors_descriptor_expected = {
# native segwit 2-of-3
"wsh(sortedmulti(2,[8d55ff0d/48h/1h/0h/2h]tpubDDxNVWk924RTUhdkVB2uLHw1hGMPNMGufpZefhkkswjbZppVZcuMdjYKQN4ewUog9vbL6RBLFPRWcgTGT7kYP79N6thyJ43ELUs4N2szXMg/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*,[0be174ee/48h/1h/0h/2h]tpubDEsePyLPkbxbrDiZSTTWdsviiNtiQjrvvzZnkLtG72QYLBygEsXePRsTdXi8DeMA7taCuuvoEBjUAfFrsNZeQJqfvG9fFoujYWbFPYUn7ux/{0,1}/*))#zw6cnrlk": ("2", "3"),
# nested segwit 2-of-3
"sh(wsh(sortedmulti(2,[73c5da0a/48h/1h/1h/0h/1h]tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/{0,1}/*,[0be174ee/48h/1h/0h/1h]tpubDEsePyLPkbxbnj6XuKvWwdERHaKkikZxaGJ9sJqmM7okbZXgkNSFiGU6GX6qEes6kD8f9Z9FosYB9UEnBSgBEyEwwJhj4uUcFE1WE8VtKoh/{0,1}/*,[8d55ff0d/48h/1h/0h/1h]tpubDDxNVWk924RTT3vyGLHdSDoZ2JUVX7jUsPcwCQ9MrKHAtJrW5zECTF9rFHCvqu526E4PjHp61hBknts2c5aGexvX7hvCZ8TGPvQFdzxxy59/{0,1}/*)))#2ujlfp73": ("2", "3"),
# legacy p2sh 2-of-3
"sh(sortedmulti(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#p5t8sa8c": ("2", "3"),
}
for desc_str, (expected_threshold, expected_n) in vectors_descriptor_expected.items():
descriptor = Descriptor.from_string(desc_str)
threshold, n = embit_utils.get_multisig_policy(descriptor)
assert threshold == expected_threshold
assert n == expected_n
# Non-multisig descriptor should raise ValueError
with pytest.raises(ValueError):
embit_utils.get_multisig_policy(Descriptor.from_string(
"wpkh([73c5da0a/84h/1h/0h]tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba/{0,1}/*)#2aj6cvca"
))
def test_parse_derivation_path():
# Shouldn't care if input uses "'" or "h"
derivation_path = "m/84'/0'/0'/0/0"
+2 -2
View File
@@ -1,4 +1,4 @@
from seedsigner.models.encode_qr import CompactSeedQrEncoder, SeedQrEncoder, SpecterXPubQrEncoder, StaticXpubQrEncoder, UrPsbtQrEncoder, UrXpubQrEncoder
from seedsigner.models.encode_qr import CompactSeedQrEncoder, SeedQrEncoder, SpecterLegacyXPubQrEncoder, StaticXpubQrEncoder, UrPsbtQrEncoder, UrXpubQrEncoder
from embit import psbt
from binascii import a2b_base64
@@ -54,7 +54,7 @@ def test_xpub_qr():
def test_specter_xpub_qr():
mnemonic = "obscure bone gas open exotic abuse virus bunker shuffle nasty ship dash"
e = SpecterXPubQrEncoder(seed=Seed(mnemonic.split(" "), passphrase="pass"), network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__LOW)
e = SpecterLegacyXPubQrEncoder(seed=Seed(mnemonic.split(" "), passphrase="pass"), network=SettingsConstants.TESTNET, derivation="m/48h/1h/0h/2h", qr_density=SettingsConstants.DENSITY__LOW)
assert e.next_part() == "p1of4 [c49122a5/48h/1h/0h/2h]Vpub5mXgECaX5yYDN"
assert e.next_part() == "p2of4 c5VnUG4jVNptyEg65qUjuofWchQeuMWWiq8rcPBo"
+117 -25
View File
@@ -182,7 +182,7 @@ 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):
def flowtest_standard_xpub(sig_tuple, script_tuple, xpub_qr_tuple):
if sig_tuple[0] == SettingsConstants.SINGLE_SIG:
sig_selection = seed_views.SeedExportXpubSigTypeView.SINGLE_SIG
else:
@@ -193,7 +193,7 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
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.SeedExportXpubQRFormatView, button_data_selection=ButtonOption(xpub_qr_tuple[1], return_data=xpub_qr_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),
@@ -209,17 +209,17 @@ class TestSeedFlows(FlowTest):
# these are lists of (constant_value, display_name) tuples
sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES
script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES
coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS
xpub_qr_formats: list[tuple[str, str]] = SettingsConstants.ALL_XPUB_QR_FORMATS
# enable non-defaults so they're available in views
self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types])
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types])
self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators])
self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [x for x,y in xpub_qr_formats])
# exhaustively test flows thru standard sig_types, script_types, and coordinators
# exhaustively test flows thru standard sig_types, script_types, and xpub_qr_formats
for sig_tuple in sig_types:
for script_tuple in script_types:
for coord_tuple in coordinators:
for xpub_qr_tuple in xpub_qr_formats:
# skip custom derivation
if script_tuple[0] == SettingsConstants.CUSTOM_DERIVATION:
continue
@@ -227,13 +227,13 @@ class TestSeedFlows(FlowTest):
elif sig_tuple[0] == SettingsConstants.MULTISIG and script_tuple[0] == SettingsConstants.TAPROOT:
continue
else:
print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple))
flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple)
print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, xpub_qr_tuple))
flowtest_standard_xpub(sig_tuple, script_tuple, xpub_qr_tuple)
def test_export_xpub_disabled_not_available_flow(self):
"""
If sig_type/script_type/coordinator disabled, then these options are not available
If sig_type/script_type/xpub_qr_format disabled, then these options are not available
"""
# Load a finalized Seed into the Controller
mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split()
@@ -243,17 +243,17 @@ class TestSeedFlows(FlowTest):
# these are lists of (constant_value, display_name) tuples
sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES
script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES
coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS
xpub_qr_formats: list[tuple[str, str]] = SettingsConstants.ALL_XPUB_QR_FORMATS
# these are the disabled types that we will be testing
disabled_sig = SettingsConstants.MULTISIG
disabled_script = SettingsConstants.TAPROOT
disabled_coord = SettingsConstants.COORDINATOR__NUNCHUK
disabled_xpub_qr_format = SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY
# enable all but our target disabled type
self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types if x!=disabled_sig])
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types if x!=disabled_script])
self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators if x!=disabled_coord])
self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [x for x,y in xpub_qr_formats if x!=disabled_xpub_qr_format])
# If multisig isn't an option, then the sig type selection is skipped altogether
self.run_sequence(
@@ -284,7 +284,7 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord),
FlowStep(seed_views.SeedExportXpubQRFormatView, button_data_selection=disabled_xpub_qr_format),
]
)
@@ -305,8 +305,8 @@ class TestSeedFlows(FlowTest):
SettingsConstants.CUSTOM_DERIVATION
])
# Ensure that all coordinators are enabled
self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x, y in SettingsConstants.ALL_COORDINATORS])
# Ensure that all xpub_qr_formats are enabled
self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [x for x, y in SettingsConstants.ALL_XPUB_QR_FORMATS])
# Set up button_data selections
sig_type = seed_views.SeedExportXpubSigTypeView.SINGLE_SIG
@@ -314,9 +314,9 @@ class TestSeedFlows(FlowTest):
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)
specter_legacy = SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY
assert SettingsConstants.ALL_XPUB_QR_FORMATS[2][0] == specter_legacy
xpub_qr_format = ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__XPUB_QR_FORMAT)[2], return_data=specter_legacy)
self.run_sequence(
initial_destination_view_args=dict(seed_num=0),
@@ -325,7 +325,7 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_type),
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_type),
FlowStep(seed_views.SeedExportXpubCustomDerivationView, screen_return_value="m/0'/0'"),
FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coordinator),
FlowStep(seed_views.SeedExportXpubQRFormatView, button_data_selection=xpub_qr_format),
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
@@ -336,18 +336,18 @@ class TestSeedFlows(FlowTest):
def test_export_xpub_skip_non_option_flow(self):
"""
Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators
Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or xpub_qr_formats
"""
# Load a finalized Seed into the Controller
mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split()
self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic))
self.controller.storage.finalize_pending_seed()
# exclusively set only one choice for each of sig_types, script_types and coordinators
# exclusively set only one choice for each of sig_types, script_types and xpub_qr_formats
self.settings.update({
SettingsConstants.SETTING__SIG_TYPES: SettingsConstants.MULTISIG,
SettingsConstants.SETTING__SCRIPT_TYPES: SettingsConstants.NESTED_SEGWIT,
SettingsConstants.SETTING__COORDINATORS: SettingsConstants.COORDINATOR__SPECTER_DESKTOP,
SettingsConstants.SETTING__XPUB_QR_FORMAT: SettingsConstants.XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT,
})
self.run_sequence(
@@ -356,7 +356,7 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubCoordinatorView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubQRFormatView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
@@ -377,7 +377,7 @@ class TestSeedFlows(FlowTest):
# Make sure all options are enabled
self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in SettingsConstants.ALL_SIG_TYPES])
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in SettingsConstants.ALL_SCRIPT_TYPES])
self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in SettingsConstants.ALL_COORDINATORS])
self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [x for x,y in SettingsConstants.ALL_XPUB_QR_FORMATS])
self.run_sequence(
initial_destination_view_args=dict(seed_num=0),
@@ -387,7 +387,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=ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0], return_data=SettingsConstants.ALL_COORDINATORS[0][0])),
FlowStep(seed_views.SeedExportXpubQRFormatView, button_data_selection=ButtonOption(self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__XPUB_QR_FORMAT)[0], return_data=SettingsConstants.ALL_XPUB_QR_FORMATS[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),
@@ -474,6 +474,98 @@ class TestSeedFlows(FlowTest):
FlowStep(seed_views.SeedOptionsView),
])
def test_transcribe_seedqr_screensaver_startable_status(self):
"""
The controller should return False for screensaver startable status when SeedTranscribeSeedQRZoomedInView
is active.
"""
# Load a finalized Seed into the Controller
mnemonic = ["abandon"] * 11 + ["about"]
self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic))
self.controller.storage.finalize_pending_seed()
self.run_sequence(
initial_destination_view_args={'num_modules': 21, 'seed_num': 0, 'seedqr_format': 'seed__seedqr'},
sequence=[
FlowStep(seed_views.SeedTranscribeSeedQRWholeQRView),
FlowStep(seed_views.SeedTranscribeSeedQRZoomedInView, is_redirect=True), # Live interactive screens are a bit weird; not sure why `is_redirect` is necessary here
])
assert self.controller.is_screensaver_start_allowed == False
class TestSeedEntryBackFlows(FlowTest):
"""
Tests for every BACK exit scenario from SeedMnemonicEntryView and related views.
A naive BackStackView swap can leave resume_main_flow dangling, causing
auto-redirects on stale flow state. These tests verify that BACK navigation
returns to the correct parent view AND that no flow state leaks.
"""
def test_back_from_seed_entry_first_word(self):
"""
Pressing BACK on the first word of mnemonic entry should return to
the View that initiated the mnemonic entry process.
"""
for seed_type in [seed_views.LoadSeedView.TYPE_12WORD, seed_views.LoadSeedView.TYPE_24WORD]:
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
FlowStep(seed_views.SeedsMenuView, is_redirect=True), # No seeds loaded; auto-redirects to LoadSeedView
FlowStep(seed_views.LoadSeedView, button_data_selection=seed_type),
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=RET_CODE__BACK_BUTTON),
FlowStep(seed_views.LoadSeedView), # Should land here, NOT MainMenuView
])
BaseTest.reset_controller()
def test_back_from_seed_entry_mid_word(self):
"""
Pressing BACK from a middle word (eg. word #2) should return to the
previous SeedMnemonicEntryView (eg. word #1) via the back stack.
"""
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
FlowStep(seed_views.SeedsMenuView, is_redirect=True),
FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD),
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="abandon"), # word #1
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK from word #2
FlowStep(seed_views.SeedMnemonicEntryView), # Returns to word #1
])
# Verify we're back on word #1: word at index 0 should still be set
# from the previous entry, while word at index 1 should be unset.
assert self.controller.storage.get_pending_mnemonic_word(0) == "abandon"
assert self.controller.storage.get_pending_mnemonic_word(1) is None
def test_back_from_seed_entry_via_seed_select(self):
"""
Backing out of mnemonic entry during an active flow must preserve
`resume_main_flow` so the user remains within that flow.
"""
from seedsigner.controller import Controller
from seedsigner.models.settings import SettingsConstants
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
def load_signmessage_into_decoder(view):
view.decoder.add_data("signmessage m/84h/0h/0h/0/0 ascii:test message")
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
FlowStep(scan_views.ScanView, before_run=load_signmessage_into_decoder),
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.TYPE_12WORD),
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK on first word
FlowStep(seed_views.SeedSelectSeedView), # Should return here, in the sign message flow
])
# Verify resume_main_flow is still set — user is still in the sign message flow
assert self.controller.resume_main_flow == Controller.FLOW__SIGN_MESSAGE
class TestMessageSigningFlows(FlowTest):
+26 -7
View File
@@ -37,16 +37,35 @@ class TestSettingsFlows(FlowTest):
def test_multiselect(self):
""" Multiselect Settings options should stay in-place; requires BACK to exit. """
"""
Multiselect Settings options should stay in-place; requires BACK to exit. If no
selections are made, route to the warning screen and return the user to the
settings entry until at least one option is selected.
"""
# Which option are we testing?
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS)
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SIG_TYPES)
# Enable all options to start
self.settings.set_value(settings_entry.attr_name, [option[0] for option in settings_entry.selection_options])
# Sanity check, we only expect two options for this setting
assert len(settings_entry.selection_options) == 2
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS),
FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.ADVANCED),
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
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # deselect first option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # deselect second option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select second option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # deselect second option
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit
# Both options were deselected, should route to the warning screen
FlowStep(settings_views.SettingsSelectionRequiredWarningView),
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select first option
# Now we can exit
FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit
FlowStep(settings_views.SettingsMenuView),
])
@@ -78,11 +97,11 @@ class TestSettingsFlows(FlowTest):
MainMenuView.
"""
def load_persistent_settingsqr_into_decoder(view: scan_views.ScanView):
settingsqr_data_persistent: str = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
settingsqr_data_persistent: str = "settings::v1 name=Total_noob_mode persistent=E xpub_qr=urca,sta denom=thr network=M qr_density=M sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
view.decoder.add_data(settingsqr_data_persistent)
def load_not_persistent_settingsqr_into_decoder(view: scan_views.ScanView):
settingsqr_data_not_persistent: str = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
settingsqr_data_not_persistent: str = "settings::v1 name=Ephemeral_noob_mode persistent=D xpub_qr=urca,sta denom=thr network=M qr_density=M sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
view.decoder.add_data(settingsqr_data_not_persistent)
def _run_test(initial_setting_state: str, load_settingsqr_into_decoder: Callable, expected_setting_state: str):
+6 -7
View File
@@ -30,13 +30,12 @@ class TestViewFlows(FlowTest):
"""
Basic flow from MainMenuView to PowerOffView
"""
with patch.object(Settings, 'HOSTNAME', return_value=Settings.SEEDSIGNER_OS):
self.run_sequence([
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF),
FlowStep(PowerOffView), # returns BackStackView
FlowStep(PowerOptionsView),
])
self.run_sequence([
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF),
FlowStep(PowerOffView), # returns BackStackView
FlowStep(PowerOptionsView),
])
def test_not_yet_implemented_flow(self):
+129 -18
View File
@@ -1,7 +1,10 @@
import json
import pytest
from base import BaseTest
from seedsigner.models.settings import InvalidSettingsQRData, Settings
from seedsigner.models.settings_definition import SettingsConstants
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
@@ -14,6 +17,10 @@ class TestSettings(BaseTest):
def test_reset_settings(self):
""" BaseTest.reset_settings() should wipe out any previous Settings changes """
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS)
assert settings_entry.default_value == SettingsConstants.OPTION__DISABLED
# Change the setting from its default
settings = Settings.get_instance()
settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED
@@ -23,18 +30,110 @@ class TestSettings(BaseTest):
assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED
def test_settings_defaults(self):
""" Settings should initialize to their default values """
BaseTest.reset_settings()
settings = Settings.get_instance()
for settings_entry in SettingsDefinition.get_settings_entries():
assert settings.get_value(settings_entry.attr_name) == settings_entry.default_value
def test_load_persistent_settings(self):
""" Settings should load previously saved persistent settings from disk, if any
exist. """
# Initial Settings will start with defaults
settings = Settings.get_instance()
# Enable persistent settings and make another change
settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
assert settings.get_value(SettingsConstants.SETTING__QR_DENSITY) != SettingsConstants.DENSITY__HIGH
settings.set_value(SettingsConstants.SETTING__QR_DENSITY, SettingsConstants.DENSITY__HIGH)
# Hold on to the settings.json content
settings_json = None
with open(Settings.SETTINGS_FILENAME) as settings_file:
settings_json = json.loads(settings_file.read())
# Now wipe out the Settings singleton
BaseTest.reset_settings()
# This also deletes settings.json, so recreate it
with open(Settings.SETTINGS_FILENAME, "w") as settings_file:
settings_file.write(json.dumps(settings_json))
# Now instantiate the Settings singleton again; it should load from disk
settings = Settings.get_instance()
# Persistent setting change should have survived
assert settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__HIGH
def test_load_empty_multiselect_settings(self):
""" Empty multiselect settings should load defaults. """
# Initial Settings will start with defaults
settings = Settings.get_instance()
# Enable persistent settings to write settings.json to disk
settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED)
# Hold on to the settings.json content
settings_dict = None
with open(Settings.SETTINGS_FILENAME) as settings_file:
settings_dict = json.loads(settings_file.read())
def _verify_defaults_loaded(attr_name: str):
# Verify that the multiselect setting has loaded its defaults
settings = Settings.get_instance()
cur_setting_value = settings.get_value(attr_name)
assert cur_setting_value == SettingsDefinition.get_settings_entry(attr_name).default_value
# Alter the settings to test against various empty values
for empty_value in ["", ",", [], None]:
settings_dict[SettingsConstants.SETTING__SIG_TYPES] = empty_value
settings.update(settings_dict)
_verify_defaults_loaded(SettingsConstants.SETTING__SIG_TYPES)
# One last test: remove the multiselect setting entirely
del settings_dict[SettingsConstants.SETTING__SIG_TYPES]
settings.update(settings_dict)
_verify_defaults_loaded(SettingsConstants.SETTING__SIG_TYPES)
class SettingsQRBase(BaseTest):
"""
Reusable base test class for testing SettingsQR data.
"""
def setup_method(self):
super().setup_method()
settingsqr_dict = SettingsDefinition.get_defaults(use_abbreviated_name=True, skip_hidden=True)
# Build baseline SettingsQR config based on defaults
self.settingsqr_prefix = "settings::v1"
self.settingsqr_default_attrs_str = ""
for abbreviated_attr_name, value in settingsqr_dict.items():
if isinstance(value, list):
value_str = ",".join(value)
else:
value_str = str(value)
self.settingsqr_default_attrs_str += f" {abbreviated_attr_name}={value_str}"
class TestSettingsQRParser(SettingsQRBase):
def test_parse_settingsqr_data(self):
"""
SettingsQR parser should successfully parse a valid settingsqr input string and
return the resulting config_name and formatted settings_update_dict.
"""
settings_name = "Test SettingsQR"
settingsqr_data = f"""settings::v1 name={ settings_name.replace(" ", "_") } persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss,ms scripts=nat,nes,tr xpub_details=E passphrase=E camera=180 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"""
settingsqr_data = f"""{self.settingsqr_prefix} name={ settings_name.replace(" ", "_") } {self.settingsqr_default_attrs_str}"""
# First explicitly set settings that differ from the settingsqr_data
self.settings.set_value(SettingsConstants.SETTING__COMPACT_SEEDQR, SettingsConstants.OPTION__DISABLED)
self.settings.set_value(SettingsConstants.SETTING__DIRE_WARNINGS, SettingsConstants.OPTION__DISABLED)
self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [SettingsConstants.COORDINATOR__BLUE_WALLET, SettingsConstants.COORDINATOR__SPARROW])
self.settings.set_value(SettingsConstants.SETTING__XPUB_QR_FORMAT, [SettingsConstants.XPUB_QR_FORMAT__STATIC, SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY])
# Now parse the settingsqr_data
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
@@ -45,43 +144,45 @@ class TestSettings(BaseTest):
assert self.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED
assert self.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED
coordinators = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)
assert SettingsConstants.COORDINATOR__BLUE_WALLET not in coordinators
assert SettingsConstants.COORDINATOR__SPARROW in coordinators
assert SettingsConstants.COORDINATOR__SPECTER_DESKTOP in coordinators
xpub_qr_formats = self.settings.get_value(SettingsConstants.SETTING__XPUB_QR_FORMAT)
assert SettingsConstants.XPUB_QR_FORMAT__UR_CRYPTO_ACCOUNT in xpub_qr_formats
assert SettingsConstants.XPUB_QR_FORMAT__STATIC in xpub_qr_formats
assert SettingsConstants.XPUB_QR_FORMAT__SPECTER_LEGACY not in xpub_qr_formats
def test_settingsqr_version(self):
""" SettingsQR parser should accept SettingsQR v1 and reject any others """
settingsqr_data = "settings::v1 name=Foo"
settingsqr_data = f"{self.settingsqr_prefix} {self.settingsqr_default_attrs_str}"
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
# Accepts update with no Exceptions
self.settings.update(new_settings=settings_update_dict)
settingsqr_data = "settings::v2 name=Foo"
settingsqr_data = f"settings::v2 {self.settingsqr_default_attrs_str}"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
assert "Unsupported SettingsQR version" in str(e.value)
# Should also fail if version omitted
settingsqr_data = "settings name=Foo"
settingsqr_data = f"settings {self.settingsqr_default_attrs_str}"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
# And if "settings" is omitted entirely
settingsqr_data = "name=Foo"
settingsqr_data = self.settingsqr_default_attrs_str
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
def test_settingsqr_ignores_unrecognized_setting(self):
""" SettingsQR parser should ignore unrecognized settings """
settingsqr_data = "settings::v1 name=Foo favorite_food=bacon xpub_export=D"
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__NETWORK)
unknown_attr = "favorite_food"
settingsqr_data = f"{self.settingsqr_prefix} {unknown_attr}=bacon {settings_entry.abbreviated_name}={settings_entry.default_value}"
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
assert "favorite_food" not in settings_update_dict
assert "xpub_export" in settings_update_dict
assert unknown_attr not in settings_update_dict
assert settings_entry.attr_name in settings_update_dict
# Accepts update with no Exceptions
self.settings.update(new_settings=settings_update_dict)
@@ -89,18 +190,28 @@ class TestSettings(BaseTest):
def test_settingsqr_fails_unrecognized_option(self):
""" SettingsQR parser should fail if a settings has an unrecognized option """
settingsqr_data = "settings::v1 name=Foo xpub_export=Yep"
settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__NETWORK)
settingsqr_data = f"{self.settingsqr_prefix} {settings_entry.abbreviated_name}=fake_value"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
assert "xpub_export" in str(e.value)
assert settings_entry.attr_name in str(e.value)
def test_settingsqr_fails_empty_values(self):
""" SettingsQR parser should fail if a setting is empty """
settingsqr_data = "settings::v1 persistent=D sigs= camera=180"
with pytest.raises(InvalidSettingsQRData) as e:
Settings.parse_settingsqr(settingsqr_data)
assert "sigs" in str(e.value)
def test_settingsqr_parses_line_break_separators(self):
""" SettingsQR parser should read line breaks as acceptable separators """
settingsqr_data = "settings::v1\nname=Foo\nsigs=ss,ms\nscripts=nat,nes,tr\nxpub_export=E\n"
attrs_with_line_breaks = self.settingsqr_default_attrs_str.replace(' ', '\n')
settingsqr_data = f"{self.settingsqr_prefix}\n{attrs_with_line_breaks}"
config_name, settings_update_dict = Settings.parse_settingsqr(settingsqr_data)
assert len(settings_update_dict.keys()) == 3
assert len(settings_update_dict.keys()) == self.settingsqr_default_attrs_str.count('=')
# Accepts update with no Exceptions
self.settings.update(new_settings=settings_update_dict)
+4 -2
View File
@@ -1,15 +1,17 @@
from test_settings import SettingsQRBase
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
class TestSettingsQRDecoder:
class TestSettingsQRDecoder(SettingsQRBase):
def test_decode_settingsqr(self):
"""
Assume the QR reader decodes the SettingsQR content correctly and begin this test
with parsing the result.
"""
settings_name = "Test SettingsQR"
settings_qr_str = f"""settings::v1 name={ settings_name.replace(" ", "_") } persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss,ms scripts=nat,nes,tr xpub_details=E passphrase=E camera=180 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"""
settings_qr_str = f"""{self.settingsqr_prefix} name={ settings_name.replace(" ", "_") } {self.settingsqr_default_attrs_str}"""
# Now parse the settings_qr_str
decoder = DecodeQR()